host: the claim path's backend gets the instance-key readers (final review I1)
OpenClaimOwners built its ExecBackend with only the directories and the
cgroup manager, so on a claim box every keyed replica was refused ("cannot
be checked against its home's key") and, after I2, every first wake would
have been deferred for want of a configuration reader. The two readers
are now one pair, instanceKeyReaders, built once from the box by
productionInstanceKeyReaders and wired into the agent's backend and the
claim path's by claimExecBackend. The test goes through that constructor
with readers built from a test bucket and list: a listed configured-key
replica starts on the claim path and is refused once unlisted, and a home
row's first wake is deferred until its configured key is delivered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+122
−7
internal/host/agent.go+6 −6internal/host/claim_replica_instancekey_test.go+72 −0internal/host/claimproduction.go+13 −1internal/host/replicahomekeys.go+31 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index bf3211f1e..e45ca06a5 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -361,12 +361,12 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| eb.Logs = logSink |
| } |
| // The verified home keys of this box's replica rows (L21 rulings 26 |
| - // and 30), read again at every replica launch, until O8 reads them |
| - // from each home's published env item. |
| - 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.ConfiguredKey = configuredKey(os.Getenv, openDeployStore) |
| + // and 30), read again at every replica launch until O8 reads them |
| + // from each home's published env item, and the bucket's |
| + // configuration, read fresh before a mint (ruling 5) and at each |
| + // replica launch whose key is configured (ruling 27). The claim |
| + // path's backend gets the same pair (claimExecBackend). |
| + productionInstanceKeyReaders().wire(eb) |
| // 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_replica_instancekey_test.go b/internal/host/claim_replica_instancekey_test.go| index 4b7207ca2..1fe91d407 100644 |
| --- a/internal/host/claim_replica_instancekey_test.go |
| +++ b/internal/host/claim_replica_instancekey_test.go |
| @@ -9,11 +9,13 @@ import ( |
| "testing" |
| "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostguard" |
| "github.com/carlosframework/platform/internal/instancekey" |
| "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| // Ruling 26 on the claim path, which has no control socket before O6: a |
| @@ -112,3 +114,73 @@ func TestClaimInputPolicyNeverReadsTheBucket(t *testing.T) { |
| t.Fatalf("the launch source = %v after %d bucket reads, want ErrReplicaKey after one", err, reads) |
| } |
| } |
| + |
| +// Final review I1: the claim path's backend is built by claimExecBackend |
| +// with the same readers the agent's backend gets, so a keyed replica |
| +// whose configured key is listed and still the bucket's starts on the |
| +// claim path (ruling 26), and a first wake asks the bucket before it mints |
| +// (ruling 5). The fixture's backend is not used: the readers are the ones |
| +// the production constructor wires, built from a box of this test's. |
| +func TestClaimBackendIsWiredWithTheKeyReaders(t *testing.T) { |
| + homeKey := strings.Repeat("H", 43) + "=" |
| + f := newAdminTokenFixture(t, "box-1") |
| + f.rt.Replica, f.rt.Account, f.rt.App = true, "bab", "jam" |
| + bucket := release.NewDirStore(t.TempDir()) |
| + priv, _, err := appconfig.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := appconfig.SetEnv(t.Context(), bucket, "bab", "jam", map[string]string{instancekey.Var: homeKey}); err != nil { |
| + t.Fatal(err) |
| + } |
| + list := []byte(adminTokenHost + "=" + instancekey.Digest(homeKey) + "\n") |
| + keys := newInstanceKeyReaders( |
| + func(k string) string { |
| + if k == "CARLOS_SECRETS_KEY" { |
| + return priv |
| + } |
| + return "" |
| + }, |
| + func(context.Context) (release.Store, error) { return bucket, nil }, |
| + func() ([]byte, error) { return list, nil }) |
| + backend := claimExecBackend(ClaimRuntimeResources{DataDir: f.backend.DataDir, OptDir: f.backend.OptDir}, keys) |
| + if backend.ReplicaHomeKeys == nil || backend.ConfiguredKey == nil { |
| + t.Fatal("claimExecBackend left a reader nil") |
| + } |
| + if err := os.WriteFile(f.envPath(), []byte("CARLOS_ADMIN_TOKEN="+strings.Repeat("c", 64)+"\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(f.data, adminTokenHost+".config.env"), []byte(activator.InstanceKeyVar+"="+homeKey+"\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + launch := claimServingLaunchSource(backend, f.tokens) |
| + got, err := launch(t.Context(), f.rt) |
| + if err != nil { |
| + t.Fatalf("a listed replica on the claim path: %v", err) |
| + } |
| + found := false |
| + for _, kv := range got.Env { |
| + found = found || kv == activator.InstanceKeyVar+"="+homeKey |
| + } |
| + if !found { |
| + t.Fatal("the replica's launch does not deliver its home's key") |
| + } |
| + // The list no longer names it: refused, by the reader the constructor wired. |
| + list = nil |
| + if _, err := launch(t.Context(), f.rt); !errors.Is(err, activator.ErrReplicaKey) { |
| + t.Fatalf("an unlisted replica = %v, want ErrReplicaKey", err) |
| + } |
| + // A first wake of a home row whose configuration sets a key this box |
| + // has not delivered is deferred by the same backend (ruling 5). |
| + f.rt.Replica = false |
| + if err := os.Remove(filepath.Join(f.data, adminTokenHost+".config.env")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := launch(t.Context(), f.rt); !errors.Is(err, activator.ErrInstanceKeyDeferred) { |
| + t.Fatalf("a first wake before delivery = %v, want ErrInstanceKeyDeferred", err) |
| + } |
| + prod := productionInstanceKeyReaders() |
| + if prod.HomeKeys == nil || prod.Configured == nil { |
| + t.Fatal("the production readers are not both built") |
| + } |
| +} |
diff --git a/internal/host/claimproduction.go b/internal/host/claimproduction.go| index f44eec178..0e3654a0b 100644 |
| --- a/internal/host/claimproduction.go |
| +++ b/internal/host/claimproduction.go |
| @@ -138,7 +138,7 @@ func (ProductionClaimOwnerProvider) OpenClaimOwners(ctx context.Context, store r |
| } |
| local := &productionClaimLocal{Resources: r, Identity: out.Identity, Directory: directory, Store: store, slots: map[string]*claimParticipation{}} |
| out.Local = local |
| - backend := &activator.ExecBackend{DataDir: r.DataDir, OptDir: r.OptDir, Cgroups: r.Manager} |
| + backend := claimExecBackend(r, productionInstanceKeyReaders()) |
| // The token mint is the only write this source makes. It runs under the |
| // same guard and claim store the assembly's WorkSource uses. |
| tokens := claimAdminTokens{Guard: hostguard.Guard{DataDir: r.DataDir, Claims: hostguard.StoreReader{Store: store}}, Claims: store, Verify: out.Identity.Verify, Local: out.Identity.Local(local), Policy: hostbox.Policy{Held: hostbox.RegistryHoldPolicy(r.Registry)}.Check, Now: time.Now} |
| @@ -186,6 +186,18 @@ func (ProductionClaimOwnerProvider) OpenClaimOwners(ctx context.Context, store r |
| return out, nil |
| } |
| +// claimExecBackend is the claim path's launch backend: the box's |
| +// directories and cgroups, and the instance-key readers every launching |
| +// backend needs (final review I1). Without them a claim launch refused |
| +// every keyed replica ("cannot be checked against its home's key") and |
| +// could mint before the configuration was delivered; the agent's backend |
| +// is wired from the same pair. |
| +func claimExecBackend(r ClaimRuntimeResources, keys instanceKeyReaders) *activator.ExecBackend { |
| + eb := &activator.ExecBackend{DataDir: r.DataDir, OptDir: r.OptDir, Cgroups: r.Manager} |
| + keys.wire(eb) |
| + return eb |
| +} |
| + |
| // claimInputPolicy is the serving claim's input policy: does a Work's |
| // identity still name this host's inputs? A config-only identity is checked |
| // against the bucket's rendered config, a release-only one against the |
diff --git a/internal/host/replicahomekeys.go b/internal/host/replicahomekeys.go| index 74e349afa..faffa9f39 100644 |
| --- a/internal/host/replicahomekeys.go |
| +++ b/internal/host/replicahomekeys.go |
| @@ -9,6 +9,7 @@ import ( |
| "strings" |
| "sync" |
| + "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/instancekey" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -22,6 +23,36 @@ import ( |
| // file, then rename it into place); the agent only reads it. |
| const replicaHomeKeysPath = "/etc/carlos/replica-home-keys" |
| +// instanceKeyReaders are the two readers every ExecBackend that launches in |
| +// production is given: the box's verified home keys for its replica rows |
| +// (rulings 26 and 30) and the bucket's configured key (rulings 5 and 27). |
| +// The agent's backend and the claim path's backend (claimExecBackend) are |
| +// wired from the same pair, so neither can gate a replica or mint a key |
| +// by a rule the other lacks (final review I1). |
| +type instanceKeyReaders struct { |
| + HomeKeys func(host string) (sha256 string, ok bool) |
| + Configured func(context.Context, registry.Route) (sha256 string, err error) |
| +} |
| + |
| +// newInstanceKeyReaders builds the pair from a box: getenv for the secrets |
| +// key and the flags, open for the deploy store, list for the home-keys |
| +// file. |
| +func newInstanceKeyReaders(getenv func(string) string, open func(context.Context) (release.Store, error), list func() ([]byte, error)) instanceKeyReaders { |
| + return instanceKeyReaders{HomeKeys: replicaHomeKeys(list), Configured: configuredKey(getenv, open)} |
| +} |
| + |
| +// productionInstanceKeyReaders reads this box: its environment, its deploy |
| +// store and /etc/carlos/replica-home-keys. |
| +func productionInstanceKeyReaders() instanceKeyReaders { |
| + return newInstanceKeyReaders(os.Getenv, openDeployStore, func() ([]byte, error) { return os.ReadFile(replicaHomeKeysPath) }) |
| +} |
| + |
| +// wire gives eb both readers. |
| +func (k instanceKeyReaders) wire(eb *activator.ExecBackend) { |
| + eb.ReplicaHomeKeys = k.HomeKeys |
| + eb.ConfiguredKey = k.Configured |
| +} |
| + |
| // replicaHomeKeys is ExecBackend.ReplicaHomeKeys (instance-key plan rulings |
| // 26, 27 and 30): it reads the list again at every call, so an entry the |
| // procedure removes or replaces takes effect at the next replica launch, |