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 unix
2
3package hostfiles
4
5import (
6 "context"
7 "errors"
8 "log"
9 "os"
10 "path/filepath"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15
16 "github.com/carlosframework/platform/internal/release"
17)
18
19type fakeWatcher struct {
20 ch chan event
21 mu sync.Mutex
22 degraded bool
23 repairs int
24 repairErr error // when nil, Repair succeeds and clears degraded
25}
26
27func newFake() *fakeWatcher { return &fakeWatcher{ch: make(chan event, 64)} }
28
29func (f *fakeWatcher) Events() <-chan event { return f.ch }
30func (f *fakeWatcher) Degraded() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.degraded }
31func (f *fakeWatcher) Close() error { return nil }
32func (f *fakeWatcher) Repair() error {
33 f.mu.Lock()
34 defer f.mu.Unlock()
35 f.repairs++
36 if f.repairErr == nil {
37 f.degraded = false
38 }
39 return f.repairErr
40}
41func (f *fakeWatcher) setDegraded(v bool) { f.mu.Lock(); f.degraded = v; f.mu.Unlock() }
42
43// eventsOnly publishes on events and nothing else: no repair scan and no
44// polling can satisfy a test that uses it. polled is the opposite.
45var (
46 eventsOnly = Options{Deadline: 40 * time.Millisecond, Scan: time.Hour, Poll: time.Hour}
47 polled = Options{Deadline: 40 * time.Millisecond, Scan: time.Hour, Poll: 40 * time.Millisecond}
48)
49
50// startRun runs the loop and returns once its first full scan has
51// finished, so nothing a test does afterwards can be found by that scan.
52func startRun(t *testing.T, p *Publisher, dir string, w watcher, o Options) (stop func() error) {
53 t.Helper()
54 first := make(chan struct{})
55 var once sync.Once
56 o.OnStats = func(SyncStats) { once.Do(func() { close(first) }) }
57 ctx, cancel := context.WithCancel(context.Background())
58 done := make(chan error, 1)
59 go func() { done <- run(ctx, p, dir, o, w) }()
60 select {
61 case <-first:
62 case err := <-done:
63 t.Fatalf("run ended before its first scan: %v", err)
64 case <-time.After(2 * time.Second):
65 t.Fatal("run never finished its first scan")
66 }
67 return func() error { cancel(); return <-done }
68}
69
70// startRunCounting is startRun with a callback on every full scan.
71func startRunCounting(t *testing.T, p *Publisher, dir string, w watcher, o Options, onScan func()) (stop func() error) {
72 t.Helper()
73 first := make(chan struct{})
74 var once sync.Once
75 o.OnStats = func(SyncStats) { onScan(); once.Do(func() { close(first) }) }
76 ctx, cancel := context.WithCancel(context.Background())
77 done := make(chan error, 1)
78 go func() { done <- run(ctx, p, dir, o, w) }()
79 select {
80 case <-first:
81 case <-time.After(2 * time.Second):
82 t.Fatal("run never finished its first scan")
83 }
84 return func() error { cancel(); return <-done }
85}
86
87// eventually polls until ok returns true or two seconds pass.
88func eventually(t *testing.T, what string, ok func() bool) {
89 t.Helper()
90 deadline := time.Now().Add(2 * time.Second)
91 for time.Now().Before(deadline) {
92 if ok() {
93 return
94 }
95 time.Sleep(10 * time.Millisecond)
96 }
97 t.Fatalf("timed out waiting for: %s", what)
98}
99
100func manifestHas(s release.Store, path, sha string) bool {
101 m, found, err := Load(context.Background(), s, pfx)
102 if err != nil || !found {
103 return false
104 }
105 e, ok := m.Entries[path]
106 return ok && !e.Deleted && e.SHA256 == sha
107}
108
109func TestRunPublishesATouchedFileAfterTheDeadline(t *testing.T) {
110 s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake()
111 p := open(t, s)
112 stop := startRun(t, p, dir, w, eventsOnly)
113 defer stop()
114 write(t, dir, "instance.key", "k1")
115 time.Sleep(3 * eventsOnly.Deadline)
116 if manifestHas(s, "instance.key", sum([]byte("k1"))) {
117 t.Fatal("published with no event: something other than the event path found it")
118 }
119 touched := time.Now()
120 w.ch <- event{rel: "instance.key"}
121 eventually(t, "instance.key published", func() bool { return manifestHas(s, "instance.key", sum([]byte("k1"))) })
122 if took := time.Since(touched); took < eventsOnly.Deadline/2 {
123 t.Fatalf("published %s after the event; the deadline is %s", took, eventsOnly.Deadline)
124 }
125}
126
127func TestRunPublishesADeleteAsATombstone(t *testing.T) {
128 s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake()
129 p := open(t, s)
130 write(t, dir, "a", "a")
131 stop := startRun(t, p, dir, w, eventsOnly) // the first scan publishes "a"
132 defer stop()
133 eventually(t, "a published", func() bool { return manifestHas(s, "a", sum([]byte("a"))) })
134 if err := os.Remove(filepath.Join(dir, "a")); err != nil {
135 t.Fatal(err)
136 }
137 w.ch <- event{rel: "a"}
138 eventually(t, "a tombstoned", func() bool {
139 m, _, _ := Load(context.Background(), s, pfx)
140 return m.Entries["a"].Deleted
141 })
142}
143
144func TestRunRescansOnOverflow(t *testing.T) {
145 s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake()
146 p := open(t, s)
147 stop := startRun(t, p, dir, w, eventsOnly)
148 defer stop()
149 write(t, dir, "missed.txt", "m") // no event for it: the queue overflowed
150 time.Sleep(3 * eventsOnly.Deadline)
151 if manifestHas(s, "missed.txt", sum([]byte("m"))) {
152 t.Fatal("found before the rescan was asked for")
153 }
154 w.ch <- event{rescan: true}
155 eventually(t, "missed.txt found by the rescan", func() bool { return manifestHas(s, "missed.txt", sum([]byte("m"))) })
156}
157
158// A2-5 residual: with a watch missing, changes are found by polling, not
159// by waiting for the ten-minute scan.
160func TestRunPollsWhileTheWatcherIsDegradedAndKeepsTryingToRepair(t *testing.T) {
161 s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake()
162 w.setDegraded(true)
163 w.repairErr = errors.New("watch limit reached")
164 p := open(t, s)
165 stop := startRun(t, p, dir, w, polled)
166 defer stop()
167 write(t, dir, "quiet.txt", "q") // no event at all
168 eventually(t, "quiet.txt found by polling", func() bool { return manifestHas(s, "quiet.txt", sum([]byte("q"))) })
169 eventually(t, "more than one repair attempt", func() bool { w.mu.Lock(); defer w.mu.Unlock(); return w.repairs > 1 })
170}
171
172// The other half: once repair succeeds, polling stops. The loop runs
173// with a short polling period, so if it kept polling the scan count would
174// keep climbing; it must scan once more for what changed meanwhile and
175// then go quiet.
176func TestRunScansOnceAfterARepairThenStopsPolling(t *testing.T) {
177 s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake()
178 w.setDegraded(true)
179 w.repairErr = errors.New("not yet")
180 p := open(t, s)
181 scans := 0
182 var mu sync.Mutex
183 count := func() int { mu.Lock(); defer mu.Unlock(); return scans }
184 stop := startRunCounting(t, p, dir, w, polled, func() { mu.Lock(); scans++; mu.Unlock() })
185 defer stop()
186
187 // While repair fails, it polls: the count climbs.
188 n := count()
189 eventually(t, "polling while degraded", func() bool { return count() >= n+3 })
190
191 write(t, dir, "during.txt", "d")
192 w.mu.Lock()
193 w.repairErr = nil // the next Repair succeeds and clears degraded
194 w.mu.Unlock()
195 eventually(t, "the repair", func() bool { return !w.Degraded() })
196 eventually(t, "during.txt found by the post-repair scan", func() bool { return manifestHas(s, "during.txt", sum([]byte("d"))) })
197
198 time.Sleep(2 * polled.Poll) // let any scan already under way finish
199 n = count()
200 time.Sleep(8 * polled.Poll)
201 if got := count(); got != n {
202 t.Fatalf("%d more scans in eight polling periods after the repair; polling should have stopped", got-n)
203 }
204}
205
206// A watcher whose reader dies closes its channel. The loop must fall back
207// to polling, not spin and not stop publishing.
208func TestRunPollsAfterTheWatcherStops(t *testing.T) {
209 s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake()
210 p := open(t, s)
211 stop := startRun(t, p, dir, w, polled)
212 defer stop()
213 close(w.ch)
214 write(t, dir, "after.txt", "a")
215 eventually(t, "after.txt found by polling", func() bool { return manifestHas(s, "after.txt", sum([]byte("a"))) })
216}
217
218func TestRunWithNoWatcherPolls(t *testing.T) {
219 s, dir := release.NewDirStore(t.TempDir()), t.TempDir()
220 p := open(t, s)
221 stop := startRun(t, p, dir, nil, polled)
222 defer stop()
223 write(t, dir, "x", "x")
224 eventually(t, "x found by polling", func() bool { return manifestHas(s, "x", sum([]byte("x"))) })
225}
226
227func TestRunStopsWhenSuperseded(t *testing.T) {
228 s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake()
229 p, other := open(t, s), open(t, s)
230 ctx, cancel := context.WithCancel(context.Background())
231 defer cancel()
232 done := make(chan error, 1)
233 go func() { done <- run(ctx, p, dir, eventsOnly, w) }()
234 stage(t, other, "theirs", "t")
235 commit(t, other)
236 write(t, dir, "mine", "m")
237 w.ch <- event{rel: "mine"}
238 select {
239 case err := <-done:
240 if !errors.Is(err, ErrSuperseded) {
241 t.Fatalf("err = %v, want ErrSuperseded", err)
242 }
243 case <-time.After(2 * time.Second):
244 t.Fatal("run did not stop after another writer published")
245 }
246}
247
248func TestRunIgnoresAnEventForAPathThatEscapes(t *testing.T) {
249 s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake()
250 p := open(t, s)
251 stop := startRun(t, p, dir, w, eventsOnly)
252 w.ch <- event{rel: "../outside"}
253 write(t, dir, "ok", "ok")
254 w.ch <- event{rel: "ok"}
255 eventually(t, "ok published", func() bool { return manifestHas(s, "ok", sum([]byte("ok"))) })
256 if err := stop(); !errors.Is(err, context.Canceled) {
257 t.Fatalf("run ended with %v; a bad path must be skipped, not fatal", err)
258 }
259}
260
261// logCapture is a log.SetOutput target that is safe to write from the loop.
262type logCapture struct {
263 mu sync.Mutex
264 b strings.Builder
265}
266
267func (l *logCapture) Write(p []byte) (int, error) {
268 l.mu.Lock()
269 defer l.mu.Unlock()
270 return l.b.Write(p)
271}
272func (l *logCapture) count(sub string) int {
273 l.mu.Lock()
274 defer l.mu.Unlock()
275 return strings.Count(l.b.String(), sub)
276}
277
278func captureLog(t *testing.T) *logCapture {
279 t.Helper()
280 c := &logCapture{}
281 log.SetOutput(c)
282 t.Cleanup(func() { log.SetOutput(os.Stderr) })
283 return c
284}
285
286// A repair is a tree walk: a cause that lasts must cost one attempt and
287// one log line per polling period at most, not one per tick.
288func TestRunRepairsAtMostOncePerPollAndLogsTheTransitionOnce(t *testing.T) {
289 logs := captureLog(t)
290 s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake()
291 w.setDegraded(true)
292 w.repairErr = errors.New("watch limit reached")
293 p := open(t, s)
294 stop := startRun(t, p, dir, w, polled)
295 time.Sleep(20 * polled.Poll)
296 stop()
297 w.mu.Lock()
298 n := w.repairs
299 w.mu.Unlock()
300 // Twenty periods; a tick is a quarter of the deadline, so per-tick
301 // would be ~80. The bounds are wide so a loaded CI runner cannot flake.
302 if n < 2 || n > 30 {
303 t.Fatalf("%d repair attempts in twenty polling periods; want about twenty", n)
304 }
305 if got := logs.count("watch coverage is missing"); got != 1 {
306 t.Fatalf("coverage-missing logged %d times, want once", got)
307 }
308 if got := logs.count("still missing"); got != 0 {
309 t.Fatalf("a per-attempt line was logged %d times", got)
310 }
311}
312
313func TestRunLogsWhenCoverageIsRestored(t *testing.T) {
314 logs := captureLog(t)
315 s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake()
316 w.setDegraded(true)
317 p := open(t, s) // Repair succeeds on its first attempt
318 stop := startRun(t, p, dir, w, polled)
319 eventually(t, "restored line", func() bool { return logs.count("watch coverage restored") == 1 })
320 stop()
321 if got := logs.count("watch coverage is missing"); got != 1 {
322 t.Fatalf("missing logged %d times, want once", got)
323 }
324}
325
326// failingPuts refuses every object upload, so no scan of a directory with a
327// file in it can finish.
328type failingPuts struct {
329 release.Store
330 mu sync.Mutex
331 puts int
332}
333
334func (f *failingPuts) Put(ctx context.Context, key string, body []byte, etag string) (string, error) {
335 if strings.HasSuffix(key, "manifest.json") {
336 return f.Store.Put(ctx, key, body, etag)
337 }
338 f.mu.Lock()
339 f.puts++
340 f.mu.Unlock()
341 return "", errors.New("store unavailable")
342}
343
344func TestRunRetriesAFailingScanOncePerPollAndLogsItOnce(t *testing.T) {
345 logs := captureLog(t)
346 fs := &failingPuts{Store: release.NewDirStore(t.TempDir())}
347 dir := t.TempDir()
348 write(t, dir, "f", "v")
349 p := open(t, fs)
350 ctx, cancel := context.WithCancel(context.Background())
351 done := make(chan error, 1)
352 go func() { done <- run(ctx, p, dir, polled, newFake()) }()
353 time.Sleep(20 * polled.Poll)
354 cancel()
355 <-done
356 fs.mu.Lock()
357 n := fs.puts
358 fs.mu.Unlock()
359 if n < 2 || n > 30 { // per-tick would be ~80; see the repair test
360 t.Fatalf("%d scan attempts in twenty polling periods; want about twenty", n)
361 }
362 if got := logs.count("scan failing"); got != 1 {
363 t.Fatalf("the failure was logged %d times, want once", got)
364 }
365}
366
367func TestRunWithZeroOptionsUsesTheDefaultsAndPublishesOnItsFirstScan(t *testing.T) {
368 s, dir := release.NewDirStore(t.TempDir()), t.TempDir()
369 write(t, dir, "a", "a")
370 p := open(t, s)
371 ctx, cancel := context.WithCancel(context.Background())
372 done := make(chan error, 1)
373 go func() { done <- run(ctx, p, dir, Options{}, nil) }()
374 eventually(t, "a published", func() bool { return manifestHas(s, "a", sum([]byte("a"))) })
375 cancel()
376 if err := <-done; !errors.Is(err, context.Canceled) {
377 t.Fatal(err)
378 }
379}
380