zdd-4 task 5: the staged-copy pin set, PinSource, and the generations file format
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+410
−0
internal/release/pins.go+216 −0internal/release/pins_test.go+194 −0
diff --git a/internal/release/pins.go b/internal/release/pins.go| new file mode 100644 |
| index 000000000..6401f082d |
| --- /dev/null |
| +++ b/internal/release/pins.go |
| @@ -0,0 +1,216 @@ |
| +// The pin set (zero-downtime deploys spec §4.2, §8.5): every staged copy |
| +// something on this box still refers to. A staged copy in it is never |
| +// overwritten, because staged paths are keyed by version name with no digest |
| +// and another instance's good build or running generation would change |
| +// underneath it. |
| +package release |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// StagedPaths is a set of staged artifact paths (VersionPath values, held |
| +// cleaned) that something on this box refers to. |
| +type StagedPaths map[string]bool |
| + |
| +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)] } |
| + |
| +// 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. |
| +type PinSource interface { |
| + StagedPins(ctx context.Context, optDir string) (StagedPaths, error) |
| +} |
| + |
| +// PinSourceFunc adapts a function to PinSource. |
| +type PinSourceFunc func(ctx context.Context, optDir string) (StagedPaths, error) |
| + |
| +func (f PinSourceFunc) StagedPins(ctx context.Context, optDir string) (StagedPaths, error) { |
| + return f(ctx, optDir) |
| +} |
| + |
| +// BoxPins is the part of the pin set this package reads itself: the target |
| +// of every entry under <opt>/live (a crashed swap's <host>.tmp included, as |
| +// LivePins includes it), and the staged path of every row's Version and |
| +// PrevVersion. routes is every registry row, unfiltered: a hibernating route, |
| +// an expired canary and a mid-provision row all still refer to their builds. |
| +// |
| +// An entry that cannot be read is an error, never a skipped entry: a pin set |
| +// that quietly narrowed is how a running build's bytes get replaced. |
| +func BoxPins(optDir string, routes []registry.Route) (StagedPaths, error) { |
| + pins := StagedPaths{} |
| + dir := liveDir(optDir) |
| + entries, err := os.ReadDir(dir) |
| + if err != nil && !os.IsNotExist(err) { |
| + return nil, fmt.Errorf("read %s: %w", dir, err) |
| + } |
| + for _, e := range entries { |
| + link := filepath.Join(dir, e.Name()) |
| + target, err := os.Readlink(link) |
| + if err != nil { |
| + return nil, fmt.Errorf("read live link %s: %w", link, err) |
| + } |
| + if !filepath.IsAbs(target) { |
| + target = filepath.Join(dir, target) |
| + } |
| + pins.add(target) |
| + } |
| + for _, rt := range routes { |
| + if rt.App == "" { |
| + continue |
| + } |
| + for _, v := range []string{rt.Version, rt.PrevVersion} { |
| + if ValidVersion(v) { |
| + pins.add(VersionPath(optDir, RouteAccount(rt), v, rt.App)) |
| + } |
| + } |
| + } |
| + return pins, nil |
| +} |
| + |
| +// Generations is the generations file: for each host with a live or pending |
| +// generation (current, successor, retiring, spawning: spec §8.5), the version |
| +// 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). Plan 5's agent writes it |
| +// with WriteGenerations whenever its operation set changes, and before its |
| +// first spawn. |
| +// |
| +// On disk: {"schema":1,"hosts":{"<host>":["<version>",...]}} |
| +type Generations struct { |
| + Schema int `json:"schema"` // 1 |
| + Hosts map[string][]string `json:"hosts"` |
| +} |
| + |
| +// GenerationsPath is the generations file under a data dir: |
| +// /data/zdd/generations.json in production. The data dir is what every |
| +// production pass already passes as BakeDir, and the one place both the |
| +// sandboxed agent and root can write and read. |
| +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. |
| +func ReadGenerations(path string) (*Generations, error) { |
| + raw, err := os.ReadFile(path) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, fmt.Errorf("generations file %s: %w", path, err) |
| + } |
| + var g Generations |
| + if err := json.Unmarshal(raw, &g); err != nil { |
| + return nil, fmt.Errorf("generations file %s: %v", path, err) |
| + } |
| + if g.Schema != 1 { |
| + return nil, fmt.Errorf("generations file %s: unsupported schema %d", path, g.Schema) |
| + } |
| + if err := validGenerations(g); err != nil { |
| + return nil, fmt.Errorf("generations file %s: %w", path, err) |
| + } |
| + 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. |
| +func WriteGenerations(path string, g Generations) error { |
| + g.Schema = 1 |
| + if g.Hosts == nil { |
| + g.Hosts = map[string][]string{} |
| + } |
| + if err := validGenerations(g); err != nil { |
| + return err |
| + } |
| + body, err := json.MarshalIndent(g, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + dir := filepath.Dir(path) |
| + if err := os.MkdirAll(dir, 0o755); err != nil { |
| + return err |
| + } |
| + tmp, err := os.CreateTemp(dir, ".generations-*") |
| + if err != nil { |
| + return err |
| + } |
| + defer os.Remove(tmp.Name()) |
| + if _, err := tmp.Write(append(body, '\n')); err != nil { |
| + tmp.Close() |
| + return err |
| + } |
| + if err := tmp.Chmod(0o644); err != nil { |
| + tmp.Close() |
| + return err |
| + } |
| + if err := tmp.Sync(); err != nil { |
| + tmp.Close() |
| + return err |
| + } |
| + if err := tmp.Close(); err != nil { |
| + return err |
| + } |
| + return os.Rename(tmp.Name(), path) |
| +} |
| + |
| +func validGenerations(g Generations) error { |
| + for host, vers := range g.Hosts { |
| + for _, v := range vers { |
| + if !ValidVersion(v) { |
| + return fmt.Errorf("host %s names invalid version %q", host, v) |
| + } |
| + } |
| + } |
| + 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. |
| +func GenerationPins(path string, rows func() ([]registry.Route, error)) PinSource { |
| + return PinSourceFunc(func(_ context.Context, optDir string) (StagedPaths, error) { |
| + g, err := ReadGenerations(path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + pins := StagedPaths{} |
| + if g == nil || len(g.Hosts) == 0 { |
| + return pins, nil |
| + } |
| + rs, err := rows() |
| + if err != nil { |
| + return nil, fmt.Errorf("list routes: %w", err) |
| + } |
| + byHost := make(map[string]registry.Route, len(rs)) |
| + for _, rt := range rs { |
| + byHost[rt.Host] = rt |
| + } |
| + for host, vers := range g.Hosts { |
| + rt, ok := byHost[host] |
| + if !ok || rt.App == "" { |
| + return nil, fmt.Errorf("generations file %s: %s has running generations and no registry row to place them", path, host) |
| + } |
| + for _, v := range vers { |
| + pins.add(VersionPath(optDir, RouteAccount(rt), v, rt.App)) |
| + } |
| + } |
| + return pins, nil |
| + }) |
| +} |
diff --git a/internal/release/pins_test.go b/internal/release/pins_test.go| new file mode 100644 |
| index 000000000..337c00620 |
| --- /dev/null |
| +++ b/internal/release/pins_test.go |
| @@ -0,0 +1,194 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func TestBoxPinsNamesLiveTargetsAndRegistryRows(t *testing.T) { |
| + opt := t.TempDir() |
| + for _, v := range []string{"v1", "v2"} { |
| + if err := StageArtifact(opt, "bab", v, "km", []byte(v)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := StageArtifact(opt, "kqmv", "v3", "km", []byte("v3")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PointLive(opt, "a.example.com", "bab", "v1", "km"); err != nil { |
| + t.Fatal(err) |
| + } |
| + routes := []registry.Route{ |
| + {Host: "b.example.com", Kind: "instance", App: "km", Version: "v2", PrevVersion: "v1"}, |
| + {Host: "c.example.com", Kind: "sidecar", App: "km", Account: "kqmv", Version: "v3"}, |
| + {Host: "d.example.com", Kind: "instance", App: "", Version: "v9"}, // no app: names no staged path |
| + } |
| + pins, err := BoxPins(opt, routes) |
| + if err != nil { |
| + t.Fatalf("BoxPins: %v", err) |
| + } |
| + want := []string{VersionPath(opt, "bab", "v1", "km"), VersionPath(opt, "bab", "v2", "km"), VersionPath(opt, "kqmv", "v3", "km")} |
| + if len(pins) != len(want) { |
| + t.Fatalf("BoxPins = %v, want exactly %v", pins, want) |
| + } |
| + for _, p := range want { |
| + if !pins.Has(p) { |
| + t.Errorf("BoxPins is missing %s", p) |
| + } |
| + } |
| +} |
| + |
| +// A crashed PointLive leaves <host>.tmp naming a real staged tree. It is a |
| +// reference, as it is to LivePins. |
| +func TestBoxPinsKeepsATmpLeftover(t *testing.T) { |
| + opt := t.TempDir() |
| + if err := StageArtifact(opt, "bab", "v1", "km", []byte("v1")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(VersionPath(opt, "bab", "v1", "km"), LiveLink(opt, "a.example.com")+".tmp"); err != nil { |
| + t.Fatal(err) |
| + } |
| + pins, err := BoxPins(opt, nil) |
| + if err != nil || !pins.Has(VersionPath(opt, "bab", "v1", "km")) { |
| + t.Fatalf("BoxPins = %v, %v; want the leftover's target pinned", pins, err) |
| + } |
| +} |
| + |
| +// Losing a pin is how a running build gets overwritten, so an unreadable |
| +// entry stops the answer rather than narrowing it. |
| +func TestBoxPinsFailsOnAnUnreadableLink(t *testing.T) { |
| + opt := t.TempDir() |
| + if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(opt, "live", "not-a-link"), []byte("x"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := BoxPins(opt, nil); err == nil { |
| + t.Fatal("BoxPins read past an entry it could not interpret") |
| + } |
| +} |
| + |
| +func TestBoxPinsWithNoLiveDir(t *testing.T) { |
| + pins, err := BoxPins(t.TempDir(), nil) |
| + if err != nil || len(pins) != 0 { |
| + t.Fatalf("BoxPins on a fresh box = %v, %v; want empty and no error", pins, err) |
| + } |
| +} |
| + |
| +func TestPinSourceFuncIsAPinSource(t *testing.T) { |
| + var src PinSource = PinSourceFunc(func(context.Context, string) (StagedPaths, error) { |
| + return StagedPaths{"/opt/carlos/versions/v1/km": true}, nil |
| + }) |
| + got, err := src.StagedPins(context.Background(), "/opt/carlos") |
| + if err != nil || !got.Has("/opt/carlos/versions/v1/./km") { |
| + t.Fatalf("StagedPins = %v, %v", got, err) |
| + } |
| +} |
| + |
| +// The documented format, written by hand, is what ReadGenerations reads. |
| +func TestReadGenerationsReadsTheDocumentedFormat(t *testing.T) { |
| + path := GenerationsPath(t.TempDir()) |
| + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, []byte(`{"schema":1,"hosts":{"a.example.com":["v1","v2"]}}`), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + g, err := ReadGenerations(path) |
| + if err != nil || g == nil || !reflect.DeepEqual(g.Hosts, map[string][]string{"a.example.com": {"v1", "v2"}}) { |
| + t.Fatalf("ReadGenerations = %+v, %v", g, err) |
| + } |
| + if !strings.HasSuffix(path, filepath.Join("zdd", "generations.json")) { |
| + t.Fatalf("GenerationsPath = %s, want <data dir>/zdd/generations.json", path) |
| + } |
| +} |
| + |
| +// Every box before plan 5 has no file: that is no generations, not unknown. |
| +func TestReadGenerationsAbsentIsNone(t *testing.T) { |
| + g, err := ReadGenerations(GenerationsPath(t.TempDir())) |
| + if err != nil || g != nil { |
| + t.Fatalf("ReadGenerations on a box with no file = %+v, %v; want nil, nil", g, err) |
| + } |
| +} |
| + |
| +func TestWriteGenerationsIsAtomicAndReadable(t *testing.T) { |
| + dataDir := t.TempDir() |
| + path := GenerationsPath(dataDir) |
| + want := Generations{Hosts: map[string][]string{"a.example.com": {"v1"}, "b.example.com": {"v2", "v3"}}} |
| + if err := WriteGenerations(path, want); err != nil { |
| + t.Fatalf("WriteGenerations: %v", err) |
| + } |
| + got, err := ReadGenerations(path) |
| + if err != nil || got.Schema != 1 || !reflect.DeepEqual(got.Hosts, want.Hosts) { |
| + t.Fatalf("read back %+v, %v", got, err) |
| + } |
| + info, err := os.Stat(path) |
| + 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) |
| + } |
| + 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 := WriteGenerations(path, Generations{Hosts: map[string][]string{"a.example.com": {"../escape"}}}); err == nil { |
| + t.Fatal("WriteGenerations wrote a version that is not a version") |
| + } |
| + if got, _ := ReadGenerations(path); !reflect.DeepEqual(got.Hosts, want.Hosts) { |
| + t.Fatalf("a refused write changed the file: %+v", got) |
| + } |
| +} |
| + |
| +func TestGenerationPinsPlaceVersionsUnderTheHostsApp(t *testing.T) { |
| + opt, path := t.TempDir(), GenerationsPath(t.TempDir()) |
| + if err := WriteGenerations(path, Generations{Hosts: map[string][]string{"c.example.com": {"v3", "v4"}}}); err != nil { |
| + t.Fatal(err) |
| + } |
| + rows := func() ([]registry.Route, error) { |
| + return []registry.Route{{Host: "c.example.com", Kind: "instance", App: "km", Account: "kqmv"}}, nil |
| + } |
| + pins, err := GenerationPins(path, rows).StagedPins(context.Background(), opt) |
| + if err != nil { |
| + t.Fatalf("StagedPins: %v", err) |
| + } |
| + for _, v := range []string{"v3", "v4"} { |
| + if !pins.Has(VersionPath(opt, "kqmv", v, "km")) { |
| + t.Errorf("pins %v miss %s", pins, v) |
| + } |
| + } |
| +} |
| + |
| +// Review Focus 5: a file root cannot trust makes the pin set incomplete. |
| +func TestGenerationPinsFailClosed(t *testing.T) { |
| + rows := func() ([]registry.Route, error) { |
| + return []registry.Route{{Host: "a.example.com", Kind: "instance", App: "km"}}, nil |
| + } |
| + for name, body := range map[string]string{ |
| + "half-written": `{"schema":1,"hosts":{"a.example.com":["v1"`, |
| + "newer schema": `{"schema":2,"hosts":{"a.example.com":["v1"]}}`, |
| + "bad version": `{"schema":1,"hosts":{"a.example.com":["../v1"]}}`, |
| + "host with no row": `{"schema":1,"hosts":{"gone.example.com":["v1"]}}`, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + path := GenerationsPath(t.TempDir()) |
| + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if pins, err := GenerationPins(path, rows).StagedPins(context.Background(), t.TempDir()); err == nil { |
| + t.Fatalf("StagedPins = %v, nil; want an error (incomplete pin set)", pins) |
| + } |
| + }) |
| + } |
| +} |