carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev f896570c3620f6878e133a9b938b0f307a026651 parent 4415e1b
5 files changed, +470 −0
  • internal/activator/backend_exec.go +12 −0
  • internal/activator/instancekey.go +14 −0
  • internal/activator/instancekey_linux_test.go +117 −0
  • internal/activator/instancekey_test.go +324 −0
  • internal/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 {