//go:build linux && rootrunner package host import ( "bytes" "context" "crypto/ed25519" "encoding/json" "fmt" "net" "net/http/httptest" "os" "path/filepath" "reflect" "sort" "strconv" "strings" "syscall" "testing" "time" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/lease" "github.com/carlosframework/platform/internal/lease/leasetest" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/rootstate" ) const seedRebootPath = "/var/lib/carlos-rootrunner-files-seed-reboot" type seedRebootObject struct { Version lease.ObjectVersion Body []byte } type seedRebootHost struct { Host string Fixture seedCrashFixture Objects []seedRebootObject Snapshot string Records []servingParkRecord } type seedRebootSave struct { Boot, Scratch, Install string Hosts []seedRebootHost } func TestRootRebootFilesSeedTwoHostPhase1(t *testing.T) { if err := os.Mkdir(seedRebootPath, 0700); err != nil { t.Fatal("refuse existing reboot fixture", err) } runServingParkPeerNamespace(t, false, false, "seed-two-host") } func saveFilesSeedReboot(t *testing.T, binary string, hosts []*seedLiveHost) { boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") if err != nil { t.Fatal(err) } saved := seedRebootSave{Boot: strings.TrimSpace(string(boot)), Scratch: os.Getenv("TMPDIR"), Install: filepath.Dir(binary)} for _, h := range hosts { f := h.p.f host := f.f.hostName() cfg := seedCrashFixture{Claims: filepath.Join(seedRebootPath, host, "claims"), Data: f.f.guard.DataDir, Locks: f.f.guard.LockDir, Root: f.rootDisk.dir, Parks: "/var/lib/carlos-claims/authority/serving-parks", Journal: f.f.journalPath, Registry: f.p.root.Reconcile.Registry.Path(), Retirement: f.p.root.Reconcile.Retirement.JournalDir, Actor: f.actor, Now: f.f.now} claims := release.NewDirStore(cfg.Claims) keys, err := f.f.claims.List(t.Context(), "") if err != nil { t.Fatal(err) } for _, key := range keys { body, _, err := f.f.claims.Get(t.Context(), key) if err != nil { t.Fatal(err) } if _, err := claims.Put(t.Context(), key, body, ""); err != nil { t.Fatal(err) } } sh := seedRebootHost{Host: host, Fixture: cfg, Snapshot: f.f.data.(*leasetest.VersionedBucket).Snapshot()} versions, err := f.f.data.(*leasetest.VersionedBucket).ListVersions(t.Context(), f.actor.Bucket, "") if err != nil { t.Fatal(err) } for _, v := range versions { var body []byte if !v.DeleteMarker { body, err = f.f.data.GetVersion(t.Context(), f.actor.Bucket, v.Key, v.VersionID) if err != nil { t.Fatal(err) } } sh.Objects = append(sh.Objects, seedRebootObject{v, body}) } names, err := f.p.ledger.state.Names() if err != nil { t.Fatal(err) } for _, name := range names { if !strings.HasPrefix(name, "retirement-") || !strings.HasSuffix(name, ".json") { continue } r, err := f.p.ledger.read(strings.TrimSuffix(strings.TrimPrefix(name, "retirement-"), ".json")) if err != nil { t.Fatal(err) } if r.Authorization.Successor.Host == host && r.Parked && !r.Closed { sh.Records = append(sh.Records, r) } } if len(sh.Records) == 0 { t.Fatal("no retirement retained", host) } if err := h.executor.registry.Close(); err != nil { t.Fatal(err) } if err := f.f.journal.Close(); err != nil { t.Fatal(err) } if err := f.p.root.Reconcile.Registry.Close(); err != nil { t.Fatal(err) } saved.Hosts = append(saved.Hosts, sh) } raw, err := json.Marshal(saved) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(seedRebootPath, "fixture.json"), raw, 0600); err != nil { t.Fatal(err) } syscall.Sync() // Phase 2 owns these exact durable fixtures. All tenant units have stopped; // process exit closes the loopback servers without running TempDir removal. t.Log("two live hosts retired independently; durable fixture ready for actual reboot") os.Exit(0) } func TestRootRebootFilesSeedTwoHostPhase2(t *testing.T) { servingRootRunner(t) raw, err := os.ReadFile(filepath.Join(seedRebootPath, "fixture.json")) if err != nil { t.Fatal(err) } var saved seedRebootSave if err := json.Unmarshal(raw, &saved); err != nil { t.Fatal(err) } boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") if err != nil || strings.TrimSpace(string(boot)) == saved.Boot { t.Fatal("actual reboot required", err) } t.Cleanup(func() { for _, p := range []string{saved.Scratch, saved.Install, seedRebootPath} { if !strings.HasPrefix(p, "/var/lib/carlos-rootrunner-") && !strings.HasPrefix(p, "/var/lib/carlos-serving-test-") { t.Errorf("refuse cleanup path %q", p) continue } if err := os.RemoveAll(p); err != nil { t.Error(err) } } }) binary, err := os.Executable() if err != nil { t.Fatal(err) } witness := rootFilesSeedIsolatedWitness(t, binary, true, true) servingRunnerMount(t, "/data") state, err := openClaimServingParkAuthority() if err != nil { t.Fatal(err) } t.Cleanup(func() { state.Close() }) fs := newServingParkFS("/data", state) ledger := &servingParkLedger{state: state, fs: fs, now: time.Now} rebootRoot := &servingParkRoot{ledger: ledger} for _, h := range saved.Hosts { for _, r := range h.Records { if err := ledger.fs.CheckParked(&r); err == nil { t.Fatal("old boot authority accepted", h.Host) } if !r.Complete { t.Fatal("unfinished reboot fixture", h.Host) } if err := rebootRoot.prepareServingReboot(t.Context(), r.Authorization.ID); err != nil { topology, topErr := fs.inspectRebootTopology(t.Context()) observation, inspectErr := fs.inspectRebootRecord(t.Context(), r.Authorization.ID) rawRecord, readErr := ledger.readHistorical(r.Authorization.ID) slot, slotErr := ledger.slot(r.Authorization) t.Logf("reboot diagnosis topology=%+v err=%v inspection=%+v err=%v savedMatches=%v readErr=%v slot=%+v slotErr=%v saved=%+v", topology, topErr, observation, inspectErr, reflect.DeepEqual(rawRecord, r), readErr, slot, slotErr, r) t.Fatal("prepare completed park", h.Host, err) } if err := rebootRoot.commitServingReboot(t.Context(), r.Authorization.ID); err != nil { t.Fatal("recover park", h.Host, err) } } } retained := map[string][]byte{} if strings.Contains(t.Name(), "Renumber") { for _, host := range saved.Hosts { for _, r := range host.Records { for _, file := range r.Source.Files { if !file.Present { continue } path := filepath.Join("/data/instances/.serving-parked", r.Authorization.ID, file.Name) body, err := os.ReadFile(path) if err != nil { t.Fatal(err) } retained[path] = body } } } } var hosts []*seedLiveHost for _, savedHost := range saved.Hosts { h := reopenSeedLiveHost(t, savedHost, witness, state) hosts = append(hosts, h) inventory, err := h.p.f.p.root.Journals.Inventory.ReadStopInventory(t.Context(), savedHost.Host) if err != nil { t.Fatal(err) } var current syscall.Stat_t if err := syscall.Stat(filepath.Dir(h.p.f.db), ¤t); err != nil { t.Fatal(err) } for _, entry := range inventory.Entries { if entry.Kind == "path" { if strings.Contains(t.Name(), "Renumber") && entry.Device == uint64(current.Dev) { t.Fatal("real filesystem device did not renumber") } if !strings.Contains(t.Name(), "Renumber") && (entry.Device != uint64(current.Dev) || entry.Inode != current.Ino) { t.Fatalf("unchanged-number reboot control changed identity: device %d -> %d, inode %d -> %d", entry.Device, current.Dev, entry.Inode, current.Ino) } t.Logf("reboot directory host=%s recordedDevice=%d currentDevice=%d recordedInode=%d currentInode=%d", savedHost.Host, entry.Device, current.Dev, entry.Inode, current.Ino) } } h.wake(t) } for _, h := range hosts { h.probe(t) } for i, h := range hosts { hosts[1-i].probe(t) h.stop() h.wake(t) hosts[1-i].probe(t) } for _, h := range hosts { h.probe(t) h.stop() } for path, want := range retained { got, err := os.ReadFile(path) if err != nil || !bytes.Equal(got, want) { t.Fatalf("retained park changed after two wakes: %s %v", path, err) } } if len(retained) != 0 { ids, more, err := ledger.recoveryIDs(t.Context(), "", servingParkRecoveryLimit) if err != nil || more { t.Fatal("retained census", err) } count := 0 for _, host := range saved.Hosts { for _, old := range host.Records { found := false for _, id := range ids { if id == old.Authorization.ID { found = true } } r, err := ledger.readHistorical(old.Authorization.ID) if !found || err != nil || r.Closed || !r.ReapAfter.Equal(old.ReapAfter) { t.Fatal("lost retained record", r, err) } count++ } } t.Logf("%d unverifiable parks retained after two live wakes per host; %d records counted", count, len(ids)) } } func reopenSeedLiveHost(t *testing.T, saved seedRebootHost, witness filesSeedLiveWitness, state rootstate.Store) *seedLiveHost { t.Helper() cfg := saved.Fixture data := leasetest.NewVersionedBucket() data.AddBucket(cfg.Actor.Bucket) sort.Slice(saved.Objects, func(i, j int) bool { return saved.Objects[i].Version.VersionID < saved.Objects[j].Version.VersionID }) next := 1 for _, o := range saved.Objects { n, err := strconv.Atoi(strings.TrimPrefix(o.Version.VersionID, "v")) if err != nil { t.Fatal(err) } for next < n { id := data.Put(cfg.Actor.Bucket, "reboot-gap", nil) if err := data.DeleteVersion(t.Context(), cfg.Actor.Bucket, "reboot-gap", id); err != nil { t.Fatal(err) } next++ } var id string if o.Version.DeleteMarker { id = data.BareDelete(cfg.Actor.Bucket, o.Version.Key) } else { id = data.Put(cfg.Actor.Bucket, o.Version.Key, o.Body) } if id != o.Version.VersionID { t.Fatal("version identity changed", id, o.Version.VersionID) } next++ } if data.Snapshot() != saved.Snapshot { t.Fatal("remote fixture version snapshot changed") } handler, _ := leasetest.NewS3Server(data) server := httptest.NewServer(handler) t.Cleanup(server.Close) for k, v := range map[string]string{"AWS_ACCESS_KEY_ID": "test-key", "AWS_SECRET_ACCESS_KEY": "test-secret", "AWS_SESSION_TOKEN": "", "AWS_EC2_METADATA_DISABLED": "true", "AWS_REGION": "eu-west-1", "AWS_DEFAULT_REGION": "eu-west-1", "CARLOS_HOST_CLAIM": "1", "CARLOS_HOST_CLAIM_ORIGINS": "1", "CARLOS_MOVES_PHASE1": "", "CARLOS_S3_ENDPOINT": server.URL} { t.Setenv(k, v) } claims := release.NewDirStore(cfg.Claims) journal, err := hostbox.OpenLaunchJournal(cfg.Journal) if err != nil { t.Fatal(err) } t.Cleanup(func() { journal.Close() }) roots := &servingAuthorityDisk{rollbackDisk: &rollbackDisk{dir: cfg.Root}} rf := &recoveryFixture{host: saved.Host, t: t, claims: claims, evidence: claims, journal: journal, journalPath: cfg.Journal, data: data, bucket: cfg.Actor.Bucket, signer: ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)), now: cfg.Now.Add(time.Minute), seq: 100000} rf.guard = hostguard.Guard{DataDir: cfg.Data, LockDir: cfg.Locks, Claims: hostguard.StoreReader{Store: claims}, Root: hostguard.NewRootAuthority(roots)} rf.kernel = &recoveryKernel{journal: journal} reg, err := registry.Open(cfg.Registry) if err != nil { t.Fatal(err) } t.Cleanup(func() { reg.Close() }) row, err := reg.Get(saved.Host) if err != nil || row == nil { t.Fatal("route", err) } journals, err := hostbox.NewJournalService(journal) if err != nil { t.Fatal(err) } f := &servingRootFixture{t: t, f: rf, rootDisk: roots, db: row.DBPath, actor: cfg.Actor} f.p = &servingParkRoot{ledger: &servingParkLedger{state: state, fs: newServingParkFS(cfg.Data, state), journal: journal, now: func() time.Time { return rf.now }}, empty: plan9D5Fixture(t).owners} coordinator := rf.root(nil) coordinator.Kernel = &noProcessKernelFake{} coordinator.Inventory = journals.Inventory f.p.root = ClaimRootHTTP{Roots: map[string]string{"data": cfg.Data}, Journals: journals, Launches: journal, Stop: ClaimStopHTTP{Coordinator: coordinator, Authenticate: func(context.Context, net.Conn) (StopPeer, error) { return rf.peer, nil }}, InventoryActor: func(context.Context) (hostbox.Actor, error) { return f.actor, nil }, RequireNewWork: func(context.Context) error { return nil }} f.p.root.Reconcile.Registry = reg f.p.root.Reconcile.Inventory = journals.Inventory f.p.root.Reconcile.Box, f.p.root.Reconcile.Enrollment = f.actor.Box, f.actor.Enrollment f.p.root.Reconcile.Retirement.JournalDir = cfg.Retirement source := &movesFilesSource{DataDir: cfg.Data, Routes: reg, newFiles: func(context.Context, string, string) (release.Store, error) { return casBucketStore{b: data, bucket: cfg.Actor.Bucket}, nil }} opened, err := source.Open(t.Context(), claims, data, cfg.Actor) if err != nil { t.Fatal(err) } p := newServingPreparation(t, f, "boot", fmt.Sprintf("reboot-%d", os.Getpid())) return &seedLiveHost{p: p, endpoint: server.URL, files: opened.(*movesFiles), witness: witness} }