Skip to content

Commit 2dc06c8

Browse files
stubbiclaude
andcommitted
feat(resources): BuildStatefulSet accepts extraInits slice (prepended to runtime-init for restore)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bb70922 commit 2dc06c8

3 files changed

Lines changed: 51 additions & 34 deletions

File tree

internal/controller/hermesinstance_controller.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ func (r *HermesInstanceReconciler) reconcileStatefulSet(ctx context.Context, ins
405405
Name: resources.StatefulSetName(inst), Namespace: inst.Namespace,
406406
}}
407407
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, obj, func() error {
408-
desired := resources.BuildStatefulSet(inst)
408+
desired := resources.BuildStatefulSet(inst, nil)
409409
obj.Labels = resources.MergePreservingForeign(obj.Labels, desired.Labels, operatorLabelPrefix)
410410
obj.Spec = desired.Spec
411411
return controllerutil.SetControllerReference(inst, obj, r.Scheme)

internal/resources/statefulset.go

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ func StatefulSetName(inst *hermesv1.HermesInstance) string { return inst.Name }
1616

1717
// BuildStatefulSet constructs the desired StatefulSet. Every k8s server-side
1818
// default is set explicitly to avoid metadata.generation thrash on reconcile.
19-
func BuildStatefulSet(inst *hermesv1.HermesInstance) *appsv1.StatefulSet {
19+
// extraInits is prepended before operator-managed init containers so that
20+
// restore/migration runs BEFORE runtime-init touches the PVC.
21+
func BuildStatefulSet(inst *hermesv1.HermesInstance, extraInits []corev1.Container) *appsv1.StatefulSet {
2022
labels := LabelsForInstance(inst)
2123
selector := map[string]string{
2224
"app.kubernetes.io/name": "hermes-agent",
@@ -213,9 +215,8 @@ func BuildStatefulSet(inst *hermesv1.HermesInstance) *appsv1.StatefulSet {
213215
podSpec.Volumes = append(podSpec.Volumes, corev1.Volume{Name: "ca-bundle", VolumeSource: *caBundleVolumeSource})
214216
}
215217

216-
// Append sidecars, init containers, and extra volumes
218+
// Append sidecars and extra volumes (init containers assembled below)
217219
podSpec.Containers = append(podSpec.Containers, inst.Spec.Sidecars...)
218-
podSpec.InitContainers = append(podSpec.InitContainers, inst.Spec.InitContainers...)
219220
podSpec.Volumes = append(podSpec.Volumes, inst.Spec.ExtraVolumes...)
220221

221222
// Determine replicas based on suspended state
@@ -255,11 +256,15 @@ func BuildStatefulSet(inst *hermesv1.HermesInstance) *appsv1.StatefulSet {
255256
},
256257
}
257258

258-
// --- Plan 3: runtime init containers + volumes ---
259-
sts.Spec.Template.Spec.InitContainers = append(
260-
sts.Spec.Template.Spec.InitContainers,
261-
BuildRuntimeInitContainers(inst)...,
262-
)
259+
// Assemble init containers: extraInits (restore/migration) → operator-managed
260+
// (runtime-init) → user-supplied. Order matters: restore must populate the PVC
261+
// before runtime-init starts writing to it.
262+
inits := append([]corev1.Container{}, extraInits...)
263+
inits = append(inits, BuildRuntimeInitContainers(inst)...)
264+
inits = append(inits, inst.Spec.InitContainers...)
265+
sts.Spec.Template.Spec.InitContainers = inits
266+
267+
// --- Plan 3: runtime volumes ---
263268
sts.Spec.Template.Spec.Volumes = append(
264269
sts.Spec.Template.Spec.Volumes,
265270
BuildRuntimeVolumes(inst)...,

internal/resources/statefulset_test.go

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"testing"
55

66
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
78
corev1 "k8s.io/api/core/v1"
89
"k8s.io/apimachinery/pkg/api/resource"
910
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -12,7 +13,7 @@ import (
1213
)
1314

1415
func TestBuildStatefulSet_NameNamespaceLabels(t *testing.T) {
15-
sts := BuildStatefulSet(minimalInstance())
16+
sts := BuildStatefulSet(minimalInstance(), nil)
1617
assert.Equal(t, "demo", sts.Name)
1718
assert.Equal(t, "agents", sts.Namespace)
1819
assert.Equal(t, "hermes-agent", sts.Labels["app.kubernetes.io/name"])
@@ -23,15 +24,15 @@ func TestBuildStatefulSet_ContainerImage(t *testing.T) {
2324
inst := minimalInstance()
2425
inst.Spec.Image.Repository = "ghcr.io/stubbi/hermes-agent"
2526
inst.Spec.Image.Tag = "v1.0.0"
26-
sts := BuildStatefulSet(inst)
27+
sts := BuildStatefulSet(inst, nil)
2728
require := sts.Spec.Template.Spec.Containers
2829
assert.Len(t, require, 1)
2930
assert.Equal(t, "ghcr.io/stubbi/hermes-agent:v1.0.0", require[0].Image)
3031
assert.Equal(t, corev1.PullIfNotPresent, require[0].ImagePullPolicy, "explicit default")
3132
}
3233

3334
func TestBuildStatefulSet_ExplicitK8sDefaults(t *testing.T) {
34-
sts := BuildStatefulSet(minimalInstance())
35+
sts := BuildStatefulSet(minimalInstance(), nil)
3536
podSpec := sts.Spec.Template.Spec
3637

3738
assert.NotNil(t, sts.Spec.RevisionHistoryLimit)
@@ -48,7 +49,7 @@ func TestBuildStatefulSet_ExplicitK8sDefaults(t *testing.T) {
4849
}
4950

5051
func TestBuildStatefulSet_HardenedPodSecurity(t *testing.T) {
51-
sts := BuildStatefulSet(minimalInstance())
52+
sts := BuildStatefulSet(minimalInstance(), nil)
5253
pc := sts.Spec.Template.Spec.SecurityContext
5354
require := sts.Spec.Template.Spec.Containers[0].SecurityContext
5455
assert.NotNil(t, pc.RunAsNonRoot)
@@ -61,7 +62,7 @@ func TestBuildStatefulSet_HardenedPodSecurity(t *testing.T) {
6162
}
6263

6364
func TestBuildStatefulSet_VolumesAndMounts(t *testing.T) {
64-
sts := BuildStatefulSet(minimalInstance())
65+
sts := BuildStatefulSet(minimalInstance(), nil)
6566
c := sts.Spec.Template.Spec.Containers[0]
6667

6768
mountNames := map[string]string{}
@@ -92,7 +93,7 @@ func TestBuildStatefulSet_HonorsResources(t *testing.T) {
9293
corev1.ResourceMemory: resource.MustParse("512Mi"),
9394
},
9495
}
95-
sts := BuildStatefulSet(inst)
96+
sts := BuildStatefulSet(inst, nil)
9697
c := sts.Spec.Template.Spec.Containers[0]
9798
assert.Equal(t, resource.MustParse("100m"), c.Resources.Requests[corev1.ResourceCPU])
9899
assert.Equal(t, resource.MustParse("512Mi"), c.Resources.Limits[corev1.ResourceMemory])
@@ -107,7 +108,7 @@ func TestBuildStatefulSet_OverridesSecurityContexts(t *testing.T) {
107108
inst.Spec.Security.ContainerSecurityContext = &corev1.SecurityContext{
108109
ReadOnlyRootFilesystem: Ptr(false),
109110
}
110-
sts := BuildStatefulSet(inst)
111+
sts := BuildStatefulSet(inst, nil)
111112
assert.Equal(t, int64(2000), *sts.Spec.Template.Spec.SecurityContext.RunAsUser)
112113
assert.False(t, *sts.Spec.Template.Spec.Containers[0].SecurityContext.ReadOnlyRootFilesystem)
113114
}
@@ -122,7 +123,7 @@ func TestBuildStatefulSet_ProbeOverrides(t *testing.T) {
122123
FailureThreshold: 5,
123124
TimeoutSeconds: 2,
124125
}
125-
sts := BuildStatefulSet(inst)
126+
sts := BuildStatefulSet(inst, nil)
126127
c := sts.Spec.Template.Spec.Containers[0]
127128
assert.NotNil(t, c.LivenessProbe)
128129
assert.Equal(t, int32(30), c.LivenessProbe.InitialDelaySeconds)
@@ -143,7 +144,7 @@ func TestBuildStatefulSet_Scheduling(t *testing.T) {
143144
},
144145
},
145146
}
146-
sts := BuildStatefulSet(inst)
147+
sts := BuildStatefulSet(inst, nil)
147148
podSpec := sts.Spec.Template.Spec
148149
assert.Equal(t, "ssd", podSpec.NodeSelector["disktype"])
149150
assert.Len(t, podSpec.Tolerations, 1)
@@ -158,7 +159,7 @@ func TestBuildStatefulSet_TopologySpread(t *testing.T) {
158159
{TopologyKey: "topology.kubernetes.io/zone", WhenUnsatisfiable: corev1.ScheduleAnyway, MaxSkew: 1,
159160
LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "x"}}},
160161
}
161-
sts := BuildStatefulSet(inst)
162+
sts := BuildStatefulSet(inst, nil)
162163
assert.Len(t, sts.Spec.Template.Spec.TopologySpreadConstraints, 1)
163164
}
164165

@@ -167,7 +168,7 @@ func TestBuildStatefulSet_InitContainersAndSidecars(t *testing.T) {
167168
inst := minimalInstance()
168169
inst.Spec.InitContainers = []corev1.Container{{Name: "user-init", Image: "alpine"}}
169170
inst.Spec.Sidecars = []corev1.Container{{Name: "user-side", Image: "alpine"}}
170-
sts := BuildStatefulSet(inst)
171+
sts := BuildStatefulSet(inst, nil)
171172
var sawInit, sawSide bool
172173
for _, c := range sts.Spec.Template.Spec.InitContainers {
173174
if c.Name == "user-init" {
@@ -188,7 +189,7 @@ func TestBuildStatefulSet_ExtraVolumesAndMounts(t *testing.T) {
188189
inst := minimalInstance()
189190
inst.Spec.ExtraVolumes = []corev1.Volume{{Name: "user-vol", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}}
190191
inst.Spec.ExtraVolumeMounts = []corev1.VolumeMount{{Name: "user-vol", MountPath: "/user"}}
191-
sts := BuildStatefulSet(inst)
192+
sts := BuildStatefulSet(inst, nil)
192193
var sawVol, sawMount bool
193194
for _, v := range sts.Spec.Template.Spec.Volumes {
194195
if v.Name == "user-vol" {
@@ -211,7 +212,7 @@ func TestBuildStatefulSet_EnvAndEnvFrom(t *testing.T) {
211212
inst.Spec.EnvFrom = []corev1.EnvFromSource{
212213
{SecretRef: &corev1.SecretEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "user-secret"}}},
213214
}
214-
sts := BuildStatefulSet(inst)
215+
sts := BuildStatefulSet(inst, nil)
215216
c := sts.Spec.Template.Spec.Containers[0]
216217
var sawEnv, sawEnvFrom bool
217218
for _, e := range c.Env {
@@ -231,19 +232,19 @@ func TestBuildStatefulSet_EnvAndEnvFrom(t *testing.T) {
231232
func TestBuildStatefulSet_ServiceAccountName(t *testing.T) {
232233
t.Parallel()
233234
inst := minimalInstance()
234-
sts := BuildStatefulSet(inst)
235+
sts := BuildStatefulSet(inst, nil)
235236
assert.Equal(t, "demo", sts.Spec.Template.Spec.ServiceAccountName)
236237

237238
inst.Spec.Security.RBAC.ServiceAccountName = "byo-sa"
238-
sts2 := BuildStatefulSet(inst)
239+
sts2 := BuildStatefulSet(inst, nil)
239240
assert.Equal(t, "byo-sa", sts2.Spec.Template.Spec.ServiceAccountName)
240241
}
241242

242243
func TestBuildStatefulSet_WorkspaceVolumeMounted(t *testing.T) {
243244
t.Parallel()
244245
inst := minimalInstance()
245246
inst.Spec.Workspace.InitialFiles = []hermesv1.WorkspaceFile{{Path: "a.md", Content: "x"}}
246-
sts := BuildStatefulSet(inst)
247+
sts := BuildStatefulSet(inst, nil)
247248
var sawVol bool
248249
for _, v := range sts.Spec.Template.Spec.Volumes {
249250
if v.Name == "workspace" && v.ConfigMap != nil && v.ConfigMap.Name == "demo-workspace" {
@@ -257,7 +258,7 @@ func TestBuildStatefulSet_CABundleConfigMapMounted(t *testing.T) {
257258
t.Parallel()
258259
inst := minimalInstance()
259260
inst.Spec.Security.CABundle = hermesv1.CABundleSpec{ConfigMapName: "corp-ca", Key: "ca.crt"}
260-
sts := BuildStatefulSet(inst)
261+
sts := BuildStatefulSet(inst, nil)
261262
var sawCA bool
262263
for _, v := range sts.Spec.Template.Spec.Volumes {
263264
if v.Name == "ca-bundle" {
@@ -279,15 +280,15 @@ func TestBuildStatefulSet_Suspended(t *testing.T) {
279280
t.Parallel()
280281
inst := minimalInstance()
281282
inst.Spec.Suspended = true
282-
sts := BuildStatefulSet(inst)
283+
sts := BuildStatefulSet(inst, nil)
283284
assert.NotNil(t, sts.Spec.Replicas)
284285
assert.Equal(t, int32(0), *sts.Spec.Replicas)
285286
}
286287

287288
func TestBuildStatefulSet_NotSuspendedDefaultReplica(t *testing.T) {
288289
t.Parallel()
289290
inst := minimalInstance()
290-
sts := BuildStatefulSet(inst)
291+
sts := BuildStatefulSet(inst, nil)
291292
assert.NotNil(t, sts.Spec.Replicas)
292293
assert.Equal(t, int32(1), *sts.Spec.Replicas)
293294
}
@@ -299,7 +300,7 @@ func TestBuildStatefulSet_RuntimeInitContainersAppended(t *testing.T) {
299300
UV: hermesv1.UVSpec{Enabled: Ptr(true)},
300301
ExtraPipPackages: []string{"polars"},
301302
}
302-
sts := BuildStatefulSet(inst)
303+
sts := BuildStatefulSet(inst, nil)
303304
names := []string{}
304305
for _, c := range sts.Spec.Template.Spec.InitContainers {
305306
names = append(names, c.Name)
@@ -320,7 +321,7 @@ func TestBuildStatefulSet_GatewayEnvWired(t *testing.T) {
320321
},
321322
},
322323
}
323-
sts := BuildStatefulSet(inst)
324+
sts := BuildStatefulSet(inst, nil)
324325
c := sts.Spec.Template.Spec.Containers[0]
325326
hasToken := false
326327
for _, e := range c.Env {
@@ -343,7 +344,7 @@ func TestBuildStatefulSet_HonchoEnvWired(t *testing.T) {
343344
},
344345
},
345346
}
346-
sts := BuildStatefulSet(inst)
347+
sts := BuildStatefulSet(inst, nil)
347348
c := sts.Spec.Template.Spec.Containers[0]
348349
byName := map[string]corev1.EnvVar{}
349350
for _, e := range c.Env {
@@ -357,7 +358,7 @@ func TestBuildStatefulSet_UVCacheVolume(t *testing.T) {
357358
t.Parallel()
358359
inst := minimalInstance()
359360
inst.Spec.Runtime = hermesv1.RuntimeSpec{UV: hermesv1.UVSpec{Enabled: Ptr(true)}}
360-
sts := BuildStatefulSet(inst)
361+
sts := BuildStatefulSet(inst, nil)
361362
found := false
362363
for _, v := range sts.Spec.Template.Spec.Volumes {
363364
if v.Name == "uv-cache" {
@@ -393,7 +394,18 @@ func TestBuildStatefulSet_IdempotentWithRuntimeGatewaysHoncho(t *testing.T) {
393394
APIKeySecretRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "honcho"}, Key: "api-key"},
394395
},
395396
}
396-
a := BuildStatefulSet(inst)
397-
b := BuildStatefulSet(inst)
397+
a := BuildStatefulSet(inst, nil)
398+
b := BuildStatefulSet(inst, nil)
398399
assert.Equal(t, a, b, "pure builder must be deterministic")
399400
}
401+
402+
func TestBuildStatefulSet_AcceptsInitContainers(t *testing.T) {
403+
inst := minimalInstance()
404+
initC := corev1.Container{Name: "init-restore", Image: "restic/restic:0.16.4"}
405+
sts := BuildStatefulSet(inst, []corev1.Container{initC})
406+
require.NotNil(t, sts)
407+
// extraInits must come BEFORE operator-managed inits — restore writes to PVC
408+
// before runtime-init starts touching it.
409+
require.NotEmpty(t, sts.Spec.Template.Spec.InitContainers)
410+
assert.Equal(t, "init-restore", sts.Spec.Template.Spec.InitContainers[0].Name)
411+
}

0 commit comments

Comments
 (0)