Skip to content

Commit 6b4832e

Browse files
scotwellsclaude
andcommitted
fix(controller): roll instances by recreate so restart actually rolls them
A template-hash change (an image update, or a restartedAt annotation from `datumctl compute restart`) previously resolved to an in-place Update of the Instance. The unikraft provider bakes the pod at creation time and never recomputes an existing pod's spec, so the in-place update silently failed to roll the running workload — instances kept their old pod. Emit a delete (recreate) for drifted Ready instances instead. The next reconcile refills the slot via the create path with the new template, and the provider's finalizer-gated teardown plus create-on-new-Instance roll the pod with no provider changes. Ordered one-at-a-time pacing is preserved by the existing descending-ordinal sort, skip-all-but-first, and the DeletionTimestamp WaitAction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b416510 commit 6b4832e

2 files changed

Lines changed: 73 additions & 50 deletions

File tree

internal/controller/instancecontrol/stateful/stateful_control.go

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,10 @@ func (c *statefulControl) GetActions(
5353
var createActions []instancecontrol.Action
5454
var waitActions []instancecontrol.Action
5555

56-
// highest -> lowest
57-
var updateActions []instancecontrol.Action
56+
// highest -> lowest. Instances whose template hash has drifted from the
57+
// desired template are deleted and recreated (not updated in place) so the
58+
// change actually rolls the backing pod — see the recreate branch below.
59+
var recreateActions []instancecontrol.Action
5860

5961
// highest -> lowest
6062
var deleteActions []instancecontrol.Action
@@ -129,14 +131,19 @@ func (c *statefulControl) GetActions(
129131
if !apimeta.IsStatusConditionTrue(instance.Status.Conditions, v1alpha.InstanceReady) {
130132
waitActions = append(waitActions, instancecontrol.NewWaitAction(instance))
131133
} else if needsUpdate(instance, instanceTemplateHash) {
132-
updatedInstance := instance.DeepCopy()
133-
updatedInstance.Annotations = deployment.Spec.Template.Annotations
134-
updatedInstance.Labels = deployment.Spec.Template.Labels
135-
136-
addInstanceControllerLabels(updatedInstance, getInstanceOrdinal(updatedInstance.Name), deployment)
137-
138-
updatedInstance.Spec = deployment.Spec.Template.Spec
139-
updateActions = append(updateActions, instancecontrol.NewUpdateAction(updatedInstance))
134+
// The instance's template hash no longer matches the desired
135+
// template — e.g. an image change, or a restart requested via the
136+
// RestartedAtAnnotation, which is part of the template hash. The
137+
// unikraft provider bakes the pod's runtime, rootfs, and file
138+
// mounts at pod-creation time and never reconciles an existing
139+
// pod's spec, so an in-place Instance update would silently fail to
140+
// roll the running workload. Delete the instance instead; the next
141+
// reconcile recreates it from the current template via the create
142+
// path above, and the provider tears down the old pod
143+
// (finalizer-gated) and boots a fresh one. Ordered, one-at-a-time
144+
// pacing is preserved by the descending-ordinal sort, the
145+
// skip-all-but-first logic, and the DeletionTimestamp WaitAction.
146+
recreateActions = append(recreateActions, instancecontrol.NewDeleteAction(instance))
140147
}
141148
}
142149
}
@@ -168,10 +175,10 @@ func (c *statefulControl) GetActions(
168175
}
169176
}
170177

171-
slices.SortFunc(updateActions, descendingOrdinal)
178+
slices.SortFunc(recreateActions, descendingOrdinal)
172179
slices.SortFunc(deleteActions, descendingOrdinal)
173180

174-
actions := make([]instancecontrol.Action, 0, len(createActions)+len(waitActions)+len(updateActions)+len(deleteActions)+len(patchLabelActions))
181+
actions := make([]instancecontrol.Action, 0, len(createActions)+len(waitActions)+len(recreateActions)+len(deleteActions)+len(patchLabelActions))
175182

