Skip to content

Commit 02537e8

Browse files
stubbiclaude
andcommitted
feat(webhook): immutability latches for restoreFrom/migration, mutual exclusion, exactly-one source
Extends HermesInstanceValidator with Plan 5 rules: reject spec.restoreFrom changes after status.restoredFrom latches, reject spec.migration.fromOpenClaw changes after status.migration.completed, reject both restoreFrom+migration set simultaneously, enforce exactly-one of openclawInstanceRef/backupRef, and emit warnings for unresolvable backup S3 credential secrets and autoUpdate+latest tag. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ec71b52 commit 02537e8

2 files changed

Lines changed: 202 additions & 11 deletions

File tree

internal/webhook/webhook_hermesinstance_validate.go

Lines changed: 112 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ package webhook
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67

78
corev1 "k8s.io/api/core/v1"
89
apierrors "k8s.io/apimachinery/pkg/api/errors"
910
"k8s.io/apimachinery/pkg/runtime"
1011
"k8s.io/apimachinery/pkg/types"
1112
"k8s.io/apimachinery/pkg/util/intstr"
13+
"k8s.io/apimachinery/pkg/util/validation/field"
1214
"sigs.k8s.io/controller-runtime/pkg/client"
1315
"sigs.k8s.io/controller-runtime/pkg/webhook"
1416
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
@@ -35,16 +37,24 @@ func (v *HermesInstanceValidator) ValidateCreate(ctx context.Context, obj runtim
3537
if !ok {
3638
return nil, fmt.Errorf("expected *HermesInstance, got %T", obj)
3739
}
38-
warns, err := validateCommon(inst)
40+
errs := field.ErrorList{}
41+
errs = append(errs, validateRestoreMigrationMutualExclusion(inst)...)
42+
errs = append(errs, validateMigrationSourceExactlyOne(inst)...)
43+
warnings := v.crossCheckSecrets(ctx, inst)
44+
if len(errs) > 0 {
45+
return warnings, errs.ToAggregate()
46+
}
47+
commonWarns, err := validateCommon(inst)
48+
warnings = append(warnings, commonWarns...)
3949
if err != nil {
40-
return warns, err
50+
return warnings, err
4151
}
4252
gwWarns, gwErr := v.validateGateways(ctx, inst)
43-
warns = append(warns, gwWarns...)
53+
warnings = append(warnings, gwWarns...)
4454
if gwErr != nil {
45-
return warns, gwErr
55+
return warnings, gwErr
4656
}
47-
return warns, nil
57+
return warnings, nil
4858
}
4959

5060
// ValidateUpdate runs the create rules + immutability rules.
@@ -54,19 +64,28 @@ func (v *HermesInstanceValidator) ValidateUpdate(ctx context.Context, oldObj, ne
5464
if !ok1 || !ok2 {
5565
return nil, fmt.Errorf("ValidateUpdate types: old=%T new=%T", oldObj, newObj)
5666
}
67+
errs := field.ErrorList{}
68+
errs = append(errs, validateImmutableTerminals(oldI, newI)...)
69+
errs = append(errs, validateRestoreMigrationMutualExclusion(newI)...)
70+
errs = append(errs, validateMigrationSourceExactlyOne(newI)...)
71+
warnings := v.crossCheckSecrets(ctx, newI)
72+
if len(errs) > 0 {
73+
return warnings, errs.ToAggregate()
74+
}
5775
if err := validateImmutable(oldI, newI); err != nil {
58-
return nil, err
76+
return warnings, err
5977
}
60-
warns, err := validateCommon(newI)
78+
commonWarns, err := validateCommon(newI)
79+
warnings = append(warnings, commonWarns...)
6180
if err != nil {
62-
return warns, err
81+
return warnings, err
6382
}
6483
gwWarns, gwErr := v.validateGateways(ctx, newI)
65-
warns = append(warns, gwWarns...)
84+
warnings = append(warnings, gwWarns...)
6685
if gwErr != nil {
67-
return warns, gwErr
86+
return warnings, gwErr
6887
}
69-
return warns, nil
88+
return warnings, nil
7089
}
7190

7291
// ValidateDelete is a no-op.
@@ -208,4 +227,86 @@ func validateImmutable(oldI, newI *hermesv1.HermesInstance) error {
208227
return nil
209228
}
210229

