carlos / platform Public

host: the config guard, wired to the lease, the mint and the status snapshot (#213)

A box takes an instance's lease, or mints its record, only if it can provide
that instance's config. One guard per process built from CARLOS_SECRETS_KEY
and the deploy store: refusals cached 30s, logged once per host, listed in
the private status Metrics. A box with no deploy store reads it as nothing
sealed; a store it cannot open or read refuses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev eb2fbc93ac6cadc46571b008bcc55e9b976683ae parent b65f57e
3 files changed, +720 −1
  • internal/host/agent.go +17 −1
  • internal/host/configguard.go +286 −0
  • internal/host/configguard_test.go +417 −0

CI passed — run details

diff --git a/internal/host/agent.go b/internal/host/agent.go
index 255b399d9..a0064e26a 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -277,6 +277,14 @@ func RunAgent(args []string) {
}
}
+ // The config guard (amadan #213), armed before the activator and every
+ // reconcile loop that asks it: a box takes an instance's lease, or mints
+ // its record, only if it can provide that instance's config. Armed
+ // whether or not the activator is, because the mint gate is the reconcile
+ // pass's. A box with no deployment store reads it as "nothing sealed
+ // anywhere" and admits as before.
+ armConfigGuard(newConfigGuard(os.Getenv, openDeployStore))
+
var act edge.Activator
var svc *activator.Service
if cfg != nil {
@@ -305,6 +313,8 @@ func RunAgent(args []string) {
// so the row is due-indexed by the next tick with nothing in
// between. reg satisfies schedule.TimerStore directly.
Timers: reg,
+ // Asked inside Acquire, before the lease (amadan #213).
+ ConfigGate: configGate(),
}
if logSink != nil { // a typed-nil in the interface would NOT be nil
eb.Logs = logSink
@@ -2058,6 +2068,9 @@ func provisionTickConfig(getenv func(string) string) provision.ReconcileConfig {
DataDir: dd,
Now: time.Now,
Inbound: inbound,
+ // The armed config guard, asked before a mint (amadan #213). Nil
+ // when none is armed (every test that does not arm one).
+ ConfigGate: configGate(),
}
// The root release public key, same source and same parser as adoptLoop's
// and domainsTickConfig's. It guards ONE decision in this pass: whether a
@@ -2729,6 +2742,9 @@ func statusLoop(reg *registry.Registry) {
// is what `carlos instances list` and `carlos routes` show as DB.
DBBytes: dbFileBytes,
}
+ // The hosts this box is refusing because it cannot provide their
+ // config (amadan #213): private Metrics only, nothing public.
+ p.ConfigRefusals, p.ConfigRefusalsTotal = configRefusalProbe()
// A fleet box (CARLOS_FLEET set) never gets here as a contender: it
// self-reports only, never attempting the election at all — no
@@ -3103,7 +3119,7 @@ func openDeployStoreEnv(ctx context.Context, getenv func(string) string) (releas
// operator hunting for a bucket var that is deliberately absent.
return nil, err
}
- return nil, errors.New("neither CARLOS_DEPLOYMENT_BUCKET nor CARLOS_DEPLOYMENT_DIR is set")
+ return nil, errNoDeployStore
}
// openFleetDoorStore builds the fleetapi client a bucketless fleet box adopts
diff --git a/internal/host/configguard.go b/internal/host/configguard.go
new file mode 100644
index 000000000..80b00b3db
--- /dev/null
+++ b/internal/host/configguard.go
@@ -0,0 +1,286 @@
+package host
+
+import (
+ "context"
+ "crypto/ecdh"
+ "errors"
+ "log"
+ "sort"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// The config guard (amadan #213). A box takes an instance's lease, or mints
+// its record, only if it can provide that instance's config.
+//
+// The failure it ends: a box with no CARLOS_SECRETS_KEY (or the wrong one)
+// still admitted and woke socket-backed shared-pool instances in its region.
+// Its config sync loops are off without the key, and ExecBackend.instanceEnv
+// treats a missing <host>.config.env as optional, so the instance started
+// without its secrets or store config. kpmg was down 1h54m on 2026-10-01
+// because the UK box had no key. The console cannot prove what reconcile will
+// admit, so the invariant lives here, at admission.
+//
+// One guard per process, built from CARLOS_SECRETS_KEY and the deploy store,
+// and asked through Gate from three places, each BEFORE the side effect it
+// guards: activator.ExecBackend.Acquire (before lease.Acquire),
+// provision.Reconcile and provision.ReconcileSidecars (before a mint). The
+// decision itself is appconfig.CanProvideConfig; this type adds what that
+// pure function cannot: the box's key, the store, a short refusal cache, the
+// once-per-host log and the list the status snapshot publishes.
+
+const (
+ // configGuardRefuseTTL bounds how long a refusal is answered from memory.
+ // A request storm on a refused host would otherwise cost a set of bucket
+ // reads per request, and the reconcile pass asks about every unminted
+ // record every tick. It is also the retry interval after a store error:
+ // the box can tell again within this long of the store coming back. An
+ // admission is never cached: wakes are rare, and the answer must be
+ // current when it is taken.
+ configGuardRefuseTTL = 30 * time.Second
+ // configGuardListCap bounds the refusal list in the status snapshot; the
+ // total is published beside it so a capped list reads as long.
+ configGuardListCap = 100
+ // configGuardRefusalMaxAge is how long a host stays in the published list
+ // without being asked about again. A wake-time refusal is only re-asked
+ // when someone requests the host, so this is what makes the list mean
+ // "currently refused" rather than "ever refused since boot".
+ configGuardRefusalMaxAge = 24 * time.Hour
+)
+
+// errNoDeployStore is openDeployStoreEnv's answer for a box with no
+// deployment store configured at all: no bucket, no dir, no fleet identity.
+// Config lives in that store, so such a box has no sealed config anywhere and
+// the guard has nothing to protect. It is NOT the answer for a store that is
+// configured and failed to open: that box cannot tell, and refuses.
+var errNoDeployStore = errors.New("neither CARLOS_DEPLOYMENT_BUCKET nor CARLOS_DEPLOYMENT_DIR is set")
+
+type guardRefusal struct {
+ reason string
+ since time.Time
+ last time.Time
+ count int
+}
+
+type guardVerdict struct {
+ err error
+ until time.Time
+}
+
+type configGuard struct {
+ priv *ecdh.PrivateKey
+ keyState appconfig.KeyState
+ openStore func(context.Context) (release.Store, error)
+ now func() time.Time
+
+ mu sync.Mutex
+ store release.Store
+ verdicts map[string]guardVerdict // host -> cached refusal
+ refused map[string]*guardRefusal
+}
+
+// newConfigGuard reads CARLOS_SECRETS_KEY once, the way the config loops do.
+// A key that does not parse is logged here, once, at arming; per-host lines
+// say only that the host was refused.
+func newConfigGuard(getenv func(string) string, openStore func(context.Context) (release.Store, error)) *configGuard {
+ g := &configGuard{
+ openStore: openStore,
+ now: time.Now,
+ verdicts: map[string]guardVerdict{},
+ refused: map[string]*guardRefusal{},
+ }
+ raw := strings.TrimSpace(getenv("CARLOS_SECRETS_KEY"))
+ switch {
+ case raw == "":
+ g.keyState = appconfig.KeyAbsent
+ default:
+ priv, err := appconfig.ParsePrivateKey(raw, "CARLOS_SECRETS_KEY")
+ if err != nil {
+ log.Printf("agent: config guard: %v", err)
+ g.keyState = appconfig.KeyUnparseable
+ } else {
+ g.priv, g.keyState = priv, appconfig.KeyOK
+ }
+ }
+ return g
+}
+
+// needsBoxConfig reports whether a route is one this box would spawn with a
+// config.env of its own: a socket-backed instance or a sidecar worker that
+// belongs to an app. Static sites, addr-backed and unit-backed routes (whose
+// config the root adopt pass delivers), platform routes and app-less rows
+// take no part: the guard must not touch them.
+func needsBoxConfig(rt registry.Route) bool {
+ if rt.App == "" || rt.Platform || rt.Unit != "" || rt.Backing == registry.BackingUnit {
+ return false
+ }
+ switch {
+ case rt.IsSidecar():
+ return true
+ case rt.Kind == "instance":
+ return rt.Socket != ""
+ }
+ return false
+}
+
+// Gate is the seam both ExecBackend.ConfigGate and ReconcileConfig.ConfigGate
+// are wired to. nil means this box can provide rt's config (or none is
+// needed); an error means refuse, and is an *appconfig.ConfigRefusal.
+func (g *configGuard) Gate(ctx context.Context, rt registry.Route) error {
+ if !needsBoxConfig(rt) {
+ return nil
+ }
+ now := g.now()
+ if err := g.cached(rt.Host, now); err != nil {
+ g.note(rt.Host, err, now)
+ return err
+ }
+ err := g.decide(ctx, rt)
+ if err != nil {
+ g.mu.Lock()
+ g.verdicts[rt.Host] = guardVerdict{err: err, until: now.Add(configGuardRefuseTTL)}
+ g.mu.Unlock()
+ g.note(rt.Host, err, now)
+ return err
+ }
+ g.clear(rt.Host)
+ return nil
+}
+
+func (g *configGuard) cached(host string, now time.Time) error {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ v, ok := g.verdicts[host]
+ if !ok {
+ return nil
+ }
+ if !now.Before(v.until) {
+ delete(g.verdicts, host)
+ return nil
+ }
+ return v.err
+}
+
+func (g *configGuard) decide(ctx context.Context, rt registry.Route) error {
+ s, err := g.deployStore(ctx)
+ if errors.Is(err, errNoDeployStore) {
+ return nil // no store, so no sealed config can exist for this box
+ }
+ if err != nil {
+ return &appconfig.ConfigRefusal{Reason: appconfig.ReasonStoreError, Err: err}
+ }
+ return appconfig.CanProvideConfig(ctx, s, g.priv, g.keyState, release.RouteAccount(rt), rt.App, rt.Environment, rt.Host)
+}
+
+// deployStore opens the store on first use and keeps it. A failure is not
+// kept, so the next ask (after the refusal TTL) tries again.
+func (g *configGuard) deployStore(ctx context.Context) (release.Store, error) {
+ g.mu.Lock()
+ s := g.store
+ g.mu.Unlock()
+ if s != nil {
+ return s, nil
+ }
+ s, err := g.openStore(ctx)
+ if err != nil {
+ return nil, err
+ }
+ g.mu.Lock()
+ g.store = s
+ g.mu.Unlock()
+ return s, nil
+}
+
+// note records a refusal for the snapshot and logs it: once per host, and
+// again only when the reason changes. The detail in the log is the operator's;
+// the snapshot carries the closed reason word alone.
+func (g *configGuard) note(host string, err error, now time.Time) {
+ reason := appconfig.ReasonStoreError
+ var r *appconfig.ConfigRefusal
+ if errors.As(err, &r) {
+ reason = r.Reason
+ }
+ g.mu.Lock()
+ e := g.refused[host]
+ fresh := e == nil || e.reason != reason
+ if e == nil {
+ e = &guardRefusal{since: now}
+ g.refused[host] = e
+ }
+ e.reason, e.last = reason, now
+ e.count++
+ g.mu.Unlock()
+ if fresh {
+ log.Printf("agent: config guard: refusing %s: %v. A box that cannot open an instance's sealed config does not take its lease or mint its record, so a keyed sibling can; it is listed in this box's status snapshot", host, err)
+ }
+}
+
+// clear forgets a host that now passes, saying so once if it was refused.
+func (g *configGuard) clear(host string) {
+ g.mu.Lock()
+ _, was := g.refused[host]
+ delete(g.refused, host)
+ delete(g.verdicts, host)
+ g.mu.Unlock()
+ if was {
+ log.Printf("agent: config guard: %s can be provided from this box again", host)
+ }
+}
+
+// Refusals is what the status snapshot publishes: the currently refused
+// hosts, newest first and capped, plus how many there are in all. A host not
+// asked about for configGuardRefusalMaxAge is dropped.
+func (g *configGuard) Refusals() ([]status.ConfigRefusal, int) {
+ now := g.now()
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ var out []status.ConfigRefusal
+ for host, e := range g.refused {
+ if now.Sub(e.last) > configGuardRefusalMaxAge {
+ delete(g.refused, host)
+ continue
+ }
+ out = append(out, status.ConfigRefusal{Host: host, Reason: e.reason, Since: e.since, Last: e.last, Count: e.count})
+ }
+ total := len(out)
+ sort.Slice(out, func(i, j int) bool {
+ if !out[i].Last.Equal(out[j].Last) {
+ return out[i].Last.After(out[j].Last)
+ }
+ return out[i].Host < out[j].Host
+ })
+ if len(out) > configGuardListCap {
+ out = out[:configGuardListCap]
+ }
+ return out, total
+}
+
+// armedConfigGuard is the process's one guard, set by RunAgent before any
+// loop that asks it starts. Nil is "not armed": every consumer treats that as
+// today's behaviour, so tests and callers that never arm one are unaffected.
+var armedConfigGuard atomic.Pointer[configGuard]
+
+func armConfigGuard(g *configGuard) { armedConfigGuard.Store(g) }
+
+// configGate is the armed guard's Gate, or nil when none is armed.
+func configGate() func(context.Context, registry.Route) error {
+ if g := armedConfigGuard.Load(); g != nil {
+ return g.Gate
+ }
+ return nil
+}
+
+// configRefusalProbe feeds status.Probes.ConfigRefusals.
+func configRefusalProbe() ([]status.ConfigRefusal, int) {
+ if g := armedConfigGuard.Load(); g != nil {
+ return g.Refusals()
+ }
+ return nil, 0
+}
diff --git a/internal/host/configguard_test.go b/internal/host/configguard_test.go
new file mode 100644
index 000000000..35939a853
--- /dev/null
+++ b/internal/host/configguard_test.go
@@ -0,0 +1,417 @@
+package host
+
+import (
+ "bytes"
+ "context"
+ "crypto/ecdh"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Issue #213: the box-side guard. A box takes an instance's lease, or mints
+// its record, only if it can provide that instance's config. These tests
+// drive the guard through its Gate seam, the one function the activator and
+// the reconcile pass both call.
+
+type guardEnv struct {
+ t *testing.T
+ store *guardStore
+ priv *ecdh.PrivateKey
+ other *ecdh.PrivateKey
+ clock *guardClock
+}
+
+type guardClock struct {
+ mu sync.Mutex
+ t time.Time
+}
+
+func (c *guardClock) now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.t }
+func (c *guardClock) advance(d time.Duration) {
+ c.mu.Lock()
+ c.t = c.t.Add(d)
+ c.mu.Unlock()
+}
+
+// guardStore counts Gets and can be made to fail every Get.
+type guardStore struct {
+ release.Store
+ mu sync.Mutex
+ gets int
+ failGet error
+}
+
+func (s *guardStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ s.mu.Lock()
+ s.gets++
+ err := s.failGet
+ s.mu.Unlock()
+ if err != nil {
+ return nil, "", err
+ }
+ return s.Store.Get(ctx, key)
+}
+
+func (s *guardStore) getCount() int { s.mu.Lock(); defer s.mu.Unlock(); return s.gets }
+func (s *guardStore) setFail(err error) {
+ s.mu.Lock()
+ s.failGet = err
+ s.mu.Unlock()
+}
+
+func newGuardEnv(t *testing.T) *guardEnv {
+ t.Helper()
+ priv, err := ecdh.P256().GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ other, err := ecdh.P256().GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &guardEnv{
+ t: t,
+ store: &guardStore{Store: release.NewDirStore(t.TempDir())},
+ priv: priv, other: other,
+ clock: &guardClock{t: time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)},
+ }
+}
+
+func (e *guardEnv) seal(plain string) appconfig.Var {
+ e.t.Helper()
+ env, err := appconfig.SealUnbound(e.priv.PublicKey(), []byte(plain))
+ if err != nil {
+ e.t.Fatal(err)
+ }
+ return appconfig.Var{Kind: "secret", Envelope: env}
+}
+
+func (e *guardEnv) putConfig(acct, app string, vars map[string]appconfig.Var) {
+ e.t.Helper()
+ body, err := json.Marshal(appconfig.Manifest{Schema: 1, App: app, Vars: vars})
+ if err != nil {
+ e.t.Fatal(err)
+ }
+ if _, err := e.store.Store.Put(context.Background(), appconfig.Key(acct, app), body, ""); err != nil {
+ e.t.Fatal(err)
+ }
+}
+
+// guard builds the guard with the given key in the environment ("" = unset)
+// and this env's store.
+func (e *guardEnv) guard(keyB64 string) *configGuard {
+ g := newConfigGuard(func(k string) string {
+ if k == "CARLOS_SECRETS_KEY" {
+ return keyB64
+ }
+ return ""
+ }, func(context.Context) (release.Store, error) { return e.store, nil })
+ g.now = e.clock.now
+ return g
+}
+
+func keyB64(k *ecdh.PrivateKey) string { return base64.StdEncoding.EncodeToString(k.Bytes()) }
+
+func guardRoute(host string) registry.Route {
+ return registry.Route{Host: host, Kind: "instance", App: "shop", Account: "bab",
+ Socket: "/run/carlos/" + host + ".sock", Hibernate: true, Provisioned: true}
+}
+
+func reasonOfGate(t *testing.T, err error) string {
+ t.Helper()
+ var r *appconfig.ConfigRefusal
+ if !errors.As(err, &r) {
+ t.Fatalf("err = %v, want *appconfig.ConfigRefusal", err)
+ }
+ return r.Reason
+}
+
+func guardCaptureLog(t *testing.T) *bytes.Buffer {
+ t.Helper()
+ var buf bytes.Buffer
+ old := log.Writer()
+ log.SetOutput(&buf)
+ t.Cleanup(func() { log.SetOutput(old) })
+ return &buf
+}
+
+func TestNeedsConfigIsTheShapeTheActivatorSpawns(t *testing.T) {
+ cases := []struct {
+ name string
+ rt registry.Route
+ want bool
+ }{
+ {"socket instance", guardRoute("a.example.com"), true},
+ {"non-hibernating socket instance", registry.Route{Host: "a", Kind: "instance", App: "shop", Socket: "/s"}, true},
+ {"sidecar worker", registry.Route{Host: "a", Kind: "sidecar", App: "shop", Hibernate: true}, true},
+ {"unit-backed by column", registry.Route{Host: "a", Kind: "instance", App: "shop", Socket: "/s", Backing: registry.BackingUnit}, false},
+ {"unit-named", registry.Route{Host: "a", Kind: "instance", App: "shop", Addr: "127.0.0.1:1", Unit: "x.service"}, false},
+ {"addr-backed", registry.Route{Host: "a", Kind: "instance", App: "shop", Addr: "127.0.0.1:9"}, false},
+ {"static", registry.Route{Host: "a", Kind: "static", App: "shop"}, false},
+ {"service", registry.Route{Host: "a", Kind: "service", App: "shop", Addr: "127.0.0.1:9"}, false},
+ {"platform", registry.Route{Host: "a", Kind: "instance", App: "console", Socket: "/s", Platform: true}, false},
+ {"no app", registry.Route{Host: "a", Kind: "instance", Socket: "/s"}, false},
+ }
+ for _, c := range cases {
+ if got := needsBoxConfig(c.rt); got != c.want {
+ t.Errorf("%s: needsBoxConfig = %v, want %v", c.name, got, c.want)
+ }
+ }
+}
+
+// A deployment with no sealed config anywhere (dev) is unaffected: no key, no
+// manifest, no refusal, nothing published.
+func TestGuardNoSealedConfigAdmitsKeylessBox(t *testing.T) {
+ e := newGuardEnv(t)
+ g := e.guard("")
+ if err := g.Gate(context.Background(), guardRoute("a.example.com")); err != nil {
+ t.Fatalf("keyless box, nothing sealed: %v", err)
+ }
+ if list, total := g.Refusals(); len(list) != 0 || total != 0 {
+ t.Errorf("refusals = %v (%d), want none", list, total)
+ }
+}
+
+func TestGuardKeylessBoxRefusesSealedInstanceAndPublishesIt(t *testing.T) {
+ e := newGuardEnv(t)
+ e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")})
+ logs := guardCaptureLog(t)
+ g := e.guard("")
+
+ err := g.Gate(context.Background(), guardRoute("kpmg.example.com"))
+ if got := reasonOfGate(t, err); got != appconfig.ReasonNoKey {
+ t.Fatalf("reason = %q, want no_key", got)
+ }
+ list, total := g.Refusals()
+ if total != 1 || len(list) != 1 {
+ t.Fatalf("refusals = %v (%d)", list, total)
+ }
+ r := list[0]
+ if r.Host != "kpmg.example.com" || r.Reason != "no_key" || r.Count != 1 || !r.Since.Equal(e.clock.now()) {
+ t.Errorf("refusal = %+v", r)
+ }
+ if n := strings.Count(logs.String(), "kpmg.example.com"); n != 1 {
+ t.Errorf("log mentions the host %d times, want once:\n%s", n, logs.String())
+ }
+}
+
+func TestGuardKeyedBoxTakesIt(t *testing.T) {
+ e := newGuardEnv(t)
+ e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")})
+ g := e.guard(keyB64(e.priv))
+ if err := g.Gate(context.Background(), guardRoute("a.example.com")); err != nil {
+ t.Fatalf("keyed box: %v", err)
+ }
+ if _, total := g.Refusals(); total != 0 {
+ t.Errorf("a keyed box publishes %d refusals", total)
+ }
+}
+
+func TestGuardWrongAndBrokenKeysRefuse(t *testing.T) {
+ e := newGuardEnv(t)
+ e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")})
+ for name, tc := range map[string]struct {
+ key string
+ want string
+ }{
+ "wrong key": {keyB64(e.other), appconfig.ReasonCannotOpen},
+ "not base64": {"%%%not-a-key", appconfig.ReasonKeyUnparseable},
+ "short": {base64.StdEncoding.EncodeToString([]byte("abc")), appconfig.ReasonKeyUnparseable},
+ "blank": {" ", appconfig.ReasonNoKey},
+ } {
+ t.Run(name, func(t *testing.T) {
+ g := e.guard(tc.key)
+ err := g.Gate(context.Background(), guardRoute("a.example.com"))
+ if got := reasonOfGate(t, err); got != tc.want {
+ t.Errorf("reason = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+// Fail closed: a store the box cannot read is not "no sealed config".
+func TestGuardStoreErrorRefusesAndRetriesAfterTheTTL(t *testing.T) {
+ e := newGuardEnv(t)
+ e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")})
+ g := e.guard(keyB64(e.priv))
+ ctx := context.Background()
+
+ e.store.setFail(errors.New("s3: 503"))
+ err := g.Gate(ctx, guardRoute("a.example.com"))
+ if got := reasonOfGate(t, err); got != appconfig.ReasonStoreError {
+ t.Fatalf("reason = %q, want store_error", got)
+ }
+
+ // Within the TTL a repeat is answered from the verdict, without another
+ // bucket read, and still refuses even though the store has recovered.
+ e.store.setFail(nil)
+ before := e.store.getCount()
+ if err := g.Gate(ctx, guardRoute("a.example.com")); err == nil {
+ t.Fatal("refusal forgotten inside the TTL")
+ }
+ if got := e.store.getCount(); got != before {
+ t.Errorf("a cached refusal read the store (%d -> %d)", before, got)
+ }
+ if list, _ := g.Refusals(); len(list) != 1 || list[0].Count != 2 {
+ t.Errorf("refusals = %+v, want one with count 2", list)
+ }
+
+ // After the TTL it asks again and, the store being back, admits and
+ // stops publishing the refusal.
+ e.clock.advance(configGuardRefuseTTL + time.Second)
+ if err := g.Gate(ctx, guardRoute("a.example.com")); err != nil {
+ t.Fatalf("after the TTL with the store back: %v", err)
+ }
+ if _, total := g.Refusals(); total != 0 {
+ t.Errorf("a recovered host still published (%d)", total)
+ }
+}
+
+func TestGuardLogsOncePerHostAndAgainWhenTheReasonChanges(t *testing.T) {
+ e := newGuardEnv(t)
+ e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")})
+ g := e.guard("")
+ logs := guardCaptureLog(t)
+ ctx := context.Background()
+
+ for i := 0; i < 5; i++ {
+ _ = g.Gate(ctx, guardRoute("a.example.com"))
+ e.clock.advance(configGuardRefuseTTL + time.Second) // defeat the verdict cache
+ }
+ if n := strings.Count(logs.String(), "a.example.com"); n != 1 {
+ t.Fatalf("5 refusals logged %d lines, want 1:\n%s", n, logs.String())
+ }
+ _ = g.Gate(ctx, guardRoute("b.example.com"))
+ if n := strings.Count(logs.String(), "b.example.com"); n != 1 {
+ t.Errorf("a second host logged %d lines, want 1", n)
+ }
+ // The reason changes (a store outage now hides the answer): said again.
+ e.store.setFail(errors.New("s3: down"))
+ _ = g.Gate(ctx, guardRoute("a.example.com"))
+ if n := strings.Count(logs.String(), "a.example.com"); n != 2 {
+ t.Errorf("a changed reason logged %d lines for the host, want 2:\n%s", n, logs.String())
+ }
+}
+
+// Routes the box does not spawn never reach the store.
+func TestGuardIgnoresRoutesItDoesNotSpawn(t *testing.T) {
+ e := newGuardEnv(t)
+ e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")})
+ g := e.guard("")
+ for _, rt := range []registry.Route{
+ {Host: "s", Kind: "static", App: "shop"},
+ {Host: "u", Kind: "instance", App: "shop", Socket: "/s", Backing: registry.BackingUnit},
+ {Host: "p", Kind: "instance", App: "shop", Socket: "/s", Platform: true},
+ } {
+ if err := g.Gate(context.Background(), rt); err != nil {
+ t.Errorf("%s: %v", rt.Host, err)
+ }
+ }
+ if e.store.getCount() != 0 {
+ t.Errorf("read the store %d times for routes it does not spawn", e.store.getCount())
+ }
+}
+
+// A dev box has no deployment store at all, so nothing sealed can exist for
+// it. A store that exists but cannot be opened is a box that cannot tell.
+func TestGuardStoreAvailability(t *testing.T) {
+ e := newGuardEnv(t)
+ noStore := newConfigGuard(func(string) string { return "" },
+ func(context.Context) (release.Store, error) { return nil, errNoDeployStore })
+ if err := noStore.Gate(context.Background(), guardRoute("a.example.com")); err != nil {
+ t.Fatalf("no deployment store configured: %v", err)
+ }
+
+ broken := newConfigGuard(func(string) string { return "" },
+ func(context.Context) (release.Store, error) { return nil, fmt.Errorf("aws: no credentials") })
+ broken.now = e.clock.now
+ err := broken.Gate(context.Background(), guardRoute("a.example.com"))
+ if got := reasonOfGate(t, err); got != appconfig.ReasonStoreError {
+ t.Errorf("unopenable store reason = %q, want store_error", got)
+ }
+}
+
+func TestGuardRefusalsAreSortedCappedAndPruned(t *testing.T) {
+ e := newGuardEnv(t)
+ e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")})
+ g := e.guard("")
+ ctx := context.Background()
+ n := configGuardListCap + 5
+ for i := 0; i < n; i++ {
+ _ = g.Gate(ctx, guardRoute(fmt.Sprintf("h%03d.example.com", i)))
+ e.clock.advance(time.Second)
+ }
+ list, total := g.Refusals()
+ if total != n || len(list) != configGuardListCap {
+ t.Fatalf("list %d total %d, want %d and %d", len(list), total, configGuardListCap, n)
+ }
+ // Newest first, so the cap drops the oldest.
+ if list[0].Host != fmt.Sprintf("h%03d.example.com", n-1) {
+ t.Errorf("first = %s, want the newest", list[0].Host)
+ }
+
+ // A refusal nobody has asked about for a day is no longer current.
+ e.clock.advance(configGuardRefusalMaxAge + time.Hour)
+ if list, total := g.Refusals(); len(list) != 0 || total != 0 {
+ t.Errorf("stale refusals survived: %d/%d", len(list), total)
+ }
+}
+
+// Nothing secret in what is published: reason words and hosts only.
+func TestGuardPublishesNoSecretMaterial(t *testing.T) {
+ e := newGuardEnv(t)
+ v := e.seal("hunter2")
+ e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": v})
+ g := e.guard(keyB64(e.other))
+ logs := guardCaptureLog(t)
+ _ = g.Gate(context.Background(), guardRoute("a.example.com"))
+ list, _ := g.Refusals()
+ raw, _ := json.Marshal(list)
+ for _, s := range []string{string(raw), logs.String()} {
+ for _, bad := range []string{v.Envelope, "hunter2", keyB64(e.other), keyB64(e.priv)} {
+ if strings.Contains(s, bad) {
+ t.Errorf("secret material %q in %s", bad, s)
+ }
+ }
+ }
+}
+
+// The agent wires one armed guard into the reconcile config and the status
+// probe, and a box with none armed behaves exactly as before.
+func TestArmedGuardReachesReconcileConfigAndStatus(t *testing.T) {
+ armConfigGuard(nil)
+ t.Cleanup(func() { armConfigGuard(nil) })
+ if cfg := provisionTickConfig(func(string) string { return "" }); cfg.ConfigGate != nil {
+ t.Error("no armed guard, but the reconcile config carries a gate")
+ }
+ if list, total := configRefusalProbe(); list != nil || total != 0 {
+ t.Errorf("no armed guard, but the probe reports %v/%d", list, total)
+ }
+
+ e := newGuardEnv(t)
+ e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")})
+ g := e.guard("")
+ armConfigGuard(g)
+ cfg := provisionTickConfig(func(string) string { return "" })
+ if cfg.ConfigGate == nil {
+ t.Fatal("armed guard not wired into the reconcile config")
+ }
+ if err := cfg.ConfigGate(context.Background(), guardRoute("a.example.com")); err == nil {
+ t.Error("the reconcile gate admitted a sealed instance on a keyless box")
+ }
+ if list, total := configRefusalProbe(); len(list) != 1 || total != 1 {
+ t.Errorf("probe = %v/%d, want the one refusal", list, total)
+ }
+}