176183
switch deployment.Spec.ScaleSettings.InstanceManagementPolicy {
177184
case v1alpha.OrderedReadyInstanceManagementPolicyType:
@@ -186,7 +193,7 @@ func (c *statefulControl) GetActions(
186193

187194
slices.SortFunc(actions, ascendingOrdinal)
188195

189-
actions = append(actions, updateActions...)
196+
actions = append(actions, recreateActions...)
190197
actions = append(actions, deleteActions...)
191198

192199
// Skip all actions except the first one.

internal/controller/instancecontrol/stateful/stateful_control_test.go

Lines changed: 53 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ func TestFreshDeployment(t *testing.T) {
4949
assert.True(t, actions[1].IsSkipped())
5050
}
5151

52+
// TestUpdateWithAllReadyInstances verifies that a template change on Ready
53+
// instances rolls them by delete+recreate (not an in-place update), ordered
54+
// highest-ordinal-first with only the first action active. An in-place update
55+
// would never roll the backing pod, since the unikraft provider bakes the pod
56+
// at creation time and ignores spec changes on an existing pod.
5257
func TestUpdateWithAllReadyInstances(t *testing.T) {
5358
ctx := context.Background()
5459
control := New()
@@ -67,11 +72,11 @@ func TestUpdateWithAllReadyInstances(t *testing.T) {
6772
assert.Len(t, actions, 2)
6873

6974
assert.Equal(t, "test-deploy-1", actions[0].Object.GetName())
70-
assert.Equal(t, instancecontrol.ActionTypeUpdate, actions[0].ActionType())
75+
assert.Equal(t, instancecontrol.ActionTypeDelete, actions[0].ActionType())
7176
assert.False(t, actions[0].IsSkipped())
7277

7378
assert.Equal(t, "test-deploy-0", actions[1].Object.GetName())
74-
assert.Equal(t, instancecontrol.ActionTypeUpdate, actions[1].ActionType())
79+
assert.Equal(t, instancecontrol.ActionTypeDelete, actions[1].ActionType())
7580
assert.True(t, actions[1].IsSkipped())
7681
}
7782

@@ -244,38 +249,48 @@ func TestInstanceLabels_FourNewLabelsStamped(t *testing.T) {
244249
"PlacementNameLabel must equal deployment.Spec.PlacementName")
245250
}
246251

247-
// TestInstanceLabels_PropagatedOnUpdate verifies that when an existing instance
248-
// is updated (rolling update path), the four new labels are refreshed from the
249-
// deployment so they remain accurate after spec changes.
250-
func TestInstanceLabels_PropagatedOnUpdate(t *testing.T) {
252+
// TestInstanceLabels_RefreshedOnRecreate verifies that when a template change
253+
// rolls an instance, the recreated instance carries the four self-describing
254+
// labels sourced from the WorkloadDeployment. A template change no longer
255+
// updates the instance in place; it deletes the drifted instance and recreates
256+
// it via the create path on the following reconcile, which stamps the labels.
257+
func TestInstanceLabels_RefreshedOnRecreate(t *testing.T) {
251258
ctx := context.Background()
252259
control := New()
253260

254261
deployment := getWorkloadDeployment("test-labels-update", 1)
255262

256-
// Build a ready existing instance.
263+
// A ready existing instance on the old template hash.
257264
currentInstances := []v1alpha.Instance{*getInstanceForDeployment(deployment, 0)}
258265

259-
// Trigger a rolling update by changing the image.
266+
// Trigger a roll by changing the image.
260267
deployment.Spec.Template.Spec.Runtime.Sandbox.Containers[0].Image = "updated-image"
261268

269+
// First reconcile: the drifted instance is deleted (recreate), not updated.
262270
actions, err := control.GetActions(ctx, scheme, deployment, currentInstances)
271+
assert.NoError(t, err)
272+
assert.Len(t, actions, 1)
273+
assert.Equal(t, instancecontrol.ActionTypeDelete, actions[0].ActionType())
274+
assert.Equal(t, "test-labels-update-0", actions[0].Object.GetName())
263275

276+
// Next reconcile, after the old instance has been fully deleted and is gone:
277+
// the empty slot is refilled by the create path, which stamps the labels.
278+
actions, err = control.GetActions(ctx, scheme, deployment, nil)
264279
assert.NoError(t, err)
265280
assert.Len(t, actions, 1)
266-
assert.Equal(t, instancecontrol.ActionTypeUpdate, actions[0].ActionType())
281+
assert.Equal(t, instancecontrol.ActionTypeCreate, actions[0].ActionType())
267282

268283
instance, ok := actions[0].Object.(*v1alpha.Instance)
269284
assert.True(t, ok)
270285

271286
assert.Equal(t, deployment.GetName(), instance.Labels[v1alpha.WorkloadDeploymentNameLabel],
272-
"WorkloadDeploymentNameLabel must be refreshed on update")
287+
"WorkloadDeploymentNameLabel must be set on the recreated instance")
273288
assert.Equal(t, deployment.Spec.CityCode, instance.Labels[v1alpha.CityCodeLabel],
274-
"CityCodeLabel must be refreshed on update")
289+
"CityCodeLabel must be set on the recreated instance")
275290
assert.Equal(t, deployment.Spec.WorkloadRef.Name, instance.Labels[v1alpha.WorkloadNameLabel],
276-
"WorkloadNameLabel must be refreshed on update")
291+
"WorkloadNameLabel must be set on the recreated instance")
277292
assert.Equal(t, deployment.Spec.PlacementName, instance.Labels[v1alpha.PlacementNameLabel],
278-
"PlacementNameLabel must be refreshed on update")
293+
"PlacementNameLabel must be set on the recreated instance")
279294
}
280295

