carlos / platform Public

activator: nothing is minted until the row's configuration is delivered (final review I2, Astra P2; minor 2)

A first wake can land before the config sync's first render: the
fast-converge pass reconciles the row ready and syncs config last, so a
request in between found no <host>.config.env, Effective saw no key, and
the platform minted one that shadowed the configured key for good once it
arrived. SyncExec removes the file for a route that was never configured,
so its existence cannot be the delivery signal; the mint now asks the
bucket, as the replica gate does. ExecBackend.ConfiguredKey (the field the
gate called ReplicaConfigKey) is read once, under adminTokenMu, only when a
mint is next: a key the configuration sets and the box has not delivered
defers the launch with ErrInstanceKeyDeferred, so does a bucket that
cannot be read, and a configuration that sets no key lets the mint go
ahead. Nil is a box with no configuration source. The launch fails and is
retried; the next start finds the key configured. Each read is bounded by
configReadTimeout and the caller's context; the startup backfill runs its
whole pass under one budget and counts a deferred row apart from a
failure.

The host's reader follows the config guard's rule for which boxes have a
configuration to read: no secrets key still renders plain values, no
deployment store is an error unless CARLOS_NO_SEALED_CONFIG says there is
no configuration at all.

Minor 2: the zero outcome is InstanceKeyFailed, what every ensure answers
beside its error, so no caller can count a refused row by reading the
outcome first. The exported ensure wrappers, used only by tests, are now
ensureInstanceKeyNoConfig and ensureInstanceKeyBeneathNoConfig, named for
what they skip.

Tests: the ensure's three answers and that the bucket is read only at a
mint; the read's bounds; a first start with delivery delayed, end to end,
then the configured key delivered and no key minted beside it; the same
on the claim path; the reader's guard semantics; the backfill's budget and
deferred count.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 96a5ac092397f698237b3f06320e0c116d3882d3 parent c74d0cd
21 files changed, +582 −185
  • docs/superpowers/plans/2026-10-10-instance-key-provision.md +29 −29
  • internal/activator/backend_exec.go +14 −7
  • internal/activator/claim_snapshot.go +1 −1
  • internal/activator/instancekey.go +106 −34
  • internal/activator/instancekey_delivery_test.go +186 −0
  • internal/activator/instancekey_linux_test.go +4 −4
  • internal/activator/instancekey_test.go +30 −30
  • internal/activator/prepare.go +2 −1
  • internal/activator/replica_instancekey_linux_test.go +4 −4
  • internal/activator/replica_instancekey_test.go +8 −8
  • internal/activator/replicakey.go +4 −9
  • internal/activator/replicakey_race_test.go +2 −2
  • internal/host/agent.go +1 −1
  • internal/host/claim_instancekey_test.go +32 −0
  • internal/host/claim_replica_instancekey_test.go +1 −1
  • internal/host/configguard.go +11 −4
  • internal/host/instancekeys.go +39 −16
  • internal/host/instancekeys_test.go +40 −8
  • internal/host/layoutleg.go +1 −1
  • internal/host/replicahomekeys.go +24 −14
  • internal/host/replicahomekeys_test.go +43 −11
