Skip to content

Commit 01bffcf

Browse files
ChrisJBurnsclaude
andcommitted
Default storage version migrator to enabled
The StorageVersionMigrator controller shipped opt-in (chart default false) so it could land without functional impact. It has soaked through a release, so make the operator helm chart enable it by default: clusters get their CRD status.storedVersions kept clean automatically, which is the precondition for a future release to drop deprecated API versions without orphaning etcd objects. - Flip operator.features.storageVersionMigrator to true in values.yaml - Update the default-install test to assert the env var renders "true" - Add explicit opt-out (false) coverage to the feature-flags suite - Refresh the chart README, reference docs, and upgrade-guide notes - Sync the now-stale opt-in comments in the operator's env-var gate (the binary still defaults off when the var is unset; the chart sets it) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9dca9dd commit 01bffcf

7 files changed

Lines changed: 41 additions & 30 deletions

File tree

cmd/thv-operator/app/app.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,11 @@ var (
4949
setupLog = log.Log.WithName("setup")
5050
)
5151

52-
// envEnableStorageVersionMigrator is the opt-in for the StorageVersionMigrator
53-
// controller. The controller defaults to OFF in this release so the change can
54-
// ship safely without functional impact. Set to "true" (or "1", "t") to enable.
55-
// A follow-up release will flip the default to true alongside the helm chart
56-
// surface and user docs.
52+
// envEnableStorageVersionMigrator gates the StorageVersionMigrator controller.
53+
// The binary itself defaults to OFF when the var is unset; the operator helm
54+
// chart sets it to "true" by default, so chart-based installs run the migrator
55+
// unless the operator explicitly opts out. Set to "true" (or "1", "t") to
56+
// enable, "false" to disable.
5757
const envEnableStorageVersionMigrator = "TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR"
5858

5959
func init() {
@@ -197,10 +197,10 @@ func setupStorageVersionMigrator(mgr ctrl.Manager) error {
197197
}
198198

199199
// isStorageVersionMigratorEnabled reports whether the StorageVersionMigrator
200-
// controller should be registered. Defaults to false in this release — admins
201-
// must explicitly opt in via TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR=true.
202-
// An unparsable value returns an error so startup fails loudly rather than
203-
// silently disabling the feature an admin asked to turn on.
200+
// controller should be registered. Defaults to false when
201+
// TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR is unset; the operator helm chart
202+
// sets it to "true" by default. An unparsable value returns an error so startup
203+
// fails loudly rather than silently disabling the feature an admin asked to turn on.
204204
func isStorageVersionMigratorEnabled() (bool, error) {
205205
value, found := os.LookupEnv(envEnableStorageVersionMigrator)
206206
if !found {

deploy/charts/operator/README.md

Lines changed: 2 additions & 2 deletions
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":[],"defaultRedis":{"addr":"","existingSecret":"","existingSecretKey":""},"env":[],"features":{"experimental":false,"storageVersionMigrator":false},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.30.0","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.30.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.30.0","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":[],"defaultRedis":{"addr":"","existingSecret":"","existingSecretKey":""},"env":[],"features":{"experimental":false,"storageVersionMigrator":true},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.30.0","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.30.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.30.0","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 |
@@ -61,7 +61,7 @@ The command removes all the Kubernetes components associated with the chart and
6161
| operator.defaultRedis.existingSecretKey | string | `""` | existingSecretKey is the key within existingSecret that holds the password. Empty means use global.redis.existingSecretKey or fall back to "redis-password". |
6262
| 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. |
6363
| operator.features.experimental | bool | `false` | Enable experimental features |
64-
| 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. |
64+
| operator.features.storageVersionMigrator | bool | `true` | 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. Enabled by default; set to false to opt out and handle storage-version cleanup yourself. Sets TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR in the operator deployment. |
6565
| operator.gc | object | `{"gogc":75,"gomemlimit":"110MiB"}` | Go memory limits and garbage collection percentage for the operator container |
6666
| operator.gc.gogc | int | `75` | Go garbage collection percentage for the operator container |
6767
| operator.gc.gomemlimit | string | `"110MiB"` | Go memory limits for the operator container |

deploy/charts/operator/tests/default_install_test.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ tests:
5252
template: deployment.yaml
5353
asserts:
5454
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: ENABLE_EXPERIMENTAL_FEATURES, value: "false" } }
55-
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR, value: "false" } }
55+
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR, value: "true" } }
5656
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: UNSTRUCTURED_LOGS, value: "false" } }
5757
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: TOOLHIVE_USE_CONFIGMAP, value: "true" } }
5858
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: GOGC, value: "75" } }
Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
11
suite: opt-in feature flags
22
# The experimental and storage-version-migrator features are quoted-boolean env
3-
# vars. A dropped quote or a renamed var silently disables the feature, so pin
4-
# both the enabled and (in the baseline suite) disabled states.
5-
set:
6-
operator.features.experimental: true
7-
operator.features.storageVersionMigrator: true
3+
# vars. A dropped quote or a renamed var silently flips the feature, so pin both
4+
# the enabled and disabled states. storageVersionMigrator defaults to true, so
5+
# the disabled state is the one the baseline suite can't cover — pin it here.
86
templates:
97
- deployment.yaml
108
tests:
119
- it: flips both feature-flag env vars to the quoted string "true"
1210
template: deployment.yaml
11+
set:
12+
operator.features.experimental: true
13+
operator.features.storageVersionMigrator: true
1314
asserts:
1415
- contains:
1516
path: 'spec.template.spec.containers[0].env'
1617
content: { name: ENABLE_EXPERIMENTAL_FEATURES, value: "true" }
1718
- contains:
1819
path: 'spec.template.spec.containers[0].env'
1920
content: { name: TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR, value: "true" }
21+
22+
- it: opts out of the storage version migrator when set to false
23+
template: deployment.yaml
24+
set:
25+
operator.features.storageVersionMigrator: false
26+
asserts:
27+
- contains:
28+
path: 'spec.template.spec.containers[0].env'
29+
content: { name: TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR, value: "false" }

