zdd plan 2 task 8: trampoline and exit writer split <iid>:<n>
Implement generation instance string parsing in the trampoline and exit writer. The instance string format <iid>:<n> is split to read generation-keyed launch files and write generation-keyed exit records. Legacy instance strings without colon continue to work unchanged. Adds launchTarget and recordTarget helper functions to route the trampoline to the correct launch file based on whether the instance string contains a generation. Only run units support generations; seed and sidecar units reject generation strings. TDD: failing tests verify the implementation before the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+121
−4
internal/instanceexec/exec.go+44 −2internal/instanceexec/exec_test.go+49 −0internal/instanceexec/exit.go+6 −2internal/instanceexec/exit_test.go+22 −0
diff --git a/internal/instanceexec/exec.go b/internal/instanceexec/exec.go| index 9b2adfb93..a52dbe6db 100644 |
| --- a/internal/instanceexec/exec.go |
| +++ b/internal/instanceexec/exec.go |
| @@ -69,9 +69,13 @@ func RunExec(args []string) { |
| // in the file. The file's host must also match the instance the unit was |
| // started for, so a rewritten file can execute only what the tenant could |
| // execute anyway: its own binary with an argv and env it chose. |
| -func execLaunch(dataDir string, role instancectl.Role, iid string, execFn func(argv0 string, argv, env []string) error) error { |
| +func execLaunch(dataDir string, role instancectl.Role, inst string, execFn func(argv0 string, argv, env []string) error) error { |
| + iid, launchName, err := launchTarget(role, inst) |
| + if err != nil { |
| + return err |
| + } |
| dir := filepath.Join(dataDir, "instances", iid) |
| - raw, err := instancefs.ReadBeneath(dir, instancectl.LaunchFileName(role)) |
| + raw, err := instancefs.ReadBeneath(dir, launchName) |
| if err != nil { |
| if errors.Is(err, instancefs.ErrSymlink) || errors.Is(err, instancefs.ErrEscapes) { |
| return fmt.Errorf("%w: %w", errSymlinkOrEscape, err) |
| @@ -108,3 +112,41 @@ func execLaunch(dataDir string, role instancectl.Role, iid string, execFn func(a |
| } |
| return execFn(argv0, l.Argv, l.Env) |
| } |
| + |
| +// launchTarget is the trampoline's split of the unit's instance string |
| +// (zero-downtime spec §7.6): "<iid>" for the three original templates, |
| +// "<iid>:<n>" for the generation template, whose launch file is keyed by |
| +// generation. Only a run unit has generations. |
| +func launchTarget(role instancectl.Role, inst string) (iid, name string, err error) { |
| + if !strings.Contains(inst, ":") { |
| + return inst, instancectl.LaunchFileName(role), nil |
| + } |
| + iid, n, err := splitGeneration(role, inst) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + return iid, instancectl.GenLaunchFileName(n), nil |
| +} |
| + |
| +// recordTarget is launchTarget for the exit record. |
| +func recordTarget(role instancectl.Role, inst, invocation string) (iid, name string, err error) { |
| + if !strings.Contains(inst, ":") { |
| + return inst, instancectl.ExitRecordName(role, invocation), nil |
| + } |
| + iid, n, err := splitGeneration(role, inst) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + return iid, instancectl.GenExitRecordName(n, invocation), nil |
| +} |
| + |
| +func splitGeneration(role instancectl.Role, inst string) (string, uint64, error) { |
| + if role != instancectl.RoleRun { |
| + return "", 0, fmt.Errorf("instance %q: only a run unit has generations", inst) |
| + } |
| + iid, n, ok := instancectl.ParseGenInstance(inst) |
| + if !ok { |
| + return "", 0, fmt.Errorf("instance %q is not <instance-id>:<generation>", inst) |
| + } |
| + return iid, n, nil |
| +} |
diff --git a/internal/instanceexec/exec_test.go b/internal/instanceexec/exec_test.go| index 75250e002..3dbd9d89b 100644 |
| --- a/internal/instanceexec/exec_test.go |
| +++ b/internal/instanceexec/exec_test.go |
| @@ -156,3 +156,52 @@ func TestExecLaunchRefusesAHostThatIsNotThisInstance(t *testing.T) { |
| t.Errorf("host match (long): calls = %d; want 1", c2.calls) |
| } |
| } |
| + |
| +func TestExecLaunchSplitsAGenerationInstance(t *testing.T) { |
| + data := t.TempDir() |
| + host := "bill.example.com" |
| + 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"}}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, instancectl.GenLaunchFileName(7)), raw, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + var c captured |
| + 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" { |
| + t.Errorf("exec = %+v", c) |
| + } |
| +} |
| + |
| +func TestExecLaunchRefusesMalformedOrMisplacedGenerations(t *testing.T) { |
| + data := t.TempDir() |
| + host := "bill.example.com" |
| + dir := filepath.Join(data, "instances", host) |
| + if err := os.MkdirAll(dir, 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + other, _ := json.Marshal(instancectl.Launch{Schema: 1, Host: "other.example.com", Argv: []string{"/opt/carlos/live/other.example.com"}}) |
| + if err := os.WriteFile(filepath.Join(dir, instancectl.GenLaunchFileName(7)), other, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, tc := range []struct { |
| + role instancectl.Role |
| + inst string |
| + }{ |
| + {instancectl.RoleRun, host + ":0"}, {instancectl.RoleRun, host + ":07"}, |
| + {instancectl.RoleRun, ":7"}, {instancectl.RoleRun, host + ":"}, |
| + {instancectl.RoleSidecar, host + ":7"}, {instancectl.RoleSeed, host + ":7"}, |
| + {instancectl.RoleRun, host + ":7"}, // the file names another host |
| + } { |
| + var c captured |
| + if err := execLaunch(data, tc.role, tc.inst, c.exec); err == nil || c.calls != 0 { |
| + t.Errorf("execLaunch(%s, %q) = %v after %d execs; want a refusal", tc.role, tc.inst, err, c.calls) |
| + } |
| + } |
| +} |
diff --git a/internal/instanceexec/exit.go b/internal/instanceexec/exit.go| index cf32a4a04..7b25fb7e7 100644 |
| --- a/internal/instanceexec/exit.go |
| +++ b/internal/instanceexec/exit.go |
| @@ -51,7 +51,7 @@ func RunExit(args []string) { |
| // writeExitRecord writes exit.<role>.<invocation>.json with O_EXCL into |
| // <runDir>/instances/<iid>. A missing invocation id is a no-op (the |
| // variables are systemd's to provide); a malformed one is an error. |
| -func writeExitRecord(runDir string, role instancectl.Role, iid string, env func(string) string, now time.Time) error { |
| +func writeExitRecord(runDir string, role instancectl.Role, inst string, env func(string) string, now time.Time) error { |
| inv := env("INVOCATION_ID") |
| if inv == "" { |
| return nil |
| @@ -59,6 +59,10 @@ func writeExitRecord(runDir string, role instancectl.Role, iid string, env func( |
| if !invocationShape.MatchString(inv) { |
| return fmt.Errorf("INVOCATION_ID %q is not 32 hex digits", inv) |
| } |
| + iid, name, err := recordTarget(role, inst, inv) |
| + if err != nil { |
| + return err |
| + } |
| rec := instancectl.ExitRecord{ |
| Invocation: inv, |
| Result: env("SERVICE_RESULT"), |
| @@ -76,7 +80,7 @@ func writeExitRecord(runDir string, role instancectl.Role, iid string, env func( |
| // systemd, which is diagnostic detail about the instance's own process, |
| // not something every reader of the instance directory needs (final |
| // review, plan 2, item 6). |
| - f, err := instancefs.OpenBeneath(dir, instancectl.ExitRecordName(role, inv), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) |
| + f, err := instancefs.OpenBeneath(dir, name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) |
| if err != nil { |
| if errors.Is(err, os.ErrExist) { |
| return fmt.Errorf("record for invocation %s already exists", inv) |
diff --git a/internal/instanceexec/exit_test.go b/internal/instanceexec/exit_test.go| index de580d64d..217f62bfe 100644 |
| --- a/internal/instanceexec/exit_test.go |
| +++ b/internal/instanceexec/exit_test.go |
| @@ -82,3 +82,25 @@ func TestWriteExitRecordRefusesAnInvocationThatIsNotAName(t *testing.T) { |
| t.Error("an invocation id with a path separator was accepted") |
| } |
| } |
| + |
| +func TestWriteExitRecordKeysAGenerationRecordByGeneration(t *testing.T) { |
| + run := t.TempDir() |
| + iid := "h.example.com" |
| + if err := os.MkdirAll(filepath.Join(run, "instances", iid), 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) |
| + env := envOf(map[string]string{"INVOCATION_ID": "deadbeefdeadbeefdeadbeefdeadbeef", "SERVICE_RESULT": "success", "EXIT_CODE": "exited", "EXIT_STATUS": "0"}) |
| + if err := writeExitRecord(run, instancectl.RoleRun, instancectl.GenInstance(iid, 7), env, now); err != nil { |
| + t.Fatalf("writeExitRecord: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(run, "instances", iid, "exit.gen.7.deadbeefdeadbeefdeadbeefdeadbeef.json")); err != nil { |
| + t.Errorf("generation record missing: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(run, "instances", iid, "exit.run.deadbeefdeadbeefdeadbeefdeadbeef.json")); !os.IsNotExist(err) { |
| + t.Errorf("a generation wrote a legacy record: %v", err) |
| + } |
| + if err := writeExitRecord(run, instancectl.RoleSidecar, iid+":7", env, now); err == nil { |
| + t.Error("a sidecar generation was accepted") |
| + } |
| +} |