carlos / platform Public

layout: the plan from the host name and the exclusive, validated journal (plan 4, task 2)

Builds internal/layout: PlanFor derives every flat/nested artifact and
endpoint from dataDir, socketDir and host alone, with no filesystem
access, and the O_EXCL journal at /data/layout records what a pass did
so recovery can validate against a freshly re-derived plan rather than
trusting anything on disk.

Applies amendments R5 (two more optional artifacts, litestream-shadow
and corrupt, appended after socket so earlier indices keep their
meaning), R8 (both socket endpoints come from PlanFor's own socketDir
argument, never the compiled-in provision.SocketDir/DefaultSocket/
NestedSocket), R18 (no dead code kept to satisfy the compiler; the
FIFO half of the symlink/FIFO refusal lives in a linux-only test
file), and C5 (JournalPresent, the one "is this host fenced by an
in-flight move" predicate later tasks will share).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 87ed86d5883fd0dc27488f5d6616ebef41ebfebd parent 25245ff
7 files changed, +763 −0
  • internal/layout/journal.go +209 −0
  • internal/layout/journal_linux.go +16 −0
  • internal/layout/journal_linux_test.go +46 −0
  • internal/layout/journal_other.go +13 −0
  • internal/layout/journal_test.go +133 −0
  • internal/layout/plan.go +186 −0
  • internal/layout/plan_test.go +160 −0
diff --git a/internal/layout/journal.go b/internal/layout/journal.go
new file mode 100644
index 000000000..14eb1c961
--- /dev/null
+++ b/internal/layout/journal.go
@@ -0,0 +1,209 @@
+package layout
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// Phase is how far a move got. files: sources are being renamed; row: the
+// files are at their destinations and the row is being swapped; done: the
+// row is swapped and only the journal remains.
+type Phase string
+
+const (
+ PhaseFiles Phase = "files"
+ PhaseRow Phase = "row"
+ PhaseDone Phase = "done"
+)
+
+// Journal is /data/layout/<host>.json (design §8.2 step 6). It records
+// presence so recovery can tell an optional artifact that was absent from
+// one that was lost (R2-7). It is NOT an instruction (§3.3): Validate
+// re-derives both endpoints from the host and the mode and refuses a
+// journal that names anything else.
+type Journal struct {
+ Host string `json:"host"`
+ Mode Mode `json:"mode"`
+ From Endpoint `json:"from"`
+ To Endpoint `json:"to"`
+ Artifacts []JournalArtifact `json:"artifacts"`
+ Phase Phase `json:"phase"`
+ WrittenAt string `json:"written_at"`
+}
+
+// JournalArtifact records one artifact's presence at the time the journal
+// was written. The path itself is not stored: it is re-derived by
+// PlanFor from the host and mode on every read, so a journal can never be
+// used to redirect a rename anywhere PlanFor itself would not have chosen.
+type JournalArtifact struct {
+ Name string `json:"name"`
+ Present bool `json:"present"`
+}
+
+// ErrJournalExists is WriteJournal's answer when a journal is already on
+// disk for the host.
+var ErrJournalExists = errors.New("layout: a journal already exists for this host")
+
+// journalMax bounds how much of a journal file ReadJournal will read. A
+// journal is a handful of paths and booleans; anything past this is either
+// corrupt or something else entirely.
+const journalMax = 64 << 10
+
+func journalDir(dataDir string) string { return filepath.Join(dataDir, "layout") }
+
+// WriteJournal creates the journal O_EXCL. An existing file, whoever wrote
+// it, is a refusal: the pass never overwrites a record of a move it did not
+// make.
+func WriteJournal(dataDir string, j *Journal) error {
+ if err := os.MkdirAll(journalDir(dataDir), 0o700); err != nil {
+ return err
+ }
+ if j.WrittenAt == "" {
+ j.WrittenAt = time.Now().UTC().Format(time.RFC3339)
+ }
+ raw, err := json.MarshalIndent(j, "", " ")
+ if err != nil {
+ return err
+ }
+ f, err := os.OpenFile(provision.LayoutJournalPath(dataDir, j.Host), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if errors.Is(err, os.ErrExist) {
+ return ErrJournalExists
+ }
+ if err != nil {
+ return err
+ }
+ if _, err := f.Write(append(raw, '\n')); err != nil {
+ f.Close()
+ return err
+ }
+ return f.Close()
+}
+
+// ReadJournal reads the host's journal without following a symlink and
+// without blocking on a special file: /data/layout is agent-owned, but a
+// flat tenant shares the agent's uid during the transition and can plant
+// things there.
+func ReadJournal(dataDir, host string) (*Journal, bool, error) {
+ path := provision.LayoutJournalPath(dataDir, host)
+ f, err := os.OpenFile(path, os.O_RDONLY|syscallNofollow|syscallNonblock, 0)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, false, nil
+ }
+ if err != nil {
+ return nil, false, err
+ }
+ defer f.Close()
+ fi, err := f.Stat()
+ if err != nil {
+ return nil, false, err
+ }
+ if !fi.Mode().IsRegular() {
+ return nil, false, fmt.Errorf("layout: %s is not a regular file", path)
+ }
+ raw, err := io.ReadAll(io.LimitReader(f, journalMax+1))
+ if err != nil {
+ return nil, false, err
+ }
+ if len(raw) > journalMax {
+ return nil, false, fmt.Errorf("layout: %s is larger than %d bytes", path, journalMax)
+ }
+ var j Journal
+ if err := json.Unmarshal(raw, &j); err != nil {
+ return nil, false, fmt.Errorf("layout: %s: %w", path, err)
+ }
+ return &j, true, nil
+}
+
+// UpdatePhase rewrites the journal with a new phase through a temp file and
+// rename in the same directory, so a crash leaves either the old phase or
+// the new one, never a torn file.
+func UpdatePhase(dataDir string, j *Journal, p Phase) error {
+ j.Phase = p
+ raw, err := json.MarshalIndent(j, "", " ")
+ if err != nil {
+ return err
+ }
+ dir := journalDir(dataDir)
+ tmp, err := os.CreateTemp(dir, "."+j.Host+".tmp-*")
+ if err != nil {
+ return err
+ }
+ name := tmp.Name()
+ if _, err := tmp.Write(append(raw, '\n')); err != nil {
+ tmp.Close()
+ os.Remove(name)
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ os.Remove(name)
+ return err
+ }
+ if err := os.Rename(name, provision.LayoutJournalPath(dataDir, j.Host)); err != nil {
+ os.Remove(name)
+ return err
+ }
+ return nil
+}
+
+// RemoveJournal deletes the host's journal. Removing an absent journal is
+// not an error: callers reach this after a move is already complete, and a
+// second call (a retried recovery, say) must be idempotent.
+func RemoveJournal(dataDir, host string) error {
+ err := os.Remove(provision.LayoutJournalPath(dataDir, host))
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return err
+}
+
+// JournalPresent reports whether a journal exists for host, without
+// reading it. Later tasks use this everywhere a caller needs to know a
+// host is "fenced" by an in-flight move but has no reason to read the
+// journal's contents (design §8.1's LayoutFenced, SyncExec's skip, the
+// litestream fenced hook). An Lstat error other than "not exist" (a
+// permission problem, say) is treated as present: silently proceeding
+// past a journal this process cannot even stat is worse than refusing.
+func JournalPresent(dataDir, host string) bool {
+ _, err := os.Lstat(provision.LayoutJournalPath(dataDir, host))
+ return !errors.Is(err, os.ErrNotExist)
+}
+
+// Validate is the recovery check of design §8.2: the journal's endpoints
+// must be exactly what PlanFor derives for its host and mode, and the row
+// must sit at one of them. It returns whether the row is at `from` (the
+// CAS has not run) or at `to` (it has).
+func Validate(j *Journal, dataDir, socketDir string, row registry.Route) (atFrom bool, err error) {
+ if j.Host != row.Host {
+ return false, fmt.Errorf("layout: journal is for %s, row is %s", j.Host, row.Host)
+ }
+ p, err := PlanFor(dataDir, socketDir, j.Host, j.Mode)
+ if err != nil {
+ return false, err
+ }
+ if j.From != p.From || j.To != p.To {
+ return false, fmt.Errorf("layout: %s: journal endpoints %+v -> %+v are not the derived %+v -> %+v; not acting", j.Host, j.From, j.To, p.From, p.To)
+ }
+ if len(j.Artifacts) != len(artifactNames) {
+ return false, fmt.Errorf("layout: %s: journal lists %d artifacts, expected %d", j.Host, len(j.Artifacts), len(artifactNames))
+ }
+ for i, a := range j.Artifacts {
+ if a.Name != artifactNames[i] {
+ return false, fmt.Errorf("layout: %s: journal artifact %d is %q, expected %q", j.Host, i, a.Name, artifactNames[i])
+ }
+ }
+ switch (Endpoint{DB: row.DBPath, Socket: row.Socket}) {
+ case j.From:
+ return true, nil
+ case j.To:
+ return false, nil
+ }
+ return false, fmt.Errorf("layout: %s: row holds db %q socket %q, at neither journal endpoint; not acting", j.Host, row.DBPath, row.Socket)
+}
diff --git a/internal/layout/journal_linux.go b/internal/layout/journal_linux.go
new file mode 100644
index 000000000..0f8aae45b
--- /dev/null
+++ b/internal/layout/journal_linux.go
@@ -0,0 +1,16 @@
+//go:build linux
+
+package layout
+
+import "golang.org/x/sys/unix"
+
+// syscallNofollow and syscallNonblock make ReadJournal refuse a symlink or
+// a special file planted at the journal path instead of following it or
+// blocking on it (a flat tenant shares the agent's uid during the
+// transition). Only meaningful on the platform the agent actually runs on;
+// see journal_other.go for why the build still needs a definition
+// everywhere.
+const (
+ syscallNofollow = unix.O_NOFOLLOW
+ syscallNonblock = unix.O_NONBLOCK
+)
diff --git a/internal/layout/journal_linux_test.go b/internal/layout/journal_linux_test.go
new file mode 100644
index 000000000..a98d23a52
--- /dev/null
+++ b/internal/layout/journal_linux_test.go
@@ -0,0 +1,46 @@
+//go:build linux
+
+package layout
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "golang.org/x/sys/unix"
+)
+
+// TestReadJournalRefusesASymlinkAndAFIFO_FIFO is the FIFO half of
+// TestReadJournalRefusesASymlinkAndAFIFO (R18): /data/layout is agent-owned,
+// but a flat tenant shares the agent's uid during the transition and can
+// plant a FIFO there in place of a journal. ReadJournal must refuse it
+// rather than block on the open. Run with a timeout so a regression here
+// fails the test instead of wedging it.
+func TestReadJournalRefusesASymlinkAndAFIFO_FIFO(t *testing.T) {
+ dd := t.TempDir()
+ dir := filepath.Join(dd, "layout")
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := unix.Mkfifo(filepath.Join(dir, "h.example.com.json"), 0o600); err != nil {
+ t.Fatalf("Mkfifo: %v", err)
+ }
+ done := make(chan error, 1)
+ go func() {
+ _, _, err := ReadJournal(dd, "h.example.com")
+ done <- err
+ }()
+ select {
+ case err := <-done:
+ if err == nil {
+ t.Error("a FIFO planted as a journal was read")
+ }
+ if errors.Is(err, os.ErrNotExist) {
+ t.Errorf("FIFO reported as absent rather than refused: %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("ReadJournal blocked on a FIFO instead of refusing it")
+ }
+}
diff --git a/internal/layout/journal_other.go b/internal/layout/journal_other.go
new file mode 100644
index 000000000..fa6e246c8
--- /dev/null
+++ b/internal/layout/journal_other.go
@@ -0,0 +1,13 @@
+//go:build !linux
+
+package layout
+
+// syscallNofollow and syscallNonblock are no-ops off Linux, so the package
+// builds on darwin and windows (the agent itself only ever runs on Linux;
+// this keeps cross-compiled builds of the rest of the binary working). No
+// golang.org/x/sys/unix import here: that package does not build for
+// windows, and this file must.
+const (
+ syscallNofollow = 0
+ syscallNonblock = 0
+)
diff --git a/internal/layout/journal_test.go b/internal/layout/journal_test.go
new file mode 100644
index 000000000..4d9747499
--- /dev/null
+++ b/internal/layout/journal_test.go
@@ -0,0 +1,133 @@
+package layout
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// presence9 is a presence vector in artifactNames order: db, db-wal, db-shm,
+// env, sidecar-env, config-env, socket, litestream-shadow, corrupt (R5).
+var presence9 = []bool{true, false, false, true, false, true, false, false, false}
+
+func journalFor(t *testing.T, dd string, mode Mode) *Journal {
+ t.Helper()
+ p, err := PlanFor(dd, "/run/carlos", "h.example.com", mode)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return p.Journal(presence9)
+}
+
+func TestJournalWriteIsExclusiveAndPhasesAdvance(t *testing.T) {
+ dd := t.TempDir()
+ j := journalFor(t, dd, Mode("nested"))
+ if err := WriteJournal(dd, j); err != nil {
+ t.Fatal(err)
+ }
+ if err := WriteJournal(dd, j); !errors.Is(err, ErrJournalExists) {
+ t.Fatalf("second write = %v, want ErrJournalExists", err)
+ }
+ got, found, err := ReadJournal(dd, "h.example.com")
+ if err != nil || !found || got.Phase != PhaseFiles || got.Mode != Mode("nested") || len(got.Artifacts) != 9 {
+ t.Fatalf("read back = %+v %v %v", got, found, err)
+ }
+ if err := UpdatePhase(dd, got, PhaseRow); err != nil {
+ t.Fatal(err)
+ }
+ got, _, _ = ReadJournal(dd, "h.example.com")
+ if got.Phase != PhaseRow {
+ t.Errorf("phase = %s", got.Phase)
+ }
+ if err := RemoveJournal(dd, "h.example.com"); err != nil {
+ t.Fatal(err)
+ }
+ if _, found, _ := ReadJournal(dd, "h.example.com"); found {
+ t.Error("journal survives removal")
+ }
+}
+
+func TestReadJournalRefusesASymlinkAndAFIFO(t *testing.T) {
+ dd := t.TempDir()
+ dir := filepath.Join(dd, "layout")
+ os.MkdirAll(dir, 0o700)
+ target := filepath.Join(dd, "elsewhere.json")
+ os.WriteFile(target, []byte(`{"host":"h.example.com"}`), 0o600)
+ if err := os.Symlink(target, filepath.Join(dir, "h.example.com.json")); err != nil {
+ t.Skip(err)
+ }
+ if _, _, err := ReadJournal(dd, "h.example.com"); err == nil {
+ t.Error("a symlinked journal was read")
+ }
+}
+
+func TestValidateChecksBothEndpointsFromTheJournalsOwnDirection(t *testing.T) {
+ dd := t.TempDir()
+ j := journalFor(t, dd, Mode("nested"))
+ flat := registry.Route{Host: "h.example.com", DBPath: j.From.DB, Socket: j.From.Socket}
+ nested := registry.Route{Host: "h.example.com", DBPath: j.To.DB, Socket: j.To.Socket}
+ if atFrom, err := Validate(j, dd, "/run/carlos", flat); err != nil || !atFrom {
+ t.Errorf("row at from: %v %v", atFrom, err)
+ }
+ if atFrom, err := Validate(j, dd, "/run/carlos", nested); err != nil || atFrom {
+ t.Errorf("row at to: %v %v", atFrom, err)
+ }
+ other := registry.Route{Host: "h.example.com", DBPath: "/data/other.db", Socket: j.From.Socket}
+ if _, err := Validate(j, dd, "/run/carlos", other); err == nil {
+ t.Error("a row at neither endpoint was accepted")
+ }
+ // A forged journal whose `to` points somewhere else is refused whatever the row says.
+ forged := *j
+ forged.To.DB = "/data/console/console.db"
+ if _, err := Validate(&forged, dd, "/run/carlos", flat); err == nil {
+ t.Error("a journal with a foreign destination was accepted")
+ }
+ forged = *j
+ forged.From.DB = "/data/console/console.db"
+ if _, err := Validate(&forged, dd, "/run/carlos", flat); err == nil {
+ t.Error("a journal with a foreign source was accepted")
+ }
+ // The flat direction swaps the two, and Validate derives from the mode.
+ r := journalFor(t, dd, Mode("flat"))
+ if atFrom, err := Validate(r, dd, "/run/carlos", nested); err != nil || !atFrom {
+ t.Errorf("revert journal, row at from (nested): %v %v", atFrom, err)
+ }
+}
+
+// TestValidateRefusesAWrongArtifactCount pins R5's "Validate's length check
+// becomes 9": a journal that predates the two new artifacts (or one that is
+// simply malformed) must not be treated as valid just because its endpoints
+// happen to match.
+func TestValidateRefusesAWrongArtifactCount(t *testing.T) {
+ dd := t.TempDir()
+ j := journalFor(t, dd, Mode("nested"))
+ short := *j
+ short.Artifacts = j.Artifacts[:7]
+ row := registry.Route{Host: "h.example.com", DBPath: j.From.DB, Socket: j.From.Socket}
+ if _, err := Validate(&short, dd, "/run/carlos", row); err == nil {
+ t.Error("a journal with 7 artifacts (pre-R5) was accepted")
+ }
+}
+
+func TestJournalPresent(t *testing.T) {
+ dd := t.TempDir()
+ if JournalPresent(dd, "h.example.com") {
+ t.Error("no journal written yet, JournalPresent = true")
+ }
+ j := journalFor(t, dd, Mode("nested"))
+ if err := WriteJournal(dd, j); err != nil {
+ t.Fatal(err)
+ }
+ if !JournalPresent(dd, "h.example.com") {
+ t.Error("journal written, JournalPresent = false")
+ }
+ if err := RemoveJournal(dd, "h.example.com"); err != nil {
+ t.Fatal(err)
+ }
+ if JournalPresent(dd, "h.example.com") {
+ t.Error("journal removed, JournalPresent = true")
+ }
+}
diff --git a/internal/layout/plan.go b/internal/layout/plan.go
new file mode 100644
index 000000000..bfee4b2d4
--- /dev/null
+++ b/internal/layout/plan.go
@@ -0,0 +1,186 @@
+// Package layout is the pass that moves an activator-managed host between
+// the flat layout (<data>/<host>.db, /run/carlos/<host>.sock) and the nested
+// one (<data>/instances/<iid>/, /run/carlos/instances/<iid>/), and back
+// (isolated-exec design §8.2). It builds its plan from the row and the host
+// name only, journals every step in /data/layout, and recovers from the
+// journal exactly. Nothing in an instance directory is read to decide
+// anything (§3.3).
+package layout
+
+import (
+ "fmt"
+ "path/filepath"
+
+ "github.com/carlosframework/platform/internal/controlname"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Mode is the direction of a move: the layout the host ends in.
+type Mode string
+
+const (
+ ModeNested Mode = release.LayoutModeNested
+ ModeFlat Mode = release.LayoutModeFlat
+)
+
+// Endpoint is the pair of row columns a layout is defined by.
+type Endpoint struct {
+ DB string `json:"db"`
+ Socket string `json:"socket"`
+}
+
+// Artifact is one file (or, for litestream-shadow, directory) the pass
+// moves. Src and Dst are RAW paths for anything inside the instance tree
+// (design §3.2): the bind mount at /data/instances is a different mount
+// from /data/.instances and rename(2) between them answers EXDEV, so the
+// pass renames through the raw directory and the mount shows the result.
+// The socket artifact is the one exception: nothing is ever renamed for
+// it, so its Src/Dst are the plan's own endpoint sockets (design §8.2
+// step 3; see PlanFor).
+type Artifact struct {
+ Name string
+ Src, Dst string
+ // Required marks the database: a missing required source is an error,
+ // a missing optional one is recorded absent and skipped (R2-7).
+ Required bool
+ // Dir marks an artifact that is a directory rather than a regular
+ // file (only litestream-shadow, design §8.2 step 3): the preflight
+ // and the rename step need to know this before they touch it, since
+ // a directory cannot be opened, stat'd or renamed the way a file is.
+ Dir bool
+ // Present records whether this artifact's source exists. PlanFor
+ // never stats anything (it builds the plan from the row and the host
+ // name alone, §3.3), so Present is always false here; the pass fills
+ // it in during preflight.
+ Present bool
+}
+
+// Plan is everything the pass will do for one host, computed before it
+// touches anything.
+type Plan struct {
+ Host string
+ IID string
+ Mode Mode
+ From, To Endpoint
+ Artifacts []Artifact
+}
+
+// artifactNames is the fixed order of the nine artifacts (R5 appended
+// litestream-shadow and corrupt after socket, so the first seven indices
+// keep their original meaning). The journal records presence by this
+// order, so it is part of the on-disk contract.
+var artifactNames = []string{
+ "db", "db-wal", "db-shm", "env", "sidecar-env", "config-env", "socket",
+ "litestream-shadow", "corrupt",
+}
+
+// rawInstanceDir and rawRunDir are the ONLY places the platform names the
+// raw directories behind the two bind mounts; a grep test (task 3) pins
+// that.
+func rawInstanceDir(dataDir, host string) string {
+ return filepath.Join(dataDir, ".instances", provision.InstanceID(host))
+}
+
+func rawRunDir(socketDir, host string) string {
+ return filepath.Join(socketDir, ".instances", provision.InstanceID(host))
+}
+
+// shadowName is the litestream shadow directory's file name for host,
+// derived exactly as internal/lease/lease.go's newStore does for a
+// database path whose base name is "<host>.db" (that function is
+// unexported and lives in a package this one must not import back from,
+// since internal/activator will import this package in task 3).
+func shadowName(host string) string {
+ return ".activator-" + host + ".db-litestream"
+}
+
+// flatFiles and nestedFiles are the nine artifact paths under each layout,
+// in artifactNames order. The nested set is expressed through the raw
+// directories, except for the socket, which the pass never renames and so
+// is expressed through the mount-visible ("instances", not ".instances")
+// path the row itself will carry (R8).
+func flatFiles(dataDir, socketDir, host string) []string {
+ db := provision.DefaultDBPath(dataDir, host)
+ return []string{
+ db,
+ db + "-wal",
+ db + "-shm",
+ filepath.Join(dataDir, host+".env"),
+ filepath.Join(dataDir, host+"-sidecar.env"),
+ filepath.Join(dataDir, host+".config.env"),
+ filepath.Join(socketDir, host+".sock"),
+ filepath.Join(dataDir, shadowName(host)),
+ db + ".corrupt",
+ }
+}
+
+func nestedFiles(dataDir, socketDir, host string) []string {
+ d := rawInstanceDir(dataDir, host)
+ db := filepath.Join(d, host+".db")
+ return []string{
+ db,
+ filepath.Join(d, host+".db-wal"),
+ filepath.Join(d, host+".db-shm"),
+ filepath.Join(d, host+".env"),
+ filepath.Join(d, host+"-sidecar.env"),
+ filepath.Join(d, host+".config.env"),
+ filepath.Join(socketDir, "instances", provision.InstanceID(host), "app.sock"),
+ filepath.Join(d, shadowName(host)),
+ db + ".corrupt",
+ }
+}
+
+// PlanFor derives the plan for host in the given direction from the host
+// name alone (design §8.2 step 3). The endpoints are the row values the
+// pass will compare against and swap; the artifacts are the files (and one
+// directory) it will rename.
+//
+// Both socket endpoints are derived from socketDir, never from the
+// compiled-in provision.SocketDir/DefaultSocket/NestedSocket (R8): those
+// ignore any directory a caller passes, which is invisible in production
+// (socketDir is always provision.SocketDir there) but would silently break
+// every test that wants an isolated socket tree.
+func PlanFor(dataDir, socketDir, host string, mode Mode) (*Plan, error) {
+ if mode != ModeNested && mode != ModeFlat {
+ return nil, fmt.Errorf("layout: mode %q is not nested or flat", mode)
+ }
+ if !controlname.ValidHostShape(host) {
+ return nil, fmt.Errorf("layout: %q is not a hostname", host)
+ }
+ iid := provision.InstanceID(host)
+ flat := Endpoint{
+ DB: provision.DefaultDBPath(dataDir, host),
+ Socket: filepath.Join(socketDir, host+".sock"),
+ }
+ nested := Endpoint{
+ DB: provision.NestedDBPath(dataDir, host),
+ Socket: filepath.Join(socketDir, "instances", iid, "app.sock"),
+ }
+ src, dst := flatFiles(dataDir, socketDir, host), nestedFiles(dataDir, socketDir, host)
+ p := &Plan{Host: host, IID: iid, Mode: mode, From: flat, To: nested}
+ if mode == ModeFlat {
+ p.From, p.To = nested, flat
+ src, dst = dst, src
+ }
+ for i, name := range artifactNames {
+ p.Artifacts = append(p.Artifacts, Artifact{
+ Name: name,
+ Src: src[i],
+ Dst: dst[i],
+ Required: name == "db",
+ Dir: name == "litestream-shadow",
+ })
+ }
+ return p, nil
+}
+
+// Journal builds the journal for this plan with the given presence
+// vector (one bool per artifact, artifactNames order).
+func (p *Plan) Journal(present []bool) *Journal {
+ j := &Journal{Host: p.Host, Mode: p.Mode, From: p.From, To: p.To, Phase: PhaseFiles}
+ for i, a := range p.Artifacts {
+ j.Artifacts = append(j.Artifacts, JournalArtifact{Name: a.Name, Present: i < len(present) && present[i]})
+ }
+ return j
+}
diff --git a/internal/layout/plan_test.go b/internal/layout/plan_test.go
new file mode 100644
index 000000000..973b84069
--- /dev/null
+++ b/internal/layout/plan_test.go
@@ -0,0 +1,160 @@
+package layout
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+func TestPlanForNestedDerivesEveryPathFromTheHost(t *testing.T) {
+ p, err := PlanFor("/data", "/run/carlos", "h.example.com", Mode("nested"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ iid := provision.InstanceID("h.example.com")
+ if p.From != (Endpoint{DB: "/data/h.example.com.db", Socket: "/run/carlos/h.example.com.sock"}) {
+ t.Errorf("from = %+v", p.From)
+ }
+ if p.To != (Endpoint{DB: provision.NestedDBPath("/data", "h.example.com"), Socket: provision.NestedSocket("h.example.com")}) {
+ t.Errorf("to = %+v", p.To)
+ }
+ if p.Host != "h.example.com" || p.IID != iid || p.Mode != Mode("nested") {
+ t.Errorf("host/iid/mode = %q %q %q", p.Host, p.IID, p.Mode)
+ }
+ if len(p.Artifacts) != 9 {
+ t.Fatalf("len(Artifacts) = %d, want 9", len(p.Artifacts))
+ }
+ byName := map[string]Artifact{}
+ for _, a := range p.Artifacts {
+ byName[a.Name] = a
+ }
+ if byName["db"].Src != "/data/h.example.com.db" || byName["db"].Dst != filepath.Join("/data/.instances", iid, "h.example.com.db") || !byName["db"].Required {
+ t.Errorf("db artifact = %+v (the destination must be the RAW directory)", byName["db"])
+ }
+ if byName["db"].Dir {
+ t.Errorf("db artifact should not be marked as a directory: %+v", byName["db"])
+ }
+ if byName["env"].Dst != filepath.Join("/data/.instances", iid, "h.example.com.env") || byName["env"].Required {
+ t.Errorf("env artifact = %+v", byName["env"])
+ }
+ if byName["db-wal"].Src != "/data/h.example.com.db-wal" || byName["db-shm"].Src != "/data/h.example.com.db-shm" {
+ t.Errorf("sidecar files = %+v %+v", byName["db-wal"], byName["db-shm"])
+ }
+ if byName["config-env"].Src != "/data/h.example.com.config.env" || byName["sidecar-env"].Src != "/data/h.example.com-sidecar.env" {
+ t.Errorf("env files = %+v %+v", byName["config-env"], byName["sidecar-env"])
+ }
+ if strings.Contains(byName["db"].Dst, "/data/instances/") {
+ t.Error("a destination through the bind mount would EXDEV")
+ }
+
+ // R5: the two artifacts appended after socket.
+ if byName["litestream-shadow"].Src != "/data/.activator-h.example.com.db-litestream" {
+ t.Errorf("litestream-shadow src = %q", byName["litestream-shadow"].Src)
+ }
+ if byName["litestream-shadow"].Dst != filepath.Join("/data/.instances", iid, ".activator-h.example.com.db-litestream") {
+ t.Errorf("litestream-shadow dst = %q", byName["litestream-shadow"].Dst)
+ }
+ if !byName["litestream-shadow"].Dir {
+ t.Error("litestream-shadow should be marked as a directory")
+ }
+ if byName["litestream-shadow"].Required {
+ t.Error("litestream-shadow must be optional")
+ }
+ if byName["corrupt"].Src != "/data/h.example.com.db.corrupt" {
+ t.Errorf("corrupt src = %q", byName["corrupt"].Src)
+ }
+ if byName["corrupt"].Dst != filepath.Join("/data/.instances", iid, "h.example.com.db.corrupt") {
+ t.Errorf("corrupt dst = %q", byName["corrupt"].Dst)
+ }
+ if byName["corrupt"].Dir || byName["corrupt"].Required {
+ t.Errorf("corrupt artifact = %+v, want a non-directory optional file", byName["corrupt"])
+ }
+
+ // R8: both socket endpoints come from the socketDir argument, and the
+ // nested one sits under "instances" (the mount-visible name), not the
+ // raw dotted directory: nothing is ever renamed for the socket.
+ if byName["socket"].Src != "/run/carlos/h.example.com.sock" {
+ t.Errorf("socket src = %q", byName["socket"].Src)
+ }
+ if byName["socket"].Dst != filepath.Join("/run/carlos/instances", iid, "app.sock") {
+ t.Errorf("socket dst = %q", byName["socket"].Dst)
+ }
+ if byName["socket"].Required {
+ t.Error("socket must be optional: it is reported, never renamed")
+ }
+}
+
+// TestPlanForUsesTheGivenSocketDirNotTheCompiledDefault pins R8: PlanFor
+// must derive both socket endpoints from its socketDir argument, never from
+// the compiled-in provision.SocketDir or provision.DefaultSocket/NestedSocket
+// (which ignore the argument entirely). A test that only ever passes
+// "/run/carlos" as socketDir cannot catch a PlanFor that silently falls back
+// to the compiled default, because the two values coincide in production.
+func TestPlanForUsesTheGivenSocketDirNotTheCompiledDefault(t *testing.T) {
+ sd := "/tmp/not-run-carlos"
+ p, err := PlanFor("/data", sd, "h.example.com", Mode("nested"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ iid := provision.InstanceID("h.example.com")
+ if p.From.Socket != filepath.Join(sd, "h.example.com.sock") {
+ t.Errorf("from.socket = %q, want under %q", p.From.Socket, sd)
+ }
+ if p.To.Socket != filepath.Join(sd, "instances", iid, "app.sock") {
+ t.Errorf("to.socket = %q, want under %q", p.To.Socket, sd)
+ }
+ if strings.HasPrefix(p.To.Socket, "/run/carlos") {
+ t.Error("nested socket endpoint fell back to the compiled-in socket dir")
+ }
+ for _, a := range p.Artifacts {
+ if a.Name == "socket" && strings.HasPrefix(a.Dst, "/run/carlos") {
+ t.Errorf("socket artifact fell back to the compiled-in socket dir: %+v", a)
+ }
+ }
+}
+
+func TestPlanForFlatIsTheExactReverse(t *testing.T) {
+ n, _ := PlanFor("/data", "/run/carlos", "h.example.com", Mode("nested"))
+ f, err := PlanFor("/data", "/run/carlos", "h.example.com", Mode("flat"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if f.From != n.To || f.To != n.From {
+ t.Errorf("flat endpoints %+v %+v are not nested's swapped %+v %+v", f.From, f.To, n.From, n.To)
+ }
+ if len(f.Artifacts) != len(n.Artifacts) {
+ t.Fatalf("artifact counts differ: %d vs %d", len(f.Artifacts), len(n.Artifacts))
+ }
+ for i := range n.Artifacts {
+ if f.Artifacts[i].Src != n.Artifacts[i].Dst || f.Artifacts[i].Dst != n.Artifacts[i].Src {
+ t.Errorf("artifact %s not reversed: %+v vs %+v", n.Artifacts[i].Name, f.Artifacts[i], n.Artifacts[i])
+ }
+ if f.Artifacts[i].Name != n.Artifacts[i].Name || f.Artifacts[i].Required != n.Artifacts[i].Required || f.Artifacts[i].Dir != n.Artifacts[i].Dir {
+ t.Errorf("artifact %d metadata not preserved across the reversal: %+v vs %+v", i, f.Artifacts[i], n.Artifacts[i])
+ }
+ }
+}
+
+// TestRawRunDirIsTheDottedRawSocketTree pins rawRunDir's own doc comment
+// ("<socketDir>/.instances/<iid>"): task 3's EnsureDirs creates the nested
+// run directory through this raw path even though the socket file inside
+// it is never renamed (PlanFor's socket artifact records the mount-visible
+// path instead, R8).
+func TestRawRunDirIsTheDottedRawSocketTree(t *testing.T) {
+ got := rawRunDir("/run/carlos", "h.example.com")
+ want := filepath.Join("/run/carlos/.instances", provision.InstanceID("h.example.com"))
+ if got != want {
+ t.Errorf("rawRunDir = %q, want %q", got, want)
+ }
+}
+
+func TestPlanForRefusesBadInput(t *testing.T) {
+ if _, err := PlanFor("/data", "/run/carlos", "h.example.com", Mode("sideways")); err == nil {
+ t.Error("unknown mode accepted")
+ }
+ if _, err := PlanFor("/data", "/run/carlos", "../h", Mode("nested")); err == nil {
+ t.Error("invalid host accepted")
+ }
+}