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 .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 /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) } // Ruling R19 item 4 adds the intent file beside it. entries, err := os.ReadDir(filepath.Dir(path)) if err != nil || len(entries) != 2 || entries[0].Name() != "generations.json" || entries[1].Name() != "generations.seq" { t.Fatalf("zdd dir holds %v, %v; want only generations.json and generations.seq, 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) } }) } } // A live link that names another link (an operator's own "current" pointer) // refers to every hop of the chain, the staged copy at its end included. func TestBoxPinsPinsEveryHopOfAChainedLink(t *testing.T) { opt := t.TempDir() if err := StageArtifact(opt, "bab", "v1", "km", []byte("v1")); err != nil { t.Fatal(err) } current := filepath.Join(t.TempDir(), "operator-current") if err := os.Symlink(VersionPath(opt, "bab", "v1", "km"), current); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil { t.Fatal(err) } if err := os.Symlink(current, LiveLink(opt, "h.example.com")); err != nil { t.Fatal(err) } pins, err := BoxPins(opt, nil) if err != nil { t.Fatalf("BoxPins: %v", err) } for _, p := range []string{current, VersionPath(opt, "bab", "v1", "km")} { if !pins.Has(p) { t.Errorf("BoxPins = %v, missing %s", pins, p) } } } // A chain that cannot be followed to its end is an unknown reference: no // answer, so no restage. func TestBoxPinsFailsOnALinkLoop(t *testing.T) { opt := t.TempDir() dir := t.TempDir() a, b := filepath.Join(dir, "a"), filepath.Join(dir, "b") if err := os.Symlink(b, a); err != nil { t.Fatal(err) } if err := os.Symlink(a, b); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil { t.Fatal(err) } if err := os.Symlink(a, LiveLink(opt, "h.example.com")); err != nil { t.Fatal(err) } if pins, err := BoxPins(opt, nil); err == nil { t.Fatalf("BoxPins = %v, nil; want an error for a link loop", pins) } } // A relative hop under a symlinked directory pins what the OS reaches, the // staged file, along with the hop as written (Astra r2 N3, ruling R17). func TestBoxPinsPinsWhatARelativeHopUnderASymlinkedDirReaches(t *testing.T) { opt := t.TempDir() staged, current := aliasedChain(t, opt) pins, err := BoxPins(opt, nil) if err != nil { t.Fatalf("BoxPins: %v", err) } for _, p := range []string{current, staged} { if !pins.Has(p) { t.Errorf("BoxPins = %v, missing %s", pins, p) } } } // Resolving a relative hop's parent must not lose the pin a box with a // symlinked opt dir had before: the copy is pinned under the opt dir's own // name, which is how VersionPath names it, as well as under its real path. func TestBoxPinsRelativeLinkUnderASymlinkedOptDir(t *testing.T) { real := t.TempDir() opt := filepath.Join(t.TempDir(), "opt") if err := os.Symlink(real, opt); err != nil { t.Fatal(err) } if err := StageArtifact(real, "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(filepath.Join("..", "versions", "v1", "km"), LiveLink(opt, "h.example.com")); err != nil { t.Fatal(err) } pins, err := BoxPins(opt, nil) if err != nil { t.Fatalf("BoxPins: %v", err) } for _, p := range []string{VersionPath(opt, "bab", "v1", "km"), VersionPath(real, "bab", "v1", "km")} { if !pins.Has(p) { t.Errorf("BoxPins = %v, missing %s", pins, p) } } } // Ruling R19 item 5: an absolute final hop reached through a symlinked // directory is pinned at the fully resolved real path too, the copy's // VersionPath here. func TestBoxPinsPinsTheRealPathOfTheChainsEnd(t *testing.T) { opt := t.TempDir() if err := StageArtifact(opt, "bab", "v1", "km", []byte("v1")); err != nil { t.Fatal(err) } staged := VersionPath(opt, "bab", "v1", "km") alias := filepath.Join(t.TempDir(), "alias") if err := os.Symlink(filepath.Dir(staged), alias); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil { t.Fatal(err) } if err := os.Symlink(filepath.Join(alias, "km"), LiveLink(opt, "h.example.com")); err != nil { t.Fatal(err) } pins, err := BoxPins(opt, nil) if err != nil { t.Fatalf("BoxPins: %v", err) } for _, p := range []string{filepath.Join(alias, "km"), staged} { if !pins.Has(p) { t.Errorf("BoxPins = %v, missing %s", pins, p) } } } // Refers compares a candidate's real path; a candidate that does not exist // is compared as written. func TestStagedPathsRefersComparesTheRealPath(t *testing.T) { real := t.TempDir() opt := filepath.Join(t.TempDir(), "opt") if err := os.Symlink(real, opt); err != nil { t.Fatal(err) } if err := StageArtifact(real, "bab", "v1", "km", []byte("v1")); err != nil { t.Fatal(err) } pins := StagedPaths{} pins.add(VersionPath(real, "bab", "v1", "km")) if ok, err := pins.Refers(VersionPath(opt, "bab", "v1", "km")); err != nil || !ok { t.Fatalf("Refers(the copy under the symlinked opt) = %v, %v; want true", ok, err) } if ok, err := pins.Refers(VersionPath(opt, "bab", "v9", "km")); err != nil || ok { t.Fatalf("Refers(a missing copy) = %v, %v; want false, nil", ok, err) } } // Spec §8.5: the good build is a pin. It is what a failed first wake falls // back to and what a hold's recovery boots, long after the row's Version and // PrevVersion have moved past it. func TestBoxPinsNamesTheGoodBuild(t *testing.T) { opt := t.TempDir() if err := StageArtifact(opt, "bab", "v0", "km", []byte("v0")); err != nil { t.Fatal(err) } pins, err := BoxPins(opt, []registry.Route{{Host: "b.example.com", Kind: "instance", App: "km", Account: "bab", Version: "v2", PrevVersion: "v1", GoodVersion: "v0"}}) if err != nil { t.Fatal(err) } if !pins.Has(VersionPath(opt, "bab", "v0", "km")) { t.Fatal("GoodVersion is not a pin (spec §8.5)") } }