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 host
4
5import (
6 "bytes"
7 "context"
8 "database/sql"
9 "encoding/binary"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "github.com/carlosframework/platform/internal/activator"
14 "github.com/carlosframework/platform/internal/cgroup"
15 "github.com/carlosframework/platform/internal/genselect"
16 "github.com/carlosframework/platform/internal/hostbox"
17 "github.com/carlosframework/platform/internal/hostclaim"
18 "github.com/carlosframework/platform/internal/hostguard"
19 "github.com/carlosframework/platform/internal/lease/leasetest"
20 "github.com/carlosframework/platform/internal/moves"
21 "github.com/carlosframework/platform/internal/park"
22 "github.com/carlosframework/platform/internal/provision"
23 "github.com/carlosframework/platform/internal/registry"
24 "github.com/carlosframework/platform/internal/release"
25 "github.com/superfly/ltx"
26 "golang.org/x/sys/unix"
27 "os"
28 "os/exec"
29 "path/filepath"
30 "strconv"
31 "testing"
32)
33
34func TestPlan12RecoveryRestoreSourceDatabase(t *testing.T) {
35 t.Parallel()
36 for _, name := range []string{hostclaim.KindRestoring, hostclaim.KindReverting, "clean-return", "never-restoring", "never-reverting", "never-clean-return", "missing-inventory", "wrong-inventory", "missing-registry", "interrupted", "admission-crash", "prejournal-failure", "intent-crash", "evidence-read-failure", "concurrent-update", "prelaunch-update"} {
37 t.Run(name, func(t *testing.T) {
38 kind, clean := name, name == "clean-return" || name == "never-clean-return"
39 bad := name == "missing-inventory" || name == "wrong-inventory" || name == "missing-registry"
40 beforeAdmission := name == "prejournal-failure" || name == "intent-crash" || name == "evidence-read-failure"
41 retryCase := name == "interrupted" || name == "admission-crash" || beforeAdmission || name == "prelaunch-update"
42 if bad || retryCase || name == "concurrent-update" {
43 kind = hostclaim.KindRestoring
44 }
45 if name == "never-restoring" {
46 kind = hostclaim.KindRestoring
47 }
48 if name == "never-reverting" {
49 kind = hostclaim.KindReverting
50 }
51 never := name == "never-restoring" || name == "never-reverting" || name == "never-clean-return"
52 if clean {
53 kind = hostclaim.KindRestoring
54 }
55 f, o, h := plan12Handover(t, hostclaim.KindRestoring, "stop")
56 stamp := o.Claim.Holder.Stamp
57 baseline := plan12RestoreBaseline(t, f)
58 db := provision.NestedDBPath(f.guard.DataDir, o.Host)
59 dbRel, _ := filepath.Rel(f.guard.DataDir, db)
60 dirRel, _ := filepath.Rel(f.guard.DataDir, filepath.Dir(db))
61 if err := os.MkdirAll(filepath.Dir(db), 0700); err != nil {
62 t.Fatal(err)
63 }
64 if err := os.Mkdir(filepath.Join(f.guard.DataDir, ".parked"), 0770); err != nil {
65 t.Fatal(err)
66 }
67 if err := os.WriteFile(db, []byte("source database"), 0600); err != nil {
68 t.Fatal(err)
69 }
70 if err := hostbox.WriteGenerationMarker(db+".gen", "src"); err != nil {
71 t.Fatal(err)
72 }
73 key := moves.StoppedKey(moves.ReportPrefix("bab", "jam", o.Host, "m1"), "source")
74 report := plan12Stopped(o, "source", "box-1")
75 raw, _ := json.Marshal(report)
76 if _, err := f.claims.Put(t.Context(), key, raw, ""); err != nil {
77 t.Fatal(err)
78 }
79 var err error
80 o, err = hostclaim.Update(t.Context(), f.claims, o, func(c *hostclaim.Claim) error {
81 c.Op.Kind = kind
82 c.Op.Journal[hostclaim.JournalSourceStamp] = strconv.FormatUint(stamp, 10)
83 c.Op.Journal[hostclaim.JournalSrcGen] = "src"
84 c.Op.Journal[hostclaim.JournalCutSession] = "2"
85 c.Op.Journal[hostclaim.JournalEvidenceSource] = key
86 if kind == hostclaim.KindRestoring {
87 c.Holder.Admissions = []hostclaim.AdmissionRecord{{Session: 2, Box: "box-2", Generation: "target-gen", Mode: "cold", Region: "uk", Predecessor: "src", PredecessorRegion: "ie"}}
88 if clean {
89 c.Holder.Generation = "src"
90 }
91 }
92 if kind == hostclaim.KindReverting {
93 c.Holder.WriterBox = "box-1"
94 c.Holder.Session = 2
95 c.Holder.Generation = "src"
96 c.Holder.Admissions = []hostclaim.AdmissionRecord{{Session: 2, Box: "box-1", Generation: "src", Mode: "cold", Region: "ie"}}
97 }
98 if never {
99 c.Holder.WriterBox = ""
100 c.Holder.Session = 0
101 c.Holder.Generation = ""
102 c.Holder.Admissions = nil
103 c.Op.Journal[hostclaim.JournalCutSession] = "0"
104 c.Op.Journal[hostclaim.JournalSrcGen] = ""
105 }
106 return nil
107 })
108 if err != nil {
109 t.Fatal(err)
110 }
111 if never {
112 if err = os.Remove(db); err != nil {
113 t.Fatal(err)
114 }
115 if err = os.Remove(db + ".gen"); err != nil {
116 t.Fatal(err)
117 }
118 }
119 local := park.Local{Guard: f.guard, Claims: f.claims, DataDir: f.guard.DataDir, Box: "box-1"}
120 if err = local.RecordInventory(t.Context(), o.Host, hostguard.Captured{Incarnation: o.Claim.Holder.Incarnation, Stamp: stamp}, "flat", []string{dbRel, dbRel + ".gen"}, func(*hostclaim.Claim) error { return nil }); err != nil {
121 t.Fatal(err)
122 }
123 o = f.read()
124 c := f.controller("boot-1", "controller-1", 4101, "start-1")
125 if claimInitialSourceRecovery(o, "box-1") {
126 if never {
127 f.kernel.noProcess = true
128 } else {
129 target := recoveryTuple(o, c.local)
130 if err := f.journal.Prelaunch(t.Context(), hostbox.LaunchInventory{ControllerPID: 4101, ProcessStart: "start-1", Target: target, Groups: []hostbox.LaunchGroup{{RelativePath: "carlos/" + claimContainmentName(target), Device: 1, Inode: target.Session}}, NoState: true}); err != nil {
131 t.Fatal(err)
132 }
133 }
134 }
135 c.wake.Inputs = plan12ReturnInputs{plan9D2Inputs: plan9D2Inputs{recoveryInputs{f}}, clean: clean, baseline: baseline}
136 c.wake.Continue = hostclaim.SessionContinues
137 c.wake.Operations = &plan9D2Attempts{}
138 c.wake.ProbeReady = func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error { return nil }
139 retirement := claimArtifactRetirement{Local: local, Continue: hostclaim.SessionContinues}
140 c.wake.RetireArtifacts, c.wake.RecoverArtifacts = retirement.Retire, retirement.Recover
141 c.assembly.Guard = f.guard
142 c.assembly.bindClaimWakeStop()
143 inv, e := h.Inventory.ReadInstallation(t.Context(), o.Host, o.Claim.Holder.Incarnation, stamp)
144 if e != nil {
145 t.Fatal(e)
146 }
147 var stat unix.Stat_t
148 if e = unix.Lstat(filepath.Dir(db), &stat); e != nil {
149 t.Fatal(e)
150 }
151 inv.Entries = append(inv.Entries, hostbox.StopInventoryEntry{Identity: inventoryIdentityFixture(t, f.guard.DataDir, dirRel), Kind: "path", ArtifactID: "state", ManagedRoot: "data", RelativePath: dirRel, Ownership: "old-owner", Device: uint64(stat.Dev), Inode: stat.Ino, Type: unix.S_IFDIR, Materialization: "materialized"})
152 if e = h.Inventory.WriteStopInventory(t.Context(), inv); e != nil {
153 t.Fatal(e)
154 }
155 roles := &ClaimRoleAssembly{Manager: c.wake.Manager, Journal: f.journal, Inventory: h.Inventory, ManagedRoots: map[string]string{"data": f.guard.DataDir}, Identity: h.Identity, BootID: "boot-1", ControllerID: "controller-1"}
156 roles.prepareGroup = func(name string, _ cgroup.Limit) (*cgroup.Prepared, hostbox.LaunchGroup, error) {
157 return nil, hostbox.LaunchGroup{RelativePath: "carlos/" + name, Device: 1, Inode: 1}, nil
158 }
159 c.wake.Prelaunch, c.wake.PrepareState = roles.Prelaunch, roles.PrepareState
160 if name == "missing-inventory" {
161 replacement, e := openLegacyInventory(t, filepath.Join(t.TempDir(), "empty.db"))
162 if e != nil {
163 t.Fatal(e)
164 }
165 defer replacement.Close()
166 h.Inventory = replacement
167 }
168 if name == "wrong-inventory" {
169 inv.Enrollment = "other"
170 if e = h.Inventory.WriteStopInventory(t.Context(), inv); e != nil {
171 t.Fatal(e)
172 }
173 }
174 if name == "missing-registry" {
175 h.Registry = nil
176 }
177 h.Launches = f.journal
178 savedRegistry := h.Registry
179 interrupted := false
180 if name == "interrupted" {
181 h.afterRecoveryRows = func() error { interrupted = true; return hostbox.ErrChanged }
182 if _, e := h.Registry.UpsertTimerRegistration(registry.Timer{Host: o.Host, Name: "uncensused", Kind: "cron", Spec: "* * * * *", ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: o.Claim.Holder.Incarnation, ClaimStamp: stamp, ClaimOwner: "old-owner", ClaimRowID: "timer-row"}}, ""); e != nil {
183 t.Fatal(e)
184 }
185 }
186 wireClaimAdmission(c.wake, "box-1", c.wake.AfterAdmission, h, nil, nil)
187 if name == "concurrent-update" {
188 h.afterRecoveryRows = func() error {
189 _, err := hostclaim.Update(t.Context(), f.claims, f.read(), func(c *hostclaim.Claim) error { c.Op.Journal["concurrent"] = "acknowledged"; return nil })
190 return err
191 }
192 wireClaimAdmission(c.wake, "box-1", c.wake.AfterAdmission, h, nil, nil)
193 }
194 if name == "intent-crash" {
195 prepare := c.wake.recoveryIntent
196 c.wake.recoveryIntent = func(ctx context.Context, o hostclaim.Observed, in hostclaim.Admission) (hostclaim.Admission, error) {
197 in, err := prepare(ctx, o, in)
198 if err != nil {
199 return in, err
200 }
201 interrupted = true
202 return in, hostbox.ErrChanged
203 }
204 }
205 if name == "prelaunch-update" {
206 c.wake.recoveryPrelaunched = func(context.Context, hostclaim.Observed) error { interrupted = true; return hostbox.ErrChanged }
207 }
208 if name == "admission-crash" {
209 c.wake.AfterAdmission = func(context.Context, hostclaim.Observed, hostclaim.Observed) error {
210 interrupted = true
211 return hostbox.ErrChanged
212 }
213 }
214 if name == "prejournal-failure" {
215 h.Registry = nil
216 wireClaimAdmission(c.wake, "box-1", c.wake.AfterAdmission, h, nil, nil)
217 }
218 if err := c.wake.stopPredecessor(t.Context(), o); err != nil {
219 t.Fatal(err)
220 }
221 if name == "evidence-read-failure" {
222 h.Evidence = &plan12FailEvidence{Store: f.claims, fail: true}
223 wireClaimAdmission(c.wake, "box-1", c.wake.AfterAdmission, h, nil, nil)
224 }
225 live, cancel := context.WithCancel(t.Context())
226 defer cancel()
227 done := make(chan struct{})
228 go func() { <-live.Done(); close(done) }()
229 c.wake.mu.Lock()
230 c.wake.pending[o.Host] = &claimWakeAttempt{observed: o, held: recoveryHeld{}, ctx: live, cancel: cancel, done: done}
231 c.wake.mu.Unlock()
232 admitted, err := c.runtime.Admit(t.Context(), registry.Route{Host: o.Host}, o, activator.StartPolicy{Probe: true})
233 if retryCase {
234 if name == "prejournal-failure" {
235 interrupted = err != nil
236 h.Registry = savedRegistry
237 }
238 if name == "evidence-read-failure" {
239 interrupted = err != nil
240 h.Evidence = f.claims
241 }
242 if err == nil || !interrupted {
243 t.Fatal("did not interrupt after registry commit", err)
244 }
245 first := f.read()
246 first, e = hostclaim.Update(t.Context(), f.claims, first, func(c *hostclaim.Claim) error {
247 c.Op.Journal["unrelated"] = "park acknowledged"
248 return nil
249 })
250 if e != nil {
251 t.Fatal(e)
252 }
253 row, e := h.Registry.Get(o.Host)
254 if e != nil || name == "interrupted" && row.ClaimStamp != first.Claim.Holder.Stamp {
255 t.Fatal("registry did not commit", e)
256 }
257 if name == "interrupted" {
258 if _, e = h.Inventory.ReadInstallation(t.Context(), o.Host, first.Claim.Holder.Incarnation, first.Claim.Holder.Stamp); !errors.Is(e, sql.ErrNoRows) {
259 t.Fatal("successor inventory already written", e)
260 }
261 }
262 c = f.controller("boot-2", "controller-2", 4102, "start-2")
263 c.wake.Inputs = plan12ReturnInputs{plan9D2Inputs: plan9D2Inputs{recoveryInputs{f}}, baseline: baseline}
264 c.wake.Continue = hostclaim.SessionContinues
265 c.wake.Operations = &plan9D2Attempts{}
266 c.wake.ProbeReady = func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error { return nil }
267 c.wake.RetireArtifacts, c.wake.RecoverArtifacts = retirement.Retire, retirement.Recover
268 c.wake.Prelaunch, c.wake.PrepareState = roles.Prelaunch, roles.PrepareState
269 c.assembly.Guard = f.guard
270 c.assembly.bindClaimWakeStop()
271 h.afterRecoveryRows = nil
272 wireClaimAdmission(c.wake, "box-1", c.wake.AfterAdmission, h, nil, nil)
273 if e := c.wake.stopPredecessor(t.Context(), first); e != nil {
274 t.Fatal("retry chose predecessor stop before handover", e)
275 }
276 if name == "prelaunch-update" {
277 bodies, err := h.Registry.PendingRecoveryHandovers(t.Context())
278 if err != nil || len(bodies) != 0 {
279 t.Fatalf("prelaunch journal stayed pending: %+v %v", bodies, err)
280 }
281 if len(f.requests) == 0 {
282 t.Fatal("prelaunch retry did not reach root stop")
283 }
284 return
285 }
286 retryCtx, retryCancel := context.WithCancel(t.Context())
287 defer retryCancel()
288 retryDone := make(chan struct{})
289 go func() { <-retryCtx.Done(); close(retryDone) }()
290 c.wake.pending[o.Host] = &claimWakeAttempt{observed: first, held: recoveryHeld{}, ctx: retryCtx, cancel: retryCancel, done: retryDone}
291 admitted, err = c.runtime.Admit(t.Context(), registry.Route{Host: o.Host}, first, activator.StartPolicy{Probe: true})
292 if err != nil || !beforeAdmission && admitted.Claim.Holder.Session != first.Claim.Holder.Session {
293 t.Fatal("retry did not resume admitted session", err)
294 }
295 if !beforeAdmission {
296 if err = c.runtime.ResumedAdmission(t.Context(), first, admitted); err != nil {
297 t.Fatal("activator could not verify resumed admission", err)
298 }
299 if err := c.runtime.ResumedAdmission(t.Context(), first, admitted); !errors.Is(err, hostbox.ErrChanged) {
300 t.Fatal("resume verification was not single use", err)
301 }
302 }
303 }
304 if bad {
305 if err == nil {
306 t.Fatal("unproved inventory admitted")
307 }
308 return
309 }
310 if err != nil {
311 t.Fatal(err)
312 }
313 launch, err := c.runtime.Prepare(t.Context(), registry.Route{Host: o.Host}, admitted, activator.StartPolicy{Probe: true})
314 if err != nil {
315 t.Fatal(err)
316 }
317 if never {
318 if f.kernel.noProcessCalls == 0 {
319 t.Fatal("R4 witness was not checked")
320 }
321 return
322 }
323 restoreErr := launch.Restore(t.Context())
324 if name == "concurrent-update" {
325 if !errors.Is(restoreErr, hostclaim.ErrConflict) {
326 t.Fatal("stale admission accepted foreign journal write", restoreErr)
327 }
328 if f.read().Claim.Op.Journal["concurrent"] != "acknowledged" {
329 t.Fatal("concurrent journal write lost")
330 }
331 if _, err := os.Stat(db); !errors.Is(err, os.ErrNotExist) {
332 t.Fatal("foreign update reached database installation", err)
333 }
334 retired := filepath.Join(f.guard.DataDir, ".parked", admitted.Claim.Holder.Incarnation, o.Host, fmt.Sprintf("superseded-%d-%d", admitted.Claim.Holder.Session, stamp), "artifacts", dbRel)
335 if got, err := os.ReadFile(retired); err != nil || string(got) != "source database" {
336 t.Fatalf("foreign update lost retired predecessor: %q %v", got, err)
337 }
338 return
339 }
340 if restoreErr != nil {
341 t.Fatal(restoreErr)
342 }
343 if got, err := os.ReadFile(db + ".gen"); err != nil || string(got) != admitted.Claim.Holder.Generation+"\n" {
344 t.Fatal(string(got), err)
345 }
346 restored, err := sql.Open("sqlite", "file:"+db+"?mode=ro")
347 if err != nil {
348 t.Fatal(err)
349 }
350 defer restored.Close()
351 var value string
352 if err = restored.QueryRow("SELECT value FROM fixture").Scan(&value); err != nil || value != "selected recovery baseline" {
353 t.Fatalf("restored database: %q %v", value, err)
354 }
355 retired := filepath.Join(f.guard.DataDir, ".parked", admitted.Claim.Holder.Incarnation, o.Host, fmt.Sprintf("superseded-%d-%d", admitted.Claim.Holder.Session, stamp), "artifacts", dbRel)
356 if got, err := os.ReadFile(retired); err != nil || string(got) != "source database" {
357 t.Fatalf("retired predecessor: %q %v", got, err)
358 }
359 if got, err := os.ReadFile(retired + ".gen"); err != nil || string(got) != "src\n" {
360 t.Fatalf("retired marker: %q %v", got, err)
361 }
362 if tag := f.read().Claim.Inventory["box-1"].Paths[dbRel]; tag != admitted.Claim.Holder.Stamp {
363 t.Fatal("old ownership", tag)
364 }
365 })
366 }
367}
368
369type plan12ReturnInputs struct {
370 plan9D2Inputs
371 clean bool
372 baseline *genselect.Plan
373}
374
375func (in plan12ReturnInputs) Select(ctx context.Context, rt registry.Route, o hostclaim.Observed, p activator.StartPolicy) (ClaimLaunchInputs, error) {
376 selected, err := in.plan9D2Inputs.Select(ctx, rt, o, p)
377 selected.Admission.KeepStamp = false
378 if in.baseline != nil {
379 selected.Empty = false
380 selected.Baseline = in.baseline
381 selected.Admission.RemoteMaxTXID = in.baseline.MaxTXID
382 }
383 selected.DBPath = provision.NestedDBPath(in.f.guard.DataDir, o.Host)
384 if in.clean {
385 selected.Admission.Coverage = "clean-return"
386 }
387 return selected, err
388}
389
390func plan12RestoreBaseline(t *testing.T, f *recoveryFixture, extraBytes ...int) *genselect.Plan {
391 t.Helper()
392 path := filepath.Join(t.TempDir(), "selected.db")
393 db, err := sql.Open("sqlite", path)
394 if err != nil {
395 t.Fatal(err)
396 }
397 if _, err := db.Exec("CREATE TABLE fixture(value TEXT); INSERT INTO fixture VALUES ('selected recovery baseline')"); err != nil {
398 t.Fatal(err)
399 }
400 if len(extraBytes) > 0 && extraBytes[0] > 0 {
401 if _, err := db.Exec("CREATE TABLE padding(value BLOB); INSERT INTO padding VALUES (zeroblob(?))", extraBytes[0]); err != nil {
402 t.Fatal(err)
403 }
404 }
405 if err := db.Close(); err != nil {
406 t.Fatal(err)
407 }
408 raw, err := os.ReadFile(path)
409 if err != nil {
410 t.Fatal(err)
411 }
412 size := int(binary.BigEndian.Uint16(raw[16:18]))
413 if size == 1 {
414 size = 65536
415 }
416 spec := ltx.FileSpec{Header: ltx.Header{Version: ltx.Version, Flags: ltx.HeaderFlagNoChecksum, PageSize: uint32(size), Commit: uint32(len(raw) / size), MinTXID: 1, MaxTXID: 1}}
417 for off := 0; off < len(raw); off += size {
418 pg := uint32(off/size + 1)
419 if pg != ltx.LockPgno(uint32(size)) {
420 spec.Pages = append(spec.Pages, ltx.PageSpec{Header: ltx.PageHeader{Pgno: pg}, Data: raw[off : off+size]})
421 }
422 }
423 var out bytes.Buffer
424 if _, err := spec.WriteTo(&out); err != nil {
425 t.Fatal(err)
426 }
427 key := "instances/" + f.hostName() + "/g-src/0009/" + ltx.FormatFilename(1, 1)
428 bucket := leasetest.NewVersionedBucket()
429 bucket.AddBucket("data")
430 version := bucket.Put("data", key, out.Bytes())
431 bucket.Put("data", key, []byte("newer unselected version"))
432 f.data, f.bucket = bucket, "data"
433 return &genselect.Plan{Generation: "src", MaxTXID: 1, Pin: &genselect.Pin{SnapshotCRC64: uint64(spec.Trailer.FileChecksum)}, Files: []genselect.File{{Key: key, VersionID: version, Level: 9, MinTXID: 1, MaxTXID: 1, Size: int64(out.Len())}}}
434}
435
436type plan12FailEvidence struct {
437 release.Store
438 fail bool
439}
440
441func (s *plan12FailEvidence) Get(ctx context.Context, key string) ([]byte, string, error) {
442 if s.fail {
443 s.fail = false
444 return nil, "", hostbox.ErrChanged
445 }
446 return s.Store.Get(ctx, key)
447}
448