diff --git a/docs/superpowers/plans/2026-10-10-instance-key-provision.md b/docs/superpowers/plans/2026-10-10-instance-key-provision.md
index 1e16b5bbc..80f1939c6 100644
--- a/docs/superpowers/plans/2026-10-10-instance-key-provision.md
+++ b/docs/superpowers/plans/2026-10-10-instance-key-provision.md
@@ -46,7 +46,7 @@ Astra's round 5 (`CODEX-PLAN-INSTANCE-KEY-R5.md`, reviewed at 37c48a40) accepted
| # | Finding | Disposition | Change | Test |
|---|---|---|---|---|
-| R5-1 | P2. A stale digest admits the wrong key after an ordinary configuration change. `SyncExec` reconciles each box separately and hourly, so the home can hold B while the replica still holds A and still matches A's entry. | **Accepted** (ruling 27). Verified: `configSyncLoop` runs hourly per box (agent.go:1078). | A configured key is never changed in place; that would be a rotation, which this plan does not support. The rollout gives the procedure for any configured-key change on an app with replicas. There is also a cheap fresh-evidence check that needs no O8: a replica whose key comes from its configuration is started only if the bucket's configuration renders that same key now (`appconfig.ConfiguredKeyDigest`, through `ExecBackend.ReplicaConfigKey`). That catches a shared configuration change the replica's box has not synced. The list itself is written only by the pre-roll check, never refreshed from a replica's configuration. | `TestReplicaLaunchWithoutAControlSocket`: "the home updated, this replica's configuration delayed: refused" (it fails with the fresh check removed); "the bucket cannot be read: refused"; "no fresh reader: refused"; "residual: a change only on the home is not seen". Also `TestConfiguredKeyDigestReadsTheBucketNow` and `TestReplicaConfigKeyReadsTheBucketNow` |
+| R5-1 | P2. A stale digest admits the wrong key after an ordinary configuration change. `SyncExec` reconciles each box separately and hourly, so the home can hold B while the replica still holds A and still matches A's entry. | **Accepted** (ruling 27). Verified: `configSyncLoop` runs hourly per box (agent.go:1078). | A configured key is never changed in place; that would be a rotation, which this plan does not support. The rollout gives the procedure for any configured-key change on an app with replicas. There is also a cheap fresh-evidence check that needs no O8: a replica whose key comes from its configuration is started only if the bucket's configuration renders that same key now (`appconfig.ConfiguredKeyDigest`, through `ExecBackend.ConfiguredKey`). That catches a shared configuration change the replica's box has not synced. The list itself is written only by the pre-roll check, never refreshed from a replica's configuration. | `TestReplicaLaunchWithoutAControlSocket`: "the home updated, this replica's configuration delayed: refused" (it fails with the fresh check removed); "the bucket cannot be read: refused"; "no fresh reader: refused"; "residual: a change only on the home is not seen". Also `TestConfiguredKeyDigestReadsTheBucketNow` and `TestReplicaConfigKeyReadsTheBucketNow` |
| R5-2 | P2. O8 has no source for a configured key's value: the published `<host>.env` leaves it out (ruling 5), and the adoption record holds only its digest. | **Accepted** (ruling 28). | O8 delivers only a key the platform minted into the home's `<host>.env`. A home whose key exists only in configuration is explicitly deferred: its keyless replica keeps refusing, and its configured-key replica follows ruling 27. | `TestReplicaLaunchWithoutAControlSocket`: "a config-only home's keyless replica is refused" |
| R5-3 | P3. A malformed list entry can reach the log unredacted: a bare digest with no `replica-host=` lands in the error. | **Accepted.** | `ParseHomeKeys` reports the entry's position and the reason it was rejected, never the entry's text. The agent logs that error and nothing else. | `TestParseHomeKeys` (no error echoes its input); `TestReplicaHomeKeysLogsAMalformedListWithoutItsText` (the logging path) |
@@ -315,7 +315,7 @@ Source (c) and `ConfiguredInstanceKey` are gone: a configured key is covered by
**27. A configured key is never changed in place (rev 5; Paul's ruling; Astra round 5 finding 1).** Changing a configured `CARLOS_INSTANCE_KEY` on an app that has data is a key rotation, and this plan does not support rotation. `SyncExec` reconciles each box on its own schedule, hourly, so during any configured-key change the home and a replica can briefly hold different keys.
- **The procedure** (rollout step 1 and the operator note): stop the app's replicas; remove their entries from the box's list and check that the file no longer names them (the agent keeps no copy, ruling 30); change the key; let every box sync; re-run the pre-roll check, which writes the new entries; then start the replicas.
-- **The safety net.** On every replica launch whose key comes from its configuration, the agent checks two things. The key's digest must equal the replica's list entry, which only the pre-roll check writes and nothing refreshes from the replica's configuration. And the bucket's configuration must render that same key now (`ReplicaConfigKey`, a fresh read on every such launch). Any mismatch, or a read that fails, refuses the launch with a log line naming the host and the reason.
+- **The safety net.** On every replica launch whose key comes from its configuration, the agent checks two things. The key's digest must equal the replica's list entry, which only the pre-roll check writes and nothing refreshes from the replica's configuration. And the bucket's configuration must render that same key now (`ConfiguredKey`, a fresh read on every such launch). Any mismatch, or a read that fails, refuses the launch with a log line naming the host and the reason.
- **The fresh read is the cheap fresh evidence that works without O8.** A shared configuration change reaches the bucket before any box syncs it, so a replica whose box lags the home is refused.
*Residual (procedure not followed):* a change the bucket does not hold is invisible to the replica's box. Examples are the home's own `<host>.env` edited by hand, or a per-host key configured on the home alone. The replica's list entry and the bucket still agree, so it runs with the old key. A test documents this case; only the procedure, or O8's fresh evidence for platform keys, closes it.
@@ -750,7 +750,7 @@ Two changes to the ask above, and only the second is new:
- Modify `internal/activator/admintoken.go` (`parseEnvBytes` calls `instancekey.ParseEnv`) and `internal/activator/backend_exec.go` (`mergeEnv`, `LaunchEnvironment`'s shadow list).
- Create `internal/activator/instancekey.go`: the mint (`EnsureInstanceKey`, `EnsureInstanceKeyBeneath`, `ensureInstanceKeyFor`, `BackfillInstanceKey`, `settleClaimInstanceEnv`, `InstanceKeyVar`, `InstanceKeyOutcome`).
- Create `internal/activator/instancekey_test.go`, `instancekey_linux_test.go`, `replica_instancekey_test.go`, `replica_instancekey_linux_test.go`, `claim_instancekey_test.go` and `claimretry_instancekey_linux_test.go`.
-- Create `internal/activator/replicakey.go` (`replicaKeyGate`, `ErrReplicaKey`), `internal/host/replicahomekeys.go` (`replicaHomeKeys`, `replicaConfigKey`) and `internal/appconfig/instancekey.go` (`ConfiguredKeyDigest`), with `internal/host/replicahomekeys_test.go`, `internal/appconfig/instancekey_test.go` and `internal/activator/replicakey_race_test.go`. Modify `internal/activator/backend_exec.go` (`ExecBackend.ReplicaHomeKeys` and `ReplicaConfigKey`; the gate in `seedChild` and `spawnChildFrom`), `internal/activator/prepare.go` and `internal/host/agent.go` (Task 3b).
+- Create `internal/activator/replicakey.go` (`replicaKeyGate`, `ErrReplicaKey`), `internal/host/replicahomekeys.go` (`replicaHomeKeys`, `configuredKey`) and `internal/appconfig/instancekey.go` (`ConfiguredKeyDigest`), with `internal/host/replicahomekeys_test.go`, `internal/appconfig/instancekey_test.go` and `internal/activator/replicakey_race_test.go`. Modify `internal/activator/backend_exec.go` (`ExecBackend.ReplicaHomeKeys` and `ConfiguredKey`; the gate in `seedChild` and `spawnChildFrom`), `internal/activator/prepare.go` and `internal/host/agent.go` (Task 3b).
- Modify `internal/activator/backend_exec.go` (`seedChild` :831, `spawnChildFrom` :1369), `internal/activator/prepare.go` (:60) and `internal/activator/claim_snapshot.go` (the snapshot's fields, `EnsureClaimAdminToken` :40-67, `SnapshotClaimLaunch`'s env read).
- Create `internal/activator/keyadoption.go`, `keyadoption_test.go` and `keyadoption_linux_test.go`: the adoption verb, mounted by O6.
- Create `internal/hostfiles/relay.go` and `relay_test.go`: `PlatformRelay`.
@@ -2573,12 +2573,12 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
**Files:**
- Create: `internal/activator/replicakey.go`, `internal/host/replicahomekeys.go`, `internal/appconfig/instancekey.go`
-- Modify: `internal/activator/backend_exec.go` (`ExecBackend.ReplicaHomeKeys` and `ReplicaConfigKey`; the gate in `seedChild`, `spawnChildFrom`, `StartWorkerStaged` and `startSidecarChildFrom`), `internal/activator/prepare.go` (`prepareAt`), `internal/host/agent.go` (the wiring)
+- Modify: `internal/activator/backend_exec.go` (`ExecBackend.ReplicaHomeKeys` and `ConfiguredKey`; the gate in `seedChild`, `spawnChildFrom`, `StartWorkerStaged` and `startSidecarChildFrom`), `internal/activator/prepare.go` (`prepareAt`), `internal/host/agent.go` (the wiring)
- Test: `internal/activator/replica_instancekey_test.go`, `internal/activator/replica_instancekey_linux_test.go`, `internal/activator/replicakey_race_test.go`, `internal/host/replicahomekeys_test.go`, `internal/appconfig/instancekey_test.go`
**Interfaces:**
- Consumes: Task 1's `instancekey.Digest`, `Effective` and `ParseHomeKeys`; appconfig's `LoadMergedHostVersions`, `RenderEnv`, `routeBinding`, and test kit `execKit`, `SetEnv`, `ApplyInstance`, `Seal`; Task 2's `InstanceKeyReplica` (never minted); test helpers `testExecBackend`, `tmpSock`, `nestedBackend`, `readEnvFile`.
-- Produces: `ExecBackend.ReplicaHomeKeys func(host string) (sha256 string, ok bool)` and `ReplicaConfigKey func(ctx, rt) (sha256 string, err error)`; `(*ExecBackend).replicaKeyGate(rt, env, layer, haveControl) error`, with `layer` the one the env's key came from, decided by the same read that composed the env (`instanceEnvLayer`, ruling 29); `activator.ErrReplicaKey`; `appconfig.ConfiguredKeyDigest(ctx, s, priv, rt) (string, error)`; `host.replicaHomeKeys(read)`, which parses the list the reader returns (`/etc/carlos/replica-home-keys`, read again at every replica launch; ruling 30), and `host.replicaConfigKey(getenv, open)`, the fresh read (ruling 27). Task 4's snapshot calls the gate.
+- Produces: `ExecBackend.ReplicaHomeKeys func(host string) (sha256 string, ok bool)` and `ConfiguredKey func(ctx, rt) (sha256 string, err error)`; `(*ExecBackend).replicaKeyGate(rt, env, layer, haveControl) error`, with `layer` the one the env's key came from, decided by the same read that composed the env (`instanceEnvLayer`, ruling 29); `activator.ErrReplicaKey`; `appconfig.ConfiguredKeyDigest(ctx, s, priv, rt) (string, error)`; `host.replicaHomeKeys(read)`, which parses the list the reader returns (`/etc/carlos/replica-home-keys`, read again at every replica launch; ruling 30), and `host.configuredKey(getenv, open)`, the fresh read (ruling 27). Task 4's snapshot calls the gate.
**Built:** two launch paths rev 5.1 left ungated pass the gate as a seed does (no control socket: the home's verified key or none, refused otherwise), by the ledger's rulings on the 3b build and its review. The sidecar worker (`StartWorkerStaged`) reads the env and its key's layer in one read, and when the agent-issued `extra` carries `CARLOS_INSTANCE_KEY` the layer is set to none, so the gate refuses it as a key of unknown origin: a worker never mints and `extra` has no business carrying a key. The legacy co-spawned sidecar (`startSidecarChildFrom`) gates its own env file's key the same way (`sidecarKeyLayer`). Refusal and log text name the file path, not the variable the plan calls the list by. The bucket read in the gate runs on its own bounded context (`prepareAt` has none to thread).
@@ -2714,13 +2714,13 @@ type replicaCase struct {
start string // "refused" (no app runs) or the helper outcome wanted
wantKeyFromOwnOK bool // a home row: the outcome is the key minted into <host>.env
// bucket is the key the replica's configuration in the bucket renders
- // now (ReplicaConfigKey; "" renders none). bucketDown makes that read
- // fail; noBucket leaves ReplicaConfigKey nil.
+ // now (ConfiguredKey; "" renders none). bucketDown makes that read
+ // fail; noBucket leaves ConfiguredKey nil.
bucket string
bucketDown, noBucket bool
}
-// freshConfig is ExecBackend.ReplicaConfigKey for one case.
+// freshConfig is ExecBackend.ConfiguredKey for one case.
func freshConfig(tc replicaCase) func(context.Context, registry.Route) (string, error) {
if tc.noBucket {
return nil
@@ -2787,7 +2787,7 @@ func TestReplicaLaunchWithoutAControlSocket(t *testing.T) {
return instancekey.Digest(tc.homeKey), h == rt.Host
}
}
- b.ReplicaConfigKey = freshConfig(tc)
+ b.ConfiguredKey = freshConfig(tc)
out := stageHelperApp(t, b, rt.Host, "v1")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
@@ -2841,7 +2841,7 @@ func TestReplicaSeedIsGated(t *testing.T) {
}
}
b.ReplicaHomeKeys = func(h string) (string, bool) { return instancekey.Digest(homeKey), true }
- b.ReplicaConfigKey = freshConfig(replicaCase{bucket: homeKey})
+ b.ConfiguredKey = freshConfig(replicaCase{bucket: homeKey})
out := stageHelperApp(t, b, rt.Host, "v1")
err := b.Seed(context.Background(), rt)
got := outcomeOf(out)
@@ -2938,7 +2938,7 @@ func TestNestedReplicaPrepareIsGated(t *testing.T) {
b, rt := nestedBackend(t, &fakeControl{inv: "inv1"}, &fakeUnit{})
rt.Replica = true
b.ReplicaHomeKeys = func(string) (string, bool) { return instancekey.Digest(homeKey), true }
- b.ReplicaConfigKey = func(context.Context, registry.Route) (string, error) { return instancekey.Digest(homeKey), nil }
+ b.ConfiguredKey = func(context.Context, registry.Route) (string, error) { return instancekey.Digest(homeKey), nil }
dir := provision.InstanceDir(b.DataDir, rt.Host)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
@@ -3055,7 +3055,7 @@ func TestReplicaGateDecidesOnTheLaunchesOwnRead(t *testing.T) {
t.Fatal(err)
}
b.ReplicaHomeKeys = func(string) (string, bool) { return instancekey.Digest(keyA), true }
- b.ReplicaConfigKey = func(context.Context, registry.Route) (string, error) { return "", nil } // the bucket: key removed
+ b.ConfiguredKey = func(context.Context, registry.Route) (string, error) { return "", nil } // the bucket: key removed
syncs := 0
replicaGateHook = func() {
syncs++
@@ -3100,7 +3100,7 @@ func TestReplicaGateReadsNoEnvFile(t *testing.T) {
bucket string
ok bool
}{{keyA, true}, {"", false}} {
- b.ReplicaConfigKey = func(context.Context, registry.Route) (string, error) {
+ b.ConfiguredKey = func(context.Context, registry.Route) (string, error) {
if tc.bucket == "" {
return "", nil
}
@@ -3218,7 +3218,7 @@ func TestReplicaConfigKeyReadsTheBucketNow(t *testing.T) {
rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", App: "shop", Account: "acct", Replica: true}
opened := 0
open := func(context.Context) (release.Store, error) { opened++; return s, nil }
- read := replicaConfigKey(func(k string) string {
+ read := configuredKey(func(k string) string {
if k == "CARLOS_SECRETS_KEY" {
return priv
}
@@ -3232,11 +3232,11 @@ func TestReplicaConfigKeyReadsTheBucketNow(t *testing.T) {
if opened != 1 {
t.Fatalf("the store was opened %d times, want once", opened)
}
- failing := replicaConfigKey(func(string) string { return priv }, func(context.Context) (release.Store, error) { return nil, errors.New("bucket unreachable") })
+ failing := configuredKey(func(string) string { return priv }, func(context.Context) (release.Store, error) { return nil, errors.New("bucket unreachable") })
if _, err := failing(ctx, rt); err == nil {
t.Fatal("an unreachable bucket was not an error")
}
- if _, err := replicaConfigKey(func(string) string { return "" }, open)(ctx, rt); err == nil {
+ if _, err := configuredKey(func(string) string { return "" }, open)(ctx, rt); err == nil {
t.Fatal("no secrets key was not an error")
}
}
@@ -3245,7 +3245,7 @@ func TestReplicaConfigKeyReadsTheBucketNow(t *testing.T) {
- [ ] **Step 2: Run them to verify they fail**
Run: `nice -n 19 go test ./internal/activator/ -run 'TestReplicaLaunchWithoutAControlSocket|TestReplicaSeedIsGated|TestReplicaGate|TestKeylessReplica|TestNestedReplicaPrepareIsGated' -count=1 -v; nice -n 19 go test ./internal/host/ -run 'TestReplicaHomeKeys|TestReplicaConfigKey' -count=1; nice -n 19 go test ./internal/appconfig/ -run TestConfiguredKeyDigest -count=1`
-Expected: build failures, `undefined: ErrReplicaKey` (and `ReplicaHomeKeys`, `ReplicaConfigKey`, `replicaHomeKeys`, `replicaConfigKey`, `ConfiguredKeyDigest`). In the scratch build, with the gate present but disabled, the keyless and differing-key cases failed: the app reported "minted", or ran with the wrong key. That is Astra's round 4 failure. With only the fresh read disabled, "the home updated, this replica's configuration delayed" failed: the app ran with the old key. That is Astra's round 5 failure. With rev 5's second read of the files restored in the gate, `TestReplicaGateDecidesOnTheLaunchesOwnRead` failed: the app ran with A. That is Astra's round 6 failure.
+Expected: build failures, `undefined: ErrReplicaKey` (and `ReplicaHomeKeys`, `ConfiguredKey`, `replicaHomeKeys`, `configuredKey`, `ConfiguredKeyDigest`). In the scratch build, with the gate present but disabled, the keyless and differing-key cases failed: the app reported "minted", or ran with the wrong key. That is Astra's round 4 failure. With only the fresh read disabled, "the home updated, this replica's configuration delayed" failed: the app ran with the old key. That is Astra's round 5 failure. With rev 5's second read of the files restored in the gate, `TestReplicaGateDecidesOnTheLaunchesOwnRead` failed: the app ran with A. That is Astra's round 6 failure.
- [ ] **Step 3: Write the gate**
@@ -3264,7 +3264,7 @@ package activator
// digest is its entry in the box's verified list (ReplicaHomeKeys, which
// only the pre-roll check writes, read again at every launch) and, when
// the key comes from the replica's configuration, the configuration in
-// the bucket renders that same key now (ReplicaConfigKey). Where the key
+// the bucket renders that same key now (ConfiguredKey). Where the key
// came from is decided by the same read that composed the env the child
// gets (instanceEnvLayer, ruling 29), never by reading the files again. A key that differs from either, or
// that cannot be checked, refuses the launch with a log line. The fresh
@@ -3297,8 +3297,8 @@ import (
// ErrReplicaKey is a replica launch refused by replicaKeyGate.
var ErrReplicaKey = errors.New("activator: replica launch refused: its instance key is not its home's")
-// replicaConfigTimeout bounds replicaKeyGate's fresh read of the bucket.
-const replicaConfigTimeout = 10 * time.Second
+// configReadTimeout bounds replicaKeyGate's fresh read of the bucket.
+const configReadTimeout = 10 * time.Second
// instanceEnvLayer is instanceEnv returning, from the same read, the layer
// its CARLOS_INSTANCE_KEY came from (ruling 29). mergeEnv delivers exactly
@@ -3359,12 +3359,12 @@ func (b *ExecBackend) replicaKeyGate(rt registry.Route, env map[string]string, l
default:
return refuse("where its " + instancekey.Var + " came from is unknown")
}
- if b.ReplicaConfigKey == nil {
+ if b.ConfiguredKey == nil {
return refuse("its configured " + instancekey.Var + " cannot be checked against the bucket's configuration")
}
- ctx, cancel := context.WithTimeout(context.Background(), replicaConfigTimeout)
+ ctx, cancel := context.WithTimeout(context.Background(), configReadTimeout)
defer cancel()
- fresh, err := b.ReplicaConfigKey(ctx, rt)
+ fresh, err := b.ConfiguredKey(ctx, rt)
switch {
case err != nil:
return refuse("the bucket's configuration could not be read: " + err.Error())
@@ -3386,12 +3386,12 @@ In `internal/activator/backend_exec.go`, add the two fields to `ExecBackend`, af
// a host, means the home key cannot be verified, and a replica launch
// that would be handed a key is refused (replicaKeyGate).
ReplicaHomeKeys func(host string) (sha256 string, ok bool)
- // ReplicaConfigKey reads rt's configuration from the bucket now and
+ // ConfiguredKey reads rt's configuration from the bucket now and
// returns the SHA-256 of the CARLOS_INSTANCE_KEY it renders, "" for
// none (appconfig.ConfiguredKeyDigest). replicaKeyGate refuses a
// replica whose configured key is not that digest: a configuration
// change is in flight (ruling 27). Nil refuses every configured key.
- ReplicaConfigKey func(ctx context.Context, rt registry.Route) (sha256 string, err error)
+ ConfiguredKey func(ctx context.Context, rt registry.Route) (sha256 string, err error)
```
In `seedChild`, read the env and its key's layer together (`instanceEnvLayer`, in `replicakey.go`), then gate:
@@ -3564,12 +3564,12 @@ func replicaHomeKeys(read func() ([]byte, error)) func(host string) (string, boo
}
}
-// replicaConfigKey is ExecBackend.ReplicaConfigKey (ruling 27): the digest
+// configuredKey is ExecBackend.ConfiguredKey (ruling 27): the digest
// of the key rt's configuration in the bucket renders now, read with the
// box's secrets key through the deploy store. The store and key are opened
// on first use and kept once both open; a failure is returned, so the
// replica is refused, and is tried again at its next launch.
-func replicaConfigKey(getenv func(string) string, open func(context.Context) (release.Store, error)) func(context.Context, registry.Route) (string, error) {
+func configuredKey(getenv func(string) string, open func(context.Context) (release.Store, error)) func(context.Context, registry.Route) (string, error) {
var (
mu sync.Mutex
store release.Store
@@ -3611,7 +3611,7 @@ In `internal/host/agent.go`, after the `ExecBackend` literal (beside `eb.Logs`):
eb.ReplicaHomeKeys = replicaHomeKeys(func() ([]byte, error) { return os.ReadFile(replicaHomeKeysPath) })
// The bucket's configuration, read fresh at each replica launch
// whose key is configured (ruling 27).
- eb.ReplicaConfigKey = replicaConfigKey(os.Getenv, openDeployStore)
+ eb.ConfiguredKey = configuredKey(os.Getenv, openDeployStore)
```
- [ ] **Step 4: Run the tests, then the launch tests around them**
@@ -3965,7 +3965,7 @@ func TestClaimReplicaLaunchIsGated(t *testing.T) {
f := newAdminTokenFixture(t, "box-1")
f.rt.Replica = true
f.backend.ReplicaHomeKeys = func(string) (string, bool) { return instancekey.Digest(homeKey), true }
- f.backend.ReplicaConfigKey = func(context.Context, registry.Route) (string, error) { return instancekey.Digest(homeKey), nil }
+ f.backend.ConfiguredKey = func(context.Context, registry.Route) (string, error) { return instancekey.Digest(homeKey), nil }
own := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n"
if err := os.WriteFile(f.envPath(), []byte(own), 0o600); err != nil {
t.Fatal(err)
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index 7566a90b3..f327f208e 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -104,12 +104,19 @@ type ExecBackend struct {
// a host, means the home key cannot be verified, and a replica launch
// that would be handed a key is refused (replicaKeyGate).
ReplicaHomeKeys func(host string) (sha256 string, ok bool)
- // ReplicaConfigKey reads rt's configuration from the bucket now and
+ // ConfiguredKey reads rt's configuration from the bucket now and
// returns the SHA-256 of the CARLOS_INSTANCE_KEY it renders, "" for
- // none (appconfig.ConfiguredKeyDigest). replicaKeyGate refuses a
- // replica whose configured key is not that digest: a configuration
- // change is in flight (ruling 27). Nil refuses every configured key.
- ReplicaConfigKey func(ctx context.Context, rt registry.Route) (sha256 string, err error)
+ // none (appconfig.ConfiguredKeyDigest). Two readers ask it. The mint
+ // asks before it mints, so a key the configuration sets is never
+ // shadowed by one minted while that configuration was still on its
+ // way to the box (ensureInstanceKey; ruling 5): a key the bucket sets
+ // and the box has not delivered defers the launch, and nil is a box
+ // with no configuration source, where nothing is on its way. The
+ // replica gate asks at every launch whose key is configured, and
+ // refuses a replica whose configured key is not that digest: a
+ // configuration change is in flight (ruling 27); there nil refuses
+ // every configured key.
+ ConfiguredKey func(ctx context.Context, rt registry.Route) (sha256 string, err error)
// Cache, when non-nil, adds the purge verb to each spawned instance's
// control socket so it can invalidate its own cached responses right
// after a write. Nil disables that ONE verb, not the socket: the control
@@ -848,7 +855,7 @@ func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route) error {
}
// The instance key, before the env is read for the same reason: a seed
// is usually the first process of a fresh instance (L21).
- if _, err := b.ensureInstanceKeyFor(rt, paths); err != nil {
+ if _, err := b.ensureInstanceKeyFor(ctx, rt, paths); err != nil {
return fmt.Errorf("instance key for %s: %w", host, err)
}
fileEnv, keyLayer, err := b.instanceEnvLayer(rt)
@@ -1398,7 +1405,7 @@ func (b *ExecBackend) spawnChildFrom(ctx context.Context, rt registry.Route, soc
// wake the same way: an app that takes its key from the platform
// refuses to start without one (L21). It returns only once the key is
// durable, so no child starts with a key a power cut could take back.
- if _, err := b.ensureInstanceKeyFor(rt, paths); err != nil {
+ if _, err := b.ensureInstanceKeyFor(ctx, rt, paths); err != nil {
return nil, "", fmt.Errorf("instance key for %s: %w", host, err)
}
diff --git a/internal/activator/claim_snapshot.go b/internal/activator/claim_snapshot.go
index 1e9b45835..21dfe1332 100644
--- a/internal/activator/claim_snapshot.go
+++ b/internal/activator/claim_snapshot.go
@@ -108,7 +108,7 @@ func (b *ExecBackend) EnsureClaimAdminToken(ctx context.Context, rt registry.Rou
// The one place the claim launch source names the host around the
// key's error: the refusal itself does not, and every other launch
// path wraps it the same way.
- if _, err := b.ensureInstanceKeyFor(rt, paths); err != nil {
+ if _, err := b.ensureInstanceKeyFor(ctx, rt, paths); err != nil {
return fmt.Errorf("instance key for %s: %w", rt.Host, err)
}
return nil
diff --git a/internal/activator/instancekey.go b/internal/activator/instancekey.go
index 72e9744dc..79a6629eb 100644
--- a/internal/activator/instancekey.go
+++ b/internal/activator/instancekey.go
@@ -18,6 +18,15 @@ package activator
// the rule mergeEnv applies): a key the rendered configuration
// supplies is the app's key and nothing is minted; an empty own value
// is no key;
+// - nothing is minted until the row's configuration is here. The files
+// say what this box has delivered, not what the configuration sets: a
+// first wake can land before the config sync's first render, and a
+// key minted then would shadow the configured one for good once it
+// arrived. So before it mints, the ensure asks the bucket
+// (ExecBackend.ConfiguredKey, the replica gate's reader) whether the
+// configuration sets a key now; if it does, or the bucket cannot say,
+// the launch is deferred (ErrInstanceKeyDeferred) and retried once the
+// sync has delivered the file;
// - durable every time: before an ensure reports success the file is
// 0600 (fchmod on its descriptor), the file is fsynced and so is its
// directory, when the key was already there too. A sync that failed
@@ -38,6 +47,7 @@ package activator
// gets the same from instancefs.
import (
+ "context"
"crypto/rand"
"encoding/base64"
"errors"
@@ -46,6 +56,7 @@ import (
"os"
"path/filepath"
"strings"
+ "time"
"github.com/carlosframework/platform/internal/instancefs"
"github.com/carlosframework/platform/internal/instancekey"
@@ -61,13 +72,21 @@ const InstanceKeyVar = instancekey.Var
// every key minted with it; changing it is a rotation.
const instanceKeyBytes = 32
-// InstanceKeyOutcome is what one ensure did.
+// InstanceKeyOutcome is what one ensure did. Its zero value is
+// InstanceKeyFailed, what every ensure answers beside an error, so a
+// caller that reads the outcome before the error can never count a
+// refused row as anything.
type InstanceKeyOutcome int
const (
+ // InstanceKeyFailed: the ensure returned an error and reports nothing.
+ // It refused the row (fenced, conflicting, a configuration not yet
+ // delivered) or failed part way (an open, a write, a sync); the error
+ // says which. Nothing it may have written is reported as durable.
+ InstanceKeyFailed InstanceKeyOutcome = iota
// InstanceKeyPresent: the own env file already carried a key. No byte
// was written; the file was made durable.
- InstanceKeyPresent InstanceKeyOutcome = iota
+ InstanceKeyPresent
// InstanceKeyMinted: a key was appended and made durable.
InstanceKeyMinted
// InstanceKeyFromConfig: the rendered configuration supplies the key.
@@ -84,6 +103,8 @@ const (
func (o InstanceKeyOutcome) String() string {
switch o {
+ case InstanceKeyFailed:
+ return "failed"
case InstanceKeyPresent:
return "present"
case InstanceKeyMinted:
@@ -157,22 +178,57 @@ func configEnvName(envName string) string {
return strings.TrimSuffix(envName, ".env") + ".config.env"
}
-// EnsureInstanceKey makes sure path (an instance's own <host>.env on the
-// flat layout) carries CARLOS_INSTANCE_KEY, creating the file if needed.
-// Launch paths call it right after EnsureAdminToken.
-func EnsureInstanceKey(path string) (InstanceKeyOutcome, error) {
- return ensureInstanceKey(filepath.Dir(path), filepath.Base(path), false, true)
+// ErrInstanceKeyDeferred is a first start held back because the row's
+// configuration is not here yet: the bucket's configuration sets
+// CARLOS_INSTANCE_KEY and this box has not delivered it, or the bucket
+// could not be read to tell. Nothing is minted (ruling 5: a configured key
+// is the app's, and one minted while it was on its way would shadow it for
+// good). The launch fails and is retried: the config sync's next pass
+// delivers the file, and the next start finds the key configured.
+var ErrInstanceKeyDeferred = errors.New("activator: instance key not minted: the instance's configuration is not delivered")
+
+// configReadTimeout bounds each read of the bucket's configuration, by
+// the mint and by the replica gate.
+const configReadTimeout = 10 * time.Second
+
+// configuredKeyReader answers, for one row, the SHA-256 of the
+// CARLOS_INSTANCE_KEY the bucket's configuration renders now, "" for
+// none. nil is a box with no configuration source.
+type configuredKeyReader func(context.Context) (string, error)
+
+// configuredKeyFor is b.ConfiguredKey for rt, each read bounded by
+// configReadTimeout; nil when the backend has no reader.
+func (b *ExecBackend) configuredKeyFor(rt registry.Route) configuredKeyReader {
+ if b.ConfiguredKey == nil {
+ return nil
+ }
+ return func(ctx context.Context) (string, error) {
+ ctx, cancel := context.WithTimeout(ctx, configReadTimeout)
+ defer cancel()
+ return b.ConfiguredKey(ctx, rt)
+ }
}
-// EnsureInstanceKeyBeneath is EnsureInstanceKey for a nested row's own env
-// file, read and written through instancefs because the directory is
-// tenant-writable (isolated-exec design §3.2).
-func EnsureInstanceKeyBeneath(dir, name string) (InstanceKeyOutcome, error) {
- return ensureInstanceKey(dir, name, true, true)
+// ensureInstanceKeyNoConfig makes sure path (an instance's own <host>.env
+// on the flat layout) carries CARLOS_INSTANCE_KEY, creating the file if
+// needed, for a row whose configuration can set no key: there is no
+// reader, so nothing is asked before the mint. Tests use it; every launch
+// path goes through ensureInstanceKeyFor, which asks the bucket first.
+func ensureInstanceKeyNoConfig(path string) (InstanceKeyOutcome, error) {
+ return ensureInstanceKey(context.Background(), filepath.Dir(path), filepath.Base(path), false, true, nil)
+}
+
+// ensureInstanceKeyBeneathNoConfig is ensureInstanceKeyNoConfig for a
+// nested row's own env file, read and written through instancefs because
+// the directory is tenant-writable (isolated-exec design §3.2).
+func ensureInstanceKeyBeneathNoConfig(dir, name string) (InstanceKeyOutcome, error) {
+ return ensureInstanceKey(context.Background(), dir, name, true, true, nil)
}
// ensureInstanceKeyFor is the launch paths' ensure for rt: none for a
// replica row, the nested body for a nested row, the flat one otherwise.
+// ctx bounds the one bucket read a mint makes; a launch with none passes
+// context.Background().
//
// A fenced or conflicting row is refused and nothing is written, as
// BackfillInstanceKey refuses it. Every launch dispatcher (Start,
@@ -184,18 +240,15 @@ func EnsureInstanceKeyBeneath(dir, name string) (InstanceKeyOutcome, error) {
// (isolated-exec design §8.2). The refusal is decided on p, the paths the
// mint would use. Every caller names the host around the error ("instance
// key for <host>: ..."), so the refusal does not.
-func (b *ExecBackend) ensureInstanceKeyFor(rt registry.Route, p instancePaths) (InstanceKeyOutcome, error) {
+func (b *ExecBackend) ensureInstanceKeyFor(ctx context.Context, rt registry.Route, p instancePaths) (InstanceKeyOutcome, error) {
if rt.Replica {
return InstanceKeyReplica, nil
}
switch p.layout {
case LayoutFenced, LayoutConflict:
- return InstanceKeyPresent, fmt.Errorf("not minted: %s", p.reason)
- }
- if p.layout == LayoutNested {
- return EnsureInstanceKeyBeneath(p.dataDir, rt.Host+".env")
+ return InstanceKeyFailed, fmt.Errorf("not minted: %s", p.reason)
}
- return EnsureInstanceKey(p.envFile)
+ return ensureInstanceKey(ctx, p.dataDir, rt.Host+".env", p.layout == LayoutNested, true, b.configuredKeyFor(rt))
}
// BackfillInstanceKey is the startup backfill's step for one row: a key is
@@ -206,18 +259,16 @@ func (b *ExecBackend) ensureInstanceKeyFor(rt registry.Route, p instancePaths) (
// refuse to start (fenced, conflict) is an error and nothing is written.
// The agent's backfill names the host when it logs the error, so the
// refusal does not.
-func (b *ExecBackend) BackfillInstanceKey(rt registry.Route) (InstanceKeyOutcome, error) {
+func (b *ExecBackend) BackfillInstanceKey(ctx context.Context, rt registry.Route) (InstanceKeyOutcome, error) {
if rt.Replica {
return InstanceKeyReplica, nil
}
p := b.pathsFor(rt)
switch p.layout {
- case LayoutNested:
- return ensureInstanceKey(p.dataDir, rt.Host+".env", true, false)
case LayoutFenced, LayoutConflict:
- return InstanceKeyPresent, fmt.Errorf("not backfilled: %s", p.reason)
+ return InstanceKeyFailed, fmt.Errorf("not backfilled: %s", p.reason)
}
- return ensureInstanceKey(p.dataDir, rt.Host+".env", false, false)
+ return ensureInstanceKey(ctx, p.dataDir, rt.Host+".env", p.layout == LayoutNested, false, b.configuredKeyFor(rt))
}
// openInstanceEnv opens an own env file for append. Only a create passes
@@ -295,8 +346,10 @@ func settleInstanceEnv(f *os.File, dir, path string) error {
// ensureInstanceKey is the shared body. It holds adminTokenMu, the lock
// every mint into <host>.env takes, so a key append and a token append can
// never interleave. create false is the backfill: an absent file is
-// InstanceKeyNoEnvFile and nothing is created.
-func ensureInstanceKey(dir, name string, beneath, create bool) (InstanceKeyOutcome, error) {
+// InstanceKeyNoEnvFile and nothing is created. configured is asked, under
+// the lock, only when nothing delivered here sets a key and a mint is
+// next: the one bucket read a row ever costs.
+func ensureInstanceKey(ctx context.Context, dir, name string, beneath, create bool, configured configuredKeyReader) (InstanceKeyOutcome, error) {
path := filepath.Join(dir, name)
adminTokenMu.Lock()
defer adminTokenMu.Unlock()
@@ -316,11 +369,11 @@ func ensureInstanceKey(dir, name string, beneath, create bool) (InstanceKeyOutco
return InstanceKeyNoEnvFile, nil
}
case err != nil:
- return InstanceKeyPresent, fmt.Errorf("read %s: %w", path, err)
+ return InstanceKeyFailed, fmt.Errorf("read %s: %w", path, err)
}
cfg, err := read(configEnvName(name))
if err != nil && !errors.Is(err, os.ErrNotExist) {
- return InstanceKeyPresent, fmt.Errorf("read %s: %w", filepath.Join(dir, configEnvName(name)), err)
+ return InstanceKeyFailed, fmt.Errorf("read %s: %w", filepath.Join(dir, configEnvName(name)), err)
}
// The decision is the effective env's: the key the launch will hand
// the app, by the same rule mergeEnv applies.
@@ -341,17 +394,36 @@ func ensureInstanceKey(dir, name string, beneath, create bool) (InstanceKeyOutco
}
f, err := openInstanceEnv(dir, name, beneath, false)
if err != nil {
- return InstanceKeyPresent, fmt.Errorf("open %s: %w", path, err)
+ return InstanceKeyFailed, fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
if err := settleInstanceEnv(f, dir, path); err != nil {
- return InstanceKeyPresent, err
+ return InstanceKeyFailed, err
}
return InstanceKeyPresent, nil
}
+ // Nothing delivered here sets a key. The configuration may still: a
+ // first wake can run before the config sync's first render of
+ // <host>.config.env (the fast-converge pass reconciles the row ready
+ // and syncs config last), and a key minted in that gap would shadow
+ // the configured one for good once it arrived (Effective: the own file
+ // wins). So ask the bucket now, and mint only when the configuration
+ // sets no key. A key it sets is not here yet: defer, and the next
+ // start finds it delivered. A bucket that cannot be read cannot say,
+ // and defers the same way. With no reader there is no configuration
+ // source, and nothing can be on its way.
+ if configured != nil {
+ digest, err := configured(ctx)
+ switch {
+ case err != nil:
+ return InstanceKeyFailed, fmt.Errorf("%w: its configuration could not be read: %w", ErrInstanceKeyDeferred, err)
+ case digest != "":
+ return InstanceKeyFailed, fmt.Errorf("%w: its configuration sets %s and this box has not delivered it yet (the next config sync does, and the next start finds it)", ErrInstanceKeyDeferred, InstanceKeyVar)
+ }
+ }
minted, err := mintInstanceKey()
if err != nil {
- return InstanceKeyPresent, fmt.Errorf("mint instance key for %s: %w", path, err)
+ return InstanceKeyFailed, fmt.Errorf("mint instance key for %s: %w", path, err)
}
if instanceKeyBeforeOpen != nil {
instanceKeyBeforeOpen(path)
@@ -361,7 +433,7 @@ func ensureInstanceKey(dir, name string, beneath, create bool) (InstanceKeyOutco
return InstanceKeyNoEnvFile, nil // removed since the read: nothing to backfill
}
if err != nil {
- return InstanceKeyPresent, fmt.Errorf("open %s: %w", path, err)
+ return InstanceKeyFailed, fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
// The length the append starts from, taken from the open descriptor:
@@ -370,7 +442,7 @@ func ensureInstanceKey(dir, name string, beneath, create bool) (InstanceKeyOutco
// appended since the read.
st, err := f.Stat()
if err != nil {
- return InstanceKeyPresent, fmt.Errorf("stat %s: %w", path, err)
+ return InstanceKeyFailed, fmt.Errorf("stat %s: %w", path, err)
}
prefix := ""
if len(raw) > 0 && raw[len(raw)-1] != '\n' {
@@ -381,10 +453,10 @@ func ensureInstanceKey(dir, name string, beneath, create bool) (InstanceKeyOutco
if err == nil {
err = io.ErrShortWrite
}
- return InstanceKeyPresent, undoInstanceKeyAppend(f, st.Size(), path, err)
+ return InstanceKeyFailed, undoInstanceKeyAppend(f, st.Size(), path, err)
}
if err := settleInstanceEnv(f, dir, path); err != nil {
- return InstanceKeyPresent, err
+ return InstanceKeyFailed, err
}
return InstanceKeyMinted, nil
}
diff --git a/internal/activator/instancekey_delivery_test.go b/internal/activator/instancekey_delivery_test.go
new file mode 100644
index 000000000..a928e5b5d
--- /dev/null
+++ b/internal/activator/instancekey_delivery_test.go
@@ -0,0 +1,186 @@
+package activator
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// bucketKey is ExecBackend.ConfiguredKey for a bucket whose configuration
+// renders key ("" for none), counting its reads; down makes every read
+// fail.
+type bucketKey struct {
+ key string
+ down bool
+ reads int
+}
+
+func (b *bucketKey) read(context.Context, registry.Route) (string, error) {
+ b.reads++
+ if b.down {
+ return "", errors.New("bucket unreachable")
+ }
+ if b.key == "" {
+ return "", nil
+ }
+ return instancekey.Digest(b.key), nil
+}
+
+// Final review I2 (Astra's P2), ruling 5: nothing is minted until the
+// row's configuration is delivered. The files say what this box has
+// delivered; the bucket says what the configuration sets. A key the bucket
+// sets and the box has not delivered defers the mint; a bucket that cannot
+// be read defers it the same way; a configuration that sets no key lets
+// the mint proceed. The bucket is asked only when a mint is next: a key
+// already delivered, in either file, costs no read.
+func TestEnsureInstanceKeyAsksTheBucketBeforeItMints(t *testing.T) {
+ tok := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n"
+ for _, tc := range []struct {
+ name string
+ own, config string
+ bucket *bucketKey
+ want InstanceKeyOutcome
+ deferred bool
+ reads int
+ }{
+ {"the configuration sets a key the box has not delivered: deferred", tok, "", &bucketKey{key: "key-A"}, InstanceKeyFailed, true, 1},
+ {"the bucket cannot be read: deferred", tok, "", &bucketKey{down: true}, InstanceKeyFailed, true, 1},
+ {"the configuration sets no key: minted", tok, "", &bucketKey{}, InstanceKeyMinted, false, 1},
+ {"no configuration source: minted, nothing asked", tok, "", nil, InstanceKeyMinted, false, 0},
+ {"the configured key is delivered: from config, nothing asked", tok, InstanceKeyVar + "=key-A\n", &bucketKey{key: "key-A"}, InstanceKeyFromConfig, false, 0},
+ {"an own key: present, nothing asked", tok + InstanceKeyVar + "=" + strings.Repeat("K", 43) + "=\n", "", &bucketKey{key: "key-A"}, InstanceKeyPresent, false, 0},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ b := &ExecBackend{DataDir: t.TempDir()}
+ if tc.bucket != nil {
+ b.ConfiguredKey = tc.bucket.read
+ }
+ rt := registry.Route{Host: "shop.example.com", Kind: "instance"}
+ own := filepath.Join(b.DataDir, rt.Host+".env")
+ if err := os.WriteFile(own, []byte(tc.own), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if tc.config != "" {
+ if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".config.env"), []byte(tc.config), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, call := range []string{"launch", "backfill"} {
+ var got InstanceKeyOutcome
+ var err error
+ if call == "launch" {
+ got, err = b.ensureInstanceKeyFor(t.Context(), rt, b.pathsFor(rt))
+ } else {
+ if err := os.WriteFile(own, []byte(tc.own), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if tc.bucket != nil {
+ tc.bucket.reads = 0
+ }
+ got, err = b.BackfillInstanceKey(t.Context(), rt)
+ }
+ if got != tc.want || errors.Is(err, ErrInstanceKeyDeferred) != tc.deferred || (err != nil) != tc.deferred {
+ t.Fatalf("%s = %v, %v; want %v, deferred %v", call, got, err, tc.want, tc.deferred)
+ }
+ raw, _ := os.ReadFile(own)
+ if minted := len(keyLines(raw)) > len(keyLines([]byte(tc.own))); minted != (tc.want == InstanceKeyMinted) {
+ t.Fatalf("%s: a key line was appended: %v, want %v", call, minted, tc.want == InstanceKeyMinted)
+ }
+ if tc.bucket != nil && tc.bucket.reads != tc.reads {
+ t.Fatalf("%s: the bucket was read %d times, want %d", call, tc.bucket.reads, tc.reads)
+ }
+ }
+ })
+ }
+}
+
+// The mint's bucket read is bounded by configReadTimeout and by the
+// launch's own context, whichever ends first.
+func TestEnsureInstanceKeyBoundsTheBucketRead(t *testing.T) {
+ b := &ExecBackend{DataDir: t.TempDir()}
+ var deadline time.Time
+ b.ConfiguredKey = func(ctx context.Context, _ registry.Route) (string, error) {
+ deadline, _ = ctx.Deadline()
+ return "", ctx.Err()
+ }
+ rt := registry.Route{Host: "shop.example.com", Kind: "instance"}
+ if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".env"), []byte("A=1\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := b.ensureInstanceKeyFor(context.Background(), rt, b.pathsFor(rt)); err != nil {
+ t.Fatal(err)
+ }
+ if until := time.Until(deadline); until <= 0 || until > configReadTimeout {
+ t.Fatalf("the read's deadline is %v away, want within %v", until, configReadTimeout)
+ }
+ if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".env"), []byte("A=1\n"), 0o600); err != nil {
+ t.Fatal(err) // keyless again, so the backfill reaches the read
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, err := b.BackfillInstanceKey(ctx, rt); !errors.Is(err, ErrInstanceKeyDeferred) || !errors.Is(err, context.Canceled) {
+ t.Fatalf("a cancelled read = %v, want deferred by the cancellation", err)
+ }
+}
+
+// A first start whose configuration is still on its way, end to end: the
+// start is deferred and no app runs; nothing is minted into <host>.env;
+// once the config sync has delivered <host>.config.env with the configured
+// key, the start runs and the app seals with that key, the configured one,
+// never a minted one.
+func TestFirstStartWaitsForTheConfiguredKey(t *testing.T) {
+ b := testExecBackend(t)
+ bucket := &bucketKey{key: "key-A"}
+ b.ConfiguredKey = bucket.read
+ rt := registry.Route{Host: "shop.example.com", Kind: "instance", Socket: noControlSocket(t)}
+ own := filepath.Join(b.DataDir, rt.Host+".env")
+ tok := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n"
+ if err := os.WriteFile(own, []byte(tok), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ out := stageHelperApp(t, b, rt.Host, "v1")
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ proc, _, err := b.Start(ctx, rt)
+ if proc != nil {
+ proc.Stop(context.Background())
+ }
+ if !errors.Is(err, ErrInstanceKeyDeferred) || !strings.Contains(err.Error(), rt.Host) {
+ t.Fatalf("Start before delivery = %v, want ErrInstanceKeyDeferred naming the host", err)
+ }
+ if got := outcomeOf(out); got != "" {
+ t.Fatalf("an app ran before delivery: %q", got)
+ }
+ if raw, _ := os.ReadFile(own); string(raw) != tok {
+ t.Fatalf("the own file changed before delivery: %d bytes", len(raw))
+ }
+ // The config sync delivers the file.
+ if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".config.env"), []byte(InstanceKeyVar+"=key-A\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ // The helper exits once it has recorded its outcome, so Start's health
+ // wait never sees a live socket: the outcome is the proof, as in the
+ // replica tests, and the error only must not be the deferral.
+ ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second) // ctx was spent waiting for the first outcome
+ defer cancel2()
+ proc, _, err = b.Start(ctx2, rt)
+ if proc != nil {
+ defer proc.Stop(context.Background())
+ }
+ if errors.Is(err, ErrInstanceKeyDeferred) {
+ t.Fatalf("Start after delivery still deferred: %v", err)
+ }
+ if got := outcomeOf(out); got != "key "+instancekey.Digest("key-A") {
+ t.Fatalf("the app's outcome = %q, want the configured key", got)
+ }
+ if raw, _ := os.ReadFile(own); string(raw) != tok {
+ t.Fatal("a key was minted beside the configured one")
+ }
+}
diff --git a/internal/activator/instancekey_linux_test.go b/internal/activator/instancekey_linux_test.go
index 095cdac08..4158427df 100644
--- a/internal/activator/instancekey_linux_test.go
+++ b/internal/activator/instancekey_linux_test.go
@@ -55,7 +55,7 @@ func TestBackfillInstanceKeyAppendsToANestedEnvFile(t *testing.T) {
if err := os.WriteFile(envPath, []byte(tok), 0o644); err != nil {
t.Fatal(err)
}
- if got, err := b.BackfillInstanceKey(rt); err != nil || got != InstanceKeyMinted {
+ if got, err := b.BackfillInstanceKey(t.Context(), rt); err != nil || got != InstanceKeyMinted {
t.Fatalf("nested backfill = %v, %v; want minted", got, err)
}
raw, _ := os.ReadFile(envPath)
@@ -65,7 +65,7 @@ func TestBackfillInstanceKeyAppendsToANestedEnvFile(t *testing.T) {
if st, _ := os.Stat(envPath); st.Mode().Perm() != 0o600 {
t.Fatalf("mode = %o, want 600", st.Mode().Perm())
}
- if got, err := b.BackfillInstanceKey(rt); err != nil || got != InstanceKeyPresent {
+ if got, err := b.BackfillInstanceKey(t.Context(), rt); err != nil || got != InstanceKeyPresent {
t.Fatalf("second nested backfill = %v, %v; want present", got, err)
}
if raw2, _ := os.ReadFile(envPath); string(raw2) != string(raw) {
@@ -84,7 +84,7 @@ func TestEnsureInstanceKeyBeneathRefusesASymlink(t *testing.T) {
if err := os.Symlink(target, filepath.Join(dir, "shop.example.com.env")); err != nil {
t.Fatal(err)
}
- if _, err := EnsureInstanceKeyBeneath(dir, "shop.example.com.env"); err == nil {
+ if _, err := ensureInstanceKeyBeneathNoConfig(dir, "shop.example.com.env"); err == nil {
t.Fatal("ensure followed a symlink planted at the env file's name")
}
if raw, _ := os.ReadFile(target); string(raw) != "X=1\n" {
@@ -137,7 +137,7 @@ func TestBackfillInstanceKeyRefusesAFIFOWithoutBlocking(t *testing.T) {
}
done := make(chan error, 1)
go func() {
- _, err := b.BackfillInstanceKey(rt)
+ _, err := b.BackfillInstanceKey(t.Context(), rt)
done <- err
}()
select {
diff --git a/internal/activator/instancekey_test.go b/internal/activator/instancekey_test.go
index aab0d36a0..18bafbfb8 100644
--- a/internal/activator/instancekey_test.go
+++ b/internal/activator/instancekey_test.go
@@ -67,7 +67,7 @@ func probeInstanceKeySync(t *testing.T) *syncProbe {
func TestEnsureInstanceKeyMintsOnceInTheAgreedFormat(t *testing.T) {
p := filepath.Join(t.TempDir(), "shop.example.com.env")
- got, err := EnsureInstanceKey(p)
+ got, err := ensureInstanceKeyNoConfig(p)
if err != nil || got != InstanceKeyMinted {
t.Fatalf("first ensure = %v, %v; want minted", got, err)
}
@@ -91,7 +91,7 @@ func TestEnsureInstanceKeyMintsOnceInTheAgreedFormat(t *testing.T) {
if env[InstanceKeyVar] != keys[0] {
t.Fatal("readEnvFile does not yield the minted key verbatim")
}
- again, err := EnsureInstanceKey(p)
+ again, err := ensureInstanceKeyNoConfig(p)
if err != nil || again != InstanceKeyPresent {
t.Fatalf("second ensure = %v, %v; want present", again, err)
}
@@ -114,7 +114,7 @@ func TestEnsureInstanceKeyNeverRewritesAnExistingKey(t *testing.T) {
}
var buf bytes.Buffer
log.SetOutput(&buf)
- got, err := EnsureInstanceKey(p)
+ got, err := ensureInstanceKeyNoConfig(p)
log.SetOutput(os.Stderr)
if err != nil || got != InstanceKeyPresent {
t.Fatalf("ensure over %q = %v, %v; want present", existing, got, err)
@@ -134,7 +134,7 @@ func TestEnsureInstanceKeyAppendsOnAFreshLineAndKeepsEveryOtherLine(t *testing.T
if err := os.WriteFile(p, []byte(before), 0o644); err != nil {
t.Fatal(err)
}
- if got, err := EnsureInstanceKey(p); err != nil || got != InstanceKeyMinted {
+ if got, err := ensureInstanceKeyNoConfig(p); err != nil || got != InstanceKeyMinted {
t.Fatalf("ensure = %v, %v", got, err)
}
raw, _ := os.ReadFile(p)
@@ -164,7 +164,7 @@ func TestEnsureInstanceKeyDefersToTheRenderedConfiguration(t *testing.T) {
if err := os.WriteFile(p, []byte(own), 0o600); err != nil {
t.Fatal(err)
}
- got, err := EnsureInstanceKey(p)
+ got, err := ensureInstanceKeyNoConfig(p)
if err != nil || got != InstanceKeyFromConfig {
t.Fatalf("own %q: ensure = %v, %v; want from-config", own, got, err)
}
@@ -177,7 +177,7 @@ func TestEnsureInstanceKeyDefersToTheRenderedConfiguration(t *testing.T) {
func TestBackfillInstanceKeyAppendsOnlyToAnExistingEnvFile(t *testing.T) {
b := &ExecBackend{DataDir: t.TempDir()}
fresh := registry.Route{Host: "new.example.com", Socket: "/run/carlos/new.sock"}
- got, err := b.BackfillInstanceKey(fresh)
+ got, err := b.BackfillInstanceKey(t.Context(), fresh)
if err != nil || got != InstanceKeyNoEnvFile {
t.Fatalf("backfill with no env file = %v, %v; want no-env-file", got, err)
}
@@ -190,7 +190,7 @@ func TestBackfillInstanceKeyAppendsOnlyToAnExistingEnvFile(t *testing.T) {
if err := os.WriteFile(envPath, []byte(tok), 0o600); err != nil {
t.Fatal(err)
}
- if got, err := b.BackfillInstanceKey(old); err != nil || got != InstanceKeyMinted {
+ if got, err := b.BackfillInstanceKey(t.Context(), old); err != nil || got != InstanceKeyMinted {
t.Fatalf("backfill = %v, %v; want minted", got, err)
}
raw, _ := os.ReadFile(envPath)
@@ -198,7 +198,7 @@ func TestBackfillInstanceKeyAppendsOnlyToAnExistingEnvFile(t *testing.T) {
t.Fatalf("file = %q", raw)
}
// Idempotent: a second backfill (the next agent start) writes nothing.
- if got, err := b.BackfillInstanceKey(old); err != nil || got != InstanceKeyPresent {
+ if got, err := b.BackfillInstanceKey(t.Context(), old); err != nil || got != InstanceKeyPresent {
t.Fatalf("second backfill = %v, %v; want present", got, err)
}
if raw2, _ := os.ReadFile(envPath); !bytes.Equal(raw, raw2) {
@@ -214,7 +214,7 @@ func TestEnsureInstanceKeyLeavesAFileWithTwoKeysAlone(t *testing.T) {
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
- if got, err := EnsureInstanceKey(p); err != nil || got != InstanceKeyPresent {
+ if got, err := ensureInstanceKeyNoConfig(p); err != nil || got != InstanceKeyPresent {
t.Fatalf("ensure = %v, %v; want present", got, err)
}
if raw, _ := os.ReadFile(p); string(raw) != body {
@@ -232,7 +232,7 @@ func TestEnsureInstanceKeyTreatsAnEmptyValueAsAbsent(t *testing.T) {
if err := os.WriteFile(p, []byte(InstanceKeyVar+"=\n"), 0o600); err != nil {
t.Fatal(err)
}
- if got, err := EnsureInstanceKey(p); err != nil || got != InstanceKeyMinted {
+ if got, err := ensureInstanceKeyNoConfig(p); err != nil || got != InstanceKeyMinted {
t.Fatalf("ensure = %v, %v; want minted", got, err)
}
env, _ := readEnvFile(p)
@@ -250,7 +250,7 @@ func TestEnsureInstanceKeyMintsOneKeyUnderConcurrentCallers(t *testing.T) {
wg.Add(1)
go func() {
defer wg.Done()
- if _, err := EnsureInstanceKey(p); err != nil {
+ if _, err := ensureInstanceKeyNoConfig(p); err != nil {
t.Error(err)
}
}()
@@ -268,7 +268,7 @@ func TestEnsureInstanceKeyMintsWhenTheConfigurationValueIsEmpty(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "shop.example.com.config.env"), []byte(InstanceKeyVar+"=\n"), 0o600); err != nil {
t.Fatal(err)
}
- if got, err := EnsureInstanceKey(filepath.Join(dir, "shop.example.com.env")); err != nil || got != InstanceKeyMinted {
+ if got, err := ensureInstanceKeyNoConfig(filepath.Join(dir, "shop.example.com.env")); err != nil || got != InstanceKeyMinted {
t.Fatalf("ensure = %v, %v; want minted", got, err)
}
}
@@ -289,14 +289,14 @@ func TestEnsureInstanceKeyRedoesAFailedSyncBeforeReportingSuccess(t *testing.T)
probe := probeInstanceKeySync(t)
p := filepath.Join(t.TempDir(), "shop.example.com.env")
tc.fail(probe, errors.New("injected: EIO"))
- if _, err := EnsureInstanceKey(p); err == nil {
+ if _, err := ensureInstanceKeyNoConfig(p); err == nil {
t.Fatal("a mint whose sync failed reported success")
}
raw, _ := os.ReadFile(p)
if len(keyLines(raw)) != 1 {
t.Fatalf("the failed mint left %d keys, want its 1", len(keyLines(raw)))
}
- if _, err := EnsureInstanceKey(p); err == nil {
+ if _, err := ensureInstanceKeyNoConfig(p); err == nil {
t.Fatal("a retry reported success while the sync still fails")
}
if err := os.Chmod(p, 0o644); err != nil {
@@ -304,7 +304,7 @@ func TestEnsureInstanceKeyRedoesAFailedSyncBeforeReportingSuccess(t *testing.T)
}
tc.fail(probe, nil)
before := *probe
- got, err := EnsureInstanceKey(p)
+ got, err := ensureInstanceKeyNoConfig(p)
if err != nil || got != InstanceKeyPresent {
t.Fatalf("retry = %v, %v; want present", got, err)
}
@@ -330,10 +330,10 @@ func TestReplicaRowsGetNoPlatformKey(t *testing.T) {
if err := os.WriteFile(envPath, []byte("CARLOS_ADMIN_TOKEN=t\n"), 0o600); err != nil {
t.Fatal(err)
}
- if got, err := b.ensureInstanceKeyFor(rt, b.pathsFor(rt)); err != nil || got != InstanceKeyReplica {
+ if got, err := b.ensureInstanceKeyFor(t.Context(), rt, b.pathsFor(rt)); err != nil || got != InstanceKeyReplica {
t.Fatalf("launch ensure = %v, %v; want replica", got, err)
}
- if got, err := b.BackfillInstanceKey(rt); err != nil || got != InstanceKeyReplica {
+ if got, err := b.BackfillInstanceKey(t.Context(), rt); err != nil || got != InstanceKeyReplica {
t.Fatalf("backfill = %v, %v; want replica", got, err)
}
if raw, _ := os.ReadFile(envPath); len(keyLines(raw)) != 0 {
@@ -354,19 +354,19 @@ func TestEnsureInstanceKeyRefusesToFollowASymlink(t *testing.T) {
call func(b *ExecBackend, rt registry.Route, link string) error
}{
{"mint", "A=1\n", 0o644, func(_ *ExecBackend, _ registry.Route, link string) error {
- _, err := EnsureInstanceKey(link)
+ _, err := ensureInstanceKeyNoConfig(link)
return err
}},
{"existing key", wellFormed, 0o644, func(_ *ExecBackend, _ registry.Route, link string) error {
- _, err := EnsureInstanceKey(link)
+ _, err := ensureInstanceKeyNoConfig(link)
return err
}},
{"backfill mint", "A=1\n", 0o644, func(b *ExecBackend, rt registry.Route, _ string) error {
- _, err := b.BackfillInstanceKey(rt)
+ _, err := b.BackfillInstanceKey(t.Context(), rt)
return err
}},
{"backfill existing key", wellFormed, 0o644, func(b *ExecBackend, rt registry.Route, _ string) error {
- _, err := b.BackfillInstanceKey(rt)
+ _, err := b.BackfillInstanceKey(t.Context(), rt)
return err
}},
{"claim settle", wellFormed, 0o600, func(b *ExecBackend, rt registry.Route, _ string) error {
@@ -417,7 +417,7 @@ func TestEnsureInstanceKeyRefusesADanglingSymlink(t *testing.T) {
if err := os.Symlink(target, link); err != nil {
t.Skipf("this filesystem will not make symlinks: %v", err)
}
- if _, err := EnsureInstanceKey(link); !errors.Is(err, errOwnEnvSymlink) {
+ if _, err := ensureInstanceKeyNoConfig(link); !errors.Is(err, errOwnEnvSymlink) {
t.Fatalf("err = %v, want errOwnEnvSymlink", err)
}
if _, err := os.Lstat(target); !os.IsNotExist(err) {
@@ -516,7 +516,7 @@ func TestEnsureInstanceKeyUndoesAPartialAppend(t *testing.T) {
}
}
setInstanceKeySeams(t, tc.write, nil)
- if _, err := EnsureInstanceKey(p); err == nil {
+ if _, err := ensureInstanceKeyNoConfig(p); err == nil {
t.Fatal("a mint whose append failed reported success")
}
raw, err := os.ReadFile(p)
@@ -534,7 +534,7 @@ func TestEnsureInstanceKeyUndoesAPartialAppend(t *testing.T) {
t.Fatalf("the undo synced the file %d times, want once", probe.file)
}
setInstanceKeySeams(t, func(f *os.File, b []byte) (int, error) { return f.Write(b) }, nil)
- got, err := EnsureInstanceKey(p)
+ got, err := ensureInstanceKeyNoConfig(p)
if err != nil || got != InstanceKeyMinted {
t.Fatalf("next ensure = %v, %v; want minted", got, err)
}
@@ -564,15 +564,15 @@ func TestEnsureInstanceKeyOpenRefusesALinkSwappedInAfterTheRead(t *testing.T) {
call func(b *ExecBackend, rt registry.Route, path string) error
}{
{"mint", "A=1\n", func(_ *ExecBackend, _ registry.Route, path string) error {
- _, err := EnsureInstanceKey(path)
+ _, err := ensureInstanceKeyNoConfig(path)
return err
}},
{"existing key", wellFormed, func(_ *ExecBackend, _ registry.Route, path string) error {
- _, err := EnsureInstanceKey(path)
+ _, err := ensureInstanceKeyNoConfig(path)
return err
}},
{"backfill mint", "A=1\n", func(b *ExecBackend, rt registry.Route, _ string) error {
- _, err := b.BackfillInstanceKey(rt)
+ _, err := b.BackfillInstanceKey(t.Context(), rt)
return err
}},
} {
@@ -636,7 +636,7 @@ func TestEnsureInstanceKeySymlinkErrorNamesThePathOnce(t *testing.T) {
if err := os.Symlink(target, link); err != nil {
t.Skipf("this filesystem will not make symlinks: %v", err)
}
- _, err := EnsureInstanceKey(link)
+ _, err := ensureInstanceKeyNoConfig(link)
if !errors.Is(err, errOwnEnvSymlink) {
t.Fatalf("err = %v, want errOwnEnvSymlink", err)
}
@@ -957,7 +957,7 @@ func TestEnsureInstanceKeyForRefusesFencedAndConflictRows(t *testing.T) {
if p.layout != layout {
t.Fatalf("paths classify as %s, want %s", p.layout, layout)
}
- if _, err := b.ensureInstanceKeyFor(rt, p); err == nil || !strings.Contains(err.Error(), "not minted") {
+ if _, err := b.ensureInstanceKeyFor(t.Context(), rt, p); err == nil || !strings.Contains(err.Error(), "not minted") {
t.Fatalf("ensure for a %s row: err = %v, want a refusal", layout, err)
} else if strings.HasPrefix(err.Error(), rt.Host) {
t.Fatalf("the refusal names the host itself (%v); its callers do", err)
@@ -991,7 +991,7 @@ func TestBackfillInstanceKeyRefusesFencedAndConflictRows(t *testing.T) {
}
refuseAfterFirstClassification(b, layout)
_, _ = b.Layout(rt)
- if _, err := b.BackfillInstanceKey(rt); err == nil || !strings.Contains(err.Error(), "not backfilled") {
+ if _, err := b.BackfillInstanceKey(t.Context(), rt); err == nil || !strings.Contains(err.Error(), "not backfilled") {
t.Fatalf("backfill of a %s row: err = %v, want a refusal", layout, err)
} else if strings.HasPrefix(err.Error(), host) {
t.Fatalf("the refusal names the host itself (%v); the agent's backfill does", err)
diff --git a/internal/activator/prepare.go b/internal/activator/prepare.go
index 09ca41981..ef63d3349 100644
--- a/internal/activator/prepare.go
+++ b/internal/activator/prepare.go
@@ -6,6 +6,7 @@
package activator
import (
+ "context"
"encoding/json"
"errors"
"fmt"
@@ -62,7 +63,7 @@ func (b *ExecBackend) prepareAt(rt registry.Route, role instancectl.Role, sock s
if err := EnsureAdminTokenBeneath(p.dataDir, rt.Host+".env"); err != nil {
return p, nil, nil, nil, fmt.Errorf("admin token for %s: %w", rt.Host, err)
}
- if _, err := b.ensureInstanceKeyFor(rt, p); err != nil {
+ if _, err := b.ensureInstanceKeyFor(context.Background(), rt, p); err != nil { // prepareAt has no ctx to bound the mint's bucket read; configReadTimeout does
return p, nil, nil, nil, fmt.Errorf("instance key for %s: %w", rt.Host, err)
}
var err error
diff --git a/internal/activator/replica_instancekey_linux_test.go b/internal/activator/replica_instancekey_linux_test.go
index d9f8bf65e..66c5cfe36 100644
--- a/internal/activator/replica_instancekey_linux_test.go
+++ b/internal/activator/replica_instancekey_linux_test.go
@@ -77,7 +77,7 @@ func TestNestedReplicaPrepareIsGated(t *testing.T) {
b, rt := nestedBackend(t, &fakeControl{inv: "inv1"}, &fakeUnit{})
rt.Replica = true
b.ReplicaHomeKeys = func(string) (string, bool) { return instancekey.Digest(homeKey), true }
- b.ReplicaConfigKey = func(context.Context, registry.Route) (string, error) {
+ b.ConfiguredKey = func(context.Context, registry.Route) (string, error) {
if tc.bucket == "" {
return "", nil
}
@@ -130,7 +130,7 @@ func TestNestedReplicaSidecarPrepareIsGated(t *testing.T) {
b, rt := nestedBackend(t, &fakeControl{inv: "inv1"}, &fakeUnit{})
rt.Replica = true
b.ReplicaHomeKeys = func(string) (string, bool) { return instancekey.Digest(homeKey), true }
- b.ReplicaConfigKey = func(context.Context, registry.Route) (string, error) { return instancekey.Digest("configured-B"), nil }
+ b.ConfiguredKey = func(context.Context, registry.Route) (string, error) { return instancekey.Digest("configured-B"), nil }
dir := provision.InstanceDir(b.DataDir, rt.Host)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
@@ -185,7 +185,7 @@ func TestReplicaRefusalClosesTheControlListener(t *testing.T) {
if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".config.env"), []byte(InstanceKeyVar+"=configured-B\n"), 0o600); err != nil {
t.Fatal(err)
}
- b.ReplicaHomeKeys, b.ReplicaConfigKey = homes, bucketB
+ b.ReplicaHomeKeys, b.ConfiguredKey = homes, bucketB
out := stageHelperApp(t, b, rt.Host, "v1")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
@@ -201,7 +201,7 @@ func TestReplicaRefusalClosesTheControlListener(t *testing.T) {
t.Run("nested run role", func(t *testing.T) {
b, rt := nestedBackend(t, &fakeControl{inv: "inv1"}, &fakeUnit{})
rt.Replica = true
- b.ReplicaHomeKeys, b.ReplicaConfigKey = homes, bucketB
+ b.ReplicaHomeKeys, b.ConfiguredKey = homes, bucketB
dir := provision.InstanceDir(b.DataDir, rt.Host)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
diff --git a/internal/activator/replica_instancekey_test.go b/internal/activator/replica_instancekey_test.go
index 21f28fb1e..41b4023f3 100644
--- a/internal/activator/replica_instancekey_test.go
+++ b/internal/activator/replica_instancekey_test.go
@@ -123,13 +123,13 @@ type replicaCase struct {
start string // "refused" (no app runs) or the helper outcome wanted
wantKeyFromOwnOK bool // a home row: the outcome is the key minted into <host>.env
// bucket is the key the replica's configuration in the bucket renders
- // now (ReplicaConfigKey; "" renders none). bucketDown makes that read
- // fail; noBucket leaves ReplicaConfigKey nil.
+ // now (ConfiguredKey; "" renders none). bucketDown makes that read
+ // fail; noBucket leaves ConfiguredKey nil.
bucket string
bucketDown, noBucket bool
}
-// freshConfig is ExecBackend.ReplicaConfigKey for one case.
+// freshConfig is ExecBackend.ConfiguredKey for one case.
func freshConfig(tc replicaCase) func(context.Context, registry.Route) (string, error) {
if tc.noBucket {
return nil
@@ -196,7 +196,7 @@ func TestReplicaLaunchWithoutAControlSocket(t *testing.T) {
return instancekey.Digest(tc.homeKey), h == rt.Host
}
}
- b.ReplicaConfigKey = freshConfig(tc)
+ b.ConfiguredKey = freshConfig(tc)
out := stageHelperApp(t, b, rt.Host, "v1")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
@@ -250,7 +250,7 @@ func TestReplicaSeedIsGated(t *testing.T) {
}
}
b.ReplicaHomeKeys = func(h string) (string, bool) { return instancekey.Digest(homeKey), true }
- b.ReplicaConfigKey = freshConfig(replicaCase{bucket: homeKey})
+ b.ConfiguredKey = freshConfig(replicaCase{bucket: homeKey})
out := stageHelperApp(t, b, rt.Host, "v1")
err := b.Seed(context.Background(), rt)
got := outcomeOf(out)
@@ -307,7 +307,7 @@ func TestReplicaWorkerIsGatedLikeASeed(t *testing.T) {
}
if tc.replica {
b.ReplicaHomeKeys = func(h string) (string, bool) { return instancekey.Digest(homeKey), h == rt.Host }
- b.ReplicaConfigKey = freshConfig(replicaCase{bucket: tc.bucket})
+ b.ConfiguredKey = freshConfig(replicaCase{bucket: tc.bucket})
}
out := stageHelperApp(t, b, rt.Host, "v1")
extra := map[string]string{"CARLOS_SIDECAR_NAME": "bot"}
@@ -362,7 +362,7 @@ func TestReplicaLegacySidecarIsGatedLikeTheWorker(t *testing.T) {
b.ReplicaHomeKeys = func(h string) (string, bool) { return instancekey.Digest(homeKey), h == rt.Host }
// The bucket agrees with whatever the case holds, so only the
// home-key check decides.
- b.ReplicaConfigKey = freshConfig(replicaCase{bucket: "configured-B"})
+ b.ConfiguredKey = freshConfig(replicaCase{bucket: "configured-B"})
}
out := stageHelperApp(t, b, rt.Host, "v1")
proc, err := b.StartSidecar(context.Background(), rt)
@@ -407,7 +407,7 @@ func TestReplicaGateNamesWhyItRefused(t *testing.T) {
}, "the bucket's configuration could not be read: bucket unreachable"},
} {
t.Run(tc.name, func(t *testing.T) {
- b := &ExecBackend{DataDir: t.TempDir(), ReplicaHomeKeys: tc.homes, ReplicaConfigKey: tc.bucket}
+ b := &ExecBackend{DataDir: t.TempDir(), ReplicaHomeKeys: tc.homes, ConfiguredKey: tc.bucket}
err := b.replicaKeyGate(rt, env, instancekey.LayerConfig, false)
if !errors.Is(err, ErrReplicaKey) || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("gate = %v; want ErrReplicaKey naming %q", err, tc.want)
diff --git a/internal/activator/replicakey.go b/internal/activator/replicakey.go
index d171798b4..515cd8f18 100644
--- a/internal/activator/replicakey.go
+++ b/internal/activator/replicakey.go
@@ -14,7 +14,7 @@ package activator
// digest is its entry in the box's verified list (ReplicaHomeKeys, which
// only the pre-roll check writes, read again at every launch) and, when
// the key comes from the replica's configuration, the configuration in
-// the bucket renders that same key now (ReplicaConfigKey). Where the key
+// the bucket renders that same key now (ConfiguredKey). Where the key
// came from is decided by the same read that composed the env the child
// gets (instanceEnvLayer, ruling 29), never by reading the files again.
// A key that differs from either, or that cannot be checked, refuses
@@ -40,7 +40,6 @@ import (
"errors"
"fmt"
"log"
- "time"
"github.com/carlosframework/platform/internal/instancekey"
"github.com/carlosframework/platform/internal/registry"
@@ -49,9 +48,6 @@ import (
// ErrReplicaKey is a replica launch refused by replicaKeyGate.
var ErrReplicaKey = errors.New("activator: replica launch refused: its instance key is not its home's")
-// replicaConfigTimeout bounds replicaKeyGate's fresh read of the bucket.
-const replicaConfigTimeout = 10 * time.Second
-
// instanceEnvLayer is instanceEnv returning, from the same read, the layer
// its CARLOS_INSTANCE_KEY came from (ruling 29). mergeEnv delivers exactly
// the key instancekey.Effective names, so env[Var] and the layer always
@@ -124,12 +120,11 @@ func (b *ExecBackend) replicaKeyGate(rt registry.Route, env map[string]string, l
default:
return refuse("where its " + instancekey.Var + " came from is unknown")
}
- if b.ReplicaConfigKey == nil {
+ configured := b.configuredKeyFor(rt)
+ if configured == nil {
return refuse("its configured " + instancekey.Var + " cannot be checked against the bucket's configuration")
}
- ctx, cancel := context.WithTimeout(context.Background(), replicaConfigTimeout)
- defer cancel()
- fresh, err := b.ReplicaConfigKey(ctx, rt)
+ fresh, err := configured(context.Background())
switch {
case err != nil:
return refuse("the bucket's configuration could not be read: " + err.Error())
diff --git a/internal/activator/replicakey_race_test.go b/internal/activator/replicakey_race_test.go
index 931666206..b7dfd5bb4 100644
--- a/internal/activator/replicakey_race_test.go
+++ b/internal/activator/replicakey_race_test.go
@@ -33,7 +33,7 @@ func TestReplicaGateDecidesOnTheLaunchesOwnRead(t *testing.T) {
t.Fatal(err)
}
b.ReplicaHomeKeys = func(string) (string, bool) { return instancekey.Digest(keyA), true }
- b.ReplicaConfigKey = func(context.Context, registry.Route) (string, error) { return "", nil } // the bucket: key removed
+ b.ConfiguredKey = func(context.Context, registry.Route) (string, error) { return "", nil } // the bucket: key removed
syncs := 0
replicaGateHook = func() {
syncs++
@@ -78,7 +78,7 @@ func TestReplicaGateReadsNoEnvFile(t *testing.T) {
bucket string
ok bool
}{{keyA, true}, {"", false}} {
- b.ReplicaConfigKey = func(context.Context, registry.Route) (string, error) {
+ b.ConfiguredKey = func(context.Context, registry.Route) (string, error) {
if tc.bucket == "" {
return "", nil
}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 9ab848b86..bf3211f1e 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -366,7 +366,7 @@ func runAgent(args []string, buildClaims ClaimFactory) {
eb.ReplicaHomeKeys = replicaHomeKeys(func() ([]byte, error) { return os.ReadFile(replicaHomeKeysPath) })
// The bucket's configuration, read fresh at each replica launch
// whose key is configured (ruling 27).
- eb.ReplicaConfigKey = replicaConfigKey(os.Getenv, openDeployStore)
+ eb.ConfiguredKey = configuredKey(os.Getenv, openDeployStore)
// The root instance-control channel (design §5.3) and the box facts
// a nested wake needs beyond the row (design §8.1, Task 6): the
// journal check and the peer list both read this SAME registry
diff --git a/internal/host/claim_instancekey_test.go b/internal/host/claim_instancekey_test.go
index 71c46b918..4674ecc3f 100644
--- a/internal/host/claim_instancekey_test.go
+++ b/internal/host/claim_instancekey_test.go
@@ -2,6 +2,7 @@ package host
import (
"context"
+ "errors"
"os"
"path/filepath"
"slices"
@@ -14,6 +15,7 @@ import (
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/instancekey"
"github.com/carlosframework/platform/internal/lease/leasetest"
+ "github.com/carlosframework/platform/internal/registry"
)
// keyCapturingFiles is a C8 provider that records the instance's own env
@@ -78,6 +80,36 @@ func TestClaimFirstWakeKeyIsOnDiskBeforeTheFilesManifest(t *testing.T) {
}
}
+// Final review I2 on the claim path: a first wake whose configuration is
+// still on its way is deferred by the launch source and nothing is minted;
+// once the config sync has delivered the configured key, the launch
+// delivers that key and the own file still holds none.
+func TestClaimFirstWakeWaitsForTheConfiguredKey(t *testing.T) {
+ f := newAdminTokenFixture(t, "box-1")
+ f.backend.ConfiguredKey = func(context.Context, registry.Route) (string, error) { return instancekey.Digest("key-A"), nil }
+ launch := claimServingLaunchSource(f.backend, f.tokens)
+ if _, err := launch(t.Context(), f.rt); !errors.Is(err, activator.ErrInstanceKeyDeferred) || !strings.Contains(err.Error(), adminTokenHost) {
+ t.Fatalf("launch before delivery = %v, want ErrInstanceKeyDeferred naming the host", err)
+ }
+ raw, _ := os.ReadFile(f.envPath())
+ if strings.Contains(string(raw), activator.InstanceKeyVar) {
+ t.Fatal("a key was minted while the configured one was on its way")
+ }
+ if err := os.WriteFile(filepath.Join(f.data, adminTokenHost+".config.env"), []byte(activator.InstanceKeyVar+"=key-A\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ got, err := launch(t.Context(), f.rt)
+ if err != nil {
+ t.Fatalf("launch after delivery: %v", err)
+ }
+ if !slices.Contains(got.Env, activator.InstanceKeyVar+"=key-A") {
+ t.Fatal("the launch does not deliver the configured key")
+ }
+ if raw, _ := os.ReadFile(f.envPath()); strings.Contains(string(raw), activator.InstanceKeyVar) {
+ t.Fatal("a key was minted beside the configured one")
+ }
+}
+
// An instance from before this build has a token and no key. Its next
// claim launch mints the key under the same guarded write, leaves the
// token alone, and a retry keeps both.
diff --git a/internal/host/claim_replica_instancekey_test.go b/internal/host/claim_replica_instancekey_test.go
index f3178c053..aa5bb0ff3 100644
--- a/internal/host/claim_replica_instancekey_test.go
+++ b/internal/host/claim_replica_instancekey_test.go
@@ -30,7 +30,7 @@ func TestClaimReplicaLaunchIsGated(t *testing.T) {
f := newAdminTokenFixture(t, "box-1")
f.rt.Replica = true
f.backend.ReplicaHomeKeys = func(string) (string, bool) { return instancekey.Digest(homeKey), true }
- f.backend.ReplicaConfigKey = func(context.Context, registry.Route) (string, error) { return instancekey.Digest(homeKey), nil }
+ f.backend.ConfiguredKey = func(context.Context, registry.Route) (string, error) { return instancekey.Digest(homeKey), nil }
own := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n"
if err := os.WriteFile(f.envPath(), []byte(own), 0o600); err != nil {
t.Fatal(err)
diff --git a/internal/host/configguard.go b/internal/host/configguard.go
index 8c4e8fee3..742e0a748 100644
--- a/internal/host/configguard.go
+++ b/internal/host/configguard.go
@@ -106,10 +106,7 @@ func newConfigGuard(getenv func(string) string, openStore func(context.Context)
verdicts: map[string]guardVerdict{},
refused: map[string]*guardRefusal{},
}
- switch strings.ToLower(strings.TrimSpace(getenv("CARLOS_NO_SEALED_CONFIG"))) {
- case "1", "true":
- g.noSealedConfig = true
- }
+ g.noSealedConfig = noSealedConfigFlag(getenv)
raw := strings.TrimSpace(getenv("CARLOS_SECRETS_KEY"))
switch {
case raw == "":
@@ -126,6 +123,16 @@ func newConfigGuard(getenv func(string) string, openStore func(context.Context)
return g
}
+// noSealedConfigFlag is the operator's CARLOS_NO_SEALED_CONFIG, read the
+// one way the guard and the instance key's configuration reader share.
+func noSealedConfigFlag(getenv func(string) string) bool {
+ switch strings.ToLower(strings.TrimSpace(getenv("CARLOS_NO_SEALED_CONFIG"))) {
+ case "1", "true":
+ return true
+ }
+ return false
+}
+
// 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 routes, Backing=unit routes
diff --git a/internal/host/instancekeys.go b/internal/host/instancekeys.go
index e68775afa..d79ea90f4 100644
--- a/internal/host/instancekeys.go
+++ b/internal/host/instancekeys.go
@@ -1,7 +1,10 @@
package host
import (
+ "context"
+ "errors"
"log"
+ "time"
"github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/registry"
@@ -20,11 +23,20 @@ import (
// that guard before every cold admission (claim_snapshot.go
// EnsureClaimAdminToken), so a claim box is not backfilled here.
-// instanceKeyBackfiller is the activator surface the backfill needs.
+// instanceKeyBackfiller is the activator surface the backfill needs. The
+// context bounds the one bucket read a mint makes (ruling 5: nothing is
+// minted until the row's configuration is delivered).
type instanceKeyBackfiller interface {
- BackfillInstanceKey(registry.Route) (activator.InstanceKeyOutcome, error)
+ BackfillInstanceKey(context.Context, registry.Route) (activator.InstanceKeyOutcome, error)
}
+// instanceKeyBackfillBudget bounds one pass. Each keyless row costs one
+// read of the bucket's configuration before its key is minted, and the
+// pass runs before the activator starts, so with the bucket unreachable
+// the rows after the budget fail fast rather than each waiting out its
+// own read; every one of them mints at its next start.
+const instanceKeyBackfillBudget = time.Minute
+
// instanceKeyRoute selects the rows the backfill writes for: ready,
// activator-spawned instances, the rows execSyncable delivers config to,
// without sidecars and without replicas (a replica is never minted a key,
@@ -37,28 +49,39 @@ func instanceKeyRoute(rt registry.Route) bool {
rt.Backing != registry.BackingUnit && rt.Tunnel == "" && rt.Unit == ""
}
-// instanceKeyBackfill counts one pass's outcomes.
+// instanceKeyBackfill counts one pass's outcomes. Deferred is a row whose
+// configuration sets a key this box has not delivered yet, or whose
+// configuration could not be read: not minted, by design, and minted or
+// found configured at its next start.
type instanceKeyBackfill struct {
- Minted, Present, FromConfig, NoEnvFile, Failed, Skipped int
+ Minted, Present, FromConfig, NoEnvFile, Deferred, Failed, Skipped int
}
-// backfillInstanceKeys runs the backfill over routes. It never fails the
-// agent: a host it cannot write is logged and counted, and that host's next
-// start mints its key (or refuses, loudly, if the same fault is still there).
-// Nothing logged names a key.
+// backfillInstanceKeys runs the backfill over routes, within
+// instanceKeyBackfillBudget. It never fails the agent: a host it cannot
+// write is logged and counted, and that host's next start mints its key (or
+// refuses, loudly, if the same fault is still there). Nothing logged names
+// a key.
//
-// The error is read before the outcome: a refusal (a fenced or conflicting
-// row) answers InstanceKeyPresent together with its error, and counting it
-// as present would call a host ready whose key was never written.
-func backfillInstanceKeys(routes []registry.Route, b instanceKeyBackfiller) instanceKeyBackfill {
+// The error is read before the outcome: a refused or failed row answers
+// InstanceKeyFailed (the zero outcome) beside its error, and counting an
+// outcome first would call a host ready whose key was never written.
+func backfillInstanceKeys(ctx context.Context, routes []registry.Route, b instanceKeyBackfiller) instanceKeyBackfill {
+ ctx, cancel := context.WithTimeout(ctx, instanceKeyBackfillBudget)
+ defer cancel()
var st instanceKeyBackfill
for _, rt := range routes {
if !instanceKeyRoute(rt) {
st.Skipped++
continue
}
- got, err := b.BackfillInstanceKey(rt)
- if err != nil {
+ got, err := b.BackfillInstanceKey(ctx, rt)
+ switch {
+ case errors.Is(err, activator.ErrInstanceKeyDeferred):
+ st.Deferred++
+ log.Printf("agent: instance key for %s: %v", rt.Host, err)
+ continue
+ case err != nil:
st.Failed++
log.Printf("agent: instance key for %s: %v (its next start mints it, or refuses for the same reason)", rt.Host, err)
continue
@@ -78,7 +101,7 @@ func backfillInstanceKeys(routes []registry.Route, b instanceKeyBackfiller) inst
st.Skipped++
}
}
- log.Printf("agent: instance keys: %d minted, %d already present, %d set by app configuration, %d never started here, %d failed, %d rows skipped",
- st.Minted, st.Present, st.FromConfig, st.NoEnvFile, st.Failed, st.Skipped)
+ log.Printf("agent: instance keys: %d minted, %d already present, %d set by app configuration, %d never started here, %d waiting for their configuration, %d failed, %d rows skipped",
+ st.Minted, st.Present, st.FromConfig, st.NoEnvFile, st.Deferred, st.Failed, st.Skipped)
return st
}
diff --git a/internal/host/instancekeys_test.go b/internal/host/instancekeys_test.go
index 950dd7ccc..1b6c63339 100644
--- a/internal/host/instancekeys_test.go
+++ b/internal/host/instancekeys_test.go
@@ -3,8 +3,10 @@ package host
import (
"context"
"errors"
+ "fmt"
"reflect"
"testing"
+ "time"
"github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/layout"
@@ -17,7 +19,7 @@ type fakeKeyBackfiller struct {
asked []string
}
-func (f *fakeKeyBackfiller) BackfillInstanceKey(rt registry.Route) (activator.InstanceKeyOutcome, error) {
+func (f *fakeKeyBackfiller) BackfillInstanceKey(_ context.Context, rt registry.Route) (activator.InstanceKeyOutcome, error) {
f.asked = append(f.asked, rt.Host)
return f.got[rt.Host], f.err[rt.Host]
}
@@ -46,7 +48,7 @@ func TestBackfillInstanceKeysWritesOnlyReadyActivatorInstances(t *testing.T) {
got: map[string]activator.InstanceKeyOutcome{"shop.example.com": activator.InstanceKeyMinted, "old.example.com": activator.InstanceKeyPresent, "cfg.example.com": activator.InstanceKeyFromConfig},
err: map[string]error{"bad.example.com": errors.New("read: permission denied")},
}
- st := backfillInstanceKeys([]registry.Route{ready("shop.example.com"), ready("old.example.com"), ready("cfg.example.com"), ready("bad.example.com"), held, landing, provisioning, unit, alias, sidecar, addr, replica}, f)
+ st := backfillInstanceKeys(t.Context(), []registry.Route{ready("shop.example.com"), ready("old.example.com"), ready("cfg.example.com"), ready("bad.example.com"), held, landing, provisioning, unit, alias, sidecar, addr, replica}, f)
if len(f.asked) != 4 {
t.Fatalf("asked %v, want only the four ready activator instances", f.asked)
}
@@ -65,7 +67,7 @@ func TestBackfillInstanceKeysCountsARefusalAsFailedNotPresent(t *testing.T) {
got: map[string]activator.InstanceKeyOutcome{rt.Host: activator.InstanceKeyPresent},
err: map[string]error{rt.Host: errors.New("not backfilled: layout migration in flight")},
}
- st := backfillInstanceKeys([]registry.Route{rt}, f)
+ st := backfillInstanceKeys(t.Context(), []registry.Route{rt}, f)
if st != (instanceKeyBackfill{Failed: 1}) {
t.Fatalf("stats = %+v, want the refusal counted as failed only", st)
}
@@ -76,17 +78,47 @@ func TestBackfillInstanceKeysCountsARefusalAsFailedNotPresent(t *testing.T) {
func TestBackfillInstanceKeysCountsAHostNeverStartedHere(t *testing.T) {
rt := registry.Route{Host: "shop.example.com", Kind: "instance", Status: "ready", Socket: "/run/carlos/shop.example.com.sock"}
f := &fakeKeyBackfiller{got: map[string]activator.InstanceKeyOutcome{rt.Host: activator.InstanceKeyNoEnvFile}}
- st := backfillInstanceKeys([]registry.Route{rt}, f)
+ st := backfillInstanceKeys(t.Context(), []registry.Route{rt}, f)
if st != (instanceKeyBackfill{NoEnvFile: 1}) {
t.Fatalf("stats = %+v", st)
}
}
+// A row whose configuration is on its way (activator.ErrInstanceKeyDeferred)
+// is counted apart from a failure: not minted by design, and found at its
+// next start. The whole pass runs under one budget, so a bucket that is
+// down costs the pass at most instanceKeyBackfillBudget, not one read's
+// timeout per row.
+func TestBackfillInstanceKeysCountsADeferredRowApartWithinOneBudget(t *testing.T) {
+ ready := func(host string) registry.Route {
+ return registry.Route{Host: host, Kind: "instance", Status: "ready", Socket: "/run/carlos/" + host + ".sock"}
+ }
+ var deadlines []time.Time
+ f := keyBackfillFunc(func(ctx context.Context, rt registry.Route) (activator.InstanceKeyOutcome, error) {
+ d, ok := ctx.Deadline()
+ if !ok {
+ t.Fatalf("%s: the backfill's context has no deadline", rt.Host)
+ }
+ deadlines = append(deadlines, d)
+ if rt.Host == "waiting.example.com" {
+ return activator.InstanceKeyFailed, fmt.Errorf("%w: its configuration sets CARLOS_INSTANCE_KEY", activator.ErrInstanceKeyDeferred)
+ }
+ return activator.InstanceKeyMinted, nil
+ })
+ st := backfillInstanceKeys(context.Background(), []registry.Route{ready("waiting.example.com"), ready("shop.example.com")}, f)
+ if st != (instanceKeyBackfill{Minted: 1, Deferred: 1}) {
+ t.Fatalf("stats = %+v, want one minted and one waiting", st)
+ }
+ if len(deadlines) != 2 || !deadlines[0].Equal(deadlines[1]) || time.Until(deadlines[0]) > instanceKeyBackfillBudget {
+ t.Fatalf("deadlines %v, want one budget shared by both rows", deadlines)
+ }
+}
+
// keyBackfillFunc is a backfiller made of one function.
-type keyBackfillFunc func(registry.Route) (activator.InstanceKeyOutcome, error)
+type keyBackfillFunc func(context.Context, registry.Route) (activator.InstanceKeyOutcome, error)
-func (f keyBackfillFunc) BackfillInstanceKey(rt registry.Route) (activator.InstanceKeyOutcome, error) {
- return f(rt)
+func (f keyBackfillFunc) BackfillInstanceKey(ctx context.Context, rt registry.Route) (activator.InstanceKeyOutcome, error) {
+ return f(ctx, rt)
}
// Task 6 ruling (K1): the start sequence is activatorStart's, so the two
@@ -116,7 +148,7 @@ func TestActivatorStartBackfillsOnlyALegacyBoxBetweenRecoveryAndPublish(t *testi
Sweep: func(context.Context) error { note("sweep"); return nil },
Recover: func(context.Context, bool) *layout.Pass { note("recover"); return nil },
Routes: func() ([]registry.Route, error) { return []registry.Route{ready}, tc.listErr },
- Keys: keyBackfillFunc(func(registry.Route) (activator.InstanceKeyOutcome, error) {
+ Keys: keyBackfillFunc(func(context.Context, registry.Route) (activator.InstanceKeyOutcome, error) {
note("backfill")
return activator.InstanceKeyMinted, nil
}),
diff --git a/internal/host/layoutleg.go b/internal/host/layoutleg.go
index c3b7821aa..2abec0341 100644
--- a/internal/host/layoutleg.go
+++ b/internal/host/layoutleg.go
@@ -380,7 +380,7 @@ func (s activatorStart) Run(ctx context.Context) {
if routes, err := s.Routes(); err != nil {
log.Printf("agent: instance keys: list routes: %v (each host's next start mints its key)", err)
} else {
- backfillInstanceKeys(routes, s.Keys)
+ backfillInstanceKeys(ctx, routes, s.Keys)
}
s.Publish(pass)
}, s.Start)
diff --git a/internal/host/replicahomekeys.go b/internal/host/replicahomekeys.go
index c1fbd2e2e..74e349afa 100644
--- a/internal/host/replicahomekeys.go
+++ b/internal/host/replicahomekeys.go
@@ -52,12 +52,19 @@ func replicaHomeKeys(read func() ([]byte, error)) func(host string) (string, boo
}
}
-// replicaConfigKey is ExecBackend.ReplicaConfigKey (ruling 27): the digest
-// of the key rt's configuration in the bucket renders now, read with the
-// box's secrets key through the deploy store. The store and key are opened
-// on first use and kept once both open; a failure is returned, so the
-// replica is refused, and is tried again at its next launch.
-func replicaConfigKey(getenv func(string) string, open func(context.Context) (release.Store, error)) func(context.Context, registry.Route) (string, error) {
+// configuredKey is ExecBackend.ConfiguredKey (rulings 5 and 27): the
+// digest of the key rt's configuration in the bucket renders now, read
+// through the deploy store with the box's secrets key. Which boxes have a
+// configuration to read is decided as the config guard decides whether a
+// box can provide a route's config at all (configguard.go): a box with no
+// deployment store and CARLOS_NO_SEALED_CONFIG set has no configuration
+// source and renders no key; one with no store and no such flag is an
+// error; one with a store and no secrets key renders what it can, and a
+// sealed value fails the render here as it fails the guard. The store is
+// opened on first use and kept once open; a failed open is returned, so
+// the mint is deferred or the replica refused, and is tried again at the
+// next launch.
+func configuredKey(getenv func(string) string, open func(context.Context) (release.Store, error)) func(context.Context, registry.Route) (string, error) {
var (
mu sync.Mutex
store release.Store
@@ -66,17 +73,20 @@ func replicaConfigKey(getenv func(string) string, open func(context.Context) (re
return func(ctx context.Context, rt registry.Route) (string, error) {
mu.Lock()
if store == nil {
- raw := getenv("CARLOS_SECRETS_KEY")
- if raw == "" {
- mu.Unlock()
- return "", errors.New("CARLOS_SECRETS_KEY is not set")
+ var k *ecdh.PrivateKey
+ if raw := getenv("CARLOS_SECRETS_KEY"); raw != "" {
+ var err error
+ k, err = appconfig.ParsePrivateKey(raw, "CARLOS_SECRETS_KEY")
+ if err != nil {
+ mu.Unlock()
+ return "", err
+ }
}
- k, err := appconfig.ParsePrivateKey(raw, "CARLOS_SECRETS_KEY")
- if err != nil {
+ st, err := open(ctx)
+ if errors.Is(err, errNoDeployStore) && noSealedConfigFlag(getenv) {
mu.Unlock()
- return "", err
+ return "", nil // no configuration source: nothing is on its way
}
- st, err := open(ctx)
if err != nil {
mu.Unlock()
return "", err
diff --git a/internal/host/replicahomekeys_test.go b/internal/host/replicahomekeys_test.go
index c2939f532..c3823dc9b 100644
--- a/internal/host/replicahomekeys_test.go
+++ b/internal/host/replicahomekeys_test.go
@@ -81,13 +81,18 @@ func TestReplicaHomeKeysLogsAMalformedListWithoutItsText(t *testing.T) {
}
}
-// Ruling 27's fresh read, as the agent wires it: the bucket's current
-// configured key, through the deploy store and the box's secrets key. With
-// no secrets key it is an error, so the replica is refused.
-func TestReplicaConfigKeyReadsTheBucketNow(t *testing.T) {
+// Rulings 5 and 27's fresh read, as the agent wires it: the bucket's
+// current configured key, through the deploy store and the box's secrets
+// key. Which boxes have a configuration to read follows the config guard:
+// no secrets key still reads the bucket and renders a plain value (a
+// sealed one fails the render, as it fails the guard); no deployment
+// store is an error, unless CARLOS_NO_SEALED_CONFIG says this deployment
+// has no configuration at all, where nothing is on its way and no key is
+// rendered.
+func TestConfiguredKeyReadsTheBucketNow(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
- priv, _, err := appconfig.GenerateKey()
+ priv, pubB64, err := appconfig.GenerateKey()
if err != nil {
t.Fatal(err)
}
@@ -97,7 +102,7 @@ func TestReplicaConfigKeyReadsTheBucketNow(t *testing.T) {
rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", App: "shop", Account: "acct", Replica: true}
opened := 0
open := func(context.Context) (release.Store, error) { opened++; return s, nil }
- read := replicaConfigKey(func(k string) string {
+ read := configuredKey(func(k string) string {
if k == "CARLOS_SECRETS_KEY" {
return priv
}
@@ -111,18 +116,45 @@ func TestReplicaConfigKeyReadsTheBucketNow(t *testing.T) {
if opened != 1 {
t.Fatalf("the store was opened %d times, want once", opened)
}
- failing := replicaConfigKey(func(string) string { return priv }, func(context.Context) (release.Store, error) { return nil, errors.New("bucket unreachable") })
+ failing := configuredKey(func(string) string { return priv }, func(context.Context) (release.Store, error) { return nil, errors.New("bucket unreachable") })
if _, err := failing(ctx, rt); err == nil {
t.Fatal("an unreachable bucket was not an error")
}
- if _, err := replicaConfigKey(func(string) string { return "" }, open)(ctx, rt); err == nil {
- t.Fatal("no secrets key was not an error")
+ if got, err := configuredKey(func(string) string { return "" }, open)(ctx, rt); err != nil || got != instancekey.Digest("key-A") {
+ t.Fatalf("no secrets key, a plain configured key = %q, %v; want the digest (the guard admits plain config on a keyless box)", got, err)
+ }
+ pub, err := appconfig.ParsePublicKey(pubB64, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ envelope, err := appconfig.SealUnbound(pub, []byte("x"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := appconfig.SetSecret(ctx, s, "acct", "shop", map[string]string{"SEALED": envelope}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := configuredKey(func(string) string { return "" }, open)(ctx, rt); err == nil {
+ t.Fatal("no secrets key beside a sealed value was not an error")
+ }
+ noStore := func(context.Context) (release.Store, error) { return nil, errNoDeployStore }
+ if _, err := configuredKey(func(string) string { return "" }, noStore)(ctx, rt); err == nil {
+ t.Fatal("no deployment store was not an error")
+ }
+ bypass := func(k string) string {
+ if k == "CARLOS_NO_SEALED_CONFIG" {
+ return "1"
+ }
+ return ""
+ }
+ if got, err := configuredKey(bypass, noStore)(ctx, rt); err != nil || got != "" {
+ t.Fatalf("no store under CARLOS_NO_SEALED_CONFIG = %q, %v; want no key and no error", got, err)
}
}
// A failed first open is not kept: the same closure opens the store again
// at the next replica launch, and reads the bucket once it opens.
-func TestReplicaConfigKeyRetriesAFailedOpen(t *testing.T) {
+func TestConfiguredKeyRetriesAFailedOpen(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
priv, _, err := appconfig.GenerateKey()
@@ -134,7 +166,7 @@ func TestReplicaConfigKeyRetriesAFailedOpen(t *testing.T) {
}
rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", App: "shop", Account: "acct", Replica: true}
opens := 0
- read := replicaConfigKey(func(string) string { return priv }, func(context.Context) (release.Store, error) {
+ read := configuredKey(func(string) string { return priv }, func(context.Context) (release.Store, error) {
opens++
if opens == 1 {
return nil, errors.New("bucket unreachable")