Add durable host maintenance guards and app lifecycle claims
11 files changed,
+1422
−33
internal/controlname/names.go+28 −0internal/maintenanceguard/guard.go+173 −0internal/maintenanceguard/guard_test.go+337 −0internal/maintenanceguard/json.go+106 −0internal/maintenanceguard/lifecycle.go+213 −0internal/maintenanceguard/store.go+89 −0internal/maintenanceguard/types.go+232 −0internal/maintenanceguard/wire_test.go+236 −0internal/provision/provision.go+3 −17internal/release/qualified.go+3 −10internal/release/release.go+2 −6
diff --git a/internal/controlname/names.go b/internal/controlname/names.go| new file mode 100644 |
| index 000000000..51c0876fa |
| --- /dev/null |
| +++ b/internal/controlname/names.go |
| @@ -0,0 +1,28 @@ |
| +// Package controlname validates identifiers used as control-plane key segments. |
| +package controlname |
| + |
| +import ( |
| + "regexp" |
| + "strings" |
| +) |
| + |
| +var ( |
| + account = regexp.MustCompile(`^[a-z]{3,16}$`) |
| + app = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`) |
| + label = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) |
| +) |
| + |
| +func ValidAccount(s string) bool { return account.MatchString(s) } |
| +func ValidApp(s string) bool { return app.MatchString(s) } |
| +func ValidLabel(s string) bool { return label.MatchString(s) } |
| +func ValidHostShape(host string) bool { |
| + if host == "" || len(host) > 253 { |
| + return false |
| + } |
| + for _, part := range strings.Split(host, ".") { |
| + if !ValidLabel(part) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
diff --git a/internal/maintenanceguard/guard.go b/internal/maintenanceguard/guard.go| new file mode 100644 |
| index 000000000..1739addfe |
| --- /dev/null |
| +++ b/internal/maintenanceguard/guard.go |
| @@ -0,0 +1,173 @@ |
| +package maintenanceguard |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "math" |
| + "os" |
| + "slices" |
| +) |
| + |
| +type GuardSnapshot struct { |
| + repository *Repository |
| + value Guard |
| + etag string |
| + exists bool |
| +} |
| + |
| +func (s GuardSnapshot) Exists() bool { return s.exists } |
| +func (s GuardSnapshot) Value() Guard { return cloneGuard(s.value) } |
| +func (s GuardSnapshot) Projection() (Projection, error) { |
| + if !s.exists { |
| + return Projection{}, ErrInvalid |
| + } |
| + raw, e := guardBytes(s.value) |
| + if e != nil { |
| + return Projection{}, e |
| + } |
| + return Projection{Version, s.value.Generation, digest(raw)}, nil |
| +} |
| +func (r *Repository) ReadGuard(ctx context.Context, host string) (GuardSnapshot, error) { |
| + key, e := GuardKey(host) |
| + if e != nil { |
| + return GuardSnapshot{}, e |
| + } |
| + raw, etag, e := r.store.Get(ctx, key) |
| + if errors.Is(e, os.ErrNotExist) { |
| + return GuardSnapshot{repository: r}, nil |
| + } |
| + if e != nil { |
| + return GuardSnapshot{}, e |
| + } |
| + var g Guard |
| + if decode(raw, &g) != nil || !g.valid() || g.Identity.Host != host || !cleanETag(etag) { |
| + return GuardSnapshot{}, ErrInvalid |
| + } |
| + if e = r.checkGuardReferences(ctx, g); e != nil { |
| + return GuardSnapshot{}, e |
| + } |
| + return GuardSnapshot{r, cloneGuard(g), etag, true}, nil |
| +} |
| +func (r *Repository) guardClaim(ctx context.Context, c ClaimSnapshot, g Guard) error { |
| + if c.value.Kind != MaintenanceOperation || c.value.Account != g.Identity.Account || c.value.App != g.Identity.App || c.value.OperationID != g.OperationID { |
| + return ErrConflict |
| + } |
| + return r.currentClaim(ctx, c) |
| +} |
| +func (r *Repository) writeGuard(ctx context.Context, c ClaimSnapshot, g Guard, etag string) (GuardSnapshot, error) { |
| + if e := r.guardClaim(ctx, c, g); e != nil { |
| + return GuardSnapshot{}, e |
| + } |
| + raw, e := guardBytes(g) |
| + if e != nil { |
| + return GuardSnapshot{}, e |
| + } |
| + if e = r.checkGuardReferences(ctx, g); e != nil { |
| + return GuardSnapshot{}, e |
| + } |
| + key, _ := GuardKey(g.Identity.Host) |
| + next, e := r.store.Put(ctx, key, raw, etag) |
| + if e != nil { |
| + return GuardSnapshot{}, e |
| + } |
| + if !cleanETag(next) { |
| + return GuardSnapshot{}, ErrUnavailable |
| + } |
| + // There is no cross-object atomicity. A lost claim leaves the durable guard |
| + // in place, but the caller receives no successful execution/resume handoff. |
| + if e = r.currentClaim(ctx, c); e != nil { |
| + return GuardSnapshot{}, e |
| + } |
| + return GuardSnapshot{r, cloneGuard(g), next, true}, nil |
| +} |
| +func (r *Repository) CreateGuard(ctx context.Context, c ClaimSnapshot, g Guard) (GuardSnapshot, error) { |
| + if !g.valid() || g.Generation != 1 || g.Phase != Requested || g.Evidence != nil { |
| + return GuardSnapshot{}, ErrInvalid |
| + } |
| + return r.writeGuard(ctx, c, g, "") |
| +} |
| +func newAttempt(old, next Guard) bool { |
| + return old.Request.ID != next.Request.ID && old.Attempt.ID != next.Attempt.ID && next.Evidence == nil |
| +} |
| +func samePins(old, next Guard) bool { |
| + return old.RecordRevision == next.RecordRevision && old.ArtifactVersion == next.ArtifactVersion && old.ArtifactDigest == next.ArtifactDigest && old.ConfigDigest == next.ConfigDigest && slices.Equal(old.RequiredContracts, next.RequiredContracts) |
| +} |
| +func validAdvance(old, next Guard) bool { |
| + if !next.valid() || old.Generation == math.MaxInt64 || next.Generation != old.Generation+1 || next.Identity != old.Identity || next.OwnerBox != old.OwnerBox || !containsAll(next.RequiredContracts, old.RequiredContracts) { |
| + return false |
| + } |
| + if old.Phase == Resumed { |
| + return next.Phase == Requested && next.OperationID != old.OperationID && newAttempt(old, next) |
| + } |
| + if next.OperationID != old.OperationID { |
| + return false |
| + } |
| + if next.Phase == Requested { |
| + return (old.Phase == Succeeded || old.Phase == Failed || old.Phase == Ambiguous) && newAttempt(old, next) |
| + } |
| + if next.Phase == ResumeRequested { |
| + return (old.Phase == Fenced || old.Phase == Succeeded || old.Phase == Failed || old.Phase == Ambiguous) && newAttempt(old, next) |
| + } |
| + if old.Request != next.Request || old.Attempt != next.Attempt || !samePins(old, next) { |
| + return false |
| + } |
| + switch old.Phase { |
| + case Requested: |
| + return next.Phase == Quiescing || next.Phase == Failed || next.Phase == Ambiguous |
| + case Quiescing: |
| + return next.Phase == Fenced || next.Phase == Failed || next.Phase == Ambiguous |
| + case Fenced: |
| + return next.Phase == Executing || next.Phase == Failed || next.Phase == Ambiguous |
| + case Executing: |
| + return next.Phase == Succeeded || next.Phase == Failed || next.Phase == Ambiguous |
| + case ResumeRequested: |
| + return next.Phase == Resumed || next.Phase == Failed || next.Phase == Ambiguous |
| + } |
| + return false |
| +} |
| + |
| +// AdvanceGuard validates continuity and state progression, then performs one |
| +// CAS. Fresh owner approval, stopped-process/lease proofs and checker semantics |
| +// remain caller preconditions; valid references alone do not prove them. |
| +func (r *Repository) AdvanceGuard(ctx context.Context, c ClaimSnapshot, old GuardSnapshot, next Guard) (GuardSnapshot, error) { |
| + if old.repository != r || !old.exists || !cleanETag(old.etag) || !validAdvance(old.value, next) { |
| + return GuardSnapshot{}, ErrConflict |
| + } |
| + return r.writeGuard(ctx, c, next, old.etag) |
| +} |
| + |
| +type ServingDecision string |
| + |
| +const ( |
| + Unmanaged ServingDecision = "unmanaged" |
| + Managed ServingDecision = "managed" |
| + Denied ServingDecision = "denied" |
| +) |
| + |
| +// CheckServing is a denial check, not a lease or process-health permit. The |
| +// caller supplies contracts from an already verified signed release manifest, |
| +// verifies the current record identity, and repeats this check after lease |
| +// acquisition before starting or seeding anything. |
| +func (r *Repository) CheckServing(ctx context.Context, identity Identity, projection *Projection, verifiedContracts []string) (ServingDecision, error) { |
| + if !identity.valid() || !ValidContracts(verifiedContracts) { |
| + return Denied, ErrDenied |
| + } |
| + s, e := r.ReadGuard(ctx, identity.Host) |
| + if e != nil { |
| + return Denied, ErrDenied |
| + } |
| + if !s.exists { |
| + if projection == nil { |
| + return Unmanaged, nil |
| + } |
| + return Denied, ErrDenied |
| + } |
| + if projection == nil || !projection.valid() || s.value.Identity != identity || s.value.Phase != Resumed || !containsAll(verifiedContracts, s.value.RequiredContracts) { |
| + return Denied, ErrDenied |
| + } |
| + expected, e := s.Projection() |
| + if e != nil || *projection != expected { |
| + return Denied, ErrDenied |
| + } |
| + return Managed, nil |
| +} |
diff --git a/internal/maintenanceguard/guard_test.go b/internal/maintenanceguard/guard_test.go| new file mode 100644 |
| index 000000000..92a8712ce |
| --- /dev/null |
| +++ b/internal/maintenanceguard/guard_test.go |
| @@ -0,0 +1,337 @@ |
| +package maintenanceguard_test |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + guard "github.com/carlosframework/platform/internal/maintenanceguard" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +var ctx = context.Background() |
| + |
| +func id(b byte) string { return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{b}, 32)) } |
| +func hash(s string) string { x := sha256.Sum256([]byte(s)); return hex.EncodeToString(x[:]) } |
| + |
| +type fixture struct { |
| + store *release.DirStore |
| + repo *guard.Repository |
| + identity guard.Identity |
| +} |
| + |
| +func setup(t *testing.T) fixture { |
| + t.Helper() |
| + s := release.NewDirStore(t.TempDir()) |
| + identity, e := guard.RecordIdentity("aaa", "home", "one.home.example", time.Date(2026, 9, 8, 0, 0, 0, 1, time.UTC)) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return fixture{s, guard.New(s), guard.Identity{Account: "aaa", App: "home", Host: "one.home.example", Record: identity}} |
| +} |
| +func (f fixture) ref(t *testing.T, kind guard.ReferenceKind, n byte) guard.Reference { |
| + t.Helper() |
| + r, e := f.repo.PutReference(ctx, f.identity.Host, kind, id(n), []byte(fmt.Sprintf(`{"metadata":"%s-%d"}`, kind, n))) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return r |
| +} |
| +func (f fixture) claim(t *testing.T, n byte) guard.ClaimSnapshot { |
| + t.Helper() |
| + s, e := f.repo.AcquireClaim(ctx, "aaa", "home", id(n), guard.MaintenanceOperation) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return s |
| +} |
| +func (f fixture) initial(t *testing.T, c guard.ClaimSnapshot) guard.Guard { |
| + t.Helper() |
| + return guard.Guard{Version: 1, Identity: f.identity, Generation: 1, Phase: guard.Requested, OperationID: c.Value().OperationID, OwnerBox: "box-one", RecordRevision: `"original-record-etag"`, Request: f.ref(t, guard.RequestReference, 2), Attempt: f.ref(t, guard.AttemptReference, 3), ArtifactVersion: "v1", ArtifactDigest: hash("artifact"), ConfigDigest: hash("randomized-config-envelope"), RequiredContracts: []string{}} |
| +} |
| +func (f fixture) create(t *testing.T, c guard.ClaimSnapshot) guard.GuardSnapshot { |
| + t.Helper() |
| + g, e := f.repo.CreateGuard(ctx, c, f.initial(t, c)) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return g |
| +} |
| +func (f fixture) step(t *testing.T, c guard.ClaimSnapshot, s guard.GuardSnapshot, phase guard.Phase) guard.GuardSnapshot { |
| + t.Helper() |
| + g := s.Value() |
| + g.Generation++ |
| + g.Phase = phase |
| + if phase == guard.ResumeRequested { |
| + g.Request = f.ref(t, guard.RequestReference, 4) |
| + g.Attempt = f.ref(t, guard.AttemptReference, 5) |
| + g.RequiredContracts = []string{"home.operator.v1"} |
| + g.Evidence = nil |
| + } |
| + if phase == guard.Succeeded || phase == guard.Resumed { |
| + ref := f.ref(t, guard.ReceiptReference, byte(g.Generation+10)) |
| + g.Evidence = &ref |
| + } |
| + next, e := f.repo.AdvanceGuard(ctx, c, s, g) |
| + if e != nil { |
| + t.Fatal(phase, e) |
| + } |
| + return next |
| +} |
| +func (f fixture) resumed(t *testing.T, c guard.ClaimSnapshot) guard.GuardSnapshot { |
| + s := f.create(t, c) |
| + for _, p := range []guard.Phase{guard.Quiescing, guard.Fenced, guard.Executing, guard.Succeeded, guard.ResumeRequested, guard.Resumed} { |
| + s = f.step(t, c, s, p) |
| + } |
| + return s |
| +} |
| +func denied(t *testing.T, f fixture, p *guard.Projection, contracts []string) { |
| + t.Helper() |
| + d, e := f.repo.CheckServing(ctx, f.identity, p, contracts) |
| + if d != guard.Denied || !errors.Is(e, guard.ErrDenied) { |
| + t.Fatal(d, e) |
| + } |
| +} |
| + |
| +func TestGuardUnmanagedAndAbsenceRecheck(t *testing.T) { |
| + f := setup(t) |
| + if d, e := f.repo.CheckServing(ctx, f.identity, nil, nil); e != nil || d != guard.Unmanaged { |
| + t.Fatal(d, e) |
| + } |
| + p := guard.Projection{Version: 1, Generation: 1, GuardDigest: hash("guard")} |
| + denied(t, f, &p, nil) |
| + c := f.claim(t, 1) |
| + // A claim alone is not a host fence. This models the crash before guard creation. |
| + if d, e := f.repo.CheckServing(ctx, f.identity, nil, nil); e != nil || d != guard.Unmanaged { |
| + t.Fatal(d, e) |
| + } |
| + s := f.create(t, c) |
| + p, e := s.Projection() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + // A worker that observed absence before acquiring its lease must recheck. |
| + denied(t, f, nil, nil) |
| + denied(t, f, &p, nil) |
| +} |
| +func TestGuardResumeCrashProjectionAndContracts(t *testing.T) { |
| + f := setup(t) |
| + c := f.claim(t, 1) |
| + s := f.create(t, c) |
| + for _, phase := range []guard.Phase{guard.Quiescing, guard.Fenced, guard.Executing, guard.Succeeded, guard.ResumeRequested} { |
| + s = f.step(t, c, s, phase) |
| + p, _ := s.Projection() |
| + denied(t, f, &p, []string{"home.operator.v1"}) |
| + } |
| + oldProjection, _ := s.Projection() |
| + s = f.step(t, c, s, guard.Resumed) |
| + // Durable guard publication without the record CAS must not open admission. |
| + denied(t, f, &oldProjection, []string{"home.operator.v1"}) |
| + denied(t, f, nil, []string{"home.operator.v1"}) |
| + reopened := guard.New(f.store) |
| + loaded, e := reopened.ReadGuard(ctx, f.identity.Host) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + p, _ := loaded.Projection() |
| + if d, e := reopened.CheckServing(ctx, f.identity, &p, []string{"home.operator.v1"}); e != nil || d != guard.Managed { |
| + t.Fatal(d, e) |
| + } |
| + denied(t, f, &p, nil) |
| + denied(t, f, &p, []string{"home.operator.v0"}) |
| + foreign := f.identity |
| + foreign.Account = "bbb" |
| + if d, e := f.repo.CheckServing(ctx, foreign, &p, []string{"home.operator.v1"}); d != guard.Denied || e == nil { |
| + t.Fatal("hostname reassigned") |
| + } |
| + foreign = f.identity |
| + foreign.Record = hash("replacement") |
| + if d, e := f.repo.CheckServing(ctx, foreign, &p, []string{"home.operator.v1"}); d != guard.Denied || e == nil { |
| + t.Fatal("record replaced") |
| + } |
| + if _, e = f.repo.ReleaseClaim(ctx, c); e != nil { |
| + t.Fatal(e) |
| + } |
| + held, e := f.repo.ReadLifecycle(ctx, "aaa", "home") |
| + if e != nil || held.Value().State != "idle" || held.Value().Generation != 2 { |
| + t.Fatal(held.Value(), e) |
| + } |
| +} |
| +func TestGuardStaleWorkersAndExecutingRecovery(t *testing.T) { |
| + f := setup(t) |
| + c := f.claim(t, 1) |
| + first := f.create(t, c) |
| + second := f.step(t, c, first, guard.Quiescing) |
| + stale := first.Value() |
| + stale.Generation++ |
| + stale.Phase = guard.Quiescing |
| + if _, e := f.repo.AdvanceGuard(ctx, c, first, stale); !errors.Is(e, release.ErrCASConflict) { |
| + t.Fatal("stale write did not preserve CAS conflict", e) |
| + } |
| + s := f.step(t, c, second, guard.Fenced) |
| + s = f.step(t, c, s, guard.Executing) |
| + afterCrash, e := f.repo.ReadGuard(ctx, f.identity.Host) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + repeat := afterCrash.Value() |
| + repeat.Generation++ |
| + if _, e = f.repo.AdvanceGuard(ctx, c, afterCrash, repeat); !errors.Is(e, guard.ErrConflict) { |
| + t.Fatal("executing automatically repeated", e) |
| + } |
| + s = f.step(t, c, afterCrash, guard.Ambiguous) |
| + retry := s.Value() |
| + retry.Generation++ |
| + retry.Phase = guard.Requested |
| + retry.Request = f.ref(t, guard.RequestReference, 8) |
| + retry.Attempt = f.ref(t, guard.AttemptReference, 9) |
| + retry.Evidence = nil |
| + if _, e = f.repo.AdvanceGuard(ctx, c, s, retry); e != nil { |
| + t.Fatal("explicit new attempt refused", e) |
| + } |
| +} |
| +func TestGuardContinuityAndContractFloorCannotShrink(t *testing.T) { |
| + f := setup(t) |
| + c := f.claim(t, 1) |
| + s := f.create(t, c) |
| + for _, change := range []func(*guard.Guard){func(g *guard.Guard) { g.Generation-- }, func(g *guard.Guard) { g.Generation++ }, func(g *guard.Guard) { g.Identity.Record = hash("other") }, func(g *guard.Guard) { g.OwnerBox = "box-two" }, func(g *guard.Guard) { g.Request.Digest = hash("other") }, func(g *guard.Guard) { g.ArtifactDigest = hash("other") }} { |
| + g := s.Value() |
| + g.Generation++ |
| + g.Phase = guard.Quiescing |
| + change(&g) |
| + if _, e := f.repo.AdvanceGuard(ctx, c, s, g); !errors.Is(e, guard.ErrConflict) { |
| + t.Fatal("continuity changed", e) |
| + } |
| + } |
| + for _, p := range []guard.Phase{guard.Quiescing, guard.Fenced, guard.ResumeRequested, guard.Resumed} { |
| + s = f.step(t, c, s, p) |
| + } |
| + if _, e := f.repo.ReleaseClaim(ctx, c); e != nil { |
| + t.Fatal(e) |
| + } |
| + c = f.claim(t, 20) |
| + g := s.Value() |
| + g.Generation++ |
| + g.Phase = guard.Requested |
| + g.OperationID = c.Value().OperationID |
| + g.Request = f.ref(t, guard.RequestReference, 21) |
| + g.Attempt = f.ref(t, guard.AttemptReference, 22) |
| + g.Evidence = nil |
| + g.RequiredContracts = nil |
| + if _, e := f.repo.AdvanceGuard(ctx, c, s, g); !errors.Is(e, guard.ErrConflict) { |
| + t.Fatal("floor removed", e) |
| + } |
| +} |
| +func TestImmutableReferenceAndMissingEvidence(t *testing.T) { |
| + f := setup(t) |
| + r := f.ref(t, guard.RequestReference, 2) |
| + if _, e := f.repo.PutReference(ctx, f.identity.Host, guard.RequestReference, r.ID, []byte("changed")); !errors.Is(e, guard.ErrConflict) { |
| + t.Fatal(e) |
| + } |
| + c := f.claim(t, 1) |
| + s := f.resumed(t, c) |
| + p, _ := s.Projection() |
| + // Private payload expiry is separate and cannot remove permanent metadata. |
| + private := "private/expired-payload" |
| + if _, e := f.store.Put(ctx, private, []byte("synthetic-private"), ""); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e := f.store.Delete(ctx, private); e != nil { |
| + t.Fatal(e) |
| + } |
| + if d, e := f.repo.CheckServing(ctx, f.identity, &p, []string{"home.operator.v1"}); e != nil || d != guard.Managed { |
| + t.Fatal(d, e) |
| + } |
| + key, _ := guard.ReceiptKey(f.identity.Host, s.Value().Evidence.ID) |
| + if e := f.store.Delete(ctx, key); e != nil { |
| + t.Fatal(e) |
| + } |
| + denied(t, f, &p, []string{"home.operator.v1"}) |
| +} |
| + |
| +// absenceBarrier forces both contenders to observe absence before either CAS. |
| +type absenceBarrier struct { |
| + release.Store |
| + key string |
| + mu sync.Mutex |
| + n int |
| + ready chan struct{} |
| +} |
| + |
| +func (s *absenceBarrier) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + b, etag, e := s.Store.Get(ctx, key) |
| + if key == s.key && errors.Is(e, os.ErrNotExist) { |
| + s.mu.Lock() |
| + s.n++ |
| + if s.n == 2 { |
| + close(s.ready) |
| + } |
| + s.mu.Unlock() |
| + <-s.ready |
| + } |
| + return b, etag, e |
| +} |
| +func TestLifecycleSimultaneousAbsenceMaintenanceVersusPurge(t *testing.T) { |
| + for i := 0; i < 20; i++ { |
| + f := setup(t) |
| + key, _ := guard.LifecycleKey("aaa", "home") |
| + barrier := &absenceBarrier{Store: f.store, key: key, ready: make(chan struct{})} |
| + r := guard.New(barrier) |
| + record := "apps/aaa/home/instances/one.home.example.json" |
| + if _, e := f.store.Put(ctx, record, []byte("existing-record"), ""); e != nil { |
| + t.Fatal(e) |
| + } |
| + type result struct { |
| + kind guard.OperationKind |
| + claim guard.ClaimSnapshot |
| + err error |
| + } |
| + out := make(chan result, 2) |
| + for n, kind := range []guard.OperationKind{guard.MaintenanceOperation, guard.PurgeOperation} { |
| + go func(n int, kind guard.OperationKind) { |
| + c, e := r.AcquireClaim(ctx, "aaa", "home", id(byte(n+40)), kind) |
| + out <- result{kind, c, e} |
| + }(n, kind) |
| + } |
| + a, b := <-out, <-out |
| + if (a.err == nil) == (b.err == nil) { |
| + t.Fatal("two absence readers both won or lost", a.err, b.err) |
| + } |
| + winner, loser := a, b |
| + if winner.err != nil { |
| + winner, loser = b, a |
| + } |
| + if !errors.Is(loser.err, release.ErrCASConflict) { |
| + t.Fatal(loser.err) |
| + } |
| + if winner.kind == guard.PurgeOperation { |
| + if e := f.store.Delete(ctx, record); e != nil { |
| + t.Fatal(e) |
| + } |
| + } else { |
| + g := f.initial(t, winner.claim) |
| + if _, e := r.CreateGuard(ctx, winner.claim, g); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + s, e := r.ReadGuard(ctx, f.identity.Host) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + _, _, recordErr := f.store.Get(ctx, record) |
| + if winner.kind == guard.MaintenanceOperation && (!s.Exists() || recordErr != nil) { |
| + t.Fatal("purge crossed maintenance claim") |
| + } |
| + if winner.kind == guard.PurgeOperation && (s.Exists() || !errors.Is(recordErr, os.ErrNotExist)) { |
| + t.Fatal("maintenance crossed purge claim") |
| + } |
| + } |
| +} |
diff --git a/internal/maintenanceguard/json.go b/internal/maintenanceguard/json.go| new file mode 100644 |
| index 000000000..a737b38bc |
| --- /dev/null |
| +++ b/internal/maintenanceguard/json.go |
| @@ -0,0 +1,106 @@ |
| +package maintenanceguard |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "reflect" |
| + "strings" |
| +) |
| + |
| +const MaxWireBytes = 16 << 10 |
| + |
| +var ErrInvalid = errors.New("maintenance guard: invalid record") |
| + |
| +// decode checks exact field names and duplicates before typed decoding. The |
| +// expected schema bounds nesting; arbitrary JSON trees are never traversed. |
| +func decode(raw []byte, dst any) error { |
| + if len(raw) == 0 || len(raw) > MaxWireBytes { |
| + return ErrInvalid |
| + } |
| + typ := reflect.TypeOf(dst) |
| + if typ == nil || typ.Kind() != reflect.Pointer { |
| + return ErrInvalid |
| + } |
| + dec := json.NewDecoder(bytes.NewReader(raw)) |
| + dec.UseNumber() |
| + if !jsonShape(dec, typ.Elem()) { |
| + return ErrInvalid |
| + } |
| + if _, e := dec.Token(); e != io.EOF { |
| + return ErrInvalid |
| + } |
| + if json.Unmarshal(raw, dst) != nil { |
| + return ErrInvalid |
| + } |
| + return nil |
| +} |
| +func jsonShape(dec *json.Decoder, typ reflect.Type) bool { |
| + for typ.Kind() == reflect.Pointer { |
| + typ = typ.Elem() |
| + } |
| + token, e := dec.Token() |
| + if e != nil || token == nil { |
| + return false |
| + } |
| + if delim, ok := token.(json.Delim); ok { |
| + switch delim { |
| + case '{': |
| + if typ.Kind() != reflect.Struct { |
| + return false |
| + } |
| + fields := map[string]reflect.Type{} |
| + for i := 0; i < typ.NumField(); i++ { |
| + field := typ.Field(i) |
| + name := strings.Split(field.Tag.Get("json"), ",")[0] |
| + if name != "" && name != "-" { |
| + fields[name] = field.Type |
| + } |
| + } |
| + seen := map[string]bool{} |
| + for dec.More() { |
| + key, e := dec.Token() |
| + if e != nil { |
| + return false |
| + } |
| + name, ok := key.(string) |
| + field, known := fields[name] |
| + if !ok || !known || seen[name] { |
| + return false |
| + } |
| + seen[name] = true |
| + if !jsonShape(dec, field) { |
| + return false |
| + } |
| + } |
| + end, e := dec.Token() |
| + return e == nil && end == json.Delim('}') |
| + case '[': |
| + if typ.Kind() != reflect.Slice { |
| + return false |
| + } |
| + for dec.More() { |
| + if !jsonShape(dec, typ.Elem()) { |
| + return false |
| + } |
| + } |
| + end, e := dec.Token() |
| + return e == nil && end == json.Delim(']') |
| + default: |
| + return false |
| + } |
| + } |
| + switch typ.Kind() { |
| + case reflect.String: |
| + _, ok := token.(string) |
| + return ok |
| + case reflect.Int, reflect.Int64: |
| + _, ok := token.(json.Number) |
| + return ok |
| + case reflect.Bool: |
| + _, ok := token.(bool) |
| + return ok |
| + } |
| + return false |
| +} |
diff --git a/internal/maintenanceguard/lifecycle.go b/internal/maintenanceguard/lifecycle.go| new file mode 100644 |
| index 000000000..9331a6f7b |
| --- /dev/null |
| +++ b/internal/maintenanceguard/lifecycle.go |
| @@ -0,0 +1,213 @@ |
| +package maintenanceguard |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "math" |
| + "os" |
| +) |
| + |
| +type OperationKind string |
| + |
| +const ( |
| + MaintenanceOperation OperationKind = "maintenance" |
| + TrashOperation OperationKind = "trash" |
| + RestoreOperation OperationKind = "restore" |
| + PurgeOperation OperationKind = "purge" |
| +) |
| + |
| +func (k OperationKind) valid() bool { |
| + return k == MaintenanceOperation || k == TrashOperation || k == RestoreOperation || k == PurgeOperation |
| +} |
| + |
| +type Lifecycle struct { |
| + Version int `json:"version"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Generation int64 `json:"generation"` |
| + State string `json:"state"` |
| + OperationID string `json:"operation_id"` |
| + Kind OperationKind `json:"kind"` |
| + ClaimGeneration int64 `json:"claim_generation"` |
| + CompletionDigest string `json:"completion_digest,omitempty"` |
| +} |
| + |
| +func (l Lifecycle) valid() bool { |
| + if _, e := appBase(l.Account, l.App); e != nil { |
| + return false |
| + } |
| + if l.Version != Version || l.Generation <= 0 || !ValidID(l.OperationID) || !l.Kind.valid() || l.ClaimGeneration <= 0 { |
| + return false |
| + } |
| + switch l.State { |
| + case "claimed": |
| + return l.Generation == l.ClaimGeneration && l.CompletionDigest == "" |
| + case "idle": |
| + return l.ClaimGeneration < math.MaxInt64 && l.Generation == l.ClaimGeneration+1 && ValidDigest(l.CompletionDigest) |
| + } |
| + return false |
| +} |
| + |
| +type claimIntent struct { |
| + Version int `json:"version"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + OperationID string `json:"operation_id"` |
| + Kind OperationKind `json:"kind"` |
| + Generation int64 `json:"generation"` |
| +} |
| + |
| +func intent(l Lifecycle) claimIntent { |
| + return claimIntent{Version, l.Account, l.App, l.OperationID, l.Kind, l.ClaimGeneration} |
| +} |
| +func claimKey(l Lifecycle, completed bool) string { |
| + p, _ := appBase(l.Account, l.App) |
| + suffix := "claim" |
| + if completed { |
| + suffix = "completion" |
| + } |
| + return p + "operations/" + l.OperationID + "/" + suffix + ".json" |
| +} |
| + |
| +type claimCompletion struct { |
| + Claim claimIntent `json:"claim"` |
| + Generation int64 `json:"generation"` |
| +} |
| + |
| +type ClaimSnapshot struct { |
| + repository *Repository |
| + value Lifecycle |
| + etag string |
| + exists bool |
| +} |
| + |
| +func (s ClaimSnapshot) Exists() bool { return s.exists } |
| +func (s ClaimSnapshot) Value() Lifecycle { return s.value } |
| +func (r *Repository) ReadLifecycle(ctx context.Context, account, app string) (ClaimSnapshot, error) { |
| + key, e := LifecycleKey(account, app) |
| + if e != nil { |
| + return ClaimSnapshot{}, e |
| + } |
| + raw, etag, e := r.store.Get(ctx, key) |
| + if errors.Is(e, os.ErrNotExist) { |
| + return ClaimSnapshot{repository: r, value: Lifecycle{Account: account, App: app}}, nil |
| + } |
| + if e != nil { |
| + return ClaimSnapshot{}, e |
| + } |
| + var l Lifecycle |
| + if decode(raw, &l) != nil || !l.valid() || l.Account != account || l.App != app || !cleanETag(etag) { |
| + return ClaimSnapshot{}, ErrInvalid |
| + } |
| + b, _, e := r.store.Get(ctx, claimKey(l, false)) |
| + var i claimIntent |
| + if e != nil || decode(b, &i) != nil || i != intent(l) { |
| + return ClaimSnapshot{}, ErrUnavailable |
| + } |
| + if l.State == "idle" { |
| + b, _, e = r.store.Get(ctx, claimKey(l, true)) |
| + var c claimCompletion |
| + if e != nil || decode(b, &c) != nil || c.Claim != i || c.Generation != l.Generation || digest(b) != l.CompletionDigest { |
| + return ClaimSnapshot{}, ErrUnavailable |
| + } |
| + } |
| + return ClaimSnapshot{r, l, etag, true}, nil |
| +} |
| + |
| +// AcquireClaim excludes competing app lifecycle operations. The caller must |
| +// authorize the operation first; acquisition alone cannot start maintenance. |
| +// Immutable intent may survive a lost CAS, but grants no claim or side effect. |
| +func (r *Repository) AcquireClaim(ctx context.Context, account, app, id string, kind OperationKind) (ClaimSnapshot, error) { |
| + if !ValidID(id) || !kind.valid() { |
| + return ClaimSnapshot{}, ErrInvalid |
| + } |
| + old, e := r.ReadLifecycle(ctx, account, app) |
| + if e != nil { |
| + return ClaimSnapshot{}, e |
| + } |
| + if old.exists && old.value.State != "idle" { |
| + return ClaimSnapshot{}, ErrBusy |
| + } |
| + generation := int64(1) |
| + if old.exists { |
| + if old.value.Generation == math.MaxInt64 { |
| + return ClaimSnapshot{}, ErrConflict |
| + } |
| + generation = old.value.Generation + 1 |
| + } |
| + l := Lifecycle{Version: Version, Account: account, App: app, Generation: generation, State: "claimed", OperationID: id, Kind: kind, ClaimGeneration: generation} |
| + b, _ := json.Marshal(intent(l)) |
| + if e = r.putImmutable(ctx, claimKey(l, false), b); e != nil { |
| + return ClaimSnapshot{}, e |
| + } |
| + raw, _ := json.Marshal(l) |
| + key, _ := LifecycleKey(account, app) |
| + etag, e := r.store.Put(ctx, key, raw, old.etag) |
| + if e != nil { |
| + return ClaimSnapshot{}, e |
| + } |
| + if !cleanETag(etag) { |
| + return ClaimSnapshot{}, ErrUnavailable |
| + } |
| + return ClaimSnapshot{r, l, etag, true}, nil |
| +} |
| + |
| +// RecoverClaim observes only the exact still-held operation. It does not expire |
| +// a claim, release it, reapprove it, or rerun any command. |
| +func (r *Repository) RecoverClaim(ctx context.Context, account, app, id string, kind OperationKind) (ClaimSnapshot, error) { |
| + if !ValidID(id) || !kind.valid() { |
| + return ClaimSnapshot{}, ErrInvalid |
| + } |
| + s, e := r.ReadLifecycle(ctx, account, app) |
| + if e != nil { |
| + return ClaimSnapshot{}, e |
| + } |
| + if !s.exists || s.value.State != "claimed" || s.value.OperationID != id || s.value.Kind != kind { |
| + return ClaimSnapshot{}, ErrConflict |
| + } |
| + return s, nil |
| +} |
| +func (r *Repository) currentClaim(ctx context.Context, s ClaimSnapshot) error { |
| + if s.repository != r || !s.exists || s.value.State != "claimed" || !s.value.valid() || !cleanETag(s.etag) { |
| + return ErrInvalid |
| + } |
| + now, e := r.ReadLifecycle(ctx, s.value.Account, s.value.App) |
| + if e != nil { |
| + return e |
| + } |
| + if !now.exists || now.etag != s.etag || now.value != s.value { |
| + return ErrConflict |
| + } |
| + return nil |
| +} |
| + |
| +// ReleaseClaim requires the caller's completed-operation proof. Maintenance |
| +// callers must first verify both resumed projections and healthy serving under |
| +// the reviewed lease boundary. This CAS does not establish those facts itself. |
| +func (r *Repository) ReleaseClaim(ctx context.Context, s ClaimSnapshot) (ClaimSnapshot, error) { |
| + if e := r.currentClaim(ctx, s); e != nil { |
| + return ClaimSnapshot{}, e |
| + } |
| + if s.value.Generation == math.MaxInt64 { |
| + return ClaimSnapshot{}, ErrConflict |
| + } |
| + l := s.value |
| + l.Generation++ |
| + l.State = "idle" |
| + completion, _ := json.Marshal(claimCompletion{intent(l), l.Generation}) |
| + if e := r.putImmutable(ctx, claimKey(l, true), completion); e != nil { |
| + return ClaimSnapshot{}, e |
| + } |
| + l.CompletionDigest = digest(completion) |
| + raw, _ := json.Marshal(l) |
| + key, _ := LifecycleKey(l.Account, l.App) |
| + etag, e := r.store.Put(ctx, key, raw, s.etag) |
| + if e != nil { |
| + return ClaimSnapshot{}, e |
| + } |
| + if !cleanETag(etag) { |
| + return ClaimSnapshot{}, ErrUnavailable |
| + } |
| + return ClaimSnapshot{r, l, etag, true}, nil |
| +} |
diff --git a/internal/maintenanceguard/store.go b/internal/maintenanceguard/store.go| new file mode 100644 |
| index 000000000..9d35a06e0 |
| --- /dev/null |
| +++ b/internal/maintenanceguard/store.go |
| @@ -0,0 +1,89 @@ |
| +package maintenanceguard |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "os" |
| +) |
| + |
| +// Store is structural so release, provision and host can share these rules |
| +// without importing each other. Put uses empty ETag for create-only writes. |
| +type Store interface { |
| + Get(context.Context, string) ([]byte, string, error) |
| + Put(context.Context, string, []byte, string) (string, error) |
| +} |
| +type Repository struct{ store Store } |
| + |
| +func New(store Store) *Repository { return &Repository{store: store} } |
| + |
| +type ReferenceKind string |
| + |
| +const ( |
| + RequestReference ReferenceKind = "requests" |
| + AttemptReference ReferenceKind = "attempts" |
| + ReceiptReference ReferenceKind = "receipts" |
| +) |
| + |
| +func (k ReferenceKind) valid() bool { |
| + return k == RequestReference || k == AttemptReference || k == ReceiptReference |
| +} |
| + |
| +// PutReference stores permanent, nonsecret metadata. Expiring private payloads |
| +// belong elsewhere: active guards must keep resolving these immutable bytes. |
| +// The caller validates metadata, ownership and any encrypted payload bindings. |
| +func (r *Repository) PutReference(ctx context.Context, host string, kind ReferenceKind, id string, raw []byte) (Reference, error) { |
| + if !kind.valid() || len(raw) == 0 || len(raw) > MaxReferenceBytes { |
| + return Reference{}, ErrInvalid |
| + } |
| + key, e := refKey(host, string(kind), id) |
| + if e != nil { |
| + return Reference{}, e |
| + } |
| + if e = r.putImmutable(ctx, key, raw); e != nil { |
| + return Reference{}, e |
| + } |
| + return Reference{ID: id, Digest: digest(raw)}, nil |
| +} |
| +func (r *Repository) putImmutable(ctx context.Context, key string, raw []byte) error { |
| + old, _, e := r.store.Get(ctx, key) |
| + if e == nil { |
| + if bytes.Equal(old, raw) { |
| + return nil |
| + } |
| + return ErrConflict |
| + } |
| + if !errors.Is(e, os.ErrNotExist) { |
| + return e |
| + } |
| + _, e = r.store.Put(ctx, key, raw, "") |
| + return e |
| +} |
| +func (r *Repository) checkReference(ctx context.Context, host string, kind ReferenceKind, ref Reference) error { |
| + if !ref.valid() { |
| + return ErrInvalid |
| + } |
| + key, e := refKey(host, string(kind), ref.ID) |
| + if e != nil { |
| + return e |
| + } |
| + raw, _, e := r.store.Get(ctx, key) |
| + if e != nil || len(raw) == 0 || len(raw) > MaxReferenceBytes || digest(raw) != ref.Digest { |
| + return ErrUnavailable |
| + } |
| + return nil |
| +} |
| +func (r *Repository) checkGuardReferences(ctx context.Context, g Guard) error { |
| + for _, v := range []struct { |
| + kind ReferenceKind |
| + ref Reference |
| + }{{RequestReference, g.Request}, {AttemptReference, g.Attempt}} { |
| + if e := r.checkReference(ctx, g.Identity.Host, v.kind, v.ref); e != nil { |
| + return e |
| + } |
| + } |
| + if g.Evidence != nil { |
| + return r.checkReference(ctx, g.Identity.Host, ReceiptReference, *g.Evidence) |
| + } |
| + return nil |
| +} |
diff --git a/internal/maintenanceguard/types.go b/internal/maintenanceguard/types.go| new file mode 100644 |
| index 000000000..afe6e8fad |
| --- /dev/null |
| +++ b/internal/maintenanceguard/types.go |
| @@ -0,0 +1,232 @@ |
| +// Package maintenanceguard stores denial state and lifecycle exclusion. Its |
| +// records do not prove human approval, process health, or lease ownership. |
| +package maintenanceguard |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/binary" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "github.com/carlosframework/platform/internal/controlname" |
| + "regexp" |
| + "sort" |
| + "strings" |
| + "time" |
| +) |
| + |
| +const Version = 1 |
| +const MaxContracts = 16 |
| +const MaxReferenceBytes = 2 << 20 |
| + |
| +var ( |
| + ErrUnavailable = errors.New("maintenance guard: unavailable") |
| + ErrConflict = errors.New("maintenance guard: conflict") |
| + ErrBusy = errors.New("maintenance guard: lifecycle claimed") |
| + ErrDenied = errors.New("maintenance guard: serving denied") |
| + atomRE = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`) |
| + contractRE = regexp.MustCompile(`^[a-z][a-z0-9]*([.-][a-z0-9]+)*$`) |
| +) |
| + |
| +func ValidID(s string) bool { |
| + b, e := base64.RawURLEncoding.Strict().DecodeString(s) |
| + return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s |
| +} |
| +func ValidDigest(s string) bool { |
| + b, e := hex.DecodeString(s) |
| + return e == nil && len(b) == 32 && hex.EncodeToString(b) == s |
| +} |
| +func ValidContract(s string) bool { return len(s) > 0 && len(s) <= 64 && contractRE.MatchString(s) } |
| +func ValidContracts(set []string) bool { |
| + if len(set) > MaxContracts { |
| + return false |
| + } |
| + for i, s := range set { |
| + if !ValidContract(s) || (i > 0 && set[i-1] >= s) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| +func atom(s string) bool { return s != "." && s != ".." && atomRE.MatchString(s) } |
| +func revision(s string) bool { |
| + if s == "" || len(s) > 256 { |
| + return false |
| + } |
| + for _, c := range []byte(s) { |
| + if c <= 32 || c > 126 || c == '*' { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| +func digest(raw []byte) string { sum := sha256.Sum256(raw); return hex.EncodeToString(sum[:]) } |
| + |
| +// RecordIdentity binds immutable creation fields. It is continuity metadata, |
| +// not proof against a privileged writer restoring or forging those fields. |
| +func RecordIdentity(account, app, host string, created time.Time) (string, error) { |
| + if !controlname.ValidAccount(account) || !controlname.ValidApp(app) || !controlname.ValidHostShape(host) || created.IsZero() { |
| + return "", ErrInvalid |
| + } |
| + stamp := created.UTC().Format(time.RFC3339Nano) |
| + if parsed, e := time.Parse(time.RFC3339Nano, stamp); e != nil || !parsed.Equal(created) { |
| + return "", ErrInvalid |
| + } |
| + raw := []byte("carlos-maintenance-record-identity-v1\x00") |
| + for _, s := range []string{account, app, host, stamp} { |
| + raw = binary.BigEndian.AppendUint32(raw, uint32(len(s))) |
| + raw = append(raw, s...) |
| + } |
| + return digest(raw), nil |
| +} |
| + |
| +type Identity struct { |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Host string `json:"host"` |
| + Record string `json:"record"` |
| +} |
| + |
| +func (i Identity) valid() bool { |
| + return controlname.ValidAccount(i.Account) && controlname.ValidApp(i.App) && controlname.ValidHostShape(i.Host) && ValidDigest(i.Record) |
| +} |
| + |
| +type Reference struct { |
| + ID string `json:"id"` |
| + Digest string `json:"digest"` |
| +} |
| + |
| +func (r Reference) valid() bool { return ValidID(r.ID) && ValidDigest(r.Digest) } |
| + |
| +type Phase string |
| + |
| +const ( |
| + Requested Phase = "requested" |
| + Quiescing Phase = "quiescing" |
| + Fenced Phase = "fenced" |
| + Executing Phase = "executing" |
| + Succeeded Phase = "succeeded" |
| + Failed Phase = "failed" |
| + Ambiguous Phase = "ambiguous" |
| + ResumeRequested Phase = "resume-requested" |
| + Resumed Phase = "resumed" |
| +) |
| + |
| +func (p Phase) valid() bool { |
| + switch p { |
| + case Requested, Quiescing, Fenced, Executing, Succeeded, Failed, Ambiguous, ResumeRequested, Resumed: |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +type Guard struct { |
| + Version int `json:"version"` |
| + Identity Identity `json:"identity"` |
| + Generation int64 `json:"generation"` |
| + Phase Phase `json:"phase"` |
| + OperationID string `json:"operation_id"` |
| + OwnerBox string `json:"owner_box"` |
| + RecordRevision string `json:"record_revision"` |
| + Request Reference `json:"request"` |
| + Attempt Reference `json:"attempt"` |
| + ArtifactVersion string `json:"artifact_version"` |
| + ArtifactDigest string `json:"artifact_digest"` |
| + ConfigDigest string `json:"config_digest"` |
| + RequiredContracts []string `json:"required_contracts"` |
| + Evidence *Reference `json:"evidence,omitempty"` |
| +} |
| + |
| +func (g Guard) valid() bool { |
| + if g.Version != Version || !g.Identity.valid() || g.Generation <= 0 || !g.Phase.valid() || !ValidID(g.OperationID) || !atom(g.OwnerBox) || !revision(g.RecordRevision) || !g.Request.valid() || !g.Attempt.valid() || !atom(g.ArtifactVersion) || !ValidDigest(g.ArtifactDigest) || !ValidDigest(g.ConfigDigest) || !ValidContracts(g.RequiredContracts) { |
| + return false |
| + } |
| + if g.Evidence != nil && !g.Evidence.valid() { |
| + return false |
| + } |
| + return (g.Phase != Resumed && g.Phase != Succeeded) || (g.Evidence != nil && (g.Phase != Resumed || len(g.RequiredContracts) > 0)) |
| +} |
| +func cloneGuard(g Guard) Guard { |
| + g.RequiredContracts = append([]string{}, g.RequiredContracts...) |
| + if g.Evidence != nil { |
| + r := *g.Evidence |
| + g.Evidence = &r |
| + } |
| + return g |
| +} |
| +func guardBytes(g Guard) ([]byte, error) { |
| + if !g.valid() { |
| + return nil, ErrInvalid |
| + } |
| + return json.Marshal(cloneGuard(g)) |
| +} |
| + |
| +type Projection struct { |
| + Version int `json:"version"` |
| + Generation int64 `json:"generation"` |
| + GuardDigest string `json:"guard_digest"` |
| +} |
| + |
| +func (p Projection) valid() bool { |
| + return p.Version == Version && p.Generation > 0 && ValidDigest(p.GuardDigest) |
| +} |
| +func EncodeProjection(p Projection) ([]byte, error) { |
| + if !p.valid() { |
| + return nil, ErrInvalid |
| + } |
| + return json.Marshal(p) |
| +} |
| +func DecodeProjection(raw []byte) (Projection, error) { |
| + var p Projection |
| + if decode(raw, &p) != nil || !p.valid() { |
| + return Projection{}, ErrInvalid |
| + } |
| + return p, nil |
| +} |
| +func containsAll(superset, subset []string) bool { |
| + for _, s := range subset { |
| + i := sort.SearchStrings(superset, s) |
| + if i == len(superset) || superset[i] != s { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| +func hostBase(host string) (string, error) { |
| + if !controlname.ValidHostShape(host) { |
| + return "", ErrInvalid |
| + } |
| + return "control/maintenance/hosts/" + host + "/", nil |
| +} |
| +func GuardKey(host string) (string, error) { |
| + p, e := hostBase(host) |
| + if e != nil { |
| + return "", e |
| + } |
| + return p + "guard.json", nil |
| +} |
| +func refKey(host, kind, id string) (string, error) { |
| + p, e := hostBase(host) |
| + if e != nil || !ValidID(id) { |
| + return "", ErrInvalid |
| + } |
| + return p + kind + "/" + id + ".json", nil |
| +} |
| +func RequestKey(host, id string) (string, error) { return refKey(host, "requests", id) } |
| +func AttemptKey(host, id string) (string, error) { return refKey(host, "attempts", id) } |
| +func ReceiptKey(host, id string) (string, error) { return refKey(host, "receipts", id) } |
| +func appBase(account, app string) (string, error) { |
| + if !controlname.ValidAccount(account) || !controlname.ValidApp(app) { |
| + return "", ErrInvalid |
| + } |
| + return "control/maintenance/apps/" + account + "/" + app + "/", nil |
| +} |
| +func LifecycleKey(account, app string) (string, error) { |
| + p, e := appBase(account, app) |
| + if e != nil { |
| + return "", e |
| + } |
| + return p + "lifecycle.json", nil |
| +} |
| +func cleanETag(s string) bool { return s != "" && len(s) <= 512 && !strings.ContainsAny(s, "\r\n\x00") } |
diff --git a/internal/maintenanceguard/wire_test.go b/internal/maintenanceguard/wire_test.go| new file mode 100644 |
| index 000000000..dabb770fa |
| --- /dev/null |
| +++ b/internal/maintenanceguard/wire_test.go |
| @@ -0,0 +1,236 @@ |
| +package maintenanceguard_test |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "math" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + guard "github.com/carlosframework/platform/internal/maintenanceguard" |
| +) |
| + |
| +func replace(t *testing.T, f fixture, key string, raw []byte) { |
| + t.Helper() |
| + _, etag, e := f.store.Get(ctx, key) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = f.store.Put(ctx, key, raw, etag); e != nil { |
| + t.Fatal(e) |
| + } |
| +} |
| +func TestWireMalformedGuardAlwaysDenies(t *testing.T) { |
| + cases := map[string]func([]byte) []byte{ |
| + "unknown": func(b []byte) []byte { return append(b[:len(b)-1], []byte(`,"unknown":1}`)...) }, |
| + "duplicate": func(b []byte) []byte { return append(b[:len(b)-1], []byte(`,"version":1}`)...) }, |
| + "case alias": func(b []byte) []byte { return []byte(strings.Replace(string(b), `"version"`, `"Version"`, 1)) }, |
| + "unknown phase": func(b []byte) []byte { |
| + return []byte(strings.Replace(string(b), `"requested"`, `"awaiting-approval"`, 1)) |
| + }, |
| + "zero generation": func(b []byte) []byte { |
| + return []byte(strings.Replace(string(b), `"generation":1`, `"generation":0`, 1)) |
| + }, |
| + "negative generation": func(b []byte) []byte { |
| + return []byte(strings.Replace(string(b), `"generation":1`, `"generation":-1`, 1)) |
| + }, |
| + "overflow generation": func(b []byte) []byte { |
| + return []byte(strings.Replace(string(b), `"generation":1`, `"generation":9223372036854775808`, 1)) |
| + }, |
| + "fraction": func(b []byte) []byte { |
| + return []byte(strings.Replace(string(b), `"generation":1`, `"generation":1.5`, 1)) |
| + }, |
| + "version": func(b []byte) []byte { return []byte(strings.Replace(string(b), `"version":1`, `"version":2`, 1)) }, |
| + "trailing": func(b []byte) []byte { return append(b, []byte(` {}`)...) }, |
| + "null": func([]byte) []byte { return []byte(`null`) }, |
| + "deep": func([]byte) []byte { return []byte(strings.Repeat("[", 1000) + strings.Repeat("]", 1000)) }, |
| + "oversize": func([]byte) []byte { return []byte(strings.Repeat(" ", guard.MaxWireBytes+1)) }, |
| + "null reference": func(b []byte) []byte { |
| + return []byte(strings.Replace(string(b), `"required_contracts":[]`, `"required_contracts":null`, 1)) |
| + }, |
| + } |
| + for name, mutate := range cases { |
| + t.Run(name, func(t *testing.T) { |
| + f := setup(t) |
| + c := f.claim(t, 1) |
| + s := f.create(t, c) |
| + p, _ := s.Projection() |
| + key, _ := guard.GuardKey(f.identity.Host) |
| + raw, _, e := f.store.Get(ctx, key) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + replace(t, f, key, mutate(raw)) |
| + if _, e = f.repo.ReadGuard(ctx, f.identity.Host); e == nil { |
| + t.Fatal("invalid guard parsed") |
| + } |
| + denied(t, f, &p, nil) |
| + }) |
| + } |
| +} |
| +func TestProjectionClosedAndBounded(t *testing.T) { |
| + p := guard.Projection{Version: 1, Generation: 3, GuardDigest: hash("guard")} |
| + b, e := guard.EncodeProjection(p) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if got, e := guard.DecodeProjection(b); e != nil || got != p { |
| + t.Fatal(got, e) |
| + } |
| + for _, raw := range []string{`null`, `{}`, `{"version":2,"generation":3,"guard_digest":"` + p.GuardDigest + `"}`, string(b[:len(b)-1]) + `,"version":1}`, string(b[:len(b)-1]) + `,"other":true}`, strings.Replace(string(b), `"version"`, `"Version"`, 1), strings.Repeat("x", guard.MaxWireBytes+1)} { |
| + if _, e := guard.DecodeProjection([]byte(raw)); e == nil { |
| + t.Fatal("invalid projection parsed") |
| + } |
| + } |
| +} |
| +func TestLifecycleRecoveryNoTimeoutAndRetainedHistory(t *testing.T) { |
| + f := setup(t) |
| + c := f.claim(t, 1) |
| + restarted := guard.New(f.store) |
| + recovered, e := restarted.RecoverClaim(ctx, "aaa", "home", id(1), guard.MaintenanceOperation) |
| + if e != nil || recovered.Value() != c.Value() { |
| + t.Fatal(e) |
| + } |
| + for _, kind := range []guard.OperationKind{guard.TrashOperation, guard.RestoreOperation, guard.PurgeOperation, guard.MaintenanceOperation} { |
| + if _, e = restarted.AcquireClaim(ctx, "aaa", "home", id(2), kind); !errors.Is(e, guard.ErrBusy) { |
| + t.Fatal("occupied claim bypassed", e) |
| + } |
| + } |
| + if _, e = restarted.RecoverClaim(ctx, "aaa", "home", id(2), guard.MaintenanceOperation); !errors.Is(e, guard.ErrConflict) { |
| + t.Fatal(e) |
| + } |
| + if _, e = restarted.RecoverClaim(ctx, "aaa", "home", id(1), guard.PurgeOperation); !errors.Is(e, guard.ErrConflict) { |
| + t.Fatal(e) |
| + } |
| + released, e := restarted.ReleaseClaim(ctx, recovered) |
| + if e != nil || released.Value().Generation != 2 || released.Value().State != "idle" { |
| + t.Fatal(e) |
| + } |
| + next, e := restarted.AcquireClaim(ctx, "aaa", "home", id(2), guard.PurgeOperation) |
| + if e != nil || next.Value().Generation != 3 { |
| + t.Fatal(e) |
| + } |
| + if _, e = restarted.ReleaseClaim(ctx, recovered); !errors.Is(e, guard.ErrConflict) { |
| + t.Fatal("stale release cleared new claim", e) |
| + } |
| + if _, e = restarted.ReleaseClaim(ctx, next); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = restarted.AcquireClaim(ctx, "aaa", "home", id(1), guard.MaintenanceOperation); !errors.Is(e, guard.ErrConflict) { |
| + t.Fatal("old operation ID reused", e) |
| + } |
| + keys, e := f.store.List(ctx, "control/maintenance/apps/aaa/home/operations/") |
| + if e != nil || len(keys) != 4 { |
| + t.Fatal("immutable history missing", len(keys), e) |
| + } |
| +} |
| +func TestMalformedLifecycleAndOverflowRefuse(t *testing.T) { |
| + f := setup(t) |
| + c := f.claim(t, 1) |
| + key, _ := guard.LifecycleKey("aaa", "home") |
| + raw, _, e := f.store.Get(ctx, key) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + for _, bad := range []string{strings.Replace(string(raw), `"claimed"`, `"expired"`, 1), strings.Replace(string(raw), `"version":1`, `"version":2`, 1), string(raw[:len(raw)-1]) + `,"expires_at":1}`} { |
| + replace(t, f, key, []byte(bad)) |
| + if _, e = f.repo.AcquireClaim(ctx, "aaa", "home", id(2), guard.PurgeOperation); e == nil { |
| + t.Fatal("malformed claim acquired") |
| + } |
| + } |
| + replace(t, f, key, raw) |
| + l := c.Value() |
| + l.Generation = math.MaxInt64 |
| + l.ClaimGeneration = math.MaxInt64 |
| + b, _ := json.Marshal(l) |
| + replace(t, f, key, b) |
| + intentKey := "control/maintenance/apps/aaa/home/operations/" + id(1) + "/claim.json" |
| + var intent map[string]json.RawMessage |
| + old, _, e := f.store.Get(ctx, intentKey) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = json.Unmarshal(old, &intent); e != nil { |
| + t.Fatal(e) |
| + } |
| + intent["generation"] = []byte("9223372036854775807") |
| + b, _ = json.Marshal(intent) |
| + replace(t, f, intentKey, b) |
| + loaded, e := f.repo.ReadLifecycle(ctx, "aaa", "home") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = f.repo.ReleaseClaim(ctx, loaded); !errors.Is(e, guard.ErrConflict) { |
| + t.Fatal("generation overflow", e) |
| + } |
| +} |
| +func TestGuardGenerationOverflowRefuses(t *testing.T) { |
| + f := setup(t) |
| + c := f.claim(t, 1) |
| + s := f.create(t, c) |
| + g := s.Value() |
| + g.Generation = math.MaxInt64 |
| + b, _ := json.Marshal(g) |
| + key, _ := guard.GuardKey(g.Identity.Host) |
| + replace(t, f, key, b) |
| + s, e := f.repo.ReadGuard(ctx, g.Identity.Host) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + g.Phase = guard.Quiescing |
| + if _, e = f.repo.AdvanceGuard(ctx, c, s, g); !errors.Is(e, guard.ErrConflict) { |
| + t.Fatal("guard generation overflow", e) |
| + } |
| +} |
| +func TestIdentityFramingKeysAndContracts(t *testing.T) { |
| + at := time.Date(2026, 9, 8, 0, 1, 2, 3, time.FixedZone("offset", 3600)) |
| + a, e := guard.RecordIdentity("aaa", "bc", "one.home.example", at) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + same, e := guard.RecordIdentity("aaa", "bc", "one.home.example", at.UTC()) |
| + if e != nil || same != a { |
| + t.Fatal("UTC identity differs") |
| + } |
| + other, _ := guard.RecordIdentity("aaab", "c", "one.home.example", at) |
| + if other == a { |
| + t.Fatal("unframed identity collision") |
| + } |
| + if _, e = guard.RecordIdentity("aaa", "home", "one.home.example", time.Time{}); e == nil { |
| + t.Fatal("zero creation") |
| + } |
| + if _, e = guard.RecordIdentity("aaa", "home", "one.home.example", time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC)); e == nil { |
| + t.Fatal("non-RFC3339 creation") |
| + } |
| + for _, host := range []string{"", "../other", "A.example", "a.example:443", "a.example.", strings.Repeat("a", 254)} { |
| + if _, e := guard.GuardKey(host); e == nil { |
| + t.Fatal("host key accepted", host) |
| + } |
| + } |
| + for _, bad := range []string{"*", "Doc.v1", "docs..v1", "docs/v1", "docs-", "docs_v1", strings.Repeat("a", 65)} { |
| + if guard.ValidContract(bad) { |
| + t.Fatal("contract accepted", bad) |
| + } |
| + } |
| + set := []string{} |
| + for i := 0; i < 16; i++ { |
| + set = append(set, "docs.v"+string(rune('a'+i))) |
| + } |
| + if !guard.ValidContracts(set) || guard.ValidContracts(append(set, "docs.vz")) || guard.ValidContracts([]string{"docs.v1", "docs.v1"}) || guard.ValidContracts([]string{"z.v1", "a.v1"}) { |
| + t.Fatal("contract set bound/order") |
| + } |
| + if !guard.ValidID(id(1)) || guard.ValidID(id(1)+"=") || guard.ValidDigest(strings.ToUpper(hash("digest"))) { |
| + t.Fatal("canonical digest/id") |
| + } |
| +} |
| + |
| +func TestPermanentReferenceDigestMismatchDenies(t *testing.T) { |
| + f := setup(t) |
| + c := f.claim(t, 1) |
| + s := f.resumed(t, c) |
| + p, _ := s.Projection() |
| + key, _ := guard.RequestKey(f.identity.Host, s.Value().Request.ID) |
| + replace(t, f, key, []byte(`{"metadata":"changed outside immutable writer"}`)) |
| + denied(t, f, &p, []string{"home.operator.v1"}) |
| +} |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index 83b848e31..887cb538a 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -11,6 +11,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "github.com/carlosframework/platform/internal/controlname" |
| "net" |
| "os" |
| "path/filepath" |
| @@ -233,11 +234,6 @@ func ParseRecordKey(key string, known map[string]bool) (acct, app, host string, |
| return acct, app, host, true |
| } |
| -// labelRe pins one DNS label the same way domains.accountNameRe pins an |
| -// account name: lowercase a-z, 0-9, hyphen, no leading or trailing hyphen, |
| -// 1-63 characters (the DNS label ceiling). |
| -var labelRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) |
| - |
| // ValidHost reports whether host is exactly one label followed by |
| // "."+domain. This is path-traversal defense stated as such: the host |
| // becomes a bucket key segment (RecordKey) and, on the box, the |
| @@ -252,7 +248,7 @@ func ValidHost(host, domain string) bool { |
| if !ok || label == "" { |
| return false |
| } |
| - return labelRe.MatchString(label) |
| + return controlname.ValidLabel(label) |
| } |
| // ValidHostShape checks host's shape alone, with no domain to validate a |
| @@ -260,17 +256,7 @@ func ValidHost(host, domain string) bool { |
| // its app's manifest (and so its domain) is gone: a delete, for instance, |
| // which must work even if the app disabled instances since the record was |
| // created. |
| -func ValidHostShape(host string) bool { |
| - if host == "" || len(host) > 253 { |
| - return false |
| - } |
| - for _, label := range strings.Split(host, ".") { |
| - if !labelRe.MatchString(label) { |
| - return false |
| - } |
| - } |
| - return true |
| -} |
| +func ValidHostShape(host string) bool { return controlname.ValidHostShape(host) } |
| // KindOrDefault resolves the record's route class. "" means "instance" — |
| // the class reconcile minted unconditionally before Kind existed, and so |
diff --git a/internal/release/qualified.go b/internal/release/qualified.go| index b3fb46b00..cb5c3a076 100644 |
| --- a/internal/release/qualified.go |
| +++ b/internal/release/qualified.go |
| @@ -25,8 +25,8 @@ import ( |
| "context" |
| "errors" |
| "fmt" |
| + "github.com/carlosframework/platform/internal/controlname" |
| "os" |
| - "regexp" |
| "strings" |
| ) |
| @@ -49,15 +49,8 @@ const OpsAccount = "bab" |
| // becomes a real account's name. |
| const LegacySource = "legacy" |
| -// acctRe pins an account sqid to what the frozen encoder emits: lowercase |
| -// letters only, at least three of them (the encoder's offset guarantees it). |
| -// It exists so a caller can't smuggle a path segment — or an empty string — |
| -// into a bucket key. |
| -var acctRe = regexp.MustCompile(`^[a-z]{3,16}$`) |
| - |
| -// ValidAccount reports whether a is usable as the account segment of a bucket |
| -// key. |
| -func ValidAccount(a string) bool { return acctRe.MatchString(a) } |
| +// ValidAccount reports whether a is usable as an account key segment. |
| +func ValidAccount(a string) bool { return controlname.ValidAccount(a) } |
| // GetQualified reads an account-qualified key. Not-found stays |
| // os.ErrNotExist-wrapped (every caller's "has this app ever shipped here" |
diff --git a/internal/release/release.go b/internal/release/release.go| index b694c2fca..27b332bb3 100644 |
| --- a/internal/release/release.go |
| +++ b/internal/release/release.go |
| @@ -31,6 +31,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "github.com/carlosframework/platform/internal/controlname" |
| "os" |
| "regexp" |
| "strings" |
| @@ -236,12 +237,7 @@ func LegacyAppPrefix(app string) string { return legacyAppPrefix(app) } |
| func legacyAppPrefix(app string) string { return "apps/" + app + "/" } |
| -// appRe pins app names to the boring lowercase words the box maps to |
| -// /opt/carlos paths — anything fancier is a manifest bug, not a feature. |
| -// (Eleven release.go's serviceNameRe.) |
| -var appRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`) |
| - |
| -func ValidApp(a string) bool { return appRe.MatchString(a) } |
| +func ValidApp(a string) bool { return controlname.ValidApp(a) } |
| // channelSegRe is one channel path segment. |
| var channelSegRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) |