| index d1649d58e..ed2e34b4b 100644 |
| --- a/docs/superpowers/plans/2026-10-10-instance-key-provision.md |
| +++ b/docs/superpowers/plans/2026-10-10-instance-key-provision.md |
| @@ -1,4 +1,4 @@ |
| -# Instance Key Provision (Region HA L21) Implementation Plan, rev 4 |
| +# Instance Key Provision (Region HA L21) Implementation Plan, rev 5 |
| |
| > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| |
| @@ -13,7 +13,21 @@ |
| - Region HA spec `docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md`: H6 (line 131) and §5.6 (line 539): `instance.key`, `report.token`, `<host>.env` are always continuous. |
| - Framework contract: rastrillo/rastrillo discussion #66 (agreed; landed on rastrillo main as e3ea08d). `carlos.InstanceKey(own string) (string, error)` resolves, in order: (1) `own`, the app's own key; (2) `$CARLOS_INSTANCE_KEY`, verbatim; (3) on CARLOS (`carlos.Running()`, which checks `$CARLOS_CONTROL_SOCKET`) with no key, `ErrNoInstanceKey`; (4) off CARLOS, `ErrNotOnCarlos`, today's behaviour. Read at e3ea08de in `~/amadan.net/rastrillo/rastrillo` (`carlos/instancekey.go`, `carlos/schedule.go:28`). |
| - Stage B plan `docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md` on `region-ha-1b-next-build` (919662d2): rulings 25 and 55, Task 8b step 0 (its O2' variant with a `KeyEvidence` reader) and Task 8c (O3). This plan's O2' replaces that step. |
| -- Review: Astra rounds 1 to 4, `docs/superpowers/plans/CODEX-PLAN-INSTANCE-KEY-R1.md` to `-R4.md` (verdict each time: not ready; rounds 3 and 4 found no P1). Their findings are answered in the four tables below, newest first. |
| +- Review: Astra rounds 1 to 5, `docs/superpowers/plans/CODEX-PLAN-INSTANCE-KEY-R1.md` to `-R5.md` (verdict each time: not ready; rounds 3 to 5 found no P1). Their findings are answered in the five tables below, newest first. |
| + |
| +## Rev 5: round 5 dispositions |
| + |
| +Astra's round 5 (`CODEX-PLAN-INSTANCE-KEY-R5.md`, reviewed at 37c48a40) accepted the rest of the plan, including the keyless gate on every launch path, and `childEnv` stripping the list from inherited tenant environments. It found two P2s and a P3, all about replicas. Paul's rulings 27 and 28 answer the P2s. The replica scope stays narrow. |
| + |
| +| # | 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-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) | |
| + |
| +**Counts:** 3 accepted, 0 declined. |
| + |
| +**Effect on stage B (IK-1):** none. IK-1's symbols (`ParseEnv`, `Effective`, `Digest`, `Binding`, `Adoption`, `AdoptionRecord`, `ParseAdoption`, `Binds`, `VerifyDelivered`, `AdoptionItem`, `AdoptionSuffix`) are unchanged in name, signature and behaviour. The only `instancekey` change is the error text of `ParseHomeKeys`, which stage B does not use. O2' and its test are unchanged. |
| |
| ## Rev 4: round 4 dispositions |
| |
| @@ -272,7 +286,18 @@ Source (c) and `ConfiguredInstanceKey` are gone: a configured key is covered by |
| - A keyless replica on a claim box does not start before O6. Claim launches start nothing before Group D anyway. |
| - A flat replica whose control bind fails does not start. |
| - A replica whose key the operator never listed is refused after the roll. That is loud and fixed by the list. |
| -- Until O8, the list is a snapshot. If a home's effective key changes after the check while its replica keeps the old one, the replica still passes until the list is refreshed. Under rulings 5 and 13 that happens only when a home's own `<host>.env` is edited by hand, or when the key is configured per host (an instance overlay) on one side only. The rollout says to re-run the check after either. A shared app-level configured key changes on both sides at once, and the replica is then refused until its list entry is refreshed, which is safe. |
| +- The list's freshness, and any change to a configured key, are ruling 27 (rev 5). O8's scope is ruling 28. |
| + |
| +**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 `CARLOS_REPLICA_HOME_KEYS`; 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 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. |
| + |
| +*Cost if wrong:* a bucket read on every launch of a replica whose key is configured. A bucket that cannot be reached refuses those launches, which is safe but costs availability while the bucket is down. |
| + |
| +**28. O8 delivers platform keys only (rev 5; Paul's ruling; Astra round 5 finding 2).** O8 hands a keyless replica only a key the platform minted into the home's `<host>.env`, read from the home's published env item and verified against it. A home whose key exists only in its configuration is explicitly deferred: its value is not in any published item, and the adoption record holds only its digest. Its keyless replica keeps refusing (ruling 26), and a replica configured with the same key follows ruling 27. *Cost if wrong:* an app that keys its home by configuration must also configure its replicas with that key (the shared app-level key, listed by the pre-roll check) for them to run. |
| |
| ## Obligations on other plans |
| |
| @@ -305,13 +330,13 @@ Rev 3 rewrites O1', O2', O3', O4, O5 and O6, and adds O7. Rev 4 adds O8. |
| - Journals: the lists are journalled in order, and a journal written before O7 keeps its recorded list. No record exists anywhere before O6 ships, so an older journal cannot miss one. |
| - Tests (in the moves change): `TestMoveAwayRetiresTheAdoptionRecordSoTheHostCanReturn`. A flat source holds a record; the move completes; the source finalization parks the record; `EmptyInstallationCensus.CheckLocal` for a reinstall of the host on the source box passes; a move back lands. Also the dirty-return variant: a return over a source that still holds a stale record parks it before placing anything. |
| |
| -- **O8 (replica placements, #162; ruling 26).** It hands a replica its home's key. Today's fallback: a replica that cannot be handed its home's key gets none, and refuses to start reliably (`replicaKeyGate`). |
| +- **O8 (replica placements, #162; rulings 26 and 28).** It hands a keyless replica its home's **platform-minted** key, and only that. Today's fallback: a replica that cannot be handed its home's key gets none, and refuses to start reliably (`replicaKeyGate`). |
| - The replica record names its home. `provision.Replica` and the console's create door record the home's host (and so its incarnation, through the home's claim) in an immutable field. Today a replica record names only the app, account, region and role. |
| - The replica's box reads the home's published `EnvItem` from the home's files manifest, in the home region's data bucket (O1' publishes it). That needs read access for the replica's region to the home region's bucket, which no box has today. |
| - - It verifies the env item's effective key by digest against the home's adoption record (`instancekey.Binds` to the home's current writer) or the env item itself. |
| - - It writes the key, create-only, into the replica's own `<host>.env` (or keeps a configured key that matches), and supplies `ExecBackend.ReplicaHomeKeys` from the same read, replacing `CARLOS_REPLICA_HOME_KEYS`. |
| - - A home with a configured key is delivered the same way: the digest is of the home's effective key, whatever its source. |
| - - Test, in O8's plan: a keyless replica of an adopting app starts with exactly its home's key, and its helper resolves it; a home whose env item is unreadable or unverifiable leaves the replica refused. |
| + - It takes the env item's own key, the one the platform minted, and verifies it by digest against the home's adoption record (`instancekey.Binds` to the home's current writer) or the env item itself. |
| + - It writes the key, create-only, into the replica's own `<host>.env`, and supplies `ExecBackend.ReplicaHomeKeys` from the same read, replacing `CARLOS_REPLICA_HOME_KEYS` for that replica. |
| + - **A home whose key exists only in configuration is out of O8's scope (ruling 28).** Its value is in no published item. Its keyless replica keeps refusing, and a configured-key replica follows ruling 27. |
| + - Test, in O8's plan: a keyless replica of an adopting app whose home holds a platform key starts with exactly that key, and its helper resolves it. A home whose env item is unreadable, unverifiable or keyless leaves the replica refused. |
| |
| ### O2' for stage B |
| |
| @@ -680,6 +705,7 @@ Two changes to the ask above, and only the second is new: |
| 5. **A failed sync, then a retry.** The retry keeps the bytes, redoes the fchmod and both syncs, and only then succeeds; no child starts in between (Tasks 2 to 4). |
| 6. **An empty `CARLOS_INSTANCE_KEY=` in `<host>.env` with a configured key.** The child gets the configured key and nothing is minted (Task 3). |
| 7. **A replica without a control socket.** A keyless replica is refused by the agent, before its app could mint; a replica whose key is not its home's is refused; its home's key runs (Task 3b, through the helper's real resolution). |
| +8. **A configured key in flight (ruling 27).** The bucket says B while the replica's box still holds A, and the replica is refused. The documented residual, a change only on the home, runs with A (Task 3b). |
| |
| ## File structure |
| |
| @@ -687,7 +713,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`) and `internal/host/replicahomekeys.go` (`replicaHomeKeys`), with `internal/host/replicahomekeys_test.go`. Modify `internal/activator/backend_exec.go` (`ExecBackend.ReplicaHomeKeys`; 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`, `replicaConfigKey`) and `internal/appconfig/instancekey.go` (`ConfiguredKeyDigest`), with `internal/host/replicahomekeys_test.go` and `internal/appconfig/instancekey_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). |
| - 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`. |
| @@ -813,7 +839,8 @@ func TestVerifyDeliveredRefusesAnyOtherKey(t *testing.T) { |
| } |
| |
| // Ruling 26: the pre-roll check's verified home keys, as the box reads |
| -// them. A list that may be wrong verifies nothing. |
| +// them. A list that may be wrong verifies nothing, and its errors never |
| +// echo an entry, which may hold a digest (Astra round 5 finding 3). |
| func TestParseHomeKeys(t *testing.T) { |
| a, b := Digest("home-key-a"), Digest("home-key-b") |
| got, err := ParseHomeKeys(" shop-au.example.com=" + a + ", shop-uk.example.com=" + b + ",") |
| @@ -823,9 +850,21 @@ func TestParseHomeKeys(t *testing.T) { |
| if got, err := ParseHomeKeys(""); err != nil || len(got) != 0 { |
| t.Fatalf("empty = %v, %v", got, err) |
| } |
| - for _, bad := range []string{"shop-au.example.com", "shop-au.example.com=abc", "=" + a, "shop-au.example.com=" + a + ",shop-au.example.com=" + b, "a b=" + a} { |
| - if _, err := ParseHomeKeys(bad); err == nil { |
| - t.Errorf("%q parsed", bad) |
| + for _, tc := range []struct{ value, want string }{ |
| + {a, "entry 1: no \"replica-host=\""}, |
| + {"shop-au.example.com=" + a + "," + b, "entry 2: no \"replica-host=\""}, |
| + {"shop-au.example.com=" + a[:40], "entry 1: the digest is not"}, |
| + {"=" + a, "entry 1: the host is empty"}, |
| + {a[:20] + "=" + a, "entry 1: the host is empty or not a host name"}, |
| + {"shop-au.example.com=" + a + ",shop-au.example.com=" + b, "entry 2: its host is listed"}, |
| + } { |
| + _, err := ParseHomeKeys(tc.value) |
| + if err == nil || !strings.Contains(err.Error(), tc.want) { |
| + t.Errorf("%q: err = %v, want %q", tc.value, err, tc.want) |
| + continue |
| + } |
| + if strings.Contains(err.Error(), a[:16]) || strings.Contains(err.Error(), b[:16]) || strings.Contains(err.Error(), "shop-") { |
| + t.Errorf("the error echoes its input: %v", err) |
| } |
| } |
| } |
| @@ -1038,26 +1077,36 @@ func ValidDigest(s string) bool { |
| } |
| |
| // ParseHomeKeys reads CARLOS_REPLICA_HOME_KEYS, the box's list of verified |
| -// home keys for its replica rows (instance-key plan ruling 26): |
| +// home keys for its replica rows (instance-key plan rulings 26 and 27): |
| // "replica-host=sha256,replica-host=sha256", each digest the home's |
| -// effective key as the pre-roll check computed it. Whitespace around |
| -// entries is ignored and "" is an empty list. A malformed entry, or a host |
| -// named twice, fails the whole value: a list that may be wrong is not a |
| -// list to verify keys against. |
| +// effective key as the pre-roll check computed it. Only the pre-roll check |
| +// writes it; nothing refreshes it from a replica's own configuration. |
| +// Whitespace around entries is ignored and "" is an empty list. A |
| +// malformed entry, or a host named twice, fails the whole value: a list |
| +// that may be wrong is not a list to verify keys against. Its errors name |
| +// the entry by position and the reason, never the entry's text, which may |
| +// hold a digest. |
| func ParseHomeKeys(s string) (map[string]string, error) { |
| out := map[string]string{} |
| + n := 0 |
| for _, e := range strings.Split(s, ",") { |
| e = strings.TrimSpace(e) |
| if e == "" { |
| continue |
| } |
| + n++ |
| host, sha, ok := strings.Cut(e, "=") |
| host, sha = strings.TrimSpace(host), strings.TrimSpace(sha) |
| - if !ok || host == "" || strings.ContainsAny(host, " /") || !ValidDigest(sha) { |
| - return nil, fmt.Errorf("instancekey: home key entry %q: want replica-host=<sha256 hex>", host) |
| + switch { |
| + case !ok: |
| + return nil, fmt.Errorf("instancekey: CARLOS_REPLICA_HOME_KEYS entry %d: no \"replica-host=\" before the digest", n) |
| + case host == "" || strings.ContainsAny(host, " /=") || !strings.Contains(host, "."): |
| + return nil, fmt.Errorf("instancekey: CARLOS_REPLICA_HOME_KEYS entry %d: the host is empty or not a host name", n) |
| + case !ValidDigest(sha): |
| + return nil, fmt.Errorf("instancekey: CARLOS_REPLICA_HOME_KEYS entry %d: the digest is not 64 lowercase hex characters", n) |
| } |
| if _, dup := out[host]; dup { |
| - return nil, fmt.Errorf("instancekey: home key for %q named twice", host) |
| + return nil, fmt.Errorf("instancekey: CARLOS_REPLICA_HOME_KEYS entry %d: its host is listed by an earlier entry", n) |
| } |
| out[host] = sha |
| } |
| @@ -2236,13 +2285,13 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| ### Task 3b: A replica gets exactly its home's key, or none and a refusal (ruling 26) |
| |
| **Files:** |
| -- Create: `internal/activator/replicakey.go`, `internal/host/replicahomekeys.go` |
| -- Modify: `internal/activator/backend_exec.go` (`ExecBackend.ReplicaHomeKeys`; the gate in `seedChild` and `spawnChildFrom`), `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/host/replicahomekeys_test.go` |
| +- 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` and `spawnChildFrom`), `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/host/replicahomekeys_test.go`, `internal/appconfig/instancekey_test.go` |
| |
| **Interfaces:** |
| -- Consumes: Task 1's `instancekey.Digest` and `ParseHomeKeys`; Task 2's `InstanceKeyReplica` (never minted); test helpers `testExecBackend`, `tmpSock`, `nestedBackend`, `readEnvFile`. |
| -- Produces: `ExecBackend.ReplicaHomeKeys func(host string) (sha256 string, ok bool)`; `(*ExecBackend).replicaKeyGate(rt, env, haveControl) error`; `activator.ErrReplicaKey`; `host.replicaHomeKeys(getenv)`, which reads `CARLOS_REPLICA_HOME_KEYS`. Task 4's snapshot calls the gate. |
| +- 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, haveControl) error`; `activator.ErrReplicaKey`; `appconfig.ConfiguredKeyDigest(ctx, s, priv, rt) (string, error)`; `host.replicaHomeKeys(getenv)`, which reads `CARLOS_REPLICA_HOME_KEYS`, and `host.replicaConfigKey(getenv, open)`, the fresh read (ruling 27). Task 4's snapshot calls the gate. |
| |
| - [ ] **Step 1: Write the failing tests** |
| |
| @@ -2375,21 +2424,61 @@ type replicaCase struct { |
| homeKey string // the home's effective key the box lists ("" = none listed) |
| 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. |
| + bucket string |
| + bucketDown, noBucket bool |
| } |
| |
| -// Ruling 26 through a real launch and the helper (Astra round 4): a |
| -// replica runs only with exactly its home's key, or with none and a control |
| -// socket its helper refuses on. Every launch here has no control socket, |
| -// which is where the helper would otherwise mint. |
| +// freshConfig is ExecBackend.ReplicaConfigKey for one case. |
| +func freshConfig(tc replicaCase) func(context.Context, registry.Route) (string, error) { |
| + if tc.noBucket { |
| + return nil |
| + } |
| + return func(context.Context, registry.Route) (string, error) { |
| + switch { |
| + case tc.bucketDown: |
| + return "", errors.New("bucket unreachable") |
| + case tc.bucket == "": |
| + return "", nil |
| + } |
| + return instancekey.Digest(tc.bucket), nil |
| + } |
| +} |
| + |
| +// Rulings 26 to 28 through a real launch and the helper (Astra rounds 4 and |
| +// 5): a replica runs only with exactly its home's key, listed by the |
| +// pre-roll check and, when configured, still the bucket's configured key; |
| +// or with none and a control socket its helper refuses on. Every launch |
| +// here has no control socket, which is where the helper would otherwise |
| +// mint. |
| func TestReplicaLaunchWithoutAControlSocket(t *testing.T) { |
| tok := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n" |
| homeKey := strings.Repeat("H", 43) + "=" |
| for _, tc := range []replicaCase{ |
| {name: "a keyless replica is refused by the agent, not left to mint", replica: true, own: tok, start: "refused"}, |
| - {name: "a configured key that is the home's runs and seals with it", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, start: "key " + instancekey.Digest(homeKey)}, |
| + {name: "a configured key that is the home's runs and seals with it", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, bucket: homeKey, start: "key " + instancekey.Digest(homeKey)}, |
| {name: "a pre-existing env key that is the home's runs", replica: true, own: tok + InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, start: "key " + instancekey.Digest(homeKey)}, |
| - {name: "a configured key that differs is refused", replica: true, own: tok, config: InstanceKeyVar + "=configured-B\n", homeKey: homeKey, start: "refused"}, |
| - {name: "a configured key with no verified home key is refused", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", start: "refused"}, |
| + {name: "a configured key that differs is refused", replica: true, own: tok, config: InstanceKeyVar + "=configured-B\n", homeKey: homeKey, bucket: "configured-B", start: "refused"}, |
| + {name: "a configured key with no verified home key is refused", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", bucket: homeKey, start: "refused"}, |
| + // Ruling 27, Astra round 5 finding 1: the key changed in the bucket |
| + // to B and the home already holds it, while this box's sync is |
| + // behind and the replica's configuration and list entry still say A. |
| + {name: "the home updated, this replica's configuration delayed: refused", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, bucket: "configured-B", start: "refused"}, |
| + {name: "the bucket cannot be read: refused", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, bucketDown: true, start: "refused"}, |
| + {name: "no fresh reader: refused", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, noBucket: true, start: "refused"}, |
| + // Ruling 27's residual, documented: a change the bucket does not |
| + // hold (the home's own <host>.env edited by hand, or a per-host key |
| + // configured on the home only) is invisible here. The replica's |
| + // list entry and the bucket both still say A, so it runs with A. |
| + // Only the procedure, or O8's fresh evidence, closes this. |
| + {name: "residual: a change only on the home is not seen, and the replica runs with the old key", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, bucket: homeKey, start: "key " + instancekey.Digest(homeKey)}, |
| + // Ruling 28: a home whose key exists only in configuration, with a |
| + // replica that is handed none (its own overlay blanks it). O8 never |
| + // delivers a configured key, so the replica stays keyless and is |
| + // refused here, where it has no control socket. |
| + {name: "a config-only home's keyless replica is refused", replica: true, own: tok, config: InstanceKeyVar + "=\n", homeKey: homeKey, bucket: "", start: "refused"}, |
| {name: "a home row mints and its app seals with the minted key", own: tok, wantKeyFromOwnOK: true}, |
| } { |
| t.Run(tc.name, func(t *testing.T) { |
| @@ -2409,6 +2498,7 @@ func TestReplicaLaunchWithoutAControlSocket(t *testing.T) { |
| return instancekey.Digest(tc.homeKey), h == rt.Host |
| } |
| } |
| + b.ReplicaConfigKey = freshConfig(tc) |
| out := stageHelperApp(t, b, rt.Host, "v1") |
| ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| defer cancel() |
| @@ -2462,6 +2552,7 @@ func TestReplicaSeedIsGated(t *testing.T) { |
| } |
| } |
| b.ReplicaHomeKeys = func(h string) (string, bool) { return instancekey.Digest(homeKey), true } |
| + b.ReplicaConfigKey = freshConfig(replicaCase{bucket: homeKey}) |
| out := stageHelperApp(t, b, rt.Host, "v1") |
| err := b.Seed(context.Background(), rt) |
| got := outcomeOf(out) |
| @@ -2558,6 +2649,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 } |
| dir := provision.InstanceDir(b.DataDir, rt.Host) |
| if err := os.MkdirAll(dir, 0o700); err != nil { |
| t.Fatal(err) |
| @@ -2589,15 +2681,71 @@ func TestNestedReplicaPrepareIsGated(t *testing.T) { |
| } |
| ``` |
| |
| +Create `internal/appconfig/instancekey_test.go`: |
| + |
| +```go |
| +package appconfig |
| + |
| +import ( |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// Ruling 27's fresh check: the digest of the key the bucket's |
| +// configuration renders now, from the app's bundle or the host's overlay, |
| +// plain or sealed; "" when it renders none. |
| +func TestConfiguredKeyDigestReadsTheBucketNow(t *testing.T) { |
| + ctx, s, _, _, priv, pub := execKit(t) |
| + pk, _ := ParsePrivateKey(priv, "test") |
| + pubKey, _ := ParsePublicKey(pub, "test") |
| + rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", App: "shop", Account: "acct", Replica: true} |
| + check := func(want string) { |
| + t.Helper() |
| + got, err := ConfiguredKeyDigest(ctx, s, pk, rt) |
| + if err != nil || got != want { |
| + t.Fatalf("ConfiguredKeyDigest = %q, %v; want %q", got, err, want) |
| + } |
| + } |
| + check("") |
| + if err := SetEnv(ctx, s, "acct", "shop", map[string]string{"CARLOS_INSTANCE_KEY": "key-A"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + check(instancekey.Digest("key-A")) |
| + if err := SetEnv(ctx, s, "acct", "shop", map[string]string{"CARLOS_INSTANCE_KEY": "key-B"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + check(instancekey.Digest("key-B")) |
| + sealed, err := Seal(pubKey, Binding{Account: "acct", App: "shop"}, []byte("key-C")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := ApplyInstance(ctx, s, "acct", "shop", rt.Host, map[string]Var{"CARLOS_INSTANCE_KEY": {Kind: "secret", Envelope: sealed}}, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + check(instancekey.Digest("key-C")) |
| +} |
| +``` |
| + |
| Create `internal/host/replicahomekeys_test.go`: |
| |
| ```go |
| package host |
| |
| import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "log" |
| + "os" |
| + "strings" |
| "testing" |
| |
| + "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| |
| func TestReplicaHomeKeysFromTheEnvironment(t *testing.T) { |
| @@ -2610,17 +2758,70 @@ func TestReplicaHomeKeysFromTheEnvironment(t *testing.T) { |
| if _, ok := keys("shop-uk.example.com"); ok { |
| t.Fatal("an unlisted replica verified") |
| } |
| - env["CARLOS_REPLICA_HOME_KEYS"] = "shop-au.example.com=" + sha + ",shop-au.example.com=" + sha |
| - if _, ok := replicaHomeKeys(func(k string) string { return env[k] })("shop-au.example.com"); ok { |
| +} |
| + |
| +// Astra round 5 finding 3: a malformed list is logged by entry position and |
| +// reason, never by its text (a bare digest, with no host before it, would |
| +// otherwise reach the log), and verifies nothing. |
| +func TestReplicaHomeKeysLogsAMalformedListWithoutItsText(t *testing.T) { |
| + sha := instancekey.Digest("home-key") |
| + var buf bytes.Buffer |
| + log.SetOutput(&buf) |
| + defer log.SetOutput(os.Stderr) |
| + keys := replicaHomeKeys(func(string) string { return "shop-au.example.com=" + sha + "," + sha }) |
| + if _, ok := keys("shop-au.example.com"); ok { |
| t.Fatal("a malformed list verified a key") |
| } |
| + line := buf.String() |
| + if !strings.Contains(line, "CARLOS_REPLICA_HOME_KEYS entry 2") || strings.Contains(line, sha[:16]) || strings.Contains(line, "shop-au") { |
| + t.Fatalf("log line %q: want the entry's position and reason, and none of its text", line) |
| + } |
| +} |
| + |
| +// 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) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + priv, _, err := appconfig.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := appconfig.SetEnv(ctx, s, "acct", "shop", map[string]string{instancekey.Var: "key-A"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + 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 { |
| + if k == "CARLOS_SECRETS_KEY" { |
| + return priv |
| + } |
| + return "" |
| + }, open) |
| + for i := 0; i < 2; i++ { |
| + if got, err := read(ctx, rt); err != nil || got != instancekey.Digest("key-A") { |
| + t.Fatalf("read %d = %q, %v", i, got, err) |
| + } |
| + } |
| + 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") }) |
| + 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") |
| + } |
| } |
| ``` |
| |
| - [ ] **Step 2: Run them to verify they fail** |
| |
| -Run: `nice -n 19 go test ./internal/activator/ -run 'TestReplicaLaunchWithoutAControlSocket|TestReplicaSeedIsGated|TestKeylessReplica|TestNestedReplicaPrepareIsGated' -count=1 -v; nice -n 19 go test ./internal/host/ -run TestReplicaHomeKeys -count=1` |
| -Expected: build failures, `undefined: ErrReplicaKey` (and `ReplicaHomeKeys`, `replicaHomeKeys`). 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 failure. |
| +Run: `nice -n 19 go test ./internal/activator/ -run 'TestReplicaLaunchWithoutAControlSocket|TestReplicaSeedIsGated|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. |
| |
| - [ ] **Step 3: Write the gate** |
| |
| @@ -2629,15 +2830,20 @@ Create `internal/activator/replicakey.go`: |
| ```go |
| package activator |
| |
| -// A replica gets its home's key (instance-key plan ruling 26). A replica |
| -// (#162) mirrors its home's data, sealed with the home's key, so the only |
| -// key it may be handed is exactly the home's effective key. The platform |
| -// never mints one on a replica row (ensureInstanceKeyFor), and every |
| -// replica launch passes replicaKeyGate before anything runs: |
| +// A replica gets its home's key (instance-key plan rulings 26 to 28). A |
| +// replica (#162) mirrors its home's data, sealed with the home's key, so |
| +// the only key it may be handed is exactly the home's effective key. The |
| +// platform never mints one on a replica row (ensureInstanceKeyFor), and |
| +// every replica launch passes replicaKeyGate before anything runs: |
| // |
| // - a replica whose env files deliver a key starts only when that key's |
| -// digest is its home's (ReplicaHomeKeys). A key that differs, or one |
| -// that cannot be checked, refuses the launch with a log line; |
| +// digest is its entry in the box's verified list (ReplicaHomeKeys, which |
| +// only the pre-roll check writes) and, when the key comes from the |
| +// replica's configuration, the configuration in the bucket renders that |
| +// same key now (ReplicaConfigKey). A key that differs from either, or |
| +// that cannot be checked, refuses the launch with a log line. The fresh |
| +// read catches a configuration change that this box has not synced yet |
| +// while the home already holds the new key (ruling 27); |
| // - a replica whose env files deliver no key starts only with a control |
| // socket. There an app on rastrillo's InstanceKey with no key of its |
| // own gets ErrNoInstanceKey and refuses (step 3), and an app that never |
| @@ -2646,14 +2852,17 @@ package activator |
| // to step 4 and mint its own key over the home's data, so the agent |
| // refuses the launch itself rather than leave that to the helper. |
| // |
| -// Delivering the home's key to a replica that has none is O8's: until a |
| -// replica can read its home's published env item, a keyless replica gets |
| -// nothing. |
| +// Delivering the home's key to a replica that has none is O8's, for a |
| +// platform-minted home key only (ruling 28): until then, and always for a |
| +// home whose key exists only in configuration, a keyless replica gets |
| +// nothing and is gated as above. |
| |
| import ( |
| + "context" |
| "errors" |
| "fmt" |
| "log" |
| + "time" |
| |
| "github.com/carlosframework/platform/internal/instancekey" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -2662,10 +2871,13 @@ 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") |
| |
| -// replicaKeyGate is ruling 26's check for a launch of rt with env (the |
| -// composed env files) and, when haveControl, a bound control socket. It is |
| -// nil for every row that is not a replica. Its errors name the host and |
| -// the reason, never a key or a digest. |
| +// replicaConfigTimeout bounds replicaKeyGate's fresh read of the bucket. |
| +const replicaConfigTimeout = 10 * time.Second |
| + |
| +// replicaKeyGate is rulings 26 and 27's check for a launch of rt with env |
| +// (the composed env files) and, when haveControl, a bound control socket. |
| +// It is nil for every row that is not a replica. Its errors and log lines |
| +// name the host and the reason, never a key or a digest. |
| func (b *ExecBackend) replicaKeyGate(rt registry.Route, env map[string]string, haveControl bool) error { |
| if !rt.Replica { |
| return nil |
| @@ -2692,11 +2904,30 @@ func (b *ExecBackend) replicaKeyGate(rt registry.Route, env map[string]string, h |
| case instancekey.Digest(key) != home: |
| return refuse(instancekey.Var + " is not its home's key") |
| } |
| + cfgEnv, ownEnv, err := b.instanceEnvParts(rt) |
| + if err != nil { |
| + return refuse("its env files could not be read again: " + err.Error()) |
| + } |
| + if _, layer := instancekey.Effective(cfgEnv, ownEnv); layer != instancekey.LayerConfig { |
| + return nil // the replica's own file: it changes only by hand |
| + } |
| + if b.ReplicaConfigKey == 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) |
| + switch { |
| + case err != nil: |
| + return refuse("the bucket's configuration could not be read: " + err.Error()) |
| + case fresh != instancekey.Digest(key): |
| + return refuse("its configured " + instancekey.Var + " is not the one the bucket's configuration now holds: a key change is in flight (ruling 27)") |
| + } |
| return nil |
| } |
| ``` |
| |
| -In `internal/activator/backend_exec.go`, add the field to `ExecBackend`, after `Region`: |
| +In `internal/activator/backend_exec.go`, add the two fields to `ExecBackend`, after `Region`: |
| |
| ```go |
| // ReplicaHomeKeys answers, for a replica row's host, the SHA-256 of its |
| @@ -2707,6 +2938,12 @@ In `internal/activator/backend_exec.go`, add the field to `ExecBackend`, after ` |
| // 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 |
| + // 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) |
| ``` |
| |
| In `seedChild`, after the env is read: |
| @@ -2755,26 +2992,75 @@ In `internal/activator/prepare.go`, `prepareAt`, before the log listener: |
| logs, err := listenLogs(b.logSocketFor(rt.Host, role, gen), b.logEmitter(rt)) |
| ``` |
| |
| +Create `internal/appconfig/instancekey.go`, the fresh read: |
| + |
| +```go |
| +package appconfig |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdh" |
| + |
| + "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// ConfiguredKeyDigest is the SHA-256 of the CARLOS_INSTANCE_KEY rt's |
| +// configuration in the bucket renders now, "" when it renders none: the |
| +// same merge and renderer SyncExec writes <host>.config.env with |
| +// (LoadMergedHostVersions, RenderEnv), read fresh rather than from the |
| +// box's last sync. A replica launch whose configured key is not this |
| +// digest is refused (instance-key plan ruling 27): a configuration change |
| +// is in flight, and the home may already hold the new key. The value |
| +// never leaves this function. |
| +func ConfiguredKeyDigest(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, rt registry.Route) (string, error) { |
| + m, _, err := LoadMergedHostVersions(ctx, s, release.RouteAccount(rt), rt.App, rt.Environment, rt.Host) |
| + if err != nil || m == nil { |
| + return "", err |
| + } |
| + rendered, err := RenderEnv(m, priv, routeBinding(rt)) |
| + if err != nil { |
| + return "", err |
| + } |
| + key, _ := instancekey.Effective(instancekey.ParseEnv(rendered), nil) |
| + if key == "" { |
| + return "", nil |
| + } |
| + return instancekey.Digest(key), nil |
| +} |
| +``` |
| + |
| Create `internal/host/replicahomekeys.go`: |
| |
| ```go |
| package host |
| |
| import ( |
| + "context" |
| + "crypto/ecdh" |
| + "errors" |
| "log" |
| + "sync" |
| |
| + "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| |
| // replicaHomeKeys is ExecBackend.ReplicaHomeKeys from the box's |
| -// CARLOS_REPLICA_HOME_KEYS (instance-key plan ruling 26): the pre-roll |
| -// check's verified home-key digests for this box's replica rows. A value |
| -// that does not parse is logged and verifies nothing, so every replica |
| -// launch that would be handed a key is refused until it is fixed. |
| +// CARLOS_REPLICA_HOME_KEYS (instance-key plan rulings 26 and 27): the |
| +// pre-roll check's verified home-key digests for this box's replica rows. |
| +// Only the pre-roll check writes the list; nothing refreshes it from a |
| +// replica's own configuration. A value that does not parse is logged by |
| +// entry position and reason, never by its text, and verifies nothing, so |
| +// every replica launch that would be handed a key is refused until it is |
| +// fixed. |
| func replicaHomeKeys(getenv func(string) string) func(host string) (string, bool) { |
| keys, err := instancekey.ParseHomeKeys(getenv("CARLOS_REPLICA_HOME_KEYS")) |
| if err != nil { |
| - log.Printf("agent: CARLOS_REPLICA_HOME_KEYS: %v (no replica home key verifies until it is fixed)", err) |
| + log.Printf("agent: %v (no replica home key verifies until it is fixed)", err) |
| keys = nil |
| } |
| return func(host string) (string, bool) { |
| @@ -2782,6 +3068,43 @@ func replicaHomeKeys(getenv func(string) string) func(host string) (string, bool |
| return sha, ok |
| } |
| } |
| + |
| +// 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) { |
| + var ( |
| + mu sync.Mutex |
| + store release.Store |
| + priv *ecdh.PrivateKey |
| + ) |
| + 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") |
| + } |
| + k, err := appconfig.ParsePrivateKey(raw, "CARLOS_SECRETS_KEY") |
| + if err != nil { |
| + mu.Unlock() |
| + return "", err |
| + } |
| + st, err := open(ctx) |
| + if err != nil { |
| + mu.Unlock() |
| + return "", err |
| + } |
| + store, priv = st, k |
| + } |
| + st, k := store, priv |
| + mu.Unlock() |
| + return appconfig.ConfiguredKeyDigest(ctx, st, k, rt) |
| + } |
| +} |
| ``` |
| |
| In `internal/host/agent.go`, after the `ExecBackend` literal (beside `eb.Logs`): |
| @@ -2790,6 +3113,9 @@ In `internal/host/agent.go`, after the `ExecBackend` literal (beside `eb.Logs`): |
| // The verified home keys of this box's replica rows (L21 ruling 26), |
| // until O8 reads them from each home's published env item. |
| eb.ReplicaHomeKeys = replicaHomeKeys(os.Getenv) |
| + // The bucket's configuration, read fresh at each replica launch |
| + // whose key is configured (ruling 27). |
| + eb.ReplicaConfigKey = replicaConfigKey(os.Getenv, openDeployStore) |
| ``` |
| |
| - [ ] **Step 4: Run the tests, then the launch tests around them** |
| @@ -2800,8 +3126,8 @@ Expected: PASS. (With the sandbox on, the control-socket case skips and the nest |
| - [ ] **Step 5: Commit** |
| |
| ```bash |
| -gofmt -l internal/activator internal/host |
| -git add internal/activator/replicakey.go internal/activator/backend_exec.go internal/activator/prepare.go internal/activator/replica_instancekey_test.go internal/activator/replica_instancekey_linux_test.go internal/host/replicahomekeys.go internal/host/replicahomekeys_test.go internal/host/agent.go |
| +gofmt -l internal/activator internal/host internal/appconfig |
| +git add internal/activator/replicakey.go internal/activator/backend_exec.go internal/activator/prepare.go internal/activator/replica_instancekey_test.go internal/activator/replica_instancekey_linux_test.go internal/host/replicahomekeys.go internal/host/replicahomekeys_test.go internal/host/agent.go internal/appconfig/instancekey.go internal/appconfig/instancekey_test.go |
| git commit -m "activator: a replica launches only with its home's key, verified, or none and a socket its helper refuses on (L21) |
| |
| Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| @@ -3112,6 +3438,7 @@ Create `internal/host/claim_replica_instancekey_test.go`, ruling 26 on the claim |
| package host |
| |
| import ( |
| + "context" |
| "errors" |
| "os" |
| "path/filepath" |
| @@ -3120,6 +3447,7 @@ import ( |
| |
| "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/registry" |
| ) |
| |
| // Ruling 26 on the claim path, which has no control socket before O6: a |
| @@ -3139,6 +3467,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 } |
| own := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n" |
| if err := os.WriteFile(f.envPath(), []byte(own), 0o600); err != nil { |
| t.Fatal(err) |
| @@ -4319,7 +4648,17 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| - **The two digests differ:** the replica will be refused after the roll. Tell the owner, who sets the replica's key to the home's, or accepts the refusal. |
| - **The replica has no key:** list nothing. It starts as before wherever it has a control socket. An adopting app without its own key refuses there itself (`ErrNoInstanceKey`) until O8. |
| |
| - Re-run the check whenever a home's own `<host>.env` is edited by hand, or a per-host key is configured on either side. The digests go only into the agent's environment, never into a log. |
| + Only this check writes `CARLOS_REPLICA_HOME_KEYS`; nothing refreshes it from a replica's configuration (ruling 27). The digests go only into the agent's environment, never into a log. |
| + |
| + **Operator note: changing a configured key on an app with replicas (ruling 27).** It is a key rotation, which this plan does not support for an app with data. If it must be done, coordinate it: |
| + 1. stop the app's replicas; |
| + 2. remove their entries from `CARLOS_REPLICA_HOME_KEYS` on their boxes; |
| + 3. change the key; |
| + 4. let every box sync its configuration; |
| + 5. re-run this check, which writes the new entries; |
| + 6. start the replicas. |
| + |
| + Do the same after editing a home's own `<host>.env` by hand, or configuring a per-host key on either side. Those are the changes the agent's fresh read cannot see. |
| 2. **Merge and release.** Merging is Paul's call, as a squash on the hub, after CI is green on the tip. Cut a platform release by the README's "Releasing the platform" recipe. |
| 3. **Ship to every box, every region.** Use the platform roll recipe (build arm64, upload, `update-platform-binary.sh` per box, `roll-console.sh` after). The console needs nothing new, but rolls with the release as usual. |
| 4. **Verify on each box.** |
| @@ -4348,6 +4687,6 @@ Question 5 (restarts inside a claim session) is withdrawn: its premise was wrong |
| |
| ## Follow-ups this plan does not build |
| |
| -- **O8, the home's key for replicas** (replica placements, #162). Until it lands, replicas run only with a verified configured key, or keyless with a control socket. |
| +- **O8, the home's platform key for replicas** (replica placements, #162; platform keys only, ruling 28). Until it lands, replicas run only with a verified configured key, or keyless with a control socket. |
| - **O7, then O6.** Both are other plans' work. O7 (moves' artifact lifecycle) ships first; O6 (the writer, on claim launches) ships only after it (ruling 24), so no record is ever written that moves cannot retire. |
| - **Low-entropy configured keys.** A `config` record publishes the SHA-256 of a configured key in the data bucket (ruling 9). For a random key that reveals nothing. For a short key an owner typed by hand, a data-bucket reader could test guesses against it. Rastrillo's docs could say that a configured `CARLOS_INSTANCE_KEY` should be random. |