| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "reflect" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/registry" |
| 12 | ) |
| 13 | |
| 14 | // writeGenFiles writes the intent file and the generations file by hand, as |
| 15 | // a crashed or failed publication would leave them. An empty body leaves |
| 16 | // that file absent. |
| 17 | func writeGenFiles(t *testing.T, path, intent, body string) { |
| 18 | t.Helper() |
| 19 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 20 | t.Fatal(err) |
| 21 | } |
| 22 | if intent != "" { |
| 23 | if err := os.WriteFile(GenerationsSeqPath(path), []byte(intent), 0o644); err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | } |
| 27 | if body != "" { |
| 28 | if err := os.WriteFile(path, []byte(body), 0o644); err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | func noRows() ([]registry.Route, error) { return nil, nil } |
| 35 | |
| 36 | // Ruling R19 item 4: a successful publication writes the intent, then the |
| 37 | // file carrying the same sequence and the complete marker, both 0644, no temp |
| 38 | // file left behind; each publication advances the sequence; and the reader |
| 39 | // returns what was written. |
| 40 | func TestGenerationsPublicationRoundTrips(t *testing.T) { |
| 41 | path := GenerationsPath(t.TempDir()) |
| 42 | first := Generations{Hosts: map[string][]string{"a.example.com": {"v1"}}} |
| 43 | if err := WriteGenerations(path, first); err != nil { |
| 44 | t.Fatalf("WriteGenerations: %v", err) |
| 45 | } |
| 46 | want := Generations{ |
| 47 | Hosts: map[string][]string{"a.example.com": {"v2"}}, |
| 48 | Paths: map[string][]string{"a.example.com": {"/opt/carlos/versions/v2/km"}, "b.example.com": {"/srv/operator/app"}}, |
| 49 | } |
| 50 | if err := WriteGenerations(path, want); err != nil { |
| 51 | t.Fatalf("WriteGenerations: %v", err) |
| 52 | } |
| 53 | got, err := ReadGenerations(path) |
| 54 | if err != nil || got == nil { |
| 55 | t.Fatalf("ReadGenerations = %+v, %v", got, err) |
| 56 | } |
| 57 | if got.Schema != 1 || got.Seq != 2 || !got.Complete || !reflect.DeepEqual(got.Hosts, want.Hosts) || !reflect.DeepEqual(got.Paths, want.Paths) { |
| 58 | t.Fatalf("read back %+v; want schema 1, seq 2, complete, and the hosts and paths written", got) |
| 59 | } |
| 60 | intent, err := os.ReadFile(GenerationsSeqPath(path)) |
| 61 | if err != nil || strings.TrimSpace(string(intent)) != "2" { |
| 62 | t.Fatalf("intent file = %q, %v; want 2", intent, err) |
| 63 | } |
| 64 | for _, p := range []string{path, GenerationsSeqPath(path)} { |
| 65 | if info, err := os.Stat(p); err != nil || info.Mode().Perm() != 0o644 { |
| 66 | t.Fatalf("%s mode = %v, %v; want 0644", p, info, err) |
| 67 | } |
| 68 | } |
| 69 | entries, err := os.ReadDir(filepath.Dir(path)) |
| 70 | if err != nil || len(entries) != 2 { |
| 71 | t.Fatalf("zdd dir holds %v, %v; want generations.json and generations.seq only", entries, err) |
| 72 | } |
| 73 | if GenerationsSeqPath(path) != filepath.Join(filepath.Dir(path), "generations.seq") { |
| 74 | t.Fatalf("GenerationsSeqPath = %s, want <data dir>/zdd/generations.seq", GenerationsSeqPath(path)) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | // The sequence is monotonic across writers (an agent restart): the next |
| 79 | // publication reads the intent on disk and adds one. |
| 80 | func TestGenerationsSequenceContinuesFromTheIntentOnDisk(t *testing.T) { |
| 81 | path := GenerationsPath(t.TempDir()) |
| 82 | writeGenFiles(t, path, "41\n", `{"schema":1,"seq":41,"complete":true,"hosts":{}}`) |
| 83 | if err := WriteGenerations(path, Generations{}); err != nil { |
| 84 | t.Fatalf("WriteGenerations: %v", err) |
| 85 | } |
| 86 | if g, err := ReadGenerations(path); err != nil || g.Seq != 42 { |
| 87 | t.Fatalf("ReadGenerations = %+v, %v; want seq 42", g, err) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | // A publication whose file write fails leaves the intent ahead of the file |
| 92 | // on disk, so the older file, still readable, is no longer trusted. |
| 93 | func TestAFailedGenerationsWriteLeavesTheIntentAhead(t *testing.T) { |
| 94 | path := GenerationsPath(t.TempDir()) |
| 95 | if err := WriteGenerations(path, Generations{Hosts: map[string][]string{"a.example.com": {"v1"}}}); err != nil { |
| 96 | t.Fatal(err) |
| 97 | } |
| 98 | // The file's rename fails: a non-empty directory sits at its path. |
| 99 | if err := os.Remove(path); err != nil { |
| 100 | t.Fatal(err) |
| 101 | } |
| 102 | if err := os.MkdirAll(filepath.Join(path, "blocker"), 0o755); err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | if err := WriteGenerations(path, Generations{Hosts: map[string][]string{"a.example.com": {"v2"}}}); err == nil { |
| 106 | t.Fatal("WriteGenerations succeeded with a directory at the file's path") |
| 107 | } |
| 108 | if intent, err := os.ReadFile(GenerationsSeqPath(path)); err != nil || strings.TrimSpace(string(intent)) != "2" { |
| 109 | t.Fatalf("intent after a failed file write = %q, %v; want 2, written before the file", intent, err) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // Ruling R19 item 4, the reader: with the intent present, anything but a |
| 114 | // complete file carrying the intent's sequence is incomplete, for the pin set |
| 115 | // as for the reader. |
| 116 | func TestGenerationsFileIncompleteUnlessItMatchesTheIntent(t *testing.T) { |
| 117 | for name, files := range map[string][2]string{ |
| 118 | "intent ahead of the file": {"2\n", `{"schema":1,"seq":1,"complete":true,"hosts":{}}`}, |
| 119 | "stale seq, no paths": {"3", `{"schema":1,"seq":2,"complete":true,"hosts":{"a.example.com":["v1"]}}`}, |
| 120 | "file without complete": {"1", `{"schema":1,"seq":1,"hosts":{}}`}, |
| 121 | "file with complete false": {"1", `{"schema":1,"seq":1,"complete":false,"hosts":{}}`}, |
| 122 | "file without seq": {"1", `{"schema":1,"complete":true,"hosts":{}}`}, |
| 123 | "intent and no file": {"1", ""}, |
| 124 | "unreadable intent": {"one", `{"schema":1,"seq":1,"complete":true,"hosts":{}}`}, |
| 125 | "zero intent": {"0", `{"schema":1,"complete":true,"hosts":{}}`}, |
| 126 | "file with seq and no intent": {"", `{"schema":1,"seq":1,"complete":true,"hosts":{}}`}, |
| 127 | "relative path": {"1", `{"schema":1,"seq":1,"complete":true,"paths":{"a.example.com":["versions/v1/km"]}}`}, |
| 128 | "empty path": {"1", `{"schema":1,"seq":1,"complete":true,"paths":{"a.example.com":[""]}}`}, |
| 129 | "legacy file with relative path": {"", `{"schema":1,"paths":{"a.example.com":["km"]}}`}, |
| 130 | } { |
| 131 | t.Run(name, func(t *testing.T) { |
| 132 | path := GenerationsPath(t.TempDir()) |
| 133 | writeGenFiles(t, path, files[0], files[1]) |
| 134 | if g, err := ReadGenerations(path); err == nil { |
| 135 | t.Fatalf("ReadGenerations = %+v, nil; want an error (incomplete)", g) |
| 136 | } |
| 137 | if pins, err := GenerationPins(path, noRows).StagedPins(context.Background(), t.TempDir()); err == nil { |
| 138 | t.Fatalf("StagedPins = %v, nil; want an error (incomplete pin set)", pins) |
| 139 | } |
| 140 | }) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // Neither file present is no generations: every box before the agent first |
| 145 | // published. A file with neither seq nor intent (schema 1 as first written) |
| 146 | // still reads, with hosts, paths, or both. |
| 147 | func TestGenerationsFileWithoutIntent(t *testing.T) { |
| 148 | path := GenerationsPath(t.TempDir()) |
| 149 | if g, err := ReadGenerations(path); err != nil || g != nil { |
| 150 | t.Fatalf("neither file: ReadGenerations = %+v, %v; want nil, nil", g, err) |
| 151 | } |
| 152 | writeGenFiles(t, path, "", `{"schema":1,"paths":{"a.example.com":["/opt/carlos/versions/v1/km"]}}`) |
| 153 | g, err := ReadGenerations(path) |
| 154 | if err != nil || g == nil || !reflect.DeepEqual(g.Paths, map[string][]string{"a.example.com": {"/opt/carlos/versions/v1/km"}}) { |
| 155 | t.Fatalf("paths-only legacy file: ReadGenerations = %+v, %v", g, err) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | // Ruling R19 item 2: a path entry is pinned as written (cleaned) and needs no |
| 160 | // registry row; version entries still do. |
| 161 | func TestGenerationPinsPinPathsWithoutARow(t *testing.T) { |
| 162 | path := GenerationsPath(t.TempDir()) |
| 163 | if err := WriteGenerations(path, Generations{Paths: map[string][]string{"gone.example.com": {"/opt/carlos/versions/v1/./km"}}}); err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | rowsAsked := false |
| 167 | rows := func() ([]registry.Route, error) { rowsAsked = true; return nil, nil } |
| 168 | pins, err := GenerationPins(path, rows).StagedPins(context.Background(), t.TempDir()) |
| 169 | if err != nil || !pins.Has("/opt/carlos/versions/v1/km") { |
| 170 | t.Fatalf("StagedPins = %v, %v; want the path pinned", pins, err) |
| 171 | } |
| 172 | if rowsAsked { |
| 173 | t.Fatal("a paths-only file asked for registry rows") |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | // WriteGenerations refuses a relative or empty path before it touches either |
| 178 | // file, so a refused publication never leaves the intent ahead. |
| 179 | func TestWriteGenerationsRefusesARelativePath(t *testing.T) { |
| 180 | path := GenerationsPath(t.TempDir()) |
| 181 | for _, bad := range []string{"versions/v1/km", ""} { |
| 182 | if err := WriteGenerations(path, Generations{Paths: map[string][]string{"a.example.com": {bad}}}); err == nil { |
| 183 | t.Fatalf("WriteGenerations wrote path %q", bad) |
| 184 | } |
| 185 | } |
| 186 | if _, err := os.Stat(GenerationsSeqPath(path)); !os.IsNotExist(err) { |
| 187 | t.Fatalf("a refused write left an intent file: %v", err) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | // Ruling R19 item 2, Astra r3 F1(b): a worker exec'd through a chained live |
| 192 | // link (live/h2 -> operator-current -> the staged v2) has Version "", because |
| 193 | // the link's own target is outside the version store, yet it runs v2's staged |
| 194 | // bytes. The agent publishes it by path. Once live/h2 has moved on and no row |
| 195 | // names v2, that path entry alone keeps a re-shipped v2 from being restaged |
| 196 | // over it. |
| 197 | func TestAPathEntryProtectsAChainedLinksStagedCopy(t *testing.T) { |
| 198 | f := newReuseFixture(t) |
| 199 | current := filepath.Join(t.TempDir(), "operator-current") |
| 200 | if err := os.Symlink(VersionPath(f.opt, "bab", "v2", "myapp"), current); err != nil { |
| 201 | t.Fatal(err) |
| 202 | } |
| 203 | if err := os.Symlink(current, LiveLink(f.opt, "h2.example.com")); err != nil { |
| 204 | t.Fatal(err) |
| 205 | } |
| 206 | id, err := ResolveLive(f.opt, "h2.example.com") |
| 207 | if err != nil { |
| 208 | t.Fatalf("ResolveLive: %v", err) |
| 209 | } |
| 210 | if id.Version != "" || id.StagedPath != VersionPath(f.opt, "bab", "v2", "myapp") { |
| 211 | t.Fatalf("captured %+v; want no Version and v2's staged path", id) |
| 212 | } |
| 213 | dataDir := t.TempDir() |
| 214 | if err := WriteGenerations(GenerationsPath(dataDir), Generations{Paths: map[string][]string{"h2.example.com": {id.StagedPath}}}); err != nil { |
| 215 | t.Fatal(err) |
| 216 | } |
| 217 | // The live link moves on; only the generations file still names the copy. |
| 218 | if err := os.Remove(LiveLink(f.opt, "h2.example.com")); err != nil { |
| 219 | t.Fatal(err) |
| 220 | } |
| 221 | f.assertRefused(t, f.pass(t, BakeDir(dataDir)), ErrVersionReused.Error()) |
| 222 | } |
| 223 | |
| 224 | // Ruling R19 item 4 through the pass: a file left behind by a publication |
| 225 | // whose intent moved on is not trusted, so nothing is restaged. |
| 226 | func TestAStaleGenerationsFileNeverPermitsARestage(t *testing.T) { |
| 227 | f := newReuseFixture(t) |
| 228 | dataDir := t.TempDir() |
| 229 | path := GenerationsPath(dataDir) |
| 230 | writeGenFiles(t, path, "2", `{"schema":1,"seq":1,"complete":true,"hosts":{}}`) |
| 231 | f.assertRefused(t, f.pass(t, BakeDir(dataDir)), "what refers to it is unknown") |
| 232 | } |
| 233 | |
| 234 | // Ruling R19 item 1: the version sweep keys pins by version directory. Every |
| 235 | // listed version is one, and every listed path inside the version store, as |
| 236 | // written or by its real path under a symlinked opt dir, names its version |
| 237 | // directory. A path outside the store names none. An incomplete file is an |
| 238 | // error, and neither file is no versions. |
| 239 | func TestGenerationVersionsNameTheVersionDirectories(t *testing.T) { |
| 240 | real := t.TempDir() |
| 241 | opt := filepath.Join(t.TempDir(), "opt") |
| 242 | if err := os.Symlink(real, opt); err != nil { |
| 243 | t.Fatal(err) |
| 244 | } |
| 245 | for _, v := range []string{"v2", "v3"} { |
| 246 | if err := StageArtifact(real, "kqmv", v, "km", []byte(v)); err != nil { |
| 247 | t.Fatal(err) |
| 248 | } |
| 249 | } |
| 250 | path := GenerationsPath(t.TempDir()) |
| 251 | if got, err := GenerationVersions(path, opt); err != nil || len(got) != 0 { |
| 252 | t.Fatalf("neither file: GenerationVersions = %v, %v; want none", got, err) |
| 253 | } |
| 254 | g := Generations{ |
| 255 | Hosts: map[string][]string{"a.example.com": {"v1"}}, |
| 256 | Paths: map[string][]string{ |
| 257 | "b.example.com": {VersionPath(opt, "kqmv", "v2", "km")}, |
| 258 | "c.example.com": {VersionPath(real, "kqmv", "v3", "km"), "/srv/operator/app"}, |
| 259 | }, |
| 260 | } |
| 261 | if err := WriteGenerations(path, g); err != nil { |
| 262 | t.Fatal(err) |
| 263 | } |
| 264 | got, err := GenerationVersions(path, opt) |
| 265 | if err != nil || !reflect.DeepEqual(got, map[string]bool{"v1": true, "v2": true, "v3": true}) { |
| 266 | t.Fatalf("GenerationVersions = %v, %v; want v1, v2 and v3", got, err) |
| 267 | } |
| 268 | writeGenFiles(t, path, "9", "") |
| 269 | if got, err := GenerationVersions(path, opt); err == nil { |
| 270 | t.Fatalf("GenerationVersions on an incomplete file = %v, nil; want an error", got) |
| 271 | } |
| 272 | } |
| 273 | |