config guard: gate worker respawns, key on Backing, require complete renderability, fail closed without a store (#213)
Review fixes. Worker in-place respawns ask the gate and stand down on refusal. The guard excludes only Backing=unit, as dispatch does. Admission requires the merged config to render, with one evidenced fallback (a delivered config.env covering every secret var). No deployment store refuses unless CARLOS_NO_SEALED_CONFIG=1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+529
−77
internal/activator/activator.go+33 −4internal/activator/activator_test.go+14 −0internal/activator/backend_exec.go+16 −4internal/activator/backend_unit.go+9 −0internal/activator/config_gate_test.go+98 −0internal/appconfig/canprovide.go+89 −18internal/appconfig/canprovide_test.go+124 −30internal/host/configguard.go+38 −9internal/host/configguard_test.go+108 −12
CI passed — run details
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index 15802bcd1..1bd4d5fc1 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -586,6 +586,15 @@ func (e *errConfigRefused) Error() string { |
| } |
| func (e *errConfigRefused) Unwrap() error { return e.err } |
| +// configAdmitter is the optional Backend capability behind the config gate |
| +// (amadan #213): AdmitConfig answers whether this box can provide rt's config. |
| +// Acquire asks it before taking a lease; paths that keep a lease and start a |
| +// child anyway (a worker's in-place respawn) ask it directly. A backend |
| +// without it is not gated. |
| +type configAdmitter interface { |
| + AdmitConfig(ctx context.Context, rt registry.Route) error |
| +} |
| + |
| // configRefusedRetryAfter is the Retry-After (seconds) a refused wake sends. |
| const configRefusedRetryAfter = 30 |
| @@ -1722,6 +1731,12 @@ func (a *Service) respawnWorkerLocked(e *actEntry, stopOld Proc, why string) { |
| spawnVer string |
| tok string |
| err error |
| + // refused is the config gate's answer for this respawn (amadan |
| + // #213). The lease is still held, so Acquire's gate is not in |
| + // the way, and a respawn onto config this box cannot provide |
| + // would start a worker without its secrets while keeping the |
| + // lease from a sibling that could run it. |
| + refused error |
| ) |
| if stopOld != nil { |
| stopCtx, cancel := context.WithTimeout(context.Background(), actStopTimeout) |
| @@ -1752,11 +1767,16 @@ func (a *Service) respawnWorkerLocked(e *actEntry, stopOld Proc, why string) { |
| // Placed inside `wanted` deliberately: an abandoned respawn (the |
| // entry was stopped or replaced before we got here) must not |
| // clear a row that isn't this attempt's to touch. |
| - if cerr := a.due.SetVersions(host, "", rt.Version); cerr != nil { |
| - log.Printf("activator: clear worker version for %s: %v", host, cerr) |
| + if g, ok := a.backend.(configAdmitter); ok { |
| + refused = g.AdmitConfig(ctx, rt) |
| } |
| - if tok, err = newWorkerToken(); err == nil { |
| - proc, spawnVer, err = a.backend.StartWorker(ctx, rt, workerEnv(a.agentSocket(), host, tok)) |
| + if refused == nil { |
| + if cerr := a.due.SetVersions(host, "", rt.Version); cerr != nil { |
| + log.Printf("activator: clear worker version for %s: %v", host, cerr) |
| + } |
| + if tok, err = newWorkerToken(); err == nil { |
| + proc, spawnVer, err = a.backend.StartWorker(ctx, rt, workerEnv(a.agentSocket(), host, tok)) |
| + } |
| } |
| cancel() |
| } |
| @@ -1775,6 +1795,15 @@ func (a *Service) respawnWorkerLocked(e *actEntry, stopOld Proc, why string) { |
| _ = proc.Stop(shedCtx) |
| shedCancel() |
| } |
| + case refused != nil: |
| + // Stand down: the old child is already gone, and nothing may |
| + // start in its place. A normal stop checkpoints and releases the |
| + // lease, so a keyed sibling can take it. Nothing re-wakes it here |
| + // until the box can provide the config (the config gate refuses |
| + // every wake before then). |
| + a.beginStopLocked(e) |
| + a.mu.Unlock() |
| + log.Printf("activator: worker %s NOT respawned (%s): %v — standing down and releasing its lease", host, why, refused) |
| case err != nil: |
| a.mu.Unlock() |
| log.Printf("activator: WORKER %s failed to respawn (%s): %v — the sweep's crash restart takes it from here", host, why, err) |
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go| index 93f38aac7..9faa04c4f 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -215,11 +215,15 @@ func (l *fakeLease) Release(context.Context) error { |
| type fakeBackend struct { |
| mu sync.Mutex |
| acquires int |
| + admits int // AdmitConfig calls |
| restores int |
| starts int |
| seeds int |
| releases int // lease releases — the failure paths' assertion |
| acquireErr error |
| + // admitErr is what AdmitConfig answers: the config gate a retained-lease |
| + // respawn must ask again (amadan #213). |
| + admitErr error |
| // restoreFresh is what Restore reports: a first boot (local DB absent AND |
| // no replica). False by default, so every pre-seed test doubles as the |
| // "a restored instance is never seeded" case. |
| @@ -345,6 +349,16 @@ func (b *fakeBackend) Acquire(context.Context, registry.Route) (Lease, error) { |
| return &fakeLease{rep: rep, b: b}, nil |
| } |
| +func (b *fakeBackend) AdmitConfig(context.Context, registry.Route) error { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + b.admits++ |
| + if b.admitErr != nil { |
| + return &errConfigRefused{err: b.admitErr} |
| + } |
| + return nil |
| +} |
| + |
| func (b *fakeBackend) Restore(context.Context, registry.Route) (bool, error) { |
| b.mu.Lock() |
| defer b.mu.Unlock() |
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index c75513e80..60fbaa181 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -239,10 +239,8 @@ func (b *ExecBackend) Acquire(ctx context.Context, rt registry.Route) (Lease, er |
| // 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} |
| - } |
| + if err := b.AdmitConfig(ctx, rt); err != nil { |
| + return nil, err |
| } |
| held, err := lease.Acquire(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host), b.Cfg.Region) |
| if err != nil { |
| @@ -259,6 +257,20 @@ func (b *ExecBackend) Acquire(ctx context.Context, rt registry.Route) (Lease, er |
| return &execLease{held: held, defaultDB: b.pathsFor(rt).dbPath}, nil |
| } |
| +// AdmitConfig asks the config gate whether this box can provide rt's config: |
| +// nil means yes (or no gate), an *errConfigRefused means no. Acquire calls it |
| +// before the lease; the activator calls it again before an in-place worker |
| +// respawn, which keeps its lease and so never passes through Acquire. |
| +func (b *ExecBackend) AdmitConfig(ctx context.Context, rt registry.Route) error { |
| + if b.ConfigGate == nil { |
| + return nil |
| + } |
| + if err := b.ConfigGate(ctx, rt); err != nil { |
| + return &errConfigRefused{err: err} |
| + } |
| + return nil |
| +} |
| + |
| // execLease adapts *lease.Held to the Lease seam. |
| type execLease struct { |
| held *lease.Held |
diff --git a/internal/activator/backend_unit.go b/internal/activator/backend_unit.go| index aa59652b6..137f63de0 100644 |
| --- a/internal/activator/backend_unit.go |
| +++ b/internal/activator/backend_unit.go |
| @@ -299,6 +299,15 @@ func (b *SplitBackend) pick(rt registry.Route) Backend { |
| func (b *SplitBackend) Acquire(ctx context.Context, rt registry.Route) (Lease, error) { |
| return b.pick(rt).Acquire(ctx, rt) |
| } |
| + |
| +// AdmitConfig dispatches like every other call: on Backing alone. A unit |
| +// tenant's config is the root adopt pass's, so only the exec half is gated. |
| +func (b *SplitBackend) AdmitConfig(ctx context.Context, rt registry.Route) error { |
| + if g, ok := b.pick(rt).(configAdmitter); ok { |
| + return g.AdmitConfig(ctx, rt) |
| + } |
| + return nil |
| +} |
| func (b *SplitBackend) Restore(ctx context.Context, rt registry.Route) (bool, error) { |
| return b.pick(rt).Restore(ctx, rt) |
| } |
diff --git a/internal/activator/config_gate_test.go b/internal/activator/config_gate_test.go| index 51ba62fed..0e7832ff5 100644 |
| --- a/internal/activator/config_gate_test.go |
| +++ b/internal/activator/config_gate_test.go |
| @@ -84,3 +84,101 @@ func TestActivatorConfigRefusal503(t *testing.T) { |
| t.Fatalf("refused wake went on: restores=%d starts=%d", b.restores, b.starts) |
| } |
| } |
| + |
| +// A worker respawn keeps its lease and calls StartWorker directly, so it |
| +// bypasses Acquire. The config gate has to be asked again there, and a refusal |
| +// has to stand the worker down (stop it, release the lease) rather than leave |
| +// a childless entry holding the lease from a sibling that could run it. |
| +func TestWorkerRespawnIsRefusedByTheConfigGateAndStandsDown(t *testing.T) { |
| + for _, why := range []string{"crash", "max_run"} { |
| + t.Run(why, func(t *testing.T) { |
| + a, b, clk, _ := testActivator(t, time.Hour) |
| + rt := alwaysOnWorkerRoute("bot.x.internal") |
| + rt.MaxRunSeconds = 30 |
| + if err := a.WakeWorker(rt); err != nil { |
| + t.Fatalf("WakeWorker: %v", err) |
| + } |
| + if b.workerStarts() != 1 { |
| + t.Fatalf("workerStarts = %d, want 1", b.workerStarts()) |
| + } |
| + first := b.lastWorker() |
| + |
| + // Secrets arrive after the worker started; this box cannot open them. |
| + b.mu.Lock() |
| + b.admitErr = errors.New("no key") |
| + b.mu.Unlock() |
| + |
| + if why == "crash" { |
| + first.kill() |
| + } else { |
| + clk.advance(31 * time.Second) |
| + } |
| + a.sweepOnce() |
| + |
| + waitFor(t, "the refused worker to stand down", func() bool { return a.entryCount() == 0 }) |
| + if n := b.workerStarts(); n != 1 { |
| + t.Fatalf("workerStarts = %d: a refused respawn still started a child", n) |
| + } |
| + if !first.isStopped() && why == "max_run" { |
| + t.Error("the old child was left running") |
| + } |
| + if n := b.releaseCount(); n != 1 { |
| + t.Fatalf("lease releases = %d, want 1: a refused worker must give its lease back", n) |
| + } |
| + if !b.lastRep().isClosed() { |
| + t.Error("replication was left open on a refused worker") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// And a gate that allows it changes nothing: the respawn runs in place and |
| +// keeps its lease, as before. |
| +func TestWorkerRespawnProceedsWhenTheConfigGateAllows(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Hour) |
| + if err := a.WakeWorker(alwaysOnWorkerRoute("bot.x.internal")); err != nil { |
| + t.Fatalf("WakeWorker: %v", err) |
| + } |
| + b.lastWorker().kill() |
| + a.sweepOnce() |
| + waitForRestart(t, a, b, "bot.x.internal", 2) |
| + if n := b.releaseCount(); n != 0 { |
| + t.Fatalf("lease releases = %d, want 0", n) |
| + } |
| + b.mu.Lock() |
| + admits := b.admits |
| + b.mu.Unlock() |
| + if admits == 0 { |
| + t.Error("the respawn never asked the config gate") |
| + } |
| +} |
| + |
| +// The gate follows dispatch, which is Backing alone (SplitBackend.pick). A |
| +// socket-backed hibernating instance may carry a Unit with an empty Backing |
| +// and still reaches ExecBackend.Acquire, so it is gated; only Backing == unit |
| +// is exempt. |
| +func TestSplitBackendGatesWhatDispatchSendsToExec(t *testing.T) { |
| + var asked []string |
| + exec := &ExecBackend{ConfigGate: func(_ context.Context, rt registry.Route) error { |
| + asked = append(asked, rt.Host) |
| + return errors.New("no key") |
| + }} |
| + b := &SplitBackend{Exec: exec, Unit: &UnitBackend{}} |
| + |
| + unitNamed := registry.Route{Host: "named.example.com", Kind: "instance", App: "shop", |
| + Socket: "/run/named.sock", Unit: "x.service", Hibernate: true} |
| + if _, err := b.Acquire(context.Background(), unitNamed); err == nil { |
| + t.Fatal("Acquire of a Unit-named, empty-Backing instance was not refused") |
| + } |
| + if err := b.AdmitConfig(context.Background(), unitNamed); err == nil { |
| + t.Fatal("AdmitConfig of a Unit-named, empty-Backing instance was not refused") |
| + } |
| + unitBacked := unitNamed |
| + unitBacked.Host, unitBacked.Backing = "backed.example.com", registry.BackingUnit |
| + if err := b.AdmitConfig(context.Background(), unitBacked); err != nil { |
| + t.Fatalf("a Backing=unit route must not be gated: %v", err) |
| + } |
| + if len(asked) != 2 || asked[0] != "named.example.com" || asked[1] != "named.example.com" { |
| + t.Fatalf("gate asked %v, want only the Unit-named exec route, twice", asked) |
| + } |
| +} |
diff --git a/internal/appconfig/canprovide.go b/internal/appconfig/canprovide.go| index 94b52bc9c..e1e2f137d 100644 |
| --- a/internal/appconfig/canprovide.go |
| +++ b/internal/appconfig/canprovide.go |
| @@ -1,10 +1,16 @@ |
| package appconfig |
| import ( |
| + "bytes" |
| "context" |
| "crypto/ecdh" |
| "fmt" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "github.com/carlosframework/platform/internal/instancefs" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -40,6 +46,11 @@ const ( |
| ReasonKeyUnparseable = "key_unparseable" |
| ReasonCannotOpen = "cannot_open" |
| ReasonStoreError = "store_error" |
| + // ReasonNoSource: the box has no deployment store configured, so it |
| + // cannot read the instance's config to know whether any is sealed. It is |
| + // a refusal unless the operator has said, explicitly, that this |
| + // deployment has no sealed config (the host's CARLOS_NO_SEALED_CONFIG). |
| + ReasonNoSource = "no_config_source" |
| ) |
| // ConfigRefusal is CanProvideConfig's refusal. Reason is one of the Reason* |
| @@ -60,26 +71,65 @@ func (r *ConfigRefusal) Error() string { |
| func (r *ConfigRefusal) Unwrap() error { return r.Err } |
| +// ReadDeliveredConfig returns the sync-rendered <host>.config.env this box |
| +// holds for rt, or nil if there is none or it cannot be read. It reads the |
| +// way SyncExec does (a nested row's file sits on tenant-owned ground, so it |
| +// goes through instancefs), so a planted FIFO cannot hang an admission. |
| +func ReadDeliveredConfig(dataDir string, rt registry.Route) []byte { |
| + path := ConfigEnvPath(dataDir, rt) |
| + var ( |
| + b []byte |
| + err error |
| + ) |
| + if nestedRoute(dataDir, rt) { |
| + b, err = instancefs.ReadBeneathLimit(filepath.Dir(path), filepath.Base(path), 1<<20) |
| + } else { |
| + var fi os.FileInfo |
| + if fi, err = os.Lstat(path); err == nil && !fi.Mode().IsRegular() { |
| + return nil |
| + } |
| + if err == nil { |
| + b, err = os.ReadFile(path) |
| + } |
| + } |
| + if err != nil { |
| + return nil |
| + } |
| + return b |
| +} |
| + |
| // CanProvideConfig reports nil when this box can provide the config of the |
| // instance at host, and a *ConfigRefusal when it cannot or cannot tell. |
| // |
| // - Nothing sealed in the merged view (no manifest at all, or only plain |
| // vars): nothing needs a key, so nil. A deployment with no sealed config |
| // anywhere is unaffected, key or no key. |
| -// - Something sealed: the box needs a parseable key, and that key must |
| -// open at least one of the merged view's envelopes under the route's own |
| -// binding. Sealing records no recipient (an envelope is ephemeral point, |
| -// nonce, ciphertext), so there is no fingerprint to compare; a GCM- |
| -// authenticated Open is the only proof, and a stronger one. |
| +// - Something sealed: the box needs a parseable key, and the merged view |
| +// must RENDER, which is what SyncExec does to produce <host>.config.env |
| +// and what instanceEnv then reads: every envelope opens under the route's |
| +// own binding and every value is writable. Sealing records no recipient |
| +// (an envelope is ephemeral point, nonce, ciphertext), so there is no |
| +// fingerprint to compare; rendering is the proof, and the only one that |
| +// matches what delivery needs. |
| // - Any read or parse failure refuses (ReasonStoreError). An error is never |
| // "no sealed config". |
| // |
| -// "At least one" is deliberate. One CARLOS_SECRETS_KEY opens every envelope |
| -// in a deployment, so one that opens proves the key; a single stale or |
| -// corrupt secret beside it is the sync pass's to report. Demanding all of |
| -// them would turn that sync fault into a refusal of every wake on a box |
| -// that holds the right key and a perfectly good rendered file. |
| -func CanProvideConfig(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, ks KeyState, acct, app, envName, host string) error { |
| +// Complete renderability, not "one envelope opens": SyncExec refuses to write |
| +// the WHOLE file when any one decrypt fails, and instanceEnv treats a missing |
| +// file as optional, so a half-openable manifest on a box with no rendered |
| +// file starts the instance with none of its sealed config. |
| +// |
| +// The one exception is evidence that this box already delivered a usable |
| +// file. If rendering fails, but (a) the key opens at least one envelope, so |
| +// the key is this deployment's, and (b) delivered() returns a rendered |
| +// <host>.config.env that carries a NAME= line for every secret var in the |
| +// merged view, then the sync pass is stalled on a stale or corrupt value |
| +// while the instance has a file from an earlier good render, and refusing |
| +// would turn that sync fault into an outage of a running tenant. A secret |
| +// var with no line in the file (new since the last render) is no evidence, |
| +// and neither is a missing key, a wrong one, or no file. delivered may be |
| +// nil: no evidence available. |
| +func CanProvideConfig(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, ks KeyState, acct, app, envName, host string, delivered func() []byte) error { |
| var ( |
| m *Manifest |
| err error |
| @@ -94,9 +144,10 @@ func CanProvideConfig(ctx context.Context, s release.Store, priv *ecdh.PrivateKe |
| if err != nil { |
| return &ConfigRefusal{Reason: ReasonStoreError, Err: err} |
| } |
| - var sealed []string |
| - for _, v := range m.Vars { |
| + var names, sealed []string |
| + for k, v := range m.Vars { |
| if v.Kind == "secret" { |
| + names = append(names, k) |
| sealed = append(sealed, v.Envelope) |
| } |
| } |
| @@ -110,13 +161,33 @@ func CanProvideConfig(ctx context.Context, s release.Store, priv *ecdh.PrivateKe |
| return &ConfigRefusal{Reason: ReasonNoKey} |
| } |
| bind := Binding{Account: acct, App: app} |
| - var lastErr error |
| + _, rerr := RenderEnv(m, priv, bind) |
| + if rerr == nil { |
| + return nil |
| + } |
| + opened := false |
| for _, env := range sealed { |
| if _, err := Open(priv, bind, env); err == nil { |
| - return nil |
| - } else { |
| - lastErr = err |
| + opened = true |
| + break |
| + } |
| + } |
| + if opened && delivered != nil && deliveredCovers(delivered(), names) { |
| + return nil |
| + } |
| + return &ConfigRefusal{Reason: ReasonCannotOpen, Err: fmt.Errorf("this box's key cannot render the config and there is no earlier rendered file to fall back on: %w", rerr)} |
| +} |
| + |
| +// deliveredCovers reports whether file has a NAME= line for every name. |
| +func deliveredCovers(file []byte, names []string) bool { |
| + if len(file) == 0 { |
| + return false |
| + } |
| + sort.Strings(names) |
| + for _, n := range names { |
| + if !bytes.HasPrefix(file, []byte(n+"=")) && !bytes.Contains(file, []byte("\n"+n+"=")) { |
| + return false |
| } |
| } |
| - return &ConfigRefusal{Reason: ReasonCannotOpen, Err: fmt.Errorf("none of %d sealed values opens with this box's key (last: %w)", len(sealed), lastErr)} |
| + return true |
| } |
diff --git a/internal/appconfig/canprovide_test.go b/internal/appconfig/canprovide_test.go| index ec8197ceb..01c076378 100644 |
| --- a/internal/appconfig/canprovide_test.go |
| +++ b/internal/appconfig/canprovide_test.go |
| @@ -5,9 +5,11 @@ import ( |
| "crypto/ecdh" |
| "encoding/json" |
| "errors" |
| + "os" |
| "strings" |
| "testing" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -70,17 +72,17 @@ func TestCanProvideConfigNothingSealedNeedsNothing(t *testing.T) { |
| e := newCanProvideEnv(t) |
| ctx := context.Background() |
| // No manifest anywhere: a dev deployment. No key either. |
| - if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "a.example.com"); err != nil { |
| + if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "a.example.com", nil); err != nil { |
| t.Fatalf("no manifest, no key: %v", err) |
| } |
| // A manifest with plain vars only has no sealed config. |
| e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}}) |
| - if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "a.example.com"); err != nil { |
| + if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "a.example.com", nil); err != nil { |
| t.Fatalf("plain-only manifest, no key: %v", err) |
| } |
| // A manifest that was fully unset exists but is empty. |
| e.put(Key("bab", "other"), Manifest{App: "other", Vars: map[string]Var{}}) |
| - if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "other", "", "a.example.com"); err != nil { |
| + if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "other", "", "a.example.com", nil); err != nil { |
| t.Fatalf("empty manifest, no key: %v", err) |
| } |
| } |
| @@ -90,16 +92,16 @@ func TestCanProvideConfigSealedNeedsAKeyThatOpensIt(t *testing.T) { |
| ctx := context.Background() |
| e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: map[string]Var{"DB": e.secret("pw")}}) |
| - if err := CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "shop", "", "a.example.com"); err != nil { |
| + if err := CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "shop", "", "a.example.com", nil); err != nil { |
| t.Fatalf("right key: %v", err) |
| } |
| - if got := reasonOf(t, CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "a.example.com")); got != ReasonNoKey { |
| + if got := reasonOf(t, CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "a.example.com", nil)); got != ReasonNoKey { |
| t.Errorf("absent key reason = %q, want %q", got, ReasonNoKey) |
| } |
| - if got := reasonOf(t, CanProvideConfig(ctx, e.s, nil, KeyUnparseable, "bab", "shop", "", "a.example.com")); got != ReasonKeyUnparseable { |
| + if got := reasonOf(t, CanProvideConfig(ctx, e.s, nil, KeyUnparseable, "bab", "shop", "", "a.example.com", nil)); got != ReasonKeyUnparseable { |
| t.Errorf("unparseable key reason = %q, want %q", got, ReasonKeyUnparseable) |
| } |
| - if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.other, KeyOK, "bab", "shop", "", "a.example.com")); got != ReasonCannotOpen { |
| + if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.other, KeyOK, "bab", "shop", "", "a.example.com", nil)); got != ReasonCannotOpen { |
| t.Errorf("wrong key reason = %q, want %q", got, ReasonCannotOpen) |
| } |
| } |
| @@ -114,26 +116,69 @@ func TestCanProvideConfigLooksAtEveryLayerTheBoxRenders(t *testing.T) { |
| e.put(InstanceConfigKey("bab", "shop", "cust.example.com"), Manifest{App: "shop", Vars: map[string]Var{"T": e.secret("y")}}) |
| // Unbound route, no overlay for this host: nothing sealed. |
| - if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "plain.example.com"); err != nil { |
| + if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "plain.example.com", nil); err != nil { |
| t.Errorf("unbound plain host: %v", err) |
| } |
| // Bound to the environment: sealed. |
| - if got := reasonOf(t, CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "staging", "plain.example.com")); got != ReasonNoKey { |
| + if got := reasonOf(t, CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "staging", "plain.example.com", nil)); got != ReasonNoKey { |
| t.Errorf("environment secret reason = %q", got) |
| } |
| // The overlay for cust.example.com only. |
| - if got := reasonOf(t, CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "cust.example.com")); got != ReasonNoKey { |
| + if got := reasonOf(t, CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "cust.example.com", nil)); got != ReasonNoKey { |
| t.Errorf("overlay secret reason = %q", got) |
| } |
| - if err := CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "shop", "", "cust.example.com"); err != nil { |
| + if err := CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "shop", "", "cust.example.com", nil); err != nil { |
| t.Errorf("overlay with the key: %v", err) |
| } |
| } |
| -// One key opens every envelope in a deployment, so one that opens proves the |
| -// key. A single stale or corrupt secret next to it is the sync loop's fault |
| -// to report, not a reason to refuse every wake of a keyed box. |
| -func TestCanProvideConfigOneOpenableEnvelopeProvesTheKey(t *testing.T) { |
| +// SyncExec writes the WHOLE config file or none of it (RenderEnv fails if any |
| +// one decrypt fails), and instanceEnv accepts a missing file. So a manifest |
| +// that is only partly openable on a box with no rendered file would start the |
| +// instance with none of its sealed config: fresh admission needs every |
| +// envelope to open. |
| +func TestCanProvideConfigRefusesPartlyOpenableConfigOnFreshAdmission(t *testing.T) { |
| + ctx := context.Background() |
| + cases := map[string]func(e *canProvideEnv, foreign string) map[string]Var{ |
| + "one foreign-key envelope": func(e *canProvideEnv, f string) map[string]Var { |
| + return map[string]Var{"A": {Kind: "secret", Envelope: f}, "C": e.secret("ok")} |
| + }, |
| + "one corrupt envelope": func(e *canProvideEnv, f string) map[string]Var { |
| + return map[string]Var{"B": {Kind: "secret", Envelope: "not-base64!!"}, "C": e.secret("ok")} |
| + }, |
| + "an unwritable plain value": func(e *canProvideEnv, f string) map[string]Var { |
| + return map[string]Var{"P": {Kind: "env", Value: "has \"quote"}, "C": e.secret("ok")} |
| + }, |
| + "all foreign": func(e *canProvideEnv, f string) map[string]Var { |
| + return map[string]Var{"A": {Kind: "secret", Envelope: f}} |
| + }, |
| + } |
| + for name, mk := range cases { |
| + t.Run(name, func(t *testing.T) { |
| + e := newCanProvideEnv(t) |
| + foreign, err := SealUnbound(e.other.PublicKey(), []byte("z")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + vars := mk(e, foreign) |
| + e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: vars}) |
| + if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "shop", "", "a.example.com", nil)); got != ReasonCannotOpen { |
| + t.Errorf("reason = %q, want cannot_open", got) |
| + } |
| + // A file that is not there, or empty, is no evidence either. |
| + none := func() []byte { return nil } |
| + if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "shop", "", "a.example.com", none)); got != ReasonCannotOpen { |
| + t.Errorf("empty evidence reason = %q", got) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The safe fallback: the key is this deployment's (one envelope opens), the |
| +// sync pass is stalled on a stale value, and this box already delivered a |
| +// rendered file that carries every secret var. Refusing there would turn a |
| +// sync fault into an outage of a running tenant. |
| +func TestCanProvideConfigFallsBackOnlyToEvidenceOfADeliveredFile(t *testing.T) { |
| e := newCanProvideEnv(t) |
| ctx := context.Background() |
| foreign, err := SealUnbound(e.other.PublicKey(), []byte("z")) |
| @@ -142,16 +187,65 @@ func TestCanProvideConfigOneOpenableEnvelopeProvesTheKey(t *testing.T) { |
| } |
| e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: map[string]Var{ |
| "A": {Kind: "secret", Envelope: foreign}, |
| - "B": {Kind: "secret", Envelope: "not-base64!!"}, |
| "C": e.secret("ok"), |
| + "P": {Kind: "env", Value: "1"}, |
| }}) |
| - if err := CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "shop", "", "a.example.com"); err != nil { |
| - t.Fatalf("one good envelope among bad ones: %v", err) |
| + file := func(s string) func() []byte { return func() []byte { return []byte(s) } } |
| + ask := func(f func() []byte) error { |
| + return CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "shop", "", "a.example.com", f) |
| + } |
| + |
| + if err := ask(file("# header\nA=old\nC=ok\nP=1\n")); err != nil { |
| + t.Errorf("file with every secret var: %v", err) |
| + } |
| + if err := ask(file("A=old\nP=1\n")); err == nil { |
| + t.Error("a file missing secret var C counted as evidence") |
| + } |
| + if err := ask(file("XA=old\nXC=ok\n")); err == nil { |
| + t.Error("a suffix match counted as evidence") |
| + } |
| + if err := ask(file("")); err == nil { |
| + t.Error("an empty file counted as evidence") |
| + } |
| + if err := ask(nil); err == nil { |
| + t.Error("no evidence source counted as evidence") |
| + } |
| + |
| + // The key must be shown to be this deployment's: with the wrong key |
| + // nothing opens, and a leftover file is not enough. |
| + third, err := ecdh.P256().GenerateKey(randReader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + good := file("A=old\nC=ok\n") |
| + if got := reasonOf(t, CanProvideConfig(ctx, e.s, third, KeyOK, "bab", "shop", "", "a.example.com", good)); got != ReasonCannotOpen { |
| + t.Errorf("wrong key with a leftover file: reason %q", got) |
| + } |
| + // And no key at all never falls back, file or not. |
| + if got := reasonOf(t, CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "a.example.com", good)); got != ReasonNoKey { |
| + t.Errorf("no key with a leftover file: reason %q", got) |
| + } |
| +} |
| + |
| +func TestReadDeliveredConfigReadsTheSyncOwnedFile(t *testing.T) { |
| + dir := t.TempDir() |
| + rt := registry.Route{Host: "a.example.com", Kind: "instance", App: "shop", Socket: "/run/a.sock"} |
| + if got := ReadDeliveredConfig(dir, rt); got != nil { |
| + t.Errorf("no file: got %q", got) |
| + } |
| + if err := os.WriteFile(ConfigEnvPath(dir, rt), []byte("A=1\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := string(ReadDeliveredConfig(dir, rt)); got != "A=1\n" { |
| + t.Errorf("flat file: got %q", got) |
| + } |
| + // A non-regular file at the path is no evidence (and must not block). |
| + rt2 := registry.Route{Host: "b.example.com", Kind: "instance", App: "shop", Socket: "/run/b.sock"} |
| + if err := os.Mkdir(ConfigEnvPath(dir, rt2), 0o700); err != nil { |
| + t.Fatal(err) |
| } |
| - // All bad: no proof. |
| - e.put(Key("bab", "bad"), Manifest{App: "bad", Vars: map[string]Var{"A": {Kind: "secret", Envelope: foreign}}}) |
| - if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "bad", "", "a.example.com")); got != ReasonCannotOpen { |
| - t.Errorf("all-foreign reason = %q", got) |
| + if got := ReadDeliveredConfig(dir, rt2); got != nil { |
| + t.Errorf("directory at the path: got %q", got) |
| } |
| } |
| @@ -165,12 +259,12 @@ func TestCanProvideConfigOpensBoundEnvelopesUnderTheRoutesBinding(t *testing.T) |
| t.Fatal(err) |
| } |
| e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: map[string]Var{"DB": {Kind: "secret", Envelope: env}}}) |
| - if err := CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "shop", "", "a.example.com"); err != nil { |
| + if err := CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "shop", "", "a.example.com", nil); err != nil { |
| t.Fatalf("bound envelope, own app: %v", err) |
| } |
| // The same bytes copied under another app do not open there. |
| e.put(Key("bab", "thief"), Manifest{App: "thief", Vars: map[string]Var{"DB": {Kind: "secret", Envelope: env}}}) |
| - if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "thief", "", "a.example.com")); got != ReasonCannotOpen { |
| + if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "thief", "", "a.example.com", nil)); got != ReasonCannotOpen { |
| t.Errorf("copied bound envelope reason = %q", got) |
| } |
| } |
| @@ -206,7 +300,7 @@ func TestCanProvideConfigFailsClosedWhenItCannotRead(t *testing.T) { |
| priv *ecdh.PrivateKey |
| st KeyState |
| }{{e.priv, KeyOK}, {nil, KeyAbsent}} { |
| - err := CanProvideConfig(ctx, fs, k.priv, k.st, "bab", "shop", "staging", "a.example.com") |
| + err := CanProvideConfig(ctx, fs, k.priv, k.st, "bab", "shop", "staging", "a.example.com", nil) |
| if got := reasonOf(t, err); got != ReasonStoreError { |
| t.Errorf("reason = %q, want %q", got, ReasonStoreError) |
| } |
| @@ -226,11 +320,11 @@ func TestCanProvideConfigFailsClosedOnUnreadableConfig(t *testing.T) { |
| if _, err := e.s.Put(ctx, Key("bab", "garbled"), []byte("{not json"), ""); err != nil { |
| t.Fatal(err) |
| } |
| - if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "garbled", "", "a.example.com")); got != ReasonStoreError { |
| + if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "garbled", "", "a.example.com", nil)); got != ReasonStoreError { |
| t.Errorf("garbled manifest reason = %q", got) |
| } |
| e.put(Key("bab", "bound"), Manifest{App: "bound", Vars: map[string]Var{}}) |
| - if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "bound", "gone", "a.example.com")); got != ReasonStoreError { |
| + if got := reasonOf(t, CanProvideConfig(ctx, e.s, e.priv, KeyOK, "bab", "bound", "gone", "a.example.com", nil)); got != ReasonStoreError { |
| t.Errorf("missing bound environment reason = %q", got) |
| } |
| } |
| @@ -241,7 +335,7 @@ func TestCanProvideConfigHostWithNoPossibleOverlay(t *testing.T) { |
| e := newCanProvideEnv(t) |
| ctx := context.Background() |
| e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}}) |
| - if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "Mixed.Case.example.com"); err != nil { |
| + if err := CanProvideConfig(ctx, e.s, nil, KeyAbsent, "bab", "shop", "", "Mixed.Case.example.com", nil); err != nil { |
| t.Fatalf("host without a possible overlay: %v", err) |
| } |
| } |
| @@ -252,13 +346,13 @@ func TestConfigRefusalReasonIsAClosedWord(t *testing.T) { |
| ctx := context.Background() |
| v := e.secret("hunter2") |
| e.put(Key("bab", "shop"), Manifest{App: "shop", Vars: map[string]Var{"DB": v}}) |
| - err := CanProvideConfig(ctx, e.s, e.other, KeyOK, "bab", "shop", "", "a.example.com") |
| + err := CanProvideConfig(ctx, e.s, e.other, KeyOK, "bab", "shop", "", "a.example.com", nil) |
| var r *ConfigRefusal |
| if !errors.As(err, &r) { |
| t.Fatal(err) |
| } |
| switch r.Reason { |
| - case ReasonNoKey, ReasonKeyUnparseable, ReasonCannotOpen, ReasonStoreError: |
| + case ReasonNoKey, ReasonKeyUnparseable, ReasonCannotOpen, ReasonStoreError, ReasonNoSource: |
| default: |
| t.Errorf("reason %q is not one of the closed words", r.Reason) |
| } |
diff --git a/internal/host/configguard.go b/internal/host/configguard.go| index 80b00b3db..f7e5bb38d 100644 |
| --- a/internal/host/configguard.go |
| +++ b/internal/host/configguard.go |
| @@ -57,9 +57,14 @@ const ( |
| // 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. |
| +// |
| +// That is NOT proof that nothing is sealed. Config lives in the deployment |
| +// store, but CARLOS_HIBERNATE_BUCKET enables leases independently of it, so a |
| +// box can wake instances whose sealed config sits in a store it was never told |
| +// about. The guard therefore refuses (no_config_source) unless the operator |
| +// has said, with CARLOS_NO_SEALED_CONFIG=1 (or "true"), that this deployment |
| +// has no sealed config: the config-free development setup. The flag only |
| +// matters when there is no store; with one, config is read as usual. |
| var errNoDeployStore = errors.New("neither CARLOS_DEPLOYMENT_BUCKET nor CARLOS_DEPLOYMENT_DIR is set") |
| type guardRefusal struct { |
| @@ -79,6 +84,13 @@ type configGuard struct { |
| keyState appconfig.KeyState |
| openStore func(context.Context) (release.Store, error) |
| now func() time.Time |
| + // dataDir is where SyncExec renders <host>.config.env, the evidence a |
| + // partly openable manifest may fall back on (appconfig.CanProvideConfig). |
| + dataDir string |
| + // noSealedConfig is the operator's explicit "this deployment has no |
| + // sealed config" (CARLOS_NO_SEALED_CONFIG), honoured only when there is |
| + // no deployment store at all. |
| + noSealedConfig bool |
| mu sync.Mutex |
| store release.Store |
| @@ -93,9 +105,17 @@ func newConfigGuard(getenv func(string) string, openStore func(context.Context) |
| g := &configGuard{ |
| openStore: openStore, |
| now: time.Now, |
| + dataDir: "/data", |
| verdicts: map[string]guardVerdict{}, |
| refused: map[string]*guardRefusal{}, |
| } |
| + if d := getenv("CARLOS_DATA_DIR"); d != "" { |
| + g.dataDir = d |
| + } |
| + switch strings.ToLower(strings.TrimSpace(getenv("CARLOS_NO_SEALED_CONFIG"))) { |
| + case "1", "true": |
| + g.noSealedConfig = true |
| + } |
| raw := strings.TrimSpace(getenv("CARLOS_SECRETS_KEY")) |
| switch { |
| case raw == "": |
| @@ -114,11 +134,16 @@ func newConfigGuard(getenv func(string) string, openStore func(context.Context) |
| // 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. |
| +// belongs to an app. Static sites, addr-backed routes, Backing=unit routes |
| +// (whose config the root adopt pass delivers), platform routes and app-less |
| +// rows take no part: the guard must not touch them. |
| +// |
| +// The exclusion is Backing and nothing else, because Backing is what |
| +// SplitBackend dispatches on. A socket-backed hibernating instance may carry a |
| +// Unit name with an empty Backing (the record validator and `carlos route |
| +// -unit` allow it) and still reaches ExecBackend.Acquire. |
| func needsBoxConfig(rt registry.Route) bool { |
| - if rt.App == "" || rt.Platform || rt.Unit != "" || rt.Backing == registry.BackingUnit { |
| + if rt.App == "" || rt.Platform || rt.Backing == registry.BackingUnit { |
| return false |
| } |
| switch { |
| @@ -171,12 +196,16 @@ func (g *configGuard) cached(host string, now time.Time) error { |
| 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 g.noSealedConfig { |
| + return nil // the operator said this deployment has none |
| + } |
| + return &appconfig.ConfigRefusal{Reason: appconfig.ReasonNoSource, Err: err} |
| } |
| 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) |
| + delivered := func() []byte { return appconfig.ReadDeliveredConfig(g.dataDir, rt) } |
| + return appconfig.CanProvideConfig(ctx, s, g.priv, g.keyState, release.RouteAccount(rt), rt.App, rt.Environment, rt.Host, delivered) |
| } |
| // deployStore opens the store on first use and keeps it. A failure is not |
diff --git a/internal/host/configguard_test.go b/internal/host/configguard_test.go| index 35939a853..8cb7bbe8f 100644 |
| --- a/internal/host/configguard_test.go |
| +++ b/internal/host/configguard_test.go |
| @@ -10,6 +10,7 @@ import ( |
| "errors" |
| "fmt" |
| "log" |
| + "os" |
| "strings" |
| "sync" |
| "testing" |
| @@ -31,6 +32,8 @@ type guardEnv struct { |
| priv *ecdh.PrivateKey |
| other *ecdh.PrivateKey |
| clock *guardClock |
| + // dataDir is where the guard looks for a previously rendered config file. |
| + dataDir string |
| } |
| type guardClock struct { |
| @@ -85,7 +88,8 @@ func newGuardEnv(t *testing.T) *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)}, |
| + clock: &guardClock{t: time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)}, |
| + dataDir: t.TempDir(), |
| } |
| } |
| @@ -113,8 +117,11 @@ func (e *guardEnv) putConfig(acct, app string, vars map[string]appconfig.Var) { |
| // and this env's store. |
| func (e *guardEnv) guard(keyB64 string) *configGuard { |
| g := newConfigGuard(func(k string) string { |
| - if k == "CARLOS_SECRETS_KEY" { |
| + switch k { |
| + case "CARLOS_SECRETS_KEY": |
| return keyB64 |
| + case "CARLOS_DATA_DIR": |
| + return e.dataDir |
| } |
| return "" |
| }, func(context.Context) (release.Store, error) { return e.store, nil }) |
| @@ -157,7 +164,11 @@ func TestNeedsConfigIsTheShapeTheActivatorSpawns(t *testing.T) { |
| {"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}, |
| + {"unit-backed sidecar", registry.Route{Host: "a", Kind: "sidecar", App: "shop", Backing: registry.BackingUnit}, false}, |
| + // Dispatch is on Backing alone (SplitBackend.pick): a socket-backed |
| + // instance that merely NAMES a unit still reaches ExecBackend.Acquire. |
| + {"unit-named, socket-backed, empty Backing", registry.Route{Host: "a", Kind: "instance", App: "shop", Socket: "/s", Unit: "x.service", Hibernate: true}, true}, |
| + {"unit-named, addr-backed", 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}, |
| @@ -324,25 +335,110 @@ func TestGuardIgnoresRoutesItDoesNotSpawn(t *testing.T) { |
| } |
| } |
| -// 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) { |
| +// Config lives in the deployment store, and CARLOS_HIBERNATE_BUCKET enables |
| +// leases without one. A box with no store configured therefore cannot know |
| +// that nothing is sealed, and refuses placed exec instances, unless the |
| +// operator has said explicitly that this deployment has no sealed config. |
| +func TestGuardNoDeployStoreFailsClosedUnlessOptedOut(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) |
| + build := func(env map[string]string) *configGuard { |
| + g := newConfigGuard(func(k string) string { return env[k] }, |
| + func(context.Context) (release.Store, error) { return nil, errNoDeployStore }) |
| + g.now = e.clock.now |
| + return g |
| } |
| + g := build(nil) |
| + err := g.Gate(context.Background(), guardRoute("a.example.com")) |
| + if got := reasonOfGate(t, err); got != appconfig.ReasonNoSource { |
| + t.Fatalf("no store, no opt-out: reason = %q, want no_config_source", got) |
| + } |
| + if list, _ := g.Refusals(); len(list) != 1 || list[0].Reason != "no_config_source" { |
| + t.Errorf("refusals = %+v", list) |
| + } |
| + |
| + for _, v := range []string{"1", "true", " TRUE "} { |
| + g := build(map[string]string{"CARLOS_NO_SEALED_CONFIG": v}) |
| + if err := g.Gate(context.Background(), guardRoute("a.example.com")); err != nil { |
| + t.Errorf("CARLOS_NO_SEALED_CONFIG=%q: %v", v, err) |
| + } |
| + } |
| + for _, v := range []string{"", "0", "no", "yes please"} { |
| + g := build(map[string]string{"CARLOS_NO_SEALED_CONFIG": v}) |
| + if err := g.Gate(context.Background(), guardRoute("a.example.com")); err == nil { |
| + t.Errorf("CARLOS_NO_SEALED_CONFIG=%q opted out", v) |
| + } |
| + } |
| + |
| + // The opt-out is only about having NO store. With one, config is read. |
| + e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")}) |
| + withStore := newConfigGuard(func(k string) string { |
| + if k == "CARLOS_NO_SEALED_CONFIG" { |
| + return "1" |
| + } |
| + return "" |
| + }, func(context.Context) (release.Store, error) { return e.store, nil }) |
| + withStore.now = e.clock.now |
| + if got := reasonOfGate(t, withStore.Gate(context.Background(), guardRoute("a.example.com"))); got != appconfig.ReasonNoKey { |
| + t.Errorf("opt-out with a real store and sealed config: reason = %q, want no_key", got) |
| + } |
| + |
| + // A store that is configured but will not open is "cannot tell". |
| 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 { |
| + if got := reasonOfGate(t, broken.Gate(context.Background(), guardRoute("a.example.com"))); got != appconfig.ReasonStoreError { |
| t.Errorf("unopenable store reason = %q, want store_error", got) |
| } |
| } |
| +// A route whose Unit is named but whose Backing is empty is exec-managed and |
| +// gated like any other. |
| +func TestGuardGatesAUnitNamedExecInstance(t *testing.T) { |
| + e := newGuardEnv(t) |
| + e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")}) |
| + g := e.guard("") |
| + rt := guardRoute("a.example.com") |
| + rt.Unit = "x.service" |
| + if got := reasonOfGate(t, g.Gate(context.Background(), rt)); got != appconfig.ReasonNoKey { |
| + t.Errorf("reason = %q, want no_key", got) |
| + } |
| + rt.Backing = registry.BackingUnit |
| + if err := g.Gate(context.Background(), rt); err != nil { |
| + t.Errorf("a true unit-backed route was gated: %v", err) |
| + } |
| +} |
| + |
| +// Partly openable config on a box with no rendered file is refused (SyncExec |
| +// would write nothing and the instance would start with none of it); with a |
| +// file this box already delivered, covering every secret var, it is admitted. |
| +func TestGuardPartlyOpenableConfigNeedsADeliveredFile(t *testing.T) { |
| + e := newGuardEnv(t) |
| + foreign, err := appconfig.SealUnbound(e.other.PublicKey(), []byte("z")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + e.putConfig("bab", "shop", map[string]appconfig.Var{ |
| + "A": {Kind: "secret", Envelope: foreign}, |
| + "C": e.seal("ok"), |
| + }) |
| + g := e.guard(keyB64(e.priv)) |
| + rt := guardRoute("a.example.com") |
| + |
| + if got := reasonOfGate(t, g.Gate(context.Background(), rt)); got != appconfig.ReasonCannotOpen { |
| + t.Fatalf("fresh box: reason = %q, want cannot_open", got) |
| + } |
| + |
| + // The same box after sync once delivered a file for this host. |
| + e.clock.advance(configGuardRefuseTTL + time.Second) |
| + if err := os.WriteFile(appconfig.ConfigEnvPath(e.dataDir, rt), []byte("A=old\nC=ok\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := g.Gate(context.Background(), rt); err != nil { |
| + t.Fatalf("box with a delivered file covering both secrets: %v", err) |
| + } |
| +} |
| + |
| func TestGuardRefusalsAreSortedCappedAndPruned(t *testing.T) { |
| e := newGuardEnv(t) |
| e.putConfig("bab", "shop", map[string]appconfig.Var{"DB": e.seal("pw")}) |