activator: a replica row's worker is gated as its seed is, and the nested test isolates the home-key digest check (L21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+98
−6
internal/activator/backend_exec.go+19 −1internal/activator/replica_instancekey_linux_test.go+13 −5internal/activator/replica_instancekey_test.go+66 −0
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index 8d953eb9b..7e04b249b 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -2157,6 +2157,10 @@ func (b *ExecBackend) StartWorker(ctx context.Context, rt registry.Route, extra |
| // Between resolving that identity and the exec it runs ctx's before-exec hook |
| // (runBeforeExec), through which the activator publishes the build first; a |
| // hook error means nothing runs (ruling R19 item 3). |
| +// |
| +// A worker on a replica row passes replicaKeyGate as a seed does, with no |
| +// control socket, before that hook: its home's key, verified, or nothing |
| +// runs (instance-key plan ruling 26). A worker never mints a key. |
| func (b *ExecBackend) StartWorkerStaged(ctx context.Context, rt registry.Route, extra map[string]string, onto *release.StagedIdentity) (Proc, release.StagedIdentity, error) { |
| var argv []string |
| if err := json.Unmarshal([]byte(rt.Args), &argv); err != nil || len(argv) == 0 { |
| @@ -2179,13 +2183,27 @@ func (b *ExecBackend) StartWorkerStaged(ctx context.Context, rt registry.Route, |
| id = *onto |
| } |
| - fileEnv, err := b.instanceEnv(rt) |
| + // One read gives the env and the layer its CARLOS_INSTANCE_KEY came |
| + // from, for the replica gate below (ruling 29). A worker never mints |
| + // (ruling 12): the key is whatever the env files already deliver. |
| + fileEnv, keyLayer, err := b.instanceEnvLayer(rt) |
| if err != nil { |
| return nil, release.StagedIdentity{}, err |
| } |
| for k, v := range extra { |
| fileEnv = withVar(fileEnv, k, v) |
| } |
| + // A worker has no control socket, so a replica row's worker is gated as |
| + // its seed is (ruling 26): its home's key, verified, or refused. extra is |
| + // agent-issued and has no business carrying a key; one that did would |
| + // not be the key the layer describes, so it is refused as of unknown |
| + // origin. |
| + if _, ok := extra[instancekey.Var]; ok { |
| + keyLayer = instancekey.LayerNone |
| + } |
| + if err := b.replicaKeyGate(rt, fileEnv, keyLayer, false); err != nil { |
| + return nil, release.StagedIdentity{}, err |
| + } |
| // The identity is resolved and nothing has run: the activator publishes |
| // the build now, before the exec, and a failure means no child (ruling |
diff --git a/internal/activator/replica_instancekey_linux_test.go b/internal/activator/replica_instancekey_linux_test.go| index 34c079cc4..44929d652 100644 |
| --- a/internal/activator/replica_instancekey_linux_test.go |
| +++ b/internal/activator/replica_instancekey_linux_test.go |
| @@ -58,23 +58,31 @@ func TestKeylessReplicaWithAControlSocketLeavesTheHelperToRefuse(t *testing.T) { |
| // Ruling 26 on the nested layout: the seed role has no control socket, so |
| // a keyless replica's seed is refused at prepare; a run role handed a key |
| // that is not the home's is refused too; one handed the home's key is |
| -// prepared with it. |
| +// prepared with it. The bucket's fresh read renders the case's own |
| +// configured key, so the "another key" refusal is the home-key digest |
| +// check's alone, not the fresh read's. |
| func TestNestedReplicaPrepareIsGated(t *testing.T) { |
| homeKey := strings.Repeat("H", 43) + "=" |
| for _, tc := range []struct { |
| name, config string |
| + bucket string // the key the bucket's configuration renders now |
| role instancectl.Role |
| refused bool |
| }{ |
| - {"a keyless seed", "", instancectl.RoleSeed, true}, |
| - {"a run role with another key", InstanceKeyVar + "=configured-B\n", instancectl.RoleRun, true}, |
| - {"a run role with the home's key", InstanceKeyVar + "=" + homeKey + "\n", instancectl.RoleRun, false}, |
| + {"a keyless seed", "", "", instancectl.RoleSeed, true}, |
| + {"a run role with another key", InstanceKeyVar + "=configured-B\n", "configured-B", instancectl.RoleRun, true}, |
| + {"a run role with the home's key", InstanceKeyVar + "=" + homeKey + "\n", homeKey, instancectl.RoleRun, false}, |
| } { |
| t.Run(tc.name, func(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.ReplicaConfigKey = func(context.Context, registry.Route) (string, error) { |
| + if tc.bucket == "" { |
| + return "", nil |
| + } |
| + return instancekey.Digest(tc.bucket), nil |
| + } |
| 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 0091b8ee7..4e39304c9 100644 |
| --- a/internal/activator/replica_instancekey_test.go |
| +++ b/internal/activator/replica_instancekey_test.go |
| @@ -266,3 +266,69 @@ func TestReplicaSeedIsGated(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// A worker (StartWorkerStaged) has no control socket, so a replica row's |
| +// worker is gated as its seed is (ruling 26), on the layer from the same |
| +// read: it runs only with its home's key, verified, and is refused with a |
| +// key that is not the home's or with none at all. A worker never mints |
| +// (ruling 12), and a worker on a row that is not a replica is unaffected. |
| +func TestReplicaWorkerIsGatedLikeASeed(t *testing.T) { |
| + tok := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n" |
| + homeKey := strings.Repeat("H", 43) + "=" |
| + for _, tc := range []struct { |
| + name string |
| + replica bool |
| + config string // <host>.config.env ("" = none) |
| + bucket string // the key the bucket's configuration renders now |
| + want string // the helper's outcome; "" = refused, no worker ran |
| + // extraKey hands the home's key through extra as well as the env |
| + // files: a key whose origin the read cannot vouch for. |
| + extraKey bool |
| + }{ |
| + {"a replica worker with the home's key runs", true, InstanceKeyVar + "=" + homeKey + "\n", homeKey, "key " + instancekey.Digest(homeKey), false}, |
| + // The bucket renders configured-B too, so only the home-key digest |
| + // check can refuse this one. |
| + {"a replica worker with a different configured key is refused", true, InstanceKeyVar + "=configured-B\n", "configured-B", "", false}, |
| + {"a keyless replica worker is refused", true, "", "", "", false}, |
| + {"a worker on a row that is not a replica is unaffected", false, InstanceKeyVar + "=configured-B\n", "", "key " + instancekey.Digest("configured-B"), false}, |
| + {"a replica worker handed a key through extra is refused", true, InstanceKeyVar + "=" + homeKey + "\n", homeKey, "", true}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + b := testExecBackend(t) |
| + rt := registry.Route{Host: "shop-au.example.com", Kind: "sidecar", Args: `["sidecar","run"]`, Replica: tc.replica} |
| + own := filepath.Join(b.DataDir, rt.Host+".env") |
| + if err := os.WriteFile(own, []byte(tok), 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) |
| + } |
| + } |
| + if tc.replica { |
| + b.ReplicaHomeKeys = func(h string) (string, bool) { return instancekey.Digest(homeKey), h == rt.Host } |
| + b.ReplicaConfigKey = freshConfig(replicaCase{bucket: tc.bucket}) |
| + } |
| + out := stageHelperApp(t, b, rt.Host, "v1") |
| + extra := map[string]string{"CARLOS_SIDECAR_NAME": "bot"} |
| + if tc.extraKey { |
| + extra[InstanceKeyVar] = homeKey |
| + } |
| + proc, _, err := b.StartWorkerStaged(context.Background(), rt, extra, nil) |
| + if proc != nil { |
| + defer proc.Stop(context.Background()) |
| + } |
| + got := outcomeOf(out) |
| + if tc.want == "" { |
| + if !errors.Is(err, ErrReplicaKey) || proc != nil || got != "" { |
| + t.Fatalf("StartWorkerStaged = %v (proc %v), outcome %q; want ErrReplicaKey and no worker", err, proc != nil, got) |
| + } |
| + } else if err != nil || got != tc.want { |
| + t.Fatalf("StartWorkerStaged = %v, outcome %q; want %q", err, got, tc.want) |
| + } |
| + if raw, _ := os.ReadFile(own); string(raw) != tok { |
| + t.Fatal("a worker launch changed its own env file: a worker never mints") |
| + } |
| + }) |
| + } |
| +} |