@@ -2,13 +2,15 @@ package webhook
22
33import (
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+
211312var _ = webhook.Admission {}
0 commit comments