carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 8cf3efdda792a83dcb72678ef09a2b8c0d357ca8 parent 44e0570
2 files changed, +410 −0
  • internal/release/pins.go +216 −0
  • internal/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)
+ }
+ })
+ }
+}