| 1 | //go:build linux |
| 2 | |
| 3 | package layout |
| 4 | |
| 5 | import ( |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/registry" |
| 13 | ) |
| 14 | |
| 15 | // The naming rules of extras.go: an entry is a host's when it is the host |
| 16 | // or the host plus a dot or dash suffix (or the activator's prefix form), |
| 17 | // and when two hosts on the box both match, the longer one owns it. |
| 18 | func TestExtraOwnershipIsTheLongestMatchingHost(t *testing.T) { |
| 19 | hosts := []string{"a.b.oncarlos.com", "a.b.oncarlos.com-x", "other.example.com"} |
| 20 | for name, want := range map[string]string{ |
| 21 | "a.b.oncarlos.com": "a.b.oncarlos.com", |
| 22 | "a.b.oncarlos.com-blobs": "a.b.oncarlos.com", |
| 23 | "a.b.oncarlos.com.cache": "a.b.oncarlos.com", |
| 24 | ".activator-a.b.oncarlos.com.db-litestream": "a.b.oncarlos.com", |
| 25 | "a.b.oncarlos.com-x": "a.b.oncarlos.com-x", |
| 26 | "a.b.oncarlos.com-x-blobs": "a.b.oncarlos.com-x", |
| 27 | "a.b.oncarlos.com-x.db": "a.b.oncarlos.com-x", |
| 28 | "a.b.oncarlos.comx": "", |
| 29 | "blobs": "", |
| 30 | "registry.db": "", |
| 31 | } { |
| 32 | if got := ownerOf(name, hosts); got != want { |
| 33 | t.Errorf("ownerOf(%q) = %q, want %q", name, got, want) |
| 34 | } |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | // A journal's extra names pass the scan's own rule or the journal is |
| 39 | // refused: one path element, not one of the plan's own names, named for |
| 40 | // the host, and never absent. Journals from before #256 have no extras. |
| 41 | func TestValidateJudgesTheJournalsExtraNames(t *testing.T) { |
| 42 | dd, rd := t.TempDir(), t.TempDir() |
| 43 | good := func() *Journal { |
| 44 | p, _ := PlanFor(dd, rd, host, ModeNested) |
| 45 | present := make([]bool, len(artifactNames)) |
| 46 | present[0] = true |
| 47 | return p.Journal(present) |
| 48 | } |
| 49 | row := registry.Route{Host: host, DBPath: good().From.DB, Socket: good().From.Socket} |
| 50 | j := good() |
| 51 | j.Extra = []JournalArtifact{{Name: host, Present: true}, {Name: host + "-blobs", Present: true}} |
| 52 | if _, err := Validate(j, dd, rd, row); err != nil { |
| 53 | t.Fatalf("two good extras: %v", err) |
| 54 | } |
| 55 | for name, extra := range map[string]JournalArtifact{ |
| 56 | "a path": {Name: "../" + host, Present: true}, |
| 57 | "a fixed name": {Name: host + ".db", Present: true}, |
| 58 | "another host's": {Name: replica + "-blobs", Present: true}, |
| 59 | "the data dir": {Name: ".", Present: true}, |
| 60 | "recorded absent": {Name: host + "-blobs", Present: false}, |
| 61 | } { |
| 62 | j := good() |
| 63 | j.Extra = []JournalArtifact{extra} |
| 64 | if _, err := Validate(j, dd, rd, row); err == nil || !strings.HasSuffix(err.Error(), "not acting") { |
| 65 | t.Errorf("%s (%q): Validate = %v, want a refusal ending in not acting", name, extra.Name, err) |
| 66 | } |
| 67 | } |
| 68 | j = good() |
| 69 | j.Extra = []JournalArtifact{{Name: host, Present: true}, {Name: host, Present: true}} |
| 70 | if _, err := Validate(j, dd, rd, row); err == nil || !strings.Contains(err.Error(), "twice") { |
| 71 | t.Errorf("a name twice: Validate = %v", err) |
| 72 | } |
| 73 | // The wire shape is additive: a journal without the field reads as |
| 74 | // one with no extras, and one with the field round-trips. |
| 75 | j = good() |
| 76 | j.Extra = []JournalArtifact{{Name: host + "-blobs", Present: true}} |
| 77 | if err := WriteJournal(dd, j); err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | back, found, err := ReadJournal(dd, host) |
| 81 | if err != nil || !found || len(back.Extra) != 1 || back.Extra[0].Name != host+"-blobs" { |
| 82 | t.Fatalf("round trip = %+v %v %v", back, found, err) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | // UnattributedEntries names what no host on the box owns and the |
| 87 | // platform did not make: the shared directories a flat app with cwd / |
| 88 | // wrote, which no move can take. |
| 89 | func TestUnattributedEntriesSkipsHostsAndPlatformNames(t *testing.T) { |
| 90 | dd := t.TempDir() |
| 91 | for _, name := range []string{"blobs", "registry.db", "layout", ".instances", "instances", ".offline-moves", host, host + "-blobs", "cache.tmp"} { |
| 92 | if err := os.MkdirAll(filepath.Join(dd, name), 0o700); err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | } |
| 96 | got := UnattributedEntries(dd, []string{host, replica}) |
| 97 | want := []string{filepath.Join(dd, "blobs"), filepath.Join(dd, "cache.tmp")} |
| 98 | if strings.Join(got, " ") != strings.Join(want, " ") { |
| 99 | t.Fatalf("UnattributedEntries = %v, want %v", got, want) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | // Review (#256 B): ownership knows more than the rows. A host deleted or |
| 104 | // moved away leaves its platform-named files behind, and when its name |
| 105 | // extends this host's, the shorter host's scan must not sweep them in, |
| 106 | // nor the app entries beside them that the files are evidence of. Each |
| 107 | // platform shape, for a dotted and a dashed extension, with no row for |
| 108 | // either: none is an extra, and each is reported with "no row". |
| 109 | func TestScanExtrasLeavesAnotherHostsPlatformFilesAlone(t *testing.T) { |
| 110 | dd, rd := t.TempDir(), t.TempDir() |
| 111 | shapes := func(x string) []string { |
| 112 | return []string{x + ".env", x + ".db", x + ".db-wal", x + ".db-shm", x + ".config.env", x + "-sidecar.env", ".activator-" + x + ".db-litestream", x + ".db.corrupt"} |
| 113 | } |
| 114 | var planted []string |
| 115 | for _, x := range []string{host + ".au", host + "-staging"} { |
| 116 | planted = append(planted, shapes(x)...) |
| 117 | // The app entries of that host, which only its files evidence. |
| 118 | planted = append(planted, x, x+"-blobs") |
| 119 | } |
| 120 | planted = append(planted, host, host+"-blobs") |
| 121 | for _, name := range planted { |
| 122 | if err := os.MkdirAll(filepath.Join(dd, name), 0o700); err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | } |
| 126 | for _, mode := range []Mode{ModeNested, ModeFlat} { |
| 127 | p, err := PlanFor(dd, rd, host, mode) |
| 128 | if err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | var logged []string |
| 132 | p.Logf = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) } |
| 133 | if mode == ModeFlat { |
| 134 | // The revert scans the instance directory; stage the same |
| 135 | // names there to show the rule holds in that direction too. |
| 136 | raw := rawInstanceDir(dd, host) |
| 137 | for _, name := range planted { |
| 138 | if err := os.MkdirAll(filepath.Join(raw, name), 0o700); err != nil { |
| 139 | t.Fatal(err) |
| 140 | } |
| 141 | } |
| 142 | os.RemoveAll(filepath.Join(dd, host)) |
| 143 | os.RemoveAll(filepath.Join(dd, host+"-blobs")) |
| 144 | } |
| 145 | got, err := ScanExtras(p, []string{replica}) |
| 146 | if err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | if strings.Join(got, " ") != host+" "+host+"-blobs" { |
| 150 | t.Errorf("%s: ScanExtras = %v, want only the host's own two entries", mode, got) |
| 151 | } |
| 152 | joined := strings.Join(logged, "\n") |
| 153 | for _, x := range []string{host + ".au", host + "-staging"} { |
| 154 | for _, name := range append(shapes(x), x, x+"-blobs") { |
| 155 | if !strings.Contains(joined, name+" ("+x+"'s, which has no row on this box)") { |
| 156 | t.Errorf("%s: %s not reported as %s's with no row:\n%s", mode, name, x, joined) |
| 157 | } |
| 158 | } |
| 159 | } |
| 160 | } |
| 161 | // And a host that is a row: reported as its own, without the "no row". |
| 162 | p, _ := PlanFor(dd, rd, host, ModeNested) |
| 163 | var logged []string |
| 164 | p.Logf = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) } |
| 165 | if _, err := ScanExtras(p, []string{host + ".au"}); err != nil { |
| 166 | t.Fatal(err) |
| 167 | } |
| 168 | if j := strings.Join(logged, "\n"); !strings.Contains(j, host+".au.env ("+host+".au's)") || strings.Contains(j, host+".au.env ("+host+".au's, which") { |
| 169 | t.Errorf("a host with a row is reported wrongly:\n%s", j) |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | func TestFixedShapeHostReadsEveryPlatformShape(t *testing.T) { |
| 174 | for name, want := range map[string]string{ |
| 175 | "a.example.com.env": "a.example.com", |
| 176 | "a.example.com.db": "a.example.com", |
| 177 | "a.example.com.db-wal": "a.example.com", |
| 178 | "a.example.com.db-shm": "a.example.com", |
| 179 | "a.example.com.config.env": "a.example.com", |
| 180 | "a.example.com-sidecar.env": "a.example.com", |
| 181 | "a.example.com.db.corrupt": "a.example.com", |
| 182 | ".activator-a.example.com.db-litestream": "a.example.com", |
| 183 | "a.example.com": "", |
| 184 | "a.example.com-blobs": "", |
| 185 | "litestream.env": "", |
| 186 | "registry.db": "", |
| 187 | ".env": "", |
| 188 | } { |
| 189 | got, ok := fixedShapeHost(name) |
| 190 | if got != want || ok != (want != "") { |
| 191 | t.Errorf("fixedShapeHost(%q) = %q %v, want %q", name, got, ok, want) |
| 192 | } |
| 193 | } |
| 194 | } |
| 195 | |