Skip to content

Commit ff9a780

Browse files
ChrisJBurnsclaude
andcommitted
Surface StorageVersionMigrator behind chart feature flag + docs
The controller (PR-A, #5362) and the opt-in labels + CI guard (PR-B, #5391) are on main. This PR is the third and final step: expose the controller to operator users via the helm chart, and ship the reference docs and upgrade-guide walkthrough. Chart surface: - New operator.features.storageVersionMigrator value (default false — feature is opt-in via chart at install/upgrade time). - deployment.yaml wires TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR from the chart value. - helm-docs README regenerated. Reference docs (docs/operator/storage-version-migration.md): - Mechanism description matches what shipped: plain Get+Update on the main resource, per-CR conflict retry, RequeueAfter sentinel. - Admission-policy compatibility section explicitly notes that webhooks fire on every Update regardless of bytes-equality elision — relevant for clusters running Kyverno/Gatekeeper/OPA. - Skip-a-version upgrade trap called out as a ⚠ warning with the kube-storage-version-migrator escape route. - Label contract reflects the no-escape-hatch rule from PR-B. - RBAC list matches what's on main today. Upgrade-guide walkthrough (docs/operator/upgrade-guide/): - Kind-cluster reproducible end-to-end test of the v1alpha1→v1beta1 graduation, verifying storedVersions converges to [v1beta1] on all 12 graduated CRDs after enabling the migrator. - CR fixtures for v1alpha1 and v1beta1 of all 12 graduated kinds. Closes #4969. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 05ca226 commit ff9a780

7 files changed

Lines changed: 766 additions & 1 deletion

File tree

deploy/charts/operator/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ The command removes all the Kubernetes components associated with the chart and
4646
|-----|------|---------|-------------|
4747
| fullnameOverride | string | `"toolhive-operator"` | Provide a fully-qualified name override for resources |
4848
| nameOverride | string | `""` | Override the name of the chart |
49-
| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"defaultImagePullSecrets":[],"env":[],"features":{"experimental":false},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.28.3","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.28.3","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.28.3","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources |
49+
| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"defaultImagePullSecrets":[],"env":[],"features":{"experimental":false,"storageVersionMigrator":false},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.28.3","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.28.3","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.28.3","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources |
5050
| operator.affinity | object | `{}` | Affinity settings for the operator pod |
5151
| operator.autoscaling | object | `{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80}` | Configuration for horizontal pod autoscaling |
5252
| operator.autoscaling.enabled | bool | `false` | Enable autoscaling for the operator |
@@ -57,6 +57,7 @@ The command removes all the Kubernetes components associated with the chart and
5757
| operator.defaultImagePullSecrets | list | `[]` | List of image pull secrets that the operator applies as defaults to every workload it spawns (proxy runners, vMCP servers, registry API, etc.). Per-CR `imagePullSecrets` take precedence on name collisions; chart-level entries are appended additively. The operator parses these once at startup from the TOOLHIVE_DEFAULT_IMAGE_PULL_SECRETS environment variable. The Secrets must exist in the namespace where each workload is created. Each entry may be either a plain string (the Secret name) or an object with a `name` field, e.g.: defaultImagePullSecrets: - regcred - name: otherscred The two shapes are equivalent; the object form matches `operator.imagePullSecrets` above for convenience. |
5858
| operator.env | list | `[]` | Environment variables to set in the operator container. Supported toolhive-specific variables include: - TOOLHIVE_SKIP_UPDATE_CHECK: set to "true" to disable the operator's periodic update check against the ToolHive update API. Also disables the usage-metrics collection that is gated on the same check. |
5959
| operator.features.experimental | bool | `false` | Enable experimental features |
60+
| operator.features.storageVersionMigrator | bool | `false` | Enable the StorageVersionMigrator controller, which auto-cleans status.storedVersions on opted-in toolhive.stacklok.dev CRDs so a future release can drop deprecated versions (e.g. v1alpha1) without orphaning etcd objects in the cluster. Sets TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR in the operator deployment. |
6061
| operator.gc | object | `{"gogc":75,"gomemlimit":"110MiB"}` | Go memory limits and garbage collection percentage for the operator container |
6162
| operator.gc.gogc | int | `75` | Go garbage collection percentage for the operator container |
6263
| operator.gc.gomemlimit | string | `"110MiB"` | Go memory limits for the operator container |

deploy/charts/operator/templates/deployment.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ spec:
6868
value: "true"
6969
- name: ENABLE_EXPERIMENTAL_FEATURES
7070
value: {{ .Values.operator.features.experimental | quote }}
71+
- name: TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR
72+
value: {{ .Values.operator.features.storageVersionMigrator | quote }}
7173
{{- if eq .Values.operator.rbac.scope "namespace" }}
7274
- name: WATCH_NAMESPACE
7375
value: "{{ .Values.operator.rbac.allowedNamespaces | join "," }}"

deploy/charts/operator/values.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ operator:
88
features:
99
# -- Enable experimental features
1010
experimental: false
11+
# -- Enable the StorageVersionMigrator controller, which auto-cleans
12+
# status.storedVersions on opted-in toolhive.stacklok.dev CRDs so a
13+
# future release can drop deprecated versions (e.g. v1alpha1) without
14+
# orphaning etcd objects in the cluster. Sets
15+
# TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR in the operator deployment.
16+
storageVersionMigrator: false
1117
# -- Number of replicas for the operator deployment
1218
replicaCount: 1
1319

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Storage Version Migration
2+
3+
The ToolHive operator ships a `StorageVersionMigrator` controller that keeps every ToolHive CRD's `status.storedVersions` list clean, so a future operator release can drop deprecated API versions (e.g. `v1alpha1`) without orphaning objects in etcd.
4+
5+
The controller is **opt-in** via the `operator.features.storageVersionMigrator` chart value (or the `TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR` environment variable if you inject it directly via `operator.env`).
6+
7+
## Why this exists
8+
9+
When a CRD graduates from, say, `v1alpha1` to `v1beta1` with both versions served and `v1beta1` as the storage version, existing objects continue to work — they are transparently converted on read/write. But the API server records every version that has ever been used for storage in `CustomResourceDefinition.status.storedVersions`. Until that list is trimmed, the API server refuses to let you remove a version from `spec.versions`, because doing so would orphan any etcd-stored objects encoded at that version.
10+
11+
The cleanup is not automatic. Someone has to re-store every existing object at the current storage version, then explicitly patch `status.storedVersions` to drop the old entry. The `StorageVersionMigrator` controller does this for you, on every opted-in ToolHive CRD, continuously. See [upstream Kubernetes documentation](https://kubernetes.io/docs/tasks/manage-kubernetes-objects/storage-version-migration/) for the underlying mechanism.
12+
13+
## How it works
14+
15+
For each opted-in CRD the controller does:
16+
17+
1. Live-reads the CRD via `APIReader` (bypassing the informer cache, so it sees the current `storedVersions`).
18+
2. Reads `spec.versions` to find the entry with `storage: true`.
19+
3. If `status.storedVersions` already equals `[<currentStorageVersion>]`, exits — nothing to do.
20+
4. Otherwise, paginates through every Custom Resource of the kind and issues a plain `Get` + `Update` against the main resource. The API server re-encodes the request body at the current storage version and compares the resulting bytes to what's in etcd. If they differ, etcd is rewritten at the new storage version. If they match (already migrated), the API server elides the write.
21+
5. Once every CR has been processed without errors, patches `CRD.status.storedVersions` to `[<currentStorageVersion>]` using an optimistic-lock merge — so concurrent API-server writes cause a clean retry rather than a silent overwrite.
22+
23+
### Concurrent-write safety
24+
25+
The migrator handles conflicts conservatively. If a per-CR `Update` returns `IsConflict` (another writer raced), the controller retries the per-CR Get + Update up to three attempts. If every retry conflicts, the migration pass returns a sentinel error and the controller requeues itself after 30 seconds without bumping the exponential backoff. `status.storedVersions` is only trimmed when every CR in a pass was successfully re-stored.
26+
27+
This is the upstream `kube-storage-version-migrator` semantics — a Conflict means another writer succeeded, which itself re-encoded the CR at the storage version, so the migration is effectively done for that CR even if our own Update didn't land.
28+
29+
### Admission webhook interaction
30+
31+
Each per-CR `Update` goes through the API server's admission chain (mutating then validating webhooks) before reaching the storage-encoder elision check. **Only the etcd write and watch fanout are elided** when the encoded bytes match what's already stored — admission webhooks fire on every Update regardless.
32+
33+
For ToolHive's own webhooks this is fine; they only reject changes that break spec invariants, and a same-spec round-trip Update cannot trigger those rejections. **If you run a cluster-wide admission policy engine** like Kyverno, Gatekeeper, or OPA, check that your policies don't reject same-spec round-trip Updates of ToolHive CRs before enabling the migrator. A policy that requires a `lastUpdatedBy` annotation, for example, would reject every migrator Update and the controller would never converge.
34+
35+
## The opt-in label
36+
37+
A CRD participates in migration only if it carries:
38+
39+
```yaml
40+
metadata:
41+
labels:
42+
toolhive.stacklok.dev/auto-migrate-storage-version: "true"
43+
```
44+
45+
The label is set at CRD-generation time via a kubebuilder marker on each Go root type:
46+
47+
```go
48+
//+kubebuilder:object:root=true
49+
//+kubebuilder:storageversion
50+
//+kubebuilder:metadata:labels=toolhive.stacklok.dev/auto-migrate-storage-version=true
51+
type MCPServer struct { ... }
52+
```
53+
54+
`task operator-manifests` bakes the label into the generated CRD YAML. Every ToolHive CRD that carries `+kubebuilder:storageversion` ships with the marker today.
55+
56+
A CI test (`TestStorageVersionRootMarkerCoverage` in `cmd/thv-operator/controllers/marker_coverage_test.go`) fails the build if a root type is added without the migrate marker. There is no self-serve "exclude" marker — every storage-version root type must opt in. If a future CRD legitimately should not be auto-migrated, the path is to add an entry to the `excludedRootTypes` allowlist in the test file via PR review, not via a self-serve marker.
57+
58+
### Adding a new CRD
59+
60+
Add the marker to the type alongside the existing kubebuilder markers:
61+
62+
```go
63+
//+kubebuilder:object:root=true
64+
//+kubebuilder:storageversion
65+
//+kubebuilder:subresource:status
66+
//+kubebuilder:metadata:labels=toolhive.stacklok.dev/auto-migrate-storage-version=true
67+
type NewShinyThing struct { ... }
68+
```
69+
70+
Then `task operator-manifests` to regenerate the CRD YAML. CI verifies the marker is present.
71+
72+
## Enabling the controller
73+
74+
Set the Helm feature flag at install or upgrade time:
75+
76+
```yaml
77+
operator:
78+
features:
79+
storageVersionMigrator: true
80+
```
81+
82+
This sets `TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR=true` on the operator Deployment and registers the reconciler with the manager.
83+
84+
Once enabled, the controller is dormant on CRDs whose `storedVersions` already equals `[<currentStorageVersion>]` — most of the time, most CRDs. It only does meaningful work when a CRD's stored-versions list is dirty (typically right after a graduation release).
85+
86+
## Per-CRD emergency escape hatch
87+
88+
Removing the label on a live cluster excludes that single CRD from migration immediately:
89+
90+
```bash
91+
kubectl label crd/mcpservers.toolhive.stacklok.dev \
92+
toolhive.stacklok.dev/auto-migrate-storage-version-
93+
```
94+
95+
Intended for incident response only. If you deploy the operator via GitOps (Argo CD, Flux) or `helm upgrade`, the chart will re-apply the chart-set label within seconds. For a long-term per-cluster opt-out, leave `storageVersionMigrator: false` and accept that you will need to handle storage-version cleanup yourself before any version-removal release.
96+
97+
## Interaction with version-removal releases
98+
99+
The `StorageVersionMigrator` must have run against your cluster *before* an operator release that drops a deprecated CRD version ships. The typical sequence is:
100+
101+
1. **Release N**: both versions served, newer version is storage, `StorageVersionMigrator` available (opt-in via the chart flag). Operators that enable the migrator have their `storedVersions` trimmed during this deprecation window.
102+
2. **Release N+1+**: the deprecated version is removed from `spec.versions`. Because every cluster that enabled the migrator already has clean `storedVersions`, the CRD update applies.
103+
104+
> **⚠ Skip-a-version upgrade trap.** If your cluster upgrades directly from a pre-migrator release to the version-removal release without ever running an intermediate release that runs the migrator, the helm upgrade will **fail** when the API server refuses to remove the deprecated version from `spec.versions`. To recover: deploy [kube-storage-version-migrator](https://github.com/kubernetes-sigs/kube-storage-version-migrator) once to clean `storedVersions`, then retry the upgrade. To avoid the trap entirely, install each release in sequence, and enable `storageVersionMigrator: true` at least one release before any version-removal release.
105+
106+
## Verification
107+
108+
For any ToolHive CRD in a cluster where the controller has run successfully:
109+
110+
```bash
111+
kubectl get crd mcpservers.toolhive.stacklok.dev \
112+
-o jsonpath='{.status.storedVersions}'
113+
# ["v1beta1"]
114+
```
115+
116+
If the list contains more than one entry, the controller has not yet finished migrating — check operator logs for reconcile errors and the `StorageVersionMigrationFailed` event on the CRD. The controller will also INFO-log `storage version migration not converging — sustained concurrent writes` after five consecutive conflict-only passes against the same CRD, which is the signal to investigate sibling reconcilers or admission policies that may be racing with the migrator.
117+
118+
## RBAC
119+
120+
The operator ServiceAccount carries (generated from kubebuilder markers, applied by the operator Helm chart):
121+
122+
- `customresourcedefinitions.apiextensions.k8s.io`: `get`, `list`, `watch`
123+
- `customresourcedefinitions/status.apiextensions.k8s.io`: `update`, `patch`
124+
- `*.toolhive.stacklok.dev`: `get`, `list`, `update`
125+
126+
The wildcard on `toolhive.stacklok.dev` resources is intentional: the set of opted-in CRDs is a runtime label decision, not a codegen-time enumeration. The runtime gate (`isManagedCRD` requiring the opt-in label) ensures the controller only writes to CRDs that explicitly opted in, even though the RBAC bound is wider.
127+
128+
## Related
129+
130+
- Issue: [stacklok/toolhive#4969](https://github.com/stacklok/toolhive/issues/4969)
131+
- PR-A — controller: [stacklok/toolhive#5362](https://github.com/stacklok/toolhive/pull/5362)
132+
- PR-B — opt-in labels + marker-coverage CI: [stacklok/toolhive#5391](https://github.com/stacklok/toolhive/pull/5391)
133+
- Kubernetes CRD versioning: [official docs](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definition-versioning/)
134+
- Upstream reference: [`kube-storage-version-migrator`](https://github.com/kubernetes-sigs/kube-storage-version-migrator)

0 commit comments

Comments
 (0)