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 "net/http"
9 "net/http/httptest"
10 "os"
11 "path/filepath"
12 "reflect"
13 "strings"
14 "sync"
15 "testing"
16 "time"
17
18 "github.com/carlosframework/platform/internal/activator"
19 "github.com/carlosframework/platform/internal/provision"
20 "github.com/carlosframework/platform/internal/registry"
21)
22
23const (
24 host = "h.example.com"
25 replica = "r.example.com"
26)
27
28// fakeFence is the activator's fence as the pass sees it. A fence already
29// in fences, of any generation, refuses a new one with ErrFenceHeld, as the
30// real exclusive install does. Otherwise every stop is proven; unproven,
31// awake and uncertain make it answer ErrStopUnproven and shape what
32// Quiesced and StopUncertain say after it. events records every fence,
33// refusal and lift, and box's Reload adds "reload", so a test can read the
34// order the pass did things in.
35type fakeFence struct {
36 fences map[string]uint64
37 unproven map[string]bool
38 awake map[string]bool
39 uncertain map[string]bool
40 onFence func(host string)
41 events []string
42}
43
44func (f *fakeFence) FenceAndStopIfUnfenced(_ context.Context, h string, gen uint64) (*activator.StopEvidence, error) {
45 if f.fences == nil {
46 f.fences = map[string]uint64{}
47 }
48 if f.fences[h] != 0 {
49 f.events = append(f.events, "held "+h)
50 return nil, activator.ErrFenceHeld
51 }
52 f.fences[h] = gen
53 f.events = append(f.events, "fence "+h)
54 if f.onFence != nil {
55 f.onFence(h)
56 }
57 if f.unproven[h] {
58 return nil, activator.ErrStopUnproven
59 }
60 return &activator.StopEvidence{}, nil
61}
62
63func (f *fakeFence) LiftFence(h string, gen uint64) bool {
64 if f.fences[h] != gen {
65 return false
66 }
67 delete(f.fences, h)
68 f.events = append(f.events, "lift "+h)
69 return true
70}
71
72func (f *fakeFence) FenceGeneration(h string) uint64 { return f.fences[h] }
73func (f *fakeFence) Quiesced(h string) bool { return !f.awake[h] }
74func (f *fakeFence) StopUncertain(h string) bool { return f.uncertain[h] }
75
76func openLayoutRegistry(t *testing.T, dir string) *registry.Registry {
77 t.Helper()
78 reg, err := registry.Open(filepath.Join(dir, "registry.db"))
79 if err != nil {
80 t.Fatal(err)
81 }
82 t.Cleanup(func() { reg.Close() })
83 return reg
84}
85
86// seedFlat writes host's flat files: the database and its wal, the admin
87// env, a litestream shadow directory with a file in it, and (for the
88// primary host only) a quarantine. The sidecar env and the rendered bundle
89// are absent, so the journal records them absent.
90func seedFlat(t *testing.T, dd, h string) {
91 t.Helper()
92 db := provision.DefaultDBPath(dd, h)
93 files := map[string]string{
94 db: "db " + h,
95 db + "-wal": "wal",
96 filepath.Join(dd, h+".env"): "CARLOS_ADMIN_TOKEN=t\n",
97 filepath.Join(dd, shadowName(h), "generation"): "g",
98 }
99 if h == host {
100 files[db+".corrupt"] = "quarantined"
101 }
102 for path, body := range files {
103 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
104 t.Fatal(err)
105 }
106 if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
107 t.Fatal(err)
108 }
109 }
110}
111
112// box is a temp data dir, a temp socket dir and a registry with two flat
113// managed rows whose files exist: an ordinary instance and a replica
114// (#162), which is its own host and migrates like any other.
115func box(t *testing.T) (*Pass, *fakeFence) {
116 t.Helper()
117 dd := t.TempDir()
118 // The socket dir is short on purpose: t.TempDir embeds the test's name,
119 // and the nested control socket must fit a unix socket address, which
120 // Preflight enforces.
121 rd, err := os.MkdirTemp("", "ly")
122 if err != nil {
123 t.Fatal(err)
124 }
125 t.Cleanup(func() { os.RemoveAll(rd) })
126 reg := openLayoutRegistry(t, dd)
127 for _, rt := range []registry.Route{
128 {Host: host, Kind: "instance", Hibernate: true, Status: "ready",
129 Socket: filepath.Join(rd, host+".sock"), DBPath: provision.DefaultDBPath(dd, host)},
130 {Host: replica, Kind: "instance", Hibernate: true, Replica: true,
131 Socket: filepath.Join(rd, replica+".sock"), DBPath: provision.DefaultDBPath(dd, replica)},
132 } {
133 if err := reg.Upsert(rt); err != nil {
134 t.Fatal(err)
135 }
136 seedFlat(t, dd, rt.Host)
137 }
138 f := &fakeFence{}
139 p := &Pass{Registry: reg, Fence: f, DataDir: dd, SocketDir: rd, SocketsGID: -1, Now: time.Now,
140 SweptAtStart: true,
141 Reload: func() { f.events = append(f.events, "reload") },
142 Logf: t.Logf}
143 return p, f
144}
145
146func byHost(t *testing.T, rs []Result) map[string]Result {
147 t.Helper()
148 out := map[string]Result{}
149 for _, r := range rs {
150 if _, dup := out[r.Host]; dup {
151 t.Errorf("host %q reported twice: %+v", r.Host, rs)
152 }
153 out[r.Host] = r
154 }
155 return out
156}
157
158func endpoints(t *testing.T, p *Pass, h string, mode Mode) Endpoint {
159 t.Helper()
160 plan, err := PlanFor(p.DataDir, p.SocketDir, h, mode)
161 if err != nil {
162 t.Fatal(err)
163 }
164 return plan.To
165}
166
167func rowAt(t *testing.T, p *Pass, h string) Endpoint {
168 t.Helper()
169 rt, err := p.Registry.Get(h)
170 if err != nil || rt == nil {
171 t.Fatalf("row %s: %v %v", h, rt, err)
172 }
173 return Endpoint{DB: rt.DBPath, Socket: rt.Socket}
174}
175
176func assertFlatUntouched(t *testing.T, p *Pass, h string) {
177 t.Helper()
178 if got := rowAt(t, p, h); got != endpoints(t, p, h, ModeFlat) {
179 t.Errorf("%s row moved: %+v", h, got)
180 }
181 if b, err := os.ReadFile(provision.DefaultDBPath(p.DataDir, h)); err != nil || string(b) != "db "+h {
182 t.Errorf("%s flat db: %q %v", h, b, err)
183 }
184 if JournalPresent(p.DataDir, h) {
185 t.Errorf("%s has a journal", h)
186 }
187}
188
189func TestConvergeNestedMovesFilesFlipsTheRowAndRemovesTheJournal(t *testing.T) {
190 p, f := box(t)
191 // Step 9's order: routing reloads on the committed row with the journal
192 // gone, and only then is the host released.
193 var reloads []string
194 p.Reload = func() {
195 for _, h := range []string{host, replica} {
196 if f.fences[h] != 0 {
197 if JournalPresent(p.DataDir, h) {
198 t.Errorf("reload for %s ran with its journal still present", h)
199 }
200 reloads = append(reloads, h+" "+rowAt(t, p, h).DB)
201 }
202 }
203 }
204 res := byHost(t, p.Converge(context.Background(), ModeNested))
205 for _, h := range []string{host, replica} {
206 if res[h].Outcome != "migrated" {
207 t.Fatalf("%s: %+v", h, res[h])
208 }
209 want := endpoints(t, p, h, ModeNested)
210 if got := rowAt(t, p, h); got != want {
211 t.Fatalf("%s row = %+v, want %+v", h, got, want)
212 }
213 raw := rawInstanceDir(p.DataDir, h)
214 for _, name := range []string{h + ".db", h + ".db-wal", h + ".env", filepath.Join(shadowName(h), "generation")} {
215 if _, err := os.Stat(filepath.Join(raw, name)); err != nil {
216 t.Errorf("%s not at the raw destination: %v", name, err)
217 }
218 }
219 if _, err := os.Lstat(provision.DefaultDBPath(p.DataDir, h)); !errors.Is(err, os.ErrNotExist) {
220 t.Errorf("%s flat db still present", h)
221 }
222 if JournalPresent(p.DataDir, h) {
223 t.Errorf("%s journal left behind after a clean move", h)
224 }
225 if fi, err := os.Lstat(rawRunDir(p.SocketDir, h)); err != nil || !fi.IsDir() {
226 t.Errorf("%s run directory: %v %v", h, fi, err)
227 }
228 }
229 if _, err := os.Stat(filepath.Join(rawInstanceDir(p.DataDir, host), host+".db.corrupt")); err != nil {
230 t.Errorf("quarantine not moved: %v", err)
231 }
232 if len(f.fences) != 0 {
233 t.Errorf("fences left installed after a clean move: %v", f.fences)
234 }
235 wantReloads := []string{host + " " + endpoints(t, p, host, ModeNested).DB, replica + " " + endpoints(t, p, replica, ModeNested).DB}
236 if !reflect.DeepEqual(reloads, wantReloads) {
237 t.Errorf("reloads under the fence = %v, want %v", reloads, wantReloads)
238 }
239 // Idempotent: a second converge does nothing.
240 for h, r := range byHost(t, p.Converge(context.Background(), ModeNested)) {
241 if r.Outcome != "unchanged" {
242 t.Fatalf("second converge, %s: %+v", h, r)
243 }
244 }
245 // Revert restores the flat layout byte for byte.
246 p.Reload = nil
247 res = byHost(t, p.Converge(context.Background(), ModeFlat))
248 for _, h := range []string{host, replica} {
249 if res[h].Outcome != "reverted" {
250 t.Fatalf("revert %s = %+v", h, res[h])
251 }
252 assertFlatUntouched(t, p, h)
253 if _, err := os.Stat(filepath.Join(p.DataDir, shadowName(h), "generation")); err != nil {
254 t.Errorf("%s shadow not back: %v", h, err)
255 }
256 }
257 if len(f.fences) != 0 {
258 t.Errorf("fences left installed after a revert: %v", f.fences)
259 }
260}
261
262// An unproven stop is accepted only for a host with no live entry, no
263// uncertain stop on record, and an agent whose startup unit sweep
264// succeeded. Anything else defers the host, writes no journal, and lifts
265// the fence this pass installed.
266func TestConvergeTrustsAnUnprovenStopOnlyWhenNothingCanBeRunning(t *testing.T) {
267 for _, tc := range []struct {
268 name string
269 swept bool
270 awake bool
271 uncertain bool
272 want string
273 }{
274 {"swept, quiesced, certain", true, false, false, "migrated"},
275 {"no startup sweep", false, false, false, "deferred"},
276 {"an entry is live", true, true, false, "deferred"},
277 {"the last stop is uncertain", true, false, true, "deferred"},
278 } {
279 t.Run(tc.name, func(t *testing.T) {
280 p, f := box(t)
281 p.SweptAtStart = tc.swept
282 f.unproven = map[string]bool{host: true}
283 f.awake = map[string]bool{host: tc.awake}
284 f.uncertain = map[string]bool{host: tc.uncertain}
285 r := byHost(t, p.Converge(context.Background(), ModeNested))[host]
286 if r.Outcome != tc.want {
287 t.Fatalf("result = %+v, want %s", r, tc.want)
288 }
289 if tc.want == "deferred" {
290 if !strings.Contains(r.Reason, "stop unproven") {
291 t.Errorf("reason = %q", r.Reason)
292 }
293 assertFlatUntouched(t, p, host)
294 }
295 if len(f.fences) != 0 {
296 t.Errorf("a layout fence was left installed: %v", f.fences)
297 }
298 })
299 }
300}
301
302// wakeBackend is an activator.Backend with no processes, no S3 and no
303// replication: just enough for a real Service to wake a host.
304type wakeBackend struct {
305 mu sync.Mutex
306 started []registry.Route
307}
308
309type nopLease struct{}
310
311func (nopLease) Replicate(context.Context, string, time.Duration) (activator.Rep, error) {
312 return nopRep{}, nil
313}
314func (nopLease) Release(context.Context) error { return nil }
315
316type nopRep struct{}
317
318func (nopRep) Err() error { return nil }
319func (nopRep) Close(context.Context) error { return nil }
320
321type nopProc struct{}
322
323func (nopProc) Stop(context.Context) error { return nil }
324func (nopProc) Exited() bool { return false }
325
326func (b *wakeBackend) Acquire(context.Context, registry.Route) (activator.Lease, error) {
327 return nopLease{}, nil
328}
329func (b *wakeBackend) Restore(context.Context, registry.Route) (bool, error) { return false, nil }
330func (b *wakeBackend) Seed(context.Context, registry.Route) error { return nil }
331func (b *wakeBackend) Start(_ context.Context, rt registry.Route) (activator.Proc, string, error) {
332 b.mu.Lock()
333 defer b.mu.Unlock()
334 b.started = append(b.started, rt)
335 return nopProc{}, "", nil
336}
337func (b *wakeBackend) NextDue(context.Context, registry.Route) (int64, error) { return 0, nil }
338func (b *wakeBackend) HasSidecar(registry.Route) bool { return false }
339func (b *wakeBackend) StartSidecar(context.Context, registry.Route) (activator.Proc, error) {
340 return nil, errors.New("wakeBackend: no sidecar in this fixture")
341}
342func (b *wakeBackend) StartWorker(context.Context, registry.Route, map[string]string) (activator.Proc, string, error) {
343 return nil, "", errors.New("wakeBackend: no worker in this fixture")
344}
345
346func realService(t *testing.T, p *Pass) (*activator.Service, *wakeBackend) {
347 t.Helper()
348 b := &wakeBackend{}
349 svc := activator.New(activator.Config{Bucket: "test-bucket", Idle: time.Hour}, b, p.Registry)
350 // The wake re-reads the committed row, so the test sees which paths it
351 // actually woke on.
352 svc.Routes = p.Registry
353 t.Cleanup(func() {
354 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
355 defer cancel()
356 svc.Shutdown(ctx)
357 })
358 p.Fence = svc
359 return svc, b
360}
361
362// After an agent restart no asleep host has a stop on record, so the real
363// activator answers ErrStopUnproven for every one of them. The pass must
364// still migrate such a host, and must leave it wakeable.
365func TestConvergeAgainstARealActivatorMigratesAnEntrylessHostAndItWakes(t *testing.T) {
366 p, _ := box(t)
367 svc, b := realService(t, p)
368 res := byHost(t, p.Converge(context.Background(), ModeNested))
369 if res[host].Outcome != "migrated" {
370 t.Fatalf("result = %+v", res[host])
371 }
372 if svc.Fenced(host) {
373 t.Fatal("the host is still fenced after the pass")
374 }
375 rt, err := p.Registry.Get(host)
376 if err != nil || rt == nil {
377 t.Fatal(err)
378 }
379 rec := httptest.NewRecorder()
380 req := httptest.NewRequest(http.MethodGet, "http://"+host+"/", nil)
381 ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
382 if !svc.ServeHTTP(rec, req, *rt, ok) || rec.Code != http.StatusOK {
383 t.Fatalf("wake after the pass: code %d", rec.Code)
384 }
385 b.mu.Lock()
386 defer b.mu.Unlock()
387 if len(b.started) != 1 || b.started[0].DBPath != endpoints(t, p, host, ModeNested).DB {
388 t.Fatalf("started = %+v", b.started)
389 }
390}
391
392func TestConvergeAgainstARealActivatorDefersWithoutAStartupSweep(t *testing.T) {
393 p, _ := box(t)
394 svc, _ := realService(t, p)
395 p.SweptAtStart = false
396 for h, r := range byHost(t, p.Converge(context.Background(), ModeNested)) {
397 if r.Outcome != "deferred" || !strings.Contains(r.Reason, "stop unproven") {
398 t.Errorf("%s: %+v", h, r)
399 }
400 if svc.Fenced(h) {
401 t.Errorf("%s left fenced after a refused quiesce", h)
402 }
403 assertFlatUntouched(t, p, h)
404 }
405}
406
407// Another operation's fence (the residence move's cut) shares the
408// activator's one fence slot per host. The pass must neither take it over
409// nor lift it.
410func TestConvergeDefersAHostSomeoneElseFenced(t *testing.T) {
411 p, _ := box(t)
412 svc, _ := realService(t, p)
413 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
414 defer cancel()
415 if _, err := svc.FenceAndStop(ctx, host, 5); err != nil && !errors.Is(err, activator.ErrStopUnproven) {
416 t.Fatalf("pre-installing the other fence: %v", err)
417 }
418 p.Generation = func() uint64 { return 1000 }
419 res := byHost(t, p.Converge(context.Background(), ModeNested))
420 if res[host].Outcome != "deferred" || !strings.Contains(res[host].Reason, "fenced by another operation") {
421 t.Fatalf("fenced host: %+v", res[host])
422 }
423 if res[replica].Outcome != "migrated" {
424 t.Fatalf("the other host: %+v", res[replica])
425 }
426 assertFlatUntouched(t, p, host)
427 if !svc.LiftFence(host, 5) {
428 t.Fatal("the original fence is not in place at its own generation")
429 }
430}
431
432// On the serial-move leg a row that becomes eligible while its host is
433// awake is left for a pass that finds it asleep: DeferAwake defers the
434// host before any fence is asked for, so the move never stops a running
435// tenant. A host already at the intent is unchanged, awake or not, and the
436// same awake host is moved when DeferAwake is off.
437func TestConvergeDefersAnAwakeHostBeforeFencingIt(t *testing.T) {
438 p, f := box(t)
439 p.DeferAwake = true
440 f.awake = map[string]bool{host: true}
441 res := byHost(t, p.Converge(context.Background(), ModeNested))
442 if r := res[host]; r.Outcome != "deferred" || !strings.HasPrefix(r.Reason, "awake") {
443 t.Fatalf("awake host: %+v", r)
444 }
445 if res[replica].Outcome != "migrated" {
446 t.Fatalf("the asleep host: %+v", res[replica])
447 }
448 for _, e := range f.events {
449 if e == "fence "+host || e == "held "+host {
450 t.Fatalf("the awake host was offered a fence: %v", f.events)
451 }
452 }
453 assertFlatUntouched(t, p, host)
454
455 // The next pass finds the host asleep and moves it; the replica is awake
456 // now, but it is already nested.
457 f.awake = map[string]bool{replica: true}
458 res = byHost(t, p.Converge(context.Background(), ModeNested))
459 if res[host].Outcome != "migrated" {
460 t.Fatalf("the host once asleep: %+v", res[host])
461 }
462 if res[replica].Outcome != "unchanged" {
463 t.Fatalf("an awake host already at the intent: %+v", res[replica])
464 }
465
466 p.DeferAwake = false
467 f.awake = map[string]bool{host: true}
468 if r := byHost(t, p.Converge(context.Background(), ModeFlat))[host]; r.Outcome != "reverted" {
469 t.Fatalf("without DeferAwake the awake host: %+v", r)
470 }
471}
472
473func TestConvergeDefersARowThatIsNotReady(t *testing.T) {
474 p, f := box(t)
475 if err := p.Registry.SetStatus(host, "moving"); err != nil {
476 t.Fatal(err)
477 }
478 r := byHost(t, p.Converge(context.Background(), ModeNested))[host]
479 if r.Outcome != "deferred" || !strings.Contains(r.Reason, "moving") {
480 t.Fatalf("result = %+v", r)
481 }
482 for _, e := range f.events {
483 if e == "fence "+host {
484 t.Fatal("a moving row was fenced")
485 }
486 }
487 assertFlatUntouched(t, p, host)
488}
489
490// The row the pass classified can change before its fence lands: a move
491// cut starting, an operator unflagging it. The pass re-reads it under the
492// fence and refuses, writing nothing.
493func TestConvergeRefusesARowThatChangedUnderTheFence(t *testing.T) {
494 for _, tc := range []struct {
495 name string
496 change func(reg *registry.Registry) error
497 }{
498 {"status", func(reg *registry.Registry) error { return reg.SetStatus(host, "moving") }},
499 {"unflagged", func(reg *registry.Registry) error { return reg.SetHibernate(host, false) }},
500 } {
501 t.Run(tc.name, func(t *testing.T) {
502 p, f := box(t)
503 f.onFence = func(h string) {
504 if h == host {
505 if err := tc.change(p.Registry); err != nil {
506 t.Fatal(err)
507 }
508 }
509 }
510 r := byHost(t, p.Converge(context.Background(), ModeNested))[host]
511 if r.Outcome != "refused" {
512 t.Fatalf("result = %+v", r)
513 }
514 assertFlatUntouched(t, p, host)
515 if len(f.fences) != 0 {
516 t.Errorf("fence left installed: %v", f.fences)
517 }
518 })
519 }
520}
521
522// A box whose isolation probe is not ready does not start nested tenants,
523// so it does not make any.
524func TestConvergeNestedIsGatedOnIsolation(t *testing.T) {
525 p, f := box(t)
526 calls := 0
527 p.NestedOK = func() error { calls++; return errors.New("probe: not ready") }
528 res := p.Converge(context.Background(), ModeNested)
529 if len(res) != 1 || res[0].Host != "" || res[0].Outcome != "refused" || res[0].Reason != "isolation not ready: probe: not ready" {
530 t.Fatalf("results = %+v", res)
531 }
532 if calls != 1 {
533 t.Errorf("NestedOK called %d times", calls)
534 }
535 if len(f.events) != 0 {
536 t.Errorf("the pass touched hosts: %v", f.events)
537 }
538 for _, h := range []string{host, replica} {
539 assertFlatUntouched(t, p, h)
540 }
541 // Reverting and recovering are never gated: they are how a box gets
542 // back to a layout it can serve.
543 p.NestedOK = nil
544 p.Converge(context.Background(), ModeNested)
545 p.NestedOK = func() error { return errors.New("probe: not ready") }
546 for h, r := range byHost(t, p.Converge(context.Background(), ModeFlat)) {
547 if r.Outcome != "reverted" {
548 t.Errorf("revert %s under a failing probe: %+v", h, r)
549 }
550 }
551}
552
553func TestConvergeSkipsForeignAndUnmanagedRows(t *testing.T) {
554 p, _ := box(t)
555 if err := p.Registry.Upsert(registry.Route{Host: "custom.example.com", Kind: "instance", Hibernate: true,
556 Socket: filepath.Join(p.SocketDir, "custom.example.com.sock"), DBPath: "/data/sub/store.db"}); err != nil {
557 t.Fatal(err)
558 }
559 if err := p.Registry.Upsert(registry.Route{Host: "ext.example.com", Kind: "instance", Hibernate: false,
560 Socket: filepath.Join(p.SocketDir, "ext.example.com.sock"), DBPath: provision.DefaultDBPath(p.DataDir, "ext.example.com")}); err != nil {
561 t.Fatal(err)
562 }
563 out := byHost(t, p.Converge(context.Background(), ModeNested))
564 if out["custom.example.com"].Outcome != "skipped" || !strings.Contains(out["custom.example.com"].Reason, "foreign") {
565 t.Errorf("foreign row: %+v", out["custom.example.com"])
566 }
567 if _, listed := out["ext.example.com"]; listed {
568 t.Errorf("an unmanaged row was considered: %+v", out["ext.example.com"])
569 }
570}
571
572// A same-uid tenant can plant <data>/.instances/<iid> as a symlink before
573// the pass arrives. The pass moves nothing through it, and since the
574// symlink is the tenant's own doing the host is held fenced and down
575// (review G1) rather than released to run flat again; once the link is
576// gone the next pass moves it from the kept fence.
577func TestConvergeRefusesASymlinkedInstanceDirectory(t *testing.T) {
578 p, f := box(t)
579 elsewhere := t.TempDir()
580 raw := rawInstanceDir(p.DataDir, host)
581 if err := os.MkdirAll(filepath.Dir(raw), 0o700); err != nil {
582 t.Fatal(err)
583 }
584 if err := os.Symlink(elsewhere, raw); err != nil {
585 t.Fatal(err)
586 }
587 r := byHost(t, p.Converge(context.Background(), ModeNested))[host]
588 if r.Outcome != "held" || !strings.Contains(r.Reason, "symlink") {
589 t.Fatalf("result = %+v", r)
590 }
591 assertFlatUntouched(t, p, host)
592 if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 {
593 t.Errorf("files landed through the symlink: %v", entries)
594 }
595 if f.fences[host] == 0 {
596 t.Error("the fence was lifted; the host would wake flat again behind its own symlink")
597 }
598 os.Remove(raw)
599 if r := byHost(t, p.Converge(context.Background(), ModeNested))[host]; r.Outcome != "migrated" || f.fences[host] != 0 {
600 t.Fatalf("after the symlink is gone: %+v fence=%d", r, f.fences[host])
601 }
602}
603
604func TestSerializeWrapsJournalWriteToJournalRemoval(t *testing.T) {
605 p, _ := box(t)
606 var wrapped []string
607 p.Serialize = func(fn func()) {
608 for _, h := range []string{host, replica} {
609 if JournalPresent(p.DataDir, h) {
610 t.Errorf("%s journal present before the serialized section", h)
611 }
612 }
613 fn()
614 for _, h := range []string{host, replica} {
615 if JournalPresent(p.DataDir, h) {
616 t.Errorf("%s journal present after the serialized section", h)
617 }
618 }
619 wrapped = append(wrapped, "section")
620 }
621 p.Converge(context.Background(), ModeNested)
622 if len(wrapped) != 2 {
623 t.Fatalf("serialized sections = %d, want one per host", len(wrapped))
624 }
625}
626
627func TestRecoverCompletesEveryCrashBoundary(t *testing.T) {
628 // Drive the pass through a move with a seam that aborts after each
629 // step, then Recover, and assert the end state is the nested layout
630 // with no journal, every time.
631 for _, step := range []string{"after-journal", "mid-files", "after-files", "after-cas", "before-remove"} {
632 t.Run(step, func(t *testing.T) {
633 p, f := box(t)
634 p.abortAt = step
635 p.Converge(context.Background(), ModeNested)
636 p.abortAt = ""
637 for _, h := range []string{host, replica} {
638 if !JournalPresent(p.DataDir, h) {
639 t.Fatalf("%s: no journal after aborting at %s", h, step)
640 }
641 }
642 if len(f.fences) != 0 {
643 t.Errorf("an aborted move left its fence: %v", f.fences)
644 }
645 res := byHost(t, p.Recover(context.Background(), ""))
646 for _, h := range []string{host, replica} {
647 if res[h].Outcome != "recovered" {
648 t.Fatalf("recover %s = %+v", h, res[h])
649 }
650 if got := rowAt(t, p, h); got != endpoints(t, p, h, ModeNested) {
651 t.Errorf("%s row not nested after recovery from %s: %+v", h, step, got)
652 }
653 if b, err := os.ReadFile(filepath.Join(rawInstanceDir(p.DataDir, h), h+".db")); err != nil || string(b) != "db "+h {
654 t.Errorf("%s db not at destination after recovery from %s: %q %v", h, step, b, err)
655 }
656 if JournalPresent(p.DataDir, h) {
657 t.Errorf("%s journal remains after recovery", h)
658 }
659 }
660 if len(f.fences) != 0 {
661 t.Errorf("recovery left a fence: %v", f.fences)
662 }
663 })
664 }
665}
666
667func TestRecoverRefusesAForgedJournalAndKeepsTheHostFenced(t *testing.T) {
668 p, _ := box(t)
669 j := &Journal{Host: host, Mode: ModeNested,
670 From: Endpoint{DB: "/data/console/console.db", Socket: filepath.Join(p.SocketDir, host+".sock")},
671 To: endpoints(t, p, host, ModeNested),
672 Phase: PhaseFiles}
673 for _, n := range artifactNames {
674 j.Artifacts = append(j.Artifacts, JournalArtifact{Name: n, Present: true})
675 }
676 if err := WriteJournal(p.DataDir, j); err != nil {
677 t.Fatal(err)
678 }
679 r := byHost(t, p.Recover(context.Background(), ModeNested))[host]
680 if r.Outcome != "refused" || !strings.Contains(r.Reason, "not acting") {
681 t.Fatalf("forged journal: %+v", r)
682 }
683 if !JournalPresent(p.DataDir, host) {
684 t.Error("forged journal removed; the host must stay fenced for an operator")
685 }
686 if _, err := os.Stat(filepath.Join(p.DataDir, "console")); err == nil {
687 t.Error("something touched the forged source")
688 }
689 // Converge reports the host once, as the recovery refused it.
690 res := byHost(t, p.Converge(context.Background(), ModeNested))
691 if res[host].Outcome != "refused" {
692 t.Errorf("converge over a refused journal: %+v", res[host])
693 }
694}
695
696// A file at both ends of a move cannot come from a crash: the destination
697// check cleared every destination before the journal was written, and a
698// rename leaves a file at exactly one of them. Recovery refuses to choose.
699func TestRecoverRefusesAnArtifactAtBothEnds(t *testing.T) {
700 p, _ := box(t)
701 p.abortAt = "after-journal"
702 p.Converge(context.Background(), ModeNested)
703 p.abortAt = ""
704 dst := filepath.Join(rawInstanceDir(p.DataDir, host), host+".db")
705 if err := os.WriteFile(dst, []byte("planted"), 0o600); err != nil {
706 t.Fatal(err)
707 }
708 r := byHost(t, p.Recover(context.Background(), ""))[host]
709 if r.Outcome != "refused" || !strings.Contains(r.Reason, "both") {
710 t.Fatalf("result = %+v", r)
711 }
712 if !JournalPresent(p.DataDir, host) {
713 t.Error("journal removed over an ambiguous artifact")
714 }
715 if b, _ := os.ReadFile(provision.DefaultDBPath(p.DataDir, host)); string(b) != "db "+host {
716 t.Error("the source database was touched")
717 }
718}
719
720// A journal at row says every file it records present is at its
721// destination. Recovery checks that before it flips the row, because a row
722// pointed at a missing database would have the next wake restore a second
723// one there.
724func TestRecoverAtRowRefusesAMissingDestinationBeforeTheCAS(t *testing.T) {
725 p, f := box(t)
726 p.abortAt = "after-files"
727 p.Converge(context.Background(), ModeNested)
728 p.abortAt = ""
729 j, found, err := ReadJournal(p.DataDir, host)
730 if err != nil || !found {
731 t.Fatalf("setup: %v %v", found, err)
732 }
733 if err := UpdatePhase(p.DataDir, j, PhaseRow); err != nil {
734 t.Fatal(err)
735 }
736 wal := filepath.Join(rawInstanceDir(p.DataDir, host), host+".db-wal")
737 if err := os.Remove(wal); err != nil {
738 t.Fatal(err)
739 }
740 f.events = nil
741 r := byHost(t, p.Recover(context.Background(), ModeNested))[host]
742 if r.Outcome != "refused" || !strings.Contains(r.Reason, wal) {
743 t.Fatalf("result = %+v, want a refusal naming %s", r, wal)
744 }
745 if got, found, _ := ReadJournal(p.DataDir, host); !found || got.Phase != PhaseRow {
746 t.Errorf("journal not kept at row: %+v", got)
747 }
748 if rowAt(t, p, host) != endpoints(t, p, host, ModeFlat) {
749 t.Errorf("the row flipped over a missing file: %+v", rowAt(t, p, host))
750 }
751}
752
753// Design §3.3: a journal is completed to its own endpoint, and the host is
754// then converged to the fleet's intent before it is released. The fence
755// is held from the recovery through the convergence: nothing lifts it in
756// between, so the host never wakes at the journal's endpoint.
757func TestConvergeRecoversAJournalBeforeConvergingToANewIntent(t *testing.T) {
758 p, f := box(t)
759 p.abortAt = "after-files"
760 p.Converge(context.Background(), ModeNested)
761 p.abortAt = ""
762 f.events = nil
763 // The intent changed to flat while the move was in flight: the journal
764 // completes to ITS endpoint (nested), then the host converges to flat.
765 res := byHost(t, p.Converge(context.Background(), ModeFlat))
766 for _, h := range []string{host, replica} {
767 if res[h].Outcome != "recovered" {
768 t.Errorf("%s: %+v", h, res[h])
769 }
770 assertFlatUntouched(t, p, h)
771 }
772 want := []string{
773 "fence " + host, "reload", "reload", "lift " + host,
774 "fence " + replica, "reload", "reload", "lift " + replica,
775 }
776 if !reflect.DeepEqual(f.events, want) {
777 t.Fatalf("events = %v\nwant %v", f.events, want)
778 }
779}
780
781// With no intent recorded, a journal is completed and the host released
782// where the journal ends.
783func TestRecoverWithNoIntentReleasesAtTheJournalsEndpoint(t *testing.T) {
784 p, f := box(t)
785 p.abortAt = "after-files"
786 p.Converge(context.Background(), ModeNested)
787 p.abortAt = ""
788 for h, r := range byHost(t, p.Recover(context.Background(), "")) {
789 if r.Outcome != "recovered" || rowAt(t, p, h) != endpoints(t, p, h, ModeNested) {
790 t.Errorf("%s: %+v at %+v", h, r, rowAt(t, p, h))
791 }
792 }
793 if len(f.fences) != 0 {
794 t.Errorf("fences: %v", f.fences)
795 }
796}
797
798// When the agent starts and cannot read the fleet's intent, a revert in
799// flight is completed but kept fenced at phase done: releasing it flat
800// could let a forged revert run a nested host unisolated until an intent
801// is read. A move to nested is completed and released as usual.
802func TestRecoverHoldsACompletedRevertWhileTheIntentIsUnreadable(t *testing.T) {
803 p, f := box(t)
804 if res := byHost(t, p.Converge(context.Background(), ModeNested)); res[host].Outcome != "migrated" {
805 t.Fatalf("setup: %+v", res)
806 }
807 p.abortAt = "after-cas"
808 p.Converge(context.Background(), ModeFlat)
809 p.abortAt = ""
810 res := byHost(t, p.Recover(context.Background(), IntentUnreadable))
811 for _, h := range []string{host, replica} {
812 if res[h].Outcome != "deferred" || !strings.Contains(res[h].Reason, "intent") {
813 t.Errorf("%s: %+v", h, res[h])
814 }
815 j, found, err := ReadJournal(p.DataDir, h)
816 if err != nil || !found || j.Phase != PhaseDone {
817 t.Fatalf("%s journal: %+v %v %v", h, j, found, err)
818 }
819 if rowAt(t, p, h) != endpoints(t, p, h, ModeFlat) {
820 t.Errorf("%s row not completed to flat", h)
821 }
822 }
823 if len(f.fences) != 0 {
824 t.Errorf("the journal is the fence; the activator's must not stay: %v", f.fences)
825 }
826 // Once the intent is read, the held host converges to it.
827 res = byHost(t, p.Recover(context.Background(), ModeNested))
828 for _, h := range []string{host, replica} {
829 if res[h].Outcome != "recovered" || rowAt(t, p, h) != endpoints(t, p, h, ModeNested) || JournalPresent(p.DataDir, h) {
830 t.Errorf("%s after the intent: %+v at %+v", h, res[h], rowAt(t, p, h))
831 }
832 }
833
834 // A move toward nested is released even while the intent is unreadable.
835 q, _ := box(t)
836 q.abortAt = "after-files"
837 q.Converge(context.Background(), ModeNested)
838 q.abortAt = ""
839 for h, r := range byHost(t, q.Recover(context.Background(), IntentUnreadable)) {
840 if r.Outcome != "recovered" || JournalPresent(q.DataDir, h) {
841 t.Errorf("%s nested journal under an unreadable intent: %+v", h, r)
842 }
843 }
844}
845
846// abortedNestedMove leaves both hosts with a nested journal at phase files
847// and their files already at the nested destination.
848func abortedNestedMove(t *testing.T, p *Pass) {
849 t.Helper()
850 p.abortAt = "after-files"
851 p.Converge(context.Background(), ModeNested)
852 p.abortAt = ""
853 for _, h := range []string{host, replica} {
854 if j, found, err := ReadJournal(p.DataDir, h); err != nil || !found || j.Phase != PhaseFiles {
855 t.Fatalf("setup %s: %+v %v %v", h, j, found, err)
856 }
857 }
858}
859
860func installForeignFence(t *testing.T, svc *activator.Service, h string, gen uint64) {
861 t.Helper()
862 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
863 defer cancel()
864 if _, err := svc.FenceAndStop(ctx, h, gen); err != nil && !errors.Is(err, activator.ErrStopUnproven) {
865 t.Fatalf("installing the foreign fence: %v", err)
866 }
867}
868
869// A journal on a host another operation holds fenced is completed under
870// that operation's fence: the pass installs and lifts nothing. When the
871// completed endpoint is the fleet's intent there is nothing left to do,
872// and the journal goes.
873func TestRecoverUnderAForeignFenceCompletesAndRemovesAtTheIntent(t *testing.T) {
874 p, _ := box(t)
875 svc, _ := realService(t, p)
876 abortedNestedMove(t, p)
877 installForeignFence(t, svc, host, 5)
878 res := byHost(t, p.Recover(context.Background(), ModeNested))
879 for _, h := range []string{host, replica} {
880 if res[h].Outcome != "recovered" || JournalPresent(p.DataDir, h) || rowAt(t, p, h) != endpoints(t, p, h, ModeNested) {
881 t.Errorf("%s: %+v at %+v", h, res[h], rowAt(t, p, h))
882 }
883 }
884 if svc.Fenced(replica) {
885 t.Error("the unfenced host was left fenced")
886 }
887 if !svc.LiftFence(host, 5) {
888 t.Fatal("the foreign fence is not in place at its own generation")
889 }
890}
891
892// When the completed endpoint is not the intent, the pass cannot converge
893// the host without a fence of its own. It keeps the journal at done, which
894// keeps the host fenced if the other operation lifts its fence, and the
895// next recovery after that lift converges it.
896func TestRecoverUnderAForeignFenceKeepsTheJournalWhenNotAtTheIntent(t *testing.T) {
897 p, _ := box(t)
898 svc, _ := realService(t, p)
899 abortedNestedMove(t, p)
900 installForeignFence(t, svc, host, 5)
901 res := byHost(t, p.Recover(context.Background(), ModeFlat))
902 if r := res[host]; r.Outcome != "deferred" || !strings.Contains(r.Reason, "fence") {
903 t.Fatalf("held host: %+v", r)
904 }
905 // The move waits for the journal and the journal waits for the move,
906 // so the reason has to tell an operator both ways out.
907 for _, want := range []string{"instance move", "abort the move", provision.LayoutJournalPath(p.DataDir, host), "stands at its destination"} {
908 if r := res[host]; !strings.Contains(r.Reason, want) {
909 t.Errorf("reason %q does not say %q", r.Reason, want)
910 }
911 }
912 if r := res[host]; strings.Contains(r.Reason, "\u2014") {
913 t.Errorf("reason has an em dash: %q", r.Reason)
914 }
915 if j, found, err := ReadJournal(p.DataDir, host); err != nil || !found || j.Phase != PhaseDone {
916 t.Fatalf("journal: %+v %v %v", j, found, err)
917 }
918 if rowAt(t, p, host) != endpoints(t, p, host, ModeNested) {
919 t.Errorf("row not completed to the journal's endpoint: %+v", rowAt(t, p, host))
920 }
921 if res[replica].Outcome != "recovered" {
922 t.Errorf("unfenced host: %+v", res[replica])
923 }
924 assertFlatUntouched(t, p, replica)
925 if !svc.Fenced(host) {
926 t.Fatal("the foreign fence was lifted")
927 }
928 if !svc.LiftFence(host, 5) {
929 t.Fatal("the foreign fence is not at its own generation")
930 }
931 // The other operation is gone: the next recovery converges the host.
932 if r := byHost(t, p.Recover(context.Background(), ModeFlat))[host]; r.Outcome != "recovered" {
933 t.Fatalf("after the foreign fence lifted: %+v", r)
934 }
935 assertFlatUntouched(t, p, host)
936 if svc.Fenced(host) {
937 t.Error("left fenced")
938 }
939}
940
941// Under a foreign fence the pass has no stop proof of its own, so it acts
942// only on a host with no live entry. Otherwise nothing changes.
943func TestRecoverUnderAForeignFenceDefersAnAwakeHost(t *testing.T) {
944 p, f := box(t)
945 abortedNestedMove(t, p)
946 f.fences = map[string]uint64{host: 5}
947 f.awake = map[string]bool{host: true}
948 f.events = nil
949 r := byHost(t, p.Recover(context.Background(), ModeNested))[host]
950 if r.Outcome != "deferred" {
951 t.Fatalf("result = %+v", r)
952 }
953 if j, found, _ := ReadJournal(p.DataDir, host); !found || j.Phase != PhaseFiles {
954 t.Fatalf("journal changed: %+v", j)
955 }
956 if rowAt(t, p, host) != endpoints(t, p, host, ModeFlat) {
957 t.Errorf("row changed: %+v", rowAt(t, p, host))
958 }
959 if f.fences[host] != 5 {
960 t.Errorf("foreign fence = %d", f.fences[host])
961 }
962 for _, e := range f.events {
963 if e == "lift "+host || e == "fence "+host {
964 t.Errorf("the pass touched the host's fence: %v", f.events)
965 }
966 }
967}
968
969// Completing a journal is never gated, but taking the host on toward a
970// nested intent is, as Converge(ModeNested) is. With the probe failing, a
971// revert completes and the host is held flat, not released: there is no
972// degraded mode (design §8.5), and a released flat host is exactly what a
973// forged revert wants. The journal at done is the fence, so the pass
974// lifts its in-memory one.
975func TestRecoverGatesTheLegTowardNestedAndHoldsFlat(t *testing.T) {
976 p, f := box(t)
977 if res := byHost(t, p.Converge(context.Background(), ModeNested)); res[host].Outcome != "migrated" {
978 t.Fatalf("setup: %+v", res)
979 }
980 p.abortAt = "after-files"
981 p.Converge(context.Background(), ModeFlat)
982 p.abortAt = ""
983 calls := 0
984 p.NestedOK = func() error { calls++; return errors.New("probe: not ready") }
985 f.events = nil
986 res := byHost(t, p.Recover(context.Background(), ModeNested))
987 for _, h := range []string{host, replica} {
988 if r := res[h]; r.Outcome != "deferred" || !strings.Contains(r.Reason, "isolation not ready: probe: not ready") {
989 t.Errorf("%s: %+v", h, r)
990 }
991 assertHeldFlat(t, p, h)
992 }
993 if calls != 1 {
994 t.Errorf("NestedOK called %d times, want once per recovery", calls)
995 }
996 want := []string{"fence " + host, "reload", "lift " + host, "fence " + replica, "reload", "lift " + replica}
997 if !reflect.DeepEqual(f.events, want) {
998 t.Fatalf("events = %v\nwant %v", f.events, want)
999 }
1000 // Once the probe passes, the next recovery takes the held host on to
1001 // nested.
1002 p.NestedOK = nil
1003 for h, r := range byHost(t, p.Recover(context.Background(), ModeNested)) {
1004 if r.Outcome != "recovered" || JournalPresent(p.DataDir, h) || rowAt(t, p, h) != endpoints(t, p, h, ModeNested) {
1005 t.Errorf("%s after the probe passed: %+v", h, r)
1006 }
1007 }
1008}
1009
1010// assertHeldFlat is a host whose revert completed and is held: the row
1011// and files at flat, and the journal still there at done.
1012func assertHeldFlat(t *testing.T, p *Pass, h string) {
1013 t.Helper()
1014 if got := rowAt(t, p, h); got != endpoints(t, p, h, ModeFlat) {
1015 t.Errorf("%s row not at flat: %+v", h, got)
1016 }
1017 if b, err := os.ReadFile(provision.DefaultDBPath(p.DataDir, h)); err != nil || string(b) != "db "+h {
1018 t.Errorf("%s flat db: %q %v", h, b, err)
1019 }
1020 if j, found, err := ReadJournal(p.DataDir, h); err != nil || !found || j.Mode != ModeFlat || j.Phase != PhaseDone {
1021 t.Errorf("%s journal: %+v %v %v, want a flat journal held at done", h, j, found, err)
1022 }
1023}
1024
1025// addFlatRow adds a fresh flat managed row with its files, after any setup
1026// that would otherwise have moved it.
1027func addFlatRow(t *testing.T, p *Pass, h string) {
1028 t.Helper()
1029 if err := p.Registry.Upsert(registry.Route{Host: h, Kind: "instance", Hibernate: true,
1030 Socket: filepath.Join(p.SocketDir, h+".sock"), DBPath: provision.DefaultDBPath(p.DataDir, h)}); err != nil {
1031 t.Fatal(err)
1032 }
1033 seedFlat(t, p.DataDir, h)
1034}
1035
1036// forgeFlatJournal writes a well-formed revert journal for a nested host,
1037// recording each artifact present exactly when it is at its nested source,
1038// except the names in absent, which it records absent regardless. It is
1039// what a flat tenant sharing the agent's uid can write into <data>/layout.
1040func forgeFlatJournal(t *testing.T, p *Pass, h string, absent ...string) {
1041 t.Helper()
1042 plan, err := PlanFor(p.DataDir, p.SocketDir, h, ModeFlat)
1043 if err != nil {
1044 t.Fatal(err)
1045 }
1046 present := make([]bool, len(plan.Artifacts))
1047 for i, a := range plan.Artifacts {
1048 ok, _ := lstatPresent(a.Src)
1049 present[i] = ok && a.Name != "socket" && !slicesContains(absent, a.Name)
1050 }
1051 if err := WriteJournal(p.DataDir, plan.Journal(present)); err != nil {
1052 t.Fatal(err)
1053 }
1054}
1055
1056func slicesContains(xs []string, x string) bool {
1057 for _, v := range xs {
1058 if v == x {
1059 return true
1060 }
1061 }
1062 return false
1063}
1064
1065// A journal completed to an endpoint that is not the fleet's intent keeps
1066// the host fenced until the converge leg succeeds. If the leg refuses, the
1067// journal stays at done: a forged revert must not leave a nested host
1068// released flat.
1069func TestRecoverHoldsTheJournalWhenTheLegTowardTheIntentRefuses(t *testing.T) {
1070 p, f := box(t)
1071 if res := byHost(t, p.Converge(context.Background(), ModeNested)); res[host].Outcome != "migrated" {
1072 t.Fatalf("setup: %+v", res)
1073 }
1074 forgeFlatJournal(t, p, host)
1075 // A route that owns <host>.config.env under the flat rules makes the
1076 // way back to nested refuse at the ownership preflight.
1077 if err := p.Registry.Upsert(registry.Route{Host: host + ".config", Kind: "instance",
1078 Socket: filepath.Join(p.SocketDir, host+".config.sock"), DBPath: provision.DefaultDBPath(p.DataDir, host+".config")}); err != nil {
1079 t.Fatal(err)
1080 }
1081 r := byHost(t, p.Recover(context.Background(), ModeNested))[host]
1082 if r.Outcome != "deferred" || !strings.Contains(r.Reason, "config") {
1083 t.Fatalf("result = %+v", r)
1084 }
1085 j, found, err := ReadJournal(p.DataDir, host)
1086 if err != nil || !found || j.Phase != PhaseDone || j.Mode != ModeFlat {
1087 t.Fatalf("journal not held at done: %+v %v %v", j, found, err)
1088 }
1089 if len(f.fences) != 0 {
1090 t.Errorf("fences: %v", f.fences)
1091 }
1092 // Every later pass holds it the same way; it is never released flat.
1093 if r := byHost(t, p.Converge(context.Background(), ModeNested))[host]; r.Outcome != "deferred" || !JournalPresent(p.DataDir, host) {
1094 t.Fatalf("second pass: %+v", r)
1095 }
1096}
1097
1098// The attack the hold closes, end to end: a forged revert that records a
1099// real nested artifact absent. Recovery refuses it outright, and the host
1100// stays nested and fenced.
1101func TestRecoverDoesNotReleaseANestedHostFlatOverAForgedRevert(t *testing.T) {
1102 p, _ := box(t)
1103 if res := byHost(t, p.Converge(context.Background(), ModeNested)); res[host].Outcome != "migrated" {
1104 t.Fatalf("setup: %+v", res)
1105 }
1106 shadow := filepath.Join(rawInstanceDir(p.DataDir, host), shadowName(host))
1107 if _, err := os.Stat(shadow); err != nil {
1108 t.Fatalf("setup: %v", err)
1109 }
1110 forgeFlatJournal(t, p, host, "litestream-shadow")
1111 r := byHost(t, p.Recover(context.Background(), ModeNested))[host]
1112 if r.Outcome == "recovered" {
1113 t.Fatalf("result = %+v", r)
1114 }
1115 if rowAt(t, p, host) != endpoints(t, p, host, ModeNested) {
1116 t.Errorf("row released flat: %+v", rowAt(t, p, host))
1117 }
1118 if !JournalPresent(p.DataDir, host) {
1119 t.Error("journal removed; the host is no longer fenced")
1120 }
1121}
1122
1123// A journal's "absent" is a claim a crash cannot contradict: the host has
1124// been fenced since the plan was made, so nothing recorded absent can have
1125// appeared at either end. Recovery refuses one that has, naming it.
1126func TestRecoverRefusesAnArtifactRecordedAbsentThatExists(t *testing.T) {
1127 for _, end := range []string{"source", "destination"} {
1128 t.Run(end, func(t *testing.T) {
1129 p, _ := box(t)
1130 abortedNestedMove(t, p)
1131 plan, _ := PlanFor(p.DataDir, p.SocketDir, host, ModeNested)
1132 sidecar := artifactNamed(t, plan, "sidecar-env")
1133 path := sidecar.Src
1134 if end == "destination" {
1135 path = sidecar.Dst
1136 }
1137 if err := os.WriteFile(path, []byte("X=1\n"), 0o600); err != nil {
1138 t.Fatal(err)
1139 }
1140 r := byHost(t, p.Recover(context.Background(), ModeNested))[host]
1141 if r.Outcome != "refused" || !strings.Contains(r.Reason, path) || !strings.Contains(r.Reason, "absent") {
1142 t.Fatalf("result = %+v", r)
1143 }
1144 if j, found, _ := ReadJournal(p.DataDir, host); !found || j.Phase != PhaseFiles {
1145 t.Fatalf("journal changed: %+v", j)
1146 }
1147 })
1148 }
1149}
1150
1151// Completing a journal is never gated, so a failing probe does not leave a
1152// crashed move fenced. Only fresh moves wait for the probe.
1153func TestConvergeNestedCompletesAJournalWhileTheProbeFails(t *testing.T) {
1154 p, _ := box(t)
1155 abortedNestedMove(t, p)
1156 addFlatRow(t, p, "f.example.com")
1157 calls := 0
1158 p.NestedOK = func() error { calls++; return errors.New("probe: not ready") }
1159 res := byHost(t, p.Converge(context.Background(), ModeNested))
1160 for _, h := range []string{host, replica} {
1161 if res[h].Outcome != "recovered" || JournalPresent(p.DataDir, h) || rowAt(t, p, h) != endpoints(t, p, h, ModeNested) {
1162 t.Errorf("%s: %+v", h, res[h])
1163 }
1164 }
1165 if r := res[""]; r.Outcome != "refused" || r.Reason != "isolation not ready: probe: not ready" {
1166 t.Errorf("pass result: %+v", r)
1167 }
1168 if _, listed := res["f.example.com"]; listed {
1169 t.Errorf("a fresh move ran under a failing probe: %+v", res["f.example.com"])
1170 }
1171 assertFlatUntouched(t, p, "f.example.com")
1172 if calls != 1 {
1173 t.Errorf("NestedOK called %d times", calls)
1174 }
1175}
1176
1177// One pass asks the probe once, whether the question comes from a
1178// recovery's leg toward nested or from the fresh moves.
1179func TestConvergeAsksTheIsolationGateOncePerPass(t *testing.T) {
1180 for _, tc := range []struct {
1181 name string
1182 gate error
1183 }{{"failing", errors.New("probe: not ready")}, {"ready", nil}} {
1184 t.Run(tc.name, func(t *testing.T) {
1185 p, _ := box(t)
1186 if res := byHost(t, p.Converge(context.Background(), ModeNested)); res[host].Outcome != "migrated" {
1187 t.Fatalf("setup: %+v", res)
1188 }
1189 p.abortAt = "after-files"
1190 p.Converge(context.Background(), ModeFlat)
1191 p.abortAt = ""
1192 addFlatRow(t, p, "f.example.com")
1193 calls := 0
1194 p.NestedOK = func() error { calls++; return tc.gate }
1195 res := byHost(t, p.Converge(context.Background(), ModeNested))
1196 if calls != 1 {
1197 t.Fatalf("NestedOK called %d times in one pass", calls)
1198 }
1199 want := "recovered"
1200 if tc.gate != nil {
1201 want = "deferred"
1202 }
1203 for _, h := range []string{host, replica} {
1204 if res[h].Outcome != want {
1205 t.Errorf("%s: %+v, want %s", h, res[h], want)
1206 }
1207 }
1208 if tc.gate != nil {
1209 assertHeldFlat(t, p, host)
1210 assertFlatUntouched(t, p, "f.example.com")
1211 } else if res["f.example.com"].Outcome != "migrated" || rowAt(t, p, host) != endpoints(t, p, host, ModeNested) {
1212 t.Errorf("ready probe: %+v", res)
1213 }
1214 })
1215 }
1216}
1217
1218// A host whose database is not on this box cannot move; finding that out
1219// must not cost it a stop, or directories and a destination check, on
1220// every pass.
1221func TestConvergeRefusesAHostWithNoLocalDatabaseWithoutFencingIt(t *testing.T) {
1222 p, f := box(t)
1223 db := provision.DefaultDBPath(p.DataDir, host)
1224 if err := os.Remove(db); err != nil {
1225 t.Fatal(err)
1226 }
1227 res := byHost(t, p.Converge(context.Background(), ModeNested))
1228 if r := res[host]; r.Outcome != "refused" || !strings.Contains(r.Reason, "db is missing at "+db) {
1229 t.Fatalf("result = %+v", r)
1230 }
1231 for _, e := range f.events {
1232 if strings.HasSuffix(e, " "+host) {
1233 t.Errorf("the host's fence was touched: %v", f.events)
1234 }
1235 }
1236 if _, err := os.Lstat(rawInstanceDir(p.DataDir, host)); !errors.Is(err, os.ErrNotExist) {
1237 t.Errorf("directories were made for a host that cannot move: %v", err)
1238 }
1239 if res[replica].Outcome != "migrated" {
1240 t.Errorf("the other host: %+v", res[replica])
1241 }
1242}
1243
1244// crashedRevert leaves both hosts nested-then-mid-revert: each has a flat
1245// journal at phase files with its files already at the flat destination.
1246func crashedRevert(t *testing.T, p *Pass) {
1247 t.Helper()
1248 if res := byHost(t, p.Converge(context.Background(), ModeNested)); res[host].Outcome != "migrated" {
1249 t.Fatalf("setup: %+v", res)
1250 }
1251 p.abortAt = "after-files"
1252 p.Converge(context.Background(), ModeFlat)
1253 p.abortAt = ""
1254}
1255
1256// The handover from the completed journal to the converge leg's journal
1257// is one rename, and anything going wrong on it leaves the host down: the
1258// held journal stays whole, the pass's fence stays installed, and nothing
1259// is released. A same-uid tenant can make <data>/layout unwritable at the
1260// right moment, so this is reachable, not only a disk fault.
1261func TestRecoverKeepsTheFenceWhenTheHandoverFails(t *testing.T) {
1262 if os.Geteuid() == 0 {
1263 t.Skip("root ignores the directory mode this test relies on")
1264 }
1265 p, f := box(t)
1266 crashedRevert(t, p)
1267 dir := journalDir(p.DataDir)
1268 calls := 0
1269 p.Serialize = func(fn func()) {
1270 calls++
1271 // The second section on the first host is the handover: make the
1272 // temp file impossible to create there, as a racing tenant could.
1273 if calls == 2 {
1274 if err := os.Chmod(dir, 0o500); err != nil {
1275 t.Fatal(err)
1276 }
1277 defer os.Chmod(dir, 0o700)
1278 }
1279 fn()
1280 }
1281 f.events = nil
1282 r := byHost(t, p.Recover(context.Background(), ModeNested))[host]
1283 if r.Outcome != "refused" || !strings.Contains(r.Reason, "held by this pass after a failed handover") {
1284 t.Fatalf("result = %+v", r)
1285 }
1286 j, found, err := ReadJournal(p.DataDir, host)
1287 if err != nil || !found || j.Mode != ModeFlat || j.Phase != PhaseDone {
1288 t.Fatalf("held journal not intact: %+v %v %v", j, found, err)
1289 }
1290 if f.fences[host] == 0 {
1291 t.Error("the pass's fence was lifted after a failed handover")
1292 }
1293 for _, e := range f.events {
1294 if e == "lift "+host {
1295 t.Errorf("released after a failed handover: %v", f.events)
1296 }
1297 }
1298 if left := tempFilesIn(t, dir); len(left) != 0 {
1299 t.Errorf("temp files left: %v", left)
1300 }
1301}
1302
1303// A fence the pass kept after a failed handover is its own, not another
1304// operation's. The next pass recognises it by its generation, re-runs the
1305// leg once <data>/layout is writable again, and lifts it when the host
1306// stands at the intent.
1307func TestRecoverRetriesAHandoverUnderTheFenceItKept(t *testing.T) {
1308 if os.Geteuid() == 0 {
1309 t.Skip("root ignores the directory mode this test relies on")
1310 }
1311 p, f := box(t)
1312 crashedRevert(t, p)
1313 dir := journalDir(p.DataDir)
1314 calls := 0
1315 p.Serialize = func(fn func()) {
1316 calls++
1317 if calls == 2 {
1318 if err := os.Chmod(dir, 0o500); err != nil {
1319 t.Fatal(err)
1320 }
1321 defer os.Chmod(dir, 0o700)
1322 }
1323 fn()
1324 }
1325 first := byHost(t, p.Recover(context.Background(), ModeNested))[host]
1326 if first.Outcome != "refused" || !strings.Contains(first.Reason, "held by this pass after a failed handover") || !strings.Contains(first.Reason, "the next pass retries it") {
1327 t.Fatalf("first pass: %+v", first)
1328 }
1329 kept := f.fences[host]
1330 if kept == 0 {
1331 t.Fatal("the fence was not kept")
1332 }
1333 p.Serialize = nil
1334 f.events = nil
1335 res := p.Recover(context.Background(), ModeNested)
1336 second := byHost(t, res)[host]
1337 if second.Outcome != "recovered" || !strings.Contains(second.Reason, "converged to nested") {
1338 t.Fatalf("second pass: %+v", second)
1339 }
1340 for _, r := range append(res, first) {
1341 if strings.Contains(r.Reason, "another operation") {
1342 t.Errorf("the pass's own fence reported as another operation's: %+v", r)
1343 }
1344 }
1345 if JournalPresent(p.DataDir, host) || rowAt(t, p, host) != endpoints(t, p, host, ModeNested) {
1346 t.Fatalf("host not converged: journal %v, row %+v", JournalPresent(p.DataDir, host), rowAt(t, p, host))
1347 }
1348 if f.fences[host] != 0 {
1349 t.Fatalf("the kept fence (generation %d) is still installed at %d", kept, f.fences[host])
1350 }
1351 if !slicesContains(f.events, "lift "+host) || slicesContains(f.events, "fence "+host) {
1352 t.Errorf("events = %v, want the kept fence lifted and no new one installed", f.events)
1353 }
1354 // The record went with the fence: a later foreign fence at the same
1355 // generation is not mistaken for the pass's own.
1356 p.keptMu.Lock()
1357 _, still := p.kept[host]
1358 p.keptMu.Unlock()
1359 if still {
1360 t.Error("the kept generation is still recorded after the lift")
1361 }
1362}
1363
1364// A successful handover leaves exactly one journal, the converge leg's
1365// own, and no temp file.
1366func TestRecoverHandsOverToTheNewJournalInOneStep(t *testing.T) {
1367 p, _ := box(t)
1368 crashedRevert(t, p)
1369 p.abortAt = "after-journal"
1370 p.Recover(context.Background(), ModeNested)
1371 p.abortAt = ""
1372 j, found, err := ReadJournal(p.DataDir, host)
1373 if err != nil || !found || j.Mode != ModeNested || j.Phase != PhaseFiles {
1374 t.Fatalf("journal after the handover: %+v %v %v", j, found, err)
1375 }
1376 entries, err := os.ReadDir(journalDir(p.DataDir))
1377 if err != nil {
1378 t.Fatal(err)
1379 }
1380 var names []string
1381 for _, e := range entries {
1382 names = append(names, e.Name())
1383 }
1384 want := []string{host + ".json", replica + ".json"}
1385 if !reflect.DeepEqual(names, want) {
1386 t.Fatalf("journal dir = %v, want %v", names, want)
1387 }
1388}
1389
1390// The presence scan runs inside the section config sync is locked out of,
1391// so a bundle rendered just before the pass took the lock is recorded and
1392// moved, never stranded at the flat path behind a journal that calls it
1393// absent.
1394func TestThePresenceScanRunsInsideTheSerializedSection(t *testing.T) {
1395 p, _ := box(t)
1396 rendered := false
1397 p.Serialize = func(fn func()) {
1398 if !rendered {
1399 // Config sync's last render, landing while it still held the
1400 // lock the pass is now taking.
1401 for _, h := range []string{host, replica} {
1402 if err := os.WriteFile(filepath.Join(p.DataDir, h+".config.env"), []byte("A=1\n"), 0o600); err != nil {
1403 t.Fatal(err)
1404 }
1405 }
1406 rendered = true
1407 }
1408 fn()
1409 }
1410 res := byHost(t, p.Converge(context.Background(), ModeNested))
1411 for _, h := range []string{host, replica} {
1412 if res[h].Outcome != "migrated" {
1413 t.Fatalf("%s: %+v", h, res[h])
1414 }
1415 if _, err := os.Lstat(filepath.Join(p.DataDir, h+".config.env")); !errors.Is(err, os.ErrNotExist) {
1416 t.Errorf("%s bundle stranded at the flat path", h)
1417 }
1418 if _, err := os.Stat(filepath.Join(rawInstanceDir(p.DataDir, h), h+".config.env")); err != nil {
1419 t.Errorf("%s bundle not moved: %v", h, err)
1420 }
1421 }
1422}
1423
1424// Review F1 end to end, as amended for landing: a planted hard link in
1425// the host's future directory is quarantined and the host migrates; the
1426// aside copy is outside the instance tree, status gets the event as a
1427// note, and the clean sibling is unaffected.
1428func TestConvergeQuarantinesPlantedEntriesAndMigrates(t *testing.T) {
1429 p, f := box(t)
1430 secret := filepath.Join(p.DataDir, "litestream.env")
1431 if err := os.WriteFile(secret, []byte("AWS_SECRET_ACCESS_KEY=x\n"), 0o600); err != nil {
1432 t.Fatal(err)
1433 }
1434 dir := rawInstanceDir(p.DataDir, host)
1435 if err := os.MkdirAll(dir, 0o700); err != nil {
1436 t.Fatal(err)
1437 }
1438 if err := os.Link(secret, filepath.Join(dir, "creds")); err != nil {
1439 t.Skipf("hard links unavailable here: %v", err)
1440 }
1441 res := byHost(t, p.Converge(context.Background(), ModeNested))
1442 for _, h := range []string{host, replica} {
1443 if res[h].Outcome != "migrated" {
1444 t.Fatalf("%s: %+v", h, res[h])
1445 }
1446 }
1447 if f.fences[host] != 0 {
1448 t.Fatal("fence still installed after the move")
1449 }
1450 if _, err := os.Lstat(filepath.Join(dir, "creds")); !errors.Is(err, os.ErrNotExist) {
1451 t.Errorf("the planted link is still in the instance directory: %v", err)
1452 }
1453 q, err := os.ReadDir(asideDir(p.DataDir, host))
1454 if err != nil || len(q) != 1 || !strings.HasPrefix(q[0].Name(), "creds.q-") {
1455 t.Fatalf("quarantine = %v %v", q, err)
1456 }
1457 if n := p.Note(host); !strings.Contains(n, "kept aside") || !strings.Contains(n, "creds.q-") {
1458 t.Errorf("note = %q", n)
1459 }
1460 if _, held := p.Held(host); held {
1461 t.Error("a quarantined entry became a hold")
1462 }
1463}
1464
1465// Review G1: a database with a second name is refused before the journal
1466// is written, and the refusal does NOT let the host run flat again. One
1467// `ln /data/B.db /data/.x` would otherwise keep a tenant's own move
1468// refused for ever while it woke flat on every request. The host stays
1469// fenced and down, status reads the hold, the refusal names the inode and
1470// the find that lists the other name, a flat intent lifts the fence, and
1471// once the second name is gone the move goes through from the kept fence.
1472func TestConvergeHoldsAFlatHostWhoseMoveWasRefusedAfterItsStop(t *testing.T) {
1473 p, f := box(t)
1474 other := filepath.Join(p.DataDir, ".x")
1475 if err := os.Link(provision.DefaultDBPath(p.DataDir, host), other); err != nil {
1476 t.Skipf("hard links unavailable here: %v", err)
1477 }
1478 res := byHost(t, p.Converge(context.Background(), ModeNested))
1479 r := res[host]
1480 if r.Outcome != "held" || !strings.Contains(r.Reason, "2 names") || !strings.Contains(r.Reason, "-samefile") || !strings.Contains(r.Reason, "inode") {
1481 t.Fatalf("linked database: %+v", r)
1482 }
1483 assertFlatUntouched(t, p, host)
1484 if f.fences[host] == 0 {
1485 t.Fatal("the fence was lifted; the host would wake flat on its next request")
1486 }
1487 if why, held := p.Held(host); !held || !strings.Contains(why, "2 names") {
1488 t.Fatalf("Held = %q %v", why, held)
1489 }
1490 // The tenant keeps the link alive: every pass meets the same refusal,
1491 // and the host stays down each time, without a second fence or lift.
1492 res = byHost(t, p.Converge(context.Background(), ModeNested))
1493 if res[host].Outcome != "held" || f.fences[host] == 0 {
1494 t.Fatalf("second pass: %+v fence=%d", res[host], f.fences[host])
1495 }
1496 lifts := 0
1497 for _, e := range f.events {
1498 if e == "lift "+host {
1499 lifts++
1500 }
1501 }
1502 if lifts != 0 {
1503 t.Fatalf("the held fence was lifted and re-installed: %v", f.events)
1504 }
1505 // The fleet decides it wants flat after all: the hold ends and the
1506 // host runs flat, as asked.
1507 res = byHost(t, p.Converge(context.Background(), ModeFlat))
1508 if res[host].Outcome != "unchanged" || f.fences[host] != 0 {
1509 t.Fatalf("flat intent: %+v fence=%d", res[host], f.fences[host])
1510 }
1511 if _, held := p.Held(host); held {
1512 t.Fatal("still held under a flat intent")
1513 }
1514 // Back to nested, still linked: held again. Then the operator removes
1515 // the second name, and the next pass moves the host and releases it.
1516 if res := byHost(t, p.Converge(context.Background(), ModeNested)); res[host].Outcome != "held" {
1517 t.Fatalf("nested again: %+v", res[host])
1518 }
1519 os.Remove(other)
1520 res = byHost(t, p.Converge(context.Background(), ModeNested))
1521 if res[host].Outcome != "migrated" || f.fences[host] != 0 {
1522 t.Fatalf("after the link is gone: %+v fence=%d", res[host], f.fences[host])
1523 }
1524 if _, held := p.Held(host); held {
1525 t.Fatal("still held after the move went through")
1526 }
1527}
1528
1529// A nested host refused its revert is not held: it is already isolated,
1530// and running is the safe side of that refusal.
1531func TestConvergeDoesNotHoldANestedHostRefusedItsRevert(t *testing.T) {
1532 p, f := box(t)
1533 if r := byHost(t, p.Converge(context.Background(), ModeNested))[host]; r.Outcome != "migrated" {
1534 t.Fatalf("migrate: %+v", r)
1535 }
1536 // A database already at the flat path makes the revert refuse.
1537 if err := os.WriteFile(provision.DefaultDBPath(p.DataDir, host), []byte("stray"), 0o600); err != nil {
1538 t.Fatal(err)
1539 }
1540 r := byHost(t, p.Converge(context.Background(), ModeFlat))[host]
1541 if r.Outcome != "refused" || !strings.Contains(r.Reason, "two databases") {
1542 t.Fatalf("revert: %+v", r)
1543 }
1544 if f.fences[host] != 0 {
1545 t.Fatal("a refused revert left the nested host fenced")
1546 }
1547}
1548
1549// Review F2: an awake host is deferred, but not for ever. Once it has been
1550// seen awake for the grace, the pass stops it as it stops any host and
1551// moves it; a host that was deferred and then found at the intent forgets
1552// its clock.
1553func TestConvergeStopsAnAwakeHostOnceItsGraceIsSpent(t *testing.T) {
1554 p, f := box(t)
1555 p.DeferAwake = true
1556 p.AwakeGrace = time.Hour
1557 clock := time.Unix(1700000000, 0)
1558 p.Now = func() time.Time { return clock }
1559 f.awake = map[string]bool{host: true}
1560
1561 res := byHost(t, p.Converge(context.Background(), ModeNested))
1562 if r := res[host]; r.Outcome != "deferred" || !strings.Contains(r.Reason, "1h0m0s") {
1563 t.Fatalf("first pass: %+v", r)
1564 }
1565 clock = clock.Add(30 * time.Minute)
1566 res = byHost(t, p.Converge(context.Background(), ModeNested))
1567 if r := res[host]; r.Outcome != "deferred" || !strings.Contains(r.Reason, "30m0s") {
1568 t.Fatalf("half way: %+v", r)
1569 }
1570 for _, e := range f.events {
1571 if e == "fence "+host {
1572 t.Fatalf("the awake host was fenced inside its grace: %v", f.events)
1573 }
1574 }
1575 clock = clock.Add(30 * time.Minute)
1576 res = byHost(t, p.Converge(context.Background(), ModeNested))
1577 if r := res[host]; r.Outcome != "migrated" {
1578 t.Fatalf("grace spent: %+v", r)
1579 }
1580 if got := rowAt(t, p, host); got != endpoints(t, p, host, ModeNested) {
1581 t.Fatalf("row = %+v", got)
1582 }
1583 // The move went through the fence, so the running tenant was stopped
1584 // for it, and released after.
1585 fenced, lifted := false, false
1586 for _, e := range f.events {
1587 fenced = fenced || e == "fence "+host
1588 lifted = lifted || e == "lift "+host
1589 }
1590 if !fenced || !lifted {
1591 t.Fatalf("events = %v, want a fence and a lift for %s", f.events, host)
1592 }
1593 if len(p.awakeSince) != 0 {
1594 t.Errorf("awake clocks still held: %v", p.awakeSince)
1595 }
1596}
1597
1598// Landing follow-up 2: the hold survives an agent restart. While a host is
1599// held its marker sits in the layout directory; a fresh pass (a new agent)
1600// re-derives the hold from the files before anything wakes, re-holds while
1601// the planted link persists, and drops the marker once the link is gone
1602// or the intent is not nested. A marker the tenant deleted costs only one
1603// grace, since the next pass meets the same refusal.
1604func TestRestoreHoldsReholdsFromTheMarkerWhileTheReasonPersists(t *testing.T) {
1605 p, f := box(t)
1606 other := filepath.Join(p.DataDir, ".x")
1607 if err := os.Link(provision.DefaultDBPath(p.DataDir, host), other); err != nil {
1608 t.Skipf("hard links unavailable here: %v", err)
1609 }
1610 if r := byHost(t, p.Converge(context.Background(), ModeNested))[host]; r.Outcome != "held" {
1611 t.Fatalf("first pass: %+v", r)
1612 }
1613 marker := filepath.Join(p.DataDir, "layout", host+".hold")
1614 if b, err := os.ReadFile(marker); err != nil || !strings.Contains(string(b), "2 names") {
1615 t.Fatalf("hold marker: %q %v", b, err)
1616 }
1617 if hosts, _ := JournalHosts(p.DataDir); len(hosts) != 0 {
1618 t.Fatalf("a hold marker read as a journal: %v", hosts)
1619 }
1620
1621 // The agent restarts: a new pass over the same registry and files, a
1622 // new fence (every tenant is stopped at start).
1623 restart := func() (*Pass, *fakeFence) {
1624 f2 := &fakeFence{}
1625 p2 := &Pass{Registry: p.Registry, Fence: f2, DataDir: p.DataDir, SocketDir: p.SocketDir, SocketsGID: -1,
1626 Now: time.Now, SweptAtStart: true, Logf: t.Logf}
1627 return p2, f2
1628 }
1629 p2, f2 := restart()
1630 res := byHost(t, p2.RestoreHolds(context.Background(), ModeNested))
1631 if r := res[host]; r.Outcome != "held" || !strings.Contains(r.Reason, "restored at start") {
1632 t.Fatalf("restore: %+v", r)
1633 }
1634 if f2.fences[host] == 0 {
1635 t.Fatal("the restored hold installed no fence; the host would wake flat")
1636 }
1637 if why, held := p2.Held(host); !held || !strings.Contains(why, "2 names") {
1638 t.Fatalf("Held after restore = %q %v", why, held)
1639 }
1640 // Unreadable intent is the fail-closed side: held too.
1641 p3, f3 := restart()
1642 if r := byHost(t, p3.RestoreHolds(context.Background(), IntentUnreadable))[host]; r.Outcome != "held" || f3.fences[host] == 0 {
1643 t.Fatalf("unreadable intent: %+v fence=%d", r, f3.fences[host])
1644 }
1645 // A flat intent drops the marker and holds nothing.
1646 p4, f4 := restart()
1647 if r := byHost(t, p4.RestoreHolds(context.Background(), ModeFlat))[host]; r.Outcome != "released" || f4.fences[host] != 0 {
1648 t.Fatalf("flat intent: %+v fence=%d", r, f4.fences[host])
1649 }
1650 if _, err := os.Lstat(marker); !errors.Is(err, os.ErrNotExist) {
1651 t.Fatalf("marker survived a flat intent: %v", err)
1652 }
1653 // Held again, then the operator removes the link: at the next start
1654 // the marker's reason is gone, so it is dropped and nothing is held;
1655 // the first pass then moves the host.
1656 if r := byHost(t, p.Converge(context.Background(), ModeNested))[host]; r.Outcome != "held" {
1657 t.Fatalf("re-hold: %+v", r)
1658 }
1659 os.Remove(other)
1660 p5, f5 := restart()
1661 if r := byHost(t, p5.RestoreHolds(context.Background(), ModeNested))[host]; r.Outcome != "released" || f5.fences[host] != 0 {
1662 t.Fatalf("reason gone: %+v fence=%d", r, f5.fences[host])
1663 }
1664 if _, err := os.Lstat(marker); !errors.Is(err, os.ErrNotExist) {
1665 t.Fatalf("marker survived its reason: %v", err)
1666 }
1667 if r := byHost(t, p5.Converge(context.Background(), ModeNested))[host]; r.Outcome != "migrated" {
1668 t.Fatalf("after restore: %+v", r)
1669 }
1670 _ = f
1671}
1672
1673// A hold released by the move going through takes its marker with it.
1674func TestHoldMarkerIsRemovedWhenTheHostMoves(t *testing.T) {
1675 p, _ := box(t)
1676 other := filepath.Join(p.DataDir, ".x")
1677 if err := os.Link(provision.DefaultDBPath(p.DataDir, host), other); err != nil {
1678 t.Skipf("hard links unavailable here: %v", err)
1679 }
1680 if r := byHost(t, p.Converge(context.Background(), ModeNested))[host]; r.Outcome != "held" {
1681 t.Fatalf("hold: %+v", r)
1682 }
1683 marker := filepath.Join(p.DataDir, "layout", host+".hold")
1684 if _, err := os.Lstat(marker); err != nil {
1685 t.Fatal(err)
1686 }
1687 os.Remove(other)
1688 if r := byHost(t, p.Converge(context.Background(), ModeNested))[host]; r.Outcome != "migrated" {
1689 t.Fatalf("after cleanup: %+v", r)
1690 }
1691 if _, err := os.Lstat(marker); !errors.Is(err, os.ErrNotExist) {
1692 t.Fatalf("marker survived the move: %v", err)
1693 }
1694}
1695
1696// The aside directory is carlos 0700 like the rest of the layout
1697// directory, so a flat tenant can plant a symlink at it, or a file at a
1698// sibling's directory under it, and a refusal there would hold the
1699// sibling (or every host) down with one command. The blocker is renamed
1700// to a random name under /data/layout and the move goes on.
1701func TestConvergeDisplacesABlockerAtTheAsideDirectory(t *testing.T) {
1702 for _, tc := range []struct {
1703 name string
1704 plant func(t *testing.T, dd string)
1705 }{
1706 {"symlink at aside/", func(t *testing.T, dd string) {
1707 if err := os.MkdirAll(filepath.Join(dd, "layout"), 0o700); err != nil {
1708 t.Fatal(err)
1709 }
1710 if err := os.Symlink(t.TempDir(), filepath.Join(dd, "layout", "aside")); err != nil {
1711 t.Fatal(err)
1712 }
1713 }},
1714 {"file at aside/<host>", func(t *testing.T, dd string) {
1715 if err := os.MkdirAll(filepath.Join(dd, "layout", "aside"), 0o700); err != nil {
1716 t.Fatal(err)
1717 }
1718 if err := os.WriteFile(filepath.Join(dd, "layout", "aside", host), []byte("planted"), 0o600); err != nil {
1719 t.Fatal(err)
1720 }
1721 }},
1722 } {
1723 t.Run(tc.name, func(t *testing.T) {
1724 p, f := box(t)
1725 tc.plant(t, p.DataDir)
1726 // Something for the move to set aside, so the aside directory
1727 // is needed: a planted entry in the host's future directory.
1728 dir := rawInstanceDir(p.DataDir, host)
1729 if err := os.MkdirAll(dir, 0o700); err != nil {
1730 t.Fatal(err)
1731 }
1732 if err := os.WriteFile(filepath.Join(dir, "x"), []byte("x"), 0o600); err != nil {
1733 t.Fatal(err)
1734 }
1735 r := byHost(t, p.Converge(context.Background(), ModeNested))[host]
1736 if r.Outcome != "migrated" {
1737 t.Fatalf("result = %+v", r)
1738 }
1739 if f.fences[host] != 0 {
1740 t.Fatal("fence left installed")
1741 }
1742 fi, err := os.Lstat(asideDir(p.DataDir, host))
1743 if err != nil || !fi.IsDir() {
1744 t.Fatalf("aside/<host> = %v %v, want a real directory", fi, err)
1745 }
1746 q, err := os.ReadDir(asideDir(p.DataDir, host))
1747 if err != nil || len(q) != 1 || !strings.HasPrefix(q[0].Name(), "x.q-") {
1748 t.Fatalf("quarantine = %v %v", q, err)
1749 }
1750 entries, err := os.ReadDir(filepath.Join(p.DataDir, "layout"))
1751 if err != nil {
1752 t.Fatal(err)
1753 }
1754 blockers := 0
1755 for _, e := range entries {
1756 if strings.Contains(e.Name(), ".blocker-") {
1757 blockers++
1758 }
1759 }
1760 if blockers != 1 {
1761 t.Fatalf("layout dir = %v, want the one displaced blocker", entries)
1762 }
1763 if _, held := p.Held(host); held {
1764 t.Fatal("the blocker became a hold")
1765 }
1766 })
1767 }
1768}
1769