instanceexec: the advisory exit record, written O_EXCL from systemd's four variables (plan 2, task 3)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YZ1ZvWj7naf73GjTX8MBv5
2 files changed,
+165
−0
internal/instanceexec/exit.go+87 −0internal/instanceexec/exit_test.go+78 −0
diff --git a/internal/instanceexec/exit.go b/internal/instanceexec/exit.go| new file mode 100644 |
| index 000000000..930933c6e |
| --- /dev/null |
| +++ b/internal/instanceexec/exit.go |
| @@ -0,0 +1,87 @@ |
| +package instanceexec |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "regexp" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/cliflag" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/instancefs" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// invocationShape is systemd's 128-bit invocation id as 32 hex digits. The |
| +// id becomes part of a file name, so anything else is refused before it can |
| +// name a path. |
| +var invocationShape = regexp.MustCompile(`^[0-9a-f]{32}$`) |
| + |
| +// RunExit is `carlos instance-exit <role> <iid>`, the ExecStopPost= of every |
| +// template unit. It runs inside the namespace as carlos with systemd's |
| +// $INVOCATION_ID, $SERVICE_RESULT, $EXIT_CODE and $EXIT_STATUS, and leaves |
| +// the advisory exit record in the instance's own run directory (design |
| +// §6.5). It never fails the unit: a stop that could not be recorded is |
| +// still a stop, and the agent proves termination from unit state and the |
| +// cgroup, not from this file. |
| +func RunExit(args []string) { |
| + fs := flag.NewFlagSet("instance-exit", flag.ExitOnError) |
| + runDir := fs.String("run-dir", provision.SocketDir, "run directory holding instances/<id>") |
| + fs.Usage = cliflag.Usage(fs, "usage: carlos instance-exit <run|seed|sidecar> <instance-id>") |
| + _ = fs.Parse(args) |
| + if fs.NArg() != 2 { |
| + fs.Usage() |
| + os.Exit(2) |
| + } |
| + role, ok := instancectl.ParseRole(fs.Arg(0)) |
| + if !ok { |
| + log.Printf("instance-exit: role %q is not run, seed or sidecar", fs.Arg(0)) |
| + return |
| + } |
| + if err := writeExitRecord(*runDir, role, fs.Arg(1), os.Getenv, time.Now()); err != nil { |
| + log.Printf("instance-exit: %v", err) |
| + } |
| +} |
| + |
| +// 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 { |
| + inv := env("INVOCATION_ID") |
| + if inv == "" { |
| + return nil |
| + } |
| + if !invocationShape.MatchString(inv) { |
| + return fmt.Errorf("INVOCATION_ID %q is not 32 hex digits", inv) |
| + } |
| + rec := instancectl.ExitRecord{ |
| + Invocation: inv, |
| + Result: env("SERVICE_RESULT"), |
| + Code: env("EXIT_CODE"), |
| + Status: env("EXIT_STATUS"), |
| + At: now.UTC(), |
| + } |
| + raw, err := json.MarshalIndent(rec, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + raw = append(raw, '\n') |
| + dir := filepath.Join(runDir, "instances", iid) |
| + f, err := instancefs.OpenBeneath(dir, instancectl.ExitRecordName(role, inv), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) |
| + if err != nil { |
| + if errors.Is(err, os.ErrExist) { |
| + return fmt.Errorf("record for invocation %s already exists", inv) |
| + } |
| + return err |
| + } |
| + if _, err := f.Write(raw); err != nil { |
| + f.Close() |
| + return err |
| + } |
| + return f.Close() |
| +} |
diff --git a/internal/instanceexec/exit_test.go b/internal/instanceexec/exit_test.go| new file mode 100644 |
| index 000000000..e3c8183d0 |
| --- /dev/null |
| +++ b/internal/instanceexec/exit_test.go |
| @@ -0,0 +1,78 @@ |
| +//go:build linux |
| + |
| +package instanceexec |
| + |
| +import ( |
| + "encoding/json" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/instancectl" |
| +) |
| + |
| +func envOf(m map[string]string) func(string) string { |
| + return func(k string) string { return m[k] } |
| +} |
| + |
| +func TestWriteExitRecordWritesTheFourVariables(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, 9, 11, 12, 0, 0, 0, time.UTC) |
| + env := envOf(map[string]string{"INVOCATION_ID": "deadbeefdeadbeefdeadbeefdeadbeef", "SERVICE_RESULT": "exit-code", "EXIT_CODE": "exited", "EXIT_STATUS": "3"}) |
| + if err := writeExitRecord(run, instancectl.RoleRun, iid, env, now); err != nil { |
| + t.Fatalf("writeExitRecord: %v", err) |
| + } |
| + b, err := os.ReadFile(filepath.Join(run, "instances", iid, "exit.run.deadbeefdeadbeefdeadbeefdeadbeef.json")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var rec instancectl.ExitRecord |
| + if err := json.Unmarshal(b, &rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Invocation != "deadbeefdeadbeefdeadbeefdeadbeef" || rec.Result != "exit-code" || rec.Code != "exited" || rec.Status != "3" || !rec.At.Equal(now) { |
| + t.Errorf("record = %+v", rec) |
| + } |
| + // O_EXCL: a second write for the same invocation is refused and the |
| + // first record stands. |
| + env2 := envOf(map[string]string{"INVOCATION_ID": "deadbeefdeadbeefdeadbeefdeadbeef", "SERVICE_RESULT": "success", "EXIT_CODE": "exited", "EXIT_STATUS": "0"}) |
| + if err := writeExitRecord(run, instancectl.RoleRun, iid, env2, now); err == nil { |
| + t.Error("second write for the same invocation succeeded") |
| + } |
| + b2, _ := os.ReadFile(filepath.Join(run, "instances", iid, "exit.run.deadbeefdeadbeefdeadbeefdeadbeef.json")) |
| + if string(b2) != string(b) { |
| + t.Error("first record was overwritten") |
| + } |
| +} |
| + |
| +func TestWriteExitRecordWithoutInvocationIsANoop(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) |
| + } |
| + if err := writeExitRecord(run, instancectl.RoleSeed, iid, envOf(nil), time.Now()); err != nil { |
| + t.Fatalf("no INVOCATION_ID: %v (must not fail the unit's stop)", err) |
| + } |
| + entries, _ := os.ReadDir(filepath.Join(run, "instances", iid)) |
| + if len(entries) != 0 { |
| + t.Errorf("wrote %v without an invocation id", entries) |
| + } |
| +} |
| + |
| +func TestWriteExitRecordRefusesAnInvocationThatIsNotAName(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) |
| + } |
| + env := envOf(map[string]string{"INVOCATION_ID": "../../etc/x", "SERVICE_RESULT": "success"}) |
| + if err := writeExitRecord(run, instancectl.RoleRun, iid, env, time.Now()); err == nil { |
| + t.Error("an invocation id with a path separator was accepted") |
| + } |
| +} |