230+
// validateImmutableTerminals checks restore + migration terminal latches.
231+
// `old` is the previous version (nil on create).
232+
func validateImmutableTerminals(old, updated *hermesv1.HermesInstance) field.ErrorList {
233+
var errs field.ErrorList
234+
if old == nil {
235+
return errs
236+
}
237+
if old.Status.RestoredFrom != "" && old.Status.RestoredFrom == old.Spec.RestoreFrom &&
238+
old.Spec.RestoreFrom != updated.Spec.RestoreFrom {
239+
errs = append(errs, field.Forbidden(
240+
field.NewPath("spec", "restoreFrom"),
241+
fmt.Sprintf("spec.restoreFrom is immutable after status.restoredFrom is set (current: %q). This is intentional to prevent accidental re-restore on restart.", old.Status.RestoredFrom),
242+
))
243+
}
244+
if old.Status.Migration.Completed {
245+
if !equalMigration(old.Spec.Migration, updated.Spec.Migration) {
246+
errs = append(errs, field.Forbidden(
247+
field.NewPath("spec", "migration", "fromOpenClaw"),
248+
"spec.migration.fromOpenClaw is immutable after status.migration.completed is true (one-shot migration).",
249+
))
250+
}
251+
}
252+
return errs
253+
}
254+
255+
// validateRestoreMigrationMutualExclusion rejects setting both fields at once.
256+
func validateRestoreMigrationMutualExclusion(inst *hermesv1.HermesInstance) field.ErrorList {
257+
if inst.Spec.RestoreFrom != "" && inst.Spec.Migration.FromOpenClaw != nil {
258+
return field.ErrorList{field.Invalid(
259+
field.NewPath("spec"),
260+
"restoreFrom + migration.fromOpenClaw",
261+
"set exactly one of spec.restoreFrom or spec.migration.fromOpenClaw — the combined order of operations is ambiguous (which source wins?). To both restore and migrate, do them as two separate instances.",
262+
)}
263+
}
264+
return nil
265+
}
266+
267+
// validateMigrationSourceExactlyOne enforces exactly-one of openclawInstanceRef
268+
// or backupRef under spec.migration.fromOpenClaw.source.
269+
func validateMigrationSourceExactlyOne(inst *hermesv1.HermesInstance) field.ErrorList {
270+
fc := inst.Spec.Migration.FromOpenClaw
271+
if fc == nil {
272+
return nil
273+
}
274+
refSet := fc.Source.OpenClawInstanceRef != nil
275+
backupSet := fc.Source.BackupRef != nil
276+
if refSet == backupSet {
277+
return field.ErrorList{field.Invalid(
278+
field.NewPath("spec", "migration", "fromOpenClaw", "source"),
279+
map[string]bool{"openclawInstanceRef": refSet, "backupRef": backupSet},
280+
"set exactly one of source.openclawInstanceRef or source.backupRef",
281+
)}
282+
}
283+
return nil
284+
}
285+
286+
// equalMigration is a JSON-based structural compare for the migration sub-spec.
287+
func equalMigration(a, b hermesv1.MigrationSpec) bool {
288+
aj, _ := json.Marshal(a)
289+
bj, _ := json.Marshal(b)
290+
return string(aj) == string(bj)
291+
}
292+
293+
// crossCheckSecrets emits warnings (never denials) for resolvable references
294+
// that are likely typos or that signal coming pitfalls (autoUpdate + tag=latest).
295+
func (v *HermesInstanceValidator) crossCheckSecrets(ctx context.Context, inst *hermesv1.HermesInstance) admission.Warnings {
296+
var warnings admission.Warnings
297+
if inst.Spec.Backup.S3 != nil && v.Client != nil {
298+
name := inst.Spec.Backup.S3.CredentialsSecretRef.Name
299+
if name != "" {
300+
secret := &corev1.Secret{}
301+
if err := v.Client.Get(ctx, types.NamespacedName{Name: name, Namespace: inst.Namespace}, secret); err != nil {
302+
warnings = append(warnings, fmt.Sprintf("spec.backup.s3.credentialsSecretRef %q is not resolvable in namespace %q: %v", name, inst.Namespace, err))
303+
}
304+
}
305+
}
306+
if inst.Spec.AutoUpdate.Enabled && inst.Spec.Image.Tag == "latest" {
307+
warnings = append(warnings, "spec.autoUpdate.enabled with spec.image.tag=\"latest\" — the operator will resolve to a concrete tag, but please pin spec.image.tag for GitOps deterministic apply")
308+
}
309+
return warnings
310+
}
311+
211312
var _ = webhook.Admission{}

