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 "context"
7 "encoding/json"
8 "errors"
9 "os"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/carlosframework/platform/internal/activator"
16 "github.com/carlosframework/platform/internal/hostbox"
17 "github.com/carlosframework/platform/internal/hostclaim"
18 "github.com/carlosframework/platform/internal/hostguard"
19 "github.com/carlosframework/platform/internal/registry"
20 "github.com/carlosframework/platform/internal/release"
21)
22
23// failingClaimPut is a control bucket whose next fail writes of one claim
24// key fail: a lost or failed claim CAS.
25type failingClaimPut struct {
26 release.Store
27 key string
28 fail int
29}
30
31func (s *failingClaimPut) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
32 if key == s.key && s.fail > 0 {
33 s.fail--
34 return "", errors.New("the claim CAS failed")
35 }
36 return s.Store.Put(ctx, key, body, ifMatch)
37}
38
39// finishWorld is a host that serves on dst after relocation rel-1
40// completed there, undischarged: its row held for rel-1, the probe stop's
41// owned fence on the activator, and the pass of dst with the production
42// Finish (relocationReleaseHold).
43type finishWorld struct {
44 t *testing.T
45 store *failingClaimPut
46 reg *registry.Registry
47 act *stopProbeActivator
48 order []string
49 discharged int
50 discharge error
51 runtime *RelocationRuntime
52 dataDir string
53 assembly *ClaimAssembly
54}
55
56const finishHost = "shop.example"
57
58func newFinishWorld(t *testing.T) *finishWorld {
59 t.Helper()
60 ctx := t.Context()
61 w := &finishWorld{t: t, dataDir: t.TempDir()}
62 created := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
63 inc, err := hostclaim.Incarnation("acct", "app", finishHost, created)
64 if err != nil {
65 t.Fatal(err)
66 }
67 done, _ := json.Marshal(hostclaim.CompletedRelocation{Op: "rel-1", Stop: hostclaim.ProbeStop{Box: "dst", Session: 12, Receipt: "fence-1", ReceiptSHA256: strings.Repeat("a", 64)}, Stamp: 9, Phase: hostclaim.PhaseRelocatingProbing, Generation: "P1"})
68 c := &hostclaim.Claim{V: 2, Gen: 31, Host: finishHost,
69 Holder: &hostclaim.Holder{Family: "instance", Key: "apps/acct/app/instances/" + finishHost + ".json", Account: "acct", App: "app", Incarnation: inc, CreatedAt: created,
70 Location: "instances/" + finishHost, Stamp: 9, Session: 12, Generation: "P1", WriterBox: "dst", Admissions: []hostclaim.AdmissionRecord{{Session: 12, Box: "dst", Generation: "P1", Mode: "cold", Region: "ie"}}},
71 Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "dst", Participants: []string{"dst"}, Journal: map[string]string{hostclaim.JournalCompleted: string(done)}}}
72 key, _ := hostclaim.Key(finishHost)
73 w.store = &failingClaimPut{Store: release.NewDirStore(t.TempDir()), key: key}
74 raw, _ := json.Marshal(c)
75 if _, err := w.store.Put(ctx, key, raw, ""); err != nil {
76 t.Fatal(err)
77 }
78 if w.reg, err = registry.Open(filepath.Join(t.TempDir(), "registry.db")); err != nil {
79 t.Fatal(err)
80 }
81 t.Cleanup(func() { w.reg.Close() })
82 if err := w.reg.Upsert(registry.Route{Host: finishHost, Status: "relocating", HoldOp: "rel-1", Socket: "/run/shop.sock"}); err != nil {
83 t.Fatal(err)
84 }
85 marker, err := relocationProbeFencePath(w.dataDir, "rel-1")
86 if err != nil {
87 t.Fatal(err)
88 }
89 owner, err := relocationProbeFenceOwner(marker, finishHost, 12)
90 if err != nil {
91 t.Fatal(err)
92 }
93 w.act = newStopProbeActivator(&w.order)
94 fctx, cancel := context.WithTimeout(ctx, time.Second)
95 defer cancel()
96 if _, err := w.act.svc.FenceAndStopAs(fctx, finishHost, 12, owner); err != nil && !errors.Is(err, activator.ErrStopUnproven) {
97 t.Fatal(err)
98 }
99 a := ClaimAssembly{Claims: w.store, Guard: hostguard.Guard{DataDir: w.dataDir}, stopLocal: func(context.Context) (claimStopLocal, error) {
100 return claimStopLocal{Actor: hostbox.Actor{Box: "dst"}}, nil
101 }}
102 w.assembly = &a
103 cfg := RelocationAssemblyConfig{Registry: w.reg, Activator: w.act, DataDir: w.dataDir, Discharge: func(context.Context, hostclaim.Observed) error {
104 w.discharged++
105 return w.discharge
106 }}
107 var steps []string
108 w.runtime = &RelocationRuntime{Worker: stepRecorder{name: "worker", order: &steps}, Participant: stepRecorder{name: "participant", order: &steps}, Box: "dst",
109 Finish: a.relocationReleaseHold(cfg), DataDir: w.dataDir}
110 return w
111}
112
113func (w *finishWorld) pass() { w.runtime.once(w.t.Context(), w.store) }
114
115func (w *finishWorld) held() bool {
116 row, err := w.reg.Get(finishHost)
117 return err == nil && row != nil && row.Status == "relocating" && row.HoldOp == "rel-1"
118}
119
120// marked reads the record, and whether a successor (a move from dst) may
121// start: the completion barrier.
122func (w *finishWorld) marked() (bool, error) {
123 obs, err := hostclaim.Read(w.t.Context(), w.store, finishHost)
124 if err != nil {
125 w.t.Fatal(err)
126 }
127 done, ok, err := hostclaim.ReadCompletedRelocation(obs.Claim)
128 if err != nil {
129 w.t.Fatal(err)
130 }
131 return ok && done.Discharged, hostclaim.StartGuard(obs.Claim, []string{"uk"}, []string{"spa"}, "")
132}
133
134// done asserts the finished state: discharged as many times in all as
135// the passes asked (Task 9 review M2: once the hold is released, a replay
136// discharges again rather than inferring the discharge ran), the hold
137// released, no fence, the completion marked so a successor may start, and
138// the op's journals swept by the same pass.
139func (w *finishWorld) done(discharges int) {
140 w.t.Helper()
141 ok, barrier := w.marked()
142 if w.discharged != discharges || w.held() || w.act.svc.Fenced(finishHost) || !ok || barrier != nil {
143 w.t.Fatalf("discharged %d (want %d) held=%v fenced=%v marked=%v barrier=%v", w.discharged, discharges, w.held(), w.act.svc.Fenced(finishHost), ok, barrier)
144 }
145 if _, err := os.Stat(filepath.Join(w.dataDir, "relocate", "ops", "rel-1")); !errors.Is(err, os.ErrNotExist) {
146 w.t.Fatalf("the finished op's journals survived: %v", err)
147 }
148}
149
150// Astra round 7, finding 1 (ruling 80): a completion is finished from the
151// claim's record on every pass, whatever the row says, and every step
152// resumes after a failure or a crash at any point, through the pass.
153func TestRelocatePassFinishesACompletionFromItsRecord(t *testing.T) {
154 t.Parallel()
155 t.Run("in one pass", func(t *testing.T) {
156 w := newFinishWorld(t)
157 w.pass()
158 w.done(1)
159 w.pass()
160 w.done(1)
161 })
162 t.Run("a failed discharge keeps everything, the next pass finishes", func(t *testing.T) {
163 w := newFinishWorld(t)
164 w.discharge = errors.New("the participation slot is still open")
165 w.pass()
166 if ok, barrier := w.marked(); w.discharged != 1 || !w.held() || w.act.svc.FenceGeneration(finishHost) != 12 || ok || !errors.Is(barrier, hostclaim.ErrPendingChore) {
167 t.Fatalf("discharged %d held=%v fence=%d marked=%v barrier=%v", w.discharged, w.held(), w.act.svc.FenceGeneration(finishHost), ok, barrier)
168 }
169 w.discharge = nil
170 w.pass()
171 w.done(2)
172 })
173 t.Run("the mark's CAS fails, the next pass marks", func(t *testing.T) {
174 w := newFinishWorld(t)
175 w.store.fail = 1
176 w.pass()
177 if ok, barrier := w.marked(); w.discharged != 1 || w.held() || w.act.svc.Fenced(finishHost) || ok || !errors.Is(barrier, hostclaim.ErrPendingChore) {
178 t.Fatalf("discharged %d held=%v fenced=%v marked=%v barrier=%v", w.discharged, w.held(), w.act.svc.Fenced(finishHost), ok, barrier)
179 }
180 // The row is no longer held: the record alone brings it back, and
181 // the released-hold branch discharges again (Task 9 review M2).
182 w.pass()
183 w.done(2)
184 })
185 t.Run("the lift never ran after the hold went, the next pass lifts", func(t *testing.T) {
186 w := newFinishWorld(t)
187 // An earlier call discharged and released the hold, then failed.
188 w.discharged = 1
189 if _, err := w.reg.ReleaseHold(finishHost, "rel-1"); err != nil {
190 t.Fatal(err)
191 }
192 w.pass()
193 w.done(2)
194 })
195 t.Run("a restart after the hold release", func(t *testing.T) {
196 w := newFinishWorld(t)
197 w.discharged = 1
198 if _, err := w.reg.ReleaseHold(finishHost, "rel-1"); err != nil {
199 t.Fatal(err)
200 }
201 w.act.svc = &activator.Service{} // the restart: the fence went with the process
202 w.pass()
203 w.done(2)
204 })
205}
206
207// productionLocal is main's participation owner after the restart, bound
208// to the world's assembly: DischargeCompleted is the production discharge
209// (carry 6), its identity a verifying one for dst.
210func (w *finishWorld) productionLocal() *productionClaimLocal {
211 identity := handoverRecoveryIdentity(w.t, hostbox.Actor{Box: "dst", Enrollment: "enr", Region: "ie", Revision: 1, Binding: "1"})
212 return &productionClaimLocal{legacyParticipation: true, Resources: ClaimRuntimeResources{Mode: "agent", DataDir: w.dataDir}, Identity: identity, Store: w.store,
213 slots: map[string]*claimParticipation{}, assembly: w.assembly, lifetime: w.t.Context()}
214}
215
216// Astra round 8 (ruling 82): A to B completes, the mark's CAS fails, and B
217// restarts with CARLOS_REGION_HA off. Claim mode alone builds the
218// completion-only runtime, which steps nothing, and its pass still lifts
219// and marks, so the barrier opens. The restarted runtime's discharge is
220// main's own (productionClaimLocal.DischargeCompleted, carry 6), which
221// finds no slot and no .hostexec record.
222func TestCompletionPassFinishesWithRegionHAOff(t *testing.T) {
223 w := newFinishWorld(t)
224 w.store.fail = 1
225 w.pass()
226 if ok, barrier := w.marked(); ok || w.held() || !errors.Is(barrier, hostclaim.ErrPendingChore) {
227 t.Fatalf("the first pass marked or kept the hold: marked=%v held=%v barrier=%v", ok, w.held(), barrier)
228 }
229 w.act.svc = &activator.Service{} // the restart
230 claimOnly := func(k string) string {
231 if k == "CARLOS_HOST_CLAIM" {
232 return "1"
233 }
234 return ""
235 }
236 r, err := NewRelocationRuntime(t.Context(), claimOnly, w.store, w.productionLocal(), ClaimProviders{}, w.reg, w.act, relocationRuntimeInputs{})
237 if err != nil || r == nil || r.Worker != nil || r.Participant != nil || r.Convert != nil || r.Finish == nil {
238 t.Fatalf("not a completion-only runtime: %+v %v", r, err)
239 }
240 w.runtime = r
241 w.pass()
242 w.done(1)
243}
244
245// Carry 6: a probe-fence owner the hold release cannot find is ErrRefused,
246// which the pass alerts and retries; it never skips the completion and
247// never marks it. Nothing is released while the owner is missing; once it
248// is back, the next pass finishes.
249func TestRelocatePassRetriesAMissingProbeFenceOwner(t *testing.T) {
250 w := newFinishWorld(t)
251 marker, err := relocationProbeFencePath(w.dataDir, "rel-1")
252 if err != nil {
253 t.Fatal(err)
254 }
255 raw, err := os.ReadFile(marker)
256 if err != nil {
257 t.Fatal(err)
258 }
259 if err := os.Remove(marker); err != nil {
260 t.Fatal(err)
261 }
262 buf := captureRacyLog(t)
263 w.pass()
264 w.pass()
265 if ok, barrier := w.marked(); ok || !w.held() || w.discharged != 0 || w.act.svc.FenceGeneration(finishHost) != 12 || !errors.Is(barrier, hostclaim.ErrPendingChore) {
266 t.Fatalf("over a missing owner: marked=%v held=%v discharged=%d fence=%d barrier=%v", ok, w.held(), w.discharged, w.act.svc.FenceGeneration(finishHost), barrier)
267 }
268 if n := strings.Count(buf.String(), "has no probe-fence owner"); n != 1 {
269 t.Fatalf("alerted %d times, want once:\n%s", n, buf.String())
270 }
271 // An operator puts the owner back. (The sweep has removed the op's
272 // directory meanwhile: with the marker gone it held no journal.)
273 if err := os.MkdirAll(filepath.Dir(marker), 0o700); err != nil {
274 t.Fatal(err)
275 }
276 if err := os.WriteFile(marker, raw, 0o600); err != nil {
277 t.Fatal(err)
278 }
279 w.pass()
280 w.done(1)
281}
282
283// Review M1: a completion whose row is gone is still visited by the
284// completion-only pass (its probe-fence journal names the host), and the
285// hold release's refusal is said once; nothing is released or marked.
286func TestCompletionPassSaysACompletionWhoseRowIsGone(t *testing.T) {
287 w := newFinishWorld(t)
288 if err := w.reg.Delete(finishHost); err != nil {
289 t.Fatal(err)
290 }
291 claimOnly := func(k string) string {
292 if k == "CARLOS_HOST_CLAIM" {
293 return "1"
294 }
295 return ""
296 }
297 r, err := NewRelocationRuntime(t.Context(), claimOnly, w.store, w.productionLocal(), ClaimProviders{}, w.reg, w.act, relocationRuntimeInputs{})
298 if err != nil {
299 t.Fatal(err)
300 }
301 w.runtime = r
302 buf := captureRacyLog(t)
303 w.pass()
304 w.pass()
305 if n := strings.Count(buf.String(), "has no row on this box"); n != 1 {
306 t.Fatalf("said %d times, want once:\n%s", n, buf.String())
307 }
308 if ok, _ := w.marked(); ok || !w.act.svc.Fenced(finishHost) {
309 t.Fatalf("finished over a missing row: marked=%v fenced=%v", ok, w.act.svc.Fenced(finishHost))
310 }
311}
312