| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "slices" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/hostbox" |
| 15 | "github.com/carlosframework/platform/internal/hostops" |
| 16 | "github.com/carlosframework/platform/internal/provision" |
| 17 | "github.com/carlosframework/platform/internal/registry" |
| 18 | ) |
| 19 | |
| 20 | // step8MovedAside is B2: claimtrial's and wakecheck's instance directories, |
| 21 | // program files and live links, moved out of leo's census roots. Their |
| 22 | // certificates stay (the certificate cache is box state). |
| 23 | func step8MovedAside(r leoRow) bool { |
| 24 | for _, h := range []string{leoClaimtrial, leoWakecheck} { |
| 25 | switch r.Root { |
| 26 | case "/data": |
| 27 | for _, dir := range []string{"instances/" + h, ".instances/" + h} { |
| 28 | if r.Path == dir || strings.HasPrefix(r.Path, dir+"/") { |
| 29 | return true |
| 30 | } |
| 31 | } |
| 32 | if r.Path == "programs/"+h+".json" { |
| 33 | return true |
| 34 | } |
| 35 | case "/opt/carlos": |
| 36 | if r.Path == "live/"+h { |
| 37 | return true |
| 38 | } |
| 39 | } |
| 40 | } |
| 41 | return false |
| 42 | } |
| 43 | |
| 44 | // newEmptyLeoCensusTree is leo's tree for the empty-leo flow (Option B): |
| 45 | // claimtrial's row gone (B1), the records keep left in the bucket, and, when |
| 46 | // movedAside, claimtrial's and wakecheck's leftovers moved out (B2). Leo |
| 47 | // reports zero installations. |
| 48 | func newEmptyLeoCensusTree(t *testing.T, keep []string, movedAside bool) *leoCensusTree { |
| 49 | t.Helper() |
| 50 | base := t.TempDir() |
| 51 | roots := map[string]string{"data": filepath.Join(base, "data"), "opt": filepath.Join(base, "opt"), "units": filepath.Join(base, "units")} |
| 52 | for _, dir := range roots { |
| 53 | if err := os.Mkdir(dir, 0o755); err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | } |
| 57 | buildLeoTree(t, leoTreeRows(t, true), leoTreeOptions{ |
| 58 | Roots: map[string]string{"/data": roots["data"], "/opt/carlos": roots["opt"], "/etc/systemd/system": roots["units"]}, |
| 59 | Skip: func(r leoRow) bool { |
| 60 | if movedAside && step8MovedAside(r) { |
| 61 | return true |
| 62 | } |
| 63 | return r.Root == "/etc/systemd/system" && r.Type == "l" && (strings.HasPrefix(r.Target, "/usr/lib/") || strings.HasPrefix(r.Target, "/lib/")) |
| 64 | }, |
| 65 | }) |
| 66 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 67 | if err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | t.Cleanup(func() { reg.Close() }) |
| 71 | c := &leoCensusTree{roots: roots, r: ClaimRuntimeResources{Mode: "instance-control", Registry: reg, DataDir: roots["data"], OptDir: roots["opt"], UnitsDir: roots["units"]}} |
| 72 | c.snap.Report.Installations = []hostops.CensusInstallation{} |
| 73 | for _, r := range leoRecords(t) { |
| 74 | if !slices.Contains(keep, r.Holder.Host) { |
| 75 | continue |
| 76 | } |
| 77 | var rec provision.Record |
| 78 | if err := json.Unmarshal(r.Body, &rec); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | c.snap.Records = append(c.snap.Records, rec) |
| 82 | } |
| 83 | return c |
| 84 | } |
| 85 | |
| 86 | // The empty-leo flow's census on leo's real tree, under the window's masks: |
| 87 | // with B1 and B2 done it accepts, whether the bucket keeps no record (the |
| 88 | // trial's ruling) or rollcheck's and wakecheck's. Without B2, and with |
| 89 | // wakecheck's record deleted, wakecheck's leftovers refuse, naming them; so |
| 90 | // do claimtrial's, whose record is gone whatever B1 kept. |
| 91 | func TestMigrationCensusClassifiesEmptyLeo(t *testing.T) { |
| 92 | for name, keep := range map[string][]string{"zero hosts": nil, "two hosts": {leoRollcheck, leoWakecheck}} { |
| 93 | t.Run(name, func(t *testing.T) { |
| 94 | c := newEmptyLeoCensusTree(t, keep, true) |
| 95 | if err := c.check(t, allWindowUnits()); err != nil { |
| 96 | t.Fatalf("empty leo under the window's masks: %v", err) |
| 97 | } |
| 98 | if err := c.check(t, nil); !errors.Is(err, hostbox.ErrTypedInventory) || !strings.Contains(err.Error(), "units entry") { |
| 99 | t.Fatalf("masks without --window = %v", err) |
| 100 | } |
| 101 | c = newEmptyLeoCensusTree(t, keep, false) |
| 102 | err := c.check(t, allWindowUnits()) |
| 103 | if !errors.Is(err, hostbox.ErrTypedInventory) { |
| 104 | t.Fatalf("leftovers not moved aside = %v, want a refusal", err) |
| 105 | } |
| 106 | if !strings.Contains(err.Error(), "claimtrial") && !strings.Contains(err.Error(), "wakecheck") { |
| 107 | t.Fatalf("leftovers refusal does not name them: %v", err) |
| 108 | } |
| 109 | t.Logf("without B2: %v", err) |
| 110 | }) |
| 111 | } |
| 112 | } |
| 113 | |