carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1//go:build linux
2
3package layout
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "os"
10 "path/filepath"
11 "strings"
12 "testing"
13
14 "github.com/carlosframework/platform/internal/registry"
15)
16
17// seedAppEntries writes what an app makes for itself beside its flat
18// database (discussion #256): a directory named for the host holding its
19// key, a `-blobs` directory, and a dot-suffixed file.
20func seedAppEntries(t *testing.T, dd, h string) {
21 t.Helper()
22 for path, body := range map[string]string{
23 filepath.Join(dd, h, "instance.key"): "key " + h,
24 filepath.Join(dd, h, "report.token"): "token " + h,
25 filepath.Join(dd, h+"-blobs", "ab", "cd"): "blob " + h,
26 filepath.Join(dd, h+".cache"): "cache " + h,
27 } {
28 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
29 t.Fatal(err)
30 }
31 if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
32 t.Fatal(err)
33 }
34 }
35}
36
37// Discussion #256: the first migration moved the nine platform names and
38// left behind /data/<host>/instance.key and /data/<host>-blobs; the app
39// started nested without them and minted a new key. Every entry the flat
40// naming rules make the host's now moves into the instance directory at
41// the same relative name, and comes back on revert. An entry whose name a
42// longer host owns (`<host>-x` is the host `<host>-x` itself) stays, and so
43// does a shared directory no host owns, which is reported.
44func TestConvergeMovesAppCreatedEntriesWithTheHost(t *testing.T) {
45 p, f := box(t)
46 var logged []string
47 p.Logf = func(format string, a ...any) { logged = append(logged, fmt.Sprintf(format, a...)) }
48 seedAppEntries(t, p.DataDir, host)
49 // A route whose name is host plus "-x": an externally supervised one
50 // (not activator-managed), so its own entries must be left alone.
51 decoy := host + "-x"
52 if err := p.Registry.Upsert(registry.Route{Host: decoy, Kind: "instance", Socket: filepath.Join(p.SocketDir, decoy+".sock")}); err != nil {
53 t.Fatal(err)
54 }
55 seedAppEntries(t, p.DataDir, decoy)
56 // What a flat app with cwd / wrote as ./blobs.
57 if err := os.MkdirAll(filepath.Join(p.DataDir, "blobs", "aa"), 0o700); err != nil {
58 t.Fatal(err)
59 }
60
61 res := byHost(t, p.Converge(context.Background(), ModeNested))
62 if res[host].Outcome != "migrated" {
63 t.Fatalf("%s: %+v", host, res[host])
64 }
65 raw := rawInstanceDir(p.DataDir, host)
66 for rel, want := range map[string]string{
67 filepath.Join(host, "instance.key"): "key " + host,
68 filepath.Join(host, "report.token"): "token " + host,
69 filepath.Join(host+"-blobs", "ab", "cd"): "blob " + host,
70 host + ".cache": "cache " + host,
71 } {
72 if b, err := os.ReadFile(filepath.Join(raw, rel)); err != nil || string(b) != want {
73 t.Errorf("%s not in the instance directory: %q %v", rel, b, err)
74 }
75 if _, err := os.Lstat(filepath.Join(p.DataDir, rel)); !errors.Is(err, os.ErrNotExist) {
76 t.Errorf("%s left behind in the data directory: %v", rel, err)
77 }
78 }
79 for _, rel := range []string{filepath.Join(decoy, "instance.key"), filepath.Join(decoy+"-blobs", "ab", "cd"), decoy + ".cache", filepath.Join("blobs", "aa")} {
80 if _, err := os.Lstat(filepath.Join(p.DataDir, rel)); err != nil {
81 t.Errorf("%s, not this host's, was moved: %v", rel, err)
82 }
83 if _, err := os.Lstat(filepath.Join(raw, rel)); !errors.Is(err, os.ErrNotExist) {
84 t.Errorf("%s, not this host's, is in the instance directory: %v", rel, err)
85 }
86 }
87 if JournalPresent(p.DataDir, host) || f.fences[host] != 0 {
88 t.Fatal("journal or fence left after the move")
89 }
90 if n := p.Note(host); !strings.Contains(n, "moved with it: "+host+", "+host+"-blobs, "+host+".cache") {
91 t.Errorf("note = %q", n)
92 }
93 // The aside directory holds nothing: these were moved, not quarantined.
94 if q, err := os.ReadDir(asideDir(p.DataDir, host)); err == nil && len(q) > 0 {
95 t.Errorf("quarantined instead of moved: %v", q)
96 }
97 joined := strings.Join(logged, "\n")
98 if !strings.Contains(joined, "owned by no host on this box") || !strings.Contains(joined, filepath.Join(p.DataDir, "blobs")) {
99 t.Errorf("the shared directory was not reported:\n%s", joined)
100 }
101 if strings.Contains(joined, filepath.Join(p.DataDir, decoy)) {
102 t.Errorf("the decoy host's own directory was reported as unowned:\n%s", joined)
103 }
104 if !strings.Contains(joined, "named for another host: "+decoy+" ("+decoy+"'s), "+decoy+"-blobs ("+decoy+"'s)") {
105 t.Errorf("the decoy host's entries were not reported as left for it:\n%s", joined)
106 }
107
108 // Revert brings the same names back, and only those.
109 res = byHost(t, p.Converge(context.Background(), ModeFlat))
110 if res[host].Outcome != "reverted" {
111 t.Fatalf("revert %s: %+v", host, res[host])
112 }
113 assertFlatUntouched(t, p, host)
114 for rel, want := range map[string]string{
115 filepath.Join(host, "instance.key"): "key " + host,
116 filepath.Join(host+"-blobs", "ab", "cd"): "blob " + host,
117 host + ".cache": "cache " + host,
118 } {
119 if b, err := os.ReadFile(filepath.Join(p.DataDir, rel)); err != nil || string(b) != want {
120 t.Errorf("%s not back in the data directory: %q %v", rel, b, err)
121 }
122 }
123 if entries, _ := os.ReadDir(raw); len(entries) != 0 {
124 t.Errorf("instance directory not emptied by the revert: %v", entries)
125 }
126}
127
128// The same protections the platform names get, on the way in: a file
129// inside an app's directory that has another name OUTSIDE it would be
130// bound read-write into the namespace while something else still reaches
131// it, so the move is refused with the inode and the find, and the host is
132// held (a tenant can plant exactly this). A hard link whose every name is
133// inside the directory is the app's own deduplication and moves.
134func TestConvergeRefusesAnAppEntryWithANameOutsideIt(t *testing.T) {
135 p, f := box(t)
136 seedAppEntries(t, p.DataDir, host)
137 secret := filepath.Join(p.DataDir, "litestream.env")
138 if err := os.WriteFile(secret, []byte("AWS_SECRET_ACCESS_KEY=x\n"), 0o600); err != nil {
139 t.Fatal(err)
140 }
141 if err := os.Link(secret, filepath.Join(p.DataDir, host, "creds")); err != nil {
142 t.Skipf("hard links unavailable here: %v", err)
143 }
144 // And a legitimate pair, both names inside the blob store.
145 if err := os.Link(filepath.Join(p.DataDir, host+"-blobs", "ab", "cd"), filepath.Join(p.DataDir, host+"-blobs", "dup")); err != nil {
146 t.Fatal(err)
147 }
148 res := byHost(t, p.Converge(context.Background(), ModeNested))
149 r := res[host]
150 if r.Outcome != "held" || !strings.Contains(r.Reason, "creds") || !strings.Contains(r.Reason, "2 names") || !strings.Contains(r.Reason, "only 1 of them are inside") || !strings.Contains(r.Reason, "-samefile") {
151 t.Fatalf("linked outside: %+v", r)
152 }
153 assertFlatUntouched(t, p, host)
154 if f.fences[host] == 0 {
155 t.Fatal("the fence was lifted; the host would wake flat with the link in place")
156 }
157 if _, err := os.Stat(filepath.Join(p.DataDir, host, "instance.key")); err != nil {
158 t.Fatalf("a refused move touched the app's directory: %v", err)
159 }
160 // The operator removes the outside name; the inside pair is fine.
161 os.Remove(filepath.Join(p.DataDir, host, "creds"))
162 res = byHost(t, p.Converge(context.Background(), ModeNested))
163 if res[host].Outcome != "migrated" || f.fences[host] != 0 {
164 t.Fatalf("after the link is gone: %+v fence=%d", res[host], f.fences[host])
165 }
166 raw := rawInstanceDir(p.DataDir, host)
167 a, err1 := os.Stat(filepath.Join(raw, host+"-blobs", "ab", "cd"))
168 b, err2 := os.Stat(filepath.Join(raw, host+"-blobs", "dup"))
169 if err1 != nil || err2 != nil || !os.SameFile(a, b) {
170 t.Fatalf("the app's own hard-link pair did not move together: %v %v", err1, err2)
171 }
172}
173
174// A symlink inside an app's directory is refused like one anywhere else
175// the pass moves, and the host is held.
176func TestConvergeRefusesASymlinkInsideAnAppEntry(t *testing.T) {
177 p, f := box(t)
178 seedAppEntries(t, p.DataDir, host)
179 if err := os.Symlink("/etc/passwd", filepath.Join(p.DataDir, host, "link")); err != nil {
180 t.Fatal(err)
181 }
182 res := byHost(t, p.Converge(context.Background(), ModeNested))
183 if r := res[host]; r.Outcome != "held" || !strings.Contains(r.Reason, "link") || !strings.Contains(r.Reason, "not a regular file") {
184 t.Fatalf("symlink: %+v", r)
185 }
186 if f.fences[host] == 0 {
187 t.Fatal("the fence was lifted")
188 }
189 assertFlatUntouched(t, p, host)
190}
191
192// An extra whose destination is already taken is refused before the
193// journal, naming both, never a choice. On the way to nested the
194// destination check has just cleared the instance directory, so a taken
195// destination was planted since (here: inside the serialized section,
196// after the check and before the scan), and the host is held.
197func TestConvergeRefusesAnAppEntryWhoseDestinationIsTaken(t *testing.T) {
198 p, f := box(t)
199 seedAppEntries(t, p.DataDir, host)
200 p.Serialize = func(fn func()) {
201 if !JournalPresent(p.DataDir, host) {
202 os.MkdirAll(filepath.Join(rawInstanceDir(p.DataDir, host), host), 0o700)
203 }
204 fn()
205 }
206 res := byHost(t, p.Converge(context.Background(), ModeNested))
207 r := res[host]
208 if r.Outcome != "held" || !strings.Contains(r.Reason, "exists at both") || !strings.Contains(r.Reason, "refusing to choose") {
209 t.Fatalf("taken destination: %+v", r)
210 }
211 if f.fences[host] == 0 {
212 t.Fatal("the fence was lifted")
213 }
214 assertFlatUntouched(t, p, host)
215 if _, err := os.Stat(filepath.Join(p.DataDir, host, "instance.key")); err != nil {
216 t.Fatalf("a refused move touched the app's directory: %v", err)
217 }
218}
219
220// Recovery completes a move with extras from its journal: the names are
221// recorded, re-derived on recovery, and moved with the rest.
222func TestRecoverCompletesAMoveWithAppEntries(t *testing.T) {
223 for _, step := range []string{"after-journal", "mid-files", "after-files"} {
224 t.Run(step, func(t *testing.T) {
225 p, _ := box(t)
226 seedAppEntries(t, p.DataDir, host)
227 p.abortAt = step
228 p.Converge(context.Background(), ModeNested)
229 p.abortAt = ""
230 j, found, err := ReadJournal(p.DataDir, host)
231 if err != nil || !found || len(j.Extra) != 3 {
232 t.Fatalf("journal = %+v %v %v, want three extras recorded", j, found, err)
233 }
234 res := byHost(t, p.Recover(context.Background(), ModeNested))
235 if res[host].Outcome != "recovered" {
236 t.Fatalf("recover = %+v", res[host])
237 }
238 raw := rawInstanceDir(p.DataDir, host)
239 for _, rel := range []string{filepath.Join(host, "instance.key"), filepath.Join(host+"-blobs", "ab", "cd"), host + ".cache"} {
240 if _, err := os.Stat(filepath.Join(raw, rel)); err != nil {
241 t.Errorf("%s not at destination after recovery from %s: %v", rel, step, err)
242 }
243 if _, err := os.Lstat(filepath.Join(p.DataDir, rel)); !errors.Is(err, os.ErrNotExist) {
244 t.Errorf("%s left behind after recovery from %s", rel, step)
245 }
246 }
247 if JournalPresent(p.DataDir, host) {
248 t.Error("journal remains")
249 }
250 })
251 }
252}
253
254// A forged journal for host H that names H-x, the directory of the host
255// H-x itself, is refused on recovery: the name passes the shape rule, but
256// ownership is judged against the box's rows as they stand, and it is not
257// H's. The host stays fenced, nothing moves.
258func TestRecoverRefusesAJournalNamingAnotherHostsEntry(t *testing.T) {
259 p, _ := box(t)
260 decoy := host + "-x"
261 if err := p.Registry.Upsert(registry.Route{Host: decoy, Kind: "instance", Socket: filepath.Join(p.SocketDir, decoy+".sock")}); err != nil {
262 t.Fatal(err)
263 }
264 seedAppEntries(t, p.DataDir, decoy)
265 plan, err := PlanFor(p.DataDir, p.SocketDir, host, ModeNested)
266 if err != nil {
267 t.Fatal(err)
268 }
269 present := make([]bool, len(plan.Artifacts))
270 present[0] = true
271 j := plan.Journal(present)
272 j.Extra = []JournalArtifact{{Name: decoy, Present: true}}
273 if err := WriteJournal(p.DataDir, j); err != nil {
274 t.Fatal(err)
275 }
276 res := byHost(t, p.Recover(context.Background(), ModeNested))
277 if r := res[host]; r.Outcome != "refused" || !strings.Contains(r.Reason, "belongs to "+decoy) || !strings.HasSuffix(r.Reason, "not acting") {
278 t.Fatalf("forged extra: %+v", r)
279 }
280 if _, err := os.Stat(filepath.Join(p.DataDir, decoy, "instance.key")); err != nil {
281 t.Fatalf("the other host's directory was moved: %v", err)
282 }
283 if !JournalPresent(p.DataDir, host) {
284 t.Fatal("the refused journal was removed")
285 }
286}
287
288// Review (#256 B): the hosts migrated before the pass carried the app's
289// entries along (the 104 on IE) still have them in the data directory,
290// where the namespace cannot see them. The pass never moves a nested
291// host's files, but it says so on every pass, in the log and in the
292// host's status note, until an operator has moved them.
293func TestConvergeReportsFlatLeftoversOfANestedHost(t *testing.T) {
294 p, _ := box(t)
295 if res := byHost(t, p.Converge(context.Background(), ModeNested)); res[host].Outcome != "migrated" {
296 t.Fatalf("setup: %+v", res[host])
297 }
298 // What an earlier release left behind: the app's directory, and a
299 // platform-named file minted at the flat path since.
300 seedAppEntries(t, p.DataDir, host)
301 if err := os.WriteFile(filepath.Join(p.DataDir, host+".env"), []byte("CARLOS_ADMIN_TOKEN=old\n"), 0o600); err != nil {
302 t.Fatal(err)
303 }
304 var logged []string
305 p.Logf = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) }
306 res := byHost(t, p.Converge(context.Background(), ModeNested))
307 if res[host].Outcome != "unchanged" {
308 t.Fatalf("%+v", res[host])
309 }
310 want := "still in " + p.DataDir + " from before its move, where the instance cannot see them: " +
311 filepath.Join(p.DataDir, host) + ", " + filepath.Join(p.DataDir, host+"-blobs") + ", " + filepath.Join(p.DataDir, host+".cache") + ", " + filepath.Join(p.DataDir, host+".env") +
312 ". move what the app needs into " + rawInstanceDir(p.DataDir, host) + " by hand"
313 if j := strings.Join(logged, "\n"); !strings.Contains(j, "layout: "+host+": "+want) {
314 t.Errorf("log:\n%s\nwant a line with:\n%s", j, want)
315 }
316 if n := p.Note(host); n != want {
317 t.Errorf("note = %q", n)
318 }
319 for _, rel := range []string{filepath.Join(host, "instance.key"), host + ".env"} {
320 if _, err := os.Stat(filepath.Join(p.DataDir, rel)); err != nil {
321 t.Errorf("a report moved %s: %v", rel, err)
322 }
323 }
324 if n := p.Note(replica); strings.Contains(n, "still in") {
325 t.Errorf("the replica, with nothing left behind, got the note: %q", n)
326 }
327}
328