carlos / platform Public

activator, provision: a ConfigGate before the lease and before a mint (#213)

ExecBackend.Acquire asks the gate before lease.Acquire, the only place an
instance or sidecar lease is taken, so every wake source is covered. A
refusal is a quiet 503 with Retry-After 30. Reconcile and ReconcileSidecars
ask it before minting a record. Nil gate is today's behaviour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9a03d19eae9f40c6c825fc2d43e23a32dbfad182 parent 104e784
6 files changed, +273 −0
  • internal/activator/activator.go +23 −0
  • internal/activator/backend_exec.go +17 −0
  • internal/activator/config_gate_test.go +86 −0
  • internal/provision/config_gate_test.go +116 −0
  • internal/provision/reconcile.go +23 −0
  • internal/provision/sidecar_reconcile.go +8 −0
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index 16ef6357a..15802bcd1 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -574,6 +574,21 @@ func (e *errLeaseHeld) Error() string {
return fmt.Sprintf("instance lease held by %q (retry in %s)", e.holder, e.retryAfter)
}
+// errConfigRefused: this box cannot provide the instance's sealed config
+// (no key, the wrong key, or it could not tell), so it will not take the
+// lease (amadan #213). The condition outlives one request and a keyed
+// sibling may take the lease in the meantime, so the edge answers 503 with a
+// longer Retry-After than a lease contention gets.
+type errConfigRefused struct{ err error }
+
+func (e *errConfigRefused) Error() string {
+ return "instance config unavailable on this box: " + e.err.Error()
+}
+func (e *errConfigRefused) Unwrap() error { return e.err }
+
+// configRefusedRetryAfter is the Retry-After (seconds) a refused wake sends.
+const configRefusedRetryAfter = 30
+
var errWakeTimeout = errors.New("timed out waiting for instance wake")
// Service is the activator (Eleven's activatorSvc). It structurally satisfies
@@ -2850,9 +2865,17 @@ func (a *Service) serve(w http.ResponseWriter, r *http.Request, rt registry.Rout
release, err := a.begin(r.Context(), rt, stamp)
if err != nil {
var held *errLeaseHeld
+ var refused *errConfigRefused
switch {
case errors.Is(err, ErrFenced):
maintenanceResponse(w, r)
+ case errors.As(err, &refused):
+ // Quiet by design: the config guard logs once per host and
+ // publishes the refusal in the box's status snapshot. A line per
+ // request here would bury that one.
+ w.Header().Set("Retry-After", strconv.Itoa(configRefusedRetryAfter))
+ edge.MarkAnswered(r)
+ http.Error(w, "instance failed to wake", http.StatusServiceUnavailable)
case errors.As(err, &held):
retry := int(held.retryAfter/time.Second) + 1
w.Header().Set("Retry-After", strconv.Itoa(retry))
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index 114ee0987..c75513e80 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -142,6 +142,14 @@ type ExecBackend struct {
// gate (every unit test and any box that predates the check). Wired in
// production by Task 6.
NestedOK func() error
+ // ConfigGate answers whether this box can provide rt's config (amadan
+ // #213): nil means yes, an error means refuse. Acquire asks it BEFORE
+ // lease.Acquire, so a box that would start the instance without its
+ // secrets never holds the lease, and a keyed sibling takes it instead.
+ // The gate owns its own policy, logging and operator-visible record of
+ // the refusal; this backend only honours the answer. Nil: no gate (every
+ // unit test, and a box with no deploy store to read config from).
+ ConfigGate func(ctx context.Context, rt registry.Route) error
// ObservePoll is how often the exit observer (unitproc.go) and a
// seed's cgroup-empty wait re-check unit state. 0: one second. A field
// so tests can shrink the wait without a package-global var shared
@@ -227,6 +235,15 @@ func (b *ExecBackend) LiveVersion(host string) (string, error) {
// Retry-After that tracks the holder's expiry.
func (b *ExecBackend) Acquire(ctx context.Context, rt registry.Route) (Lease, error) {
host := rt.Host
+ // Admission before the lease: the instance's config has to be
+ // providable from THIS box, or it must not be the one that starts it.
+ // Here, not in doWake, because this is the only place an instance or
+ // sidecar lease is taken; every wake source reaches it.
+ if b.ConfigGate != nil {
+ if err := b.ConfigGate(ctx, rt); err != nil {
+ return nil, &errConfigRefused{err: err}
+ }
+ }
held, err := lease.Acquire(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host), b.Cfg.Region)
if err != nil {
var he *lease.HeldError
diff --git a/internal/activator/config_gate_test.go b/internal/activator/config_gate_test.go
new file mode 100644
index 000000000..51ba62fed
--- /dev/null
+++ b/internal/activator/config_gate_test.go
@@ -0,0 +1,86 @@
+package activator
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// Issue #213: a box that cannot provide an instance's config must not take
+// its lease. The gate runs inside ExecBackend.Acquire, before lease.Acquire,
+// so every wake source (request, tick, sweeper, worker boot) is covered by
+// the one place a lease is taken.
+
+// The gate is asked about the route being woken, and a refusal means the
+// lease is never attempted. Cfg.Bucket is empty here, so a call that fell
+// through to lease.Acquire would fail with a different error.
+func TestExecBackendAcquireRefusesBeforeTheLease(t *testing.T) {
+ var asked []registry.Route
+ boom := errors.New("cannot open sealed config")
+ b := &ExecBackend{ConfigGate: func(_ context.Context, rt registry.Route) error {
+ asked = append(asked, rt)
+ return boom
+ }}
+ rt := registry.Route{Host: "kpmg.example.com", Kind: "instance", App: "shop", Hibernate: true}
+
+ lease, err := b.Acquire(context.Background(), rt)
+ if lease != nil {
+ t.Fatalf("lease = %v, want nil", lease)
+ }
+ var refused *errConfigRefused
+ if !errors.As(err, &refused) {
+ t.Fatalf("err = %v, want *errConfigRefused", err)
+ }
+ if !errors.Is(err, boom) {
+ t.Errorf("err %v does not wrap the gate's reason", err)
+ }
+ if len(asked) != 1 || asked[0].Host != rt.Host {
+ t.Errorf("gate asked about %v, want exactly %s", asked, rt.Host)
+ }
+}
+
+// The refusal is the member's 503 with a Retry-After, answered quietly (the
+// guard logs once per host; a log line per request would drown it), and the
+// failed wake leaves no entry behind so the next request asks again.
+func TestActivatorConfigRefusal503(t *testing.T) {
+ a, b, _, reg := testActivator(t, time.Minute)
+ b.acquireErr = &errConfigRefused{err: errors.New("no key")}
+
+ if err := reg.Upsert(registry.Route{Host: "nokey.example.com", Kind: "instance", Socket: "/tmp/nokey.sock", Hibernate: true}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ rt, err := newTestRouter(reg, a)
+ if err != nil {
+ t.Fatalf("new router: %v", err)
+ }
+ front := httptest.NewServer(rt)
+ t.Cleanup(front.Close)
+
+ req, _ := http.NewRequest("GET", front.URL+"/", nil)
+ req.Host = "nokey.example.com"
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("status %d, want 503", resp.StatusCode)
+ }
+ if ra := resp.Header.Get("Retry-After"); ra != "30" {
+ t.Fatalf("Retry-After %q, want 30", ra)
+ }
+ if a.entryCount() != 0 {
+ t.Fatal("refused wake left an entry behind")
+ }
+ // Nothing past Acquire ran: no restore, no spawn.
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if b.restores != 0 || b.starts != 0 {
+ t.Fatalf("refused wake went on: restores=%d starts=%d", b.restores, b.starts)
+ }
+}
diff --git a/internal/provision/config_gate_test.go b/internal/provision/config_gate_test.go
new file mode 100644
index 000000000..a13eecbf1
--- /dev/null
+++ b/internal/provision/config_gate_test.go
@@ -0,0 +1,116 @@
+package provision
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// Issue #213: a box mints an instance's record only if it can provide that
+// instance's config. ReconcileConfig.ConfigGate is the seam; the host agent
+// wires it to the same guard the activator asks before taking a lease.
+
+type gateLog struct {
+ asked []registry.Route
+ refuse map[string]error // host -> refusal
+}
+
+func (g *gateLog) gate(_ context.Context, rt registry.Route) error {
+ g.asked = append(g.asked, rt)
+ return g.refuse[rt.Host]
+}
+
+func TestReconcileDoesNotMintWhatTheGateRefuses(t *testing.T) {
+ s, sqid := setup(t)
+ if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil {
+ t.Fatal(err)
+ }
+ reg := regWith(t)
+ g := &gateLog{refuse: map[string]error{"acme.go.tito.io": errors.New("no key")}}
+ cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data", ConfigGate: g.gate}
+
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ noRoute(t, reg, "acme.go.tito.io", "a refused record was minted")
+ if len(g.asked) != 1 {
+ t.Fatalf("gate asked %d times, want 1", len(g.asked))
+ }
+ // The gate sees the route that WOULD be minted: the activator's own gate
+ // reads App, Account, Environment off it.
+ if got := g.asked[0]; got.App != "tito" || got.Account != sqid || got.Kind != "instance" || got.Socket == "" {
+ t.Errorf("gate saw %+v, want the would-be minted socket-backed instance route", got)
+ }
+
+ // Retried next pass: once the box can provide the config, it mints.
+ delete(g.refuse, "acme.go.tito.io")
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ hasRoute(t, reg, "acme.go.tito.io", "not minted once the gate allowed it")
+}
+
+// A nil gate is today's behaviour exactly.
+func TestReconcileNilGateMintsAsBefore(t *testing.T) {
+ s, sqid := setup(t)
+ if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil {
+ t.Fatal(err)
+ }
+ reg := regWith(t)
+ if err := Reconcile(ctx, s, reg, ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"}); err != nil {
+ t.Fatal(err)
+ }
+ hasRoute(t, reg, "acme.go.tito.io", "nil gate stopped a mint")
+}
+
+// The gate is asked about every minted shape and decides itself which need
+// config; a refusal of one record leaves the others minted, and an existing
+// route is never unminted by a refusal (the wake gate is what protects it).
+func TestReconcileGateRefusesOnlyTheRecordItNames(t *testing.T) {
+ s, sqid := setup(t)
+ a := acmeRecord(sqid)
+ b := acmeRecord(sqid)
+ b.Host, b.Slug = "beta.go.tito.io", "beta"
+ for _, r := range []Record{a, b} {
+ if err := CreateRecord(ctx, s, r); err != nil {
+ t.Fatal(err)
+ }
+ }
+ reg := regWith(t)
+ g := &gateLog{refuse: map[string]error{"beta.go.tito.io": errors.New("cannot open")}}
+ cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data", ConfigGate: g.gate}
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ hasRoute(t, reg, "acme.go.tito.io", "an allowed record was held back by its neighbour's refusal")
+ noRoute(t, reg, "beta.go.tito.io", "a refused record was minted")
+
+ // Already minted, then refused: the route stays (removal is only for a
+ // retired record).
+ g.refuse["acme.go.tito.io"] = errors.New("now cannot open")
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ hasRoute(t, reg, "acme.go.tito.io", "a refusal unminted an existing route")
+}
+
+func TestSidecarReconcileDoesNotMintWhatTheGateRefuses(t *testing.T) {
+ f := sidecarSetup(t)
+ f.putManifest(t, "eleven", SidecarManifest{
+ Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}, Channel: "stable"})
+ f.putRecord(t, SidecarRecord{Schema: 1, Name: "bot", App: "eleven", Channel: "stable"})
+ host := f.host("bot", "eleven")
+ g := &gateLog{refuse: map[string]error{host: errors.New("no key")}}
+
+ f.reconcile(t, ReconcileConfig{ConfigGate: g.gate})
+ noRoute(t, f.reg, host, "a refused sidecar was minted")
+ if len(g.asked) != 1 || g.asked[0].Kind != "sidecar" || g.asked[0].App != "eleven" {
+ t.Fatalf("gate asked %+v, want the would-be sidecar route", g.asked)
+ }
+
+ delete(g.refuse, host)
+ f.reconcile(t, ReconcileConfig{ConfigGate: g.gate})
+ hasRoute(t, f.reg, host, "sidecar not minted once the gate allowed it")
+}
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go
index ffef7f3a9..d6a55ef1b 100644
--- a/internal/provision/reconcile.go
+++ b/internal/provision/reconcile.go
@@ -75,6 +75,18 @@ type ReconcileConfig struct {
// path; the auto-purge that does still lives in domains, still refuses
// without a key.
ReleasePub ed25519.PublicKey
+ // ConfigGate answers whether this box can provide the config of the
+ // route a record would mint (amadan #213): nil error means yes, an error
+ // means do not mint it this pass. It is handed the route the mint would
+ // create (routeFor / sidecarRouteFor), so it reads App, Account,
+ // Environment and the shape off the one renderer, and it decides for
+ // itself which shapes need config at all. A refused record is retried
+ // every pass, so a box that gains the key mints on its next tick. The
+ // gate owns its own logging and operator-visible record; this pass only
+ // honours the answer. Existing routes are not unminted by a refusal: the
+ // wake-time gate (activator.ExecBackend.ConfigGate) is what stops one
+ // starting. Nil: no gate, every record mints as before.
+ ConfigGate func(ctx context.Context, rt registry.Route) error
}
// InboundAny and InboundNone are the closed vocabulary ReconcileConfig.Inbound
@@ -934,6 +946,17 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// the additivity rule, pinned by TestRecordWireShapeIsAdditive and
// TestReconcileMints.
nr := routeFor(rec, cfg.DataDir, seed)
+ // A box that cannot provide this instance's config must not
+ // admit it: it would start without its secrets, and a keyed
+ // sibling would be left with a route that lost the race to one
+ // that was never going to work. Refused before the write, and
+ // before the dry-run line, so a dry run reports what a real pass
+ // would do.
+ if cfg.ConfigGate != nil {
+ if err := cfg.ConfigGate(ctx, nr); err != nil {
+ continue
+ }
+ }
if cfg.DryRun {
log.Printf("provision: dry-run: would mint %s route %s (app %q acct %q channel %q)",
nr.Kind, rec.Host, rec.App, rec.Account, rec.Channel)
diff --git a/internal/provision/sidecar_reconcile.go b/internal/provision/sidecar_reconcile.go
index d7f86c8f3..687dd9349 100644
--- a/internal/provision/sidecar_reconcile.go
+++ b/internal/provision/sidecar_reconcile.go
@@ -387,6 +387,14 @@ func ReconcileSidecars(ctx context.Context, s release.Store, reg *registry.Regis
continue
}
nr := sidecarRouteFor(rec, m, cfg.DataDir)
+ // Same admission rule as the instance pass (amadan #213): a
+ // sidecar that would start without its sealed config is not
+ // minted here. See ReconcileConfig.ConfigGate.
+ if cfg.ConfigGate != nil {
+ if err := cfg.ConfigGate(ctx, nr); err != nil {
+ continue
+ }
+ }
if cfg.DryRun {
log.Printf("provision: dry-run: would mint sidecar route %s (app %q acct %q channel %q args %s)",
rec.Host, rec.App, rec.Account, nr.Channel, nr.Args)