Skip to content

Commit ab03d5e

Browse files
ChrisJBurnsclaude
andcommitted
Drop annotation toggle; use plain Get+Update for re-encoding
The annotation toggle was a workaround for a misdiagnosed problem. Earlier in this branch we observed empty SSAs being elided and generalised it to "all unmodified writes elide" — and added a toolhive.stacklok.dev/storage-version-migrated-at annotation toggle to force a real diff per write. A second-opinion investigation pointed out the elision check happens AFTER storage encoding: the apiserver re-encodes the request body at the current storage version, then byte-compares against etcd. For the actual migration scenario (CR stamped at an older apiVersion in etcd, storage version now newer) the encoded apiVersion differs, the comparison fails, and the write proceeds. Verified empirically: a CR stored at v1alpha1 with storage flipped to v1beta1 has plain Get+Update bump RV from 202 to 204; a CR already at v1beta1 has plain Get+Update elide cleanly. Both behaviours are correct — the elision is exactly the no-migration-needed case. Changes: - restoreOne: plain Get + Update of the unmodified live object. No annotation, no timestamp, no MigrationTimestampAnnotation constant. - Controller docstring rewritten to describe the actual mechanism (encode-then-compare elision) and cite ksvm#65 for posterity. - HappyPath envtest loosened: per-CR resourceVersion bump assertions are dropped (already-clean CRs correctly elide; checking RV bumps on them was an artefact of the annotation-induced writes). The storedVersions convergence assertion remains as the contract test. - New cross-version envtest "re-encodes CRs that are stored at a prior storage version" exercises the real migration scenario: install CRD with v1alpha1 storage, create CR, flip storage to v1beta1, run reconciler, assert storedVersions trims and the CR's RV bumped (proving the apiserver actually rewrote etcd). - User docs updated to drop annotation references and describe the real mechanism. Reference implementation citation switched from cluster-api/crdmigrator to kube-storage-version-migrator (the actual upstream SIG tool we share semantics with). - FallsBackWhenNoStatusSubresource test removed: the SSA path it exercised no longer exists. Plain Update on a CRD without /status is just a normal Update; covered by the happy-path spec. 8/8 envtest cases pass. Lint clean. No RBAC drift (verbs already correct). Refs PR #5011 review by @jhrozek. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dad30c9 commit ab03d5e

3 files changed

Lines changed: 170 additions & 112 deletions

File tree

cmd/thv-operator/controllers/storageversionmigrator_controller.go

