console: one policy writer per bucket, on the platform's lease (#328, #329)
Ruling 10: the app policy writer lease, internal/lease's lock at control/app-policy-lease/lock.json in the deployment bucket. Lazy, then long-lived; numbered holdings; policyAttempt binds every signing and publication attempt to the same live holding inside its safe deadline (Astra plan R3-1). A directory deployment is the sole writer; a bucket deployment without the leaser refuses. cmd/console wires ProductionPolicyLeaser from CARLOS_DEPLOYMENT_BUCKET and releases the lease on SIGTERM or interrupt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+677
−1
cmd/console/main.go+27 −0cmd/console/wiring_test.go+27 −1internal/console/policylease.go+249 −0internal/console/policylease_test.go+359 −0internal/console/server.go+15 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go| index d78578a29..1c02fb51f 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -129,10 +129,12 @@ import ( |
| "math/rand/v2" |
| "net" |
| "os" |
| + "os/signal" |
| "path/filepath" |
| "sort" |
| "strconv" |
| "strings" |
| + "syscall" |
| "time" |
| "unicode/utf8" |
| @@ -407,6 +409,10 @@ func serve() { |
| Bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"), |
| HomeRegion: strings.TrimSpace(os.Getenv("CONSOLE_HOME_REGION")), |
| RegionHA: regionHAEnabled(os.Getenv), |
| + // The app policy writer lease (ruling 10), in the deployment bucket. A |
| + // CARLOS_DEPLOYMENT_DIR deployment has none: its console is the sole |
| + // writer. |
| + PolicyLease: policyLeaseFor(os.Getenv), |
| // The deployment's AWS account id, the trust anchor of the Phase 1 |
| // move door's box identity check (unset: no box verifies). |
| AWSAccount: strings.TrimSpace(os.Getenv("CARLOS_AWS_ACCOUNT")), |
| @@ -521,6 +527,17 @@ func serve() { |
| if err != nil { |
| log.Fatal(err) |
| } |
| + // A graceful stop releases the app policy writer lease, so a rolled |
| + // console's successor need not wait out its TTL (the housekeeper's |
| + // "graceful stand-down must be reachable"). Nothing else here shuts down |
| + // gracefully, so the handler exits straight after the release. |
| + go func() { |
| + sig := make(chan os.Signal, 1) |
| + signal.Notify(sig, syscall.SIGTERM, os.Interrupt) |
| + <-sig |
| + srv.ReleasePolicyWriter(context.Background()) |
| + os.Exit(0) |
| + }() |
| // The console mailer's identity ensure (consoleMailer above) — makes or |
| // re-reads the sending domain's SES/DKIM identity. Started here, not in |
| // NewServer, same reasoning as the alert watcher below: constructing a |
| @@ -1759,6 +1776,16 @@ func sweepMailBoundaries(ctx context.Context, claims bool, p mail.Provisioner, l |
| logf("console: mail boundary sweep: all %d mail users bounded (%d already, %d fixed)", res.Total, res.Bounded, len(res.Fixed)) |
| } |
| +// policyLeaseFor is the app policy writer's leaser: the production one on a |
| +// deployment with a bucket (CARLOS_DEPLOYMENT_BUCKET), nil on a |
| +// CARLOS_DEPLOYMENT_DIR deployment, whose console is the sole writer. |
| +func policyLeaseFor(getenv func(string) string) console.PolicyLeaser { |
| + if getenv("CARLOS_DEPLOYMENT_BUCKET") == "" { |
| + return nil |
| + } |
| + return console.ProductionPolicyLeaser |
| +} |
| + |
| // regionHAEnabled is CARLOS_REGION_HA as strconv.ParseBool reads it: on |
| // for "1", "t", "true" in any case, off for "", "0", "false" and anything |
| // unparseable. The same rule as the box's (internal/host/boxrecord.go). |
diff --git a/cmd/console/wiring_test.go b/cmd/console/wiring_test.go| index 415e59a8f..64bf748f5 100644 |
| --- a/cmd/console/wiring_test.go |
| +++ b/cmd/console/wiring_test.go |
| @@ -1,6 +1,11 @@ |
| package main |
| -import "testing" |
| +import ( |
| + "reflect" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/console" |
| +) |
| // The wiring resolver, whose refusals are otherwise a fatal path nobody can |
| // exercise. |
| @@ -96,3 +101,24 @@ func TestDBDoorWiringIsOffWithoutABucket(t *testing.T) { |
| t.Fatalf("bucket = %q, want the door off", bucket) |
| } |
| } |
| + |
| +// The policy writer lease (ruling 10) follows the deployment bucket: a |
| +// bucket deployment gets the production leaser, a CARLOS_DEPLOYMENT_DIR one |
| +// none (its console is the sole writer by construction). In this file rather |
| +// than main_test.go because that file is package main_test and cannot reach |
| +// policyLeaseFor. |
| +func TestPolicyLeaseForFollowsTheDeploymentBucket(t *testing.T) { |
| + env := func(m map[string]string) func(string) string { |
| + return func(k string) string { return m[k] } |
| + } |
| + got := policyLeaseFor(env(map[string]string{"CARLOS_DEPLOYMENT_BUCKET": "deploy"})) |
| + if got == nil { |
| + t.Fatal("a bucket deployment has no policy leaser") |
| + } |
| + if reflect.ValueOf(got).Pointer() != reflect.ValueOf(console.ProductionPolicyLeaser).Pointer() { |
| + t.Fatal("a bucket deployment's policy leaser is not console.ProductionPolicyLeaser") |
| + } |
| + if got := policyLeaseFor(env(map[string]string{"CARLOS_DEPLOYMENT_DIR": "/srv/deploy"})); got != nil { |
| + t.Fatal("a directory deployment has a policy leaser") |
| + } |
| +} |
diff --git a/internal/console/policylease.go b/internal/console/policylease.go| new file mode 100644 |
| index 000000000..cf8d7b33a |
| --- /dev/null |
| +++ b/internal/console/policylease.go |
| @@ -0,0 +1,249 @@ |
| +// The policy writer lease: one console signs app policies per deployment |
| +// bucket (spec 2026-10-21-instance-sizes-and-egress-design.md, ruling 10). |
| +// Two consoles signing the same app's policy would each build on what they |
| +// last saw, and the later write would silently undo the earlier one, a revoke |
| +// included. So nothing signs a policy, by door write or re-sign, without this |
| +// lease, and a second console refuses and names the holder. |
| +// |
| +// The mechanism is internal/lease, the platform's one election (litestream's |
| +// conditional-write lock.json), already reused by the housekeeper under the |
| +// rule "no second election pattern may be introduced" (internal/host/ |
| +// housekeeper.go). There is no store-CAS record of our own and no bespoke |
| +// TTL: the lock is control/app-policy-lease/lock.json in the deployment |
| +// bucket, held for lease.LeaseTTL and renewed by internal/lease every TTL/3. |
| +// |
| +// It is lazy, then long-lived. Nothing is acquired at start, so a deployment |
| +// that never writes a policy or creates an app sends no lease requests at all. |
| +// The first need (a door PUT, a re-sign pass with something to sign, an app |
| +// create) acquires it, and it is kept for as long as renewal wins. While held |
| +// it costs one conditional PUT per renewal, 288 a day for the one writer |
| +// console, outside release.Store and independent of apps and boxes. A refused |
| +// console tries again only on its next need, never on a timer. |
| +// |
| +// A holding is numbered. Each successful acquisition is a new holding, one |
| +// above the last, and a new holding means "another console may have written |
| +// since I last did": the door (plan Task 9) uses the number to decide when to |
| +// rebuild on the bucket's verified record instead of its own last write. |
| +// |
| +// Every write is bound to its holding (Astra plan R3-1). A writer keeps the |
| +// holding policyWriter gave it and calls policyAttempt before each signing and |
| +// each publication attempt, retries included. A write that outlived its |
| +// holding could overwrite the next writer's content, a revoke among it, so the |
| +// attempt is refused unless the same holding is still current, its lease is |
| +// healthy and its safe deadline is at least policyAttemptWindow away, and the |
| +// attempt then runs under a context that ends at that window, at the safe |
| +// deadline, or the moment the lease reports Lost. The bound: SafeUntil is |
| +// lease.RenewMargin (5 s) before the lock's expiry and no other console can |
| +// acquire before that expiry, so an attempt either reaches the store while |
| +// this console still holds the lock or is cancelled. A request the network |
| +// holds back past its own cancellation is the residual; a later holder's |
| +// conditional write then sees a changed ETag (Task 9). |
| +// |
| +// Release is reachable: cmd/console calls ReleasePolicyWriter on SIGTERM, so |
| +// a rolled console's successor need not wait out the TTL. Where the console's |
| +// role is denied s3:DeleteObject on the bucket (the flagship's store IAM Deny; |
| +// plan 2026-10-21-instance-policy-1-foundation, "Boundary verification"), the |
| +// release fails, is logged, and the successor waits up to lease.LeaseTTL, 15 |
| +// minutes. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/lease" |
| +) |
| + |
| +// appPolicyLeasePrefix is where the lock lands in the deployment bucket: |
| +// <prefix>/lock.json. |
| +const appPolicyLeasePrefix = "control/app-policy-lease" |
| + |
| +// policyAttemptWindow is the longest one signing or publication attempt may |
| +// run, and the least time its holding must have left before its safe deadline. |
| +const policyAttemptWindow = 10 * time.Second |
| + |
| +// policyLeaseReleaseBudget bounds a release, as the housekeeper's does. |
| +const policyLeaseReleaseBudget = 5 * time.Second |
| + |
| +// Door copy (collected for review in plan Task 15). |
| +const ( |
| + policyLeaseHeldMsg = "another console (%s) is the policy writer for this deployment until %s. Make the change there, or try again after that" |
| + policyLeaseUnavailableMsg = "could not take the policy writer lease. Try again" |
| + policyLeaseMissingMsg = "this console is not set up to write app policies (no writer lease). Deploy a console built from current main" |
| + policyLeaseLostMsg = "this console stopped being the policy writer during the change, so it may not have been published. Read the policy again before you retry" |
| +) |
| + |
| +// PolicyLease is the slice of *lease.Held the writer uses. |
| +type PolicyLease interface { |
| + Err() error |
| + Release(ctx context.Context) error |
| + Lost() <-chan struct{} |
| + SafeUntil() (time.Time, error) |
| +} |
| + |
| +var _ PolicyLease = (*lease.Held)(nil) |
| + |
| +// PolicyLeaser acquires the policy writer lease: Config.PolicyLease. |
| +type PolicyLeaser func(ctx context.Context, bucket, prefix string, ttl time.Duration) (PolicyLease, error) |
| + |
| +// ProductionPolicyLeaser is lease.AcquireTTL with the region left to the AWS |
| +// default chain, the same answer release.NewS3Store signs for against this |
| +// bucket (acquireDeploymentLease's reasoning). A func, not a method value, |
| +// because AcquireTTL returns a typed nil *lease.Held with its error, which |
| +// would arrive as a non-nil PolicyLease. |
| +func ProductionPolicyLeaser(ctx context.Context, bucket, prefix string, ttl time.Duration) (PolicyLease, error) { |
| + h, err := lease.AcquireTTL(ctx, bucket, prefix, "", ttl) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return h, nil |
| +} |
| + |
| +// policyHolding is one acquisition of the writer lease. n counts holdings |
| +// from 1; sole is a directory deployment's console, the only writer by |
| +// construction, whose lease is nil. |
| +type policyHolding struct { |
| + n uint64 |
| + sole bool |
| + lease PolicyLease |
| +} |
| + |
| +// policyWriter returns the current holding, acquiring the lease if there is |
| +// none or the last one was lost. The caller keeps the holding for its whole |
| +// write and checks it with policyAttempt before every signing and publication. |
| +func (s *Server) policyWriter(ctx context.Context) (policyHolding, *doorRefusal) { |
| + if s.cfg.Bucket == "" { |
| + return policyHolding{n: 1, sole: true}, nil |
| + } |
| + if s.cfg.PolicyLease == nil { |
| + return policyHolding{}, &doorRefusal{http.StatusServiceUnavailable, policyLeaseMissingMsg} |
| + } |
| + s.policyLeaseMu.Lock() |
| + defer s.policyLeaseMu.Unlock() |
| + if cur := s.policyCur.Load(); cur != nil { |
| + err := cur.lease.Err() |
| + if err == nil { |
| + return *cur, nil |
| + } |
| + s.endPolicyHolding(cur, err) |
| + } |
| + ttl := s.cfg.PolicyLeaseTTL |
| + if ttl == 0 { |
| + ttl = lease.LeaseTTL |
| + } |
| + l, err := s.cfg.PolicyLease(ctx, s.cfg.Bucket, appPolicyLeasePrefix, ttl) |
| + if err == nil && l == nil { |
| + err = errors.New("the leaser returned no lease") |
| + } |
| + if err != nil { |
| + var held *lease.HeldError |
| + if errors.As(err, &held) { |
| + return policyHolding{}, &doorRefusal{http.StatusServiceUnavailable, |
| + fmt.Sprintf(policyLeaseHeldMsg, held.Owner, held.ExpiresAt.UTC().Format(time.RFC3339))} |
| + } |
| + log.Printf("console: policy writer lease: %v", err) |
| + return policyHolding{}, &doorRefusal{http.StatusServiceUnavailable, policyLeaseUnavailableMsg} |
| + } |
| + s.policyN++ |
| + h := &policyHolding{n: s.policyN, lease: l} |
| + s.policyCur.Store(h) |
| + return *h, nil |
| +} |
| + |
| +// policyAttempt is the check before each signing and each publication |
| +// attempt (Astra plan R3-1). It refuses unless h is still the current holding |
| +// and its lease is healthy with at least policyAttemptWindow left before its |
| +// safe deadline; otherwise it returns the context the attempt must run under, |
| +// which ends at the window, at the safe deadline, or when the lease is lost. |
| +// An attempt cut off by that context is this same refusal, not a store |
| +// failure. A sole holding is never refused and its context is ctx itself. |
| +func (s *Server) policyAttempt(ctx context.Context, h policyHolding) (context.Context, context.CancelFunc, *doorRefusal) { |
| + if h.sole { |
| + return ctx, func() {}, nil |
| + } |
| + lost := &doorRefusal{http.StatusServiceUnavailable, policyLeaseLostMsg} |
| + cur := s.policyCur.Load() |
| + if cur == nil || cur.n != h.n || h.lease == nil { |
| + return ctx, func() {}, lost |
| + } |
| + if err := h.lease.Err(); err != nil { |
| + s.endPolicyHolding(cur, err) |
| + return ctx, func() {}, lost |
| + } |
| + select { |
| + case <-h.lease.Lost(): |
| + s.endPolicyHolding(cur, errors.New("the lease reported lost")) |
| + return ctx, func() {}, lost |
| + default: |
| + } |
| + safe, err := h.lease.SafeUntil() |
| + if err != nil { |
| + s.endPolicyHolding(cur, err) |
| + return ctx, func() {}, lost |
| + } |
| + now := time.Now() |
| + if safe.Sub(now) < policyAttemptWindow { |
| + s.endPolicyHolding(cur, fmt.Errorf("safe until %s, less than %s away", safe.UTC().Format(time.RFC3339), policyAttemptWindow)) |
| + return ctx, func() {}, lost |
| + } |
| + deadline := now.Add(policyAttemptWindow) |
| + if safe.Before(deadline) { |
| + deadline = safe |
| + } |
| + actx, cancel := context.WithDeadline(ctx, deadline) |
| + go func() { |
| + select { |
| + case <-h.lease.Lost(): |
| + cancel() |
| + case <-actx.Done(): |
| + } |
| + }() |
| + return actx, cancel, nil |
| +} |
| + |
| +// policyHeld reports, without blocking, whether this console holds the |
| +// writer lease and its renewal is healthy. A sole console always does. |
| +func (s *Server) policyHeld() bool { |
| + if s.cfg.Bucket == "" { |
| + return true |
| + } |
| + if s.cfg.PolicyLease == nil { |
| + return false |
| + } |
| + cur := s.policyCur.Load() |
| + return cur != nil && cur.lease.Err() == nil |
| +} |
| + |
| +// ReleasePolicyWriter ends the holding and releases its lease within |
| +// policyLeaseReleaseBudget, so a successor console need not wait out the TTL. |
| +// Nothing held is a no-op. It does not take policyLeaseMu, so a stand-down |
| +// never waits on an acquisition in flight. |
| +func (s *Server) ReleasePolicyWriter(ctx context.Context) { |
| + cur := s.policyCur.Swap(nil) |
| + if cur == nil { |
| + return |
| + } |
| + releasePolicyLease(ctx, cur.lease) |
| +} |
| + |
| +// endPolicyHolding ends cur if it is still the current holding, logging why |
| +// and releasing its lease best-effort. Only the caller that ends it releases. |
| +func (s *Server) endPolicyHolding(cur *policyHolding, why error) { |
| + if !s.policyCur.CompareAndSwap(cur, nil) { |
| + return |
| + } |
| + log.Printf("console: policy writer lease lost: %v", why) |
| + releasePolicyLease(context.Background(), cur.lease) |
| +} |
| + |
| +func releasePolicyLease(ctx context.Context, l PolicyLease) { |
| + ctx, cancel := context.WithTimeout(ctx, policyLeaseReleaseBudget) |
| + defer cancel() |
| + if err := l.Release(ctx); err != nil { |
| + log.Printf("console: policy writer lease release: %v", err) |
| + } |
| +} |
diff --git a/internal/console/policylease_test.go b/internal/console/policylease_test.go| new file mode 100644 |
| index 000000000..563939912 |
| --- /dev/null |
| +++ b/internal/console/policylease_test.go |
| @@ -0,0 +1,359 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "net/http" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/lease" |
| +) |
| + |
| +// fakePolicyLease is a PolicyLease whose health the test sets. |
| +type fakePolicyLease struct { |
| + mu sync.Mutex |
| + err error |
| + safe time.Time |
| + safeErr error |
| + lost chan struct{} |
| + releases int |
| +} |
| + |
| +func newFakePolicyLease() *fakePolicyLease { |
| + return &fakePolicyLease{safe: time.Now().Add(10 * time.Minute), lost: make(chan struct{})} |
| +} |
| + |
| +func (f *fakePolicyLease) Err() error { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + return f.err |
| +} |
| + |
| +func (f *fakePolicyLease) Release(context.Context) error { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + f.releases++ |
| + return nil |
| +} |
| + |
| +func (f *fakePolicyLease) Lost() <-chan struct{} { return f.lost } |
| + |
| +func (f *fakePolicyLease) SafeUntil() (time.Time, error) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + return f.safe, f.safeErr |
| +} |
| + |
| +func (f *fakePolicyLease) set(err error, safe time.Time, safeErr error) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + f.err, f.safe, f.safeErr = err, safe, safeErr |
| +} |
| + |
| +func (f *fakePolicyLease) released() int { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + return f.releases |
| +} |
| + |
| +type policyLeaseCall struct { |
| + bucket, prefix string |
| + ttl time.Duration |
| +} |
| + |
| +// fakePolicyLeaser records its calls and hands out a fresh fake lease per |
| +// call, or fails with err when it is set. |
| +type fakePolicyLeaser struct { |
| + mu sync.Mutex |
| + calls []policyLeaseCall |
| + leases []*fakePolicyLease |
| + err error |
| +} |
| + |
| +func (f *fakePolicyLeaser) leaser(_ context.Context, bucket, prefix string, ttl time.Duration) (PolicyLease, error) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + f.calls = append(f.calls, policyLeaseCall{bucket, prefix, ttl}) |
| + if f.err != nil { |
| + return nil, f.err |
| + } |
| + l := newFakePolicyLease() |
| + f.leases = append(f.leases, l) |
| + return l, nil |
| +} |
| + |
| +func (f *fakePolicyLeaser) callCount() int { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + return len(f.calls) |
| +} |
| + |
| +func (f *fakePolicyLeaser) lease(i int) *fakePolicyLease { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + return f.leases[i] |
| +} |
| + |
| +func policyLeaseServer(f *fakePolicyLeaser) *Server { |
| + return &Server{cfg: Config{Bucket: "deploy", PolicyLease: f.leaser}} |
| +} |
| + |
| +func wantPolicyRefusal(t *testing.T, ref *doorRefusal, msg string) { |
| + t.Helper() |
| + if ref == nil { |
| + t.Fatalf("no refusal, want 503 %q", msg) |
| + } |
| + if ref.Status != http.StatusServiceUnavailable || ref.Msg != msg { |
| + t.Fatalf("refusal %d %q, want 503 %q", ref.Status, ref.Msg, msg) |
| + } |
| +} |
| + |
| +func TestPolicyWriterIsSoleWithoutABucket(t *testing.T) { |
| + t.Parallel() |
| + f := &fakePolicyLeaser{} |
| + s := &Server{cfg: Config{PolicyLease: f.leaser}} |
| + h, ref := s.policyWriter(context.Background()) |
| + if ref != nil { |
| + t.Fatalf("refused: %+v", ref) |
| + } |
| + if h.n != 1 || !h.sole || h.lease != nil { |
| + t.Fatalf("holding %+v, want {1 true nil}", h) |
| + } |
| + if f.callCount() != 0 { |
| + t.Fatalf("the leaser was called %d times on a directory deployment", f.callCount()) |
| + } |
| + if !s.policyHeld() { |
| + t.Fatal("policyHeld false on a sole console") |
| + } |
| +} |
| + |
| +func TestPolicyWriterRefusesWithoutALeaser(t *testing.T) { |
| + t.Parallel() |
| + s := &Server{cfg: Config{Bucket: "deploy"}} |
| + _, ref := s.policyWriter(context.Background()) |
| + wantPolicyRefusal(t, ref, policyLeaseMissingMsg) |
| + if s.policyHeld() { |
| + t.Fatal("policyHeld true without a leaser") |
| + } |
| +} |
| + |
| +func TestPolicyWriterAcquiresLazilyAndKeepsTheHolding(t *testing.T) { |
| + t.Parallel() |
| + f := &fakePolicyLeaser{} |
| + s := policyLeaseServer(f) |
| + if f.callCount() != 0 || s.policyHeld() { |
| + t.Fatalf("a new server: %d calls, held %v", f.callCount(), s.policyHeld()) |
| + } |
| + |
| + h, ref := s.policyWriter(context.Background()) |
| + if ref != nil { |
| + t.Fatalf("refused: %+v", ref) |
| + } |
| + want := policyLeaseCall{"deploy", "control/app-policy-lease", lease.LeaseTTL} |
| + if f.callCount() != 1 || f.calls[0] != want { |
| + t.Fatalf("calls %+v, want [%+v]", f.calls, want) |
| + } |
| + if h.n != 1 || h.sole || h.lease != PolicyLease(f.lease(0)) { |
| + t.Fatalf("holding %+v", h) |
| + } |
| + if !s.policyHeld() { |
| + t.Fatal("policyHeld false while held") |
| + } |
| + |
| + h, ref = s.policyWriter(context.Background()) |
| + if ref != nil || h.n != 1 || f.callCount() != 1 { |
| + t.Fatalf("second call: holding %+v, refusal %+v, %d calls", h, ref, f.callCount()) |
| + } |
| + |
| + f.lease(0).set(errors.New("renewal lost"), time.Now().Add(10*time.Minute), nil) |
| + if s.policyHeld() { |
| + t.Fatal("policyHeld true once Err is set") |
| + } |
| + h, ref = s.policyWriter(context.Background()) |
| + if ref != nil { |
| + t.Fatalf("refused after a lost renewal: %+v", ref) |
| + } |
| + if got := f.lease(0).released(); got != 1 { |
| + t.Fatalf("the lost lease was released %d times, want 1", got) |
| + } |
| + if f.callCount() != 2 || h.n != 2 || h.lease != PolicyLease(f.lease(1)) { |
| + t.Fatalf("after loss: %d calls, holding %+v", f.callCount(), h) |
| + } |
| + if !s.policyHeld() { |
| + t.Fatal("policyHeld false on the fresh holding") |
| + } |
| +} |
| + |
| +func TestPolicyWriterNamesTheHolder(t *testing.T) { |
| + t.Parallel() |
| + t1 := time.Date(2026, 10, 25, 14, 30, 0, 0, time.FixedZone("x", 3600)) |
| + f := &fakePolicyLeaser{err: fmt.Errorf("acquire lease: %w", &lease.HeldError{Owner: "console-b:4242", ExpiresAt: t1})} |
| + s := policyLeaseServer(f) |
| + _, ref := s.policyWriter(context.Background()) |
| + if ref == nil || ref.Status != http.StatusServiceUnavailable { |
| + t.Fatalf("refusal %+v, want 503", ref) |
| + } |
| + if !strings.Contains(ref.Msg, "another console (console-b:4242)") || !strings.Contains(ref.Msg, t1.UTC().Format(time.RFC3339)) { |
| + t.Fatalf("refusal %q does not name the holder and its expiry", ref.Msg) |
| + } |
| + if ref.Msg != fmt.Sprintf(policyLeaseHeldMsg, "console-b:4242", "2026-10-25T13:30:00Z") { |
| + t.Fatalf("refusal %q", ref.Msg) |
| + } |
| + if s.policyHeld() { |
| + t.Fatal("policyHeld true after a refused acquire") |
| + } |
| + |
| + f.mu.Lock() |
| + f.err = errors.New("dial tcp: no route to host") |
| + f.mu.Unlock() |
| + _, ref = s.policyWriter(context.Background()) |
| + wantPolicyRefusal(t, ref, policyLeaseUnavailableMsg) |
| +} |
| + |
| +func TestReleasePolicyWriterEndsTheHolding(t *testing.T) { |
| + t.Parallel() |
| + f := &fakePolicyLeaser{} |
| + s := policyLeaseServer(f) |
| + s.ReleasePolicyWriter(context.Background()) // nothing held: a no-op |
| + if _, ref := s.policyWriter(context.Background()); ref != nil { |
| + t.Fatalf("refused: %+v", ref) |
| + } |
| + s.ReleasePolicyWriter(context.Background()) |
| + if got := f.lease(0).released(); got != 1 { |
| + t.Fatalf("released %d times, want 1", got) |
| + } |
| + if s.policyHeld() { |
| + t.Fatal("policyHeld true after ReleasePolicyWriter") |
| + } |
| + s.ReleasePolicyWriter(context.Background()) |
| + if got := f.lease(0).released(); got != 1 { |
| + t.Fatalf("a second ReleasePolicyWriter released again: %d", got) |
| + } |
| +} |
| + |
| +// Astra plan R3-1. Fails if any check is dropped. |
| +func TestPolicyAttemptBindsToTheSameLiveHolding(t *testing.T) { |
| + t.Parallel() |
| + bg := context.Background() |
| + |
| + // Sole: the caller's context, untouched. |
| + sole := &Server{} |
| + sh, _ := sole.policyWriter(bg) |
| + actx, cancel, ref := sole.policyAttempt(bg, sh) |
| + if ref != nil || actx != bg { |
| + t.Fatalf("sole: ctx %v, refusal %+v", actx, ref) |
| + } |
| + if _, ok := actx.Deadline(); ok { |
| + t.Fatal("sole: a deadline was set") |
| + } |
| + cancel() |
| + |
| + f := &fakePolicyLeaser{} |
| + s := policyLeaseServer(f) |
| + attempt := func(h policyHolding) (context.Context, *doorRefusal) { |
| + t.Helper() |
| + actx, cancel, ref := s.policyAttempt(bg, h) |
| + t.Cleanup(cancel) |
| + return actx, ref |
| + } |
| + wantWindow := func(actx context.Context, before time.Time) { |
| + t.Helper() |
| + dl, ok := actx.Deadline() |
| + if !ok { |
| + t.Fatal("no deadline on the attempt context") |
| + } |
| + if dl.Before(before.Add(policyAttemptWindow)) || dl.After(time.Now().Add(policyAttemptWindow)) { |
| + t.Fatalf("deadline %v is not policyAttemptWindow from now (%v)", dl, before) |
| + } |
| + } |
| + |
| + h1, ref := s.policyWriter(bg) |
| + if ref != nil { |
| + t.Fatalf("refused: %+v", ref) |
| + } |
| + l1 := f.lease(0) |
| + |
| + // Healthy, safe 10 minutes out: the window bounds the attempt. |
| + before := time.Now() |
| + actx, ref = attempt(h1) |
| + if ref != nil { |
| + t.Fatalf("healthy holding refused: %+v", ref) |
| + } |
| + wantWindow(actx, before) |
| + |
| + // Safe 30 minutes out: still the window, never longer. |
| + l1.set(nil, time.Now().Add(30*time.Minute), nil) |
| + before = time.Now() |
| + actx, ref = attempt(h1) |
| + if ref != nil { |
| + t.Fatalf("refused: %+v", ref) |
| + } |
| + wantWindow(actx, before) |
| + |
| + // Safe inside the window: refused, and the holding ends. |
| + l1.set(nil, time.Now().Add(8*time.Second), nil) |
| + _, ref = attempt(h1) |
| + wantPolicyRefusal(t, ref, policyLeaseLostMsg) |
| + if l1.released() != 1 || s.policyHeld() { |
| + t.Fatalf("a refused holding did not end: released %d, held %v", l1.released(), s.policyHeld()) |
| + } |
| + |
| + // SafeUntil failing: refused. |
| + h2, ref := s.policyWriter(bg) |
| + if ref != nil || h2.n != 2 { |
| + t.Fatalf("holding %+v, refusal %+v", h2, ref) |
| + } |
| + l2 := f.lease(1) |
| + l2.set(nil, time.Now().Add(10*time.Minute), errors.New("lease spent")) |
| + _, ref = attempt(h2) |
| + wantPolicyRefusal(t, ref, policyLeaseLostMsg) |
| + |
| + // Err set: refused. |
| + h3, ref := s.policyWriter(bg) |
| + if ref != nil || h3.n != 3 { |
| + t.Fatalf("holding %+v, refusal %+v", h3, ref) |
| + } |
| + l3 := f.lease(2) |
| + l3.set(errors.New("renewal lost"), time.Now().Add(10*time.Minute), nil) |
| + _, ref = attempt(h3) |
| + wantPolicyRefusal(t, ref, policyLeaseLostMsg) |
| + |
| + // A later holding does not revive an earlier one, even one whose lease |
| + // looks healthy again. |
| + h4, ref := s.policyWriter(bg) |
| + if ref != nil || h4.n != 4 { |
| + t.Fatalf("holding %+v, refusal %+v", h4, ref) |
| + } |
| + l3.set(nil, time.Now().Add(10*time.Minute), nil) |
| + if _, ref = attempt(h3); ref == nil { |
| + t.Fatal("an earlier holding was accepted after a later one began") |
| + } |
| + wantPolicyRefusal(t, ref, policyLeaseLostMsg) |
| + if _, ref = attempt(h1); ref == nil { |
| + t.Fatal("holding 1 was accepted while holding 4 is current") |
| + } |
| + actx, ref = attempt(h4) |
| + if ref != nil { |
| + t.Fatalf("the live holding was refused: %+v", ref) |
| + } |
| + |
| + // The lease's Lost channel closing ends the attempt at once. |
| + select { |
| + case <-actx.Done(): |
| + t.Fatal("the attempt context ended before the lease was lost") |
| + default: |
| + } |
| + close(f.lease(3).lost) |
| + select { |
| + case <-actx.Done(): |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("the attempt context outlived the lease's Lost") |
| + } |
| + // And a lease already lost refuses the next attempt outright. |
| + _, ref = attempt(h4) |
| + wantPolicyRefusal(t, ref, policyLeaseLostMsg) |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 433d08e15..99e089fdc 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -520,6 +520,13 @@ type Config struct { |
| // struct. "" is legal — a local CARLOS_DEPLOYMENT_DIR deployment has no |
| // bucket name to draw — and simply draws no bucket node. |
| Bucket string |
| + // PolicyLease acquires the app policy writer lease in Bucket |
| + // (policylease.go, ruling 10): ProductionPolicyLeaser when Bucket is set. |
| + // With Bucket set and this nil, every policy write refuses 503; with |
| + // Bucket "" the console is the sole writer and this is never called. |
| + PolicyLease PolicyLeaser |
| + // PolicyLeaseTTL is the writer lease's TTL; 0 is lease.LeaseTTL. |
| + PolicyLeaseTTL time.Duration |
| // RegionHA is CARLOS_REGION_HA as strconv.ParseBool reads it (region HA |
| // spec 1 §8 step 2): with it off the console chooses no executor at |
| // create and relocation has no console half. A deployment fact, on |
| @@ -971,6 +978,14 @@ type Server struct { |
| // under doorPublishMu, so the zero Server needs no setup. |
| doorPublishMu sync.Mutex |
| doorPublish map[string]*sync.Mutex |
| + // The app policy writer lease (policylease.go). policyLeaseMu serializes |
| + // acquisition and the holding count policyN; policyCur is the current |
| + // holding, nil when none, read without the mutex so policyHeld and |
| + // policyAttempt never wait on an acquisition. The zero Server needs no |
| + // setup. |
| + policyLeaseMu sync.Mutex |
| + policyN uint64 |
| + policyCur atomic.Pointer[policyHolding] |
| // sendApp is how the alert watcher (alertwatch.go) delivers keymail: |
| // km.SendApp in production, a capture in tests. A field rather than a |
| // direct call so an alertPass assertion needs no keymail server. |