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 hostexec
4
5import (
6 "context"
7 "encoding/json"
8 "errors"
9 "os"
10 "path/filepath"
11 "strings"
12 "sync"
13 "sync/atomic"
14 "testing"
15 "time"
16
17 "github.com/carlosframework/platform/internal/hostclaim"
18 "github.com/carlosframework/platform/internal/hostguard"
19 "github.com/carlosframework/platform/internal/hostquarantine"
20 "github.com/carlosframework/platform/internal/release"
21)
22
23type alarm struct {
24 at int64
25 ch chan time.Time
26}
27type testClock struct {
28 mu sync.Mutex
29 wall time.Time
30 boot int64
31 alarms []alarm
32}
33
34func (k *testClock) Now() (time.Time, int64, error) {
35 k.mu.Lock()
36 defer k.mu.Unlock()
37 return k.wall, k.boot, nil
38}
39func (k *testClock) After(d time.Duration) <-chan time.Time {
40 k.mu.Lock()
41 defer k.mu.Unlock()
42 ch := make(chan time.Time, 1)
43 if d <= 0 {
44 ch <- k.wall
45 } else {
46 k.alarms = append(k.alarms, alarm{k.boot + int64(d), ch})
47 }
48 return ch
49}
50func (k *testClock) advance(d time.Duration) {
51 k.mu.Lock()
52 defer k.mu.Unlock()
53 k.boot += int64(d)
54 k.wall = k.wall.Add(d)
55 rest := k.alarms[:0]
56 for _, a := range k.alarms {
57 if a.at <= k.boot {
58 a.ch <- k.wall
59 } else {
60 rest = append(rest, a)
61 }
62 }
63 k.alarms = rest
64}
65
66type claims struct {
67 mu sync.Mutex
68 q hostclaim.Claim
69 err error
70}
71
72func (r *claims) Read(context.Context, string) (hostclaim.Observed, error) {
73 r.mu.Lock()
74 defer r.mu.Unlock()
75 raw, _ := json.Marshal(r.q)
76 var q hostclaim.Claim
77 _ = json.Unmarshal(raw, &q)
78 return hostclaim.Observed{Host: q.Host, Claim: &q, ETag: "claim-etag"}, r.err
79}
80
81type directory struct {
82 state string
83 err error
84}
85
86func (d directory) Lookup(context.Context, string) (hostquarantine.Actor, error) {
87 return hostquarantine.Actor{Enrollment: "enroll", State: d.state}, d.err
88}
89
90type delayedStore struct {
91 release.Store
92 mu sync.Mutex
93 calls int
94 onPut func(int)
95 failAt int
96 called chan int
97}
98
99func (s *delayedStore) Put(ctx context.Context, key string, b []byte, etag string) (string, error) {
100 s.mu.Lock()
101 s.calls++
102 n := s.calls
103 s.mu.Unlock()
104 if s.called != nil {
105 s.called <- n
106 }
107 if s.onPut != nil {
108 s.onPut(n)
109 }
110 if n == s.failAt {
111 return "", errors.New("ambiguous network failure")
112 }
113 // Deliberately permit a late response even after cancellation.
114 return s.Store.Put(context.Background(), key, b, etag)
115}
116func (s *delayedStore) DeleteIfMatch(ctx context.Context, key, etag string) error {
117 return release.DeleteIfMatch(ctx, s.Store, key, etag)
118}
119
120func fixture(t *testing.T) (Config, *claims, *testClock, *delayedStore, *atomic.Int32) {
121 t.Helper()
122 dir := t.TempDir()
123 for _, name := range []string{"hostguard", ".hostexec", "locks", "exec-locks"} {
124 if err := os.Mkdir(filepath.Join(dir, name), 0770); err != nil {
125 t.Fatal(err)
126 }
127 }
128 host := "shop.example.com"
129 inc, _ := hostclaim.Incarnation("bab", "jam", host, time.Time{})
130 q := hostclaim.Claim{V: 2, Host: host, Gen: 7, Holder: &hostclaim.Holder{Family: "instance", Key: "apps/bab/jam/instances/" + host + ".json", Account: "bab", App: "jam", Incarnation: inc, Location: "instances/" + host, Stamp: 3}, Op: hostclaim.Op{Kind: hostclaim.KindDeleting, ID: "op", Phase: "stop", Exec: "box", Participants: []string{"box"}}}
131 if err := q.Validate(); err != nil {
132 t.Fatal(err)
133 }
134 r := &claims{q: q}
135 clock := &testClock{wall: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC), boot: int64(time.Hour)}
136 store := &delayedStore{Store: release.NewDirStore(t.TempDir()), called: make(chan int, 20)}
137 cleaned := &atomic.Int32{}
138 cfg := Config{Binding: Binding{Box: "box", Enrollment: "enroll", Host: host, Incarnation: inc, Op: "op", BootID: "boot"}, Store: store, Guard: hostguard.Guard{DataDir: dir, LockDir: filepath.Join(dir, "locks"), Claims: r}, Directory: directory{state: "active"}, Clock: clock, LockDir: filepath.Join(dir, "exec-locks"), CloseAdmission: func() {}, CancelWork: func() {}, Cleanup: func(context.Context) error { cleaned.Add(1); return nil }}
139 return cfg, r, clock, store, cleaned
140}
141func ready(t *testing.T, c *Controller) State {
142 t.Helper()
143 ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
144 defer cancel()
145 s, err := c.Ready(ctx)
146 if err != nil {
147 t.Fatal(err)
148 }
149 return s
150}
151func closeController(t *testing.T, c *Controller) {
152 t.Helper()
153 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
154 defer cancel()
155 if err := c.Close(ctx); err != nil {
156 t.Error(err)
157 }
158}
159func until(t *testing.T, fn func() bool) {
160 t.Helper()
161 deadline := time.Now().Add(2 * time.Second)
162 for !fn() {
163 if time.Now().After(deadline) {
164 t.Fatal("condition did not become true")
165 }
166 time.Sleep(time.Millisecond)
167 }
168}
169
170func TestRenewalRequestStartAndIndependentExpiry(t *testing.T) {
171 cfg, _, clock, store, cleaned := fixture(t)
172 blocked := make(chan struct{})
173 store.onPut = func(n int) {
174 if n == 2 {
175 <-blocked
176 }
177 }
178 c, err := Open(t.Context(), cfg)
179 if err != nil {
180 t.Fatal(err)
181 }
182 defer closeController(t, c)
183 first := ready(t, c)
184 key, _ := Key(first.Host, first.Box)
185 b, _, err := store.Get(t.Context(), key)
186 if err != nil {
187 t.Fatal(err)
188 }
189 var wire map[string]any
190 _ = json.Unmarshal(b, &wire)
191 if len(wire) != 2 || wire["op"] != "op" {
192 t.Fatalf("wire %s", b)
193 }
194 until(t, func() bool { clock.mu.Lock(); defer clock.mu.Unlock(); return len(clock.alarms) >= 2 })
195 clock.advance(Renewal)
196 for {
197 select {
198 case n := <-store.called:
199 if n == 2 {
200 goto renewing
201 }
202 case <-time.After(2 * time.Second):
203 t.Fatal("no renewal")
204 }
205 }
206renewing:
207 clock.advance(65 * time.Second)
208 select {
209 case <-c.Lost():
210 case <-time.After(2 * time.Second):
211 t.Fatal("blocked PUT blocked watchdog")
212 }
213 if err := CheckLocal(cfg.Guard.DataDir, first.Binding, clock, cfg.LockDir); err == nil {
214 t.Fatal("writer admitted at deadline")
215 }
216 close(blocked)
217 <-c.done
218 if _, err := c.Ready(t.Context()); err == nil {
219 t.Fatal("late success revived controller")
220 }
221 until(t, func() bool { return cleaned.Load() >= 2 })
222}
223
224func TestRenewalUsesStartAndWallRollbackCannotExtend(t *testing.T) {
225 cfg, _, clock, store, _ := fixture(t)
226 store.onPut = func(n int) {
227 if n == 1 {
228 clock.advance(9 * time.Second)
229 }
230 }
231 c, err := Open(t.Context(), cfg)
232 if err != nil {
233 t.Fatal(err)
234 }
235 defer closeController(t, c)
236 s := ready(t, c)
237 if s.SafeUntil != int64(time.Hour+85*time.Second) {
238 t.Fatalf("response-relative deadline %d", s.SafeUntil)
239 }
240 clock.mu.Lock()
241 clock.wall = clock.wall.Add(-time.Hour)
242 clock.mu.Unlock()
243 clock.advance(76 * time.Second)
244 if err := CheckLocal(cfg.Guard.DataDir, s.Binding, clock, cfg.LockDir); err == nil {
245 t.Fatal("wall rollback extended deadline")
246 }
247}
248
249func TestAmbiguousPUTLatchesAndConditionalReleasePreservesSuccessor(t *testing.T) {
250 cfg, _, _, store, _ := fixture(t)
251 store.failAt = 1
252 c, err := Open(t.Context(), cfg)
253 if err != nil {
254 t.Fatal(err)
255 }
256 if _, err = c.Ready(t.Context()); err == nil {
257 t.Fatal("ambiguous PUT admitted")
258 }
259 closeController(t, c)
260 cfg, _, _, store, _ = fixture(t)
261 c, err = Open(t.Context(), cfg)
262 if err != nil {
263 t.Fatal(err)
264 }
265 s := ready(t, c)
266 key, _ := Key(s.Host, s.Box)
267 successor := []byte(`{"op":"successor","expires":"2030-01-01T00:00:00Z"}`)
268 if _, err = store.Store.Put(t.Context(), key, successor, s.LeaseETag); err != nil {
269 t.Fatal(err)
270 }
271 if err = c.Close(t.Context()); !errors.Is(err, release.ErrCASConflict) {
272 t.Fatalf("release = %v", err)
273 }
274 got, _, err := store.Get(t.Context(), key)
275 if err != nil || string(got) != string(successor) {
276 t.Fatal("deleted successor")
277 }
278}
279
280func TestControllerExclusionBootAndCC9(t *testing.T) {
281 cfg, r, clock, _, _ := fixture(t)
282 c, err := Open(t.Context(), cfg)
283 if err != nil {
284 t.Fatal(err)
285 }
286 s := ready(t, c)
287 ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond)
288 defer cancel()
289 if _, err = Open(ctx, cfg); !errors.Is(err, context.DeadlineExceeded) {
290 t.Fatalf("second controller = %v", err)
291 }
292 wrong := s.Binding
293 wrong.BootID = "next-boot"
294 if err = CheckLocal(cfg.Guard.DataDir, wrong, clock, cfg.LockDir); err == nil {
295 t.Fatal("inherited prior boot")
296 }
297 closeController(t, c)
298 if err = ClearStale(t.Context(), cfg); !errors.Is(err, ErrInactive) {
299 t.Fatalf("cleared active op: %v", err)
300 }
301 r.mu.Lock()
302 r.q.Op = hostclaim.Op{Kind: hostclaim.KindServing, Participants: []string{"box"}}
303 r.mu.Unlock()
304 if err = ClearStale(t.Context(), cfg); err != nil {
305 t.Fatal(err)
306 }
307 if _, err = os.Stat(filepath.Join(cfg.Guard.DataDir, ".hostexec", cfg.Binding.Host+".json")); !errors.Is(err, os.ErrNotExist) {
308 t.Fatal("stale state remains", err)
309 }
310 // No lease traffic for retained serving participants, even when enabled.
311 if _, err = Open(t.Context(), cfg); !errors.Is(err, ErrInactive) {
312 t.Fatalf("serving controller = %v", err)
313 }
314}
315
316func TestMalformedPendingMissingAndQuarantineRefuse(t *testing.T) {
317 cfg, _, clock, _, _ := fixture(t)
318 b := cfg.Binding
319 b.ControllerID = "controller"
320 if err := CheckLocal(cfg.Guard.DataDir, b, clock, cfg.LockDir); err == nil {
321 t.Fatal("missing state admitted")
322 }
323 p := filepath.Join(cfg.Guard.DataDir, ".hostexec", b.Host+".json")
324 if err := os.WriteFile(p, []byte(`{"v":1}`), 0660); err != nil {
325 t.Fatal(err)
326 }
327 if _, err := Open(t.Context(), cfg); err == nil {
328 t.Fatal("malformed state replaced")
329 }
330 if err := os.Remove(p); err != nil {
331 t.Fatal(err)
332 }
333 cfg.Directory = directory{state: "quarantined"}
334 if _, err := Open(t.Context(), cfg); err == nil {
335 t.Fatal("quarantined box admitted")
336 }
337}
338
339func TestLocalPathsPinnedAndSymlinkRefused(t *testing.T) {
340 cfg, _, _, _, _ := fixture(t)
341 outside := filepath.Join(t.TempDir(), "outside")
342 if err := os.WriteFile(outside, []byte("untouched"), 0600); err != nil {
343 t.Fatal(err)
344 }
345 record := filepath.Join(cfg.Guard.DataDir, ".hostexec", cfg.Binding.Host+".json")
346 if err := os.Symlink(outside, record); err != nil {
347 t.Fatal(err)
348 }
349 if _, err := Open(t.Context(), cfg); err == nil {
350 t.Fatal("symlink admitted")
351 }
352 b, _ := os.ReadFile(outside)
353 st, _ := os.Stat(outside)
354 if string(b) != "untouched" || st.Mode().Perm() != 0600 {
355 t.Fatal("changed symlink target")
356 }
357 if err := os.Remove(record); err != nil {
358 t.Fatal(err)
359 }
360 d, err := openLocal(cfg.Guard.DataDir)
361 if err != nil {
362 t.Fatal(err)
363 }
364 defer d.close()
365 old := filepath.Join(cfg.Guard.DataDir, ".hostexec")
366 if err = os.Rename(old, old+".pinned"); err != nil {
367 t.Fatal(err)
368 }
369 if err = os.Mkdir(old, 0770); err != nil {
370 t.Fatal(err)
371 }
372 s := State{V: 1, Binding: cfg.Binding, State: "pending", Expires: time.Now().Add(TTL), SafeUntil: 99}
373 s.ControllerID = "controller"
374 if err = d.write(s); err != nil {
375 t.Fatal(err)
376 }
377 if _, err = os.Stat(filepath.Join(old+".pinned", s.Host+".json")); err != nil {
378 t.Fatal(err)
379 }
380 if _, err = os.Stat(filepath.Join(old, s.Host+".json")); !errors.Is(err, os.ErrNotExist) {
381 t.Fatal("followed directory replacement")
382 }
383 d.sync = func(int) error { return errors.New("fsync failure") }
384 if err = d.write(s); err == nil {
385 t.Fatal("ignored persistence failure")
386 }
387 entries, _ := os.ReadDir(old + ".pinned")
388 for _, e := range entries {
389 if strings.HasPrefix(e.Name(), ".tmp-") {
390 t.Fatal("leaked temp file")
391 }
392 }
393}
394
395func TestSuccessfulRenewalDurablyExtendsFromItsRequestStart(t *testing.T) {
396 cfg, _, clock, store, _ := fixture(t)
397 c, err := Open(t.Context(), cfg)
398 if err != nil {
399 t.Fatal(err)
400 }
401 defer closeController(t, c)
402 first := ready(t, c)
403 until(t, func() bool {
404 clock.mu.Lock()
405 defer clock.mu.Unlock()
406 for _, a := range clock.alarms {
407 if a.at == clock.boot+int64(Renewal) {
408 return true
409 }
410 }
411 return false
412 })
413 clock.advance(Renewal)
414 until(t, func() bool {
415 c.mu.Lock()
416 defer c.mu.Unlock()
417 return c.state.SafeUntil == first.SafeUntil+int64(Renewal)
418 })
419 renewed := ready(t, c)
420 if renewed.LeaseETag == first.LeaseETag || !renewed.Expires.Equal(first.Expires.Add(Renewal)) {
421 t.Fatalf("renewal did not advance coherent tuple: %+v", renewed)
422 }
423 d, err := openLocal(cfg.Guard.DataDir)
424 if err != nil {
425 t.Fatal(err)
426 }
427 defer d.close()
428 disk, err := d.read(renewed.Host)
429 if err != nil || disk != renewed {
430 t.Fatalf("renewal not durable: %+v %v", disk, err)
431 }
432 key, _ := Key(renewed.Host, renewed.Box)
433 raw, etag, err := store.Get(t.Context(), key)
434 if err != nil || etag != renewed.LeaseETag {
435 t.Fatalf("renewed remote ownership: %q %v", etag, err)
436 }
437 var wire Record
438 if err = json.Unmarshal(raw, &wire); err != nil || wire.Op != renewed.Op || !wire.Expires.Equal(renewed.Expires) {
439 t.Fatalf("renewed wire: %+v %v", wire, err)
440 }
441}
442