Lines changed: 24 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -79,20 +79,20 @@ var errMigrationRetriedDueToConflicts = errors.New(
7979
// StorageVersionMigratorReconciler reconciles CustomResourceDefinition objects
8080
// in the toolhive.stacklok.dev group that carry the opt-in
8181
// AutoMigrateLabel=AutoMigrateValue. For each such CRD it re-stores every CR
82-
// at the current storage version by doing a Get + Update on the live object
83-
// while toggling the MigrationTimestampAnnotation to force a real content
84-
// diff. The annotation toggle is required because the apiserver elides no-op
85-
// writes (an Update with bytes equal to what's already stored does not bump
86-
// resourceVersion and does not re-encode) — without a real diff the migration
87-
// would be a hollow operation. After all CRs have been re-stored it patches
82+
// at the current storage version by doing a Get + Update on the live object.
83+
// The Update is a full PUT of the unmodified object; the apiserver re-encodes
84+
// the request body at the current storage version, then compares the
85+
// resulting bytes to what's in etcd. When the CR was originally stored at a
86+
// different version (the actual migration scenario) the bytes carry a
87+
// different apiVersion stamp than etcd's record, the comparison fails, and
88+
// the write proceeds — re-encoding the object at the current storage
89+
// version. When the CR is already at the current storage version, the bytes
90+
// match and the apiserver harmlessly elides the write — there was nothing to
91+
// migrate. After all CRs have been processed it patches
8892
// CRD.status.storedVersions down to [<currentStorageVersion>] so a future
8993
// release can drop deprecated versions from spec.versions without orphaning
90-
// etcd objects.
91-
//
92-
// Webhook interaction: the Update goes through the main resource, so
93-
// validating/mutating admission webhooks on the kind DO see this write.
94-
// Webhooks that need to ignore migration-only writes should branch on the
95-
// presence of MigrationTimestampAnnotation in the diff.
94+
// etcd objects. See https://github.com/kubernetes-sigs/kube-storage-version-migrator/issues/65
95+
// for the upstream maintainers' explanation of this mechanism.
9696
//
9797
// Enabled by default. Opt out operator-wide via
9898
// operator.features.storageVersionMigrator (ENABLE_STORAGE_VERSION_MIGRATOR=false)
@@ -323,29 +323,18 @@ func (r *StorageVersionMigratorReconciler) restoreCRs(
323323
return kerrors.NewAggregate(errs)
324324
}
325325

326-
// MigrationTimestampAnnotation is set on each CR by the migrator to force a
327-
// real content diff on Update. The apiserver's storage layer elides no-op
328-
// writes (an Update with bytes equal to what's already in etcd does not
329-
// re-encode and does not bump resourceVersion), so a Get + Update on the live
330-
// object is not enough on its own — there must be at least one byte of
331-
// difference. Mutating this annotation guarantees the apiserver re-writes the
332-
// object, which is exactly the storage-version re-encode we need. The value
333-
// is the RFC3339Nano timestamp of the most recent successful migration.
334-
//
335-
// This matches the upstream kube-storage-version-migrator's approach (which
336-
// also uses an annotation toggle) and is part of the public contract: do not
337-
// remove or rename this constant across releases without a migration plan.
338-
const MigrationTimestampAnnotation = "toolhive.stacklok.dev/storage-version-migrated-at"
339-
340-
// restoreOne issues a Get + Update on the live CR, mutating the migration
341-
// timestamp annotation to force a real content diff so the apiserver actually
342-
// re-encodes the object at the current storage version. The Update goes
343-
// through the main resource (not /status), so any validating/mutating
344-
// admission webhooks on the kind WILL see this write. CRDs that need to avoid
345-
// webhook side effects from this controller should configure their webhooks
346-
// to ignore writes that change only this annotation. Returns the live object
347-
// after the update so the caller can record its post-update resourceVersion
348-
// in the cache.
326+
// restoreOne issues a plain Get + Update on the live CR. The apiserver
327+
// re-encodes the request body at the current storage version and compares
328+
// it to etcd's record; when the CR was originally stored at a different
329+
// apiVersion the bytes differ, the write proceeds, and etcd is re-encoded
330+
// at the current storage version. When the CR is already at the current
331+
// storage version the bytes match and the apiserver harmlessly elides the
332+
// write — there was nothing to migrate. The Update goes through the main
333+
// resource, so validating/mutating admission webhooks on the kind see this
334+
// request as part of normal admission flow; only requests that actually
335+
// persist produce downstream state changes. Returns the live object after
336+
// the update so the caller can record its post-update resourceVersion in
337+
// the cache.
349338
func (r *StorageVersionMigratorReconciler) restoreOne(
350339
ctx context.Context,
351340
gvk schema.GroupVersionKind,
@@ -357,12 +346,6 @@ func (r *StorageVersionMigratorReconciler) restoreOne(
357346
// IsNotFound is propagated to the caller, which handles it.
358347
return nil, err
359348
}
360-
annotations := live.GetAnnotations()
361-
if annotations == nil {
362-
annotations = map[string]string{}
363-
}
364-
annotations[MigrationTimestampAnnotation] = time.Now().UTC().Format(time.RFC3339Nano)
365-
live.SetAnnotations(annotations)
366349
if err := r.Update(ctx, live); err != nil {
367350
return nil, err
368351
}

cmd/thv-operator/test-integration/storageversionmigrator/controller_test.go

Lines changed: 140 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,15 @@ func createCRs(gvk schema.GroupVersionKind, basename string, count int) []*unstr
174174

175175
// Note on "did the re-store actually fire" verification:
176176
//
177-
// The controller now does a Get + Update on /status (or the main resource as a
178-
// fallback). An unconditional Update always writes the object back to etcd, so
179-
// the object's resourceVersion bumps on every successful re-store — that's the
180-
// observable proof a re-encode happened. The HappyPath test snapshots each
181-
// CR's resourceVersion before reconcile and asserts it has increased after
182-
// reconcile completes.
177+
// The controller does a plain Get + Update on each CR. When the CR is already
178+
// at the current storage version the apiserver freshly re-encodes the request
179+
// body, sees it matches etcd byte-for-byte, and elides the write — that's
180+
// correct behaviour, not a controller bug, and it means the per-CR RV does
181+
// not bump for an already-clean CR. The dedicated cross-version test
182+
// ("re-encodes CRs that are stored at a prior storage version") proves the
183+
// migration mechanism actually works for objects stored at older versions:
184+
// it stores a CR at v1alpha1, flips storage to v1beta1, and asserts the CR's
185+
// RV bumps after reconcile.
183186
//
184187
// The pagination test additionally verifies the continue-token loop via a
185188
// list-call counter, and the partial-failure test asserts storedVersions is
@@ -245,7 +248,7 @@ var _ = Describe("StorageVersionMigrator", func() {
245248
Expect(getStoredVersions(spec.Name)).To(Equal([]string{"v1beta1"}))
246249
})
247250

248-
It("migrates storedVersions from [v1alpha1,v1beta1] to [v1beta1] and re-stores all CRs", func() {
251+
It("migrates storedVersions from [v1alpha1,v1beta1] to [v1beta1]", func() {
249252
suf := uniqueSuffix()
250253
spec := crdSpec{
251254
Name: "happies" + suf + "." + toolhiveGroup,
@@ -264,39 +267,147 @@ var _ = Describe("StorageVersionMigrator", func() {
264267
installCRD(spec)
265268
DeferCleanup(func() { deleteCRD(spec.Name) })
266269

267-
crs := createCRs(
270+
// The CRs created here are already stored at v1beta1 (the
271+
// current storage version), so the apiserver will elide each
272+
// per-CR Update — its freshly-encoded body matches etcd
273+
// byte-for-byte. That's correct apiserver behaviour, not a
274+
// controller bug, so this test does NOT assert per-CR
275+
// resourceVersion bumps. The cross-version test below proves
276+
// the migration mechanism actually re-encodes objects that were
277+
// stored at an older apiVersion.
278+
createCRs(
268279
schema.GroupVersionKind{Group: spec.Group, Version: "v1beta1", Kind: spec.Kind},
269280
"obj-"+suf, 3,
270281
)
271282

272-
// Snapshot pre-reconcile resourceVersions. The controller does
273-
// Get + Update on each CR, so a successful re-store must bump RV.
274-
// Asserting the bump is the empirical proof the re-encode happened
275-
// (an empty SSA would not have bumped RV — that was the bug).
276-
preRVs := make(map[string]string, len(crs))
277-
for _, cr := range crs {
278-
live := &unstructured.Unstructured{}
279-
live.SetGroupVersionKind(cr.GroupVersionKind())
280-
Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cr), live)).To(Succeed())
281-
preRVs[cr.GetName()] = live.GetResourceVersion()
282-
}
283-
284283
setStoredVersions(spec.Name, []string{"v1alpha1", "v1beta1"})
285284

286285
_, err := reconcile(newReconciler(), spec.Name)
287286
Expect(err).NotTo(HaveOccurred())
288287
Expect(getStoredVersions(spec.Name)).To(Equal([]string{"v1beta1"}))
288+
})
289289

290-
// Verify every CR still exists AND its RV bumped, proving the
291-
// /status Update actually wrote to etcd.
292-
for _, cr := range crs {
293-
live := &unstructured.Unstructured{}
294-
live.SetGroupVersionKind(cr.GroupVersionKind())
295-
Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cr), live)).To(Succeed())
296-
Expect(live.GetResourceVersion()).NotTo(Equal(preRVs[cr.GetName()]),
297-
"CR %s/%s resourceVersion did not bump — re-store did not write to etcd",
298-
cr.GetNamespace(), cr.GetName())
290+
// Load-bearing proof of the migration mechanism: a CR stored at
291+
// v1alpha1, after the storage version has flipped to v1beta1, must
292+
// have its resourceVersion bumped by reconcile — that's the
293+
// observable evidence the apiserver actually re-encoded the etcd
294+
// document. See the upstream confirmation at
295+
// https://github.com/kubernetes-sigs/kube-storage-version-migrator/issues/65.
296+
It("re-encodes CRs that are stored at a prior storage version", func() {
297+
suf := uniqueSuffix()
298+
crdName := "crossvers" + suf + "." + toolhiveGroup
299+
kind := "CrossVer" + suf
300+
plural := "crossvers" + suf
301+
302+
versionSchema := func() *apiextensionsv1.CustomResourceValidation {
303+
return &apiextensionsv1.CustomResourceValidation{
304+
OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{
305+
Type: "object",
306+
Properties: map[string]apiextensionsv1.JSONSchemaProps{
307+
"spec": {Type: "object", XPreserveUnknownFields: ptrBool(true)},
308+
"status": {Type: "object", XPreserveUnknownFields: ptrBool(true)},
309+
},
310+
},
311+
}
299312
}
313+
314+
// Step 1: install CRD with v1alpha1 as the storage version so
315+
// CRs created next are written to etcd as v1alpha1 bytes.
316+
crd := &apiextensionsv1.CustomResourceDefinition{
317+
ObjectMeta: metav1.ObjectMeta{
318+
Name: crdName,
319+
Labels: map[string]string{migrateLabel: migrateValue},
320+
},
321+
Spec: apiextensionsv1.CustomResourceDefinitionSpec{
322+
Group: toolhiveGroup,
323+
Names: apiextensionsv1.CustomResourceDefinitionNames{
324+
Kind: kind,
325+
ListKind: kind + "List",
326+
Plural: plural,
327+
Singular: "crossver" + suf,
328+
},
329+
Scope: apiextensionsv1.NamespaceScoped,
330+
Versions: []apiextensionsv1.CustomResourceDefinitionVersion{
331+
{Name: "v1alpha1", Served: true, Storage: true, Schema: versionSchema()},
332+
{Name: "v1beta1", Served: true, Storage: false, Schema: versionSchema()},
333+
},
334+
},
335+
}
336+
Expect(k8sClient.Create(ctx, crd)).To(Succeed())
337+
DeferCleanup(func() { deleteCRD(crdName) })
338+
339+
Eventually(func() bool {
340+
live := &apiextensionsv1.CustomResourceDefinition{}
341+
if err := k8sClient.Get(ctx, types.NamespacedName{Name: crdName}, live); err != nil {
342+
return false
343+
}
344+
for _, c := range live.Status.Conditions {
345+
if c.Type == apiextensionsv1.Established && c.Status == apiextensionsv1.ConditionTrue {
346+
return true
347+
}
348+
}
349+
return false
350+
}, time.Second*10, time.Millisecond*200).Should(BeTrue())
351+
352+
// Step 2: create one CR — etcd writes apiVersion: v1alpha1 bytes.
353+
cr := createCRs(
354+
schema.GroupVersionKind{Group: toolhiveGroup, Version: "v1alpha1", Kind: kind},
355+
"obj-"+suf, 1,
356+
)[0]
357+
358+
// Step 3: flip storage to v1beta1.
359+
Eventually(func() error {
360+
live := &apiextensionsv1.CustomResourceDefinition{}
361+
if err := k8sClient.Get(ctx, types.NamespacedName{Name: crdName}, live); err != nil {
362+
return err
363+
}
364+
orig := live.DeepCopy()
365+
for i := range live.Spec.Versions {
366+
live.Spec.Versions[i].Storage = (live.Spec.Versions[i].Name == "v1beta1")
367+
}
368+
return k8sClient.Patch(ctx, live, client.MergeFrom(orig))
369+
}, time.Second*10, time.Millisecond*200).Should(Succeed())
370+
371+
// Confirm the storage flip settled before proceeding.
372+
Eventually(func() bool {
373+
live := &apiextensionsv1.CustomResourceDefinition{}
374+
if err := k8sClient.Get(ctx, types.NamespacedName{Name: crdName}, live); err != nil {
375+
return false
376+
}
377+
for _, v := range live.Spec.Versions {
378+
if v.Name == "v1beta1" && v.Storage {
379+
return true
380+
}
381+
}
382+
return false
383+
}, time.Second*10, time.Millisecond*200).Should(BeTrue())
384+
385+
// Step 4: storedVersions reflects the historical v1alpha1 entry.
386+
setStoredVersions(crdName, []string{"v1alpha1", "v1beta1"})
387+
388+
// Step 5: snapshot RV before reconcile.
389+
preLive := &unstructured.Unstructured{}
390+
preLive.SetGroupVersionKind(schema.GroupVersionKind{
391+
Group: toolhiveGroup, Version: "v1beta1", Kind: kind,
392+
})
393+
Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cr), preLive)).To(Succeed())
394+
preRV := preLive.GetResourceVersion()
395+
396+
// Step 6: reconcile.
397+
_, err := reconcile(newReconciler(), crdName)
398+
Expect(err).NotTo(HaveOccurred())
399+
400+
// Step 7: storedVersions trimmed.
401+
Expect(getStoredVersions(crdName)).To(Equal([]string{"v1beta1"}))
402+
403+
// Step 8: empirical proof — RV bumped because the cross-version
404+
// Update actually wrote etcd.
405+
postLive := &unstructured.Unstructured{}
406+
postLive.SetGroupVersionKind(preLive.GroupVersionKind())
407+
Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cr), postLive)).To(Succeed())
408+
Expect(postLive.GetResourceVersion()).NotTo(Equal(preRV),
409+
"CR %s/%s resourceVersion did not bump (pre=%s post=%s) — cross-version re-store did not write to etcd",
410+
cr.GetNamespace(), cr.GetName(), preRV, postLive.GetResourceVersion())
300411
})
301412

