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 host
4
5import (
6 "bytes"
7 "context"
8 "crypto/sha256"
9 "encoding/hex"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "os"
14 "path/filepath"
15 "slices"
16 "strings"
17 "testing"
18 "time"
19
20 "github.com/carlosframework/platform/internal/activator"
21 "github.com/carlosframework/platform/internal/hostbox"
22 "github.com/carlosframework/platform/internal/hostclaim"
23 "github.com/carlosframework/platform/internal/hostguard"
24 "github.com/carlosframework/platform/internal/hostrelocate"
25)
26
27// stopProbeActivator is a real activator.Service's fences, the calls
28// recorded in order. Replacing svc is an agent restart: the activator's
29// fences live only in its process. failNext, when set, is the next
30// FenceAndStopAs's answer instead of the service's.
31type stopProbeActivator struct {
32 order *[]string
33 svc *activator.Service
34 failNext error
35}
36
37func newStopProbeActivator(order *[]string) *stopProbeActivator {
38 return &stopProbeActivator{order: order, svc: &activator.Service{}}
39}
40
41func (a *stopProbeActivator) ProbeWake(context.Context, activator.ProbeConfig) (activator.ProbeResult, error) {
42 return activator.ProbeResult{}, errors.New("not a probe")
43}
44
45func (a *stopProbeActivator) FenceAndStopAs(ctx context.Context, host string, generation uint64, owner string) (*activator.StopEvidence, error) {
46 *a.order = append(*a.order, "fence-as")
47 if err := a.failNext; err != nil {
48 a.failNext = nil
49 return nil, err
50 }
51 return a.svc.FenceAndStopAs(ctx, host, generation, owner)
52}
53
54func (a *stopProbeActivator) LiftFenceAs(host string, generation uint64, owner string) bool {
55 *a.order = append(*a.order, "lift")
56 return a.svc.LiftFenceAs(host, generation, owner)
57}
58
59// foreignFence installs another operation's plain fence on a.svc.
60func (a *stopProbeActivator) foreignFence(t *testing.T, host string, generation uint64) {
61 t.Helper()
62 ctx, cancel := context.WithTimeout(t.Context(), time.Second)
63 defer cancel()
64 if _, err := a.svc.FenceAndStop(ctx, host, generation); err != nil && !errors.Is(err, activator.ErrStopUnproven) {
65 t.Fatal(err)
66 }
67}
68
69// stopProbeFixture is a target at relocating/probing whose probe passed
70// with session 12: the guard activated at the install stamp, main's
71// Wake.Stop standing for root's writer-session stop (it fences and
72// completes the stop in the guard journal with a receipt), and the probe
73// stop's provider bound to them.
74type stopProbeFixture struct {
75 t *testing.T
76 order []string
77 act *stopProbeActivator
78 a ClaimAssembly
79 cfg RelocationAssemblyConfig
80 obs hostclaim.Observed
81 receipt []byte
82 target hostguard.FenceTarget
83}
84
85func newStopProbeFixture(t *testing.T) *stopProbeFixture {
86 t.Helper()
87 ctx := t.Context()
88 const host, box = "shop.example", "dst"
89 created := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
90 inc, err := hostclaim.Incarnation("acct", "app", host, created)
91 if err != nil {
92 t.Fatal(err)
93 }
94 probe, _ := json.Marshal(hostclaim.Probe{Box: box, Session: 12, At: created.Add(time.Hour)})
95 f := &stopProbeFixture{t: t}
96 f.obs = hostclaim.Observed{Host: host, ETag: "e1", Claim: &hostclaim.Claim{V: 2, Gen: 30, Host: host,
97 Holder: &hostclaim.Holder{Family: "instance", Key: "apps/acct/app/instances/" + host + ".json", Account: "acct", App: "app", Incarnation: inc, CreatedAt: created,
98 Location: "instances/" + host, Stamp: 9, Session: 12, Generation: "P1", WriterBox: box, Admissions: []hostclaim.AdmissionRecord{{Session: 12, Box: box, Generation: "P1", Mode: "cold", Region: "ie"}}},
99 Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseRelocatingProbing, Exec: box, Participants: []string{"src", box}, Journal: map[string]string{"probe": string(probe)}}}}
100 dataDir := t.TempDir()
101 if err := os.Mkdir(filepath.Join(dataDir, "hostguard"), 0o770); err != nil {
102 t.Fatal(err)
103 }
104 guard := hostguard.Guard{DataDir: dataDir, LockDir: t.TempDir(), Claims: &guardClaims{obs: f.obs}}
105 if err := guard.Activate(ctx, host, box, nil); err != nil {
106 t.Fatal(err)
107 }
108 f.target = hostguard.FenceTarget{FenceID: "fence-1", Region: "ie", Host: host, Incarnation: inc, Stamp: 9, Session: 12, Op: "rel-1", Phase: hostclaim.PhaseRelocatingProbing,
109 ObligationID: hostguard.WriterStopObligation(12), Box: box, Enrollment: "enr", BootID: "boot", ControllerID: "ctl", ClaimETag: "e1", DirectoryRevision: "1", RosterRevision: 1}
110 at := created.Add(2 * time.Hour)
111 f.receipt = mustMarshalStopReceipt(StopReceipt{V: 1, ID: "r1", Kind: StopReceiptStopped, Region: "ie", ClaimETag: "e1", ObligationID: f.target.ObligationID, FenceID: "fence-1", Host: host, Incarnation: inc,
112 InstallationStamp: 9, WriterSession: 12, OperationID: "rel-1", Phase: hostclaim.PhaseRelocatingProbing, Box: box, Enrollment: "enr", BootID: "boot", ControllerID: "ctl", DirectoryRevision: testReceiptNumeral(f.target), Binding: "1", EvidenceAt: at})
113 wake := &ClaimWakeAssembly{Stop: func(ctx context.Context, o hostclaim.Observed) error {
114 f.order = append(f.order, "wake-stop")
115 // Root's writer-session stop: deterministic, so a repeat meets the
116 // same, already completed, fence.
117 if got, err := guard.CompletedWriterStops(ctx, host, hostguard.Captured{Incarnation: inc, Stamp: 9}, "rel-1", box); err == nil && len(got) == 1 {
118 return nil
119 }
120 if _, err := guard.BeginFence(ctx, f.target); err != nil {
121 return err
122 }
123 return guard.CompleteFence(ctx, f.target, f.receipt, at, func(context.Context, hostguard.FenceTarget, []byte, time.Time) error { return nil })
124 }}
125 f.act = newStopProbeActivator(&f.order)
126 f.a = ClaimAssembly{Guard: guard, Wake: wake, stopLocal: func(context.Context) (claimStopLocal, error) {
127 return claimStopLocal{Actor: hostbox.Actor{Box: box, Enrollment: "enr", Region: "ie", Revision: 1, Binding: "1"}, BootID: "boot", ControllerID: "ctl", PID: 100, ProcessStart: "5000"}, nil
128 }}
129 f.cfg = RelocationAssemblyConfig{Activator: f.act, DataDir: dataDir}
130 return f
131}
132
133func (f *stopProbeFixture) stop() (hostclaim.ProbeStop, error) {
134 return f.a.relocationStopProbe(f.cfg)(f.t.Context(), f.obs)
135}
136
137// marker is the probe stop's persisted owner for rel-1.
138func (f *stopProbeFixture) marker() relocationProbeFence {
139 f.t.Helper()
140 path, err := relocationProbeFencePath(f.cfg.DataDir, "rel-1")
141 if err != nil {
142 f.t.Fatal(err)
143 }
144 rec, ok, err := readRelocationProbeFence(path)
145 if err != nil || !ok {
146 f.t.Fatalf("no probe-fence marker: %v", err)
147 }
148 return rec
149}
150
151// Ruling 49 steps 1 to 3 with ruling 75's owned fence, against the real
152// activator: the owner is persisted, then the fence goes in before root is
153// asked; an unproven stop (the probe's entry is gone) still asks root;
154// root's receipt is the guard journal's writer-session stop of the probe's
155// session; a repeat meets its own fence and returns the same ProbeStop;
156// another operation's fence and an uninstalled fence stop everything with
157// nothing asked of root and the other fence as it was.
158func TestRelocationStopProbeFencesStopsAndReadsRootsReceipt(t *testing.T) {
159 sum := func(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) }
160 const host = "shop.example"
161 t.Run("the probe's session", func(t *testing.T) {
162 f := newStopProbeFixture(t)
163 s, err := f.stop()
164 if err != nil {
165 t.Fatal(err)
166 }
167 if want := (hostclaim.ProbeStop{Box: "dst", Session: 12, Receipt: "fence-1", ReceiptSHA256: sum(f.receipt)}); s != want {
168 t.Fatalf("stop %+v, want %+v", s, want)
169 }
170 if want := []string{"fence-as", "wake-stop"}; !slices.Equal(f.order, want) {
171 t.Fatalf("order %v, want %v", f.order, want)
172 }
173 m := f.marker()
174 if m.Host != host || m.Session != 12 || len(m.Owner) != 32 || f.act.svc.FenceGeneration(host) != 12 {
175 t.Fatalf("marker %+v, fence %d", m, f.act.svc.FenceGeneration(host))
176 }
177 // A repeat (the claim CAS lost, or a later pass) meets its own
178 // fence under the same owner.
179 f.order = nil
180 again, err := f.stop()
181 if err != nil || again != s {
182 t.Fatalf("repeat: %+v %v", again, err)
183 }
184 if want := []string{"fence-as", "wake-stop"}; !slices.Equal(f.order, want) || f.marker() != m {
185 t.Fatalf("repeat order %v, marker %+v", f.order, f.marker())
186 }
187 })
188 for _, gen := range []uint64{5, 12, 20} {
189 t.Run(fmt.Sprintf("another operation's fence at %d", gen), func(t *testing.T) {
190 f := newStopProbeFixture(t)
191 f.act.foreignFence(t, host, gen)
192 if _, err := f.stop(); !errors.Is(err, activator.ErrFenceHeld) {
193 t.Fatalf("got %v, want ErrFenceHeld", err)
194 }
195 if want := []string{"fence-as"}; !slices.Equal(f.order, want) || f.act.svc.FenceGeneration(host) != gen {
196 t.Fatalf("root was asked or the fence changed: %v, fence %d", f.order, f.act.svc.FenceGeneration(host))
197 }
198 })
199 }
200 t.Run("no fence installed", func(t *testing.T) {
201 f := newStopProbeFixture(t)
202 f.act.failNext = activator.ErrFenceNotInstalled
203 if _, err := f.stop(); !errors.Is(err, activator.ErrFenceNotInstalled) {
204 t.Fatalf("got %v", err)
205 }
206 if want := []string{"fence-as"}; !slices.Equal(f.order, want) {
207 t.Fatalf("root was asked: %v", f.order)
208 }
209 })
210 t.Run("not the probe's session", func(t *testing.T) {
211 f := newStopProbeFixture(t)
212 f.obs.Claim.Holder.Session = 13
213 if _, err := f.stop(); !errors.Is(err, hostbox.ErrChanged) || len(f.order) != 0 {
214 t.Fatalf("%v %v", err, f.order)
215 }
216 })
217}
218
219// Astra round 6, finding 1: the agent restarts after the probe stop
220// installed its fence and persisted its owner. The restart empties the
221// activator's fences; the marker survives. Another operation then fences
222// the host at a lower or equal generation. The surviving marker must not
223// let the probe stop take that fence over: ErrFenceHeld, the other fence
224// exactly as it was, root not asked. Once the other operation lifts its
225// fence, the next pass installs this probe's own fence and completes.
226func TestRelocationStopProbeAfterARestartNeverTakesAForeignFence(t *testing.T) {
227 const host = "shop.example"
228 for _, gen := range []uint64{5, 12} {
229 t.Run(fmt.Sprint(gen), func(t *testing.T) {
230 f := newStopProbeFixture(t)
231 first, err := f.stop()
232 if err != nil {
233 t.Fatal(err)
234 }
235 before := f.marker()
236 f.act.svc = &activator.Service{} // the restart
237 f.act.foreignFence(t, host, gen)
238 f.order = nil
239 if _, err := f.stop(); !errors.Is(err, activator.ErrFenceHeld) {
240 t.Fatalf("got %v, want ErrFenceHeld", err)
241 }
242 if want := []string{"fence-as"}; !slices.Equal(f.order, want) || f.act.svc.FenceGeneration(host) != gen || f.marker() != before {
243 t.Fatalf("order %v, fence %d, marker %+v", f.order, f.act.svc.FenceGeneration(host), f.marker())
244 }
245 if f.act.svc.LiftFenceAs(host, gen, before.Owner) {
246 t.Fatal("the surviving owner lifted the other operation's fence")
247 }
248 if !f.act.svc.LiftFence(host, gen) {
249 t.Fatal("the other operation's fence was not as it installed it")
250 }
251 f.order = nil
252 again, err := f.stop()
253 if err != nil || again != first || f.act.svc.FenceGeneration(host) != 12 {
254 t.Fatalf("after the other fence went: %+v %v, fence %d", again, err, f.act.svc.FenceGeneration(host))
255 }
256 })
257 }
258}
259
260// Astra round 6, finding 2: the owner is persisted before the activator is
261// asked, so a failed write installs nothing and blocks nothing. The
262// retry persists it and proceeds, under the owner it persisted.
263func TestRelocationStopProbeRetriesAFailedMarkerWrite(t *testing.T) {
264 const host = "shop.example"
265 f := newStopProbeFixture(t)
266 path, err := relocationProbeFencePath(f.cfg.DataDir, "rel-1")
267 if err != nil {
268 t.Fatal(err)
269 }
270 // The op's journal directory cannot be made: a file stands in its way.
271 if err := os.MkdirAll(filepath.Dir(filepath.Dir(path)), 0o700); err != nil {
272 t.Fatal(err)
273 }
274 if err := os.WriteFile(filepath.Dir(path), []byte("in the way"), 0o600); err != nil {
275 t.Fatal(err)
276 }
277 if _, err := f.stop(); err == nil {
278 t.Fatal("the probe stop went on without its owner persisted")
279 }
280 if len(f.order) != 0 || f.act.svc.Fenced(host) {
281 t.Fatalf("the activator was asked before the owner was persisted: %v", f.order)
282 }
283 if err := os.Remove(filepath.Dir(path)); err != nil {
284 t.Fatal(err)
285 }
286 s, err := f.stop()
287 if err != nil || s.Session != 12 {
288 t.Fatalf("retry: %+v %v", s, err)
289 }
290 if want := []string{"fence-as", "wake-stop"}; !slices.Equal(f.order, want) {
291 t.Fatalf("retry order %v", f.order)
292 }
293 if m := f.marker(); !f.act.svc.LiftFenceAs(host, 12, m.Owner) {
294 t.Fatal("the fence installed is not the persisted owner's")
295 }
296}
297
298// Absence is never evidence: a probe-fence marker that is present is the
299// only record of the owner a fence may already be installed under, so one
300// that cannot be read, or that names another host, session or no owner,
301// is never replaced by a fresh owner. The stop refuses before the
302// activator is asked, no fence is installed, and the marker stays as it
303// was, for an operator.
304func TestRelocationStopProbeNeverReplacesAPresentMarker(t *testing.T) {
305 const host = "shop.example"
306 owner := strings.Repeat("a", 32)
307 for name, body := range map[string][]byte{
308 "unreadable": []byte("{not json"),
309 "another session": []byte(`{"host":"shop.example","session":13,"owner":"` + owner + `"}`),
310 "another host": []byte(`{"host":"other.example","session":12,"owner":"` + owner + `"}`),
311 "no owner": []byte(`{"host":"shop.example","session":12}`),
312 } {
313 t.Run(name, func(t *testing.T) {
314 f := newStopProbeFixture(t)
315 path, err := relocationProbeFencePath(f.cfg.DataDir, "rel-1")
316 if err != nil {
317 t.Fatal(err)
318 }
319 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
320 t.Fatal(err)
321 }
322 if err := os.WriteFile(path, body, 0o600); err != nil {
323 t.Fatal(err)
324 }
325 if _, err := f.stop(); !errors.Is(err, hostrelocate.ErrRefused) {
326 t.Fatalf("the probe stop over a marker it did not write: %v", err)
327 }
328 if len(f.order) != 0 || f.act.svc.Fenced(host) {
329 t.Fatalf("the activator was asked: %v", f.order)
330 }
331 if after, err := os.ReadFile(path); err != nil || !bytes.Equal(after, body) {
332 t.Fatalf("the marker was replaced: %q %v", after, err)
333 }
334 })
335 }
336}
337
338// Two completed writer-session stops of the probe's session under the same
339// op on this box (a second fence under another roster revision) leave no
340// one receipt to record or to hand over from. The stop and N6 refuse with
341// hostguard.ErrFenceTarget, as main's CompletedColdPredecessor does,
342// rather than take the first the journal lists.
343func TestRelocationStopProbeRefusesTwoCompletedStopsOfTheSession(t *testing.T) {
344 ctx := t.Context()
345 f := newStopProbeFixture(t)
346 first, err := f.stop()
347 if err != nil {
348 t.Fatal(err)
349 }
350 // Root's second stop of session 12 under rel-1: a repeat under the next
351 // roster revision, which main's fence target carries, so the guard
352 // journals it beside the first rather than meeting it again.
353 second := f.target
354 second.FenceID, second.DirectoryRevision, second.RosterRevision = "fence-2", "2", 2
355 if _, err := f.a.Guard.BeginFence(ctx, second); err != nil {
356 t.Fatal(err)
357 }
358 at := time.Date(2026, 10, 7, 4, 0, 0, 0, time.UTC)
359 receipt := mustMarshalStopReceipt(StopReceipt{V: 1, ID: "r2", Kind: StopReceiptStopped, Region: "ie", ClaimETag: "e1", ObligationID: second.ObligationID, FenceID: "fence-2", Host: f.obs.Host, Incarnation: second.Incarnation,
360 InstallationStamp: 9, WriterSession: 12, OperationID: "rel-1", Phase: hostclaim.PhaseRelocatingProbing, Box: "dst", Enrollment: "enr", BootID: "boot", ControllerID: "ctl", DirectoryRevision: testReceiptNumeral(second), Binding: "2", EvidenceAt: at})
361 if err := f.a.Guard.CompleteFence(ctx, second, receipt, at, func(context.Context, hostguard.FenceTarget, []byte, time.Time) error { return nil }); err != nil {
362 t.Fatal(err)
363 }
364 if _, err := f.stop(); !errors.Is(err, hostguard.ErrFenceTarget) {
365 t.Fatalf("the stop over two receipts: %v", err)
366 }
367 // N6 over the serving record of the first stop refuses the same way.
368 done, _ := json.Marshal(hostclaim.CompletedRelocation{Op: "rel-1", Stop: first, Stamp: 9, Phase: hostclaim.PhaseRelocatingProbing, Generation: "P1"})
369 c := *f.obs.Claim
370 c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "dst", Participants: []string{"dst"}, Journal: map[string]string{hostclaim.JournalCompleted: string(done)}}
371 h := relocationCompletedHandover{Guard: f.a.Guard, Box: "dst"}
372 if _, err := h.receipt(ctx, hostclaim.Observed{Host: f.obs.Host, ETag: "e2", Claim: &c}); !errors.Is(err, hostguard.ErrFenceTarget) {
373 t.Fatalf("N6 over two receipts: %v", err)
374 }
375}
376