//go:build linux && rootrunner package host import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) const migrationInstalledDir = "/var/lib/plan14-p3-migration-installed" const migrationInstalledUnit = "plan14-p3-migration-installed.service" type migrationInstalledFixture struct { Timer registry.Timer Registry, TimerRegistration string Data, Opt, Units, Scratch, PublicKey, Boot string Target hostguard.FenceTarget Receipt []byte } func saveMigrationInstalled(t *testing.T, store release.Store, r ClaimRuntimeResources, scratch string, target hostguard.FenceTarget, receipt []byte) { t.Helper() if err := os.Mkdir(migrationInstalledDir, 0700); err != nil { t.Fatal(err) } saved := release.NewDirStore(filepath.Join(migrationInstalledDir, "store")) keys, err := store.List(t.Context(), "") if err != nil { t.Fatal(err) } for _, key := range keys { raw, _, err := store.Get(t.Context(), key) if err != nil { t.Fatal(err) } if _, err = saved.Put(t.Context(), key, raw, ""); err != nil { t.Fatal(err) } } boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") if err != nil { t.Fatal(err) } timer, err := r.Registry.GetTimer(target.Host, "refresh") if err != nil || timer == nil || !timer.ClaimOwnership.Valid() { t.Fatal("unadopted reboot timer", timer, err) } f := migrationInstalledFixture{Data: r.DataDir, Opt: r.OptDir, Units: r.UnitsDir, Scratch: scratch, PublicKey: os.Getenv("CARLOS_RELEASE_PUBKEY"), Boot: string(boot), Registry: r.Registry.Path(), TimerRegistration: timer.Registration, Timer: *timer, Target: target, Receipt: receipt} if err := r.Registry.Close(); err != nil { t.Fatal(err) } registryBytes, err := os.ReadFile(f.Registry) if err != nil { t.Fatal(err) } f.Registry = filepath.Join(migrationInstalledDir, "registry.db") if err := writeFileDurable(f.Registry, registryBytes, 0600); err != nil { t.Fatal(err) } raw, err := json.Marshal(f) if err != nil { t.Fatal(err) } if err := writeFileDurable(filepath.Join(migrationInstalledDir, "fixture.json"), raw, 0600); err != nil { t.Fatal(err) } binary, err := os.ReadFile(os.Args[0]) if err != nil { t.Fatal(err) } if err := writeFileDurable(filepath.Join(migrationInstalledDir, "runner.test"), binary, 0755); err != nil { t.Fatal(err) } // Deliberately retain this test's root state and scratch across the real reboot. // Phase 2 removes the exact saved paths. No testing cleanup may erase them now. os.Exit(0) } func installedMigrationFixture(t *testing.T) migrationInstalledFixture { t.Helper() raw, err := os.ReadFile(filepath.Join(migrationInstalledDir, "fixture.json")) if err != nil { t.Fatal(err) } var f migrationInstalledFixture if json.Unmarshal(raw, &f) != nil { t.Fatal("fixture") } return f } func migrationInstalledCtl(t *testing.T, args ...string) { t.Helper() out, err := exec.CommandContext(t.Context(), "systemctl", args...).CombinedOutput() if err != nil { logs, _ := exec.Command("journalctl", "-b", "-u", migrationInstalledUnit, "--no-pager").CombinedOutput() t.Fatal(args, err, string(out), string(logs)) } } func TestRootRebootPlan14MigrationInstalledPhase1(t *testing.T) { if os.Getenv("PLAN14_P3_BOOTSTRAP_CHILD") == "" { t.Run("unknown crashing target recovery", TestPlan14RootFloorUnknownCrashRecoveryRootRunner) } testRootMigrationBootstrap(t, true, false) f := installedMigrationFixture(t) raw, err := unitsFS.ReadFile("units/" + claimControlUnit) if err != nil { t.Fatal(err) } content := claimUnitContent(claimControlUnit, raw, claimUnitSettings{enabled: true, data: f.Data, opt: f.Opt}) var lines []string for _, line := range strings.Split(string(content), "\n") { if strings.HasPrefix(line, "Requires=") || strings.HasPrefix(line, "After=") || strings.HasPrefix(line, "ExecStart=") || strings.HasPrefix(line, "EnvironmentFile=") { continue } lines = append(lines, line) } content = []byte(strings.Join(lines, "\n") + fmt.Sprintf("\nType=oneshot\nRuntimeDirectory=carlos carlos-control carlos/hostguard\nReadWritePaths=%s\nTimeoutStartSec=120s\nExecStart=%s/runner.test -test.run=^TestPlan14MigrationInstalledStartupPayload$ -test.v -test.timeout=90s\nIPAddressDeny=any\n[Install]\nWantedBy=multi-user.target\n", migrationInstalledDir, migrationInstalledDir)) path := filepath.Join("/etc/systemd/system", migrationInstalledUnit) fd, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) if err != nil { t.Fatal(err) } if _, err := fd.Write(content); err != nil { t.Fatal(err) } if err := fd.Close(); err != nil { t.Fatal(err) } migrationInstalledCtl(t, "daemon-reload") migrationInstalledCtl(t, "enable", migrationInstalledUnit) migrationInstalledCtl(t, "start", migrationInstalledUnit) } func TestRootRebootPlan14MigrationInstalledPhase2(t *testing.T) { f := installedMigrationFixture(t) defer func() { migrationInstalledCtl(t, "disable", "--now", migrationInstalledUnit) for _, path := range []string{filepath.Join("/etc/systemd/system", migrationInstalledUnit), f.Scratch, "/var/lib/carlos-claims", "/var/lib/carlos-release", migrationInstalledDir} { if err := os.RemoveAll(path); err != nil { t.Error(err) } } migrationInstalledCtl(t, "daemon-reload") }() boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") if err != nil || string(boot) == f.Boot { t.Fatal("runner did not reboot", err) } // The enabled installed unit must have started during this boot, before phase 2. observed, err := os.ReadFile(filepath.Join(migrationInstalledDir, "started-boot")) if err != nil || !bytes.Equal(observed, boot) { out, _ := exec.Command("journalctl", "-b", "-u", migrationInstalledUnit, "--no-pager").CombinedOutput() t.Fatal("installed startup", err, string(observed), string(out)) } migrationInstalledCtl(t, "start", migrationInstalledUnit) path := filepath.Join(rootMigrationDir, "migration.json") original, err := os.ReadFile(path) if err != nil { t.Fatal(err) } var pending rootMigrationRecord if json.Unmarshal(original, &pending) != nil { t.Fatal("saved migration") } pending.Phase = "prepared" raw, _ := json.Marshal(pending) if err := writeFileDurable(path, raw, 0600); err != nil { t.Fatal(err) } if out, err := exec.CommandContext(t.Context(), "systemctl", "start", migrationInstalledUnit).CombinedOutput(); err == nil { t.Fatal("installed startup ignored incomplete migration", string(out)) } if err := writeFileDurable(path, original, 0600); err != nil { t.Fatal(err) } migrationInstalledCtl(t, "reset-failed", migrationInstalledUnit) migrationInstalledCtl(t, "start", migrationInstalledUnit) } func TestPlan14MigrationInstalledStartupPayload(t *testing.T) { f := installedMigrationFixture(t) t.Setenv("CARLOS_OPT_DIR", f.Opt) t.Setenv("CARLOS_DATA_DIR", f.Data) t.Setenv("CARLOS_RELEASE_PUBKEY", f.PublicKey) t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "deployment") // External actor/store services are deterministic fixtures. The constructor, // root openers, migration verification and retained journals are production. authority, journal := openClaimRootAuthority, openClaimRootJournal _, _, owners, _ := fix4JournalFixture(t) openClaimRootAuthority, openClaimRootJournal = authority, journal store := release.NewDirStore(filepath.Join(migrationInstalledDir, "store")) owners.RetirementAuthority.Store = store owners.RetirementAuthority.Directory.Store = store pub, err := base64.StdEncoding.DecodeString(f.PublicKey) if err != nil { t.Fatal(err) } owners.RetirementAuthority.Directory.PublicKey = pub reg, err := registry.Open(f.Registry) if err != nil { t.Fatal(err) } defer reg.Close() r := ClaimRuntimeResources{Mode: "instance-control", Registry: reg, DataDir: f.Data, OptDir: f.Opt, UnitsDir: f.Units} assembly, err := OpenProductionClaimAssembly(t.Context(), store, r, owners) if err != nil { t.Fatal("installed production constructor", err) } defer assembly.Close(context.Background()) receipt, err := assembly.Guard.FenceReceiptFor(t.Context(), f.Target) if err != nil || !bytes.Equal(receipt.Bytes, f.Receipt) { t.Fatal("reboot changed receipt", err) } captured := hostguard.Captured{Incarnation: f.Target.Incarnation, Stamp: f.Target.Stamp} timer, err := reg.GetTimer(f.Target.Host, "refresh") expectedTimer := f.Timer expectedTimer.Registration = f.TimerRegistration if err != nil || *timer != expectedTimer || timer.ClaimRowID != f.TimerRegistration || timer.ClaimIncarnation != f.Target.Incarnation || timer.ClaimStamp != f.Target.Stamp { t.Fatal("reboot timer identity", timer, err) } view, err := assembly.Guard.Root.Installation(t.Context(), f.Target.Host, captured) if err != nil || view.Artifacts.Timers != "refresh" || view.InventoryRevision != 2 { t.Fatal("reboot root timer inventory", view, err) } launches := 0 launch := func() error { launches++; return exec.Command("/usr/bin/true").Run() } allow := func(*hostclaim.Claim) error { return nil } if err := assembly.Guard.With(t.Context(), f.Target.Host, captured, allow, launch); err != nil || launches != 1 { t.Fatal("installed successor", err, launches) } // Replay of the old claim session is still refused after reboot and startup. current, err := hostclaim.Read(t.Context(), store, f.Target.Host) if err != nil { t.Fatal(err) } old := *current.Claim holder := *old.Holder holder.Session = f.Target.Session old.Holder = &holder denied := assembly.Guard denied.Claims = rootViewClaimReader{obs: hostclaim.Observed{Host: old.Host, Claim: &old, ETag: current.ETag}} if err := denied.With(t.Context(), f.Target.Host, captured, allow, launch); err == nil || launches != 1 { t.Fatal("old session after reboot", err, launches) } boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") if err != nil { t.Fatal(err) } if err := writeFileDurable(filepath.Join(migrationInstalledDir, "started-boot"), boot, 0600); err != nil { t.Fatal(err) } }