deploy/charts/operator/values.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ operator:
1111
# -- Enable the StorageVersionMigrator controller, which auto-cleans
1212
# status.storedVersions on opted-in toolhive.stacklok.dev CRDs so a
1313
# future release can drop deprecated versions (e.g. v1alpha1) without
14-
# orphaning etcd objects in the cluster. Sets
14+
# orphaning etcd objects in the cluster. Enabled by default; set to
15+
# false to opt out and handle storage-version cleanup yourself. Sets
1516
# TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR in the operator deployment.
16-
storageVersionMigrator: false
17+
storageVersionMigrator: true
1718
# -- Number of replicas for the operator deployment
1819
replicaCount: 1
1920

docs/operator/storage-version-migration.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
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.
44

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`).
5+
The controller is **enabled by default** via the `operator.features.storageVersionMigrator` chart value (or the `TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR` environment variable if you inject it directly via `operator.env`). Set the value to `false` to opt out.
66

77
## Why this exists
88

@@ -71,16 +71,16 @@ Then `task operator-manifests` to regenerate the CRD YAML. CI verifies the marke
7171

7272
## Enabling the controller
7373

74-
Set the Helm feature flag at install or upgrade time:
74+
The controller is enabled by default — `operator.features.storageVersionMigrator` defaults to `true`, which sets `TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR=true` on the operator Deployment and registers the reconciler with the manager. No action is required to use it.
75+
76+
To opt out, set the Helm feature flag to `false` at install or upgrade time:
7577

7678
```yaml
7779
operator:
7880
features:
79-
storageVersionMigrator: true
81+
storageVersionMigrator: false
8082
```
8183
82-
This sets `TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR=true` on the operator Deployment and registers the reconciler with the manager.
83-
8484
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).
8585

8686
## Per-CRD emergency escape hatch
@@ -98,10 +98,10 @@ Intended for incident response only. If you deploy the operator via GitOps (Argo
9898

9999
The `StorageVersionMigrator` must have run against your cluster *before* an operator release that drops a deprecated CRD version ships. The typical sequence is:
100100

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.
101+
1. **Release N**: both versions served, newer version is storage, `StorageVersionMigrator` enabled by default. Operators running the migrator have their `storedVersions` trimmed during this deprecation window.
102102
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.
103103

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.
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 keep `storageVersionMigrator` enabled (the default) for at least one release before any version-removal release.
105105

106106
## Verification
107107

docs/operator/upgrade-guide/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,10 +211,10 @@ kubectl rollout status deployment -n toolhive-system --timeout=180s
211211

212212
# Confirm flag is now true — read off the Deployment spec directly (see step 5
213213
# for why a pod-based check races with the old pod's Terminating state).
214-
# NOTE: the flag must be set explicitly. The chart default is
215-
# storageVersionMigrator=false (the feature is opt-in), and `helm upgrade` does
216-
# not reuse the previous release's --set values, so omitting it here renders the
217-
# env var as false and the migrator never runs.
214+
# NOTE: this is set explicitly here only because step 5 explicitly disabled it
215+
# (and `helm upgrade` does not reuse the previous release's --set values). The
216+
# chart default is storageVersionMigrator=true, so a normal install/upgrade that
217+
# omits the flag entirely already runs the migrator.
218218
kubectl get deploy -n toolhive-system toolhive-operator \
219219
-o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR")].value}'
220220
# expected: true

0 commit comments

Comments
 (0)