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 /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") } // Ruling R19 item 1: the version sweep keys pins by version directory. Every // listed version is one, and every listed path inside the version store, as // written or by its real path under a symlinked opt dir, names its version // directory. A path outside the store names none. An incomplete file is an // error, and neither file is no versions. func TestGenerationVersionsNameTheVersionDirectories(t *testing.T) { real := t.TempDir() opt := filepath.Join(t.TempDir(), "opt") if err := os.Symlink(real, opt); err != nil { t.Fatal(err) } for _, v := range []string{"v2", "v3"} { if err := StageArtifact(real, "kqmv", v, "km", []byte(v)); err != nil { t.Fatal(err) } } path := GenerationsPath(t.TempDir()) if got, err := GenerationVersions(path, opt); err != nil || len(got) != 0 { t.Fatalf("neither file: GenerationVersions = %v, %v; want none", got, err) } g := Generations{ Hosts: map[string][]string{"a.example.com": {"v1"}}, Paths: map[string][]string{ "b.example.com": {VersionPath(opt, "kqmv", "v2", "km")}, "c.example.com": {VersionPath(real, "kqmv", "v3", "km"), "/srv/operator/app"}, }, } if err := WriteGenerations(path, g); err != nil { t.Fatal(err) } got, err := GenerationVersions(path, opt) if err != nil || !reflect.DeepEqual(got, map[string]bool{"v1": true, "v2": true, "v3": true}) { t.Fatalf("GenerationVersions = %v, %v; want v1, v2 and v3", got, err) } writeGenFiles(t, path, "9", "") if got, err := GenerationVersions(path, opt); err == nil { t.Fatalf("GenerationVersions on an incomplete file = %v, nil; want an error", got) } }