| 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 | func TestBoxPinsNamesLiveTargetsAndRegistryRows(t *testing.T) { |
| 15 | opt := t.TempDir() |
| 16 | for _, v := range []string{"v1", "v2"} { |
| 17 | if err := StageArtifact(opt, "bab", v, "km", []byte(v)); err != nil { |
| 18 | t.Fatal(err) |
| 19 | } |
| 20 | } |
| 21 | if err := StageArtifact(opt, "kqmv", "v3", "km", []byte("v3")); err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | if err := PointLive(opt, "a.example.com", "bab", "v1", "km"); err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | routes := []registry.Route{ |
| 28 | {Host: "b.example.com", Kind: "instance", App: "km", Version: "v2", PrevVersion: "v1"}, |
| 29 | {Host: "c.example.com", Kind: "sidecar", App: "km", Account: "kqmv", Version: "v3"}, |
| 30 | {Host: "d.example.com", Kind: "instance", App: "", Version: "v9"}, // no app: names no staged path |
| 31 | } |
| 32 | pins, err := BoxPins(opt, routes) |
| 33 | if err != nil { |
| 34 | t.Fatalf("BoxPins: %v", err) |
| 35 | } |
| 36 | want := []string{VersionPath(opt, "bab", "v1", "km"), VersionPath(opt, "bab", "v2", "km"), VersionPath(opt, "kqmv", "v3", "km")} |
| 37 | if len(pins) != len(want) { |
| 38 | t.Fatalf("BoxPins = %v, want exactly %v", pins, want) |
| 39 | } |
| 40 | for _, p := range want { |
| 41 | if !pins.Has(p) { |
| 42 | t.Errorf("BoxPins is missing %s", p) |
| 43 | } |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | // A crashed PointLive leaves <host>.tmp naming a real staged tree. It is a |
| 48 | // reference, as it is to LivePins. |
| 49 | func TestBoxPinsKeepsATmpLeftover(t *testing.T) { |
| 50 | opt := t.TempDir() |
| 51 | if err := StageArtifact(opt, "bab", "v1", "km", []byte("v1")); err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | if err := os.Symlink(VersionPath(opt, "bab", "v1", "km"), LiveLink(opt, "a.example.com")+".tmp"); err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | pins, err := BoxPins(opt, nil) |
| 61 | if err != nil || !pins.Has(VersionPath(opt, "bab", "v1", "km")) { |
| 62 | t.Fatalf("BoxPins = %v, %v; want the leftover's target pinned", pins, err) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | // Losing a pin is how a running build gets overwritten, so an unreadable |
| 67 | // entry stops the answer rather than narrowing it. |
| 68 | func TestBoxPinsFailsOnAnUnreadableLink(t *testing.T) { |
| 69 | opt := t.TempDir() |
| 70 | if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | if err := os.WriteFile(filepath.Join(opt, "live", "not-a-link"), []byte("x"), 0o644); err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | if _, err := BoxPins(opt, nil); err == nil { |
| 77 | t.Fatal("BoxPins read past an entry it could not interpret") |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | func TestBoxPinsWithNoLiveDir(t *testing.T) { |
| 82 | pins, err := BoxPins(t.TempDir(), nil) |
| 83 | if err != nil || len(pins) != 0 { |
| 84 | t.Fatalf("BoxPins on a fresh box = %v, %v; want empty and no error", pins, err) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | func TestPinSourceFuncIsAPinSource(t *testing.T) { |
| 89 | var src PinSource = PinSourceFunc(func(context.Context, string) (StagedPaths, error) { |
| 90 | return StagedPaths{"/opt/carlos/versions/v1/km": true}, nil |
| 91 | }) |
| 92 | got, err := src.StagedPins(context.Background(), "/opt/carlos") |
| 93 | if err != nil || !got.Has("/opt/carlos/versions/v1/./km") { |
| 94 | t.Fatalf("StagedPins = %v, %v", got, err) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | // The documented format, written by hand, is what ReadGenerations reads. |
| 99 | func TestReadGenerationsReadsTheDocumentedFormat(t *testing.T) { |
| 100 | path := GenerationsPath(t.TempDir()) |
| 101 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | if err := os.WriteFile(path, []byte(`{"schema":1,"hosts":{"a.example.com":["v1","v2"]}}`), 0o644); err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | g, err := ReadGenerations(path) |
| 108 | if err != nil || g == nil || !reflect.DeepEqual(g.Hosts, map[string][]string{"a.example.com": {"v1", "v2"}}) { |
| 109 | t.Fatalf("ReadGenerations = %+v, %v", g, err) |
| 110 | } |
| 111 | if !strings.HasSuffix(path, filepath.Join("zdd", "generations.json")) { |
| 112 | t.Fatalf("GenerationsPath = %s, want <data dir>/zdd/generations.json", path) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | // Every box before plan 5 has no file: that is no generations, not unknown. |
| 117 | func TestReadGenerationsAbsentIsNone(t *testing.T) { |
| 118 | g, err := ReadGenerations(GenerationsPath(t.TempDir())) |
| 119 | if err != nil || g != nil { |
| 120 | t.Fatalf("ReadGenerations on a box with no file = %+v, %v; want nil, nil", g, err) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | func TestWriteGenerationsIsAtomicAndReadable(t *testing.T) { |
| 125 | dataDir := t.TempDir() |
| 126 | path := GenerationsPath(dataDir) |
| 127 | want := Generations{Hosts: map[string][]string{"a.example.com": {"v1"}, "b.example.com": {"v2", "v3"}}} |
| 128 | if err := WriteGenerations(path, want); err != nil { |
| 129 | t.Fatalf("WriteGenerations: %v", err) |
| 130 | } |
| 131 | got, err := ReadGenerations(path) |
| 132 | if err != nil || got.Schema != 1 || !reflect.DeepEqual(got.Hosts, want.Hosts) { |
| 133 | t.Fatalf("read back %+v, %v", got, err) |
| 134 | } |
| 135 | info, err := os.Stat(path) |
| 136 | if err != nil || info.Mode().Perm() != 0o644 { |
| 137 | t.Fatalf("mode = %v, %v; want 0644 so root and the agent can both read it", info.Mode(), err) |
| 138 | } |
| 139 | // Ruling R19 item 4 adds the intent file beside it. |
| 140 | entries, err := os.ReadDir(filepath.Dir(path)) |
| 141 | if err != nil || len(entries) != 2 || entries[0].Name() != "generations.json" || entries[1].Name() != "generations.seq" { |
| 142 | t.Fatalf("zdd dir holds %v, %v; want only generations.json and generations.seq, no temp file left behind", entries, err) |
| 143 | } |
| 144 | if err := WriteGenerations(path, Generations{Hosts: map[string][]string{"a.example.com": {"../escape"}}}); err == nil { |
| 145 | t.Fatal("WriteGenerations wrote a version that is not a version") |
| 146 | } |
| 147 | if got, _ := ReadGenerations(path); !reflect.DeepEqual(got.Hosts, want.Hosts) { |
| 148 | t.Fatalf("a refused write changed the file: %+v", got) |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | func TestGenerationPinsPlaceVersionsUnderTheHostsApp(t *testing.T) { |
| 153 | opt, path := t.TempDir(), GenerationsPath(t.TempDir()) |
| 154 | if err := WriteGenerations(path, Generations{Hosts: map[string][]string{"c.example.com": {"v3", "v4"}}}); err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | rows := func() ([]registry.Route, error) { |
| 158 | return []registry.Route{{Host: "c.example.com", Kind: "instance", App: "km", Account: "kqmv"}}, nil |
| 159 | } |
| 160 | pins, err := GenerationPins(path, rows).StagedPins(context.Background(), opt) |
| 161 | if err != nil { |
| 162 | t.Fatalf("StagedPins: %v", err) |
| 163 | } |
| 164 | for _, v := range []string{"v3", "v4"} { |
| 165 | if !pins.Has(VersionPath(opt, "kqmv", v, "km")) { |
| 166 | t.Errorf("pins %v miss %s", pins, v) |
| 167 | } |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | // Review Focus 5: a file root cannot trust makes the pin set incomplete. |
| 172 | func TestGenerationPinsFailClosed(t *testing.T) { |
| 173 | rows := func() ([]registry.Route, error) { |
| 174 | return []registry.Route{{Host: "a.example.com", Kind: "instance", App: "km"}}, nil |
| 175 | } |
| 176 | for name, body := range map[string]string{ |
| 177 | "half-written": `{"schema":1,"hosts":{"a.example.com":["v1"`, |
| 178 | "newer schema": `{"schema":2,"hosts":{"a.example.com":["v1"]}}`, |
| 179 | "bad version": `{"schema":1,"hosts":{"a.example.com":["../v1"]}}`, |
| 180 | "host with no row": `{"schema":1,"hosts":{"gone.example.com":["v1"]}}`, |
| 181 | } { |
| 182 | t.Run(name, func(t *testing.T) { |
| 183 | path := GenerationsPath(t.TempDir()) |
| 184 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | if err := os.WriteFile(path, []byte(body), 0o644); err != nil { |
| 188 | t.Fatal(err) |
| 189 | } |
| 190 | if pins, err := GenerationPins(path, rows).StagedPins(context.Background(), t.TempDir()); err == nil { |
| 191 | t.Fatalf("StagedPins = %v, nil; want an error (incomplete pin set)", pins) |
| 192 | } |
| 193 | }) |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | // A live link that names another link (an operator's own "current" pointer) |
| 198 | // refers to every hop of the chain, the staged copy at its end included. |
| 199 | func TestBoxPinsPinsEveryHopOfAChainedLink(t *testing.T) { |
| 200 | opt := t.TempDir() |
| 201 | if err := StageArtifact(opt, "bab", "v1", "km", []byte("v1")); err != nil { |
| 202 | t.Fatal(err) |
| 203 | } |
| 204 | current := filepath.Join(t.TempDir(), "operator-current") |
| 205 | if err := os.Symlink(VersionPath(opt, "bab", "v1", "km"), current); err != nil { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil { |
| 209 | t.Fatal(err) |
| 210 | } |
| 211 | if err := os.Symlink(current, LiveLink(opt, "h.example.com")); err != nil { |
| 212 | t.Fatal(err) |
| 213 | } |
| 214 | pins, err := BoxPins(opt, nil) |
| 215 | if err != nil { |
| 216 | t.Fatalf("BoxPins: %v", err) |
| 217 | } |
| 218 | for _, p := range []string{current, VersionPath(opt, "bab", "v1", "km")} { |
| 219 | if !pins.Has(p) { |
| 220 | t.Errorf("BoxPins = %v, missing %s", pins, p) |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | // A chain that cannot be followed to its end is an unknown reference: no |
| 226 | // answer, so no restage. |
| 227 | func TestBoxPinsFailsOnALinkLoop(t *testing.T) { |
| 228 | opt := t.TempDir() |
| 229 | dir := t.TempDir() |
| 230 | a, b := filepath.Join(dir, "a"), filepath.Join(dir, "b") |
| 231 | if err := os.Symlink(b, a); err != nil { |
| 232 | t.Fatal(err) |
| 233 | } |
| 234 | if err := os.Symlink(a, b); err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil { |
| 238 | t.Fatal(err) |
| 239 | } |
| 240 | if err := os.Symlink(a, LiveLink(opt, "h.example.com")); err != nil { |
| 241 | t.Fatal(err) |
| 242 | } |
| 243 | if pins, err := BoxPins(opt, nil); err == nil { |
| 244 | t.Fatalf("BoxPins = %v, nil; want an error for a link loop", pins) |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | // A relative hop under a symlinked directory pins what the OS reaches, the |
| 249 | // staged file, along with the hop as written (Astra r2 N3, ruling R17). |
| 250 | func TestBoxPinsPinsWhatARelativeHopUnderASymlinkedDirReaches(t *testing.T) { |
| 251 | opt := t.TempDir() |
| 252 | staged, current := aliasedChain(t, opt) |
| 253 | pins, err := BoxPins(opt, nil) |
| 254 | if err != nil { |
| 255 | t.Fatalf("BoxPins: %v", err) |
| 256 | } |
| 257 | for _, p := range []string{current, staged} { |
| 258 | if !pins.Has(p) { |
| 259 | t.Errorf("BoxPins = %v, missing %s", pins, p) |
| 260 | } |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | // Resolving a relative hop's parent must not lose the pin a box with a |
| 265 | // symlinked opt dir had before: the copy is pinned under the opt dir's own |
| 266 | // name, which is how VersionPath names it, as well as under its real path. |
| 267 | func TestBoxPinsRelativeLinkUnderASymlinkedOptDir(t *testing.T) { |
| 268 | real := t.TempDir() |
| 269 | opt := filepath.Join(t.TempDir(), "opt") |
| 270 | if err := os.Symlink(real, opt); err != nil { |
| 271 | t.Fatal(err) |
| 272 | } |
| 273 | if err := StageArtifact(real, "bab", "v1", "km", []byte("v1")); err != nil { |
| 274 | t.Fatal(err) |
| 275 | } |
| 276 | if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil { |
| 277 | t.Fatal(err) |
| 278 | } |
| 279 | if err := os.Symlink(filepath.Join("..", "versions", "v1", "km"), LiveLink(opt, "h.example.com")); err != nil { |
| 280 | t.Fatal(err) |
| 281 | } |
| 282 | pins, err := BoxPins(opt, nil) |
| 283 | if err != nil { |
| 284 | t.Fatalf("BoxPins: %v", err) |
| 285 | } |
| 286 | for _, p := range []string{VersionPath(opt, "bab", "v1", "km"), VersionPath(real, "bab", "v1", "km")} { |
| 287 | if !pins.Has(p) { |
| 288 | t.Errorf("BoxPins = %v, missing %s", pins, p) |
| 289 | } |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | // Ruling R19 item 5: an absolute final hop reached through a symlinked |
| 294 | // directory is pinned at the fully resolved real path too, the copy's |
| 295 | // VersionPath here. |
| 296 | func TestBoxPinsPinsTheRealPathOfTheChainsEnd(t *testing.T) { |
| 297 | opt := t.TempDir() |
| 298 | if err := StageArtifact(opt, "bab", "v1", "km", []byte("v1")); err != nil { |
| 299 | t.Fatal(err) |
| 300 | } |
| 301 | staged := VersionPath(opt, "bab", "v1", "km") |
| 302 | alias := filepath.Join(t.TempDir(), "alias") |
| 303 | if err := os.Symlink(filepath.Dir(staged), alias); err != nil { |
| 304 | t.Fatal(err) |
| 305 | } |
| 306 | if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil { |
| 307 | t.Fatal(err) |
| 308 | } |
| 309 | if err := os.Symlink(filepath.Join(alias, "km"), LiveLink(opt, "h.example.com")); err != nil { |
| 310 | t.Fatal(err) |
| 311 | } |
| 312 | pins, err := BoxPins(opt, nil) |
| 313 | if err != nil { |
| 314 | t.Fatalf("BoxPins: %v", err) |
| 315 | } |
| 316 | for _, p := range []string{filepath.Join(alias, "km"), staged} { |
| 317 | if !pins.Has(p) { |
| 318 | t.Errorf("BoxPins = %v, missing %s", pins, p) |
| 319 | } |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | // Refers compares a candidate's real path; a candidate that does not exist |
| 324 | // is compared as written. |
| 325 | func TestStagedPathsRefersComparesTheRealPath(t *testing.T) { |
| 326 | real := t.TempDir() |
| 327 | opt := filepath.Join(t.TempDir(), "opt") |
| 328 | if err := os.Symlink(real, opt); err != nil { |
| 329 | t.Fatal(err) |
| 330 | } |
| 331 | if err := StageArtifact(real, "bab", "v1", "km", []byte("v1")); err != nil { |
| 332 | t.Fatal(err) |
| 333 | } |
| 334 | pins := StagedPaths{} |
| 335 | pins.add(VersionPath(real, "bab", "v1", "km")) |
| 336 | if ok, err := pins.Refers(VersionPath(opt, "bab", "v1", "km")); err != nil || !ok { |
| 337 | t.Fatalf("Refers(the copy under the symlinked opt) = %v, %v; want true", ok, err) |
| 338 | } |
| 339 | if ok, err := pins.Refers(VersionPath(opt, "bab", "v9", "km")); err != nil || ok { |
| 340 | t.Fatalf("Refers(a missing copy) = %v, %v; want false, nil", ok, err) |
| 341 | } |
| 342 | } |
| 343 | |
| 344 | // Spec §8.5: the good build is a pin. It is what a failed first wake falls |
| 345 | // back to and what a hold's recovery boots, long after the row's Version and |
| 346 | // PrevVersion have moved past it. |
| 347 | func TestBoxPinsNamesTheGoodBuild(t *testing.T) { |
| 348 | opt := t.TempDir() |
| 349 | if err := StageArtifact(opt, "bab", "v0", "km", []byte("v0")); err != nil { |
| 350 | t.Fatal(err) |
| 351 | } |
| 352 | pins, err := BoxPins(opt, []registry.Route{{Host: "b.example.com", Kind: "instance", App: "km", Account: "bab", Version: "v2", PrevVersion: "v1", GoodVersion: "v0"}}) |
| 353 | if err != nil { |
| 354 | t.Fatal(err) |
| 355 | } |
| 356 | if !pins.Has(VersionPath(opt, "bab", "v0", "km")) { |
| 357 | t.Fatal("GoodVersion is not a pin (spec §8.5)") |
| 358 | } |
| 359 | } |
| 360 | |