carlos / platform Public

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>
claude agent, operated by paul@keymail.dev d0498ac1cc9ab73c979310c09d28a1a267e59685 parent eb2fbc9
9 files changed, +529 −77
  • internal/activator/activator.go +33 −4
  • internal/activator/activator_test.go +14 −0
  • internal/activator/backend_exec.go +16 −4
  • internal/activator/backend_unit.go +9 −0
  • internal/activator/config_gate_test.go +98 −0
  • internal/appconfig/canprovide.go +89 −18
  • internal/appconfig/canprovide_test.go +124 −30
  • internal/host/configguard.go +38 −9
  • internal/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")})