| 1 | //go:build linux && rootrunner |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "encoding/base64" |
| 9 | "encoding/json" |
| 10 | "fmt" |
| 11 | "os" |
| 12 | "os/exec" |
| 13 | "path/filepath" |
| 14 | "strings" |
| 15 | "testing" |
| 16 | |
| 17 | "github.com/carlosframework/platform/internal/hostclaim" |
| 18 | "github.com/carlosframework/platform/internal/hostguard" |
| 19 | "github.com/carlosframework/platform/internal/registry" |
| 20 | "github.com/carlosframework/platform/internal/release" |
| 21 | ) |
| 22 | |
| 23 | const migrationInstalledDir = "/var/lib/plan14-p3-migration-installed" |
| 24 | const migrationInstalledUnit = "plan14-p3-migration-installed.service" |
| 25 | |
| 26 | type migrationInstalledFixture struct { |
| 27 | Timer registry.Timer |
| 28 | Registry, TimerRegistration string |
| 29 | Data, Opt, Units, Scratch, PublicKey, Boot string |
| 30 | Target hostguard.FenceTarget |
| 31 | Receipt []byte |
| 32 | } |
| 33 | |
| 34 | func saveMigrationInstalled(t *testing.T, store release.Store, r ClaimRuntimeResources, scratch string, target hostguard.FenceTarget, receipt []byte) { |
| 35 | t.Helper() |
| 36 | if err := os.Mkdir(migrationInstalledDir, 0700); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | saved := release.NewDirStore(filepath.Join(migrationInstalledDir, "store")) |
| 40 | keys, err := store.List(t.Context(), "") |
| 41 | if err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | for _, key := range keys { |
| 45 | raw, _, err := store.Get(t.Context(), key) |
| 46 | if err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | if _, err = saved.Put(t.Context(), key, raw, ""); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | } |
| 53 | boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | timer, err := r.Registry.GetTimer(target.Host, "refresh") |
| 58 | if err != nil || timer == nil || !timer.ClaimOwnership.Valid() { |
| 59 | t.Fatal("unadopted reboot timer", timer, err) |
| 60 | } |
| 61 | 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} |
| 62 | if err := r.Registry.Close(); err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | registryBytes, err := os.ReadFile(f.Registry) |
| 66 | if err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | f.Registry = filepath.Join(migrationInstalledDir, "registry.db") |
| 70 | if err := writeFileDurable(f.Registry, registryBytes, 0600); err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | raw, err := json.Marshal(f) |
| 74 | if err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | if err := writeFileDurable(filepath.Join(migrationInstalledDir, "fixture.json"), raw, 0600); err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | binary, err := os.ReadFile(os.Args[0]) |
| 81 | if err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | if err := writeFileDurable(filepath.Join(migrationInstalledDir, "runner.test"), binary, 0755); err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | // Deliberately retain this test's root state and scratch across the real reboot. |
| 88 | // Phase 2 removes the exact saved paths. No testing cleanup may erase them now. |
| 89 | os.Exit(0) |
| 90 | } |
| 91 | |
| 92 | func installedMigrationFixture(t *testing.T) migrationInstalledFixture { |
| 93 | t.Helper() |
| 94 | raw, err := os.ReadFile(filepath.Join(migrationInstalledDir, "fixture.json")) |
| 95 | if err != nil { |
| 96 | t.Fatal(err) |
| 97 | } |
| 98 | var f migrationInstalledFixture |
| 99 | if json.Unmarshal(raw, &f) != nil { |
| 100 | t.Fatal("fixture") |
| 101 | } |
| 102 | return f |
| 103 | } |
| 104 | func migrationInstalledCtl(t *testing.T, args ...string) { |
| 105 | t.Helper() |
| 106 | out, err := exec.CommandContext(t.Context(), "systemctl", args...).CombinedOutput() |
| 107 | if err != nil { |
| 108 | logs, _ := exec.Command("journalctl", "-b", "-u", migrationInstalledUnit, "--no-pager").CombinedOutput() |
| 109 | t.Fatal(args, err, string(out), string(logs)) |
| 110 | } |
| 111 | } |
| 112 | func TestRootRebootPlan14MigrationInstalledPhase1(t *testing.T) { |
| 113 | if os.Getenv("PLAN14_P3_BOOTSTRAP_CHILD") == "" { |
| 114 | t.Run("unknown crashing target recovery", TestPlan14RootFloorUnknownCrashRecoveryRootRunner) |
| 115 | } |
| 116 | testRootMigrationBootstrap(t, true, false) |
| 117 | f := installedMigrationFixture(t) |
| 118 | raw, err := unitsFS.ReadFile("units/" + claimControlUnit) |
| 119 | if err != nil { |
| 120 | t.Fatal(err) |
| 121 | } |
| 122 | content := claimUnitContent(claimControlUnit, raw, claimUnitSettings{enabled: true, data: f.Data, opt: f.Opt}) |
| 123 | var lines []string |
| 124 | for _, line := range strings.Split(string(content), "\n") { |
| 125 | if strings.HasPrefix(line, "Requires=") || strings.HasPrefix(line, "After=") || strings.HasPrefix(line, "ExecStart=") || strings.HasPrefix(line, "EnvironmentFile=") { |
| 126 | continue |
| 127 | } |
| 128 | lines = append(lines, line) |
| 129 | } |
| 130 | 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)) |
| 131 | path := filepath.Join("/etc/systemd/system", migrationInstalledUnit) |
| 132 | fd, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) |
| 133 | if err != nil { |
| 134 | t.Fatal(err) |
| 135 | } |
| 136 | if _, err := fd.Write(content); err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | if err := fd.Close(); err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | migrationInstalledCtl(t, "daemon-reload") |
| 143 | migrationInstalledCtl(t, "enable", migrationInstalledUnit) |
| 144 | migrationInstalledCtl(t, "start", migrationInstalledUnit) |
| 145 | } |
| 146 | func TestRootRebootPlan14MigrationInstalledPhase2(t *testing.T) { |
| 147 | f := installedMigrationFixture(t) |
| 148 | defer func() { |
| 149 | migrationInstalledCtl(t, "disable", "--now", migrationInstalledUnit) |
| 150 | for _, path := range []string{filepath.Join("/etc/systemd/system", migrationInstalledUnit), f.Scratch, "/var/lib/carlos-claims", "/var/lib/carlos-release", migrationInstalledDir} { |
| 151 | if err := os.RemoveAll(path); err != nil { |
| 152 | t.Error(err) |
| 153 | } |
| 154 | } |
| 155 | migrationInstalledCtl(t, "daemon-reload") |
| 156 | }() |
| 157 | boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| 158 | if err != nil || string(boot) == f.Boot { |
| 159 | t.Fatal("runner did not reboot", err) |
| 160 | } |
| 161 | // The enabled installed unit must have started during this boot, before phase 2. |
| 162 | observed, err := os.ReadFile(filepath.Join(migrationInstalledDir, "started-boot")) |
| 163 | if err != nil || !bytes.Equal(observed, boot) { |
| 164 | out, _ := exec.Command("journalctl", "-b", "-u", migrationInstalledUnit, "--no-pager").CombinedOutput() |
| 165 | t.Fatal("installed startup", err, string(observed), string(out)) |
| 166 | } |
| 167 | migrationInstalledCtl(t, "start", migrationInstalledUnit) |
| 168 | path := filepath.Join(rootMigrationDir, "migration.json") |
| 169 | original, err := os.ReadFile(path) |
| 170 | if err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | var pending rootMigrationRecord |
| 174 | if json.Unmarshal(original, &pending) != nil { |
| 175 | t.Fatal("saved migration") |
| 176 | } |
| 177 | pending.Phase = "prepared" |
| 178 | raw, _ := json.Marshal(pending) |
| 179 | if err := writeFileDurable(path, raw, 0600); err != nil { |
| 180 | t.Fatal(err) |
| 181 | } |
| 182 | if out, err := exec.CommandContext(t.Context(), "systemctl", "start", migrationInstalledUnit).CombinedOutput(); err == nil { |
| 183 | t.Fatal("installed startup ignored incomplete migration", string(out)) |
| 184 | } |
| 185 | if err := writeFileDurable(path, original, 0600); err != nil { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | migrationInstalledCtl(t, "reset-failed", migrationInstalledUnit) |
| 189 | migrationInstalledCtl(t, "start", migrationInstalledUnit) |
| 190 | } |
| 191 | func TestPlan14MigrationInstalledStartupPayload(t *testing.T) { |
| 192 | f := installedMigrationFixture(t) |
| 193 | t.Setenv("CARLOS_OPT_DIR", f.Opt) |
| 194 | t.Setenv("CARLOS_DATA_DIR", f.Data) |
| 195 | t.Setenv("CARLOS_RELEASE_PUBKEY", f.PublicKey) |
| 196 | t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "deployment") |
| 197 | // External actor/store services are deterministic fixtures. The constructor, |
| 198 | // root openers, migration verification and retained journals are production. |
| 199 | authority, journal := openClaimRootAuthority, openClaimRootJournal |
| 200 | _, _, owners, _ := fix4JournalFixture(t) |
| 201 | openClaimRootAuthority, openClaimRootJournal = authority, journal |
| 202 | store := release.NewDirStore(filepath.Join(migrationInstalledDir, "store")) |
| 203 | owners.RetirementAuthority.Store = store |
| 204 | owners.RetirementAuthority.Directory.Store = store |
| 205 | pub, err := base64.StdEncoding.DecodeString(f.PublicKey) |
| 206 | if err != nil { |
| 207 | t.Fatal(err) |
| 208 | } |
| 209 | owners.RetirementAuthority.Directory.PublicKey = pub |
| 210 | reg, err := registry.Open(f.Registry) |
| 211 | if err != nil { |
| 212 | t.Fatal(err) |
| 213 | } |
| 214 | defer reg.Close() |
| 215 | r := ClaimRuntimeResources{Mode: "instance-control", Registry: reg, DataDir: f.Data, OptDir: f.Opt, UnitsDir: f.Units} |
| 216 | assembly, err := OpenProductionClaimAssembly(t.Context(), store, r, owners) |
| 217 | if err != nil { |
| 218 | t.Fatal("installed production constructor", err) |
| 219 | } |
| 220 | defer assembly.Close(context.Background()) |
| 221 | receipt, err := assembly.Guard.FenceReceiptFor(t.Context(), f.Target) |
| 222 | if err != nil || !bytes.Equal(receipt.Bytes, f.Receipt) { |
| 223 | t.Fatal("reboot changed receipt", err) |
| 224 | } |
| 225 | captured := hostguard.Captured{Incarnation: f.Target.Incarnation, Stamp: f.Target.Stamp} |
| 226 | timer, err := reg.GetTimer(f.Target.Host, "refresh") |
| 227 | expectedTimer := f.Timer |
| 228 | expectedTimer.Registration = f.TimerRegistration |
| 229 | if err != nil || *timer != expectedTimer || timer.ClaimRowID != f.TimerRegistration || timer.ClaimIncarnation != f.Target.Incarnation || timer.ClaimStamp != f.Target.Stamp { |
| 230 | t.Fatal("reboot timer identity", timer, err) |
| 231 | } |
| 232 | view, err := assembly.Guard.Root.Installation(t.Context(), f.Target.Host, captured) |
| 233 | if err != nil || view.Artifacts.Timers != "refresh" || view.InventoryRevision != 2 { |
| 234 | t.Fatal("reboot root timer inventory", view, err) |
| 235 | } |
| 236 | launches := 0 |
| 237 | launch := func() error { launches++; return exec.Command("/usr/bin/true").Run() } |
| 238 | allow := func(*hostclaim.Claim) error { return nil } |
| 239 | if err := assembly.Guard.With(t.Context(), f.Target.Host, captured, allow, launch); err != nil || launches != 1 { |
| 240 | t.Fatal("installed successor", err, launches) |
| 241 | } |
| 242 | // Replay of the old claim session is still refused after reboot and startup. |
| 243 | current, err := hostclaim.Read(t.Context(), store, f.Target.Host) |
| 244 | if err != nil { |
| 245 | t.Fatal(err) |
| 246 | } |
| 247 | old := *current.Claim |
| 248 | holder := *old.Holder |
| 249 | holder.Session = f.Target.Session |
| 250 | old.Holder = &holder |
| 251 | denied := assembly.Guard |
| 252 | denied.Claims = rootViewClaimReader{obs: hostclaim.Observed{Host: old.Host, Claim: &old, ETag: current.ETag}} |
| 253 | if err := denied.With(t.Context(), f.Target.Host, captured, allow, launch); err == nil || launches != 1 { |
| 254 | t.Fatal("old session after reboot", err, launches) |
| 255 | } |
| 256 | boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| 257 | if err != nil { |
| 258 | t.Fatal(err) |
| 259 | } |
| 260 | if err := writeFileDurable(filepath.Join(migrationInstalledDir, "started-boot"), boot, 0600); err != nil { |
| 261 | t.Fatal(err) |
| 262 | } |
| 263 | } |
| 264 | |