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 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "slices"
9 "sync"
10 "testing"
11 "time"
12
13 "github.com/carlosframework/platform/internal/activator"
14 "github.com/carlosframework/platform/internal/hostbox"
15 "github.com/carlosframework/platform/internal/hostclaim"
16 "github.com/carlosframework/platform/internal/hostguard"
17 "github.com/carlosframework/platform/internal/stopevidence"
18)
19
20type writersJournalFake struct {
21 mu sync.Mutex
22 inv hostbox.LaunchInventory
23 settled []string
24 reads int
25 err error
26}
27
28func (j *writersJournalFake) Read(_ context.Context, t hostguard.FenceTarget) (hostbox.LaunchInventory, error) {
29 j.mu.Lock()
30 defer j.mu.Unlock()
31 j.reads++
32 if j.err != nil {
33 return hostbox.LaunchInventory{}, j.err
34 }
35 if !hostbox.SameLaunchOwner(j.inv.Target, t) {
36 return hostbox.LaunchInventory{}, hostbox.ErrChanged
37 }
38 out := j.inv
39 out.Reservations = slices.Clone(j.inv.Reservations)
40 return out, nil
41}
42
43func (j *writersJournalFake) Settled(_ context.Context, r hostbox.LaunchReservation) error {
44 j.mu.Lock()
45 defer j.mu.Unlock()
46 for i := range j.inv.Reservations {
47 if j.inv.Reservations[i].ID == r.ID {
48 j.inv.Reservations[i].State = "settled"
49 j.settled = append(j.settled, r.ID)
50 return nil
51 }
52 }
53 return hostbox.ErrChanged
54}
55
56type writersCgroupFake struct {
57 mu sync.Mutex
58 populated bool
59 kills int
60 killErr error
61}
62
63func (c *writersCgroupFake) Populated() (bool, error) {
64 c.mu.Lock()
65 defer c.mu.Unlock()
66 return c.populated, nil
67}
68
69func (c *writersCgroupFake) KillAndEmpty(context.Context) error {
70 c.mu.Lock()
71 defer c.mu.Unlock()
72 c.kills++
73 if c.killErr != nil {
74 return c.killErr
75 }
76 c.populated = false
77 return nil
78}
79
80// producerProbe is one registered producer: done closes when it finished.
81type producerProbe struct {
82 ctx context.Context
83 done chan struct{}
84}
85
86// runProducer registers id and runs it until its context ends, as a role
87// launch, a timer delivery or the replicator does.
88func runProducer(t *testing.T, p *ClaimProducers, target hostguard.FenceTarget, id string) producerProbe {
89 t.Helper()
90 ctx, finish, err := p.Register(context.Background(), target, id)
91 if err != nil {
92 t.Fatalf("register %s: %v", id, err)
93 }
94 done := make(chan struct{})
95 go func() {
96 <-ctx.Done()
97 finish(nil)
98 close(done)
99 }()
100 return producerProbe{ctx: ctx, done: done}
101}
102
103type writersFixture struct {
104 wake *ClaimWakeAssembly
105 attempt *claimWakeAttempt
106 target hostguard.FenceTarget
107 journal *writersJournalFake
108 cgroup *writersCgroupFake
109 main producerProbe
110 timer producerProbe
111 rep producerProbe
112 pub producerProbe
113 producer *ClaimProducers
114}
115
116func newWritersFixture(t *testing.T) *writersFixture {
117 t.Helper()
118 target := hostguard.FenceTarget{Host: "shop.example", Incarnation: "inc-1", Stamp: 11, Session: 13, Box: "box-1", Enrollment: "enrollment-1", BootID: "boot-1", ControllerID: "controller-1", DirectoryRevision: "7"}
119 claim := &hostclaim.Claim{V: 2, Host: target.Host, Gen: 4,
120 Holder: &hostclaim.Holder{Incarnation: "inc-1", Stamp: 11, Session: 13, WriterBox: "box-1", Generation: "g-13", Location: "loc"},
121 Op: hostclaim.Op{Kind: hostclaim.KindServing}}
122 observed := hostclaim.Observed{Host: target.Host, Claim: claim, ETag: "etag-1"}
123 producers, err := NewClaimProducers(target, "start-1", func(context.Context, hostguard.FenceTarget, string) error { return nil })
124 if err != nil {
125 t.Fatal(err)
126 }
127 journal := &writersJournalFake{inv: hostbox.LaunchInventory{Target: target, Reservations: []hostbox.LaunchReservation{
128 {ID: "attempt-1:main", Role: "main", State: "started", Target: target, PID: 77},
129 {ID: "attempt-0:main", Role: "main", State: "settled", Target: target},
130 }}}
131 cg := &writersCgroupFake{populated: true}
132 wake := &ClaimWakeAssembly{Producers: func(context.Context, hostclaim.Observed) (*ClaimProducers, error) { return producers, nil }}
133 live, cancel := context.WithCancel(context.Background())
134 t.Cleanup(cancel)
135 a := &claimWakeAttempt{owner: wake, observed: observed, inputs: ClaimLaunchInputs{DBPath: "/var/lib/carlos/shop.example/data.db"},
136 prelaunch: &activator.RoleLaunch{Target: target, ParentPath: "carlos/claim-1.local"}, writersJournal: journal, writersCgroup: cg,
137 ctx: live, cancel: cancel, done: make(chan struct{})}
138 f := &writersFixture{wake: wake, attempt: a, target: target, journal: journal, cgroup: cg, producer: producers}
139 f.main = runProducer(t, producers, target, "main")
140 f.timer = runProducer(t, producers, target, "timer-1")
141 f.rep = runProducer(t, producers, target, ClaimProducerReplicator)
142 f.pub = runProducer(t, producers, target, ClaimProducerPublisher)
143 return f
144}
145
146func closed(c <-chan struct{}) bool {
147 select {
148 case <-c:
149 return true
150 default:
151 return false
152 }
153}
154
155func TestStopWritersDrainsWritersAndKeepsTheReplicator(t *testing.T) {
156 f := newWritersFixture(t)
157 drained, err := f.attempt.StopWriters(t.Context())
158 if err != nil {
159 t.Fatal(err)
160 }
161 if !closed(f.main.done) || !closed(f.timer.done) {
162 t.Fatal("the role launch or the timer delivery was not joined")
163 }
164 if f.rep.ctx.Err() != nil || f.pub.ctx.Err() != nil || f.attempt.ctx.Err() != nil {
165 t.Fatal("the replicator, the publisher or the attempt was cancelled")
166 }
167 if !drained.FenceClosed || !drained.SpawnsSettled || !drained.WorkDrained || !drained.LaunchJoined || !drained.CgroupEmpty {
168 t.Fatalf("facts = %+v", drained)
169 }
170 if drained.Target != f.target || drained.Generation != "g-13" || drained.Cgroup != "carlos/claim-1.local" || !slices.Equal(drained.DBPaths, []string{"/var/lib/carlos/shop.example/data.db"}) {
171 t.Fatalf("witness = %+v", drained)
172 }
173 if !slices.Equal(drained.Drained, []string{"main", "timer-1"}) || !slices.Equal(drained.Retained, []string{ClaimProducerPublisher, ClaimProducerReplicator}) {
174 t.Fatalf("drained %v retained %v", drained.Drained, drained.Retained)
175 }
176 if !slices.Equal(f.journal.settled, []string{"attempt-1:main"}) || f.cgroup.kills != 1 {
177 t.Fatalf("settled %v kills %d", f.journal.settled, f.cgroup.kills)
178 }
179 if err := drained.For(f.attempt.observed); err != nil {
180 t.Fatalf("witness does not bind its own session: %v", err)
181 }
182}
183
184func TestStopWritersClosesAdmission(t *testing.T) {
185 f := newWritersFixture(t)
186 if _, err := f.attempt.StopWriters(t.Context()); err != nil {
187 t.Fatal(err)
188 }
189 for _, id := range []string{"main-2", "timer-2"} {
190 if _, _, err := f.producer.Register(context.Background(), f.target, id); !errors.Is(err, ErrClaimWritersStopped) {
191 t.Fatalf("%s registered after the writers stopped: %v", id, err)
192 }
193 if err := f.producer.Launch(context.Background(), f.target, id, func(context.Context) error { return nil }); !errors.Is(err, ErrClaimWritersStopped) {
194 t.Fatalf("%s launched after the writers stopped: %v", id, err)
195 }
196 }
197 // The files publisher is retained: it may still register (L13).
198 if _, finish, err := f.producer.Register(context.Background(), f.target, ClaimProducerFiles); err != nil {
199 t.Fatalf("files publisher refused: %v", err)
200 } else {
201 finish(nil)
202 }
203 if err := f.attempt.admitsWriters(); !errors.Is(err, ErrClaimWritersStopped) {
204 t.Fatalf("the attempt still admits writers: %v", err)
205 }
206}
207
208func TestStopWritersIsIdempotent(t *testing.T) {
209 f := newWritersFixture(t)
210 first, err := f.attempt.StopWriters(t.Context())
211 if err != nil {
212 t.Fatal(err)
213 }
214 reads, kills := f.journal.reads, f.cgroup.kills
215 second, err := f.attempt.StopWriters(t.Context())
216 if err != nil {
217 t.Fatal(err)
218 }
219 if !slices.Equal(first.Drained, second.Drained) || first.Target != second.Target || first.CheckedAt != second.CheckedAt {
220 t.Fatalf("second = %+v first = %+v", second, first)
221 }
222 if f.journal.reads != reads || f.cgroup.kills != kills {
223 t.Fatal("a stopped attempt repeated its effects")
224 }
225}
226
227func TestStopWritersRetriesAfterAFailure(t *testing.T) {
228 f := newWritersFixture(t)
229 f.cgroup.killErr = errors.New("cgroup busy")
230 if _, err := f.attempt.StopWriters(t.Context()); err == nil {
231 t.Fatal("a cgroup that would not empty passed")
232 }
233 if !closed(f.main.done) {
234 t.Fatal("the role was not joined before the cgroup step")
235 }
236 f.cgroup.killErr = nil
237 drained, err := f.attempt.StopWriters(t.Context())
238 if err != nil || !drained.CgroupEmpty {
239 t.Fatalf("retry = %+v %v", drained, err)
240 }
241}
242
243func TestStopWritersRefuses(t *testing.T) {
244 t.Run("a pending role intention", func(t *testing.T) {
245 f := newWritersFixture(t)
246 f.journal.inv.Reservations = append(f.journal.inv.Reservations, hostbox.LaunchReservation{ID: "attempt-2:main", State: "intended", Target: f.target})
247 if _, err := f.attempt.StopWriters(t.Context()); !errors.Is(err, ErrClaimStopInventory) {
248 t.Fatalf("got %v", err)
249 }
250 })
251 t.Run("a populated cgroup after the kill", func(t *testing.T) {
252 f := newWritersFixture(t)
253 f.cgroup.killErr = nil
254 f.attempt.writersCgroup = stuckCgroup{}
255 if _, err := f.attempt.StopWriters(t.Context()); !errors.Is(err, ErrClaimStopInventory) {
256 t.Fatalf("got %v", err)
257 }
258 })
259 t.Run("another session", func(t *testing.T) {
260 f := newWritersFixture(t)
261 next := *f.attempt.observed.Claim
262 holder := *next.Holder
263 holder.Session = 14
264 next.Holder = &holder
265 f.attempt.observed.Claim = &next
266 if _, err := f.attempt.StopWriters(t.Context()); !errors.Is(err, hostbox.ErrChanged) {
267 t.Fatalf("got %v", err)
268 }
269 if closed(f.main.done) {
270 t.Fatal("a mismatched session drained the writers")
271 }
272 })
273 t.Run("no launch record", func(t *testing.T) {
274 f := newWritersFixture(t)
275 f.attempt.prelaunch = nil
276 if _, err := f.attempt.StopWriters(t.Context()); err == nil {
277 t.Fatal("an attempt with no launch record stopped writers")
278 }
279 })
280 t.Run("after the full drain", func(t *testing.T) {
281 f := newWritersFixture(t)
282 req := StopRequest{Host: f.target.Host, Incarnation: f.target.Incarnation, InstallationStamp: f.target.Stamp, WriterSession: f.target.Session, Box: f.target.Box, Enrollment: f.target.Enrollment, BootID: f.target.BootID, ControllerID: f.target.ControllerID}
283 if _, err := f.producer.DrainClaim(t.Context(), req, "fence-1"); err != nil {
284 t.Fatal(err)
285 }
286 if _, err := f.attempt.StopWriters(t.Context()); !errors.Is(err, ErrClaimStopIdentity) {
287 t.Fatalf("got %v", err)
288 }
289 })
290}
291
292type stuckCgroup struct{}
293
294func (stuckCgroup) Populated() (bool, error) { return true, nil }
295func (stuckCgroup) KillAndEmpty(context.Context) error { return nil }
296
297func TestWritersWitnessBindsTheExactSession(t *testing.T) {
298 f := newWritersFixture(t)
299 drained, err := f.attempt.StopWriters(t.Context())
300 if err != nil {
301 t.Fatal(err)
302 }
303 for name, change := range map[string]func(*hostclaim.Holder){
304 "session": func(h *hostclaim.Holder) { h.Session++ },
305 "stamp": func(h *hostclaim.Holder) { h.Stamp++ },
306 "incarnation": func(h *hostclaim.Holder) { h.Incarnation = "inc-2" },
307 "generation": func(h *hostclaim.Holder) { h.Generation = "g-14" },
308 "writer box": func(h *hostclaim.Holder) { h.WriterBox = "box-2" },
309 } {
310 o := f.attempt.observed
311 c := *o.Claim
312 h := *c.Holder
313 change(&h)
314 c.Holder = &h
315 o.Claim = &c
316 if err := drained.For(o); !errors.Is(err, hostbox.ErrChanged) {
317 t.Fatalf("%s: %v", name, err)
318 }
319 }
320 if err := (ClaimWritersDrained{}).For(f.attempt.observed); !errors.Is(err, hostbox.ErrChanged) {
321 t.Fatalf("a zero witness passed: %v", err)
322 }
323}
324
325func TestOwnedReplicatorOutlivesTheActivatorCloseOnceWritersStop(t *testing.T) {
326 f := newWritersFixture(t)
327 if _, err := f.attempt.StopWriters(t.Context()); err != nil {
328 t.Fatal(err)
329 }
330 rep := &claimOwnedRep{attempt: f.attempt, owner: f.wake, host: f.target.Host}
331 ctx, cancel := context.WithTimeout(t.Context(), time.Second)
332 defer cancel()
333 if err := rep.Close(ctx); err != nil {
334 t.Fatal(err)
335 }
336 if f.attempt.ctx.Err() != nil || f.rep.ctx.Err() != nil {
337 t.Fatal("the activator's close released a writers-stopped attempt")
338 }
339}
340
341// R3's agent half: the replicator's descriptors are the difference between
342// this process's own table before and after it opens.
343func TestRecordReplicatorHintsKeepsOnlyTheNewDescriptors(t *testing.T) {
344 dir := t.TempDir()
345 db := filepath.Join(dir, "data.db")
346 if err := os.WriteFile(db, nil, 0o600); err != nil {
347 t.Fatal(err)
348 }
349 earlier, err := os.Open(db)
350 if err != nil {
351 t.Fatal(err)
352 }
353 defer earlier.Close()
354 var opened *os.File
355 defer func() {
356 if opened != nil {
357 opened.Close()
358 }
359 }()
360 hints, err := recordReplicatorHints(t.Context(), "/proc", os.Getpid(), db, func() error {
361 var err error
362 opened, err = os.Open(db)
363 return err
364 })
365 if err != nil {
366 t.Fatal(err)
367 }
368 if len(hints) != 1 || hints[0].FD != int(opened.Fd()) {
369 t.Fatalf("hints = %+v, want only fd %d", hints, opened.Fd())
370 }
371 failed := errors.New("open failed")
372 if _, err := recordReplicatorHints(t.Context(), "/proc", os.Getpid(), db, func() error { return failed }); !errors.Is(err, failed) {
373 t.Fatalf("got %v", err)
374 }
375}
376
377// The agent's whole step: StopWriters, then root's observation of the
378// witnessed session at the current claim, carrying the recorded hint.
379func TestClaimAssemblyWritersStoppedAsksRootForTheWitnessedSession(t *testing.T) {
380 f := newWritersFixture(t)
381 f.attempt.replicatorFDs = []stopevidence.DescriptorHint{{FD: 7, Device: 1, Inode: 2}}
382 f.wake.pending = map[string]*claimWakeAttempt{f.target.Host: f.attempt}
383 current := f.attempt.observed
384 a := ClaimAssembly{Wake: f.wake, stopLocal: func(context.Context) (claimStopLocal, error) {
385 return claimStopLocal{Actor: hostbox.Actor{Box: "box-1", Enrollment: "enrollment-1", Region: "eu-west-1", Revision: 9, Binding: "9"}, BootID: "boot-1", ControllerID: "controller-1", PID: 42, ProcessStart: "start-42"}, nil
386 }}
387 read := func(context.Context, string) (hostclaim.Observed, error) { return current, nil }
388 var got WritersStoppedRequest
389 collect := func(_ context.Context, r WritersStoppedRequest) (WritersStoppedEvidence, error) {
390 got = r
391 return WritersStoppedEvidence{Host: r.Host, WriterSession: r.WriterSession}, nil
392 }
393 drained, ev, err := a.writersStopped(t.Context(), f.target.Host, read, collect)
394 if err != nil {
395 t.Fatal(err)
396 }
397 if !drained.Valid() || ev.Host != f.target.Host || f.rep.ctx.Err() != nil {
398 t.Fatalf("drained %+v ev %+v", drained, ev)
399 }
400 if got.WriterSession != 13 || got.ExpectedClaimETag != "etag-1" || got.ExpectedDirectoryRevision != "7" || got.Region != "eu-west-1" || got.BootID != "boot-1" || len(got.Replicator) != 1 {
401 t.Fatalf("request = %+v", got)
402 }
403 t.Run("a later session", func(t *testing.T) {
404 c := *current.Claim
405 h := *c.Holder
406 h.Session = 14
407 c.Holder = &h
408 current.Claim = &c
409 if _, _, err := a.writersStopped(t.Context(), f.target.Host, read, collect); !errors.Is(err, hostbox.ErrChanged) {
410 t.Fatalf("got %v", err)
411 }
412 })
413 t.Run("another controller", func(t *testing.T) {
414 current = f.attempt.observed
415 b := a
416 b.stopLocal = func(context.Context) (claimStopLocal, error) {
417 return claimStopLocal{Actor: hostbox.Actor{Box: "box-1", Enrollment: "enrollment-1"}, BootID: "boot-1", ControllerID: "controller-2"}, nil
418 }
419 if _, _, err := b.writersStopped(t.Context(), f.target.Host, read, collect); !errors.Is(err, hostbox.ErrChanged) {
420 t.Fatalf("got %v", err)
421 }
422 })
423 t.Run("no attempt", func(t *testing.T) {
424 if _, _, err := a.writersStopped(t.Context(), "other.example", read, collect); !errors.Is(err, ErrClaimStopUnavailable) {
425 t.Fatalf("got %v", err)
426 }
427 })
428}
429
430func (j *writersJournalFake) CloseWriters(_ context.Context, target hostguard.FenceTarget) error {
431 j.mu.Lock()
432 defer j.mu.Unlock()
433 if j.err != nil {
434 return j.err
435 }
436 if !hostbox.SameLaunchOwner(j.inv.Target, target) {
437 return hostbox.ErrChanged
438 }
439 j.inv.WritersClosed = true
440 return nil
441}
442