281296
// TestInstanceLocation_SetWhenDeploymentStatusLocationPresent verifies that when
@@ -331,7 +346,7 @@ func TestInstanceLocation_NilWhenDeploymentStatusLocationAbsent(t *testing.T) {
331346

332347
// TestLabelBackfill_NotReadyMatchingHash verifies that a not-Ready instance
333348
// with an unchanged template hash receives a PatchLabels action when it is
334-
// missing controller-managed labels. The action must not be a rollout Update,
349+
// missing controller-managed labels. The action must not be a rollout recreate,
335350
// must not alter spec/template, and must not block subsequent instances.
336351
func TestLabelBackfill_NotReadyMatchingHash(t *testing.T) {
337352
ctx := context.Background()
@@ -361,15 +376,15 @@ func TestLabelBackfill_NotReadyMatchingHash(t *testing.T) {
361376
assert.NoError(t, err)
362377

363378
// Collect actions by type.
364-
var waitActions, createActions, updateActions, patchActions []instancecontrol.Action
379+
var waitActions, createActions, recreateActions, patchActions []instancecontrol.Action
365380
for _, a := range actions {
366381
switch a.ActionType() {
367382
case instancecontrol.ActionTypeWait:
368383
waitActions = append(waitActions, a)
369384
case instancecontrol.ActionTypeCreate:
370385
createActions = append(createActions, a)
371-
case instancecontrol.ActionTypeUpdate:
372-
updateActions = append(updateActions, a)
386+
case instancecontrol.ActionTypeDelete:
387+
recreateActions = append(recreateActions, a)
373388
case instancecontrol.ActionTypePatchLabels:
374389
patchActions = append(patchActions, a)
375390
}
@@ -383,8 +398,8 @@ func TestLabelBackfill_NotReadyMatchingHash(t *testing.T) {
383398
assert.Len(t, createActions, 1, "instance-1 create action must be present")
384399
assert.True(t, createActions[0].IsSkipped(), "create for instance-1 must be skipped while instance-0 is waiting")
385400

386-
// No template Update actions must be produced.
387-
assert.Empty(t, updateActions, "no template Update must be produced for a matching-hash instance")
401+
// No rollout recreate actions must be produced.
402+
assert.Empty(t, recreateActions, "no rollout recreate must be produced for a matching-hash instance")
388403

389404
// A PatchLabels action must be produced for instance-0.
390405
assert.Len(t, patchActions, 1, "exactly one PatchLabels action for the label-drifted instance")
@@ -439,7 +454,7 @@ func TestLabelBackfill_Idempotent(t *testing.T) {
439454

440455
// TestLabelBackfill_ReadyInstanceCorrected verifies that a Ready instance with
441456
// correct template hash but drifted labels receives a PatchLabels action
442-
// without triggering a template rollout Update.
457+
// without triggering a rollout recreate.
443458
func TestLabelBackfill_ReadyInstanceCorrected(t *testing.T) {
444459
ctx := context.Background()
445460
control := New()
@@ -456,18 +471,18 @@ func TestLabelBackfill_ReadyInstanceCorrected(t *testing.T) {
456471

457472
assert.NoError(t, err)
458473

459-
var updateActions, patchActions []instancecontrol.Action
474+
var recreateActions, patchActions []instancecontrol.Action
460475
for _, a := range actions {
461476
switch a.ActionType() {
462-
case instancecontrol.ActionTypeUpdate:
463-
updateActions = append(updateActions, a)
477+
case instancecontrol.ActionTypeDelete:
478+
recreateActions = append(recreateActions, a)
464479
case instancecontrol.ActionTypePatchLabels:
465480
patchActions = append(patchActions, a)
466481
}
467482
}
468483

469-
// No template Update must be produced — template hash matches.
470-
assert.Empty(t, updateActions, "no template Update must be produced for a matching-hash ready instance")
484+
// No rollout recreate must be produced — template hash matches.
485+
assert.Empty(t, recreateActions, "no rollout recreate must be produced for a matching-hash ready instance")
471486

472487
// A PatchLabels action must be produced.
473488
assert.Len(t, patchActions, 1, "PatchLabels action must be produced for the label-drifted ready instance")
@@ -478,8 +493,9 @@ func TestLabelBackfill_ReadyInstanceCorrected(t *testing.T) {
478493
}
479494

480495
// TestLabelBackfill_DoesNotAffectRollingUpdate verifies that a genuine template
481-
// change on a Ready instance still produces a normal ordered Update action and
482-
// that the PatchLabels path does not interfere with or duplicate it.
496+
// change on a Ready instance still produces the normal ordered roll (a recreate
497+
// Delete per instance) and that the PatchLabels path does not interfere with or
498+
// duplicate it.
483499
func TestLabelBackfill_DoesNotAffectRollingUpdate(t *testing.T) {
484500
ctx := context.Background()
485501
control := New()
@@ -516,23 +532,23 @@ func TestLabelBackfill_DoesNotAffectRollingUpdate(t *testing.T) {
516532

517533
assert.NoError(t, err)
518534

519-
var updateActions, patchActions []instancecontrol.Action
535+
var recreateActions, patchActions []instancecontrol.Action
520536
for _, a := range actions {
521537
switch a.ActionType() {
522-
case instancecontrol.ActionTypeUpdate:
523-
updateActions = append(updateActions, a)
538+
case instancecontrol.ActionTypeDelete:
539+
recreateActions = append(recreateActions, a)
524540
case instancecontrol.ActionTypePatchLabels:
525541
patchActions = append(patchActions, a)
526542
}
527543
}
528544

529-
// Two Update actions expected (one per instance), ordered highest-to-lowest.
530-
assert.Len(t, updateActions, 2, "both instances must produce Update actions on template change")
531-
assert.Equal(t, "test-backfill-rolling-1", updateActions[0].Object.GetName(),
532-
"Update actions must be ordered highest ordinal first")
533-
assert.Equal(t, "test-backfill-rolling-0", updateActions[1].Object.GetName())
534-
assert.False(t, updateActions[0].IsSkipped(), "first Update must be active")
535-
assert.True(t, updateActions[1].IsSkipped(), "second Update must be skipped (ordered rollout)")
545+
// Two recreate (Delete) actions expected (one per instance), ordered highest-to-lowest.
546+
assert.Len(t, recreateActions, 2, "both instances must produce recreate actions on template change")
547+
assert.Equal(t, "test-backfill-rolling-1", recreateActions[0].Object.GetName(),
548+
"recreate actions must be ordered highest ordinal first")
549+
assert.Equal(t, "test-backfill-rolling-0", recreateActions[1].Object.GetName())
550+
assert.False(t, recreateActions[0].IsSkipped(), "first recreate must be active")
551+
assert.True(t, recreateActions[1].IsSkipped(), "second recreate must be skipped (ordered rollout)")
536552

537553
// No PatchLabels — all labels are already correct.
538554
assert.Empty(t, patchActions, "no PatchLabels when all labels are already correct")

0 commit comments

Comments
 (0)