zdd-gen-socket-names
Squashed from zdd-gen-socket-names into main.
17 files changed,
+1083
−106
cmd/zddprobe/main.go+32 −0cmd/zddprobe/main_test.go+92 −4docs/superpowers/specs/2026-10-13-zero-downtime-deploys-rev19-amendment.md+93 −0internal/activator/backend_exec.go+30 −15internal/activator/genbackend.go+39 −5internal/activator/generations.go+85 −18internal/activator/generations_backend_test.go+18 −14internal/activator/generations_test.go+69 −24internal/activator/gensocket_rev19_test.go+439 −0internal/activator/opset.go+28 −5internal/activator/prepare.go+16 −3internal/activator/swap.go+5 −5internal/activator/swaphold.go+23 −11internal/instanceexec/exec_test.go+2 −2internal/provision/plan14_legacy_identity_test.go+69 −0internal/provision/plan14_root_reads_test.go+10 −0internal/provision/testdata/plan14-root-reads-zddr4.tsv+33 −0
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.012s
? 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 352.512s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.017s
ok github.com/carlosframework/platform/cmd/moveprobe 92.253s
ok github.com/carlosframework/platform/cmd/zddprobe 0.705s
ok github.com/carlosframework/platform/internal/activator 232.545s
ok github.com/carlosframework/platform/internal/agentsock 0.018s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.021s
ok github.com/carlosframework/platform/internal/appconfig 2.371s
ok github.com/carlosframework/platform/internal/apperr 0.074s
ok github.com/carlosframework/platform/internal/applog 0.123s
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 1.162s
? 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.012s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.710s
ok github.com/carlosframework/platform/internal/changelog 0.296s
ok github.com/carlosframework/platform/internal/clamd 0.055s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 365.791s
? 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 6.310s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
ok github.com/carlosframework/platform/internal/dbverify 0.006s
ok github.com/carlosframework/platform/internal/dnsrec 0.002s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.140s
ok github.com/carlosframework/platform/internal/doorkey 0.007s
ok github.com/carlosframework/platform/internal/economics 0.005s
ok github.com/carlosframework/platform/internal/edge 25.961s
? 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.124s
ok github.com/carlosframework/platform/internal/gate 1.654s
ok github.com/carlosframework/platform/internal/genselect 0.016s
ok github.com/carlosframework/platform/internal/geo 0.016s
ok github.com/carlosframework/platform/internal/geoip 0.030s
ok github.com/carlosframework/platform/internal/host 127.992s
ok github.com/carlosframework/platform/internal/hostactors 0.252s
ok github.com/carlosframework/platform/internal/hostbox 2.145s
ok github.com/carlosframework/platform/internal/hostclaim 0.516s
ok github.com/carlosframework/platform/internal/hostevidence 0.016s
ok github.com/carlosframework/platform/internal/hostexec 4.886s
ok github.com/carlosframework/platform/internal/hostfence 0.032s
ok github.com/carlosframework/platform/internal/hostfiles 10.506s
ok github.com/carlosframework/platform/internal/hostfloor 0.016s
ok github.com/carlosframework/platform/internal/hostguard 0.968s
ok github.com/carlosframework/platform/internal/hostops 1.475s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.016s
ok github.com/carlosframework/platform/internal/hostquarantine 0.016s
ok github.com/carlosframework/platform/internal/hostrelocate 0.459s
ok github.com/carlosframework/platform/internal/hostresolve 0.136s
ok github.com/carlosframework/platform/internal/hostwrite 0.016s
ok github.com/carlosframework/platform/internal/infraview 0.018s
ok github.com/carlosframework/platform/internal/instancectl 1.820s
ok github.com/carlosframework/platform/internal/instanceexec 2.725s
ok github.com/carlosframework/platform/internal/instancefs 0.017s
ok github.com/carlosframework/platform/internal/instancekey 0.018s
ok github.com/carlosframework/platform/internal/instanceupdates 0.023s
ok github.com/carlosframework/platform/internal/layout 1.463s
ok github.com/carlosframework/platform/internal/lease 9.467s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.025s
ok github.com/carlosframework/platform/internal/ledger 0.011s
ok github.com/carlosframework/platform/internal/mail 0.160s
ok github.com/carlosframework/platform/internal/maintenance 0.079s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.514s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.074s
ok github.com/carlosframework/platform/internal/metrics 0.006s
ok github.com/carlosframework/platform/internal/moves 21.116s
ok github.com/carlosframework/platform/internal/moves/archive 0.763s
ok github.com/carlosframework/platform/internal/objstore 0.181s
ok github.com/carlosframework/platform/internal/ops 0.159s
ok github.com/carlosframework/platform/internal/park 0.211s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 12.204s
ok github.com/carlosframework/platform/internal/publicrelease 0.006s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 3.971s
ok github.com/carlosframework/platform/internal/relay 7.232s
ok github.com/carlosframework/platform/internal/release 19.338s
ok github.com/carlosframework/platform/internal/rollout 3.680s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.006s
ok github.com/carlosframework/platform/internal/rootstate 0.025s
ok github.com/carlosframework/platform/internal/s3compat 0.007s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.302s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.549s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.259s
ok github.com/carlosframework/platform/internal/status 0.040s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.230s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.006s
ok github.com/carlosframework/platform/internal/termfence 0.004s
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.004s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.543s
=== 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.023s
=== RUN TestT4StorageProbeMinio
2026/10/14 03:47:21 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE48B462AA2113, 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.022s
minio contract: PASS
landed from zdd-gen-socket-names
diff --git a/cmd/zddprobe/main.go b/cmd/zddprobe/main.go| index 9d692bc01..a13cf9574 100644 |
| --- a/cmd/zddprobe/main.go |
| +++ b/cmd/zddprobe/main.go |
| @@ -9,6 +9,17 @@ |
| // `<binary> -socket <sock>`. The probe takes both. It keeps no data, so --db |
| // is accepted and ignored. |
| // |
| +// On a platform launch, the one that passes --db, the probe checks before |
| +// it listens what a real app may rely on (zero-downtime spec rev 19, rule |
| +// 5), and exits 1 with a line on stderr if either is wrong: CARLOS_HOST |
| +// must be set, and the file name of --socket must be the flag-off one, |
| +// app.sock (nested) or $CARLOS_HOST.sock (flat). A generation launch that |
| +// named its socket anything else crash-looped a tenant whose framework |
| +// reads its host from that name (amadan #272); the probe ignored the name |
| +// and passed. Vet's boot passes no --db, no CARLOS_HOST and a socket name |
| +// of its own choosing, so it skips the check and the probe serves. (The |
| +// flag package reads -db and --db alike, so --db being given is the test.) |
| +// |
| // version is set at build time with -ldflags "-X main.version=...". |
| // |
| // With ZDDPROBE_TRACE=1 the probe writes one line, "serving", to stdout as a |
| @@ -26,6 +37,7 @@ import ( |
| "net/http" |
| "os" |
| "os/signal" |
| + "path/filepath" |
| "syscall" |
| "time" |
| ) |
| @@ -41,6 +53,19 @@ func main() { |
| } |
| } |
| +// checkLaunch is rule 5's check of a platform launch: host (CARLOS_HOST) |
| +// is set, and sock's file name is app.sock or <host>.sock, the names a |
| +// flag-off launch gives. |
| +func checkLaunch(sock, host string) error { |
| + if host == "" { |
| + return errors.New("zddprobe: CARLOS_HOST is empty; the platform sets it on every instance launch") |
| + } |
| + if base := filepath.Base(sock); base != "app.sock" && base != host+".sock" { |
| + return fmt.Errorf("zddprobe: --socket file name is %q; want app.sock or %s.sock, the flag-off names", base, host) |
| + } |
| + return nil |
| +} |
| + |
| func run(args []string, stop <-chan os.Signal) error { |
| fs := flag.NewFlagSet("zddprobe", flag.ContinueOnError) |
| fs.SetOutput(os.Stderr) |
| @@ -52,6 +77,13 @@ func run(args []string, stop <-chan os.Signal) error { |
| if *sock == "" { |
| return errors.New("zddprobe: --socket is required") |
| } |
| + platform := false |
| + fs.Visit(func(f *flag.Flag) { platform = platform || f.Name == "db" }) |
| + if platform { |
| + if err := checkLaunch(*sock, os.Getenv("CARLOS_HOST")); err != nil { |
| + return err |
| + } |
| + } |
| _ = os.Remove(*sock) |
| l, err := net.Listen("unix", *sock) |
| if err != nil { |
diff --git a/cmd/zddprobe/main_test.go b/cmd/zddprobe/main_test.go| index 42a002879..28dfbda6b 100644 |
| --- a/cmd/zddprobe/main_test.go |
| +++ b/cmd/zddprobe/main_test.go |
| @@ -3,6 +3,7 @@ package main |
| import ( |
| "bufio" |
| "context" |
| + "errors" |
| "io" |
| "net" |
| "net/http" |
| @@ -80,7 +81,7 @@ func getOverUnix(t *testing.T, sock, path string) string { |
| func TestProbeServesWithThePlatformsRunArgv(t *testing.T) { |
| dir := t.TempDir() |
| sock, db := filepath.Join(dir, "app.sock"), filepath.Join(dir, "app.db") |
| - cmd, stdout := launch(t, []string{"ZDDPROBE_TRACE=1"}, "--socket", sock, "--db", db) // backend_exec.go:2335 |
| + cmd, stdout := launch(t, []string{"ZDDPROBE_TRACE=1", "CARLOS_HOST=shop.example.com"}, "--socket", sock, "--db", db) // backend_exec.go spawnUnitFrom |
| if body := getOverUnix(t, sock, "/healthz"); !strings.HasPrefix(body, "ok ") { |
| t.Fatalf("body %q", body) |
| } |
| @@ -122,7 +123,9 @@ func TestProbeServesWithThePlatformsRunArgv(t *testing.T) { |
| func TestProbeServesWithVetsArgv(t *testing.T) { |
| sock := filepath.Join(t.TempDir(), "app.sock") |
| - launch(t, nil, "-socket", sock) // internal/vet/vet.go:135 |
| + // Vet's argv: no --db and no CARLOS_HOST. Rule 5 is a platform-launch |
| + // check and does not apply. |
| + launch(t, []string{"CARLOS_HOST="}, "-socket", sock) // internal/vet/vet.go bootHealthz: no --db, no CARLOS_HOST |
| if body := getOverUnix(t, sock, "/"); !strings.HasPrefix(body, "ok ") { |
| t.Fatalf("body %q", body) |
| } |
| @@ -130,7 +133,7 @@ func TestProbeServesWithVetsArgv(t *testing.T) { |
| func TestProbeIsSilentWithoutTrace(t *testing.T) { |
| sock := filepath.Join(t.TempDir(), "app.sock") |
| - cmd, stdout := launch(t, []string{"ZDDPROBE_TRACE="}, "--socket", sock) |
| + cmd, stdout := launch(t, []string{"ZDDPROBE_TRACE=", "CARLOS_HOST=shop.example.com"}, "--socket", sock) |
| getOverUnix(t, sock, "/") |
| if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { |
| t.Fatal(err) |
| @@ -145,8 +148,93 @@ func TestProbeIsSilentWithoutTrace(t *testing.T) { |
| } |
| func TestProbeRequiresSocket(t *testing.T) { |
| - cmd, _ := launch(t, nil, "--db", "x.db") |
| + cmd, _ := launch(t, []string{"CARLOS_HOST=shop.example.com"}, "--db", "x.db") |
| if err := cmd.Wait(); err == nil { |
| t.Fatal("probe without --socket exited 0") |
| } |
| } |
| + |
| +// The flat flag-off name, <host>.sock, is served like app.sock. |
| +func TestProbeServesOnTheFlatHostSocketName(t *testing.T) { |
| + sock := filepath.Join(t.TempDir(), "shop.example.com.sock") |
| + launch(t, []string{"CARLOS_HOST=shop.example.com"}, "--socket", sock, "--db", "x.db") |
| + if body := getOverUnix(t, sock, "/"); !strings.HasPrefix(body, "ok ") { |
| + t.Fatalf("body %q", body) |
| + } |
| +} |
| + |
| +// A vet boot is not checked whatever its socket is called: vet picks the |
| +// name, and CARLOS_HOST is not set. |
| +func TestProbeServesVetsArgvWithAnyName(t *testing.T) { |
| + for _, form := range []string{"-socket", "--socket"} { |
| + t.Run(form, func(t *testing.T) { |
| + sock := filepath.Join(t.TempDir(), "probe.3.sock") |
| + launch(t, []string{"CARLOS_HOST="}, form, sock) |
| + if body := getOverUnix(t, sock, "/"); !strings.HasPrefix(body, "ok ") { |
| + t.Fatalf("body %q", body) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Rev 19, rule 5: a platform launch (it passes --db) whose socket file name |
| +// is not a flag-off one, or that has no CARLOS_HOST, exits 1 with a line on |
| +// stderr, before it listens, so a probe on such a launch fails its health |
| +// check as the tenant did. -db and --db are the same flag. |
| +func TestProbeRefusesALaunchARealAppMightRefuse(t *testing.T) { |
| + for _, tc := range []struct { |
| + name, host, file, want string |
| + }{ |
| + {"generation file name", "shop.example.com", "app.3.sock", `--socket file name is "app.3.sock"`}, |
| + {"hashed file name", "shop.example.com", "0123456789ab.3.sock", "want app.sock or shop.example.com.sock"}, |
| + {"another host's name", "shop.example.com", "other.example.com.sock", `"other.example.com.sock"`}, |
| + {"no CARLOS_HOST, nested name", "", "app.sock", "CARLOS_HOST is empty"}, |
| + {"no CARLOS_HOST, flat name", "", "shop.example.com.sock", "CARLOS_HOST is empty"}, |
| + } { |
| + for _, form := range []string{"--db", "-db"} { |
| + t.Run(tc.name+" "+form, func(t *testing.T) { |
| + refused(t, tc.host, tc.file, form, tc.want) |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func refused(t *testing.T, host, file, dbFlag, want string) { |
| + t.Helper() |
| + sock := filepath.Join(t.TempDir(), file) |
| + cmd := exec.Command(os.Args[0], "--socket", sock, dbFlag, "x.db") |
| + cmd.Env = append(os.Environ(), "ZDDPROBE_AS_MAIN=1", "CARLOS_HOST="+host) |
| + var stderr strings.Builder |
| + cmd.Stderr = &stderr |
| + err := cmd.Run() |
| + var exit *exec.ExitError |
| + if !errors.As(err, &exit) || exit.ExitCode() != 1 { |
| + t.Fatalf("exit = %v, want status 1", err) |
| + } |
| + if !strings.Contains(stderr.String(), want) { |
| + t.Fatalf("stderr %q, want it to say %q", stderr.String(), want) |
| + } |
| + if _, err := os.Lstat(sock); !os.IsNotExist(err) { |
| + t.Fatalf("the probe listened before refusing (lstat %s: %v)", sock, err) |
| + } |
| +} |
| + |
| +func TestCheckLaunch(t *testing.T) { |
| + for _, tc := range []struct { |
| + sock, host string |
| + ok bool |
| + }{ |
| + {"/run/carlos/instances/shop.example.com/g3/app.sock", "shop.example.com", true}, |
| + {"/run/carlos/instances/shop.example.com/app.sock", "shop.example.com", true}, |
| + {"/run/carlos/g/3/shop.example.com.sock", "shop.example.com", true}, |
| + {"/run/carlos/shop.example.com.sock", "shop.example.com", true}, |
| + {"/run/carlos/instances/shop.example.com/app.3.sock", "shop.example.com", false}, |
| + {"/run/carlos/g/0123456789ab.3.sock", "shop.example.com", false}, |
| + {"/run/carlos/shop.example.com.sock", "", false}, |
| + {"/run/carlos/.sock", "", false}, |
| + } { |
| + if err := checkLaunch(tc.sock, tc.host); (err == nil) != tc.ok { |
| + t.Errorf("checkLaunch(%q, %q) = %v, want ok %v", tc.sock, tc.host, err, tc.ok) |
| + } |
| + } |
| +} |
diff --git a/docs/superpowers/specs/2026-10-13-zero-downtime-deploys-rev19-amendment.md b/docs/superpowers/specs/2026-10-13-zero-downtime-deploys-rev19-amendment.md| new file mode 100644 |
| index 000000000..1e2a533c5 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-10-13-zero-downtime-deploys-rev19-amendment.md |
| @@ -0,0 +1,93 @@ |
| +# Zero-downtime deploys: rev 19 amendment (generation socket names) |
| + |
| +Status: rev 19, 2026-10-13. Paul approved the fix on 2026-10-13 after Gate F |
| +failed. This amends §7.1 of |
| +`2026-10-07-zero-downtime-deploys-design.md` (rev 17, as amended by rev 18). |
| + |
| +## What happened |
| + |
| +Gate F armed the flagship's IE box at 22:08Z on 2026-10-13. For about |
| +3.5 minutes every generation wake of three instances of one app crash-looped, |
| +until the flag was removed (amadan #272). |
| + |
| +The app's framework works out its origin from the file name of its `--socket` |
| +argument: |
| + |
| +- A bare `app.sock` names no host, so the framework falls back to its |
| + configured origin. This is how every nested instance starts with the flag |
| + off. |
| +- The nested generation socket `app.3.sock` parsed as the host `app.3`. That |
| + disagreed with the configured origin, so the app refused to start. |
| + |
| +`CARLOS_HOST` was set on those launches, and the docs say to read it rather |
| +than the socket name, but deployed builds do not. A platform release must not |
| +break a build that starts today. The dev probe passed because `zddprobe` |
| +ignores its socket name. |
| + |
| +## The rule (replaces §7.1's generation paths) |
| + |
| +1. **A generation's socket file name is the flag-off one.** |
| + - Nested: `app.sock`. |
| + - Flat: `<host>.sock`. |
| + |
| + Each generation gets its own directory instead: |
| + - Nested: `<instance run dir>/g<n>/app.sock`, for example |
| + `/run/carlos/instances/<id>/g3/app.sock`. |
| + - Flat: `/run/carlos/g/<n>/<host>.sock`. |
| + |
| + The control socket stays `<socket>.control`, beside it. Rev 15's canonical |
| + link at `rt.Socket` keeps pointing at the current generation's socket by |
| + absolute path. §7.1's "the hostname never appears in it" no longer holds |
| + for flat generation paths. See rule 4 for the length bound that replaced |
| + it. |
| +2. **A generation launch matches a flag-off launch.** Its argv and its |
| + agent-owned environment are the same as a flag-off launch of the same |
| + build, except for the directory part of `--socket`. This already holds for |
| + the environment (`agentOwnedVars`). A test pins it for both layouts. |
| +3. **The directory's lifecycle.** |
| + - Before generation n's stale-file removal and start, the agent makes its |
| + directory, mode 0750 with setgid, inheriting the carlos-sockets group as |
| + the run dirs do. |
| + - The stale-file rule is unchanged: before the app binds, only the file at |
| + this generation's own path is removed. |
| + - Once the generation's termination proof holds and its socket is gone, |
| + the agent removes the directory if it is empty. A sibling's directory is |
| + never touched. |
| + - A leftover directory from an earlier agent process is harmless, and the |
| + stale-file rule clears its socket the next time n is used. |
| +4. **The length bound.** A unix socket path is at most 107 bytes. A flat host |
| + whose generation path would exceed that has no generation path: it is |
| + handled exactly as a foreign row is today, keeping its canonical socket. |
| + This adds no new member-facing state and no new copy. The window is small, |
| + because the flag-off path `/run/carlos/<host>.sock` is already near the |
| + bound for the same host. Nested paths are bounded by the 40-byte instance |
| + identifier and stay well under it. |
| + |
| + The bound is measured on the host alone: the control socket |
| + `/run/carlos/g/<n>/<host>.sock.control` at the largest generation number |
| + (20 digits). So flat hosts of up to 59 bytes get generation paths, |
| + against 82 bytes for the flag-off control socket. Measuring at the |
| + current number would let a row change treatment as the counter grows, |
| + even in the middle of a swap: an attempt admitted as an overlap could |
| + meet a generation that cannot bind. |
| +5. **The probe checks it.** `cmd/zddprobe` exits 1, with a clear line on |
| + stderr, before it listens if either of these is wrong: |
| + - the base name of `--socket` is neither `app.sock` nor |
| + `$CARLOS_HOST.sock`; |
| + - `CARLOS_HOST` is empty. |
| + |
| + A dev or flagship probe then fails its health check the way the tenant |
| + did, instead of passing. |
| + |
| +## Not changed |
| + |
| +- Generation numbering, the generations file, the termination proof, the |
| + scheduler, the canary, and the failure book. |
| +- Flag-off paths. With `CARLOS_SWAP` unset, nothing in this amendment runs. |
| + |
| +## Before Gate F runs again |
| + |
| +- Release the fix, and roll it to dev and then the flagship. |
| +- Re-run the dev swap under load with the new probe. |
| +- Clear annie's stale failure record. It holds bdn/ips build `2d577ebda` on |
| + canada, which is the live build. Clearing it needs Paul's approval. |
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index 0578016a5..798479ab6 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -1766,15 +1766,19 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| // StartGen is Start for generation n (zero-downtime spec §7.1). perGen false |
| // is Start exactly: the canonical socket, the canonical control socket, the |
| // same argv. perGen true binds the generation's own socket: GenSocketPath |
| -// for a flat row, app.<n>.sock in the instance run dir for a nested one. A |
| -// foreign row keeps its canonical socket either way: it is outside the two |
| -// layouts the spec gives generation paths to, and keeping it is the |
| -// conservative reading (plan 1, "Spec questions"). |
| +// for a flat row, g<n>/app.sock in the instance run dir for a nested one |
| +// (rev 19, rule 1). Only the directory differs from the flag-off launch: |
| +// the file name, the rest of the argv and the agent-owned environment are |
| +// the same (rule 2). A foreign row keeps its canonical socket either way: |
| +// it is outside the two layouts the spec gives generation paths to, and |
| +// keeping it is the conservative reading (plan 1, "Spec questions"). So |
| +// does a flat row whose generation path would not fit a unix socket path |
| +// (genLayout, rule 4). |
| // |
| -// The stale-file rule (spec §7.1): before the app binds, the file at THIS |
| -// generation's own path is removed (left there by an earlier agent process, |
| -// whose counter started at 1 like this one's). A sibling generation's path |
| -// is never touched. |
| +// The stale-file rule (spec §7.1): before the app binds, the generation's |
| +// directory is made and the file at THIS generation's own path is removed |
| +// (left there by an earlier agent process, whose counter started at 1 like |
| +// this one's). A sibling generation's path is never touched. |
| func (b *ExecBackend) StartGen(ctx context.Context, rt registry.Route, n uint64, perGen bool) (Proc, Gen, error) { |
| return b.startGenFrom(ctx, rt, n, perGen, nil, cgroup.Limit{}) |
| } |
| @@ -1820,11 +1824,15 @@ func (b *ExecBackend) startGenFrom(ctx context.Context, rt registry.Route, n uin |
| p := b.pathsFor(rt) |
| sock := rt.Socket |
| if perGen { |
| - switch p.layout { |
| + switch genLayout(p.layout, rt.Host) { |
| case LayoutFlat: |
| sock = b.flatGenSocket(rt.Host, n) |
| case LayoutNested: |
| sock = nestedGenSocketIn(p.runDir, n) |
| + case LayoutForeign: |
| + if p.layout == LayoutFlat { |
| + log.Printf("activator: %s: a generation socket path would exceed %d bytes; generation %d keeps the canonical socket", rt.Host, maxUnixPath, n) |
| + } |
| } |
| } |
| var ( |
| @@ -1834,14 +1842,20 @@ func (b *ExecBackend) startGenFrom(ctx context.Context, rt registry.Route, n uin |
| ) |
| switch p.layout { |
| case LayoutNested: |
| - // prepareAt removes the stale file at sock for the nested layout. |
| + // prepareAt makes sock's generation directory and removes the |
| + // stale file at sock for the nested layout. |
| proc, ver, err = b.startUnitAt(ctx, rt, sock, n, unitExe, tier) |
| case LayoutFenced, LayoutConflict: |
| return nil, Gen{}, fmt.Errorf("%s: refusing to wake: %s", rt.Host, p.reason) |
| default: |
| - if !perGen { |
| + if sock == rt.Socket { |
| // A link left at the canonical path by an agent that ran with |
| - // generations on would make the app's bind fail. |
| + // generations on would make the app's bind fail. That holds for |
| + // every spawn on the canonical socket, not only a flag-off one: |
| + // a foreign row, or a flat row too long for a generation path |
| + // (rev 19 rule 4) whose box ran the old naming, binds it with |
| + // perGen true too (Astra, review of 19c07b2b), as StartStaged's |
| + // foreign arm already handles. |
| if err := clearLeftoverCanonicalLink(rt.Socket); err != nil { |
| return nil, Gen{}, fmt.Errorf("%s: leftover canonical link: %w", rt.Host, err) |
| } |
| @@ -1873,9 +1887,10 @@ func (b *ExecBackend) flatGenSocket(host string, n uint64) string { |
| return GenSocketPath(host, n) |
| } |
| -// clearOwnGenSocket makes the flat generation directory and removes a stale |
| -// file at sock, and only at sock. The directory inherits /run/carlos's |
| -// setgid carlos-sockets group, as the nested run dirs do. |
| +// clearOwnGenSocket makes the flat generation's own directory, |
| +// /run/carlos/g/<n>, and removes a stale file at sock, and only at sock (rev |
| +// 19, rule 3). The directory is 0750 and inherits /run/carlos's setgid |
| +// carlos-sockets group, as the nested run dirs do. |
| func clearOwnGenSocket(sock string) error { |
| if err := os.MkdirAll(filepath.Dir(sock), 0o750|os.ModeSetgid); err != nil { |
| return err |
diff --git a/internal/activator/genbackend.go b/internal/activator/genbackend.go| index 240327d29..06fcc0b21 100644 |
| --- a/internal/activator/genbackend.go |
| +++ b/internal/activator/genbackend.go |
| @@ -178,13 +178,15 @@ func (a *Service) SetLiveExecGate(gate func(rt registry.Route, build string) err |
| // child execs g.StagedPath directly. A nested unit's executable mount names |
| // it, which is Task 9's leg; until then a nested row is refused. A foreign |
| // row keeps its canonical socket (plan 1): it gets one generation at a time |
| -// (a cold, pinned or hold start), and Swap never overlaps it. |
| +// (a cold, pinned or hold start), and Swap never overlaps it. A flat row |
| +// whose generation path would not fit a unix socket path is handled the |
| +// same way (genLayout, rev 19 rule 4). |
| func (b *ExecBackend) StartStaged(ctx context.Context, rt registry.Route, g Gen) (Gen, Proc, error) { |
| if g.Tier == (cgroup.Limit{}) { |
| g.Tier = b.GenTier(rt, g.Build) |
| } |
| p := b.pathsFor(rt) |
| - switch p.layout { |
| + switch genLayout(p.layout, rt.Host) { |
| case LayoutFlat: |
| sock := b.flatGenSocket(rt.Host, g.N) |
| if err := clearOwnGenSocket(sock); err != nil { |
| @@ -452,18 +454,50 @@ func (b *ExecBackend) writeFence(host string, gen uint64) schedule.WriteFence { |
| return func(write func()) bool { return b.ScheduleFence(host, gen, write) } |
| } |
| +// genLayouter is a backend whose rows can read differently to generations |
| +// than to Classify (genLayout, rev 19 rule 4). The swap's layout decisions |
| +// ask it before Classifier (Service.swapLayout). |
| +type genLayouter interface { |
| + GenLayout(rt registry.Route) Layout |
| +} |
| + |
| +var ( |
| + _ genLayouter = (*ExecBackend)(nil) |
| + _ genLayouter = (*SplitBackend)(nil) |
| +) |
| + |
| +// GenLayout is Layout as generations see it: a flat row whose generation |
| +// path would not fit a unix socket path reads foreign (genLayout). |
| +func (b *ExecBackend) GenLayout(rt registry.Route) Layout { |
| + l, _ := b.Layout(rt) |
| + return genLayout(l, rt.Host) |
| +} |
| + |
| +// GenLayout forwards to the exec half for exec rows. A unit row reads as |
| +// its Layout does. |
| +func (b *SplitBackend) GenLayout(rt registry.Route) Layout { |
| + if rt.Backing != registry.BackingUnit { |
| + if g, ok := b.Exec.(genLayouter); ok { |
| + return g.GenLayout(rt) |
| + } |
| + } |
| + l, _ := b.Layout(rt) |
| + return l |
| +} |
| + |
| // genPather is a backend that can name a generation's sockets. |
| type genPather interface { |
| GenPaths(rt registry.Route, n uint64) (Gen, bool) |
| } |
| // GenPaths is the generation socket pair a spawn of generation n of rt |
| -// binds, as plan 1 lays them out. It reports false for a row with no |
| -// generation path (foreign, fenced, conflict). |
| +// binds, as rev 19 lays them out. It reports false for a row with no |
| +// generation path (foreign, fenced, conflict, and a flat row whose |
| +// generation path would not fit a unix socket path, genLayout). |
| func (b *ExecBackend) GenPaths(rt registry.Route, n uint64) (Gen, bool) { |
| p := b.pathsFor(rt) |
| var sock string |
| - switch p.layout { |
| + switch genLayout(p.layout, rt.Host) { |
| case LayoutFlat: |
| sock = b.flatGenSocket(rt.Host, n) |
| case LayoutNested: |
diff --git a/internal/activator/generations.go b/internal/activator/generations.go| index 6d499315c..fce7a7fce 100644 |
| --- a/internal/activator/generations.go |
| +++ b/internal/activator/generations.go |
| @@ -1,13 +1,16 @@ |
| // generations.go is the generation identity of zero-downtime deploys |
| -// (docs/superpowers/specs/2026-10-07-zero-downtime-deploys-design.md §7.1): |
| -// the process-wide counter, the bounded generation socket paths, the Gen |
| -// handle, and the dormant switch that keeps all of it off by default. |
| +// (docs/superpowers/specs/2026-10-07-zero-downtime-deploys-design.md §7.1, |
| +// as amended by 2026-10-13-zero-downtime-deploys-rev19-amendment.md): the |
| +// process-wide counter, the generation socket paths, the Gen handle, and the |
| +// dormant switch that keeps all of it off by default. |
| package activator |
| import ( |
| "context" |
| - "crypto/sha256" |
| - "encoding/hex" |
| + "errors" |
| + "log" |
| + "math" |
| + "os" |
| "path/filepath" |
| "strconv" |
| "sync/atomic" |
| @@ -50,7 +53,7 @@ func nextGen() uint64 { return genCounter.Add(1) } |
| // staged path. |
| type Gen struct { |
| N uint64 // process-wide, never reused (spec §7.1) |
| - Socket string // generation app socket (bounded path, spec §7.1) |
| + Socket string // generation app socket (spec §7.1, rev 19: the flag-off file name in a directory of its own) |
| Control string // Socket + ".control" |
| Build string // version name the generation runs |
| StagedPath string // immutable staged executable it exec'd (spec §7.5 step 3) |
| @@ -67,13 +70,21 @@ type Gen struct { |
| Worker bool |
| } |
| -// GenSocketDir is where flat generation sockets live: /run/carlos/g. |
| +// GenSocketDir is the root of the flat generation directories: |
| +// /run/carlos/g. Generation n's directory is /run/carlos/g/<n>. |
| const GenSocketDir = provision.SocketDir + "/g" |
| -// GenSocketPath is the bounded flat generation path (spec §7.1): |
| -// /run/carlos/g/<first 12 hex of sha256(host)>.<n>.sock. It is at most 52 |
| -// bytes (60 with ".control") whatever the hostname, because the hostname |
| -// never appears in it. |
| +// maxUnixPath is the longest path a unix socket address holds. |
| +const maxUnixPath = provision.MaxUnixPath |
| + |
| +// GenSocketPath is flat generation n's socket (zero-downtime spec §7.1 as |
| +// amended by rev 19, rule 1): /run/carlos/g/<n>/<host>.sock. The file name |
| +// is the flag-off one, because an app may work out its host from the file |
| +// name of its --socket argument, and a name the flag-off launch never gives |
| +// it can make it refuse to start (amadan #272, Gate F). Each generation gets |
| +// its own directory instead. The hostname is in the path, so its length is |
| +// bounded by the host's: flatGenFits decides whether a host has generation |
| +// paths at all (rule 4). |
| func GenSocketPath(host string, n uint64) string { |
| return genSocketPathIn(GenSocketDir, host, n) |
| } |
| @@ -81,21 +92,77 @@ func GenSocketPath(host string, n uint64) string { |
| // genSocketPathIn is GenSocketPath under another directory (a test's |
| // RunDirOverride). |
| func genSocketPathIn(dir, host string, n uint64) string { |
| - sum := sha256.Sum256([]byte(host)) |
| - return filepath.Join(dir, hex.EncodeToString(sum[:])[:12]+"."+strconv.FormatUint(n, 10)+".sock") |
| + return filepath.Join(dir, strconv.FormatUint(n, 10), host+".sock") |
| } |
| -// NestedGenSocket is the nested layout's generation socket: |
| -// /run/carlos/instances/<InstanceID>/app.<n>.sock (spec §7.1). The |
| -// identifier is bounded at 40 bytes, so this is at most 92 bytes (100 with |
| -// ".control"). |
| +// flatGenFits reports whether host's flat generation sockets fit a unix |
| +// socket address (provision.MaxUnixPath, 107 bytes) for every generation |
| +// number (rev 19, rule 4): the control socket, the longer of the pair, at |
| +// the longest number a generation can have. That admits hosts of up to 59 |
| +// bytes; the flag-off control socket already caps a flat host at 82. It |
| +// depends on the host alone, so a row never changes treatment as |
| +// the process-wide counter grows, and an attempt admitted as an overlap |
| +// never meets a generation that cannot bind. A host that does not fit is |
| +// handled as a foreign row is (genLayout): it keeps its canonical socket. |
| +// |
| +// It is measured against GenSocketDir even under a test's RunDirOverride, |
| +// so a test sees the decision a box makes. |
| +func flatGenFits(host string) bool { |
| + return len(GenSocketPath(host, math.MaxUint64)+".control") <= maxUnixPath |
| +} |
| + |
| +// genLayout is layout l of host's row as generations see it (rev 19, rule |
| +// 4): a flat row whose generation sockets would not fit a unix socket path |
| +// (flatGenFits) reads foreign, so it keeps its canonical socket and an |
| +// overlap on it runs as a hold, exactly as for a foreign row. Every other |
| +// layout is l. Classify and pathsFor are unchanged: the flag-off spawn, the |
| +// layout pass and every path but the generation socket's still read the row |
| +// as flat. |
| +func genLayout(l Layout, host string) Layout { |
| + if l == LayoutFlat && !flatGenFits(host) { |
| + return LayoutForeign |
| + } |
| + return l |
| +} |
| + |
| +// NestedGenSocket is the nested layout's generation socket (rev 19, rule |
| +// 1): /run/carlos/instances/<InstanceID>/g<n>/app.sock, the flag-off file |
| +// name in a directory of the generation's own. The identifier is bounded at |
| +// 40 bytes, so this is at most 93 bytes (101 with ".control") whatever n is. |
| func NestedGenSocket(host string, n uint64) string { |
| return nestedGenSocketIn(provision.InstanceRunDir(host), n) |
| } |
| // nestedGenSocketIn is NestedGenSocket under an already-resolved run dir. |
| func nestedGenSocketIn(runDir string, n uint64) string { |
| - return filepath.Join(runDir, "app."+strconv.FormatUint(n, 10)+".sock") |
| + return filepath.Join(runDir, "g"+strconv.FormatUint(n, 10), "app.sock") |
| +} |
| + |
| +// removeGenDir removes generation g's own socket directory once g's |
| +// termination is proven, if it is empty (rev 19, rule 3). It touches only a |
| +// directory named for g.N (flat: <n>, nested: g<n>) that holds g.Socket, so |
| +// a sibling's directory, the run dir and GenSocketDir itself are never |
| +// touched. A generation on its row's canonical socket (generations off, a |
| +// foreign row, a unit tenant) has no directory of its own and is skipped. A |
| +// directory that still holds a file (an app that left its socket behind) |
| +// stays: it is harmless, and the stale-file rule clears that file if the |
| +// number is ever used again. |
| +func removeGenDir(rt registry.Route, g Gen) { |
| + if g.N == 0 || !linksCanonical(rt, g) { |
| + return |
| + } |
| + dir := filepath.Dir(g.Socket) |
| + n := strconv.FormatUint(g.N, 10) |
| + if base := filepath.Base(dir); base != n && base != "g"+n { |
| + return |
| + } |
| + entries, err := os.ReadDir(dir) |
| + if err != nil || len(entries) != 0 { |
| + return |
| + } |
| + if err := os.Remove(dir); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + log.Printf("activator: %s: remove generation %d's socket directory: %v", rt.Host, g.N, err) |
| + } |
| } |
| // canonicalGen is the generation handle of a spawn that bound the route's |
diff --git a/internal/activator/generations_backend_test.go b/internal/activator/generations_backend_test.go| index d1cec5b28..a4dacf0e4 100644 |
| --- a/internal/activator/generations_backend_test.go |
| +++ b/internal/activator/generations_backend_test.go |
| @@ -105,14 +105,14 @@ func TestStartGenWithGenerationsOffBindsTheCanonicalSocket(t *testing.T) { |
| } |
| } |
| -// Flag on, flat: a 120-byte host binds the bounded generation socket and |
| -// its control socket, and only the generation's OWN stale file is removed. |
| -// The canonical path for this host would not even fit a socket address. |
| +// Flag on, flat: the host binds its generation socket, g/<n>/<host>.sock |
| +// (rev 19), and its control socket beside it, and only the generation's OWN |
| +// stale file is removed. |
| func TestStartGenFlatBindsItsOwnGenerationSocket(t *testing.T) { |
| requireUnixSockets(t) |
| b := testExecBackend(t) |
| b.RunDirOverride = shortRunDir(t) |
| - host := strings.Repeat("b", 113) + ".bab.io" // 120 bytes |
| + host := "shop.example.com" |
| stageServingLive(t, b, host) |
| rt := flatGenRoute(b, host) |
| if l, _ := b.Layout(rt); l != LayoutFlat { |
| @@ -120,10 +120,13 @@ func TestStartGenFlatBindsItsOwnGenerationSocket(t *testing.T) { |
| } |
| own := genSocketPathIn(filepath.Join(b.RunDirOverride, "g"), host, 9) |
| sibling := genSocketPathIn(filepath.Join(b.RunDirOverride, "g"), host, 8) |
| - if err := os.MkdirAll(filepath.Dir(own), 0o750); err != nil { |
| - t.Fatal(err) |
| + if want := filepath.Join(b.RunDirOverride, "g", "9", host+".sock"); own != want { |
| + t.Fatalf("own generation path %s, want %s", own, want) |
| } |
| for _, p := range []string{own, sibling} { |
| + if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil { |
| + t.Fatal(err) |
| + } |
| if err := os.WriteFile(p, []byte("stale"), 0o600); err != nil { |
| t.Fatal(err) |
| } |
| @@ -152,19 +155,20 @@ func TestStartGenFlatBindsItsOwnGenerationSocket(t *testing.T) { |
| } |
| } |
| -// Flag on, nested: the launch file names app.<n>.sock, the control env |
| -// follows it, and prepare removes only that generation's stale file. |
| -func TestStartGenNestedUsesAppNSock(t *testing.T) { |
| +// Flag on, nested: the launch file names g<n>/app.sock (rev 19), the |
| +// control env follows it, and prepare removes only that generation's stale |
| +// file. |
| +func TestStartGenNestedUsesItsOwnDirectory(t *testing.T) { |
| ctl := &fakeControl{inv: "inv1"} |
| unit := &fakeUnit{} |
| b, rt := nestedBackend(t, ctl, unit) |
| runDir := filepath.Dir(rt.Socket) |
| - own := filepath.Join(runDir, "app.9.sock") |
| - sibling := filepath.Join(runDir, "app.8.sock") |
| - if err := os.MkdirAll(runDir, 0o750); err != nil { |
| - t.Fatal(err) |
| - } |
| + own := filepath.Join(runDir, "g9", "app.sock") |
| + sibling := filepath.Join(runDir, "g8", "app.sock") |
| for _, p := range []string{rt.Socket, sibling, own} { |
| + if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil { |
| + t.Fatal(err) |
| + } |
| if err := os.WriteFile(p, []byte("stale"), 0o600); err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/activator/generations_test.go b/internal/activator/generations_test.go| index bb26d138d..36cf00518 100644 |
| --- a/internal/activator/generations_test.go |
| +++ b/internal/activator/generations_test.go |
| @@ -2,6 +2,7 @@ package activator |
| import ( |
| "math" |
| + "path/filepath" |
| "strings" |
| "testing" |
| @@ -20,48 +21,92 @@ func withGenerations(t *testing.T, on bool) { |
| t.Cleanup(func() { SetGenerations(prev) }) |
| } |
| -// The cross-plan review item this plan owns: a 120-byte hostname gets |
| -// generation and control sockets under the 108-byte sockaddr_un limit, in |
| -// both layouts, at the largest generation number a uint64 can hold. |
| -func TestGenerationSocketsFitSockaddrUnForLongHosts(t *testing.T) { |
| +// Rev 19, rule 4: a nested generation's socket pair fits a unix socket |
| +// address for any host and any generation number, because the instance |
| +// identifier is bounded. A flat one fits for every number exactly when |
| +// flatGenFits says so, and a flat host that does not fit has no generation |
| +// path at all. |
| +func TestGenerationSocketsFitSockaddrUn(t *testing.T) { |
| for _, host := range []string{ |
| "h", |
| - strings.Repeat("a", 110) + ".bab.io", // 117 bytes |
| - strings.Repeat("b", 113) + ".bab.io", // 120 bytes |
| - strings.Repeat("c", 253), |
| + "shop.example.com", |
| + strings.Repeat("a", 59-len(".example.com")) + ".example.com", // 59 bytes: the longest that fits |
| + strings.Repeat("b", 60-len(".example.com")) + ".example.com", // 60 bytes |
| + strings.Repeat("c", 113) + ".bab.io", // 120 bytes |
| + strings.Repeat("d", 253), |
| } { |
| + fits := flatGenFits(host) |
| + if want := len(host) <= 59; fits != want { |
| + t.Errorf("flatGenFits(%d-byte host) = %v, want %v", len(host), fits, want) |
| + } |
| for _, n := range []uint64{1, 42, math.MaxUint64} { |
| - flat := GenSocketPath(host, n) |
| - if got := len(flat + ".control"); got > 60 { |
| - t.Errorf("GenSocketPath(%d-byte host, %d)+.control = %d bytes, want at most 60 (spec §7.1)", len(host), n, got) |
| - } |
| nested := NestedGenSocket(host, n) |
| - for _, p := range []string{flat, flat + ".control", nested, nested + ".control"} { |
| + paths := []string{nested, nested + ".control"} |
| + if fits { |
| + flat := GenSocketPath(host, n) |
| + paths = append(paths, flat, flat+".control") |
| + } |
| + for _, p := range paths { |
| if len(p) > provision.MaxUnixPath { |
| t.Errorf("%q is %d bytes, over the %d a unix socket address holds", p, len(p), provision.MaxUnixPath) |
| } |
| } |
| } |
| + if !fits && len(GenSocketPath(host, math.MaxUint64)+".control") <= provision.MaxUnixPath { |
| + t.Errorf("%d-byte host refused, yet its longest generation control socket fits", len(host)) |
| + } |
| } |
| } |
| +// Rev 19, rule 1: a generation socket's file name is the flag-off one, in a |
| +// directory of the generation's own. |
| func TestGenSocketPathShape(t *testing.T) { |
| - got := GenSocketPath("bill.go.tito.io", 7) |
| - if !strings.HasPrefix(got, "/run/carlos/g/") || !strings.HasSuffix(got, ".7.sock") { |
| - t.Fatalf("GenSocketPath = %q, want /run/carlos/g/<h12>.7.sock", got) |
| + host := "shop.example.com" |
| + if got, want := GenSocketPath(host, 7), "/run/carlos/g/7/shop.example.com.sock"; got != want { |
| + t.Fatalf("GenSocketPath = %q, want %q", got, want) |
| } |
| - h12 := strings.TrimSuffix(strings.TrimPrefix(got, "/run/carlos/g/"), ".7.sock") |
| - if len(h12) != 12 || strings.Trim(h12, "0123456789abcdef") != "" { |
| - t.Fatalf("hash part %q is not 12 lower-case hex digits", h12) |
| + if got, want := filepath.Base(GenSocketPath(host, 7)), filepath.Base(provision.DefaultSocket(host)); got != want { |
| + t.Fatalf("flat generation file name %q, want the flag-off %q", got, want) |
| } |
| - if strings.Contains(got, "bill") { |
| - t.Fatalf("GenSocketPath %q embeds the hostname; the bound depends on it never doing so", got) |
| + if got, want := NestedGenSocket(host, 7), "/run/carlos/instances/"+provision.InstanceID(host)+"/g7/app.sock"; got != want { |
| + t.Fatalf("NestedGenSocket = %q, want %q", got, want) |
| } |
| - if GenSocketPath("bill.go.tito.io", 8) == got || GenSocketPath("other.go.tito.io", 7) == got { |
| - t.Fatal("generation paths collide across generations or hosts") |
| + if got, want := filepath.Base(NestedGenSocket(host, 7)), filepath.Base(provision.NestedSocket(host)); got != want { |
| + t.Fatalf("nested generation file name %q, want the flag-off %q", got, want) |
| } |
| - if got, want := NestedGenSocket("bill.go.tito.io", 7), "/run/carlos/instances/bill.go.tito.io/app.7.sock"; got != want { |
| - t.Fatalf("NestedGenSocket = %q, want %q", got, want) |
| + for _, pair := range [][2]string{ |
| + {GenSocketPath(host, 7), GenSocketPath(host, 8)}, |
| + {GenSocketPath(host, 7), GenSocketPath("other.example.com", 7)}, |
| + {NestedGenSocket(host, 7), NestedGenSocket(host, 8)}, |
| + {NestedGenSocket(host, 7), provision.NestedSocket(host)}, |
| + } { |
| + if pair[0] == pair[1] { |
| + t.Fatalf("generation paths collide: %q", pair[0]) |
| + } |
| + } |
| + if got := filepath.Dir(NestedGenSocket(host, 7)); filepath.Dir(got) != provision.InstanceRunDir(host) { |
| + t.Fatalf("nested generation directory %q is not directly in the run dir %q", got, provision.InstanceRunDir(host)) |
| + } |
| +} |
| + |
| +// Rev 19, rule 4: genLayout reads only an over-long flat row as foreign. |
| +func TestGenLayoutReadsAnOverLongFlatRowAsForeign(t *testing.T) { |
| + short, long := "shop.example.com", strings.Repeat("a", 60-len(".example.com"))+".example.com" |
| + for _, tc := range []struct { |
| + l Layout |
| + host string |
| + want Layout |
| + }{ |
| + {LayoutFlat, short, LayoutFlat}, |
| + {LayoutFlat, long, LayoutForeign}, |
| + {LayoutNested, long, LayoutNested}, |
| + {LayoutForeign, short, LayoutForeign}, |
| + {LayoutFenced, long, LayoutFenced}, |
| + {LayoutConflict, long, LayoutConflict}, |
| + } { |
| + if got := genLayout(tc.l, tc.host); got != tc.want { |
| + t.Errorf("genLayout(%s, %d-byte host) = %s, want %s", tc.l, len(tc.host), got, tc.want) |
| + } |
| } |
| } |
diff --git a/internal/activator/gensocket_rev19_test.go b/internal/activator/gensocket_rev19_test.go| new file mode 100644 |
| index 000000000..975120111 |
| --- /dev/null |
| +++ b/internal/activator/gensocket_rev19_test.go |
| @@ -0,0 +1,439 @@ |
| +package activator |
| + |
| +// Tests for the zero-downtime-deploys rev 19 amendment |
| +// (docs/superpowers/specs/2026-10-13-zero-downtime-deploys-rev19-amendment.md): |
| +// a generation's socket keeps the flag-off file name in a directory of its |
| +// own, a generation launch matches a flag-off launch, the directory goes |
| +// once the generation is proven gone, and a flat host too long for a |
| +// generation path keeps its canonical socket as a foreign row does. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "maps" |
| + "os" |
| + "path/filepath" |
| + "slices" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// goneProc is a process whose leader has exited. |
| +type goneProc struct{} |
| + |
| +func (goneProc) Stop(context.Context) error { return nil } |
| +func (goneProc) Exited() bool { return true } |
| + |
| +// envMap is an environment list ("K=V") as a map. |
| +func envMap(t *testing.T, env []string) map[string]string { |
| + t.Helper() |
| + m := make(map[string]string, len(env)) |
| + for _, kv := range env { |
| + k, v, ok := strings.Cut(kv, "=") |
| + if !ok { |
| + continue // a multi-line value's continuation in `env` output |
| + } |
| + m[k] = v |
| + } |
| + return m |
| +} |
| + |
| +// sameLaunch checks rule 2 for one layout: the two argvs differ only in |
| +// --socket's directory, and the two environments only in |
| +// CARLOS_CONTROL_SOCKET, which names each launch's own socket + ".control". |
| +func sameLaunch(t *testing.T, offArgv, genArgv []string, offEnv, genEnv map[string]string, offSock, genSock string) { |
| + t.Helper() |
| + i := slices.Index(offArgv, "--socket") |
| + j := slices.Index(genArgv, "--socket") |
| + if i < 0 || j != i || i+1 >= len(offArgv) || len(genArgv) != len(offArgv) { |
| + t.Fatalf("argv shapes differ: flag off %v, generation %v", offArgv, genArgv) |
| + } |
| + if offArgv[i+1] != offSock || genArgv[i+1] != genSock { |
| + t.Fatalf("--socket: flag off %q (want %q), generation %q (want %q)", offArgv[i+1], offSock, genArgv[i+1], genSock) |
| + } |
| + if offSock == genSock { |
| + t.Fatalf("the generation launch bound the canonical socket %s", offSock) |
| + } |
| + if a, b := filepath.Base(offArgv[i+1]), filepath.Base(genArgv[i+1]); a != b { |
| + t.Fatalf("--socket file name: flag off %q, generation %q; rule 2 wants the same", a, b) |
| + } |
| + rest := func(argv []string) []string { return slices.Delete(slices.Clone(argv), i+1, i+2) } |
| + if !slices.Equal(rest(offArgv), rest(genArgv)) { |
| + t.Fatalf("argv differs beyond --socket: flag off %v, generation %v", offArgv, genArgv) |
| + } |
| + if offEnv["CARLOS_CONTROL_SOCKET"] != offSock+".control" || genEnv["CARLOS_CONTROL_SOCKET"] != genSock+".control" { |
| + t.Fatalf("CARLOS_CONTROL_SOCKET: flag off %q, generation %q", offEnv["CARLOS_CONTROL_SOCKET"], genEnv["CARLOS_CONTROL_SOCKET"]) |
| + } |
| + off, gen := maps.Clone(offEnv), maps.Clone(genEnv) |
| + delete(off, "CARLOS_CONTROL_SOCKET") |
| + delete(gen, "CARLOS_CONTROL_SOCKET") |
| + if !maps.Equal(off, gen) { |
| + for k := range off { |
| + if gen[k] != off[k] { |
| + t.Errorf("env %s: flag off %q, generation %q", k, off[k], gen[k]) |
| + } |
| + } |
| + for k := range gen { |
| + if _, ok := off[k]; !ok { |
| + t.Errorf("env %s: only the generation launch has it (%q)", k, gen[k]) |
| + } |
| + } |
| + t.FailNow() |
| + } |
| + if off["CARLOS_HOST"] == "" { |
| + t.Fatal("neither launch carries CARLOS_HOST") |
| + } |
| +} |
| + |
| +// Rule 2, flat: a generation's exec has the flag-off argv and agent-owned |
| +// environment apart from the socket's directory and the control path. |
| +func TestFlatGenerationLaunchMatchesTheFlagOffLaunch(t *testing.T) { |
| + requireUnixSockets(t) |
| + b := testExecBackend(t) |
| + b.RunDirOverride = shortRunDir(t) |
| + host := "shop.example.com" |
| + argvPath, envPath := stageRecordingLiveFullEnv(t, b, host, "v1") |
| + rt := flatGenRoute(b, host) |
| + launch := func(n uint64, perGen bool) ([]string, map[string]string, string) { |
| + t.Helper() |
| + _ = os.Remove(argvPath) |
| + _ = os.Remove(envPath) |
| + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) |
| + defer cancel() |
| + var ( |
| + proc Proc |
| + g Gen |
| + ) |
| + done := make(chan struct{}) |
| + go func() { |
| + proc, g, _ = b.StartGen(ctx, rt, n, perGen) // the recorder never binds; a health error is expected |
| + close(done) |
| + }() |
| + argv := strings.Split(strings.TrimSpace(waitForFile(t, argvPath, 3*time.Second)), "\n") |
| + env := envMap(t, strings.Split(strings.TrimSpace(waitForFile(t, envPath, 3*time.Second)), "\n")) |
| + <-done |
| + if proc != nil { |
| + _ = proc.Stop(context.Background()) |
| + } |
| + return argv, env, g.Socket |
| + } |
| + offArgv, offEnv, offSock := launch(5, false) |
| + genArgv, genEnv, genSock := launch(6, true) |
| + if want := filepath.Join(b.RunDirOverride, "g", "6", host+".sock"); genSock != want { |
| + t.Fatalf("generation socket %s, want %s", genSock, want) |
| + } |
| + sameLaunch(t, offArgv, genArgv, offEnv, genEnv, rt.Socket, genSock) |
| + if offSock != rt.Socket { |
| + t.Fatalf("flag-off handle names %s, want the canonical %s", offSock, rt.Socket) |
| + } |
| + fi, err := os.Stat(filepath.Dir(genSock)) |
| + if err != nil || !fi.IsDir() || fi.Mode().Perm() != 0o750 { |
| + t.Fatalf("generation directory %s: %v, %v; want a 0750 directory", filepath.Dir(genSock), fi, err) |
| + } |
| +} |
| + |
| +// Rule 2, nested: the generation's launch file has the flag-off argv and |
| +// agent-owned environment apart from the socket's directory and the control |
| +// path, and prepare made the generation's own directory before the start. |
| +func TestNestedGenerationLaunchMatchesTheFlagOffLaunch(t *testing.T) { |
| + ctl := &fakeControl{} |
| + unit := &fakeUnit{} |
| + b, rt := nestedBackend(t, ctl, unit) |
| + launchFile := filepath.Join(provision.InstanceDir(b.DataDir, rt.Host), "launch.run.json") |
| + var ( |
| + serveAt string |
| + dirAtStart bool |
| + stop func() |
| + ) |
| + ctl.onStart = func() { |
| + fi, err := os.Stat(filepath.Dir(serveAt)) |
| + dirAtStart = err == nil && fi.IsDir() |
| + stop = serveOK(t, serveAt) |
| + } |
| + launch := func(n uint64, perGen bool, sock, inv string) instancectl.Launch { |
| + t.Helper() |
| + serveAt, dirAtStart = sock, false |
| + ctl.mu.Lock() |
| + ctl.inv = inv |
| + ctl.mu.Unlock() |
| + unit.set("active", inv, true, true) |
| + p, g, err := b.StartGen(context.Background(), rt, n, perGen) |
| + if err != nil { |
| + t.Fatalf("StartGen(%d, %v): %v", n, perGen, err) |
| + } |
| + if g.Socket != sock { |
| + t.Fatalf("handle socket %s, want %s", g.Socket, sock) |
| + } |
| + if !dirAtStart { |
| + t.Fatalf("%s did not exist when root was asked to start the unit", filepath.Dir(sock)) |
| + } |
| + raw, err := os.ReadFile(launchFile) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var l instancectl.Launch |
| + if err := json.Unmarshal(raw, &l); err != nil { |
| + t.Fatal(err) |
| + } |
| + unit.set("inactive", inv, false, false) |
| + waitFor(t, "the unit to exit", func() bool { return p.Exited() }) |
| + ctx, cancel := context.WithTimeout(context.Background(), time.Second) |
| + defer cancel() |
| + _ = p.Stop(ctx) |
| + if stop != nil { |
| + stop() |
| + stop = nil |
| + } |
| + return l |
| + } |
| + genSock := filepath.Join(filepath.Dir(rt.Socket), "g9", "app.sock") |
| + off := launch(8, false, rt.Socket, "inv1") |
| + gen := launch(9, true, genSock, "inv2") |
| + sameLaunch(t, off.Argv, gen.Argv, envMap(t, off.Env), envMap(t, gen.Env), rt.Socket, genSock) |
| +} |
| + |
| +// Rule 4: a flat host whose generation path would not fit a unix socket |
| +// address is handled as a foreign row is: no generation path, a generation |
| +// on its canonical socket, and an overlap that runs as a hold. |
| +func TestOverLongFlatHostKeepsItsCanonicalSocket(t *testing.T) { |
| + requireUnixSockets(t) |
| + b := testExecBackend(t) |
| + b.RunDirOverride = shortRunDir(t) |
| + host := strings.Repeat("a", 60-len(".example.com")) + ".example.com" // 60 bytes |
| + stageServingLive(t, b, host) |
| + rt := flatGenRoute(b, host) |
| + if l, _ := b.Layout(rt); l != LayoutFlat { |
| + t.Fatalf("fixture classifies %q, want flat", l) |
| + } |
| + if l := b.GenLayout(rt); l != LayoutForeign { |
| + t.Fatalf("GenLayout = %s, want foreign", l) |
| + } |
| + split := &SplitBackend{Exec: b} |
| + if l := split.GenLayout(rt); l != LayoutForeign { |
| + t.Fatalf("SplitBackend.GenLayout = %s, want foreign", l) |
| + } |
| + if g, ok := b.GenPaths(rt, 9); ok { |
| + t.Fatalf("GenPaths = %+v, true; want no generation path", g) |
| + } |
| + a := &Service{backend: b, entries: map[string]*actEntry{host: {route: rt}}} |
| + if m := a.EffectiveMode(host, ModeOverlap); m != ModeHold { |
| + t.Fatalf("EffectiveMode(overlap) = %s, want hold, as for a foreign row", m) |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + proc, g, err := b.StartGen(ctx, rt, 9, true) |
| + if err != nil { |
| + t.Fatalf("StartGen: %v", err) |
| + } |
| + defer proc.Stop(context.Background()) |
| + if g.Socket != rt.Socket || g.Control != rt.Socket+".control" { |
| + t.Fatalf("handle = %+v, want the canonical socket", g) |
| + } |
| + if !isSocket(rt.Socket) { |
| + t.Fatalf("%s is not a bound socket", rt.Socket) |
| + } |
| + if _, err := os.Stat(filepath.Join(b.RunDirOverride, "g")); !os.IsNotExist(err) { |
| + t.Fatalf("a generation directory was made for a host with no generation path (stat: %v)", err) |
| + } |
| + |
| + // The same host one byte shorter has generation paths. |
| + short := host[1:] |
| + if l := b.GenLayout(flatGenRoute(b, short)); l != LayoutFlat { |
| + t.Fatalf("GenLayout(%d-byte host) = %s, want flat", len(short), l) |
| + } |
| + if _, ok := b.GenPaths(flatGenRoute(b, short), 9); !ok { |
| + t.Fatalf("GenPaths(%d-byte host) has no generation path", len(short)) |
| + } |
| +} |
| + |
| +// Rule 3: once a generation's termination is proven, its own empty |
| +// directory goes. A directory its app left a socket in stays, a sibling's |
| +// directory is never touched (empty or not), and a generation on the |
| +// canonical socket touches nothing. |
| +func TestRetiredGenerationDirectoryIsRemovedAndASiblingsIsNot(t *testing.T) { |
| + root := t.TempDir() |
| + flat := registry.Route{Host: "shop.example.com", Socket: filepath.Join(root, "shop.example.com.sock")} |
| + nestedRun := filepath.Join(root, "instances", "shop.example.com") |
| + nested := registry.Route{Host: "shop.example.com", Socket: filepath.Join(nestedRun, "app.sock")} |
| + mk := func(dir string, files ...string) { |
| + t.Helper() |
| + if err := os.MkdirAll(dir, 0o750); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, f := range files { |
| + if err := os.WriteFile(filepath.Join(dir, f), nil, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + exists := func(p string) bool { _, err := os.Lstat(p); return err == nil } |
| + a := &Service{} |
| + |
| + for _, tc := range []struct { |
| + name string |
| + rt registry.Route |
| + own string // generation 9's directory |
| + siblings []string |
| + }{ |
| + {"flat", flat, filepath.Join(root, "g", "9"), []string{filepath.Join(root, "g", "8"), filepath.Join(root, "g", "10")}}, |
| + {"nested", nested, filepath.Join(nestedRun, "g9"), []string{filepath.Join(nestedRun, "g8"), filepath.Join(nestedRun, "g10")}}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + sock := filepath.Join(tc.own, filepath.Base(tc.rt.Socket)) |
| + g := Gen{N: 9, Socket: sock, Control: sock + ".control"} |
| + mk(tc.own) |
| + mk(tc.siblings[0], filepath.Base(tc.rt.Socket)) // a running sibling's socket |
| + mk(tc.siblings[1]) // an empty sibling |
| + mk(filepath.Dir(tc.rt.Socket), filepath.Base(tc.rt.Socket)) |
| + |
| + if a.genTerminated(tc.rt, g, &maintenanceProc{}) { |
| + t.Fatal("a running generation read as terminated") |
| + } |
| + if !exists(tc.own) { |
| + t.Fatal("the directory went before the termination proof") |
| + } |
| + |
| + mk(tc.own, filepath.Base(tc.rt.Socket)) // the app left its socket behind |
| + if !a.genTerminated(tc.rt, g, goneProc{}) { |
| + t.Fatal("an exited generation read as running") |
| + } |
| + if !exists(sock) { |
| + t.Fatal("the generation's own leftover socket was removed; only an empty directory goes") |
| + } |
| + |
| + if err := os.Remove(sock); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !a.genTerminated(tc.rt, g, goneProc{}) { |
| + t.Fatal("an exited generation read as running") |
| + } |
| + if exists(tc.own) { |
| + t.Fatalf("the retired generation's empty directory %s is still there", tc.own) |
| + } |
| + for _, s := range tc.siblings { |
| + if !exists(s) { |
| + t.Fatalf("a sibling's directory %s was removed", s) |
| + } |
| + } |
| + if !exists(tc.rt.Socket) { |
| + t.Fatal("the canonical socket was touched") |
| + } |
| + |
| + // A handleless generation proven gone is cleaned the same way. |
| + mk(tc.own) |
| + if !a.handlelessGenEmpty(tc.rt, g) { |
| + t.Fatal("a handleless generation with no child read as running") |
| + } |
| + if exists(tc.own) { |
| + t.Fatalf("the handleless generation's empty directory %s is still there", tc.own) |
| + } |
| + |
| + // A generation on the canonical socket has no directory of its |
| + // own: the run dir it sits in is never a candidate. |
| + canon := Gen{N: 9, Socket: tc.rt.Socket, Control: tc.rt.Socket + ".control"} |
| + if err := os.Remove(tc.rt.Socket); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, s := range tc.siblings { |
| + if err := os.RemoveAll(s); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if tc.name == "flat" { |
| + if err := os.Remove(filepath.Join(root, "g")); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if !a.genTerminated(tc.rt, canon, goneProc{}) { |
| + t.Fatal("an exited canonical generation read as running") |
| + } |
| + if !exists(filepath.Dir(tc.rt.Socket)) { |
| + t.Fatal("a canonical generation's run dir was removed") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Astra, review of 19c07b2b (P2 1): a flat host too long for a generation |
| +// path binds its canonical socket with perGen true, and a canonical link a |
| +// box left from the old naming must not fail that bind, as it does not on |
| +// a flag-off spawn or StartStaged's foreign arm. |
| +func TestOverLongFlatHostClearsALeftoverCanonicalLink(t *testing.T) { |
| + requireUnixSockets(t) |
| + b := testExecBackend(t) |
| + b.RunDirOverride = shortRunDir(t) |
| + host := strings.Repeat("a", 60-len(".example.com")) + ".example.com" // 60 bytes |
| + stageServingLive(t, b, host) |
| + rt := flatGenRoute(b, host) |
| + if err := os.Symlink("/run/carlos/g/0123456789ab.3.sock", rt.Socket); err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + proc, g, err := b.StartGen(ctx, rt, 9, true) |
| + if err != nil { |
| + t.Fatalf("StartGen over a leftover canonical link: %v", err) |
| + } |
| + defer proc.Stop(context.Background()) |
| + if g.Socket != rt.Socket { |
| + t.Fatalf("handle socket %s, want the canonical %s", g.Socket, rt.Socket) |
| + } |
| + if !isSocket(rt.Socket) { |
| + t.Fatalf("%s is not a bound socket: the leftover link was not cleared", rt.Socket) |
| + } |
| +} |
| + |
| +// Astra, review of 19c07b2b (P2 2): a failed wake's generation directory |
| +// goes once failedWakeStopped's whole proof holds, the host cgroup's |
| +// included where the generation had no child of its own, and never before. |
| +func TestFailedWakeRemovesItsDirectoryOnlyOnceProven(t *testing.T) { |
| + running := &sigtermIgnorer{} |
| + exited := &sigtermIgnorer{} |
| + exited.exited.Store(true) |
| + for _, row := range []struct { |
| + name string |
| + proc Proc |
| + killGen error // nil: the generation's own child was proven empty |
| + populated bool // the host cgroup still holds something |
| + want bool |
| + }{ |
| + {"leader still running", running, nil, false, false}, |
| + {"child not proven empty", exited, errors.New("kill gen: timeout"), false, false}, |
| + {"uncontained, host cgroup populated", exited, errNoGenContainment, true, false}, |
| + {"uncontained, host cgroup empty", exited, errNoGenContainment, false, true}, |
| + {"contained, child proven empty", exited, nil, true, true}, |
| + } { |
| + t.Run(row.name, func(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Hour) |
| + kb := &killGenBackend{fakeBackend: b, err: row.killGen} |
| + a.backend = &uncontainedStartBackend{unprovenStartBackend: &unprovenStartBackend{killGenBackend: kb}, populated: row.populated} |
| + root := t.TempDir() |
| + rt := testRoute("shop.example.com") |
| + rt.Socket = filepath.Join(root, "shop.example.com.sock") |
| + own := filepath.Join(root, "g", "4") |
| + sibling := filepath.Join(root, "g", "3") |
| + for _, d := range []string{own, sibling} { |
| + if err := os.MkdirAll(d, 0o750); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + g := Gen{N: 4, Socket: filepath.Join(own, "shop.example.com.sock")} |
| + g.Control = g.Socket + ".control" |
| + if got := a.failedWakeStopped(rt, g, row.proc, LayoutFlat); got != row.want { |
| + t.Fatalf("failedWakeStopped = %v, want %v", got, row.want) |
| + } |
| + if _, err := os.Stat(own); os.IsNotExist(err) != row.want { |
| + t.Fatalf("own directory removed = %v, want %v (stat: %v)", os.IsNotExist(err), row.want, err) |
| + } |
| + if _, err := os.Stat(sibling); err != nil { |
| + t.Fatalf("a sibling's empty directory was touched: %v", err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/activator/opset.go b/internal/activator/opset.go| index d9ca3b9fe..a14dc1514 100644 |
| --- a/internal/activator/opset.go |
| +++ b/internal/activator/opset.go |
| @@ -214,12 +214,17 @@ func (a *Service) stopMember(e *actEntry, m *genMember) bool { |
| // process handle (a member that never spawned, a failed wake whose start |
| // returned none): its own child, proven empty through KillGen. A number of |
| // 0 names no child, so it is trivially proven for a member (nothing was |
| -// ever asked to start under it). |
| +// ever asked to start under it). Once proven, the generation's own socket |
| +// directory goes if it is empty (removeGenDir, rev 19 rule 3). |
| func (a *Service) handlelessGenEmpty(rt registry.Route, g Gen) bool { |
| if g.N == 0 { |
| return true |
| } |
| - return a.genChildEmpty(rt, &genMember{gen: g}) |
| + if !a.genChildEmpty(rt, &genMember{gen: g}) { |
| + return false |
| + } |
| + removeGenDir(rt, g) |
| + return true |
| } |
| // genTerminated is the ONE termination proof for a generation (spec §7.3, |
| @@ -228,6 +233,12 @@ func (a *Service) handlelessGenEmpty(rt registry.Route, g Gen) bool { |
| // set asks it: a member's stop, the reaper, the current generation's stop, |
| // a pending stop's finish, a failed wake and its retained retry. A nil |
| // process never ran. |
| +// |
| +// Once it holds for a process that ran, the generation's own socket |
| +// directory goes if it is empty (removeGenDir, rev 19 rule 3). Its socket |
| +// is gone by then unless the app left it behind, and the agent's control |
| +// listener closed with the stop. A nil process proves nothing about a |
| +// directory, so it is left; a leftover is harmless (rule 3). |
| func (a *Service) genTerminated(rt registry.Route, g Gen, p Proc) bool { |
| if p == nil { |
| if g.Worker { |
| @@ -240,7 +251,11 @@ func (a *Service) genTerminated(rt registry.Route, g Gen, p Proc) bool { |
| if !p.Exited() { |
| return false |
| } |
| - return a.genChildEmpty(rt, &genMember{gen: g, proc: p}) |
| + if !a.genChildEmpty(rt, &genMember{gen: g, proc: p}) { |
| + return false |
| + } |
| + removeGenDir(rt, g) |
| + return true |
| } |
| // genChildEmpty proves m's generation child empty, after its leader has |
| @@ -766,6 +781,9 @@ func genStartUnproven(err error) bool { |
| // Astra IMPL-3 F6). Its leader's exit, all genTerminated can see there, |
| // leaves whatever the leader forked running in carlos-exec@<host>. launched |
| // is the layout the wake's launch used (wokenInstance.launchLayout). |
| +// |
| +// Once the whole proof holds, and never before, the generation's own empty |
| +// socket directory goes (removeGenDir, rev 19 rule 3). |
| func (a *Service) failedWakeStopped(rt registry.Route, g Gen, p Proc, launched Layout) bool { |
| if p == nil && g.N == 0 { |
| log.Printf("activator: %s: failed wake left no process handle and no generation number; it cannot be proven stopped, so its lease stays held", rt.Host) |
| @@ -780,9 +798,14 @@ func (a *Service) failedWakeStopped(rt registry.Route, g Gen, p Proc, launched L |
| if !proven { |
| return false |
| } |
| - if uncontained { |
| - return a.uncontainedHostEmpty(rt, g, launched) |
| + if uncontained && !a.uncontainedHostEmpty(rt, g, launched) { |
| + return false |
| } |
| + // The whole proof holds, the host cgroup's included where it is needed: |
| + // the generation's own socket directory goes if it is empty (rev 19 |
| + // rule 3), as genTerminated does for every other path (Astra, review of |
| + // 19c07b2b: a failed wake bypassed it, and directories piled up). |
| + removeGenDir(rt, g) |
| return true |
| } |
diff --git a/internal/activator/prepare.go b/internal/activator/prepare.go| index 7fb9b6f40..644ac20e0 100644 |
| --- a/internal/activator/prepare.go |
| +++ b/internal/activator/prepare.go |
| @@ -38,9 +38,11 @@ func (b *ExecBackend) prepare(rt registry.Route, role instancectl.Role) (instanc |
| } |
| // prepareAt is prepare for a run role whose app socket is sock: rt.Socket, |
| -// or a generation socket (StartGen, zero-downtime spec §7.1). Only the |
| -// file at sock is removed, never a sibling generation's, and the control |
| -// listener binds sock + ".control". The seed and sidecar roles ignore sock. |
| +// or a generation socket (StartGen, zero-downtime spec §7.1). A generation |
| +// socket's own directory, g<n> in the run dir, is made first (rev 19, rule |
| +// 3). Only the file at sock is removed, never a sibling generation's, and |
| +// the control listener binds sock + ".control". The seed and sidecar roles |
| +// ignore sock. |
| // gen is the generation unit being prepared (0 for the original templates): |
| // it keys the log socket and the exit records cleared (zero-downtime spec |
| // §7.6). |
| @@ -107,6 +109,17 @@ func (b *ExecBackend) prepareAt(rt registry.Route, role instancectl.Role, sock s |
| // no member-facing diagnosis at all, unlike the flat path's #129 |
| // handling, which at least turns the bind failure into a named |
| // error (memberSafeWakeReason). |
| + // |
| + // A generation's socket lives in a directory of its own (rev 19, |
| + // rule 1), made here with the run dir's mode. It inherits the run |
| + // dir's setgid carlos-sockets group, and it sits inside the run dir |
| + // the generation unit binds, so the app (uid carlos, as this agent |
| + // is) can bind its socket there. |
| + if dir := filepath.Dir(sock); dir != p.runDir { |
| + if err := os.MkdirAll(dir, 0o750|os.ModeSetgid); err != nil { |
| + return p, nil, nil, nil, err |
| + } |
| + } |
| _ = os.Remove(sock) |
| ctl = b.listenControl(rt, sock+".control", p.envFile, gen) |
| } |
diff --git a/internal/activator/swap.go b/internal/activator/swap.go| index 6834fd490..321ca794f 100644 |
| --- a/internal/activator/swap.go |
| +++ b/internal/activator/swap.go |
| @@ -456,11 +456,11 @@ func (a *Service) swapOverlap(ctx context.Context, gb SwapBackend, at Attempt, n |
| return errSwapKind |
| } |
| // A foreign row keeps its canonical socket (plan 1), so two |
| - // generations of it cannot coexist. It may still hold-swap. |
| - if c, ok := a.backend.(Classifier); ok { |
| - if l, _ := c.Layout(e.route); l != LayoutFlat && l != LayoutNested { |
| - return errSwapLayout |
| - } |
| + // generations of it cannot coexist. It may still hold-swap. So |
| + // can a flat row whose generation path would not fit (rev 19 |
| + // rule 4), which swapLayout reads as foreign. |
| + if l, ok := a.swapLayout(e.route); ok && l != LayoutFlat && l != LayoutNested { |
| + return errSwapLayout |
| } |
| // A parent still marked raised with no member is a restore that |
| // failed and awaits the sweep's retry (final review F11): an overlap |
diff --git a/internal/activator/swaphold.go b/internal/activator/swaphold.go| index 2acde9891..abe3f19dd 100644 |
| --- a/internal/activator/swaphold.go |
| +++ b/internal/activator/swaphold.go |
| @@ -214,10 +214,8 @@ func (a *Service) holdAdmitLocked(e *actEntry, at Attempt, newest func(string) u |
| } |
| // The layouts that can run a staged generation: flat, foreign on its |
| // canonical socket, and nested (root mounts the staged build, Task 9). |
| - if c, ok := a.backend.(Classifier); ok { |
| - if l, _ := c.Layout(e.route); l != LayoutFlat && l != LayoutForeign && l != LayoutNested { |
| - return errSwapLayout |
| - } |
| + if l, ok := a.swapLayout(e.route); ok && l != LayoutFlat && l != LayoutForeign && l != LayoutNested { |
| + return errSwapLayout |
| } |
| if len(e.ops.members) > 0 { |
| return errSwapBusy |
| @@ -479,12 +477,10 @@ func (a *Service) startPinnedAt(ctx context.Context, rt registry.Route, pin Gen, |
| } |
| // foreignRow reports whether host's row has the foreign layout (a |
| -// hand-set socket path), which keeps its canonical socket. |
| +// hand-set socket path), which keeps its canonical socket. A flat row whose |
| +// generation path would not fit a unix socket path reads foreign here too |
| +// (swapLayout, rev 19 rule 4). |
| func (a *Service) foreignRow(host string) bool { |
| - c, ok := a.backend.(Classifier) |
| - if !ok { |
| - return false |
| - } |
| a.mu.Lock() |
| e := a.entries[host] |
| var rt registry.Route |
| @@ -495,6 +491,22 @@ func (a *Service) foreignRow(host string) bool { |
| if e == nil { |
| return false |
| } |
| - l, _ := c.Layout(rt) |
| - return l == LayoutForeign |
| + l, ok := a.swapLayout(rt) |
| + return ok && l == LayoutForeign |
| +} |
| + |
| +// swapLayout is rt's layout for the swap's decisions: the backend's |
| +// GenLayout where it has one (a flat row whose generation path would not |
| +// fit reads foreign, rev 19 rule 4), else its Classifier layout. ok is false |
| +// for a backend that can say neither, whose rows are never refused on |
| +// layout here. |
| +func (a *Service) swapLayout(rt registry.Route) (Layout, bool) { |
| + if g, ok := a.backend.(genLayouter); ok { |
| + return g.GenLayout(rt), true |
| + } |
| + if c, ok := a.backend.(Classifier); ok { |
| + l, _ := c.Layout(rt) |
| + return l, true |
| + } |
| + return "", false |
| } |
diff --git a/internal/instanceexec/exec_test.go b/internal/instanceexec/exec_test.go| index fc1165991..3d94b51db 100644 |
| --- a/internal/instanceexec/exec_test.go |
| +++ b/internal/instanceexec/exec_test.go |
| @@ -163,7 +163,7 @@ func TestExecLaunchSplitsAGenerationInstance(t *testing.T) { |
| dir := filepath.Join(data, "instances", host) |
| // The legacy launch file is a decoy: a generation must never read it. |
| writeLaunch(t, dir, instancectl.RoleRun, instancectl.Launch{Schema: 1, Host: host, Argv: []string{"/opt/carlos/live/" + host, "legacy"}}) |
| - raw, err := json.Marshal(instancectl.Launch{Schema: 1, Host: host, Argv: []string{"/opt/carlos/live/" + host, "--socket", "/run/carlos/instances/" + host + "/app.7.sock"}}) |
| + raw, err := json.Marshal(instancectl.Launch{Schema: 1, Host: host, Argv: []string{"/opt/carlos/live/" + host, "--socket", "/run/carlos/instances/" + host + "/g7/app.sock"}}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -174,7 +174,7 @@ func TestExecLaunchSplitsAGenerationInstance(t *testing.T) { |
| if err := execLaunch(data, instancectl.RoleRun, instancectl.GenInstance(host, 7), c.exec); err != nil { |
| t.Fatalf("execLaunch: %v", err) |
| } |
| - if c.calls != 1 || len(c.argv) != 3 || c.argv[2] != "/run/carlos/instances/"+host+"/app.7.sock" { |
| + if c.calls != 1 || len(c.argv) != 3 || c.argv[2] != "/run/carlos/instances/"+host+"/g7/app.sock" { |
| t.Errorf("exec = %+v", c) |
| } |
| } |
diff --git a/internal/provision/plan14_legacy_identity_test.go b/internal/provision/plan14_legacy_identity_test.go| index c0d52099d..532c38815 100644 |
| --- a/internal/provision/plan14_legacy_identity_test.go |
| +++ b/internal/provision/plan14_legacy_identity_test.go |
| @@ -248,3 +248,72 @@ func TestPlan14Zddr3OverlayPreservesLegacySeal(t *testing.T) { |
| t.Fatal("unapproved legacy fingerprint accepted") |
| } |
| } |
| + |
| +// Census ruling zddr4 (zero-downtime deploys rev 19, Gate F, #272): the |
| +// generation socket overlay, loaded after hotfix281. |
| +func TestPlan14Zddr4OverlayPreservesLegacySeal(t *testing.T) { |
| + root := censusRoot(t) |
| + inv := rootReadInventory{Entries: map[string]classifiedRootRead{}, Coverage: map[string]string{}} |
| + for i, name := range []string{"plan14-root-reads.tsv", "plan14-root-reads-moves.tsv", "plan14-root-reads-merge.tsv", "plan14-root-reads-fix1.tsv", "plan14-root-reads-fix2.tsv", "plan14-root-reads-merge3.tsv", "plan14-root-reads-fix4.tsv", "plan14-root-reads-fix5.tsv", "plan14-root-reads-fix6.tsv", "plan14-root-reads-zddr3.tsv", "plan14-root-reads-hotfix281.tsv"} { |
| + if err := loadRootReadInventory(root+"/internal/provision/testdata/"+name, &inv, i > 0); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if errs := checkRootReadLegacy(inv, "6da50872e15f7f396d23e5b1b2e044a73cd9e5c07d85c8e22252b0a1a5304bbd"); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| + original := make(map[string]bool, len(inv.Legacy)) |
| + for hash := range inv.Legacy { |
| + original[hash] = true |
| + } |
| + if err := loadRootReadInventory(root+"/internal/provision/testdata/plan14-root-reads-zddr4.tsv", &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + for hash := range original { |
| + if !inv.Legacy[hash] { |
| + t.Fatalf("zddr4 removed original freeze %s", hash) |
| + } |
| + } |
| + if len(inv.Legacy) != len(original)+14 { |
| + t.Fatal("census ruling zddr4 permits exactly fourteen new frozen declarations") |
| + } |
| + // Census ruling zddr4 seal: ten changed activator rows (startGenFrom, |
| + // GenPaths, StartStaged, genTerminated, handlelessGenEmpty, |
| + // failedWakeStopped, prepareAt, swapOverlap, foreignRow, |
| + // holdAdmitLocked) plus four new ones (ExecBackend.GenLayout, |
| + // SplitBackend.GenLayout, removeGenDir, swapLayout). |
| + const seal = "e736a5e2652cc546db02c5a385ae7da9fa8a4313d3064f6b431642431974d65d" |
| + if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| + units := []string{ |
| + "internal/activator/backend_exec.go:ExecBackend.startGenFrom", |
| + "internal/activator/genbackend.go:ExecBackend.GenPaths", |
| + "internal/activator/genbackend.go:ExecBackend.StartStaged", |
| + "internal/activator/opset.go:Service.genTerminated", |
| + "internal/activator/opset.go:Service.handlelessGenEmpty", |
| + "internal/activator/opset.go:Service.failedWakeStopped", |
| + "internal/activator/prepare.go:ExecBackend.prepareAt", |
| + "internal/activator/swap.go:Service.swapOverlap", |
| + "internal/activator/swaphold.go:Service.foreignRow", |
| + "internal/activator/swaphold.go:Service.holdAdmitLocked", |
| + "internal/activator/genbackend.go:ExecBackend.GenLayout", |
| + "internal/activator/genbackend.go:SplitBackend.GenLayout", |
| + "internal/activator/generations.go:removeGenDir", |
| + "internal/activator/swaphold.go:Service.swapLayout", |
| + } |
| + const boundary = "Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before." |
| + for _, key := range units { |
| + e, ok := inv.Entries[key] |
| + if !ok || e.Class != "legacy" || e.Task != "accepted-legacy" || !strings.HasPrefix(e.Basis, "accepted-legacy;rev3=census-ruling-zddr4;") || !strings.HasSuffix(e.Basis, boundary) { |
| + t.Fatalf("wrong zddr4 disposition for %s: %+v", key, e) |
| + } |
| + if f, ok := inv.Frozen[key]; !ok || rootReadLegacyFingerprint(f) != rootReadLegacyFingerprint(e) { |
| + t.Fatalf("%s is not frozen as classified", key) |
| + } |
| + } |
| + inv.Legacy[strings.Repeat("0", 64)] = true |
| + if errs := checkRootReadLegacy(inv, seal); len(errs) == 0 { |
| + t.Fatal("unapproved legacy fingerprint accepted") |
| + } |
| +} |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 0033203f3..fe8dbe6b7 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -658,6 +658,16 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| t.Fatal(err) |
| } |
| seal = "6da50872e15f7f396d23e5b1b2e044a73cd9e5c07d85c8e22252b0a1a5304bbd" |
| + if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-zddr4.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Census ruling zddr4 (zero-downtime rev 19, Gate F, #272): ten changed |
| + // activator rows and four new ones (generation socket directories) add |
| + // fourteen frozen declarations. |
| + seal = "e736a5e2652cc546db02c5a385ae7da9fa8a4313d3064f6b431642431974d65d" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/testdata/plan14-root-reads-zddr4.tsv b/internal/provision/testdata/plan14-root-reads-zddr4.tsv| new file mode 100644 |
| index 000000000..88aca72bc |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-zddr4.tsv |
| @@ -0,0 +1,33 @@ |
| +# Plan 14 census ruling zddr4 (zero-downtime deploys rev 19 amendment, Gate F, amadan #272): generation sockets keep the flag-off file name in a directory of their own. The changed and new activator units are accepted legacy because they stay agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. |
| +# An over-long flat host counted as foreign for generation decisions is an agent-side deny and fits that boundary. If any of these units ever runs as root or reads outside that boundary it stops being legacy and needs the Plan 14 owner's ruling. Earlier overlays are immutable. |
| +@legacy dc698b892747c4be0ec17b83d180edf2da154c92fc224abc3ab391666db7ef9f internal/activator/backend_exec.go ExecBackend.startGenFrom legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; the generation socket path is chosen through genLayout on the same row's Host, an over-long flat host is logged and keeps its canonical socket as a foreign row does, and the leftover canonical link is cleared for any spawn on the canonical socket. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. registry:Host@1084,registry:Host@1709,registry:Host@1871,registry:Host@380,registry:Host@436,registry:Host@685,registry:Socket@1126,registry:Socket@1615,registry:Socket@1745,registry:Socket@328,whole:Route@1933,whole:Route@315,whole:Route@960 9d1788ec9700865c6046e1c466b1e06e654d00e605d2b35297cc192bc731861c |
| +internal/activator/backend_exec.go ExecBackend.startGenFrom legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; the generation socket path is chosen through genLayout on the same row's Host, an over-long flat host is logged and keeps its canonical socket as a foreign row does, and the leftover canonical link is cleared for any spawn on the canonical socket. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. registry:Host@1084,registry:Host@1709,registry:Host@1871,registry:Host@380,registry:Host@436,registry:Host@685,registry:Socket@1126,registry:Socket@1615,registry:Socket@1745,registry:Socket@328,whole:Route@1933,whole:Route@315,whole:Route@960 9d1788ec9700865c6046e1c466b1e06e654d00e605d2b35297cc192bc731861c |
| +@legacy 795ff1d9aa340c52bd2b08601cb090c9e318c93eee0b7eaa70d0996f1541ed51 internal/activator/genbackend.go ExecBackend.GenPaths legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; the same Classify layout is passed through genLayout on the same row's Host, so an over-long flat host has no generation path, as a foreign row has none. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. registry:Host@140,registry:Host@194,whole:Route@91 34d63dd48fd86a2e3d4ea4bf7d7022bb5e39eab9e820b8a1df12887fd0ccc2f4 |
| +internal/activator/genbackend.go ExecBackend.GenPaths legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; the same Classify layout is passed through genLayout on the same row's Host, so an over-long flat host has no generation path, as a foreign row has none. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. registry:Host@140,registry:Host@194,whole:Route@91 34d63dd48fd86a2e3d4ea4bf7d7022bb5e39eab9e820b8a1df12887fd0ccc2f4 |
| +@legacy 36678d5f13f63fa11f515dd04da937963c16f589701aee2829ac1e1aa39dc722 internal/activator/genbackend.go ExecBackend.StartStaged legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; the same layout switch goes through genLayout on the same row's Host, so an over-long flat host takes the existing foreign arm on its canonical socket. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. registry:Host@1701,registry:Host@221,registry:Host@276,registry:Host@398,registry:Host@898,registry:Socket@1067,registry:Socket@1078,registry:Socket@809,registry:Socket@960,whole:Route@1467,whole:Route@156,whole:Route@189,whole:Route@456,whole:Route@956 2561db67e458818736964d27fe18b2b16027f6b4ed523bc23f9bf99c2700ad18 |
| +internal/activator/genbackend.go ExecBackend.StartStaged legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; the same layout switch goes through genLayout on the same row's Host, so an over-long flat host takes the existing foreign arm on its canonical socket. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. registry:Host@1701,registry:Host@221,registry:Host@276,registry:Host@398,registry:Host@898,registry:Socket@1067,registry:Socket@1078,registry:Socket@809,registry:Socket@960,whole:Route@1467,whole:Route@156,whole:Route@189,whole:Route@456,whole:Route@956 2561db67e458818736964d27fe18b2b16027f6b4ed523bc23f9bf99c2700ad18 |
| +@legacy 03bddaccf2f9196bf5f3d9ebeb9f2c1ae27721d52ed43cadf7754ec0247e82f4 internal/activator/opset.go Service.genTerminated legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; after the unchanged termination proof holds, removeGenDir removes the generation's own empty socket directory. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. whole:Route@266,whole:Route@368,whole:Route@435 ce408258825ac8650517a004d9d1868912c0b3ab9920f04c132060a5564bd496 |
| +internal/activator/opset.go Service.genTerminated legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; after the unchanged termination proof holds, removeGenDir removes the generation's own empty socket directory. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. whole:Route@266,whole:Route@368,whole:Route@435 ce408258825ac8650517a004d9d1868912c0b3ab9920f04c132060a5564bd496 |
| +@legacy df0ed90064b7ae08e98bf26cae8752828d8cf2525b1d5acf9ffb99fd89bbbe5f internal/activator/opset.go Service.handlelessGenEmpty legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; after the unchanged KillGen proof holds, removeGenDir removes the generation's own empty socket directory. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. whole:Route@123,whole:Route@181 9ab9046c4eaa787968a51629564812a11f9b16f79d6b2ed48e1b6259127d0f82 |
| +internal/activator/opset.go Service.handlelessGenEmpty legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; after the unchanged KillGen proof holds, removeGenDir removes the generation's own empty socket directory. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. whole:Route@123,whole:Route@181 9ab9046c4eaa787968a51629564812a11f9b16f79d6b2ed48e1b6259127d0f82 |
| +@legacy 3f25557f06cf9a8d84cb79f405766afdb355108faeca9337ae953563d43368b3 internal/activator/opset.go Service.failedWakeStopped legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; after the unchanged proof holds, the host-cgroup proof included where it is needed, removeGenDir removes the generation's own empty socket directory. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. registry:Host@266,whole:Route@514,whole:Route@622,whole:Route@959 ce0575660e2528b04edb7d69de3fb8cda9761182431629b6a591e63d153ee433 |
| +internal/activator/opset.go Service.failedWakeStopped legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; after the unchanged proof holds, the host-cgroup proof included where it is needed, removeGenDir removes the generation's own empty socket directory. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. registry:Host@266,whole:Route@514,whole:Route@622,whole:Route@959 ce0575660e2528b04edb7d69de3fb8cda9761182431629b6a591e63d153ee433 |
| +@legacy 364c06e030c985f9071eb6240813c5a26915e83e2fbf3154a49a92cb7299df0c internal/activator/prepare.go ExecBackend.prepareAt legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; a generation socket's own g<n> directory inside the instance run dir is made, 0750 setgid, before the unchanged stale-file removal. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. file:ReadDir@1842,registry:Host@1035,registry:Host@1280,registry:Host@4027,registry:Host@4188,registry:Host@443,registry:Host@940,whole:Route@1113,whole:Route@1356,whole:Route@197,whole:Route@3532,whole:Route@3871,whole:Route@4061 87013e6a3295e78f1ca06f34698dedf87a548ac211d5d9099e1f876e9df5bfb4 |
| +internal/activator/prepare.go ExecBackend.prepareAt legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; a generation socket's own g<n> directory inside the instance run dir is made, 0750 setgid, before the unchanged stale-file removal. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. file:ReadDir@1842,registry:Host@1035,registry:Host@1280,registry:Host@4027,registry:Host@4188,registry:Host@443,registry:Host@940,whole:Route@1113,whole:Route@1356,whole:Route@197,whole:Route@3532,whole:Route@3871,whole:Route@4061 87013e6a3295e78f1ca06f34698dedf87a548ac211d5d9099e1f876e9df5bfb4 |
| +@legacy 4bd1a38bcae29e6b5861d4ce33e54cf8b00f7ab94383f84f293d104117f72293 internal/activator/swap.go Service.swapOverlap legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; the overlap admission's layout check reads the same entry route through swapLayout instead of Classifier.Layout, so an over-long flat host is refused as a foreign row is (an agent-side deny). Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. registry:Kind@1902,whole:Route@1904,whole:Route@2228,whole:Route@2631,whole:Route@2649,whole:Route@3244,whole:Route@3292,whole:Route@3807,whole:Route@5291,whole:Route@7681,whole:Route@9215,whole:Route@9222,whole:Route@9262 b4f0c2b27d9b66bd4ce34f0bb36d41ab663be4b3bf0818eecdb9f4b9705a8c42 |
| +internal/activator/swap.go Service.swapOverlap legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; the overlap admission's layout check reads the same entry route through swapLayout instead of Classifier.Layout, so an over-long flat host is refused as a foreign row is (an agent-side deny). Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. registry:Kind@1902,whole:Route@1904,whole:Route@2228,whole:Route@2631,whole:Route@2649,whole:Route@3244,whole:Route@3292,whole:Route@3807,whole:Route@5291,whole:Route@7681,whole:Route@9215,whole:Route@9222,whole:Route@9262 b4f0c2b27d9b66bd4ce34f0bb36d41ab663be4b3bf0818eecdb9f4b9705a8c42 |
| +@legacy 034d3f17d51fdc2f6eba5388983c25029ec69362530807afb04183298bc7ebbe internal/activator/swaphold.go Service.foreignRow legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; the same entry route is read through swapLayout instead of Classifier.Layout, so an over-long flat host counts as foreign and its overlap runs as a hold (an agent-side deny). Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. whole:Route@124,whole:Route@131,whole:Route@211 202e99f9b25f5d72b055610ce6c5702115a192bc5e3eb8dccf2ae13ec2ec6782 |
| +internal/activator/swaphold.go Service.foreignRow legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; the same entry route is read through swapLayout instead of Classifier.Layout, so an over-long flat host counts as foreign and its overlap runs as a hold (an agent-side deny). Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. whole:Route@124,whole:Route@131,whole:Route@211 202e99f9b25f5d72b055610ce6c5702115a192bc5e3eb8dccf2ae13ec2ec6782 |
| +@legacy 74cc7260b1589d9ce1008dff776047452059a23b491d59b78b8085a939f7d529 internal/activator/swaphold.go Service.holdAdmitLocked legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; the same entry route's layout is read through swapLayout instead of Classifier.Layout; the accepted layouts are unchanged. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. registry:Backing@216,registry:Kind@186,whole:Route@188,whole:Route@218,whole:Route@454 d799942b4dd82aff68e3cd893a5454b4064cccabc8c223cd00b01cce8c94c27f |
| +internal/activator/swaphold.go Service.holdAdmitLocked legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: changed legacy row; the same entry route's layout is read through swapLayout instead of Classifier.Layout; the accepted layouts are unchanged. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. registry:Backing@216,registry:Kind@186,whole:Route@188,whole:Route@218,whole:Route@454 d799942b4dd82aff68e3cd893a5454b4064cccabc8c223cd00b01cce8c94c27f |
| +@legacy 0ad95a7feaff196ab33517d84c7093216e99a50cd8949139784c733bf063a1b7 internal/activator/genbackend.go ExecBackend.GenLayout legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: new unit; ExecBackend.Layout of the row passed through genLayout on its Host, a pure length check on a path under /run/carlos. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. registry:Host@103,whole:Route@78 006f4773612ecc5331b82dcb68d2d7ccae12ef1ebf112c4969bacab1286b64ae |
| +internal/activator/genbackend.go ExecBackend.GenLayout legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: new unit; ExecBackend.Layout of the row passed through genLayout on its Host, a pure length check on a path under /run/carlos. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. registry:Host@103,whole:Route@78 006f4773612ecc5331b82dcb68d2d7ccae12ef1ebf112c4969bacab1286b64ae |
| +@legacy e13b5ef5bb44d76fc8461aa39deb429642ebd786b0a59201f0525f3e22472a88 internal/activator/genbackend.go SplitBackend.GenLayout legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: new unit; forwards an exec row to the exec half's GenLayout and reads a unit row's Backing as SplitBackend.Layout already does. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. file:Exec@116,registry:Backing@65,whole:Route@165,whole:Route@194 446df7024a611a62293f8a296b51631ab90251874b7e475daf0c3912b21dc624 |
| +internal/activator/genbackend.go SplitBackend.GenLayout legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: new unit; forwards an exec row to the exec half's GenLayout and reads a unit row's Backing as SplitBackend.Layout already does. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. file:Exec@116,registry:Backing@65,whole:Route@165,whole:Route@194 446df7024a611a62293f8a296b51631ab90251874b7e475daf0c3912b21dc624 |
| +@legacy 5ecb1a3f92c17838124aa0f6e4e33e63d02c07e0e875ad23c4d786b75e014411 internal/activator/generations.go removeGenDir legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: new unit; reads the agent's own generation socket directory (ReadDir) to remove it only when empty, and the row's Host only for a log line; only a directory named for the generation's number that holds its socket is a candidate. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. file:ReadDir@254,registry:Host@470,whole:Route@78 f61d1d2caab7cf3b16428a7f052b1b8266135532569a8a239415e66f75f1a459 |
| +internal/activator/generations.go removeGenDir legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: new unit; reads the agent's own generation socket directory (ReadDir) to remove it only when empty, and the row's Host only for a log line; only a directory named for the generation's number that holds its socket is a candidate. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. file:ReadDir@254,registry:Host@470,whole:Route@78 f61d1d2caab7cf3b16428a7f052b1b8266135532569a8a239415e66f75f1a459 |
| +@legacy f4db0317506a91218701894b9343de98ba491519066006172485f45cd6fff4ba internal/activator/swaphold.go Service.swapLayout legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: new unit; passes an entry route to the backend's GenLayout, else to its Classifier.Layout, as the callers did before. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. whole:Route@129,whole:Route@203 a18de2da8d0bfa931af0ff6d65f1095abadf5271ef753eba385aba3aab2d3c46 |
| +internal/activator/swaphold.go Service.swapLayout legacy accepted-legacy accepted-legacy;rev3=census-ruling-zddr4;basis=Census ruling zddr4: new unit; passes an entry route to the backend's GenLayout, else to its Classifier.Layout, as the callers did before. Boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree; no root decision; no registry, /data, release or claim read beyond what the unit read before. whole:Route@129,whole:Route@203 a18de2da8d0bfa931af0ff6d65f1095abadf5271ef753eba385aba3aab2d3c46 |
| +# Envelopes, census ruling zddr4: each changed root source envelope below is accepted only because the units inside it are the ones listed above; boundary: agent-side, running as carlos; generation socket directories under the agent's own /run/carlos tree. |
| +@coverage cmd/zddprobe 7caf15d5b0663091108482a70ed71d1970eff947b0fe1860e560dd3822f0e675 |
| +@coverage internal/activator 000f17d496e5791e7404952f0f4236a7cb3b02306042e3dc03149369a2878cf7 |