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
2
3package host
4
5import (
6 "errors"
7 "os"
8 "path/filepath"
9 "sync/atomic"
10 "syscall"
11 "testing"
12 "time"
13
14 "github.com/carlosframework/platform/internal/hostbox"
15 "github.com/carlosframework/platform/internal/hostclaim"
16 "github.com/carlosframework/platform/internal/registry"
17 "github.com/carlosframework/platform/internal/release"
18)
19
20// The held-stage lock's file is removed when the stage finishes (region HA
21// M-K25, answered by moves on #236): main's empty-installation census
22// refuses a file that names the host being installed, so a lock left
23// behind would refuse every later from-empty install of that host on the
24// box that held a stage for it.
25
26// heldLockCensus runs main's empty-installation census for a from-empty
27// install of host on a box whose data root is data, with the production
28// classification (claimGlobalSharedPaths) and any extra data entries.
29func heldLockCensus(t *testing.T, data, host string, extra ...string) error {
30 t.Helper()
31 base := t.TempDir()
32 inventory, err := openLegacyInventory(t, filepath.Join(base, "inventory.db"))
33 if err != nil {
34 t.Fatal(err)
35 }
36 defer inventory.Close()
37 reg, err := registry.Open(filepath.Join(base, "registry.db"))
38 if err != nil {
39 t.Fatal(err)
40 }
41 defer reg.Close()
42 launches, err := hostbox.OpenLaunchJournal(filepath.Join(base, "launches.db"))
43 if err != nil {
44 t.Fatal(err)
45 }
46 defer launches.Close()
47 r := ClaimRuntimeResources{DataDir: data, OptDir: filepath.Join(base, "opt"), UnitsDir: filepath.Join(base, "units")}
48 for _, dir := range []string{r.OptDir, r.UnitsDir} {
49 if err := os.Mkdir(dir, 0o755); err != nil {
50 t.Fatal(err)
51 }
52 }
53 shared := claimGlobalSharedPaths(r)
54 shared["data"] = append(shared["data"], extra...)
55 c := hostbox.EmptyInstallationCensus{Inventory: inventory, Registry: reg, Launches: launches, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1",
56 Roots: map[string]string{"data": r.DataDir, "opt": r.OptDir, "units": r.UnitsDir}, SharedPaths: shared}
57 obs := hostclaim.Observed{Host: host, Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 1}}}
58 return c.CheckLocal(t.Context(), obs, hostbox.RecordSelection{Host: host, Incarnation: "inc"})
59}
60
61func heldLockInode(t *testing.T, path string) uint64 {
62 t.Helper()
63 info, err := os.Lstat(path)
64 if err != nil {
65 t.Fatal(err)
66 }
67 return info.Sys().(*syscall.Stat_t).Ino
68}
69
70func TestHeldStageLockIsRemovedWhenTheStageFinishes(t *testing.T) {
71 t.Parallel()
72 data := t.TempDir()
73 path := filepath.Join(data, ".moves", heldStageLockFile(hsHost))
74 if err := heldLockCensus(t, data, hsHost); err != nil {
75 t.Fatalf("census before any stage: %v", err)
76 }
77 for _, result := range []error{nil, errors.New("the stage failed")} {
78 err := withHeldStageLock(data, hsHost, func() error {
79 if _, err := os.Lstat(path); err != nil {
80 t.Fatalf("no lock file while the lock is held: %v", err)
81 }
82 return result
83 })
84 if err != result {
85 t.Fatalf("stage: %v, want %v", err, result)
86 }
87 if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
88 t.Fatalf("after a stage that returned %v, %s remains: %v", result, path, err)
89 }
90 }
91 // The publication path: heldStageAuthority's then, inside the freeze.
92 s := release.NewDirStore(t.TempDir())
93 hsClaim(t, s, movingOp())
94 hsReport(t, s, hsOp, "v7")
95 auth := heldStageAuthority(testRegistry(t), s, hsBox, data)
96 published := false
97 if err := auth(hsRequest(), func() error { published = true; return nil }); err != nil || !published {
98 t.Fatalf("publication: %v (ran %v)", err, published)
99 }
100 if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
101 t.Fatalf("after a publication, %s remains: %v", path, err)
102 }
103 // A from-empty install of that host on this box passes main's census.
104 if err := heldLockCensus(t, data, hsHost); err != nil {
105 t.Fatalf("census after held stages of the host: %v", err)
106 }
107}
108
109// A waiter that opened the file a holder then removes must not lock that
110// file and run beside whoever holds the new one at the same name: it
111// re-checks the name after its flock and retries.
112func TestHeldStageLockWaiterOnARemovedFileRetries(t *testing.T) {
113 prevWait, prevHook := heldLockWait, lockNamedOpened
114 t.Cleanup(func() { heldLockWait, lockNamedOpened = prevWait, prevHook })
115 heldLockWait = 10 * time.Second
116 data := t.TempDir()
117 path := filepath.Join(data, ".moves", heldStageLockFile(hsHost))
118 var holders, overlaps atomic.Int32
119 hold := func(entered chan<- struct{}, leave <-chan struct{}) func() error {
120 return func() error {
121 if holders.Add(1) != 1 {
122 overlaps.Add(1)
123 }
124 defer holders.Add(-1)
125 close(entered)
126 if leave != nil {
127 <-leave
128 }
129 return nil
130 }
131 }
132
133 // The first holder holds the lock on the first file.
134 in1, out1, done1 := make(chan struct{}), make(chan struct{}), make(chan error, 1)
135 go func() { done1 <- withHeldStageLock(data, hsHost, hold(in1, out1)) }()
136 <-in1
137
138 // The waiter opens that file and is parked before its flock.
139 opened, resume := make(chan struct{}), make(chan struct{})
140 var opens atomic.Int32
141 lockNamedOpened = func(name string) {
142 if name == heldStageLockFile(hsHost) && opens.Add(1) == 1 {
143 close(opened)
144 <-resume
145 }
146 }
147 inW, doneW := make(chan struct{}), make(chan error, 1)
148 go func() { doneW <- withHeldStageLock(data, hsHost, hold(inW, nil)) }()
149 <-opened
150
151 // The first holder finishes: it removes its file and releases.
152 close(out1)
153 if err := <-done1; err != nil {
154 t.Fatal(err)
155 }
156 if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
157 close(resume)
158 t.Fatalf("the first holder left %s: %v", path, err)
159 }
160
161 // A second holder arrives and locks a new file at the same name.
162 in2, out2, done2 := make(chan struct{}), make(chan struct{}), make(chan error, 1)
163 go func() { done2 <- withHeldStageLock(data, hsHost, hold(in2, out2)) }()
164 <-in2
165
166 // The waiter resumes. Its fd names the removed file, which nobody
167 // holds: it must not run until the second holder is done.
168 close(resume)
169 select {
170 case <-inW:
171 close(out2)
172 t.Fatalf("the waiter ran while the second holder held the lock (overlaps %d)", overlaps.Load())
173 case <-time.After(500 * time.Millisecond):
174 }
175 close(out2)
176 if err := <-done2; err != nil {
177 t.Fatal(err)
178 }
179 select {
180 case <-inW:
181 case <-time.After(5 * time.Second):
182 t.Fatal("the waiter never took the lock after the second holder released it")
183 }
184 if err := <-doneW; err != nil {
185 t.Fatal(err)
186 }
187 if n := overlaps.Load(); n != 0 {
188 t.Fatalf("%d holders ran beside another", n)
189 }
190 if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
191 t.Fatalf("after every holder finished, %s remains: %v", path, err)
192 }
193}
194
195// A holder killed mid-stage never reaches the removal, so its file stays
196// (the existing behaviour). Main's census then refuses an install of that
197// host; region HA's fallback (CensusTree(".moves")) refuses that host only.
198// The next stage of the host locks the file the crash left and removes it.
199func TestHeldStageLockLeftByACrashIsReusedThenRemoved(t *testing.T) {
200 t.Parallel()
201 data := t.TempDir()
202 path := filepath.Join(data, ".moves", heldStageLockFile(hsHost))
203 if err := os.Mkdir(filepath.Dir(path), 0o755); err != nil {
204 t.Fatal(err)
205 }
206 if err := os.WriteFile(path, nil, 0o644); err != nil {
207 t.Fatal(err)
208 }
209 left := heldLockInode(t, path)
210 if err := heldLockCensus(t, data, hsHost); !errors.Is(err, hostbox.ErrTypedInventory) {
211 t.Fatalf("main's census with a crashed stage's lock: %v, want ErrTypedInventory", err)
212 }
213 if err := heldLockCensus(t, data, hsHost, hostbox.CensusTree(".moves")); !errors.Is(err, hostbox.ErrTypedInventory) {
214 t.Fatalf("the fallback census for the lock's host: %v, want ErrTypedInventory", err)
215 }
216 if err := heldLockCensus(t, data, "other.example.com", hostbox.CensusTree(".moves")); err != nil {
217 t.Fatalf("the fallback census for another host: %v", err)
218 }
219 ran := false
220 if err := withHeldStageLock(data, hsHost, func() error {
221 ran = true
222 if got := heldLockInode(t, path); got != left {
223 t.Fatalf("the next stage locked a new file (inode %d), not the one the crash left (%d)", got, left)
224 }
225 return nil
226 }); err != nil || !ran {
227 t.Fatalf("the next stage: %v (ran %v)", err, ran)
228 }
229 if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
230 t.Fatalf("the next stage left %s: %v", path, err)
231 }
232 if err := heldLockCensus(t, data, hsHost); err != nil {
233 t.Fatalf("census after the next stage: %v", err)
234 }
235}
236
237// A holder's removal can land inside a waiter's open: the name vanishes
238// between the create and the reopen, or the file reopened has no link
239// left. lockNamed opens again then, but keeps the refusal of anything
240// else at the name.
241func TestHeldStageLockOpenRetriesOnlyTheRemovalRace(t *testing.T) {
242 data := t.TempDir()
243 d, err := openMovesDir(data, true)
244 if err != nil {
245 t.Fatal(err)
246 }
247 defer d.Close()
248 at := func(name string) string { return filepath.Join(data, ".moves", name) }
249 if err := os.WriteFile(at("fresh.lock"), nil, 0o644); err != nil {
250 t.Fatal(err)
251 }
252 if err := os.WriteFile(at("linked.lock"), nil, 0o644); err != nil {
253 t.Fatal(err)
254 }
255 if err := os.Link(at("linked.lock"), at("second.lock")); err != nil {
256 t.Fatal(err)
257 }
258 if err := os.Symlink(t.TempDir(), at("planted.lock")); err != nil {
259 t.Fatal(err)
260 }
261 if err := syscall.Mkfifo(at("fifo.lock"), 0o644); err != nil {
262 t.Fatal(err)
263 }
264 unlinked := &os.PathError{Op: "open", Path: "x", Err: errors.New("not a regular file with one link")}
265 for _, c := range []struct {
266 name string
267 err error
268 want bool
269 }{
270 {"gone.lock", &os.PathError{Op: "open", Path: "x", Err: syscall.ENOENT}, true},
271 {"gone.lock", unlinked, true},
272 {"fresh.lock", unlinked, true},
273 {"fresh.lock", &os.PathError{Op: "open", Path: "x", Err: syscall.EACCES}, false},
274 {"linked.lock", unlinked, false},
275 {"planted.lock", unlinked, false},
276 {"fifo.lock", unlinked, false},
277 } {
278 if got := d.removedDuringOpen(c.name, c.err); got != c.want {
279 t.Errorf("%s after %v: retry %v, want %v", c.name, c.err, got, c.want)
280 }
281 }
282 // A .moves removed under an open movesDir: nothing can be created in
283 // it, so the open's error stands at once.
284 gone := t.TempDir()
285 g, err := openMovesDir(gone, true)
286 if err != nil {
287 t.Fatal(err)
288 }
289 defer g.Close()
290 if err := os.Remove(filepath.Join(gone, ".moves")); err != nil {
291 t.Fatal(err)
292 }
293 if g.removedDuringOpen("gone.lock", &os.PathError{Op: "open", Path: "x", Err: syscall.ENOENT}) {
294 t.Error("a removed .moves was retried")
295 }
296}
297
298// Fix round 1: the re-check's retries are bounded by the one wait. With
299// every file the waiter opens replaced at the name before its flock, each
300// re-check finds another file there; the call still ends with the busy
301// error once the wait has passed, and never runs fn. Publication waits
302// inside the registry's freeze, so the wait must hold.
303func TestHeldStageLockRetriesEndWithTheWait(t *testing.T) {
304 prevWait, prevHook := heldLockWait, lockNamedOpened
305 const wait = 200 * time.Millisecond
306 heldLockWait = wait
307 data := t.TempDir()
308 path := filepath.Join(data, ".moves", heldStageLockFile(hsHost))
309 var stop atomic.Bool
310 var replaced atomic.Int32
311 lockNamedOpened = func(name string) {
312 if name != heldStageLockFile(hsHost) || stop.Load() {
313 return
314 }
315 if err := os.Remove(path); err != nil {
316 return
317 }
318 if err := os.WriteFile(path, nil, 0o644); err == nil {
319 replaced.Add(1)
320 }
321 }
322 var ran atomic.Bool
323 done := make(chan error, 1)
324 start := time.Now()
325 go func() { done <- withHeldStageLock(data, hsHost, func() error { ran.Store(true); return nil }) }()
326 t.Cleanup(func() {
327 // A waiter still retrying is let through before the seam goes.
328 stop.Store(true)
329 select {
330 case <-done:
331 case <-time.After(10 * time.Second):
332 }
333 heldLockWait, lockNamedOpened = prevWait, prevHook
334 })
335 var err error
336 select {
337 case err = <-done:
338 done <- err
339 case <-time.After(5 * time.Second):
340 t.Fatalf("still retrying %v after a %v wait (%d files replaced, ran %v)", time.Since(start), wait, replaced.Load(), ran.Load())
341 }
342 elapsed := time.Since(start)
343 if !errors.Is(err, errHeldLockBusy) {
344 t.Fatalf("with every opened file replaced: %v, want errHeldLockBusy", err)
345 }
346 if ran.Load() {
347 t.Fatal("fn ran without the lock")
348 }
349 if elapsed > wait+2*time.Second {
350 t.Fatalf("gave up after %v, want about %v", elapsed, wait)
351 }
352 if replaced.Load() < 2 {
353 t.Fatalf("only %d files were replaced; the re-check was not exercised", replaced.Load())
354 }
355 // Fix round 2: replacement retries pause (lockNamedRetryPause, capped
356 // at what is left of the wait), so they cannot spin. A pause is a floor
357 // on each retry's time, so this bound holds however loaded the box.
358 if most := int32(wait/lockNamedRetryPause) + 2; replaced.Load() > most {
359 t.Fatalf("%d replacement retries in a %v wait, want at most %d", replaced.Load(), wait, most)
360 }
361}
362
363// Fix round 2: an open that meets a holder's removal after the wait has
364// passed ends in the caller's busy error, like every other way the wait
365// runs out, never the open's own error. Every open here meets that race:
366// it fails as an unlinked file does and the name is gone.
367func TestHeldStageLockOpenRaceAfterTheWaitIsBusy(t *testing.T) {
368 prevWait, prevOpen := heldLockWait, lockNamedOpenAt
369 t.Cleanup(func() { heldLockWait, lockNamedOpenAt = prevWait, prevOpen })
370 const wait = 150 * time.Millisecond
371 heldLockWait = wait
372 var opens atomic.Int32
373 lockNamedOpenAt = func(dir int, name string, flags int, mode os.FileMode) (*os.File, error) {
374 opens.Add(1)
375 return nil, &os.PathError{Op: "open", Path: name, Err: errors.New("not a regular file with one link")}
376 }
377 ran := false
378 start := time.Now()
379 err := withHeldStageLock(t.TempDir(), hsHost, func() error { ran = true; return nil })
380 elapsed := time.Since(start)
381 if !errors.Is(err, errHeldLockBusy) {
382 t.Fatalf("every open raced a removal until past the wait: %v, want errHeldLockBusy", err)
383 }
384 if ran {
385 t.Fatal("fn ran without the lock")
386 }
387 if elapsed < wait || elapsed > wait+time.Second {
388 t.Fatalf("gave up after %v, want about %v", elapsed, wait)
389 }
390 if opens.Load() < 2 {
391 t.Fatalf("only %d opens; the race was not retried", opens.Load())
392 }
393}
394