carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package hostops
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/carlosframework/platform/internal/hostactors"
12 "github.com/carlosframework/platform/internal/hostclaim"
13 "github.com/carlosframework/platform/internal/release"
14 "github.com/carlosframework/platform/internal/termfence"
15)
16
17const completionHost = "shop.example"
18
19// completionWorld is ruling 86's schedule (the two-box model, seed 2224):
20// a relocation completed on box-a, which is terminated before it
21// discharges, so the serving op's completed relocation stays undischarged
22// and box-a is not on lost. The signed roster holds box-a and the worker
23// box-w that writes box-a's fence; the authority is production's
24// (ProductionAuthority over that roster, ClaimFenceAuthority).
25type completionWorld struct {
26 s release.Store
27 a ProductionAuthority
28}
29
30func newCompletionWorld(t *testing.T, change func(*hostclaim.Claim)) *completionWorld {
31 t.Helper()
32 ctx := context.Background()
33 x, s, _, _ := chooserFixture(t, "box-a", "box-w")
34 w := &completionWorld{s: s, a: ProductionAuthority{Store: s, Directory: x.Directory, Namespace: "platform", Now: x.Now}}
35 obs, err := hostclaim.Read(ctx, s, completionHost)
36 if err != nil {
37 t.Fatal(err)
38 }
39 obs, err = hostclaim.Update(ctx, s, obs, hostclaim.Adopt(hostclaim.NewHolder{Family: "instance", Key: "apps/acct/app/instances/" + completionHost + ".json", Account: "acct", App: "app", Host: completionHost, Region: "ie"}, "box-a", nil, nil, "adopt", func() string { return "id" }))
40 if err != nil {
41 t.Fatal(err)
42 }
43 done, err := json.Marshal(hostclaim.CompletedRelocation{Op: "rel-1", Stop: hostclaim.ProbeStop{Box: "box-a", Session: 7, Receipt: "probe-fence-1", ReceiptSHA256: strings.Repeat("a", 64)}, Phase: hostclaim.PhaseRelocatingProbing})
44 if err != nil {
45 t.Fatal(err)
46 }
47 if _, err = hostclaim.Update(ctx, s, obs, func(c *hostclaim.Claim) error {
48 c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box-a", Participants: []string{"box-a"}, Journal: map[string]string{hostclaim.JournalCompleted: string(done)}}
49 if change != nil {
50 change(c)
51 }
52 return nil
53 }); err != nil {
54 t.Fatal(err)
55 }
56 return w
57}
58
59// fence writes raw at box-a's fence key (its roster enrollment).
60func (w *completionWorld) fence(t *testing.T, raw []byte) {
61 t.Helper()
62 if _, err := w.s.Put(context.Background(), "control/box-fences/"+strings.Repeat("a", 32)+".json", raw, ""); err != nil {
63 t.Fatal(err)
64 }
65}
66
67// terminated is box-w's termination fence of box-a, valid under the
68// roster; edit changes it before it is written.
69func (w *completionWorld) terminated(t *testing.T, edit func(*termfence.Record)) {
70 t.Helper()
71 f := termfence.Record{V: 1, Box: "box-a", Enrollment: strings.Repeat("a", 32), State: "terminated",
72 Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-box-a"},
73 ObservedAt: "2026-10-07T08:00:00Z", Via: termfence.ViaDescribe, RequestID: "req",
74 WrittenBy: termfence.WrittenBy{Box: "box-w", Enrollment: strings.Repeat("b", 32)}}
75 if edit != nil {
76 edit(&f)
77 }
78 raw, err := json.Marshal(f)
79 if err != nil {
80 t.Fatal(err)
81 }
82 w.fence(t, raw)
83}
84
85// purge is the purge start as startAppLifecycle makes it: the verified
86// completion fence, then BeginPurge's CAS with it. delete is the same
87// for StartDeleteClaim and BeginDelete.
88func (w *completionWorld) purge(t *testing.T) (hostclaim.Observed, error) {
89 t.Helper()
90 ctx := context.Background()
91 obs, err := hostclaim.Read(ctx, w.s, completionHost)
92 if err != nil {
93 t.Fatal(err)
94 }
95 fenced, err := w.a.completionFence(ctx, obs.Claim)
96 if err != nil {
97 return obs, err
98 }
99 return hostclaim.Update(ctx, w.s, obs, BeginPurge("purge-1", "console", time.Unix(200, 0), []string{"ie"}, []string{"box-a", "box-w"}, fenced))
100}
101
102func (w *completionWorld) delete(t *testing.T) (hostclaim.Observed, error) {
103 t.Helper()
104 ctx := context.Background()
105 obs, err := hostclaim.Read(ctx, w.s, completionHost)
106 if err != nil {
107 t.Fatal(err)
108 }
109 fenced, err := w.a.completionFence(ctx, obs.Claim)
110 if err != nil {
111 return obs, err
112 }
113 return hostclaim.Update(ctx, w.s, obs, hostclaim.BeginDelete("delete-1", "console", "record-etag", time.Unix(200, 0), []string{"ie"}, []string{"box-a", "box-w"}, fenced))
114}
115
116func (w *completionWorld) unchanged(t *testing.T, what string) {
117 t.Helper()
118 obs, err := hostclaim.Read(context.Background(), w.s, completionHost)
119 if err != nil {
120 t.Fatal(err)
121 }
122 if obs.Claim.Op.Kind != hostclaim.KindServing || obs.Claim.Op.ID != "" {
123 t.Fatalf("%s: the claim was started: %+v", what, obs.Claim.Op)
124 }
125}
126
127// Ruling 86, the deadlock schedule: the completion box is terminated before
128// it discharged, and a purge (or a delete) starts. With box-a's termination
129// fence verified under the roster, the start passes the completion barrier
130// as a failover's would; without the exemption StartGuard's barrier
131// refuses it, which is the deadlock the model found.
132func TestPurgeStartsOverATerminatedCompletionBox(t *testing.T) {
133 w := newCompletionWorld(t, nil)
134 w.terminated(t, nil)
135 ctx := context.Background()
136 obs, err := hostclaim.Read(ctx, w.s, completionHost)
137 if err != nil {
138 t.Fatal(err)
139 }
140 if _, err := hostclaim.Update(ctx, w.s, obs, BeginPurge("purge-1", "console", time.Unix(200, 0), []string{"ie"}, []string{"box-a"}, "")); !errors.Is(err, hostclaim.ErrRelocationFinishing) {
141 t.Fatalf("the barrier without the exemption: %v (the deadlock's refusal)", err)
142 }
143 if fenced, err := w.a.completionFence(ctx, obs.Claim); err != nil || fenced != "box-a" {
144 t.Fatalf("the verified completion fence: %q %v", fenced, err)
145 }
146 started, err := w.purge(t)
147 if err != nil || started.Claim.Op.Kind != hostclaim.KindPurging || started.Claim.Op.ID != "purge-1" {
148 t.Fatalf("the purge over a terminated completion box: %v %+v", err, started.Claim)
149 }
150 d := newCompletionWorld(t, nil)
151 d.terminated(t, nil)
152 if started, err := d.delete(t); err != nil || started.Claim.Op.Kind != hostclaim.KindDeleting {
153 t.Fatalf("the delete over a terminated completion box: %v", err)
154 }
155}
156
157// Ruling 86's guards: only a verified fence exempts. A live completion box
158// (no fence), a fence that does not verify (another instance, written by
159// the box itself, not terminated, another box's record at this key), an
160// unreadable fence and a completion box the roster does not hold all leave
161// the purge refused; an unreadable or unverifiable fence is its error. A
162// completion already discharged, or whose box is on lost, needs no fence:
163// the barrier passes it and an unreadable fence is never read.
164func TestPurgeOverACompletionNeedsAVerifiedFence(t *testing.T) {
165 ctx := context.Background()
166 t.Run("a live completion box", func(t *testing.T) {
167 w := newCompletionWorld(t, nil)
168 if _, err := w.purge(t); !errors.Is(err, hostclaim.ErrRelocationFinishing) {
169 t.Fatalf("a live completion box: %v", err)
170 }
171 if _, err := w.delete(t); !errors.Is(err, hostclaim.ErrRelocationFinishing) {
172 t.Fatalf("a live completion box (delete): %v", err)
173 }
174 w.unchanged(t, "a live completion box")
175 })
176 for name, edit := range map[string]func(*termfence.Record){
177 "another instance": func(f *termfence.Record) { f.Cloud.InstanceID = "i-other" },
178 "written by the box": func(f *termfence.Record) {
179 f.WrittenBy = termfence.WrittenBy{Box: "box-a", Enrollment: strings.Repeat("a", 32)}
180 },
181 "not terminated": func(f *termfence.Record) { f.State = "running" },
182 "another box's record": func(f *termfence.Record) { f.Box = "box-w" },
183 "written by an unenrolled": func(f *termfence.Record) { f.WrittenBy.Box = "box-x" },
184 } {
185 t.Run("a fence that does not verify: "+name, func(t *testing.T) {
186 w := newCompletionWorld(t, nil)
187 w.terminated(t, edit)
188 if _, err := w.purge(t); err == nil {
189 t.Fatalf("an unverified fence exempted the purge")
190 }
191 if _, err := w.delete(t); err == nil {
192 t.Fatalf("an unverified fence exempted the delete")
193 }
194 w.unchanged(t, name)
195 })
196 }
197 t.Run("an unreadable fence", func(t *testing.T) {
198 w := newCompletionWorld(t, nil)
199 w.fence(t, []byte("{"))
200 if _, err := w.purge(t); err == nil || errors.Is(err, hostclaim.ErrRelocationFinishing) {
201 t.Fatalf("an unreadable fence: %v (want its error)", err)
202 }
203 w.unchanged(t, "an unreadable fence")
204 })
205 t.Run("a fence read that fails", func(t *testing.T) {
206 w := newCompletionWorld(t, nil)
207 w.terminated(t, nil)
208 w.a.Store = &faultStore{Store: w.s, failGet: "control/box-fences/" + strings.Repeat("a", 32) + ".json"}
209 if fenced, err := w.a.completionFence(ctx, mustClaim(t, w)); fenced != "" || err == nil {
210 t.Fatalf("a failed fence read: %q %v (want its error)", fenced, err)
211 }
212 })
213 t.Run("a completion box the roster does not hold", func(t *testing.T) {
214 w := newCompletionWorld(t, func(c *hostclaim.Claim) {
215 done, _, _ := hostclaim.ReadCompletedRelocation(c)
216 done.Stop.Box = "box-gone"
217 raw, _ := json.Marshal(done)
218 c.Op.Exec, c.Op.Participants, c.Op.Journal[hostclaim.JournalCompleted] = "box-gone", []string{"box-gone"}, string(raw)
219 })
220 w.terminated(t, nil)
221 if fenced, err := w.a.completionFence(ctx, mustClaim(t, w)); fenced != "" || err != nil {
222 t.Fatalf("an unrostered box: %q %v", fenced, err)
223 }
224 if _, err := w.purge(t); !errors.Is(err, hostclaim.ErrRelocationFinishing) {
225 t.Fatalf("an unrostered box: %v", err)
226 }
227 })
228 t.Run("a completion record that cannot be read", func(t *testing.T) {
229 w := newCompletionWorld(t, func(c *hostclaim.Claim) { c.Op.Journal[hostclaim.JournalCompleted] = "{" })
230 w.terminated(t, nil)
231 if _, err := w.purge(t); !errors.Is(err, hostclaim.ErrRelocation) {
232 t.Fatalf("an unreadable completion record: %v", err)
233 }
234 w.unchanged(t, "an unreadable completion record")
235 })
236 for name, change := range map[string]func(*hostclaim.Claim){
237 "already discharged": func(c *hostclaim.Claim) {
238 done, _, _ := hostclaim.ReadCompletedRelocation(c)
239 done.Discharged = true
240 raw, _ := json.Marshal(done)
241 c.Op.Journal[hostclaim.JournalCompleted] = string(raw)
242 },
243 "its box on lost": func(c *hostclaim.Claim) { c.Lost = append(c.Lost, "box-a") },
244 "no completion": func(c *hostclaim.Claim) { c.Op.Journal = nil },
245 } {
246 t.Run("nothing to exempt: "+name, func(t *testing.T) {
247 w := newCompletionWorld(t, change)
248 w.fence(t, []byte("{"))
249 if fenced, err := w.a.completionFence(ctx, mustClaim(t, w)); fenced != "" || err != nil {
250 t.Fatalf("%s: %q %v (the fence must not be read)", name, fenced, err)
251 }
252 if started, err := w.purge(t); err != nil || started.Claim.Op.Kind != hostclaim.KindPurging {
253 t.Fatalf("%s: %v", name, err)
254 }
255 })
256 }
257}
258
259func mustClaim(t *testing.T, w *completionWorld) *hostclaim.Claim {
260 t.Helper()
261 obs, err := hostclaim.Read(context.Background(), w.s, completionHost)
262 if err != nil {
263 t.Fatal(err)
264 }
265 return obs.Claim
266}
267