internal/webhook/webhook_hermesinstance_validate_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,3 +231,93 @@ func TestValidateSelfConfigure_UnknownActionDenied(t *testing.T) {
231231
assert.Error(t, err)
232232
assert.Contains(t, err.Error(), "reboot-cluster")
233233
}
234+
235+
func TestValidateRestoreFromImmutableAfterLatch(t *testing.T) {
236+
old := &hermesv1.HermesInstance{
237+
Spec: hermesv1.HermesInstanceSpec{RestoreFrom: "k1"},
238+
Status: hermesv1.HermesInstanceStatus{RestoredFrom: "k1"},
239+
}
240+
newer := old.DeepCopy()
241+
newer.Spec.RestoreFrom = "k2"
242+
errs := validateImmutableTerminals(old, newer)
243+
assert.NotEmpty(t, errs)
244+
assert.Contains(t, errs[0].Error(), "spec.restoreFrom")
245+
}
246+
247+
func TestValidateMigrationImmutableAfterCompleted(t *testing.T) {
248+
old := &hermesv1.HermesInstance{
249+
Spec: hermesv1.HermesInstanceSpec{
250+
Migration: hermesv1.MigrationSpec{
251+
FromOpenClaw: &hermesv1.MigrationFromOpenClawSpec{
252+
Mode: "copy",
253+
Source: hermesv1.MigrationFromOpenClawSource{
254+
OpenClawInstanceRef: &hermesv1.NamespacedObjectReference{Name: "x", Namespace: "y"},
255+
},
256+
},
257+
},
258+
},
259+
Status: hermesv1.HermesInstanceStatus{Migration: hermesv1.MigrationStatus{Completed: true}},
260+
}
261+
newer := old.DeepCopy()
262+
newer.Spec.Migration.FromOpenClaw.Mode = "move"
263+
errs := validateImmutableTerminals(old, newer)
264+
assert.NotEmpty(t, errs)
265+
assert.Contains(t, errs[0].Error(), "migration")
266+
}
267+
268+
func TestValidateMutualExclusion(t *testing.T) {
269+
inst := &hermesv1.HermesInstance{
270+
Spec: hermesv1.HermesInstanceSpec{
271+
RestoreFrom: "k1",
272+
Migration: hermesv1.MigrationSpec{
273+
FromOpenClaw: &hermesv1.MigrationFromOpenClawSpec{
274+
Source: hermesv1.MigrationFromOpenClawSource{
275+
OpenClawInstanceRef: &hermesv1.NamespacedObjectReference{Name: "x", Namespace: "y"},
276+
},
277+
},
278+
},
279+
},
280+
}
281+
errs := validateRestoreMigrationMutualExclusion(inst)
282+
assert.NotEmpty(t, errs)
283+
}
284+
285+
func TestValidateMigrationSourceExactlyOne(t *testing.T) {
286+
both := &hermesv1.HermesInstance{
287+
Spec: hermesv1.HermesInstanceSpec{
288+
Migration: hermesv1.MigrationSpec{
289+
FromOpenClaw: &hermesv1.MigrationFromOpenClawSpec{
290+
Source: hermesv1.MigrationFromOpenClawSource{
291+
OpenClawInstanceRef: &hermesv1.NamespacedObjectReference{Name: "x", Namespace: "y"},
292+
BackupRef: &hermesv1.MigrationBackupRef{S3: hermesv1.MigrationBackupS3{Bucket: "b", Key: "k", Endpoint: "e", CredentialsSecretRef: hermesv1.LocalObjectReference{Name: "s"}}},
293+
},
294+
},
295+
},
296+
},
297+
}
298+
assert.NotEmpty(t, validateMigrationSourceExactlyOne(both))
299+
300+
neither := &hermesv1.HermesInstance{
301+
Spec: hermesv1.HermesInstanceSpec{
302+
Migration: hermesv1.MigrationSpec{
303+
FromOpenClaw: &hermesv1.MigrationFromOpenClawSpec{
304+
Source: hermesv1.MigrationFromOpenClawSource{},
305+
},
306+
},
307+
},
308+
}
309+
assert.NotEmpty(t, validateMigrationSourceExactlyOne(neither))
310+
311+
one := &hermesv1.HermesInstance{
312+
Spec: hermesv1.HermesInstanceSpec{
313+
Migration: hermesv1.MigrationSpec{
314+
FromOpenClaw: &hermesv1.MigrationFromOpenClawSpec{
315+
Source: hermesv1.MigrationFromOpenClawSource{
316+
OpenClawInstanceRef: &hermesv1.NamespacedObjectReference{Name: "x", Namespace: "y"},
317+
},
318+
},
319+
},
320+
},
321+
}
322+
assert.Empty(t, validateMigrationSourceExactlyOne(one))
323+
}

0 commit comments

Comments
 (0)