activator: seed, start and nested prepare mint the instance key durably before the env is read, and the mint refuses a row fenced or conflicting on the launch body's own paths (L21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+470
−0
internal/activator/backend_exec.go+12 −0internal/activator/instancekey.go+14 −0internal/activator/instancekey_linux_test.go+117 −0internal/activator/instancekey_test.go+324 −0internal/activator/prepare.go+3 −0
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 300.498s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.912s
ok github.com/carlosframework/platform/internal/activator 160.824s
ok github.com/carlosframework/platform/internal/agentsock 0.016s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.018s
ok github.com/carlosframework/platform/internal/appconfig 2.166s
ok github.com/carlosframework/platform/internal/apperr 0.070s
ok github.com/carlosframework/platform/internal/applog 0.122s
ok github.com/carlosframework/platform/internal/awsinv 0.003s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.473s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.008s
ok github.com/carlosframework/platform/internal/caps 0.011s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.185s
ok github.com/carlosframework/platform/internal/changelog 0.245s
ok github.com/carlosframework/platform/internal/clamd 0.054s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 315.407s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 5.806s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.011s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.005s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 1.935s
ok github.com/carlosframework/platform/internal/doorkey 0.008s
ok github.com/carlosframework/platform/internal/economics 0.008s
ok github.com/carlosframework/platform/internal/edge 25.793s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.125s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.018s
ok github.com/carlosframework/platform/internal/geo 0.014s
ok github.com/carlosframework/platform/internal/geoip 0.043s
ok github.com/carlosframework/platform/internal/host 43.418s
ok github.com/carlosframework/platform/internal/hostactors 0.340s
ok github.com/carlosframework/platform/internal/hostbox 1.210s
ok github.com/carlosframework/platform/internal/hostclaim 0.210s
ok github.com/carlosframework/platform/internal/hostevidence 0.010s
ok github.com/carlosframework/platform/internal/hostexec 0.311s
ok github.com/carlosframework/platform/internal/hostfence 0.031s
ok github.com/carlosframework/platform/internal/hostfiles 4.081s
ok github.com/carlosframework/platform/internal/hostfloor 0.013s
ok github.com/carlosframework/platform/internal/hostguard 0.141s
ok github.com/carlosframework/platform/internal/hostops 1.214s
ok github.com/carlosframework/platform/internal/hostquarantine 0.006s
ok github.com/carlosframework/platform/internal/hostresolve 0.123s
ok github.com/carlosframework/platform/internal/hostwrite 0.002s
ok github.com/carlosframework/platform/internal/infraview 0.006s
ok github.com/carlosframework/platform/internal/instancectl 1.701s
ok github.com/carlosframework/platform/internal/instanceexec 2.678s
ok github.com/carlosframework/platform/internal/instancefs 0.007s
ok github.com/carlosframework/platform/internal/instancekey 0.002s
ok github.com/carlosframework/platform/internal/layout 1.301s
ok github.com/carlosframework/platform/internal/lease 9.428s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.008s
ok github.com/carlosframework/platform/internal/ledger 0.015s
ok github.com/carlosframework/platform/internal/mail 0.163s
ok github.com/carlosframework/platform/internal/maintenance 0.077s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.495s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.071s
ok github.com/carlosframework/platform/internal/metrics 0.004s
ok github.com/carlosframework/platform/internal/moves 21.022s
ok github.com/carlosframework/platform/internal/moves/archive 0.733s
ok github.com/carlosframework/platform/internal/objstore 0.151s
ok github.com/carlosframework/platform/internal/ops 0.152s
ok github.com/carlosframework/platform/internal/park 0.181s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 2.961s
ok github.com/carlosframework/platform/internal/publicrelease 0.006s
ok github.com/carlosframework/platform/internal/registry 3.508s
ok github.com/carlosframework/platform/internal/relay 7.222s
ok github.com/carlosframework/platform/internal/release 17.756s
ok github.com/carlosframework/platform/internal/rollout 1.461s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.173s
ok github.com/carlosframework/platform/internal/sim/hostlife 2.304s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.342s
ok github.com/carlosframework/platform/internal/status 0.036s
ok github.com/carlosframework/platform/internal/steering 0.005s
ok github.com/carlosframework/platform/internal/stopevidence 0.136s
ok github.com/carlosframework/platform/internal/svcrole 0.003s
ok github.com/carlosframework/platform/internal/telemetry 0.003s
ok github.com/carlosframework/platform/internal/termfence 0.003s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.002s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.001s
ok github.com/carlosframework/platform/internal/vet 1.529s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.021s
=== RUN TestT4StorageProbeMinio
2026/10/11 14:40:56 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DD80A130A178C4, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.014s
minio contract: PASS
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index aac499383..57323a46f 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -832,6 +832,11 @@ func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route) error { |
| if err := EnsureAdminToken(paths.envFile); err != nil { |
| return fmt.Errorf("admin token for %s: %w", host, err) |
| } |
| + // 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 { |
| + return fmt.Errorf("instance key for %s: %w", host, err) |
| + } |
| fileEnv, err := b.instanceEnv(rt) |
| if err != nil { |
| return err |
| @@ -1370,6 +1375,13 @@ func (b *ExecBackend) spawnChildFrom(ctx context.Context, rt registry.Route, soc |
| if err := EnsureAdminToken(paths.envFile); err != nil { |
| return nil, "", fmt.Errorf("admin token for %s: %w", host, err) |
| } |
| + // The instance key is minted here for the same reason and fails the |
| + // 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 { |
| + return nil, "", fmt.Errorf("instance key for %s: %w", host, err) |
| + } |
| fileEnv, err := b.instanceEnv(rt) |
| if err != nil { |
diff --git a/internal/activator/instancekey.go b/internal/activator/instancekey.go| index 7ae7348a5..29589cd9d 100644 |
| --- a/internal/activator/instancekey.go |
| +++ b/internal/activator/instancekey.go |
| @@ -173,10 +173,24 @@ func EnsureInstanceKeyBeneath(dir, name string) (InstanceKeyOutcome, error) { |
| // ensureInstanceKeyFor is the launch paths' ensure for rt: none for a |
| // replica row, the nested body for a nested row, the flat one otherwise. |
| +// |
| +// A fenced or conflicting row is refused and nothing is written, as |
| +// BackfillInstanceKey refuses it. Every launch dispatcher (Start, |
| +// StartGen, StartStaged, Seed) refuses those rows already, but on its own |
| +// classification: the launch body classifies again in pathsFor, and p is |
| +// that second answer. A layout journal written between the two (a |
| +// migration starting mid-wake) makes p fenced, with the flat directory as |
| +// its dataDir, and minting there would write to a half-migrated host |
| +// (isolated-exec design §8.2). The refusal is decided on p, the paths the |
| +// mint would use. |
| func (b *ExecBackend) ensureInstanceKeyFor(rt registry.Route, p instancePaths) (InstanceKeyOutcome, error) { |
| if rt.Replica { |
| return InstanceKeyReplica, nil |
| } |
| + switch p.layout { |
| + case LayoutFenced, LayoutConflict: |
| + return InstanceKeyPresent, fmt.Errorf("%s: not minted: %s", rt.Host, p.reason) |
| + } |
| if p.layout == LayoutNested { |
| return EnsureInstanceKeyBeneath(p.dataDir, rt.Host+".env") |
| } |
diff --git a/internal/activator/instancekey_linux_test.go b/internal/activator/instancekey_linux_test.go| new file mode 100644 |
| index 000000000..9aca29b74 |
| --- /dev/null |
| +++ b/internal/activator/instancekey_linux_test.go |
| @@ -0,0 +1,117 @@ |
| +package activator |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "golang.org/x/sys/unix" |
| + |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// The nested prepare mints the key into the instance directory's own env |
| +// file and hands it to the role in the same env it returns: the run role |
| +// and the seed role (coverage, Astra round 1). |
| +func TestPrepareMintsTheInstanceKeyIntoTheNestedEnvFile(t *testing.T) { |
| + for _, role := range []instancectl.Role{instancectl.RoleRun, instancectl.RoleSeed} { |
| + t.Run(string(role), func(t *testing.T) { |
| + b, rt := nestedBackend(t, &fakeControl{inv: "inv1"}, &fakeUnit{}) |
| + _, ctl, logs, env, err := b.prepare(rt, role) |
| + if err != nil { |
| + t.Fatalf("prepare: %v", err) |
| + } |
| + logs.Close() |
| + if ctl != nil { |
| + ctl.Close() |
| + } |
| + own, err := readEnvFileBeneath(provision.InstanceDir(b.DataDir, rt.Host), rt.Host+".env") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !wellFormedInstanceKey(own[InstanceKeyVar]) || env[InstanceKeyVar] != own[InstanceKeyVar] { |
| + t.Fatal("prepare's env does not carry the key minted into the nested env file") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Finding 3: the nested backfill opens an existing file with mode 0 |
| +// (openat2 refuses a mode without O_CREAT), appends, and sets 0600 on the |
| +// descriptor. |
| +func TestBackfillInstanceKeyAppendsToANestedEnvFile(t *testing.T) { |
| + b, rt := nestedBackend(t, &fakeControl{inv: "inv1"}, &fakeUnit{}) |
| + dir := provision.InstanceDir(b.DataDir, rt.Host) |
| + if err := os.MkdirAll(dir, 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + envPath := filepath.Join(dir, rt.Host+".env") |
| + tok := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("e", 64) + "\n" |
| + if err := os.WriteFile(envPath, []byte(tok), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := b.BackfillInstanceKey(rt); err != nil || got != InstanceKeyMinted { |
| + t.Fatalf("nested backfill = %v, %v; want minted", got, err) |
| + } |
| + raw, _ := os.ReadFile(envPath) |
| + if !strings.HasPrefix(string(raw), tok) || len(keyLines(raw)) != 1 || !wellFormedInstanceKey(keyLines(raw)[0]) { |
| + t.Fatalf("nested env file after the backfill: %d bytes, %d keys", len(raw), len(keyLines(raw))) |
| + } |
| + 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 { |
| + t.Fatalf("second nested backfill = %v, %v; want present", got, err) |
| + } |
| + if raw2, _ := os.ReadFile(envPath); string(raw2) != string(raw) { |
| + t.Fatal("the second backfill changed the file") |
| + } |
| +} |
| + |
| +// A nested own env file is on tenant-writable ground: a symlink planted at |
| +// its name is refused, and the file it points at is not touched. |
| +func TestEnsureInstanceKeyBeneathRefusesASymlink(t *testing.T) { |
| + dir := t.TempDir() |
| + target := filepath.Join(t.TempDir(), "elsewhere.env") |
| + if err := os.WriteFile(target, []byte("X=1\n"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + 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 { |
| + t.Fatal("ensure followed a symlink planted at the env file's name") |
| + } |
| + if raw, _ := os.ReadFile(target); string(raw) != "X=1\n" { |
| + t.Fatalf("the symlink's target changed: %q", raw) |
| + } |
| +} |
| + |
| +// Review focus 4: a FIFO planted at a nested env file's name fails the |
| +// backfill fast; it never blocks agent start. |
| +func TestBackfillInstanceKeyRefusesAFIFOWithoutBlocking(t *testing.T) { |
| + b, rt := nestedBackend(t, &fakeControl{inv: "inv1"}, &fakeUnit{}) |
| + dir := provision.InstanceDir(b.DataDir, rt.Host) |
| + if err := os.MkdirAll(dir, 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := unix.Mkfifo(filepath.Join(dir, rt.Host+".env"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + done := make(chan error, 1) |
| + go func() { |
| + _, err := b.BackfillInstanceKey(rt) |
| + done <- err |
| + }() |
| + select { |
| + case err := <-done: |
| + if err == nil { |
| + t.Fatal("backfill over a FIFO succeeded, want an error") |
| + } |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("backfill blocked on a FIFO") |
| + } |
| +} |
diff --git a/internal/activator/instancekey_test.go b/internal/activator/instancekey_test.go| index 949bc5ed1..bf85473f7 100644 |
| --- a/internal/activator/instancekey_test.go |
| +++ b/internal/activator/instancekey_test.go |
| @@ -2,6 +2,7 @@ package activator |
| import ( |
| "bytes" |
| + "context" |
| "encoding/base64" |
| "errors" |
| "log" |
| @@ -9,9 +10,12 @@ import ( |
| "path/filepath" |
| "strings" |
| "sync" |
| + "sync/atomic" |
| "syscall" |
| "testing" |
| + "time" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -640,3 +644,323 @@ func TestEnsureInstanceKeySymlinkErrorNamesThePathOnce(t *testing.T) { |
| t.Fatalf("error %q names the path %d times, want once", err, n) |
| } |
| } |
| + |
| +// startRecorded starts host on b with the recording binary and returns the |
| +// environment the child saw, or nil when no child ran within a second. |
| +func startRecorded(t *testing.T, b *ExecBackend, host string) (map[string]string, error) { |
| + t.Helper() |
| + _, envPath := stageRecordingLiveFullEnv(t, b, host, "v1") |
| + rt := registry.Route{Host: host, Kind: "instance", Socket: tmpSock(t)} |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + proc, _, err := b.Start(ctx, rt) // the fake binary never binds: the health wait fails, as in the sibling tests |
| + if proc != nil { |
| + defer proc.Stop(context.Background()) |
| + } |
| + deadline := time.Now().Add(time.Second) |
| + for time.Now().Before(deadline) { |
| + if raw, rerr := os.ReadFile(envPath); rerr == nil { |
| + child := map[string]string{} |
| + for _, line := range strings.Split(string(raw), "\n") { |
| + if k, v, ok := strings.Cut(line, "="); ok { |
| + child[k] = v |
| + } |
| + } |
| + return child, err |
| + } |
| + time.Sleep(10 * time.Millisecond) |
| + } |
| + return nil, err |
| +} |
| + |
| +// Start hands the child the key it minted, and a second Start (a restart, a |
| +// redeploy) hands it the same one. |
| +func TestExecBackendStartHandsTheChildTheMintedInstanceKey(t *testing.T) { |
| + b := testExecBackend(t) |
| + host := "shop.example.com" |
| + first, _ := startRecorded(t, b, host) |
| + minted, err := readEnvFile(filepath.Join(b.DataDir, host+".env")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if first == nil || !wellFormedInstanceKey(minted[InstanceKeyVar]) || first[InstanceKeyVar] != minted[InstanceKeyVar] { |
| + t.Fatal("the child was not handed the key minted into <host>.env") |
| + } |
| + if second, _ := startRecorded(t, b, host); second == nil || second[InstanceKeyVar] != minted[InstanceKeyVar] { |
| + t.Fatal("a second start handed the child a different key") |
| + } |
| +} |
| + |
| +// Finding 2 through a launch: while the key's sync fails, Start fails and |
| +// no child runs. Once the sync succeeds, the child is handed the key the |
| +// failed attempt wrote, never a second one. |
| +func TestExecBackendStartStartsNoChildUntilTheKeyIsSynced(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + fail func(*syncProbe, error) |
| + }{ |
| + {"file sync", func(p *syncProbe, err error) { p.fileErr = err }}, |
| + {"directory sync", func(p *syncProbe, err error) { p.dirErr = err }}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + probe := probeInstanceKeySync(t) |
| + b := testExecBackend(t) |
| + host := "shop.example.com" |
| + tc.fail(probe, errors.New("injected: EIO")) |
| + for i := 0; i < 2; i++ { |
| + child, err := startRecorded(t, b, host) |
| + if err == nil || !strings.Contains(err.Error(), "instance key for "+host) { |
| + t.Fatalf("start %d with a failing sync: err = %v, want the instance key's error", i, err) |
| + } |
| + if child != nil { |
| + t.Fatalf("start %d ran a child before the key was durable", i) |
| + } |
| + } |
| + written, _ := readEnvFile(filepath.Join(b.DataDir, host+".env")) |
| + tc.fail(probe, nil) |
| + child, _ := startRecorded(t, b, host) |
| + if child == nil || child[InstanceKeyVar] == "" || child[InstanceKeyVar] != written[InstanceKeyVar] { |
| + t.Fatal("after the sync succeeded the child was not handed the key the failed attempt wrote") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Finding 4 through a launch: <host>.env and <host>.config.env together. |
| +// An empty override never hands the child an empty key while the |
| +// configuration supplies one, and no key is minted over the configured one. |
| +// With neither supplying one, the child gets the minted key. |
| +func TestExecBackendStartNeverHandsTheChildAnEmptyKeyWhileOneExists(t *testing.T) { |
| + tok := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n" |
| + for _, tc := range []struct { |
| + name, own, config string |
| + configured bool |
| + }{ |
| + {"empty override, configured key", tok + InstanceKeyVar + "=\n", InstanceKeyVar + "=from-config\n", true}, |
| + {"no override, configured key", tok, InstanceKeyVar + "=from-config\n", true}, |
| + {"empty override, empty configuration", tok + InstanceKeyVar + "=\n", InstanceKeyVar + "=\n", false}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + b := testExecBackend(t) |
| + host := "shop.example.com" |
| + own := filepath.Join(b.DataDir, host+".env") |
| + if err := os.WriteFile(own, []byte(tc.own), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(b.DataDir, host+".config.env"), []byte(tc.config), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + child, _ := startRecorded(t, b, host) |
| + if child == nil { |
| + t.Fatal("no child ran") |
| + } |
| + raw, _ := os.ReadFile(own) |
| + if tc.configured { |
| + if child[InstanceKeyVar] != "from-config" { |
| + t.Fatalf("the child was handed %q, want the configured key", child[InstanceKeyVar]) |
| + } |
| + if string(raw) != tc.own { |
| + t.Fatal("a key was minted over the configured one") |
| + } |
| + return |
| + } |
| + env, _ := readEnvFile(own) |
| + if !wellFormedInstanceKey(child[InstanceKeyVar]) || child[InstanceKeyVar] != env[InstanceKeyVar] { |
| + t.Fatal("the child was not handed the minted key") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Coverage (Astra round 1): the first process of a fresh flat instance is |
| +// often its seed. The seed mints the key, sees it, and the first start |
| +// hands the instance the same one. |
| +func TestExecBackendSeedMintsTheKeyForAFreshFlatInstance(t *testing.T) { |
| + b := testExecBackend(t) |
| + host := "seeded.example.com" |
| + _, envPath := stageRecordingLiveFullEnv(t, b, host, "v1") |
| + if err := b.Seed(context.Background(), registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`}); err != nil { |
| + t.Fatalf("Seed: %v", err) |
| + } |
| + seed := map[string]string{} |
| + for _, line := range strings.Split(waitForFile(t, envPath, time.Second), "\n") { |
| + if k, v, ok := strings.Cut(line, "="); ok { |
| + seed[k] = v |
| + } |
| + } |
| + own, _ := readEnvFile(filepath.Join(b.DataDir, host+".env")) |
| + if !wellFormedInstanceKey(own[InstanceKeyVar]) || seed[InstanceKeyVar] != own[InstanceKeyVar] { |
| + t.Fatal("the seed did not mint the key and see it") |
| + } |
| + if child, _ := startRecorded(t, b, host); child == nil || child[InstanceKeyVar] != own[InstanceKeyVar] { |
| + t.Fatal("the first start handed the instance a different key from its seed's") |
| + } |
| +} |
| + |
| +// Coverage (Astra round 1): a hibernation wake restores the database and |
| +// leaves <host>.env alone, so the start after the restore hands the |
| +// instance the key it had. |
| +func TestHibernationRestoreKeepsTheInstanceKey(t *testing.T) { |
| + b := testExecBackend(t) |
| + host := "sleepy.example.com" |
| + if child, _ := startRecorded(t, b, host); child == nil { |
| + t.Fatal("no child ran") |
| + } |
| + envPath := filepath.Join(b.DataDir, host+".env") |
| + before, _ := os.ReadFile(envPath) |
| + key := parseEnvBytes(before)[InstanceKeyVar] |
| + if !wellFormedInstanceKey(key) { |
| + t.Fatal("the first start minted no key") |
| + } |
| + old := restoreReplica |
| + restoreReplica = func(_ context.Context, dbPath, bucket, prefix, region string) (bool, error) { |
| + return true, writeMinimalSQLite(dbPath) |
| + } |
| + t.Cleanup(func() { restoreReplica = old }) |
| + _ = os.Remove(filepath.Join(b.DataDir, host+".db")) // hibernated: the database is gone |
| + rt := registry.Route{Host: host, Kind: "instance", Socket: tmpSock(t)} |
| + if fresh, err := b.Restore(context.Background(), rt); err != nil || fresh { |
| + t.Fatalf("Restore = fresh %v, %v; want a restored database", fresh, err) |
| + } |
| + if after, _ := os.ReadFile(envPath); !bytes.Equal(before, after) { |
| + t.Fatal("the restore changed <host>.env") |
| + } |
| + if child, _ := startRecorded(t, b, host); child == nil || child[InstanceKeyVar] != key { |
| + t.Fatal("the start after a restore handed the instance a different key") |
| + } |
| +} |
| + |
| +// refuseAfterFirstClassification makes b classify a row as it does today |
| +// the first time it is asked and as layout (fenced or conflict) every time |
| +// after: a launch dispatcher's own check passes, and the launch body's |
| +// pathsFor sees a layout journal or a colliding peer that appeared since. |
| +func refuseAfterFirstClassification(b *ExecBackend, layout Layout) { |
| + var calls atomic.Int32 |
| + switch layout { |
| + case LayoutFenced: |
| + b.LayoutFacts.Journal = func(string) bool { return calls.Add(1) > 1 } |
| + case LayoutConflict: |
| + b.LayoutFacts.SameID = func(a, b string) bool { return true } |
| + b.LayoutFacts.Peers = func() ([]registry.Route, error) { |
| + if calls.Add(1) == 1 { |
| + return nil, nil |
| + } |
| + return []registry.Route{{Host: "other.example.com", Kind: "instance", Hibernate: true, Socket: "/nonexistent/other.sock"}}, nil |
| + } |
| + } |
| +} |
| + |
| +// Review (Task 2): the mint refuses a fenced or conflicting row on the |
| +// paths the launch body hands it, not only on the dispatcher's earlier |
| +// classification. A row that becomes fenced or conflicting between Start's |
| +// or Seed's check and the body's own pathsFor fails the launch with the |
| +// key's error, gets no key, and runs no child. |
| +func TestLaunchBodiesMintNoKeyForARowRefusedSinceTheDispatch(t *testing.T) { |
| + for _, layout := range []Layout{LayoutFenced, LayoutConflict} { |
| + for _, launch := range []string{"start", "seed"} { |
| + t.Run(string(layout)+"/"+launch, func(t *testing.T) { |
| + b := testExecBackend(t) |
| + host := "shop.example.com" |
| + _, envPath := stageRecordingLiveFullEnv(t, b, host, "v1") |
| + refuseAfterFirstClassification(b, layout) |
| + rt := registry.Route{Host: host, Kind: "instance", Socket: tmpSock(t), Seed: `["seed"]`} |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + var err error |
| + if launch == "start" { |
| + var proc Proc |
| + proc, _, err = b.Start(ctx, rt) |
| + if proc != nil { |
| + defer proc.Stop(context.Background()) |
| + } |
| + } else { |
| + err = b.Seed(ctx, rt) |
| + } |
| + if err == nil || !strings.Contains(err.Error(), "instance key for "+host) || !strings.Contains(err.Error(), "not minted") { |
| + t.Fatalf("%s of a row %s since the dispatch: err = %v, want the key's refusal", launch, layout, err) |
| + } |
| + raw, _ := os.ReadFile(filepath.Join(b.DataDir, host+".env")) |
| + if n := len(keyLines(raw)); n != 0 { |
| + t.Fatalf("a %s row was minted %d key(s)", layout, n) |
| + } |
| + if _, serr := os.Stat(envPath); !errors.Is(serr, os.ErrNotExist) { |
| + t.Fatalf("a child ran for a %s row (stat %v)", layout, serr) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +// The refusal itself, for every layout pathsFor can report it on: a flat |
| +// row and a nested one, each fenced and conflicting. Nothing is written in |
| +// the data directory or the instance directory. (A nested row's prepare |
| +// cannot reach the mint in a test once fenced: its fenced paths name |
| +// /run/carlos, which prepare creates first. prepareAt hands the mint its |
| +// paths unchanged, so this is the refusal it gets.) |
| +func TestEnsureInstanceKeyForRefusesFencedAndConflictRows(t *testing.T) { |
| + for _, nested := range []bool{false, true} { |
| + for _, layout := range []Layout{LayoutFenced, LayoutConflict} { |
| + name := "flat" |
| + if nested { |
| + name = "nested" |
| + } |
| + t.Run(name+"/"+string(layout), func(t *testing.T) { |
| + var b *ExecBackend |
| + var rt registry.Route |
| + if nested { |
| + b, rt = nestedBackend(t, &fakeControl{inv: "inv1"}, &fakeUnit{}) |
| + } else { |
| + b = testExecBackend(t) |
| + rt = registry.Route{Host: "shop.example.com", Kind: "instance", Hibernate: true, |
| + DBPath: provision.DefaultDBPath(b.DataDir, "shop.example.com"), Socket: filepath.Join(provision.SocketDir, "shop.example.com.sock")} |
| + } |
| + if l, _ := b.Layout(rt); (nested && l != LayoutNested) || (!nested && l != LayoutFlat) { |
| + t.Fatalf("fixture classifies as %s", l) |
| + } |
| + refuseAfterFirstClassification(b, layout) |
| + _, _ = b.Layout(rt) // the dispatcher's check |
| + p := b.pathsFor(rt) |
| + 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") { |
| + t.Fatalf("ensure for a %s row: err = %v, want a refusal", layout, err) |
| + } |
| + for _, f := range []string{ |
| + filepath.Join(b.DataDir, rt.Host+".env"), |
| + filepath.Join(provision.InstanceDir(b.DataDir, rt.Host), rt.Host+".env"), |
| + } { |
| + if _, err := os.Lstat(f); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("%s exists after a refused ensure (lstat %v)", f, err) |
| + } |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +// The backfill refuses the same rows (instancekey.go BackfillInstanceKey): |
| +// an existing env file without a key is left byte for byte. |
| +func TestBackfillInstanceKeyRefusesFencedAndConflictRows(t *testing.T) { |
| + for _, layout := range []Layout{LayoutFenced, LayoutConflict} { |
| + t.Run(string(layout), func(t *testing.T) { |
| + b := testExecBackend(t) |
| + host := "shop.example.com" |
| + rt := registry.Route{Host: host, Kind: "instance", Hibernate: true, |
| + DBPath: provision.DefaultDBPath(b.DataDir, host), Socket: filepath.Join(provision.SocketDir, host+".sock")} |
| + own := filepath.Join(b.DataDir, host+".env") |
| + tok := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("d", 64) + "\n" |
| + if err := os.WriteFile(own, []byte(tok), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + refuseAfterFirstClassification(b, layout) |
| + _, _ = b.Layout(rt) |
| + if _, err := b.BackfillInstanceKey(rt); err == nil || !strings.Contains(err.Error(), "not backfilled") { |
| + t.Fatalf("backfill of a %s row: err = %v, want a refusal", layout, err) |
| + } |
| + if raw, _ := os.ReadFile(own); string(raw) != tok { |
| + t.Fatalf("the backfill changed a %s row's env file", layout) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/activator/prepare.go b/internal/activator/prepare.go| index 16e3d45a8..3ffc0af70 100644 |
| --- a/internal/activator/prepare.go |
| +++ b/internal/activator/prepare.go |
| @@ -60,6 +60,9 @@ 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 { |
| + return p, nil, nil, nil, fmt.Errorf("instance key for %s: %w", rt.Host, err) |
| + } |
| var err error |
| env, err = b.instanceEnv(rt) |
| if err != nil { |