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>
7 files changed,
+763
−0
internal/layout/journal.go+209 −0internal/layout/journal_linux.go+16 −0internal/layout/journal_linux_test.go+46 −0internal/layout/journal_other.go+13 −0internal/layout/journal_test.go+133 −0internal/layout/plan.go+186 −0internal/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") |
| + } |
| +} |