Upgrade 0.1 to 0.2
0.2.0 moved the API to v2alpha1 and removed the fields v1alpha1 used to request a machine. It
is a breaking release, and the migration is a cut, not a rolling conversion: the pool goes to zero,
the objects are recreated, and the machines come back on demand.
| Fact | Consequence for you |
|---|---|
Node.spec.powerState is removed | every Node manifest in Git is edited |
Node.status.progress is removed | read it once, before you start; after the CRDs land it answers <none> |
v1alpha1 is out of the served set | a 0.1.x operator cannot watch the cluster once the CRDs are applied |
| There is no conversion webhook | spec.powerState: on has no honest conversion: it is a request with no attribution, no deadline and no attempt |
NodeClaim is new | the CRD set is three files, not two |
Plan a window in which the pool is empty. The downtime is one boot: from the operator step until
the first scale-up brings a machine back. Upgrade to the current chart (0.2.3) directly; the
migration has not changed shape since 0.2.0.
Prerequisites
- You can leave the pool at zero for the length of one boot. Do this on a pool that may be empty, not on the one that serves your family's Plex.
kubectlandhelmpointed at the cluster, and write access to the CRDs.- The GitOps repo that declares the pool's
Nodemanifests, editable in the same window. - The chart's CRD values understood:
crd.enable: truerenders the three CRDs as templates, sohelm upgradereplaces their schemas;crd.keep: truekeeps them throughhelm uninstall. A chart that carries CRDs in acrds/directory never replaces them on upgrade and never drops a served version — if you installed that way, the CRDs on the cluster are yours to edit by hand.
1. Quiesce, and record what is in flight
kubectl scale deployment/homelab-autoscaler-cluster-autoscaler -n homelab-autoscaler-system --replicas=0
# The 0.1.22 fields. After step 2 they are gone from the schema, and the same read answers <none>.
kubectl get nodes.infra.homecluster.dev -n homelab-autoscaler-system -o custom-columns=\
SLOT:.metadata.name,CLAIM:.spec.powerState,OBSERVED:.status.powerState,PROGRESS:.status.progress
Every slot that reads on is a machine the autoscaler asked for. Note them: after step 5 the pool is
empty by design, and the requests that refill it come from workload demand.
2. CRDs
Check what the cluster stores before you replace anything:
kubectl get crd nodes.infra.homecluster.dev \
-o jsonpath='{.spec.versions[*].name}{"\n"}{.status.storedVersions}{"\n"}'
kubectl get nodes.infra.homecluster.dev -A -o jsonpath=\
'{range .items[*]}{.apiVersion} {.metadata.namespace}/{.metadata.name}{"\n"}{end}'
The field that decides whether the upgrade is accepted is status.storedVersions, and the API
server only ever adds to it. The CRD registry appends the storage version on create and on update
(customresourcedefinition/strategy.go), and every name in the list has to still be in
spec.versions — which is the rule a v2-only CRD breaks:
CustomResourceDefinition.apiextensions.k8s.io "nodes.infra.homecluster.dev" is invalid:
status.storedVersions[0]: Invalid value: "v1alpha1": missing from spec.versions; v1alpha1 was
previously a storage version, and must remain in spec.versions until a storage migration ensures no
data remains persisted in v1alpha1 and removes v1alpha1 from status.storedVersions
Deleting the objects does not clear the field. It is a status field of the CRD, and a write to the spec cannot touch it (the registry strategy copies the old status over the new one). Two ways out, and both mean the same thing for your data, because the pool goes to zero anyway:
- Delete the objects, then patch the status. The slots are what step 4 recreates from Git.
- Step through a merged CRD. Apply a CRD that serves
v1alpha1andv2alpha1withv2alpha1as the storage version, let the existing objects be read asv2alpha1, delete them, then land the chart's CRD set. This is the path that keeps objects readable in the middle, and it is the slower one — and it ends at the same status patch, because the merged CRD keepsv1alpha1inspec.versions, so the entry stays instatus.storedVersions.
2a. Delete the objects
kubectl delete nodes.infra.homecluster.dev -n homelab-autoscaler-system --all
kubectl delete groups.infra.homecluster.dev -n homelab-autoscaler-system --all
2b. Patch the stored versions — mandatory, both CRDs
kubectl patch crd nodes.infra.homecluster.dev --subresource=status --type=merge -p '{"status":{"storedVersions":["v2alpha1"]}}'
kubectl patch crd groups.infra.homecluster.dev --subresource=status --type=merge -p '{"status":{"storedVersions":["v2alpha1"]}}'
# both lines must answer ["v2alpha1"] before the upgrade
kubectl get crd nodes.infra.homecluster.dev groups.infra.homecluster.dev \
-o jsonpath='{range .items[*]}{.metadata.name}: {.status.storedVersions}{"\n"}{end}'
Without this the helm upgrade below is refused — crd.enable: true renders the three CRDs as
templates, so the upgrade replaces them, and replacing them is the write the validation above
refuses. The check at the end of this step, status.storedVersions == ["v2alpha1"], is unreachable:
the field still says ["v1alpha1"] and the release never lands.
The patch is not a claim about data you kept: you deleted the objects in 2a, so nothing is persisted
under v1alpha1, and ["v2alpha1"] is the truth the CRD update requires. If you did not delete
the objects, do not patch. With v1alpha1 gone from spec.versions those objects are unreadable —
no schema, no decode — and the migration has to go through the merged CRD instead.
Then apply the 0.2.3 CRDs:
helm upgrade homelab-autoscaler homelab-autoscaler/homelab-autoscaler \
--namespace homelab-autoscaler-system --version 0.2.3 \
--set controllerManager.replicas=0
--set controllerManager.replicas=0 stages the release with no manager running: the same upgrade
stops the 0.1.x pods and installs the 0.2.3 ones at zero replicas, so nothing is watching a schema
that is being replaced underneath it. Step 3 turns the manager on. Verify before you continue — the
storage version is the thing that has to be clean:
kubectl get crd nodes.infra.homecluster.dev -o jsonpath='{.status.storedVersions}{"\n"}' # ["v2alpha1"]
kubectl get crd groups.infra.homecluster.dev nodes.infra.homecluster.dev nodeclaims.infra.homecluster.dev
If that line says ["v1alpha1"], 2b did not run and the helm upgrade above did not land: the CRD on
the cluster is still the 0.1.x one, and the manager you are about to start in step 3 has no
v2alpha1 to watch.
A stale metadata.managedFields. An object whose managed-fields entries name
infra.homecluster.dev/v1alpha1 can be refused by a write after that version stops being served, by
a conversion the API server cannot answer:
Error from server (InternalError): Internal error occurred: request to convert CR from an invalid
group/version: infra.homecluster.dev/v1alpha1
A write to the object converts its managed fields, and the converter only knows the group/versions the
CRD serves. The fix is to re-add v1alpha1 to the CRD as a served version and not the storage
version (served: true, storage: false) long enough to delete the stale objects, or rewrite them with
kubectl replace, then drop the version again — which needs the 2b status patch a second time, for
the same reason. The procedure above recreates the slots from Git (step 4), so it does not hit this.
If you restore from a kubectl get -o yaml backup, strip metadata.managedFields (and
metadata.creationTimestamp, metadata.uid, metadata.resourceVersion) from the file you apply.
3. The operator
helm upgrade homelab-autoscaler homelab-autoscaler/homelab-autoscaler \
--namespace homelab-autoscaler-system --version 0.2.3 \
--values values.yaml
kubectl scale deployment/homelab-autoscaler-controller-manager -n homelab-autoscaler-system --replicas=1
kubectl rollout status deployment/homelab-autoscaler-controller-manager -n homelab-autoscaler-system
Scale to the replica count your values set — one, or two if controllerManager.replicas: 2 and the
manager elects a leader, which makes the second a hot standby.
If you split the CRD apply and the operator start into two commands, do them inside one window. A
running 0.1.x operator loses its watches the instant
v1alpha1 stops being served: its informers fail with
no matches for kind Node in version infra.homecluster.dev/v1alpha1 and the manager restarts. The
gRPC server runs in the same binary, so scale-up requests fail until this step lands. Nothing is
powered off in that window — a machine that is up keeps serving pods.
Expect the operator to power machines off on its first pass, and read it as correct: it reads the
existing slots through v2alpha1, finds no claims, and applies the law — a machine observed up with
no claim is unwanted. That is why step 1 exists.
4. The Node manifests
Re-apply every slot as v2alpha1, with no spec.powerState. Because v1alpha1 is gone and there is
no conversion, the objects are recreated, not patched: a value written under a removed field
stays in etcd and reappears if the field is ever added back to a schema.
apiVersion: infra.homecluster.dev/v2alpha1
kind: Node
metadata:
name: worker-01
namespace: homelab-autoscaler-system
labels:
group: worker-nodes
spec:
kubernetesNodeName: worker-01
pricing:
hourlyRate: "0.05"
podRate: "0.001"
nodeCapacity:
cpu: 1950m
memory: 3.2Gi
startupPodSpec:
image: gemtest/ipmitool:latest
command: ["ipmitool"]
args: ["-I", "lanplus", "-H", "worker-01-ipmi.homelab.local", "-U", "admin", "-P", "password", "power", "on"]
shutdownPodSpec:
image: gemtest/ipmitool:latest
command: ["ipmitool"]
args: ["-I", "lanplus", "-H", "worker-01-ipmi.homelab.local", "-U", "admin", "-P", "password", "power", "soft"]
kubectl delete nodes.infra.homecluster.dev -l group=worker-nodes -n homelab-autoscaler-system
kubectl apply -f apps/homelab-autoscaler/worker-nodes/nodes.yaml
Two things are load-bearing in the names you re-apply:
metadata.nameandspec.kubernetesNodeName. The provider looks a slot up by name, andspec.providerIDishomelab://<slot>: a renamed slot matches nocorev1.Node, and its machine becomes invisible to the pool.- The
grouplabel. The pool's members are the Node CRs labelledgroup: <name>, and the count of them is the pool's ceiling. A partial re-apply lowers the ceiling of the pool.
With Argo CD, a sync policy that is not Replace with prune off keeps the pruned v1 objects alive:
a slot that exists in Git and not in the cluster is capacity that will never be claimed.
5. The pool goes to zero, then scales on demand
With v2alpha1 slots, no claims and no autoscaler, every slot is free and every machine is off — the
idle state of a homelab pool. Bring cluster-autoscaler back and watch the first request:
kubectl scale deployment/homelab-autoscaler-cluster-autoscaler -n homelab-autoscaler-system --replicas=1
kubectl get nodeclaim -n homelab-autoscaler-system -w
pending → booting → ready inside spec.deadlineAt, then the pending pod binds. If a claim
reaches failed, read status.reason and Node.status.lastClaim before you touch anything: that
pair is the record this release exists to produce.
kubectl get groups -n homelab-autoscaler-system
kubectl get nodes.infra.homecluster.dev -n homelab-autoscaler-system
kubectl describe nodes.infra.homecluster.dev worker-01 -n homelab-autoscaler-system
Roll forward, not back
Rolling a cluster back to 0.1.x means restoring the v1alpha1 CRD by hand (Helm does not downgrade
CRDs), re-adding spec.powerState to every manifest, and living with the claims the operator left
behind. It is not a supported path. Do the upgrade on a pool that can be empty for ten minutes.
Related Documentation
- Changelog — what each release changed
- Installation — the routine upgrade inside
0.2.x - The NodeClaim model — the model you are moving to
- NodeClaim design — the reasoning behind the cut