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 && rootrunner
2
3package host
4
5import (
6 "bytes"
7 "context"
8 "crypto/ed25519"
9 "encoding/json"
10 "fmt"
11 "net"
12 "net/http/httptest"
13 "os"
14 "path/filepath"
15 "reflect"
16 "sort"
17 "strconv"
18 "strings"
19 "syscall"
20 "testing"
21 "time"
22
23 "github.com/carlosframework/platform/internal/hostbox"
24 "github.com/carlosframework/platform/internal/hostguard"
25 "github.com/carlosframework/platform/internal/lease"
26 "github.com/carlosframework/platform/internal/lease/leasetest"
27 "github.com/carlosframework/platform/internal/registry"
28 "github.com/carlosframework/platform/internal/release"
29 "github.com/carlosframework/platform/internal/rootstate"
30)
31
32const seedRebootPath = "/var/lib/carlos-rootrunner-files-seed-reboot"
33
34type seedRebootObject struct {
35 Version lease.ObjectVersion
36 Body []byte
37}
38type seedRebootHost struct {
39 Host string
40 Fixture seedCrashFixture
41 Objects []seedRebootObject
42 Snapshot string
43 Records []servingParkRecord
44}
45type seedRebootSave struct {
46 Boot, Scratch, Install string
47 Hosts []seedRebootHost
48}
49
50func TestRootRebootFilesSeedTwoHostPhase1(t *testing.T) {
51 if err := os.Mkdir(seedRebootPath, 0700); err != nil {
52 t.Fatal("refuse existing reboot fixture", err)
53 }
54 runServingParkPeerNamespace(t, false, false, "seed-two-host")
55}
56
57func saveFilesSeedReboot(t *testing.T, binary string, hosts []*seedLiveHost) {
58 boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
59 if err != nil {
60 t.Fatal(err)
61 }
62 saved := seedRebootSave{Boot: strings.TrimSpace(string(boot)), Scratch: os.Getenv("TMPDIR"), Install: filepath.Dir(binary)}
63 for _, h := range hosts {
64 f := h.p.f
65 host := f.f.hostName()
66 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}
67 claims := release.NewDirStore(cfg.Claims)
68 keys, err := f.f.claims.List(t.Context(), "")
69 if err != nil {
70 t.Fatal(err)
71 }
72 for _, key := range keys {
73 body, _, err := f.f.claims.Get(t.Context(), key)
74 if err != nil {
75 t.Fatal(err)
76 }
77 if _, err := claims.Put(t.Context(), key, body, ""); err != nil {
78 t.Fatal(err)
79 }
80 }
81 sh := seedRebootHost{Host: host, Fixture: cfg, Snapshot: f.f.data.(*leasetest.VersionedBucket).Snapshot()}
82 versions, err := f.f.data.(*leasetest.VersionedBucket).ListVersions(t.Context(), f.actor.Bucket, "")
83 if err != nil {
84 t.Fatal(err)
85 }
86 for _, v := range versions {
87 var body []byte
88 if !v.DeleteMarker {
89 body, err = f.f.data.GetVersion(t.Context(), f.actor.Bucket, v.Key, v.VersionID)
90 if err != nil {
91 t.Fatal(err)
92 }
93 }
94 sh.Objects = append(sh.Objects, seedRebootObject{v, body})
95 }
96 names, err := f.p.ledger.state.Names()
97 if err != nil {
98 t.Fatal(err)
99 }
100 for _, name := range names {
101 if !strings.HasPrefix(name, "retirement-") || !strings.HasSuffix(name, ".json") {
102 continue
103 }
104 r, err := f.p.ledger.read(strings.TrimSuffix(strings.TrimPrefix(name, "retirement-"), ".json"))
105 if err != nil {
106 t.Fatal(err)
107 }
108 if r.Authorization.Successor.Host == host && r.Parked && !r.Closed {
109
110 sh.Records = append(sh.Records, r)
111 }
112 }
113 if len(sh.Records) == 0 {
114 t.Fatal("no retirement retained", host)
115 }
116 if err := h.executor.registry.Close(); err != nil {
117 t.Fatal(err)
118 }
119 if err := f.f.journal.Close(); err != nil {
120 t.Fatal(err)
121 }
122 if err := f.p.root.Reconcile.Registry.Close(); err != nil {
123 t.Fatal(err)
124 }
125 saved.Hosts = append(saved.Hosts, sh)
126 }
127 raw, err := json.Marshal(saved)
128 if err != nil {
129 t.Fatal(err)
130 }
131 if err := os.WriteFile(filepath.Join(seedRebootPath, "fixture.json"), raw, 0600); err != nil {
132 t.Fatal(err)
133 }
134 syscall.Sync()
135 // Phase 2 owns these exact durable fixtures. All tenant units have stopped;
136 // process exit closes the loopback servers without running TempDir removal.
137 t.Log("two live hosts retired independently; durable fixture ready for actual reboot")
138 os.Exit(0)
139}
140
141func TestRootRebootFilesSeedTwoHostPhase2(t *testing.T) {
142 servingRootRunner(t)
143 raw, err := os.ReadFile(filepath.Join(seedRebootPath, "fixture.json"))
144 if err != nil {
145 t.Fatal(err)
146 }
147 var saved seedRebootSave
148 if err := json.Unmarshal(raw, &saved); err != nil {
149 t.Fatal(err)
150 }
151 boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
152 if err != nil || strings.TrimSpace(string(boot)) == saved.Boot {
153 t.Fatal("actual reboot required", err)
154 }
155 t.Cleanup(func() {
156 for _, p := range []string{saved.Scratch, saved.Install, seedRebootPath} {
157 if !strings.HasPrefix(p, "/var/lib/carlos-rootrunner-") && !strings.HasPrefix(p, "/var/lib/carlos-serving-test-") {
158 t.Errorf("refuse cleanup path %q", p)
159 continue
160 }
161 if err := os.RemoveAll(p); err != nil {
162 t.Error(err)
163 }
164 }
165 })
166 binary, err := os.Executable()
167 if err != nil {
168 t.Fatal(err)
169 }
170 witness := rootFilesSeedIsolatedWitness(t, binary, true, true)
171 servingRunnerMount(t, "/data")
172 state, err := openClaimServingParkAuthority()
173 if err != nil {
174 t.Fatal(err)
175 }
176 t.Cleanup(func() { state.Close() })
177 fs := newServingParkFS("/data", state)
178 ledger := &servingParkLedger{state: state, fs: fs, now: time.Now}
179 rebootRoot := &servingParkRoot{ledger: ledger}
180 for _, h := range saved.Hosts {
181 for _, r := range h.Records {
182 if err := ledger.fs.CheckParked(&r); err == nil {
183 t.Fatal("old boot authority accepted", h.Host)
184 }
185 if !r.Complete {
186 t.Fatal("unfinished reboot fixture", h.Host)
187 }
188 if err := rebootRoot.prepareServingReboot(t.Context(), r.Authorization.ID); err != nil {
189 topology, topErr := fs.inspectRebootTopology(t.Context())
190 observation, inspectErr := fs.inspectRebootRecord(t.Context(), r.Authorization.ID)
191 rawRecord, readErr := ledger.readHistorical(r.Authorization.ID)
192 slot, slotErr := ledger.slot(r.Authorization)
193 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)
194 t.Fatal("prepare completed park", h.Host, err)
195 }
196 if err := rebootRoot.commitServingReboot(t.Context(), r.Authorization.ID); err != nil {
197 t.Fatal("recover park", h.Host, err)
198 }
199 }
200 }
201 retained := map[string][]byte{}
202 if strings.Contains(t.Name(), "Renumber") {
203 for _, host := range saved.Hosts {
204 for _, r := range host.Records {
205 for _, file := range r.Source.Files {
206 if !file.Present {
207 continue
208 }
209 path := filepath.Join("/data/instances/.serving-parked", r.Authorization.ID, file.Name)
210 body, err := os.ReadFile(path)
211 if err != nil {
212 t.Fatal(err)
213 }
214 retained[path] = body
215 }
216 }
217 }
218 }
219 var hosts []*seedLiveHost
220 for _, savedHost := range saved.Hosts {
221 h := reopenSeedLiveHost(t, savedHost, witness, state)
222 hosts = append(hosts, h)
223 inventory, err := h.p.f.p.root.Journals.Inventory.ReadStopInventory(t.Context(), savedHost.Host)
224 if err != nil {
225 t.Fatal(err)
226 }
227 var current syscall.Stat_t
228 if err := syscall.Stat(filepath.Dir(h.p.f.db), &current); err != nil {
229 t.Fatal(err)
230 }
231 for _, entry := range inventory.Entries {
232 if entry.Kind == "path" {
233 if strings.Contains(t.Name(), "Renumber") && entry.Device == uint64(current.Dev) {
234 t.Fatal("real filesystem device did not renumber")
235 }
236 if !strings.Contains(t.Name(), "Renumber") && (entry.Device != uint64(current.Dev) || entry.Inode != current.Ino) {
237 t.Fatalf("unchanged-number reboot control changed identity: device %d -> %d, inode %d -> %d", entry.Device, current.Dev, entry.Inode, current.Ino)
238 }
239 t.Logf("reboot directory host=%s recordedDevice=%d currentDevice=%d recordedInode=%d currentInode=%d", savedHost.Host, entry.Device, current.Dev, entry.Inode, current.Ino)
240 }
241 }
242 h.wake(t)
243 }
244 for _, h := range hosts {
245 h.probe(t)
246 }
247 for i, h := range hosts {
248 hosts[1-i].probe(t)
249 h.stop()
250 h.wake(t)
251 hosts[1-i].probe(t)
252 }
253 for _, h := range hosts {
254 h.probe(t)
255 h.stop()
256 }
257 for path, want := range retained {
258 got, err := os.ReadFile(path)
259 if err != nil || !bytes.Equal(got, want) {
260 t.Fatalf("retained park changed after two wakes: %s %v", path, err)
261 }
262 }
263 if len(retained) != 0 {
264 ids, more, err := ledger.recoveryIDs(t.Context(), "", servingParkRecoveryLimit)
265 if err != nil || more {
266 t.Fatal("retained census", err)
267 }
268 count := 0
269 for _, host := range saved.Hosts {
270 for _, old := range host.Records {
271 found := false
272 for _, id := range ids {
273 if id == old.Authorization.ID {
274 found = true
275 }
276 }
277 r, err := ledger.readHistorical(old.Authorization.ID)
278 if !found || err != nil || r.Closed || !r.ReapAfter.Equal(old.ReapAfter) {
279 t.Fatal("lost retained record", r, err)
280 }
281 count++
282 }
283 }
284 t.Logf("%d unverifiable parks retained after two live wakes per host; %d records counted", count, len(ids))
285 }
286}
287
288func reopenSeedLiveHost(t *testing.T, saved seedRebootHost, witness filesSeedLiveWitness, state rootstate.Store) *seedLiveHost {
289 t.Helper()
290 cfg := saved.Fixture
291 data := leasetest.NewVersionedBucket()
292 data.AddBucket(cfg.Actor.Bucket)
293 sort.Slice(saved.Objects, func(i, j int) bool { return saved.Objects[i].Version.VersionID < saved.Objects[j].Version.VersionID })
294 next := 1
295 for _, o := range saved.Objects {
296 n, err := strconv.Atoi(strings.TrimPrefix(o.Version.VersionID, "v"))
297 if err != nil {
298 t.Fatal(err)
299 }
300 for next < n {
301 id := data.Put(cfg.Actor.Bucket, "reboot-gap", nil)
302 if err := data.DeleteVersion(t.Context(), cfg.Actor.Bucket, "reboot-gap", id); err != nil {
303 t.Fatal(err)
304 }
305 next++
306 }
307 var id string
308 if o.Version.DeleteMarker {
309 id = data.BareDelete(cfg.Actor.Bucket, o.Version.Key)
310 } else {
311 id = data.Put(cfg.Actor.Bucket, o.Version.Key, o.Body)
312 }
313 if id != o.Version.VersionID {
314 t.Fatal("version identity changed", id, o.Version.VersionID)
315 }
316 next++
317 }
318 if data.Snapshot() != saved.Snapshot {
319 t.Fatal("remote fixture version snapshot changed")
320 }
321 handler, _ := leasetest.NewS3Server(data)
322 server := httptest.NewServer(handler)
323 t.Cleanup(server.Close)
324 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} {
325 t.Setenv(k, v)
326 }
327 claims := release.NewDirStore(cfg.Claims)
328 journal, err := hostbox.OpenLaunchJournal(cfg.Journal)
329 if err != nil {
330 t.Fatal(err)
331 }
332 t.Cleanup(func() { journal.Close() })
333 roots := &servingAuthorityDisk{rollbackDisk: &rollbackDisk{dir: cfg.Root}}
334 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}
335 rf.guard = hostguard.Guard{DataDir: cfg.Data, LockDir: cfg.Locks, Claims: hostguard.StoreReader{Store: claims}, Root: hostguard.NewRootAuthority(roots)}
336 rf.kernel = &recoveryKernel{journal: journal}
337 reg, err := registry.Open(cfg.Registry)
338 if err != nil {
339 t.Fatal(err)
340 }
341 t.Cleanup(func() { reg.Close() })
342 row, err := reg.Get(saved.Host)
343 if err != nil || row == nil {
344 t.Fatal("route", err)
345 }
346 journals, err := hostbox.NewJournalService(journal)
347 if err != nil {
348 t.Fatal(err)
349 }
350 f := &servingRootFixture{t: t, f: rf, rootDisk: roots, db: row.DBPath, actor: cfg.Actor}
351 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}
352 coordinator := rf.root(nil)
353 coordinator.Kernel = &noProcessKernelFake{}
354 coordinator.Inventory = journals.Inventory
355 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 }}
356 f.p.root.Reconcile.Registry = reg
357 f.p.root.Reconcile.Inventory = journals.Inventory
358 f.p.root.Reconcile.Box, f.p.root.Reconcile.Enrollment = f.actor.Box, f.actor.Enrollment
359 f.p.root.Reconcile.Retirement.JournalDir = cfg.Retirement
360 source := &movesFilesSource{DataDir: cfg.Data, Routes: reg, newFiles: func(context.Context, string, string) (release.Store, error) {
361 return casBucketStore{b: data, bucket: cfg.Actor.Bucket}, nil
362 }}
363 opened, err := source.Open(t.Context(), claims, data, cfg.Actor)
364 if err != nil {
365 t.Fatal(err)
366 }
367 p := newServingPreparation(t, f, "boot", fmt.Sprintf("reboot-%d", os.Getpid()))
368 return &seedLiveHost{p: p, endpoint: server.URL, files: opened.(*movesFiles), witness: witness}
369}
370