|
| 1 | +# CRD Version Graduation Strategies |
| 2 | + |
| 3 | +This is a supporting document for the [Instrumentation v1beta1 RFC](instrumentation-v1beta1.md). It outlines how Kubernetes handles multiple CRD versions, strategies for operator maintainers, and lessons learned from other projects. |
| 4 | + |
| 5 | +## Background |
| 6 | + |
| 7 | +When graduating a CRD from `v1alpha1` to `v1beta1` (or `v1beta1` to `v1`), operators face a choice: how to handle the transition for existing users? Kubernetes supports serving multiple versions of the same CRD simultaneously, but this comes with complexity. |
| 8 | + |
| 9 | +## Kubernetes CRD Versioning Basics |
| 10 | + |
| 11 | +### Storage Version |
| 12 | + |
| 13 | +Only one version can be the **storage version** - the version persisted in etcd. All other versions are converted to/from this version. |
| 14 | + |
| 15 | +```yaml |
| 16 | +apiVersion: apiextensions.k8s.io/v1 |
| 17 | +kind: CustomResourceDefinition |
| 18 | +spec: |
| 19 | + versions: |
| 20 | + - name: v1alpha1 |
| 21 | + served: true |
| 22 | + storage: false # Not stored, converted from v1beta1 |
| 23 | + - name: v1beta1 |
| 24 | + served: true |
| 25 | + storage: true # Stored in etcd |
| 26 | +``` |
| 27 | +
|
| 28 | +### Served Versions |
| 29 | +
|
| 30 | +The `served` field controls whether the API server accepts requests for that version. |
| 31 | + |
| 32 | +**When `served: true`:** |
| 33 | +- Clients can create, read, update, and delete resources using that version (e.g. `instrumentations.v1alpha1.opentelemetry.io`) |
| 34 | +- Resources are auto-converted to/from the storage version |
| 35 | + |
| 36 | +**When `served: false`:** |
| 37 | +- API server returns 404 for that version's endpoint |
| 38 | +- `kubectl get instrumentations.v1alpha1.opentelemetry.io` fails |
| 39 | +- Existing resources in etcd are still accessible via served versions - with `strategy: None`, the API server just swaps the `apiVersion` field (requires identical schemas) |
| 40 | +- New resources cannot be created using that version |
| 41 | + |
| 42 | +### Conversion Strategies |
| 43 | + |
| 44 | +| Strategy | When to Use | |
| 45 | +|----------|-------------| |
| 46 | +| `None` | Schemas are identical (only apiVersion differs) | |
| 47 | +| `Webhook` | Schemas differ (field renames, restructuring, removals) | |
| 48 | + |
| 49 | +If schemas differ and you use `None`, data won't map correctly between versions: |
| 50 | +- **Renamed fields**: `foo` in `v1alpha1` won't appear in `bar` in `v1beta1` — appears empty |
| 51 | +- **Restructured fields**: `spec.exporter.endpoint` won't map to `spec.envConfig.exporter.endpoint` |
| 52 | +- **Removed fields**: Data preserved in etcd but invisible in new schema |
| 53 | + |
| 54 | +#### CRD conversions examples |
| 55 | + |
| 56 | +**Example: No conversion (identical schemas)** |
| 57 | + |
| 58 | +```yaml |
| 59 | +apiVersion: apiextensions.k8s.io/v1 |
| 60 | +kind: CustomResourceDefinition |
| 61 | +spec: |
| 62 | + conversion: |
| 63 | + strategy: None |
| 64 | +``` |
| 65 | + |
| 66 | +**Example: Webhook conversion** |
| 67 | + |
| 68 | +```yaml |
| 69 | +apiVersion: apiextensions.k8s.io/v1 |
| 70 | +kind: CustomResourceDefinition |
| 71 | +spec: |
| 72 | + conversion: |
| 73 | + strategy: Webhook |
| 74 | + webhook: |
| 75 | + conversionReviewVersions: ["v1"] # ConversionReview API versions the webhook accepts (not CRD versions) |
| 76 | + clientConfig: |
| 77 | + service: |
| 78 | + namespace: opentelemetry-operator-system |
| 79 | + name: opentelemetry-operator-webhook |
| 80 | + path: /convert |
| 81 | +``` |
| 82 | + |
| 83 | +## Strategy 1: Conversion Webhook |
| 84 | + |
| 85 | +Implement a webhook that converts between versions automatically. |
| 86 | + |
| 87 | +### Pros |
| 88 | + |
| 89 | +- **Seamless migration**: Users can continue using old version, resources auto-convert |
| 90 | +- **No forced migration**: Users upgrade at their own pace |
| 91 | +- **Backwards compatible**: Old tools/scripts continue working |
| 92 | + |
| 93 | +### Cons |
| 94 | + |
| 95 | +- **Deployment complexity**: Webhook requires TLS certificates, secrets, and firewall rules on GKE private clusters (default firewall only allows ports 443/10250 from control plane to nodes — webhooks on other ports require custom firewall rules) |
| 96 | +- **Maintenance burden**: Must maintain conversion logic |
| 97 | +- **Helm complexity**: Webhooks need complex orchestration in Helm charts (OpenTelemetry operator solved this with templated CRDs — see [UPGRADING.md](https://github.com/open-telemetry/opentelemetry-helm-charts/blob/main/charts/opentelemetry-operator/UPGRADING.md)) |
| 98 | + |
| 99 | +### OpenTelemetry Collector `v1alpha1` → `v1beta1` Experience |
| 100 | + |
| 101 | +The OpenTelemetry Operator implemented a conversion webhook for OpenTelemetryCollector v1alpha1 → v1beta1. Key issues encountered: |
| 102 | + |
| 103 | +**Helm chart complications:** |
| 104 | +- Webhook service name must be templated for custom Helm release names ([helm-charts#1167](https://github.com/open-telemetry/opentelemetry-helm-charts/issues/1167)) |
| 105 | +- Users get "service opentelemetry-operator-webhook not found" errors ([helm-charts#1199](https://github.com/open-telemetry/opentelemetry-helm-charts/issues/1199)) |
| 106 | + |
| 107 | +*Why this happens:* CRDs are cluster-scoped with hardcoded webhook service references, but Helm prefixes resource names with the release name (e.g., `helm install my-otel ...` creates `my-otel-opentelemetry-operator-webhook`). The CRD references `opentelemetry-operator-webhook`, but the actual service has a different name. |
| 108 | + |
| 109 | +**OLM install mode restriction:** |
| 110 | +- Only `AllNamespaces` install mode supported (operator watches all namespaces) — CRDs are cluster-scoped, so conversion webhooks must handle resources from all namespaces, incompatible with `OwnNamespace` mode. OLM v1 is moving away from install modes entirely, but the fundamental constraint remains: conversion webhooks are cluster-scoped. |
| 111 | + |
| 112 | +## Strategy 2: Identical Schemas |
| 113 | + |
| 114 | +Make all breaking changes while still in alpha, then graduate with identical schemas. |
| 115 | + |
| 116 | +Prometheus Operator chose this approach for ScrapeConfig graduation after experiencing pain with conversion webhooks for AlertmanagerConfig. See [ScrapeConfig Graduation Proposal](https://prometheus-operator.dev/docs/proposals/accepted/scrapeconfig-graduation/#path-for-graduation). |
| 117 | + |
| 118 | +When using `strategy: None`, no separate controllers are needed per version: |
| 119 | + |
| 120 | +1. User creates resource using any served version (e.g., `v1alpha1`) |
| 121 | +2. API server converts to storage version by changing `apiVersion` field |
| 122 | +3. Resource is persisted in etcd as storage version (e.g., `v1beta1`) |
| 123 | +4. Controller watches only the storage version using a single Go struct |
| 124 | +5. When user reads with old version, API server converts back on the fly |
| 125 | + |
| 126 | +The operator code remains unchanged — it reconciles only the storage version. The API server handles all version transformations transparently. |
| 127 | + |
| 128 | +### Approach |
| 129 | + |
| 130 | +1. Make all breaking changes in `v1alpha1` while it's still alpha (breaking changes are expected) |
| 131 | +2. When schema is finalized, graduate to `v1beta1` with identical schema |
| 132 | +3. Use conversion strategy `None` — only `apiVersion` changes |
| 133 | +4. No conversion webhook needed |
| 134 | + |
| 135 | +### Pros |
| 136 | + |
| 137 | +- No conversion webhook complexity |
| 138 | +- No maintenance burden for conversion logic |
| 139 | +- Clear expectations — both versions behave identically |
| 140 | +- Simple Helm/deployment — no webhook TLS/firewall concerns |
| 141 | + |
| 142 | +### Cons |
| 143 | + |
| 144 | +- **Breaking changes in alpha** — users on v1alpha1 must update their manifests |
| 145 | +- **No automatic migration** — users must manually update `apiVersion` |
| 146 | + |
| 147 | +## Cert-Manager `cmctl` Approach |
| 148 | + |
| 149 | +Cert-manager used conversion webhooks for their core CRDs (`Certificate`, `Issuer`, `ClusterIssuer`, `CertificateRequest`) during the transition period while multiple versions were served. They had breaking changes between versions: |
| 150 | + |
| 151 | +- **API group rename**: `certmanager.k8s.io` → `cert-manager.io` |
| 152 | +- **Field removals**: `certificate.spec.acme`, `issuer.spec.http01`, `issuer.spec.dns01` |
| 153 | +- **Field restructuring**: challenge solver configuration moved to new location |
| 154 | + |
| 155 | +In addition to the runtime conversion webhook, they provide `cmctl convert` — an offline CLI tool for migrating stored manifests before upgrading. |
| 156 | + |
| 157 | +**Version progression:** `v1alpha2` → `v1alpha3` → `v1beta1` → `v1` |
| 158 | + |
| 159 | +| cert-manager | Storage | Served | Notes | |
| 160 | +|--------------|---------|--------|-------| |
| 161 | +| v1.0 - v1.3 | `v1` | `v1`, `v1beta1`, `v1alpha3`, `v1alpha2` | All versions served | |
| 162 | +| v1.4 - v1.5 | `v1` | `v1`, `v1beta1`, `v1alpha3`, `v1alpha2` | Old versions deprecated | |
| 163 | +| v1.6 | `v1` | `v1` only | Old versions no longer served | |
| 164 | +| v1.7+ | `v1` | `v1` only | Old versions removed from CRD | |
| 165 | + |
| 166 | +### How It Works |
| 167 | + |
| 168 | +```bash |
| 169 | +# Convert a single file |
| 170 | +cmctl convert -f old-certificate.yaml > new-certificate.yaml |
| 171 | +
|
| 172 | +# Convert and apply directly |
| 173 | +cmctl convert -f old-certificate.yaml | kubectl apply -f - |
| 174 | +
|
| 175 | +# Convert entire directory |
| 176 | +cmctl convert -f ./manifests/ --output-dir ./converted/ |
| 177 | +``` |
| 178 | + |
| 179 | +The tool: |
| 180 | +1. Parses input YAML with old API version |
| 181 | +2. Maps old fields to new field names/locations |
| 182 | +3. Applies defaults for new required fields |
| 183 | +4. Outputs valid YAML for the new API version |
| 184 | + |
| 185 | +### References |
| 186 | + |
| 187 | +- [Migrating Deprecated API Resources](https://cert-manager.io/docs/releases/upgrading/remove-deprecated-apis/) — official migration guide |
| 188 | +- [Upgrading from v0.16 to v1.0](https://cert-manager.io/docs/installation/upgrading/upgrading-0.16-1.0/) — major version upgrade guide |
| 189 | +- [Issue #4686: Make cmctl upgrade old API versions](https://github.com/cert-manager/cert-manager/issues/4686) — discussion on migration tooling |
| 190 | + |
| 191 | +## Storage Version Migration |
| 192 | + |
| 193 | +When the storage version changes, existing resources in etcd remain in the old format until updated. The CRD's `status.storedVersions` tracks which versions still have objects in etcd: |
| 194 | + |
| 195 | +```bash |
| 196 | +kubectl get crd instrumentations.opentelemetry.io -o jsonpath='{.status.storedVersions}' |
| 197 | +# Output: ["v1alpha1","v1beta1"] |
| 198 | +``` |
| 199 | + |
| 200 | +You cannot remove a version from the CRD while it still appears in `storedVersions`. |
| 201 | + |
| 202 | +### Migration Options |
| 203 | + |
| 204 | +**Manual migration:** |
| 205 | +```bash |
| 206 | +# Empty patch forces read→convert→write cycle |
| 207 | +kubectl get instrumentations -A -o name | xargs -I {} kubectl patch {} -p '{}' |
| 208 | +
|
| 209 | +# Or use get + apply |
| 210 | +kubectl get instrumentations -A -o yaml | kubectl apply -f - |
| 211 | +``` |
| 212 | + |
| 213 | +**Automated options:** |
| 214 | + |
| 215 | +| Approach | Description | |
| 216 | +|----------|-------------| |
| 217 | +| [cmctl upgrade migrate-api-version](https://cert-manager.io/docs/reference/cmctl/) | Cert-manager CLI command; some Helm charts run this in CRD install jobs | |
| 218 | +| [kube-storage-version-migrator](https://github.com/kubernetes-sigs/kube-storage-version-migrator) | Kubernetes SIG project; auto-detects storage version changes and migrates | |
| 219 | +| [OpenShift migrator operator](https://github.com/openshift/cluster-kube-storage-version-migrator-operator) | Built into OpenShift; requires manual migration request creation | |
| 220 | + |
| 221 | +See [Kubernetes docs: Upgrade existing objects to a new stored version](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definition-versioning/#upgrade-existing-objects-to-a-new-stored-version). |
| 222 | + |
| 223 | +## Removing an Old CRD Version |
| 224 | + |
| 225 | +Removing an old API version from a CRD is a multi-step process that requires careful coordination. Premature removal can make stored objects unreadable. |
| 226 | + |
| 227 | +### Prerequisites |
| 228 | + |
| 229 | +Before removing a version, ensure: |
| 230 | + |
| 231 | +1. **Storage migration complete** — no objects stored in the old version |
| 232 | +2. **No clients using the old version** — all tools, controllers, and users migrated |
| 233 | +3. **ManagedFields cleaned up** — old version references removed from field ownership tracking |
| 234 | + |
| 235 | +### Step-by-Step Process |
| 236 | + |
| 237 | +#### 1. Stop serving the old version |
| 238 | + |
| 239 | +Set `served: false` for the version being removed. The API server will return 404 for requests to that version: |
| 240 | + |
| 241 | +```yaml |
| 242 | +spec: |
| 243 | + versions: |
| 244 | + - name: v1alpha1 |
| 245 | + served: false # Stop accepting requests |
| 246 | + storage: false |
| 247 | + - name: v1beta1 |
| 248 | + served: true |
| 249 | + storage: true |
| 250 | +``` |
| 251 | + |
| 252 | +At this point: |
| 253 | +- New requests to `v1alpha1` fail with 404 |
| 254 | +- Existing objects are still accessible via `v1beta1` |
| 255 | +- Objects stored as `v1alpha1` in etcd are converted on read (requires conversion webhook if schemas differ) |
| 256 | + |
| 257 | +#### 2. Communicate deprecation and provide migration time |
| 258 | + |
| 259 | +For OSS operators, maintainers don't have access to users' clusters. Instead: |
| 260 | + |
| 261 | +- **Deprecation warnings** — log warnings when the operator processes resources using deprecated versions |
| 262 | +- **Documentation** — clearly document the deprecation timeline and migration steps |
| 263 | +- **Release cadence** — keep deprecated versions served for multiple releases (e.g., 2-3 minor versions) |
| 264 | +- **Migration guides** — provide kubectl commands users can run to check their own clusters: |
| 265 | + |
| 266 | +```bash |
| 267 | +# Users can check if any manifests reference the old version |
| 268 | +kubectl get instrumentations -A -o jsonpath='{range .items[*]}{.apiVersion}{"\n"}{end}' | sort | uniq -c |
| 269 | +
|
| 270 | +# Check if old version is still in storedVersions |
| 271 | +kubectl get crd instrumentations.opentelemetry.io -o jsonpath='{.status.storedVersions}' |
| 272 | +``` |
| 273 | + |
| 274 | +Users are responsible for migrating their own resources before upgrading to a release that removes the old version. |
| 275 | + |
| 276 | +#### 3. Run storage version migration |
| 277 | + |
| 278 | +Ensure all objects are re-written in the new storage version: |
| 279 | + |
| 280 | +```bash |
| 281 | +# Option A: Manual migration |
| 282 | +kubectl get instrumentations -A -o name | xargs -I {} kubectl patch {} -p '{}' |
| 283 | +
|
| 284 | +# Option B: StorageVersionMigration resource (Kubernetes 1.30+, beta in 1.35) |
| 285 | +kubectl apply -f - <<EOF |
| 286 | +apiVersion: storagemigration.k8s.io/v1beta1 |
| 287 | +kind: StorageVersionMigration |
| 288 | +metadata: |
| 289 | + name: instrumentation-migration |
| 290 | +spec: |
| 291 | + resource: |
| 292 | + group: opentelemetry.io |
| 293 | + resource: instrumentations |
| 294 | +EOF |
| 295 | +
|
| 296 | +kubectl wait --for=condition=Succeeded storageversionmigration/instrumentation-migration |
| 297 | +``` |
| 298 | + |
| 299 | +#### 4. Verify storedVersions |
| 300 | + |
| 301 | +Confirm the old version is no longer in `status.storedVersions`: |
| 302 | + |
| 303 | +```bash |
| 304 | +kubectl get crd instrumentations.opentelemetry.io -o jsonpath='{.status.storedVersions}' |
| 305 | +# Should show: ["v1beta1"] |
| 306 | +``` |
| 307 | + |
| 308 | +If the old version still appears, patch the status subresource: |
| 309 | + |
| 310 | +```bash |
| 311 | +kubectl patch crd instrumentations.opentelemetry.io \ |
| 312 | + --subresource='status' \ |
| 313 | + --type='merge' \ |
| 314 | + -p '{"status":{"storedVersions":["v1beta1"]}}' |
| 315 | +``` |
| 316 | + |
| 317 | +**Warning:** Only patch `storedVersions` after confirming all objects have been migrated. Incorrectly removing a version from `storedVersions` can make objects unreadable. |
| 318 | + |
| 319 | +#### 6. Remove the version from the CRD |
| 320 | + |
| 321 | +Once storage migration is complete: |
| 322 | + |
| 323 | +```yaml |
| 324 | +spec: |
| 325 | + versions: |
| 326 | + - name: v1beta1 # Only the new version remains |
| 327 | + served: true |
| 328 | + storage: true |
| 329 | +``` |
| 330 | + |
| 331 | +#### 7. Remove conversion webhook support |
| 332 | + |
| 333 | +If using a conversion webhook, remove the code handling the old version. This reduces maintenance burden and potential bugs. |
| 334 | + |
| 335 | +## References |
| 336 | + |
| 337 | +- [Kubernetes CRD Versioning](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definition-versioning/) |
| 338 | +- [Kubernetes Storage Version Migration](https://kubernetes.io/docs/tasks/manage-kubernetes-objects/storage-version-migration/) |
| 339 | +- [Cluster API: Removal of old apiVersions in CRDs](https://github.com/kubernetes-sigs/cluster-api/issues/11894) |
| 340 | +- [Prometheus Operator ScrapeConfig Graduation](https://prometheus-operator.dev/docs/proposals/accepted/scrapeconfig-graduation/) |
| 341 | +- [Prometheus Operator AlertmanagerConfig v1beta1 Issue](https://github.com/prometheus-operator/prometheus-operator/issues/4677) |
| 342 | +- [Helm Charts v1beta1 Missing Issue](https://github.com/prometheus-community/helm-charts/issues/5168) |
| 343 | +- [Kubernetes Bug: Conversion for Unserved Versions](https://github.com/kubernetes/kubernetes/issues/129979) |
0 commit comments