zdd-4 third fix wave: the generations file is published under a sequence intent and names builds by path (R19 items 2, 4)
WriteGenerations now writes <data>/zdd/generations.seq holding the next sequence N (monotonic: one more than the intent or file on disk), then generations.json carrying seq N and complete:true, each atomically (temp file, 0644, rename). ReadGenerations, the one reader, treats neither file as no generations, and with the intent present refuses any file that is absent, incomplete or on another sequence. The file gains "paths" (host -> absolute staged paths); GenerationPins pins them, with their real paths, and needs no registry row for them. A schema-1 file with only hosts still reads. TestWriteGenerationsIsAtomicAndReadable's directory listing now expects the intent file beside the generations file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+425
−27
internal/release/generations_publish_test.go+232 −0internal/release/pins.go+190 −25internal/release/pins_test.go+3 −2
diff --git a/internal/release/generations_publish_test.go b/internal/release/generations_publish_test.go| new file mode 100644 |
| index 000000000..831174d32 |
| --- /dev/null |
| +++ b/internal/release/generations_publish_test.go |
| @@ -0,0 +1,232 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// writeGenFiles writes the intent file and the generations file by hand, as |
| +// a crashed or failed publication would leave them. An empty body leaves |
| +// that file absent. |
| +func writeGenFiles(t *testing.T, path, intent, body string) { |
| + t.Helper() |
| + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if intent != "" { |
| + if err := os.WriteFile(GenerationsSeqPath(path), []byte(intent), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if body != "" { |
| + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +func noRows() ([]registry.Route, error) { return nil, nil } |
| + |
| +// Ruling R19 item 4: a successful publication writes the intent, then the |
| +// file carrying the same sequence and the complete marker, both 0644, no temp |
| +// file left behind; each publication advances the sequence; and the reader |
| +// returns what was written. |
| +func TestGenerationsPublicationRoundTrips(t *testing.T) { |
| + path := GenerationsPath(t.TempDir()) |
| + first := Generations{Hosts: map[string][]string{"a.example.com": {"v1"}}} |
| + if err := WriteGenerations(path, first); err != nil { |
| + t.Fatalf("WriteGenerations: %v", err) |
| + } |
| + want := Generations{ |
| + Hosts: map[string][]string{"a.example.com": {"v2"}}, |
| + Paths: map[string][]string{"a.example.com": {"/opt/carlos/versions/v2/km"}, "b.example.com": {"/srv/operator/app"}}, |
| + } |
| + if err := WriteGenerations(path, want); err != nil { |
| + t.Fatalf("WriteGenerations: %v", err) |
| + } |
| + got, err := ReadGenerations(path) |
| + if err != nil || got == nil { |
| + t.Fatalf("ReadGenerations = %+v, %v", got, err) |
| + } |
| + if got.Schema != 1 || got.Seq != 2 || !got.Complete || !reflect.DeepEqual(got.Hosts, want.Hosts) || !reflect.DeepEqual(got.Paths, want.Paths) { |
| + t.Fatalf("read back %+v; want schema 1, seq 2, complete, and the hosts and paths written", got) |
| + } |
| + intent, err := os.ReadFile(GenerationsSeqPath(path)) |
| + if err != nil || strings.TrimSpace(string(intent)) != "2" { |
| + t.Fatalf("intent file = %q, %v; want 2", intent, err) |
| + } |
| + for _, p := range []string{path, GenerationsSeqPath(path)} { |
| + if info, err := os.Stat(p); err != nil || info.Mode().Perm() != 0o644 { |
| + t.Fatalf("%s mode = %v, %v; want 0644", p, info, err) |
| + } |
| + } |
| + entries, err := os.ReadDir(filepath.Dir(path)) |
| + if err != nil || len(entries) != 2 { |
| + t.Fatalf("zdd dir holds %v, %v; want generations.json and generations.seq only", entries, err) |
| + } |
| + if GenerationsSeqPath(path) != filepath.Join(filepath.Dir(path), "generations.seq") { |
| + t.Fatalf("GenerationsSeqPath = %s, want <data dir>/zdd/generations.seq", GenerationsSeqPath(path)) |
| + } |
| +} |
| + |
| +// The sequence is monotonic across writers (an agent restart): the next |
| +// publication reads the intent on disk and adds one. |
| +func TestGenerationsSequenceContinuesFromTheIntentOnDisk(t *testing.T) { |
| + path := GenerationsPath(t.TempDir()) |
| + writeGenFiles(t, path, "41\n", `{"schema":1,"seq":41,"complete":true,"hosts":{}}`) |
| + if err := WriteGenerations(path, Generations{}); err != nil { |
| + t.Fatalf("WriteGenerations: %v", err) |
| + } |
| + if g, err := ReadGenerations(path); err != nil || g.Seq != 42 { |
| + t.Fatalf("ReadGenerations = %+v, %v; want seq 42", g, err) |
| + } |
| +} |
| + |
| +// A publication whose file write fails leaves the intent ahead of the file |
| +// on disk, so the older file, still readable, is no longer trusted. |
| +func TestAFailedGenerationsWriteLeavesTheIntentAhead(t *testing.T) { |
| + path := GenerationsPath(t.TempDir()) |
| + if err := WriteGenerations(path, Generations{Hosts: map[string][]string{"a.example.com": {"v1"}}}); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The file's rename fails: a non-empty directory sits at its path. |
| + if err := os.Remove(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(filepath.Join(path, "blocker"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := WriteGenerations(path, Generations{Hosts: map[string][]string{"a.example.com": {"v2"}}}); err == nil { |
| + t.Fatal("WriteGenerations succeeded with a directory at the file's path") |
| + } |
| + if intent, err := os.ReadFile(GenerationsSeqPath(path)); err != nil || strings.TrimSpace(string(intent)) != "2" { |
| + t.Fatalf("intent after a failed file write = %q, %v; want 2, written before the file", intent, err) |
| + } |
| +} |
| + |
| +// Ruling R19 item 4, the reader: with the intent present, anything but a |
| +// complete file carrying the intent's sequence is incomplete, for the pin set |
| +// as for the reader. |
| +func TestGenerationsFileIncompleteUnlessItMatchesTheIntent(t *testing.T) { |
| + for name, files := range map[string][2]string{ |
| + "intent ahead of the file": {"2\n", `{"schema":1,"seq":1,"complete":true,"hosts":{}}`}, |
| + "stale seq, no paths": {"3", `{"schema":1,"seq":2,"complete":true,"hosts":{"a.example.com":["v1"]}}`}, |
| + "file without complete": {"1", `{"schema":1,"seq":1,"hosts":{}}`}, |
| + "file with complete false": {"1", `{"schema":1,"seq":1,"complete":false,"hosts":{}}`}, |
| + "file without seq": {"1", `{"schema":1,"complete":true,"hosts":{}}`}, |
| + "intent and no file": {"1", ""}, |
| + "unreadable intent": {"one", `{"schema":1,"seq":1,"complete":true,"hosts":{}}`}, |
| + "zero intent": {"0", `{"schema":1,"complete":true,"hosts":{}}`}, |
| + "file with seq and no intent": {"", `{"schema":1,"seq":1,"complete":true,"hosts":{}}`}, |
| + "relative path": {"1", `{"schema":1,"seq":1,"complete":true,"paths":{"a.example.com":["versions/v1/km"]}}`}, |
| + "empty path": {"1", `{"schema":1,"seq":1,"complete":true,"paths":{"a.example.com":[""]}}`}, |
| + "legacy file with relative path": {"", `{"schema":1,"paths":{"a.example.com":["km"]}}`}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + path := GenerationsPath(t.TempDir()) |
| + writeGenFiles(t, path, files[0], files[1]) |
| + if g, err := ReadGenerations(path); err == nil { |
| + t.Fatalf("ReadGenerations = %+v, nil; want an error (incomplete)", g) |
| + } |
| + if pins, err := GenerationPins(path, noRows).StagedPins(context.Background(), t.TempDir()); err == nil { |
| + t.Fatalf("StagedPins = %v, nil; want an error (incomplete pin set)", pins) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Neither file present is no generations: every box before the agent first |
| +// published. A file with neither seq nor intent (schema 1 as first written) |
| +// still reads, with hosts, paths, or both. |
| +func TestGenerationsFileWithoutIntent(t *testing.T) { |
| + path := GenerationsPath(t.TempDir()) |
| + if g, err := ReadGenerations(path); err != nil || g != nil { |
| + t.Fatalf("neither file: ReadGenerations = %+v, %v; want nil, nil", g, err) |
| + } |
| + writeGenFiles(t, path, "", `{"schema":1,"paths":{"a.example.com":["/opt/carlos/versions/v1/km"]}}`) |
| + g, err := ReadGenerations(path) |
| + if err != nil || g == nil || !reflect.DeepEqual(g.Paths, map[string][]string{"a.example.com": {"/opt/carlos/versions/v1/km"}}) { |
| + t.Fatalf("paths-only legacy file: ReadGenerations = %+v, %v", g, err) |
| + } |
| +} |
| + |
| +// Ruling R19 item 2: a path entry is pinned as written (cleaned) and needs no |
| +// registry row; version entries still do. |
| +func TestGenerationPinsPinPathsWithoutARow(t *testing.T) { |
| + path := GenerationsPath(t.TempDir()) |
| + if err := WriteGenerations(path, Generations{Paths: map[string][]string{"gone.example.com": {"/opt/carlos/versions/v1/./km"}}}); err != nil { |
| + t.Fatal(err) |
| + } |
| + rowsAsked := false |
| + rows := func() ([]registry.Route, error) { rowsAsked = true; return nil, nil } |
| + pins, err := GenerationPins(path, rows).StagedPins(context.Background(), t.TempDir()) |
| + if err != nil || !pins.Has("/opt/carlos/versions/v1/km") { |
| + t.Fatalf("StagedPins = %v, %v; want the path pinned", pins, err) |
| + } |
| + if rowsAsked { |
| + t.Fatal("a paths-only file asked for registry rows") |
| + } |
| +} |
| + |
| +// WriteGenerations refuses a relative or empty path before it touches either |
| +// file, so a refused publication never leaves the intent ahead. |
| +func TestWriteGenerationsRefusesARelativePath(t *testing.T) { |
| + path := GenerationsPath(t.TempDir()) |
| + for _, bad := range []string{"versions/v1/km", ""} { |
| + if err := WriteGenerations(path, Generations{Paths: map[string][]string{"a.example.com": {bad}}}); err == nil { |
| + t.Fatalf("WriteGenerations wrote path %q", bad) |
| + } |
| + } |
| + if _, err := os.Stat(GenerationsSeqPath(path)); !os.IsNotExist(err) { |
| + t.Fatalf("a refused write left an intent file: %v", err) |
| + } |
| +} |
| + |
| +// Ruling R19 item 2, Astra r3 F1(b): a worker exec'd through a chained live |
| +// link (live/h2 -> operator-current -> the staged v2) has Version "", because |
| +// the link's own target is outside the version store, yet it runs v2's staged |
| +// bytes. The agent publishes it by path. Once live/h2 has moved on and no row |
| +// names v2, that path entry alone keeps a re-shipped v2 from being restaged |
| +// over it. |
| +func TestAPathEntryProtectsAChainedLinksStagedCopy(t *testing.T) { |
| + f := newReuseFixture(t) |
| + current := filepath.Join(t.TempDir(), "operator-current") |
| + if err := os.Symlink(VersionPath(f.opt, "bab", "v2", "myapp"), current); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(current, LiveLink(f.opt, "h2.example.com")); err != nil { |
| + t.Fatal(err) |
| + } |
| + id, err := ResolveLive(f.opt, "h2.example.com") |
| + if err != nil { |
| + t.Fatalf("ResolveLive: %v", err) |
| + } |
| + if id.Version != "" || id.StagedPath != VersionPath(f.opt, "bab", "v2", "myapp") { |
| + t.Fatalf("captured %+v; want no Version and v2's staged path", id) |
| + } |
| + dataDir := t.TempDir() |
| + if err := WriteGenerations(GenerationsPath(dataDir), Generations{Paths: map[string][]string{"h2.example.com": {id.StagedPath}}}); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The live link moves on; only the generations file still names the copy. |
| + if err := os.Remove(LiveLink(f.opt, "h2.example.com")); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.assertRefused(t, f.pass(t, BakeDir(dataDir)), ErrVersionReused.Error()) |
| +} |
| + |
| +// Ruling R19 item 4 through the pass: a file left behind by a publication |
| +// whose intent moved on is not trusted, so nothing is restaged. |
| +func TestAStaleGenerationsFileNeverPermitsARestage(t *testing.T) { |
| + f := newReuseFixture(t) |
| + dataDir := t.TempDir() |
| + path := GenerationsPath(dataDir) |
| + writeGenFiles(t, path, "2", `{"schema":1,"seq":1,"complete":true,"hosts":{}}`) |
| + f.assertRefused(t, f.pass(t, BakeDir(dataDir)), "what refers to it is unknown") |
| +} |
diff --git a/internal/release/pins.go b/internal/release/pins.go| index 8f1591d6e..bc86e2e52 100644 |
| --- a/internal/release/pins.go |
| +++ b/internal/release/pins.go |
| @@ -12,6 +12,8 @@ import ( |
| "fmt" |
| "os" |
| "path/filepath" |
| + "strconv" |
| + "strings" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -25,6 +27,43 @@ func (sp StagedPaths) add(p string) { sp[filepath.Clean(p)] = true } |
| // Has reports whether p is referred to. |
| func (sp StagedPaths) Has(p string) bool { return sp[filepath.Clean(p)] } |
| +// addReal adds p and, when p exists, the fully resolved real path it names |
| +// (filepath.EvalSymlinks), so a copy reached through a symlinked directory is |
| +// pinned under the name the OS gives it too (Astra r3 N4, ruling R19 item 5). |
| +// A p that does not exist adds p alone; any other resolution error is |
| +// returned, and the caller's pin set is incomplete. |
| +func (sp StagedPaths) addReal(p string) error { |
| + sp.add(p) |
| + real, err := filepath.EvalSymlinks(p) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil |
| + } |
| + if err != nil { |
| + return fmt.Errorf("resolve %s: %w", p, err) |
| + } |
| + sp.add(real) |
| + return nil |
| +} |
| + |
| +// Refers is the membership check a restage asks: whether p, or the fully |
| +// resolved real path of p when p exists, is referred to. Comparing the real |
| +// path as well means a symlinked opt dir cannot make a pinned file look |
| +// unpinned under its other name (Astra r3 N4). A resolution error other than |
| +// not existing is returned, and never permits a restage. |
| +func (sp StagedPaths) Refers(p string) (bool, error) { |
| + if sp.Has(p) { |
| + return true, nil |
| + } |
| + real, err := filepath.EvalSymlinks(p) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return false, nil |
| + } |
| + if err != nil { |
| + return false, fmt.Errorf("resolve %s: %w", p, err) |
| + } |
| + return sp.Has(real), nil |
| +} |
| + |
| // PinSource supplies references beyond the live links and the registry rows. |
| // GenerationPins is the one this package ships. A source that cannot answer |
| // returns an error, and an error never permits a restage. |
| @@ -84,18 +123,36 @@ func BoxPins(optDir string, routes []registry.Route) (StagedPaths, error) { |
| } |
| // Generations is the generations file: for each host with a live or pending |
| -// generation (current, successor, retiring, spawning: spec §8.5), the version |
| +// generation (current, successor, retiring, spawning: spec §8.5), the build |
| // each one runs. Live generations exist only in the agent's memory, while the |
| // pin set is also needed by root's pass, a separate process; this file is how |
| // root learns them (index amendments, 2026-10-07). The agent's activator |
| // writes it with WriteGenerations, listing every worker's captured serving |
| -// build, before its first worker spawn and whenever a spawn or stop changes |
| -// that set (ruling R17); plan 5 extends the same writer to its operation set. |
| +// build and every build a spawn is about to exec, before that exec and |
| +// whenever a spawn or stop changes the set (rulings R17, R19); plan 5 extends |
| +// the same writer to its operation set. |
| +// |
| +// A build is named by version (Hosts, placed under the host's row by |
| +// GenerationPins) and by the absolute staged path it is exec'd from (Paths, |
| +// pinned as written, no row needed). Paths covers a build whose Version is |
| +// empty, such as one reached through an operator's chained link, which a |
| +// version alone cannot name (ruling R19 item 2). |
| // |
| -// On disk: {"schema":1,"hosts":{"<host>":["<version>",...]}} |
| +// On disk: {"schema":1,"seq":N,"complete":true,"hosts":{"<host>":["<version>",...]}, |
| +// "paths":{"<host>":["/abs/path",...]}}. A file with only hosts, only paths, |
| +// or no seq (as first written, ruling R17) still reads. |
| +// |
| +// Publication is two atomic writes (ruling R19 item 4): first the intent |
| +// file, GenerationsSeqPath, holding the publication's sequence N, then this |
| +// file carrying seq N and complete:true. A publication that fails between |
| +// them leaves the intent ahead of the file, and the reader then refuses the |
| +// older file instead of trusting it. |
| type Generations struct { |
| - Schema int `json:"schema"` // 1 |
| - Hosts map[string][]string `json:"hosts"` |
| + Schema int `json:"schema"` // 1 |
| + Seq uint64 `json:"seq,omitempty"` |
| + Complete bool `json:"complete,omitempty"` |
| + Hosts map[string][]string `json:"hosts"` |
| + Paths map[string][]string `json:"paths,omitempty"` |
| } |
| // GenerationsPath is the generations file under a data dir: |
| @@ -106,14 +163,33 @@ func GenerationsPath(dataDir string) string { |
| return filepath.Join(dataDir, "zdd", "generations.json") |
| } |
| -// ReadGenerations reads the generations file. An absent file is (nil, nil): |
| -// no generations, which is every box before plan 5. Anything else that is not |
| -// a schema-1 file of valid versions is an error, and the caller treats the pin |
| -// set as incomplete. |
| +// GenerationsSeqPath is the publication intent file beside the generations |
| +// file at path: /data/zdd/generations.seq in production. It holds the |
| +// sequence of the latest publication begun, as a decimal number. |
| +func GenerationsSeqPath(path string) string { |
| + return filepath.Join(filepath.Dir(path), "generations.seq") |
| +} |
| + |
| +// ReadGenerations reads the generations file and its intent file, the one |
| +// reader of both (GenerationPins and the version sweep's GenerationVersions |
| +// use it). Neither file present is (nil, nil): no generations, which is every |
| +// box before its agent first published. With the intent present, the file |
| +// must exist, read, parse, and carry complete:true and the intent's sequence; |
| +// anything else is a publication that did not finish, and an error. A file |
| +// with no intent is read as first written (ruling R17) only if it carries no |
| +// sequence. Any error means the caller's pin set is incomplete. |
| func ReadGenerations(path string) (*Generations, error) { |
| + seqPath := GenerationsSeqPath(path) |
| + intent, hasIntent, err := readGenerationsSeq(seqPath) |
| + if err != nil { |
| + return nil, err |
| + } |
| raw, err := os.ReadFile(path) |
| if errors.Is(err, os.ErrNotExist) { |
| - return nil, nil |
| + if !hasIntent { |
| + return nil, nil |
| + } |
| + return nil, fmt.Errorf("generations file %s: absent while %s names publication %d: incomplete", path, seqPath, intent) |
| } |
| if err != nil { |
| return nil, fmt.Errorf("generations file %s: %w", path, err) |
| @@ -128,13 +204,69 @@ func ReadGenerations(path string) (*Generations, error) { |
| if err := validGenerations(g); err != nil { |
| return nil, fmt.Errorf("generations file %s: %w", path, err) |
| } |
| + switch { |
| + case hasIntent && (!g.Complete || g.Seq != intent): |
| + return nil, fmt.Errorf("generations file %s: publication %d (complete %t) while %s names publication %d: incomplete", path, g.Seq, g.Complete, seqPath, intent) |
| + case !hasIntent && (g.Seq != 0 || g.Complete): |
| + return nil, fmt.Errorf("generations file %s: publication %d with no intent file %s: incomplete", path, g.Seq, seqPath) |
| + } |
| return &g, nil |
| } |
| -// WriteGenerations replaces the generations file atomically: a temp file in |
| -// the same directory, fsynced, mode 0644 (root and the agent both read it), |
| -// then renamed over the old one, so a reader sees the old file or the new |
| -// one and never a partial write. |
| +// readGenerationsSeq reads the intent file: (0, false, nil) when it is |
| +// absent, an error when it cannot be read or does not hold a sequence of 1 or |
| +// more. |
| +func readGenerationsSeq(seqPath string) (uint64, bool, error) { |
| + raw, err := os.ReadFile(seqPath) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return 0, false, nil |
| + } |
| + if err != nil { |
| + return 0, false, fmt.Errorf("generations intent %s: %w", seqPath, err) |
| + } |
| + n, err := strconv.ParseUint(strings.TrimSpace(string(raw)), 10, 64) |
| + if err != nil || n == 0 { |
| + return 0, false, fmt.Errorf("generations intent %s: %q is not a publication sequence: incomplete", seqPath, raw) |
| + } |
| + return n, true, nil |
| +} |
| + |
| +// nextGenerationsSeq is the sequence for the next publication: one more than |
| +// the larger of the intent on disk and the file's own seq, so it is |
| +// monotonic across agent restarts. An intent that cannot be read is an error |
| +// (no publication); one that reads but does not parse, or a file that does |
| +// not read, counts as 0, because the file on disk can never carry the new |
| +// sequence either way. |
| +func nextGenerationsSeq(path string) (uint64, error) { |
| + var n uint64 |
| + seqPath := GenerationsSeqPath(path) |
| + raw, err := os.ReadFile(seqPath) |
| + switch { |
| + case err == nil: |
| + if v, perr := strconv.ParseUint(strings.TrimSpace(string(raw)), 10, 64); perr == nil { |
| + n = v |
| + } |
| + case !errors.Is(err, os.ErrNotExist): |
| + return 0, fmt.Errorf("generations intent %s: %w", seqPath, err) |
| + } |
| + if body, err := os.ReadFile(path); err == nil { |
| + var g Generations |
| + if json.Unmarshal(body, &g) == nil && g.Seq > n { |
| + n = g.Seq |
| + } |
| + } |
| + return n + 1, nil |
| +} |
| + |
| +// WriteGenerations publishes g (ruling R19 item 4). It validates g first, so |
| +// a refused g touches neither file. Then it writes the intent file holding |
| +// the next sequence N, and then the generations file carrying seq N and |
| +// complete:true. Each write is atomic: a temp file in the same directory, |
| +// fsynced, mode 0644 (root and the agent both read them), then renamed over |
| +// the old one, so a reader sees the old file or the new one and never a |
| +// partial write. A failure of either write is returned; after a failed second |
| +// write the intent is ahead of the file, which the reader refuses. g's own |
| +// Schema, Seq and Complete are ignored. |
| func WriteGenerations(path string, g Generations) error { |
| g.Schema = 1 |
| if g.Hosts == nil { |
| @@ -143,20 +275,33 @@ func WriteGenerations(path string, g Generations) error { |
| if err := validGenerations(g); err != nil { |
| return err |
| } |
| - body, err := json.MarshalIndent(g, "", " ") |
| + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| + return err |
| + } |
| + seq, err := nextGenerationsSeq(path) |
| if err != nil { |
| return err |
| } |
| - dir := filepath.Dir(path) |
| - if err := os.MkdirAll(dir, 0o755); err != nil { |
| + if err := writeFileAtomic(GenerationsSeqPath(path), []byte(strconv.FormatUint(seq, 10)+"\n")); err != nil { |
| return err |
| } |
| - tmp, err := os.CreateTemp(dir, ".generations-*") |
| + g.Seq, g.Complete = seq, true |
| + body, err := json.MarshalIndent(g, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + return writeFileAtomic(path, append(body, '\n')) |
| +} |
| + |
| +// writeFileAtomic replaces path with body: a temp file in path's directory, |
| +// written, mode 0644, fsynced, closed, then renamed over path. |
| +func writeFileAtomic(path string, body []byte) error { |
| + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*") |
| if err != nil { |
| return err |
| } |
| defer os.Remove(tmp.Name()) |
| - if _, err := tmp.Write(append(body, '\n')); err != nil { |
| + if _, err := tmp.Write(body); err != nil { |
| tmp.Close() |
| return err |
| } |
| @@ -182,14 +327,24 @@ func validGenerations(g Generations) error { |
| } |
| } |
| } |
| + for host, paths := range g.Paths { |
| + for _, p := range paths { |
| + if p == "" || !filepath.IsAbs(p) { |
| + return fmt.Errorf("host %s names staged path %q, which is not absolute", host, p) |
| + } |
| + } |
| + } |
| return nil |
| } |
| // GenerationPins is the PinSource for the generations file at path. Each |
| -// listed version is placed under its host's own account and app, from rows. |
| -// A file ReadGenerations refuses, or a listed host with no row to place its |
| -// generations, is an error: those bytes are running somewhere this pass |
| -// cannot name, so nothing may be restaged. |
| +// listed version is placed under its host's own account and app, from rows, |
| +// and each listed path is pinned as written (cleaned), with the real path it |
| +// resolves to when it exists, and needs no row. A file ReadGenerations |
| +// refuses, a listed version whose host has no row to place it, or a path |
| +// whose resolution fails other than by not existing, is an error: those |
| +// bytes are running somewhere this pass cannot name, so nothing may be |
| +// restaged. |
| func GenerationPins(path string, rows func() ([]registry.Route, error)) PinSource { |
| return PinSourceFunc(func(_ context.Context, optDir string) (StagedPaths, error) { |
| g, err := ReadGenerations(path) |
| @@ -197,7 +352,17 @@ func GenerationPins(path string, rows func() ([]registry.Route, error)) PinSourc |
| return nil, err |
| } |
| pins := StagedPaths{} |
| - if g == nil || len(g.Hosts) == 0 { |
| + if g == nil { |
| + return pins, nil |
| + } |
| + for _, paths := range g.Paths { |
| + for _, p := range paths { |
| + if err := pins.addReal(p); err != nil { |
| + return nil, fmt.Errorf("generations file %s: %w", path, err) |
| + } |
| + } |
| + } |
| + if len(g.Hosts) == 0 { |
| return pins, nil |
| } |
| rs, err := rows() |
diff --git a/internal/release/pins_test.go b/internal/release/pins_test.go| index 0fc767741..7fd86386d 100644 |
| --- a/internal/release/pins_test.go |
| +++ b/internal/release/pins_test.go |
| @@ -136,9 +136,10 @@ func TestWriteGenerationsIsAtomicAndReadable(t *testing.T) { |
| if err != nil || info.Mode().Perm() != 0o644 { |
| t.Fatalf("mode = %v, %v; want 0644 so root and the agent can both read it", info.Mode(), err) |
| } |
| + // Ruling R19 item 4 adds the intent file beside it. |
| entries, err := os.ReadDir(filepath.Dir(path)) |
| - if err != nil || len(entries) != 1 { |
| - t.Fatalf("zdd dir holds %v, %v; want only generations.json, no temp file left behind", entries, err) |
| + if err != nil || len(entries) != 2 || entries[0].Name() != "generations.json" || entries[1].Name() != "generations.seq" { |
| + t.Fatalf("zdd dir holds %v, %v; want only generations.json and generations.seq, no temp file left behind", entries, err) |
| } |
| if err := WriteGenerations(path, Generations{Hosts: map[string][]string{"a.example.com": {"../escape"}}}); err == nil { |
| t.Fatal("WriteGenerations wrote a version that is not a version") |