| new file mode 100644 |
| index 000000000..2f9b6dff7 |
| --- /dev/null |
| +++ b/internal/layout/pass_linux_test.go |
| @@ -0,0 +1,757 @@ |
| +//go:build linux |
| + |
| +package layout |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +const ( |
| + host = "h.example.com" |
| + replica = "r.example.com" |
| +) |
| + |
| +// fakeFence is the activator's fence as the pass sees it. By default every |
| +// stop is proven; unproven, awake and uncertain make FenceAndStop answer |
| +// ErrStopUnproven and shape what Quiesced and StopUncertain say after it. |
| +// events records every fence and lift, and box's Reload adds "reload", so a |
| +// test can read the order the pass did things in. |
| +type fakeFence struct { |
| + fences map[string]uint64 |
| + unproven map[string]bool |
| + awake map[string]bool |
| + uncertain map[string]bool |
| + onFence func(host string) |
| + events []string |
| +} |
| + |
| +func (f *fakeFence) FenceAndStop(_ context.Context, h string, gen uint64) (*activator.StopEvidence, error) { |
| + if f.fences == nil { |
| + f.fences = map[string]uint64{} |
| + } |
| + f.fences[h] = gen |
| + f.events = append(f.events, "fence "+h) |
| + if f.onFence != nil { |
| + f.onFence(h) |
| + } |
| + if f.unproven[h] { |
| + return nil, activator.ErrStopUnproven |
| + } |
| + return &activator.StopEvidence{}, nil |
| +} |
| + |
| +func (f *fakeFence) LiftFence(h string, gen uint64) bool { |
| + if f.fences[h] != gen { |
| + return false |
| + } |
| + delete(f.fences, h) |
| + f.events = append(f.events, "lift "+h) |
| + return true |
| +} |
| + |
| +func (f *fakeFence) Fenced(h string) bool { return f.fences[h] != 0 } |
| +func (f *fakeFence) Quiesced(h string) bool { return !f.awake[h] } |
| +func (f *fakeFence) StopUncertain(h string) bool { return f.uncertain[h] } |
| + |
| +func openLayoutRegistry(t *testing.T, dir string) *registry.Registry { |
| + t.Helper() |
| + reg, err := registry.Open(filepath.Join(dir, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + return reg |
| +} |
| + |
| +// seedFlat writes host's flat files: the database and its wal, the admin |
| +// env, a litestream shadow directory with a file in it, and (for the |
| +// primary host only) a quarantine. The sidecar env and the rendered bundle |
| +// are absent, so the journal records them absent. |
| +func seedFlat(t *testing.T, dd, h string) { |
| + t.Helper() |
| + db := provision.DefaultDBPath(dd, h) |
| + files := map[string]string{ |
| + db: "db " + h, |
| + db + "-wal": "wal", |
| + filepath.Join(dd, h+".env"): "CARLOS_ADMIN_TOKEN=t\n", |
| + filepath.Join(dd, shadowName(h), "generation"): "g", |
| + } |
| + if h == host { |
| + files[db+".corrupt"] = "quarantined" |
| + } |
| + for path, body := range files { |
| + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +// box is a temp data dir, a temp socket dir and a registry with two flat |
| +// managed rows whose files exist: an ordinary instance and a replica |
| +// (#162), which is its own host and migrates like any other. |
| +func box(t *testing.T) (*Pass, *fakeFence) { |
| + t.Helper() |
| + dd := t.TempDir() |
| + // The socket dir is short on purpose: t.TempDir embeds the test's name, |
| + // and the nested control socket must fit a unix socket address, which |
| + // Preflight enforces. |
| + rd, err := os.MkdirTemp("", "ly") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll(rd) }) |
| + reg := openLayoutRegistry(t, dd) |
| + for _, rt := range []registry.Route{ |
| + {Host: host, Kind: "instance", Hibernate: true, Status: "ready", |
| + Socket: filepath.Join(rd, host+".sock"), DBPath: provision.DefaultDBPath(dd, host)}, |
| + {Host: replica, Kind: "instance", Hibernate: true, Replica: true, |
| + Socket: filepath.Join(rd, replica+".sock"), DBPath: provision.DefaultDBPath(dd, replica)}, |
| + } { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + seedFlat(t, dd, rt.Host) |
| + } |
| + f := &fakeFence{} |
| + p := &Pass{Registry: reg, Fence: f, DataDir: dd, SocketDir: rd, SocketsGID: -1, Now: time.Now, |
| + SweptAtStart: true, |
| + Reload: func() { f.events = append(f.events, "reload") }, |
| + Logf: t.Logf} |
| + return p, f |
| +} |
| + |
| +func byHost(t *testing.T, rs []Result) map[string]Result { |
| + t.Helper() |
| + out := map[string]Result{} |
| + for _, r := range rs { |
| + if _, dup := out[r.Host]; dup { |
| + t.Errorf("host %q reported twice: %+v", r.Host, rs) |
| + } |
| + out[r.Host] = r |
| + } |
| + return out |
| +} |
| + |
| +func endpoints(t *testing.T, p *Pass, h string, mode Mode) Endpoint { |
| + t.Helper() |
| + plan, err := PlanFor(p.DataDir, p.SocketDir, h, mode) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return plan.To |
| +} |
| + |
| +func rowAt(t *testing.T, p *Pass, h string) Endpoint { |
| + t.Helper() |
| + rt, err := p.Registry.Get(h) |
| + if err != nil || rt == nil { |
| + t.Fatalf("row %s: %v %v", h, rt, err) |
| + } |
| + return Endpoint{DB: rt.DBPath, Socket: rt.Socket} |
| +} |
| + |
| +func assertFlatUntouched(t *testing.T, p *Pass, h string) { |
| + t.Helper() |
| + if got := rowAt(t, p, h); got != endpoints(t, p, h, ModeFlat) { |
| + t.Errorf("%s row moved: %+v", h, got) |
| + } |
| + if b, err := os.ReadFile(provision.DefaultDBPath(p.DataDir, h)); err != nil || string(b) != "db "+h { |
| + t.Errorf("%s flat db: %q %v", h, b, err) |
| + } |
| + if JournalPresent(p.DataDir, h) { |
| + t.Errorf("%s has a journal", h) |
| + } |
| +} |
| + |
| +func TestConvergeNestedMovesFilesFlipsTheRowAndRemovesTheJournal(t *testing.T) { |
| + p, f := box(t) |
| + // Step 9's order: routing reloads on the committed row with the journal |
| + // gone, and only then is the host released. |
| + var reloads []string |
| + p.Reload = func() { |
| + for _, h := range []string{host, replica} { |
| + if f.fences[h] != 0 { |
| + if JournalPresent(p.DataDir, h) { |
| + t.Errorf("reload for %s ran with its journal still present", h) |
| + } |
| + reloads = append(reloads, h+" "+rowAt(t, p, h).DB) |
| + } |
| + } |
| + } |
| + res := byHost(t, p.Converge(context.Background(), ModeNested)) |
| + for _, h := range []string{host, replica} { |
| + if res[h].Outcome != "migrated" { |
| + t.Fatalf("%s: %+v", h, res[h]) |
| + } |
| + want := endpoints(t, p, h, ModeNested) |
| + if got := rowAt(t, p, h); got != want { |
| + t.Fatalf("%s row = %+v, want %+v", h, got, want) |
| + } |
| + raw := rawInstanceDir(p.DataDir, h) |
| + for _, name := range []string{h + ".db", h + ".db-wal", h + ".env", filepath.Join(shadowName(h), "generation")} { |
| + if _, err := os.Stat(filepath.Join(raw, name)); err != nil { |
| + t.Errorf("%s not at the raw destination: %v", name, err) |
| + } |
| + } |
| + if _, err := os.Lstat(provision.DefaultDBPath(p.DataDir, h)); !errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("%s flat db still present", h) |
| + } |
| + if JournalPresent(p.DataDir, h) { |
| + t.Errorf("%s journal left behind after a clean move", h) |
| + } |
| + if fi, err := os.Lstat(rawRunDir(p.SocketDir, h)); err != nil || !fi.IsDir() { |
| + t.Errorf("%s run directory: %v %v", h, fi, err) |
| + } |
| + } |
| + if _, err := os.Stat(filepath.Join(rawInstanceDir(p.DataDir, host), host+".db.corrupt")); err != nil { |
| + t.Errorf("quarantine not moved: %v", err) |
| + } |
| + if len(f.fences) != 0 { |
| + t.Errorf("fences left installed after a clean move: %v", f.fences) |
| + } |
| + wantReloads := []string{host + " " + endpoints(t, p, host, ModeNested).DB, replica + " " + endpoints(t, p, replica, ModeNested).DB} |
| + if !reflect.DeepEqual(reloads, wantReloads) { |
| + t.Errorf("reloads under the fence = %v, want %v", reloads, wantReloads) |
| + } |
| + // Idempotent: a second converge does nothing. |
| + for h, r := range byHost(t, p.Converge(context.Background(), ModeNested)) { |
| + if r.Outcome != "unchanged" { |
| + t.Fatalf("second converge, %s: %+v", h, r) |
| + } |
| + } |
| + // Revert restores the flat layout byte for byte. |
| + p.Reload = nil |
| + res = byHost(t, p.Converge(context.Background(), ModeFlat)) |
| + for _, h := range []string{host, replica} { |
| + if res[h].Outcome != "reverted" { |
| + t.Fatalf("revert %s = %+v", h, res[h]) |
| + } |
| + assertFlatUntouched(t, p, h) |
| + if _, err := os.Stat(filepath.Join(p.DataDir, shadowName(h), "generation")); err != nil { |
| + t.Errorf("%s shadow not back: %v", h, err) |
| + } |
| + } |
| + if len(f.fences) != 0 { |
| + t.Errorf("fences left installed after a revert: %v", f.fences) |
| + } |
| +} |
| + |
| +// An unproven stop is accepted only for a host with no live entry, no |
| +// uncertain stop on record, and an agent whose startup unit sweep |
| +// succeeded. Anything else defers the host, writes no journal, and lifts |
| +// the fence this pass installed. |
| +func TestConvergeTrustsAnUnprovenStopOnlyWhenNothingCanBeRunning(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + swept bool |
| + awake bool |
| + uncertain bool |
| + want string |
| + }{ |
| + {"swept, quiesced, certain", true, false, false, "migrated"}, |
| + {"no startup sweep", false, false, false, "deferred"}, |
| + {"an entry is live", true, true, false, "deferred"}, |
| + {"the last stop is uncertain", true, false, true, "deferred"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + p, f := box(t) |
| + p.SweptAtStart = tc.swept |
| + f.unproven = map[string]bool{host: true} |
| + f.awake = map[string]bool{host: tc.awake} |
| + f.uncertain = map[string]bool{host: tc.uncertain} |
| + r := byHost(t, p.Converge(context.Background(), ModeNested))[host] |
| + if r.Outcome != tc.want { |
| + t.Fatalf("result = %+v, want %s", r, tc.want) |
| + } |
| + if tc.want == "deferred" { |
| + if !strings.Contains(r.Reason, "stop unproven") { |
| + t.Errorf("reason = %q", r.Reason) |
| + } |
| + assertFlatUntouched(t, p, host) |
| + } |
| + if len(f.fences) != 0 { |
| + t.Errorf("a layout fence was left installed: %v", f.fences) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// wakeBackend is an activator.Backend with no processes, no S3 and no |
| +// replication: just enough for a real Service to wake a host. |
| +type wakeBackend struct { |
| + mu sync.Mutex |
| + started []registry.Route |
| +} |
| + |
| +type nopLease struct{} |
| + |
| +func (nopLease) Replicate(context.Context, string, time.Duration) (activator.Rep, error) { |
| + return nopRep{}, nil |
| +} |
| +func (nopLease) Release(context.Context) error { return nil } |
| + |
| +type nopRep struct{} |
| + |
| +func (nopRep) Err() error { return nil } |
| +func (nopRep) Close(context.Context) error { return nil } |
| + |
| +type nopProc struct{} |
| + |
| +func (nopProc) Stop(context.Context) error { return nil } |
| +func (nopProc) Exited() bool { return false } |
| + |
| +func (b *wakeBackend) Acquire(context.Context, registry.Route) (activator.Lease, error) { |
| + return nopLease{}, nil |
| +} |
| +func (b *wakeBackend) Restore(context.Context, registry.Route) (bool, error) { return false, nil } |
| +func (b *wakeBackend) Seed(context.Context, registry.Route) error { return nil } |
| +func (b *wakeBackend) Start(_ context.Context, rt registry.Route) (activator.Proc, string, error) { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + b.started = append(b.started, rt) |
| + return nopProc{}, "", nil |
| +} |
| +func (b *wakeBackend) NextDue(context.Context, registry.Route) (int64, error) { return 0, nil } |
| +func (b *wakeBackend) HasSidecar(registry.Route) bool { return false } |
| +func (b *wakeBackend) StartSidecar(context.Context, registry.Route) (activator.Proc, error) { |
| + return nil, errors.New("wakeBackend: no sidecar in this fixture") |
| +} |
| +func (b *wakeBackend) StartWorker(context.Context, registry.Route, map[string]string) (activator.Proc, string, error) { |
| + return nil, "", errors.New("wakeBackend: no worker in this fixture") |
| +} |
| + |
| +func realService(t *testing.T, p *Pass) (*activator.Service, *wakeBackend) { |
| + t.Helper() |
| + b := &wakeBackend{} |
| + svc := activator.New(activator.Config{Bucket: "test-bucket", Idle: time.Hour}, b, p.Registry) |
| + // The wake re-reads the committed row, so the test sees which paths it |
| + // actually woke on. |
| + svc.Routes = p.Registry |
| + t.Cleanup(func() { |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + svc.Shutdown(ctx) |
| + }) |
| + p.Fence = svc |
| + return svc, b |
| +} |
| + |
| +// After an agent restart no asleep host has a stop on record, so the real |
| +// activator answers ErrStopUnproven for every one of them. The pass must |
| +// still migrate such a host, and must leave it wakeable. |
| +func TestConvergeAgainstARealActivatorMigratesAnEntrylessHostAndItWakes(t *testing.T) { |
| + p, _ := box(t) |
| + svc, b := realService(t, p) |
| + res := byHost(t, p.Converge(context.Background(), ModeNested)) |
| + if res[host].Outcome != "migrated" { |
| + t.Fatalf("result = %+v", res[host]) |
| + } |
| + if svc.Fenced(host) { |
| + t.Fatal("the host is still fenced after the pass") |
| + } |
| + rt, err := p.Registry.Get(host) |
| + if err != nil || rt == nil { |
| + t.Fatal(err) |
| + } |
| + rec := httptest.NewRecorder() |
| + req := httptest.NewRequest(http.MethodGet, "http://"+host+"/", nil) |
| + ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) |
| + if !svc.ServeHTTP(rec, req, *rt, ok) || rec.Code != http.StatusOK { |
| + t.Fatalf("wake after the pass: code %d", rec.Code) |
| + } |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + if len(b.started) != 1 || b.started[0].DBPath != endpoints(t, p, host, ModeNested).DB { |
| + t.Fatalf("started = %+v", b.started) |
| + } |
| +} |
| + |
| +func TestConvergeAgainstARealActivatorDefersWithoutAStartupSweep(t *testing.T) { |
| + p, _ := box(t) |
| + svc, _ := realService(t, p) |
| + p.SweptAtStart = false |
| + for h, r := range byHost(t, p.Converge(context.Background(), ModeNested)) { |
| + if r.Outcome != "deferred" || !strings.Contains(r.Reason, "stop unproven") { |
| + t.Errorf("%s: %+v", h, r) |
| + } |
| + if svc.Fenced(h) { |
| + t.Errorf("%s left fenced after a refused quiesce", h) |
| + } |
| + assertFlatUntouched(t, p, h) |
| + } |
| +} |
| + |
| +// Another operation's fence (the residence move's cut) shares the |
| +// activator's one fence slot per host. The pass must neither take it over |
| +// nor lift it. |
| +func TestConvergeDefersAHostSomeoneElseFenced(t *testing.T) { |
| + p, _ := box(t) |
| + svc, _ := realService(t, p) |
| + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| + defer cancel() |
| + if _, err := svc.FenceAndStop(ctx, host, 5); err != nil && !errors.Is(err, activator.ErrStopUnproven) { |
| + t.Fatalf("pre-installing the other fence: %v", err) |
| + } |
| + p.Generation = func() uint64 { return 1000 } |
| + res := byHost(t, p.Converge(context.Background(), ModeNested)) |
| + if res[host].Outcome != "deferred" { |
| + t.Fatalf("fenced host: %+v", res[host]) |
| + } |
| + if res[replica].Outcome != "migrated" { |
| + t.Fatalf("the other host: %+v", res[replica]) |
| + } |
| + assertFlatUntouched(t, p, host) |
| + if !svc.LiftFence(host, 5) { |
| + t.Fatal("the original fence is not in place at its own generation") |
| + } |
| +} |
| + |
| +func TestConvergeDefersARowThatIsNotReady(t *testing.T) { |
| + p, f := box(t) |
| + if err := p.Registry.SetStatus(host, "moving"); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := byHost(t, p.Converge(context.Background(), ModeNested))[host] |
| + if r.Outcome != "deferred" || !strings.Contains(r.Reason, "moving") { |
| + t.Fatalf("result = %+v", r) |
| + } |
| + for _, e := range f.events { |
| + if e == "fence "+host { |
| + t.Fatal("a moving row was fenced") |
| + } |
| + } |
| + assertFlatUntouched(t, p, host) |
| +} |
| + |
| +// The row the pass classified can change before its fence lands: a move |
| +// cut starting, an operator unflagging it. The pass re-reads it under the |
| +// fence and refuses, writing nothing. |
| +func TestConvergeRefusesARowThatChangedUnderTheFence(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + change func(reg *registry.Registry) error |
| + }{ |
| + {"status", func(reg *registry.Registry) error { return reg.SetStatus(host, "moving") }}, |
| + {"unflagged", func(reg *registry.Registry) error { return reg.SetHibernate(host, false) }}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + p, f := box(t) |
| + f.onFence = func(h string) { |
| + if h == host { |
| + if err := tc.change(p.Registry); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + r := byHost(t, p.Converge(context.Background(), ModeNested))[host] |
| + if r.Outcome != "refused" { |
| + t.Fatalf("result = %+v", r) |
| + } |
| + assertFlatUntouched(t, p, host) |
| + if len(f.fences) != 0 { |
| + t.Errorf("fence left installed: %v", f.fences) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A box whose isolation probe is not ready does not start nested tenants, |
| +// so it does not make any. |
| +func TestConvergeNestedIsGatedOnIsolation(t *testing.T) { |
| + p, f := box(t) |
| + calls := 0 |
| + p.NestedOK = func() error { calls++; return errors.New("probe: not ready") } |
| + res := p.Converge(context.Background(), ModeNested) |
| + if len(res) != 1 || res[0].Host != "" || res[0].Outcome != "refused" || res[0].Reason != "isolation not ready: probe: not ready" { |
| + t.Fatalf("results = %+v", res) |
| + } |
| + if calls != 1 { |
| + t.Errorf("NestedOK called %d times", calls) |
| + } |
| + if len(f.events) != 0 { |
| + t.Errorf("the pass touched hosts: %v", f.events) |
| + } |
| + for _, h := range []string{host, replica} { |
| + assertFlatUntouched(t, p, h) |
| + } |
| + // Reverting and recovering are never gated: they are how a box gets |
| + // back to a layout it can serve. |
| + p.NestedOK = nil |
| + p.Converge(context.Background(), ModeNested) |
| + p.NestedOK = func() error { return errors.New("probe: not ready") } |
| + for h, r := range byHost(t, p.Converge(context.Background(), ModeFlat)) { |
| + if r.Outcome != "reverted" { |
| + t.Errorf("revert %s under a failing probe: %+v", h, r) |
| + } |
| + } |
| +} |
| + |
| +func TestConvergeSkipsForeignAndUnmanagedRows(t *testing.T) { |
| + p, _ := box(t) |
| + if err := p.Registry.Upsert(registry.Route{Host: "custom.example.com", Kind: "instance", Hibernate: true, |
| + Socket: filepath.Join(p.SocketDir, "custom.example.com.sock"), DBPath: "/data/sub/store.db"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.Registry.Upsert(registry.Route{Host: "ext.example.com", Kind: "instance", Hibernate: false, |
| + Socket: filepath.Join(p.SocketDir, "ext.example.com.sock"), DBPath: provision.DefaultDBPath(p.DataDir, "ext.example.com")}); err != nil { |
| + t.Fatal(err) |
| + } |
| + out := byHost(t, p.Converge(context.Background(), ModeNested)) |
| + if out["custom.example.com"].Outcome != "skipped" || !strings.Contains(out["custom.example.com"].Reason, "foreign") { |
| + t.Errorf("foreign row: %+v", out["custom.example.com"]) |
| + } |
| + if _, listed := out["ext.example.com"]; listed { |
| + t.Errorf("an unmanaged row was considered: %+v", out["ext.example.com"]) |
| + } |
| +} |
| + |
| +// A same-uid tenant can plant <data>/.instances/<iid> as a symlink before |
| +// the pass arrives. The pass refuses and moves nothing through it. |
| +func TestConvergeRefusesASymlinkedInstanceDirectory(t *testing.T) { |
| + p, f := box(t) |
| + elsewhere := t.TempDir() |
| + raw := rawInstanceDir(p.DataDir, host) |
| + if err := os.MkdirAll(filepath.Dir(raw), 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(elsewhere, raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := byHost(t, p.Converge(context.Background(), ModeNested))[host] |
| + if r.Outcome != "refused" || !strings.Contains(r.Reason, "symlink") { |
| + t.Fatalf("result = %+v", r) |
| + } |
| + assertFlatUntouched(t, p, host) |
| + if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 { |
| + t.Errorf("files landed through the symlink: %v", entries) |
| + } |
| + if len(f.fences) != 0 { |
| + t.Errorf("fence left installed: %v", f.fences) |
| + } |
| +} |
| + |
| +func TestSerializeWrapsJournalWriteToJournalRemoval(t *testing.T) { |
| + p, _ := box(t) |
| + var wrapped []string |
| + p.Serialize = func(fn func()) { |
| + for _, h := range []string{host, replica} { |
| + if JournalPresent(p.DataDir, h) { |
| + t.Errorf("%s journal present before the serialized section", h) |
| + } |
| + } |
| + fn() |
| + for _, h := range []string{host, replica} { |
| + if JournalPresent(p.DataDir, h) { |
| + t.Errorf("%s journal present after the serialized section", h) |
| + } |
| + } |
| + wrapped = append(wrapped, "section") |
| + } |
| + p.Converge(context.Background(), ModeNested) |
| + if len(wrapped) != 2 { |
| + t.Fatalf("serialized sections = %d, want one per host", len(wrapped)) |
| + } |
| +} |
| + |
| +func TestRecoverCompletesEveryCrashBoundary(t *testing.T) { |
| + // Drive the pass through a move with a seam that aborts after each |
| + // step, then Recover, and assert the end state is the nested layout |
| + // with no journal, every time. |
| + for _, step := range []string{"after-journal", "mid-files", "after-files", "after-cas", "before-remove"} { |
| + t.Run(step, func(t *testing.T) { |
| + p, f := box(t) |
| + p.abortAt = step |
| + p.Converge(context.Background(), ModeNested) |
| + p.abortAt = "" |
| + for _, h := range []string{host, replica} { |
| + if !JournalPresent(p.DataDir, h) { |
| + t.Fatalf("%s: no journal after aborting at %s", h, step) |
| + } |
| + } |
| + if len(f.fences) != 0 { |
| + t.Errorf("an aborted move left its fence: %v", f.fences) |
| + } |
| + res := byHost(t, p.Recover(context.Background(), "")) |
| + for _, h := range []string{host, replica} { |
| + if res[h].Outcome != "recovered" { |
| + t.Fatalf("recover %s = %+v", h, res[h]) |
| + } |
| + if got := rowAt(t, p, h); got != endpoints(t, p, h, ModeNested) { |
| + t.Errorf("%s row not nested after recovery from %s: %+v", h, step, got) |
| + } |
| + if b, err := os.ReadFile(filepath.Join(rawInstanceDir(p.DataDir, h), h+".db")); err != nil || string(b) != "db "+h { |
| + t.Errorf("%s db not at destination after recovery from %s: %q %v", h, step, b, err) |
| + } |
| + if JournalPresent(p.DataDir, h) { |
| + t.Errorf("%s journal remains after recovery", h) |
| + } |
| + } |
| + if len(f.fences) != 0 { |
| + t.Errorf("recovery left a fence: %v", f.fences) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRecoverRefusesAForgedJournalAndKeepsTheHostFenced(t *testing.T) { |
| + p, _ := box(t) |
| + j := &Journal{Host: host, Mode: ModeNested, |
| + From: Endpoint{DB: "/data/console/console.db", Socket: filepath.Join(p.SocketDir, host+".sock")}, |
| + To: endpoints(t, p, host, ModeNested), |
| + Phase: PhaseFiles} |
| + for _, n := range artifactNames { |
| + j.Artifacts = append(j.Artifacts, JournalArtifact{Name: n, Present: true}) |
| + } |
| + if err := WriteJournal(p.DataDir, j); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := byHost(t, p.Recover(context.Background(), ModeNested))[host] |
| + if r.Outcome != "refused" || !strings.Contains(r.Reason, "not acting") { |
| + t.Fatalf("forged journal: %+v", r) |
| + } |
| + if !JournalPresent(p.DataDir, host) { |
| + t.Error("forged journal removed; the host must stay fenced for an operator") |
| + } |
| + if _, err := os.Stat(filepath.Join(p.DataDir, "console")); err == nil { |
| + t.Error("something touched the forged source") |
| + } |
| + // Converge reports the host once, as the recovery refused it. |
| + res := byHost(t, p.Converge(context.Background(), ModeNested)) |
| + if res[host].Outcome != "refused" { |
| + t.Errorf("converge over a refused journal: %+v", res[host]) |
| + } |
| +} |
| + |
| +// A file at both ends of a move cannot come from a crash: the destination |
| +// check cleared every destination before the journal was written, and a |
| +// rename leaves a file at exactly one of them. Recovery refuses to choose. |
| +func TestRecoverRefusesAnArtifactAtBothEnds(t *testing.T) { |
| + p, _ := box(t) |
| + p.abortAt = "after-journal" |
| + p.Converge(context.Background(), ModeNested) |
| + p.abortAt = "" |
| + dst := filepath.Join(rawInstanceDir(p.DataDir, host), host+".db") |
| + if err := os.WriteFile(dst, []byte("planted"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := byHost(t, p.Recover(context.Background(), ""))[host] |
| + if r.Outcome != "refused" || !strings.Contains(r.Reason, "both") { |
| + t.Fatalf("result = %+v", r) |
| + } |
| + if !JournalPresent(p.DataDir, host) { |
| + t.Error("journal removed over an ambiguous artifact") |
| + } |
| + if b, _ := os.ReadFile(provision.DefaultDBPath(p.DataDir, host)); string(b) != "db "+host { |
| + t.Error("the source database was touched") |
| + } |
| +} |
| + |
| +// Design §3.3: a journal is completed to its own endpoint, and the host is |
| +// then converged to the fleet's intent before it is released. The fence |
| +// is held from the recovery through the convergence: nothing lifts it in |
| +// between, so the host never wakes at the journal's endpoint. |
| +func TestConvergeRecoversAJournalBeforeConvergingToANewIntent(t *testing.T) { |
| + p, f := box(t) |
| + p.abortAt = "after-files" |
| + p.Converge(context.Background(), ModeNested) |
| + p.abortAt = "" |
| + f.events = nil |
| + // The intent changed to flat while the move was in flight: the journal |
| + // completes to ITS endpoint (nested), then the host converges to flat. |
| + res := byHost(t, p.Converge(context.Background(), ModeFlat)) |
| + for _, h := range []string{host, replica} { |
| + if res[h].Outcome != "recovered" { |
| + t.Errorf("%s: %+v", h, res[h]) |
| + } |
| + assertFlatUntouched(t, p, h) |
| + } |
| + want := []string{ |
| + "fence " + host, "reload", "reload", "lift " + host, |
| + "fence " + replica, "reload", "reload", "lift " + replica, |
| + } |
| + if !reflect.DeepEqual(f.events, want) { |
| + t.Fatalf("events = %v\nwant %v", f.events, want) |
| + } |
| +} |
| + |
| +// With no intent recorded, a journal is completed and the host released |
| +// where the journal ends. |
| +func TestRecoverWithNoIntentReleasesAtTheJournalsEndpoint(t *testing.T) { |
| + p, f := box(t) |
| + p.abortAt = "after-files" |
| + p.Converge(context.Background(), ModeNested) |
| + p.abortAt = "" |
| + for h, r := range byHost(t, p.Recover(context.Background(), "")) { |
| + if r.Outcome != "recovered" || rowAt(t, p, h) != endpoints(t, p, h, ModeNested) { |
| + t.Errorf("%s: %+v at %+v", h, r, rowAt(t, p, h)) |
| + } |
| + } |
| + if len(f.fences) != 0 { |
| + t.Errorf("fences: %v", f.fences) |
| + } |
| +} |
| + |
| +// When the agent starts and cannot read the fleet's intent, a revert in |
| +// flight is completed but kept fenced at phase done: releasing it flat |
| +// could let a forged revert run a nested host unisolated until an intent |
| +// is read. A move to nested is completed and released as usual. |
| +func TestRecoverHoldsACompletedRevertWhileTheIntentIsUnreadable(t *testing.T) { |
| + p, f := box(t) |
| + if res := byHost(t, p.Converge(context.Background(), ModeNested)); res[host].Outcome != "migrated" { |
| + t.Fatalf("setup: %+v", res) |
| + } |
| + p.abortAt = "after-cas" |
| + p.Converge(context.Background(), ModeFlat) |
| + p.abortAt = "" |
| + res := byHost(t, p.Recover(context.Background(), IntentUnreadable)) |
| + for _, h := range []string{host, replica} { |
| + if res[h].Outcome != "deferred" || !strings.Contains(res[h].Reason, "intent") { |
| + t.Errorf("%s: %+v", h, res[h]) |
| + } |
| + j, found, err := ReadJournal(p.DataDir, h) |
| + if err != nil || !found || j.Phase != PhaseDone { |
| + t.Fatalf("%s journal: %+v %v %v", h, j, found, err) |
| + } |
| + if rowAt(t, p, h) != endpoints(t, p, h, ModeFlat) { |
| + t.Errorf("%s row not completed to flat", h) |
| + } |
| + } |
| + if len(f.fences) != 0 { |
| + t.Errorf("the journal is the fence; the activator's must not stay: %v", f.fences) |
| + } |
| + // Once the intent is read, the held host converges to it. |
| + res = byHost(t, p.Recover(context.Background(), ModeNested)) |
| + for _, h := range []string{host, replica} { |
| + if res[h].Outcome != "recovered" || rowAt(t, p, h) != endpoints(t, p, h, ModeNested) || JournalPresent(p.DataDir, h) { |
| + t.Errorf("%s after the intent: %+v at %+v", h, res[h], rowAt(t, p, h)) |
| + } |
| + } |
| + |
| + // A move toward nested is released even while the intent is unreadable. |
| + q, _ := box(t) |
| + q.abortAt = "after-files" |
| + q.Converge(context.Background(), ModeNested) |
| + q.abortAt = "" |
| + for h, r := range byHost(t, q.Recover(context.Background(), IntentUnreadable)) { |
| + if r.Outcome != "recovered" || JournalPresent(q.DataDir, h) { |
| + t.Errorf("%s nested journal under an unreadable intent: %+v", h, r) |
| + } |
| + } |
| +} |