302413
It("skips CRDs in foreign API groups", func() {
@@ -353,42 +464,6 @@ var _ = Describe("StorageVersionMigrator", func() {
353464
"storedVersions must be untouched for unlabelled CRDs")
354465
})
355466

356-
It("falls back to main-resource SSA when the storage version has no /status subresource", func() {
357-
suf := uniqueSuffix()
358-
spec := crdSpec{
359-
Name: "nostatus" + suf + "." + toolhiveGroup,
360-
Group: toolhiveGroup,
361-
Kind: "NoStatus" + suf,
362-
ListKind: "NoStatus" + suf + "List",
363-
Plural: "nostatus" + suf,
364-
Singular: "nostatus" + suf,
365-
Labelled: true,
366-
HasStatusOnStored: false,
367-
Versions: []versionSpec{
368-
{Name: "v1alpha1", Served: true, Storage: false},
369-
{Name: "v1beta1", Served: true, Storage: true},
370-
},
371-
}
372-
installCRD(spec)
373-
DeferCleanup(func() { deleteCRD(spec.Name) })
374-
375-
cr := createCRs(
376-
schema.GroupVersionKind{Group: spec.Group, Version: "v1beta1", Kind: spec.Kind},
377-
"obj-"+suf, 1,
378-
)[0]
379-
setStoredVersions(spec.Name, []string{"v1alpha1", "v1beta1"})
380-
381-
_, err := reconcile(newReconciler(), spec.Name)
382-
Expect(err).NotTo(HaveOccurred(),
383-
"reconcile must succeed via main-resource SSA when no /status subresource exists")
384-
Expect(getStoredVersions(spec.Name)).To(Equal([]string{"v1beta1"}))
385-
386-
// CR still exists (migrator doesn't delete).
387-
live := &unstructured.Unstructured{}
388-
live.SetGroupVersionKind(cr.GroupVersionKind())
389-
Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cr), live)).To(Succeed())
390-
})
391-
392467
It("handles pagination across multiple list pages", func() {
393468
suf := uniqueSuffix()
394469
spec := crdSpec{

0 commit comments

Comments
 (0)