carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "encoding/json"
5 "errors"
6 "os"
7 "os/user"
8 "path/filepath"
9 "strconv"
10 "strings"
11 "testing"
12
13 "github.com/carlosframework/platform/internal/hostactors"
14 "github.com/carlosframework/platform/internal/hostfloor"
15 "github.com/carlosframework/platform/internal/release"
16)
17
18// plan9aNewBody is the target build's executable in the M1-4 fixture.
19var plan9aNewBody = []byte("#!/bin/sh\necho carlos new-build\n")
20
21// plan9aTargetFixture is guardRecoveryFixture with a reservation whose
22// target is not the image on disk: current and recovery are the old build,
23// the target is new-build, which has its own signed identity.
24func plan9aTargetFixture(t *testing.T) (*applyFixture, string) {
25 t.Helper()
26 f, id := guardRecoveryFixture(t)
27 a := f.a
28 old, err := os.ReadFile(systemBinaryPath(a.optDir))
29 if err != nil {
30 t.Fatal(err)
31 }
32 oldHash, newHash := hostfloor.Digest(old), hostfloor.Digest(plan9aNewBody)
33 sg := release.RootSigner(f.priv)
34 rel := release.SystemRelease{Version: "new-build", ReleaseTag: "v1.2.4", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{a.platform: {Key: release.SystemArtifactKey("new-build", a.platform), SHA256: newHash}}}
35 if _, err := release.PromoteSystem(t.Context(), f.store, sg, "router", "plan9a", rel, release.SystemPromoteOptions{}); err != nil {
36 t.Fatal(err)
37 }
38 identity, _, err := f.store.Get(t.Context(), release.SystemPointerKey("router", "plan9a"))
39 if err != nil {
40 t.Fatal(err)
41 }
42 if err := hostfloor.Durable(filepath.Join(a.floor.Dir, "identities", "v1.2.4.json"), identity, 0644); err != nil {
43 t.Fatal(err)
44 }
45 tx, err := a.guardTransaction()
46 if err != nil {
47 t.Fatal(err)
48 }
49 var roster hostactors.Roster
50 if err := release.VerifyRootEnvelope(f.pub, tx.Roster, &roster); err != nil {
51 t.Fatal(err)
52 }
53 h := roster.Activations[id]
54 h.Hashes = map[string][]string{tx.Actor: {oldHash, newHash, oldHash}}
55 roster.Activations[id] = h
56 if err := roster.Validate("bucket"); err != nil {
57 t.Fatal(err)
58 }
59 if tx.Roster, err = release.SignRootEnvelope(sg, roster); err != nil {
60 t.Fatal(err)
61 }
62 raw, _ := json.Marshal(tx)
63 if err := hostfloor.Durable(filepath.Join(a.floor.Dir, "activation.json"), raw, 0644); err != nil {
64 t.Fatal(err)
65 }
66 return f, id
67}
68
69func plan9aAbsent(t *testing.T, paths ...string) {
70 t.Helper()
71 for _, p := range paths {
72 if _, err := os.Lstat(p); !errors.Is(err, os.ErrNotExist) {
73 t.Fatalf("%s present: %v", p, err)
74 }
75 }
76}
77
78// M1-4: a transaction that never changed the box (refused or deferred
79// before the first destructive step, or abandoned at the rename) records no
80// completion. Root's marker and transaction go, so the box is not wedged,
81// and the console's reservation stays held for a retry or an operator.
82func TestPlan9AUnchangedTransactionRecordsNoCompletion(t *testing.T) {
83 t.Parallel()
84 for _, state := range []string{"prepared", "staging-failed", "late-participation", "abandoned-install"} {
85 t.Run(state, func(t *testing.T) {
86 f, id := plan9aTargetFixture(t)
87 a := f.a
88 marker := filepath.Join(a.floor.Dir, updateBegunFile)
89 record := filepath.Join(a.dataDir, hostexecDir, "pending.json")
90 if state != "prepared" {
91 if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil {
92 t.Fatal(err)
93 }
94 }
95 switch state {
96 case "late-participation":
97 // The late re-check before the first destructive step runs
98 // under CARLOS_REGION_HA (systemparticipation.go).
99 a.regionHA = true
100 if err := os.WriteFile(record, []byte(`{"state":"pending"}`), 0600); err != nil {
101 t.Fatal(err)
102 }
103 if err := a.beginExecutableChange(t.Context(), "new-build", f.now); !errors.Is(err, errSystemDeferred) {
104 t.Fatalf("late participation = %v", err)
105 }
106 case "abandoned-install":
107 if err := a.journal(systemJournal{Phase: systemPhaseInstalling, Target: "new-build", PrevBuild: "old-build", StartedAt: f.now}); err != nil {
108 t.Fatal(err)
109 }
110 a.removeJournal()
111 }
112 before, err := os.ReadFile(systemBinaryPath(a.optDir))
113 if err != nil {
114 t.Fatal(err)
115 }
116 for range 2 {
117 if err := a.Run(t.Context(), true); err != nil {
118 t.Fatalf("unchanged transaction wedged recovery: %v", err)
119 }
120 }
121 plan9aAbsent(t, filepath.Join(a.floor.Dir, "completed.json"), marker, filepath.Join(a.floor.Dir, "activation.json"))
122 if after, _ := os.ReadFile(systemBinaryPath(a.optDir)); string(after) != string(before) {
123 t.Fatal("binary changed")
124 }
125 if len(f.calls) != 0 {
126 t.Fatal("recovery executed something", f.calls)
127 }
128 if state == "late-participation" {
129 if _, err := os.Stat(record); err != nil {
130 t.Fatal("root removed a controller record", err)
131 }
132 if err := os.Remove(record); err != nil {
133 t.Fatal(err)
134 }
135 }
136 // The retry begins the same reservation again.
137 if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil {
138 t.Fatalf("retry blocked: %v", err)
139 }
140 })
141 }
142}
143
144// M1-4: the target on disk is a finished transaction.
145func TestPlan9AForwardFinishRecordsCompletion(t *testing.T) {
146 f, id := plan9aTargetFixture(t)
147 a := f.a
148 if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil {
149 t.Fatal(err)
150 }
151 if err := a.journal(systemJournal{Phase: systemPhaseGated, Target: "new-build", PrevBuild: "old-build", StartedAt: f.now}); err != nil {
152 t.Fatal(err)
153 }
154 if err := os.WriteFile(systemBinaryPath(a.optDir), plan9aNewBody, 0755); err != nil {
155 t.Fatal(err)
156 }
157 if err := a.closeGuardJournal(t.Context()); err != nil {
158 t.Fatal(err)
159 }
160 if raw, err := os.ReadFile(filepath.Join(a.floor.Dir, "completed.json")); err != nil || string(raw) != id {
161 t.Fatalf("completion = %q, %v", raw, err)
162 }
163 plan9aAbsent(t, filepath.Join(a.floor.Dir, updateBegunFile))
164}
165
166// M1-4: a backward recovery restores the old image and still completes the
167// transaction, because it rolled back. This pins the call-site wiring.
168func TestPlan9ABackwardRecoveryRecordsCompletion(t *testing.T) {
169 f, id := plan9aTargetFixture(t)
170 a := f.a
171 old, err := os.ReadFile(systemBinaryPath(a.optDir))
172 if err != nil {
173 t.Fatal(err)
174 }
175 if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil {
176 t.Fatal(err)
177 }
178 if err := os.WriteFile(systemPrevPath(a.optDir), old, 0755); err != nil {
179 t.Fatal(err)
180 }
181 if err := os.WriteFile(systemBinaryPath(a.optDir), plan9aNewBody, 0755); err != nil {
182 t.Fatal(err)
183 }
184 // The install staged carlos.prev's plan; recovery reuses it rather than
185 // exec'ing a binary (floor.Run needs root-owned paths).
186 prevDir := filepath.Join(a.stagingDir(), "prev")
187 units := filepath.Join(prevDir, "etc", "systemd", "system")
188 if err := os.MkdirAll(units, 0755); err != nil {
189 t.Fatal(err)
190 }
191 if err := os.WriteFile(filepath.Join(units, "carlos-edge.service"), []byte("[Unit]\n"), 0644); err != nil {
192 t.Fatal(err)
193 }
194 if err := os.MkdirAll(filepath.Join(prevDir, "etc", "tmpfiles.d"), 0755); err != nil {
195 t.Fatal(err)
196 }
197 if err := os.WriteFile(filepath.Join(prevDir, "etc", "tmpfiles.d", "carlos.conf"), []byte("d /run/carlos\n"), 0644); err != nil {
198 t.Fatal(err)
199 }
200 if err := writeJSONDurable(filepath.Join(prevDir, "plan.json"), bootstrapPlan{Enabled: []string{"carlos-edge.service"}}, 0600); err != nil {
201 t.Fatal(err)
202 }
203 if err := a.journal(systemJournal{Phase: systemPhaseInstalled, Target: "new-build", PrevBuild: "old-build", StartedAt: f.now}); err != nil {
204 t.Fatal(err)
205 }
206 f.edgeStartedThisBoot = false
207 if err := a.Run(t.Context(), true); err != nil {
208 t.Fatal(err)
209 }
210 if got, _ := os.ReadFile(systemBinaryPath(a.optDir)); string(got) != string(old) {
211 t.Fatal("old image not restored")
212 }
213 if r := f.result(); r == nil || r.Outcome != systemOutcomeRolledBack {
214 t.Fatalf("result = %+v", r)
215 }
216 if raw, err := os.ReadFile(filepath.Join(a.floor.Dir, "completed.json")); err != nil || string(raw) != id {
217 t.Fatalf("rolled-back transaction not completed: %q, %v", raw, err)
218 }
219 plan9aAbsent(t, filepath.Join(a.floor.Dir, updateBegunFile), a.file("journal.json"))
220}
221
222// M1-4: a crash after the rollback was recorded and the journal removed,
223// before completion, still completes on the old image.
224func TestPlan9ARolledBackMarkerCompletesAfterCrash(t *testing.T) {
225 f, id := plan9aTargetFixture(t)
226 a := f.a
227 if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil {
228 t.Fatal(err)
229 }
230 if err := hostfloor.MarkRolledBack(a.floor.Dir, id); err != nil {
231 t.Fatal(err)
232 }
233 if err := a.Run(t.Context(), true); err != nil {
234 t.Fatal(err)
235 }
236 if raw, err := os.ReadFile(filepath.Join(a.floor.Dir, "completed.json")); err != nil || string(raw) != id {
237 t.Fatalf("completion = %q, %v", raw, err)
238 }
239}
240
241// M1-4: closing a rollback under someone else's marker refuses and keeps
242// the journal; it never completes the local transaction instead.
243func TestPlan9ARollbackCloseRefusesForeignMarker(t *testing.T) {
244 f, _ := plan9aTargetFixture(t)
245 a := f.a
246 marker := filepath.Join(a.floor.Dir, updateBegunFile)
247 if err := os.WriteFile(marker, []byte(`{"id":"other"}`), 0644); err != nil {
248 t.Fatal(err)
249 }
250 if err := a.journal(systemJournal{Phase: systemPhaseUnits, Target: "new-build", PrevBuild: "old-build", StartedAt: f.now}); err != nil {
251 t.Fatal(err)
252 }
253 if err := a.closeGuardRollback(t.Context()); err == nil {
254 t.Fatal("foreign marker accepted")
255 }
256 if _, err := os.Stat(a.file("journal.json")); err != nil {
257 t.Fatal("journal discarded", err)
258 }
259 plan9aAbsent(t, filepath.Join(a.floor.Dir, "completed.json"))
260}
261
262// M1-3: guard-arm refuses while root's update state is anywhere in the
263// guard directory, including main's region marker and its temporary file.
264func TestPlan9AGuardArmRefusesUpdateState(t *testing.T) {
265 for _, name := range []string{updateBegunFile, updateBegunFile + ".tmp", updateBegunFile + ".old"} {
266 t.Run(name, func(t *testing.T) {
267 guard, root := t.TempDir(), t.TempDir()
268 if err := os.WriteFile(filepath.Join(guard, name), []byte(`{"target":"v2"}`), 0644); err != nil {
269 t.Fatal(err)
270 }
271 err := guardArmIdle(guard, root)
272 if !errors.Is(err, hostfloor.ErrFloor) || !strings.Contains(err.Error(), name) {
273 t.Fatalf("guard-arm with %s = %v", name, err)
274 }
275 })
276 }
277 guard, root := t.TempDir(), t.TempDir()
278 for _, name := range []string{"policy.json", "carlos", ".guard-123", "completed.json"} {
279 if err := os.WriteFile(filepath.Join(guard, name), nil, 0644); err != nil {
280 t.Fatal(err)
281 }
282 }
283 if err := guardArmIdle(guard, root); err != nil {
284 t.Fatalf("settled guard directory refused: %v", err)
285 }
286 if err := guardArmIdle(filepath.Join(guard, "absent"), root); err != nil {
287 t.Fatalf("absent guard directory refused: %v", err)
288 }
289 if err := os.WriteFile(filepath.Join(root, "journal.json"), []byte("{}"), 0600); err != nil {
290 t.Fatal(err)
291 }
292 if err := guardArmIdle(guard, root); !errors.Is(err, hostfloor.ErrFloor) {
293 t.Fatalf("open journal = %v", err)
294 }
295}
296
297// M1-2: guard-arm creates the service-owned controller hint directory.
298// Root census and launch completeness independently authorize an update.
299func TestPlan9AGuardArmCreatesControllerDir(t *testing.T) {
300 f, id := guardRecoveryFixture(t)
301 a := f.a
302 if err := os.RemoveAll(filepath.Join(a.dataDir, hostexecDir)); err != nil {
303 t.Fatal(err)
304 }
305 verify := a.floor.Participation.VerifyLaunches
306 a.floor.Participation.VerifyLaunches = nil
307 if err := a.floor.Begin(t.Context(), id, a.dataDir); err == nil {
308 t.Fatal("precondition: update began without root completeness")
309 }
310 me, err := user.Current()
311 if err != nil {
312 t.Skip(err)
313 }
314 original := lookupServiceUser
315 t.Cleanup(func() { lookupServiceUser = original })
316 var asked []string
317 lookupServiceUser = func(name string) (*user.User, error) { asked = append(asked, name); return me, nil }
318 if err := guardArmControllers(a.dataDir); err != nil {
319 t.Fatal(err)
320 }
321 if len(asked) != 1 || asked[0] != serviceUser {
322 t.Fatalf("looked up %v, want %s", asked, serviceUser)
323 }
324 fi, err := os.Lstat(filepath.Join(a.dataDir, hostexecDir))
325 if err != nil || !fi.IsDir() || fi.Mode().Perm() != 0o770 {
326 t.Fatalf("controller dir = %v, %v", fi, err)
327 }
328 if err := a.floor.Begin(t.Context(), id, a.dataDir); err == nil {
329 t.Fatal("agent directory replaced root completeness")
330 }
331 a.floor.Participation.VerifyLaunches = verify
332 if err := a.floor.Begin(t.Context(), id, a.dataDir); err != nil {
333 t.Fatalf("armed update still refused: %v", err)
334 }
335 lookupServiceUser = func(string) (*user.User, error) { return nil, user.UnknownUserError(serviceUser) }
336 if err := guardArmControllers(t.TempDir()); err == nil {
337 t.Fatal("arming proceeded without the service account")
338 }
339 lookupServiceUser = func(string) (*user.User, error) {
340 return &user.User{Uid: "x", Gid: strconv.Itoa(os.Getgid())}, nil
341 }
342 if err := guardArmControllers(t.TempDir()); err == nil {
343 t.Fatal("arming accepted a malformed uid")
344 }
345}
346
347// I-2: an edge restart inside an apply pulls in the recover unit. On an
348// armed box guard-exec recover hands the journal to the lock holder at once
349// instead of waiting out systemRecoverLockWait.
350func TestPlan9AGuardRecoverYieldsToHeldApplyLock(t *testing.T) {
351 opt := t.TempDir()
352 if held, err := recoverLockHeld(opt); err != nil || held {
353 t.Fatalf("no lock file = %v, %v", held, err)
354 }
355 plan9aAbsent(t, filepath.Join(systemRootDir(opt), systemLockFile))
356 if err := os.MkdirAll(systemRootDir(opt), 0755); err != nil {
357 t.Fatal(err)
358 }
359 lock, err := os.OpenFile(filepath.Join(systemRootDir(opt), systemLockFile), os.O_CREATE|os.O_RDWR, 0600)
360 if err != nil {
361 t.Fatal(err)
362 }
363 defer lock.Close()
364 if held, err := recoverLockHeld(opt); err != nil || held {
365 t.Fatalf("free lock = %v, %v", held, err)
366 }
367 if err := flockExclusiveNonblock(lock); err != nil {
368 t.Skipf("flock unsupported: %v", err)
369 }
370 if held, err := recoverLockHeld(opt); err != nil || !held {
371 t.Fatalf("held lock = %v, %v", held, err)
372 }
373 lock.Close()
374 if held, err := recoverLockHeld(opt); err != nil || held {
375 t.Fatalf("released lock = %v, %v", held, err)
376 }
377 again, err := os.OpenFile(filepath.Join(systemRootDir(opt), systemLockFile), os.O_RDWR, 0)
378 if err != nil {
379 t.Fatal(err)
380 }
381 defer again.Close()
382 if err := flockExclusiveNonblock(again); err != nil {
383 t.Fatalf("probe left the lock held: %v", err)
384 }
385}
386