carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostclaim
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "slices"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/release"
13)
14
15// probedDrain drives a drain of the serving fixture to a passed probe on
16// relTarget: released, pinned, installed, probing, P admitted, the probe
17// journaled with its time.
18func probedDrain(t *testing.T) (func(func(*Claim) error) Observed, func() Observed) {
19 t.Helper()
20 ctx := context.Background()
21 s, cur := probedDrainIn(t)
22 apply := func(tr func(*Claim) error) Observed {
23 t.Helper()
24 next, err := Update(ctx, s, cur, tr)
25 if err != nil {
26 t.Fatal(err)
27 }
28 cur = next
29 return next
30 }
31 return apply, func() Observed { return cur }
32}
33
34// probedDrainIn is probedDrain's claim and its store.
35func probedDrainIn(t *testing.T) (release.Store, Observed) {
36 t.Helper()
37 ctx := context.Background()
38 s, o := relServing(t)
39 o, err := StartRelocation(ctx, s, o, relStart(ReasonDrain, nil), relAuthority{})
40 if err != nil {
41 t.Fatal(err)
42 }
43 o = step(t, s, o, ack(relSource))
44 o = step(t, s, o, ack(relTarget))
45 o = step(t, s, o, ToReleasing("rel-1", relObserved))
46 h := o.Claim.Holder
47 o = step(t, s, o, JournalRelease("rel-1", Release{ReleasedBy: relSource, Enrollment: relEnrollment(relSource), Generation: h.Generation, Session: h.Session, FinalTXID: 40, ManifestSeq: 7, Receipt: "control/stop/r.json", ReceiptSHA256: strings.Repeat("d", 64)}))
48 if o, err = PinAfterRelease(ctx, s, o, "rel-1", relReleases{}, nil); err != nil {
49 t.Fatal(err)
50 }
51 o = step(t, s, o, JournalPin("rel-1", relPin(o, 40, 7, relObserved)))
52 o = toProbing(t, s, o, "P1", 40)
53 o = step(t, s, o, AdmitWriter(Admission{ObservedOpID: "rel-1", ObservedPhase: PhaseRelocatingProbing, ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation,
54 Box: relTarget, Mode: "cold", NewGeneration: "P1", KeepStamp: true, PredecessorCertified: true, Region: "ie", PredecessorRegion: "ie"}))
55 o = step(t, s, o, RecordProbe("rel-1", Probe{Box: relTarget, Session: o.Claim.Holder.Session, At: relObserved.Add(time.Minute)}))
56 return s, o
57}
58
59// copyOf is a deep copy, for trying a transition without writing it.
60func copyOf(t *testing.T, c *Claim) *Claim {
61 t.Helper()
62 raw, err := json.Marshal(c)
63 if err != nil {
64 t.Fatal(err)
65 }
66 var out Claim
67 if err := json.Unmarshal(raw, &out); err != nil {
68 t.Fatal(err)
69 }
70 return &out
71}
72
73func stopOf(o Observed) ProbeStop {
74 return ProbeStop{Box: relTarget, Session: o.Claim.Holder.Session, Receipt: "fence-1", ReceiptSHA256: strings.Repeat("e", 64)}
75}
76
77// Ruling 49 step 4: the serving CAS requires the probe's stop, journaled
78// for exactly the passed probe's box and session; it then records the
79// completed relocation on the serving op (ruling 56).
80func TestCompleteRelocationRequiresTheProbeStop(t *testing.T) {
81 apply, cur := probedDrain(t)
82 o := cur()
83 var p Probe
84 if !readJournal(o.Claim.Op, "probe", &p) || p.At.IsZero() {
85 t.Fatalf("the probe has no time: %+v", p)
86 }
87 if err := CompleteRelocation("rel-1", counter())(copyOf(t, o.Claim)); !errors.Is(err, ErrRelocation) {
88 t.Fatalf("completed with the probe's session running: %v", err)
89 }
90 for name, s := range map[string]ProbeStop{
91 "another session": {Box: relTarget, Session: o.Claim.Holder.Session + 1, Receipt: "fence-1", ReceiptSHA256: strings.Repeat("e", 64)},
92 "another box": {Box: relSource, Session: o.Claim.Holder.Session, Receipt: "fence-1", ReceiptSHA256: strings.Repeat("e", 64)},
93 "no receipt": {Box: relTarget, Session: o.Claim.Holder.Session, ReceiptSHA256: strings.Repeat("e", 64)},
94 "a bad digest": {Box: relTarget, Session: o.Claim.Holder.Session, Receipt: "fence-1", ReceiptSHA256: "short"},
95 } {
96 if err := RecordProbeStop("rel-1", s)(copyOf(t, o.Claim)); !errors.Is(err, ErrRelocation) {
97 t.Fatalf("%s: recorded: %v", name, err)
98 }
99 }
100 stop := stopOf(o)
101 o = apply(RecordProbeStop("rel-1", stop))
102 if err := RecordProbeStop("rel-1", stop)(copyOf(t, o.Claim)); !errors.Is(err, ErrAlreadyRecorded) {
103 t.Fatalf("a replay: %v", err)
104 }
105 other := stop
106 other.Receipt = "fence-2"
107 if err := RecordProbeStop("rel-1", other)(copyOf(t, o.Claim)); !errors.Is(err, ErrRelocation) {
108 t.Fatalf("a second, different stop: %v", err)
109 }
110 o = apply(CompleteRelocation("rel-1", counter()))
111 done, ok, _ := ReadCompletedRelocation(o.Claim)
112 if !ok || done.Op != "rel-1" || done.Stop != stop || done.Stamp != o.Claim.Holder.Stamp || done.Phase != PhaseRelocatingProbing || done.Generation != "P1" {
113 t.Fatalf("completed relocation: %+v %v", done, ok)
114 }
115 if o.Claim.Op.Journal["released_move"] != "" {
116 t.Fatal("a plain relocation named a move")
117 }
118}
119
120// M1: a converted landing's completion names the move on the serving op,
121// beside the completed relocation.
122func TestCompleteRelocationJournalsReleasedMoveForAConvertedLanding(t *testing.T) {
123 apply, cur := probedDrain(t)
124 apply(func(c *Claim) error { c.Op.Journal["conversion"] = "committed-landing"; return nil })
125 apply(RecordProbeStop("rel-1", stopOf(cur())))
126 o := apply(CompleteRelocation("rel-1", counter()))
127 if o.Claim.Op.Journal["released_move"] != "rel-1" {
128 t.Fatalf("serving op journal: %v", o.Claim.Op.Journal)
129 }
130 if _, ok, err := ReadCompletedRelocation(o.Claim); !ok || err != nil {
131 t.Fatal("no completed relocation beside the released move")
132 }
133}
134
135// Ruling 60: installed starts its own bound.
136func TestToInstalledAtJournalsItsStart(t *testing.T) {
137 ctx := context.Background()
138 s, o := relServing(t)
139 o, err := StartRelocation(ctx, s, o, relStart(ReasonDrain, nil), relAuthority{})
140 if err != nil {
141 t.Fatal(err)
142 }
143 o = step(t, s, o, ack(relSource))
144 o = step(t, s, o, ack(relTarget))
145 o = step(t, s, o, ToReleasing("rel-1", relObserved))
146 h := o.Claim.Holder
147 o = step(t, s, o, JournalRelease("rel-1", Release{ReleasedBy: relSource, Enrollment: relEnrollment(relSource), Generation: h.Generation, Session: h.Session, FinalTXID: 40, ManifestSeq: 7, Receipt: "control/stop/r.json", ReceiptSHA256: strings.Repeat("d", 64)}))
148 if o, err = PinAfterRelease(ctx, s, o, "rel-1", relReleases{}, nil); err != nil {
149 t.Fatal(err)
150 }
151 o = step(t, s, o, JournalPin("rel-1", relPin(o, 40, 7, relObserved)))
152 o = step(t, s, o, ToInstalling("rel-1", Target{relTarget, relEnrollment(relTarget)}, "P1"))
153 if err := ToInstalledAt("rel-1", relPrepared("P1", 40, 7), time.Time{})(copyOf(t, o.Claim)); !errors.Is(err, ErrRelocation) {
154 t.Fatalf("installed without a start: %v", err)
155 }
156 o = step(t, s, o, ToInstalledAt("rel-1", relPrepared("P1", 40, 7), relObserved))
157 if o.Claim.Op.Phase != PhaseInstalled || o.Claim.Op.Journal["installed_at"] != relObserved.Format(time.RFC3339Nano) {
158 t.Fatalf("installed: %s %v", o.Claim.Op.Phase, o.Claim.Op.Journal)
159 }
160}
161
162// Astra round 6, finding 3: A to B completes, then a successor B to C
163// asks to start before B has discharged its probe's participation (C10).
164// Through the real start CAS: a drain or rebalance from B is refused, with
165// the claim unchanged, until MarkCompletedDischarged; a failover that
166// cites B's termination fence is not, since a dead B's slot binds nothing
167// on C.
168func TestRelocationSuccessorWaitsForTheCompletedDischarge(t *testing.T) {
169 ctx := context.Background()
170 successor := func(reason string, fence *FenceRef) RelocationStart {
171 return RelocationStart{OpID: "rel-2", By: "worker", Now: relObserved.Add(time.Hour), Region: "ie", Reason: reason,
172 SourceBox: relTarget, SourceEnrollment: relEnrollment(relTarget), TargetBox: relSpare, TargetEnrollment: relEnrollment(relSpare),
173 DirectoryRevision: 9, AdmissionID: "adm-2", Fence: fence}
174 }
175 completed := completedDrain
176 t.Run("a drain from B waits for B's discharge", func(t *testing.T) {
177 s, o := completed(t)
178 for _, reason := range []string{ReasonDrain, ReasonRebalance} {
179 if _, err := StartRelocation(ctx, s, o, successor(reason, nil), relAuthority{}); !errors.Is(err, ErrPendingChore) {
180 t.Fatalf("%s started before the discharge: %v", reason, err)
181 }
182 }
183 if now, err := Read(ctx, s, o.Claim.Host); err != nil || now.ETag != o.ETag {
184 t.Fatalf("a refused start wrote the claim: %v", err)
185 }
186 if _, err := Update(ctx, s, o, MarkCompletedDischarged("rel-9")); !errors.Is(err, ErrRelocation) {
187 t.Fatalf("another op's discharge: %v", err)
188 }
189 o = step(t, s, o, MarkCompletedDischarged("rel-1"))
190 if _, err := Update(ctx, s, o, MarkCompletedDischarged("rel-1")); !errors.Is(err, ErrAlreadyRecorded) {
191 t.Fatalf("a repeat: %v", err)
192 }
193 if done, ok, err := ReadCompletedRelocation(o.Claim); err != nil || !ok || !done.Discharged || done.Stop != stopOf(o) {
194 t.Fatalf("the mark changed the record: %+v", done)
195 }
196 next, err := StartRelocation(ctx, s, o, successor(ReasonDrain, nil), relAuthority{})
197 if err != nil {
198 t.Fatalf("the successor after the discharge: %v", err)
199 }
200 if next.Claim.Op.ID != "rel-2" || next.Claim.Op.Intent["source_box"] != relTarget || next.Claim.Op.Intent["target_box"] != relSpare {
201 t.Fatalf("successor op %+v", next.Claim.Op)
202 }
203 })
204 t.Run("a failover from a fenced B does not wait", func(t *testing.T) {
205 s, o := completed(t)
206 ref := putFence(t, s, relTarget)
207 next, err := StartRelocation(ctx, s, o, successor(ReasonFailover, &ref), relAuthority{})
208 if err != nil {
209 t.Fatalf("failover from the fenced source: %v", err)
210 }
211 if next.Claim.Op.Exec != "none" || !slices.Contains(next.Claim.Lost, relTarget) {
212 t.Fatalf("failover op %+v lost %v", next.Claim.Op, next.Claim.Lost)
213 }
214 })
215 t.Run("no completed relocation, nothing to mark", func(t *testing.T) {
216 _, o := relServing(t)
217 if err := MarkCompletedDischarged("rel-1")(copyOf(t, o.Claim)); !errors.Is(err, ErrWrongState) {
218 t.Fatalf("%v", err)
219 }
220 })
221}
222
223// completedDrain is A to B through every real transition, completed and
224// not yet discharged on B.
225func completedDrain(t *testing.T) (release.Store, Observed) {
226 t.Helper()
227 s, o := probedDrainIn(t)
228 o = step(t, s, o, RecordProbeStop("rel-1", stopOf(o)))
229 o = step(t, s, o, CompleteRelocation("rel-1", counter()))
230 if done, ok, err := ReadCompletedRelocation(o.Claim); err != nil || !ok || done.Discharged || o.Claim.Op.Exec != relTarget {
231 t.Fatalf("A to B did not complete undischarged: %+v %v", done, ok)
232 }
233 return s, o
234}
235
236// Astra round 7, finding 2: the discharge barrier is StartGuard's too, so
237// a move from B (and a delete) after A to B completes is refused, with the
238// claim unchanged, until B marks the completion discharged; then the real
239// move start CAS succeeds. A completion box on lost holds nothing.
240func TestMoveSuccessorWaitsForTheCompletedDischarge(t *testing.T) {
241 ctx := context.Background()
242 s, o := completedDrain(t)
243 move := BeginAdmittedMove("mv-1", "console", relTarget, relSpare, "uk", relObserved.Add(time.Hour),
244 MoveAdmission{AppID: "app-1", AdmissionID: "mv-1", SourceEnrollment: relEnrollment(relTarget), TargetEnrollment: relEnrollment(relSpare)})
245 if _, err := Update(ctx, s, o, move); !errors.Is(err, ErrPendingChore) || !errors.Is(err, ErrRelocationFinishing) {
246 t.Fatalf("a move started before B's discharge: %v", err)
247 }
248 if _, err := Update(ctx, s, o, BeginDelete("del-1", "console", "etag-1", relObserved.Add(time.Hour), []string{"ie"}, []string{relTarget}, "")); !errors.Is(err, ErrPendingChore) {
249 t.Fatalf("a delete started before B's discharge: %v", err)
250 }
251 if now, err := Read(ctx, s, o.Claim.Host); err != nil || now.ETag != o.ETag {
252 t.Fatalf("a refused start wrote the claim: %v", err)
253 }
254 lost := copyOf(t, o.Claim)
255 lost.Lost = append(lost.Lost, relTarget)
256 if err := StartGuard(lost, []string{"uk"}, []string{relSpare}, ""); err != nil {
257 t.Fatalf("a lost completion box still holds the barrier: %v", err)
258 }
259 o = step(t, s, o, MarkCompletedDischarged("rel-1"))
260 next, err := Update(ctx, s, o, move)
261 if err != nil {
262 t.Fatalf("the move after the discharge: %v", err)
263 }
264 if next.Claim.Op.Kind != KindMoving || next.Claim.Op.ID != "mv-1" || next.Claim.Op.Exec != relTarget {
265 t.Fatalf("move op %+v", next.Claim.Op)
266 }
267}
268
269// Fix round 1, I1: a completed_relocation entry that is present but cannot
270// be read is an error, never "none". Every start from serving refuses over
271// it (a failover citing a fence included, since which box the record binds
272// is unknown), the discharge mark refuses, and the claim is left as it is.
273func TestAnUnreadableCompletedRelocationRefusesEveryStart(t *testing.T) {
274 ctx := context.Background()
275 for name, raw := range map[string]string{
276 "unparseable": `{"op":"rel-1","stop":`,
277 "no box": `{"op":"rel-1","stop":{"session":4,"receipt":"fence-1"}}`,
278 "no session": `{"op":"rel-1","stop":{"box":"` + relTarget + `","receipt":"fence-1"}}`,
279 "no op": `{"stop":{"box":"` + relTarget + `","session":4,"receipt":"fence-1"}}`,
280 "empty": ``,
281 "another shape": `["rel-1"]`,
282 } {
283 t.Run(name, func(t *testing.T) {
284 s, o := completedDrain(t)
285 o = step(t, s, o, func(c *Claim) error { c.Op.Journal[JournalCompleted] = raw; return nil })
286 if _, ok, err := ReadCompletedRelocation(o.Claim); ok || !errors.Is(err, ErrRelocation) {
287 t.Fatalf("read as %v, %v", ok, err)
288 }
289 successor := RelocationStart{OpID: "rel-2", By: "worker", Now: relObserved.Add(time.Hour), Region: "ie", Reason: ReasonDrain,
290 SourceBox: relTarget, SourceEnrollment: relEnrollment(relTarget), TargetBox: relSpare, TargetEnrollment: relEnrollment(relSpare),
291 DirectoryRevision: 9, AdmissionID: "adm-2"}
292 if _, err := StartRelocation(ctx, s, o, successor, relAuthority{}); !errors.Is(err, ErrRelocation) {
293 t.Fatalf("a drain started over an unreadable completion: %v", err)
294 }
295 ref := putFence(t, s, relTarget)
296 failover := successor
297 failover.Reason, failover.Fence = ReasonFailover, &ref
298 if _, err := StartRelocation(ctx, s, o, failover, relAuthority{}); !errors.Is(err, ErrRelocation) {
299 t.Fatalf("a failover started over an unreadable completion: %v", err)
300 }
301 if err := StartGuard(copyOf(t, o.Claim), []string{"uk"}, []string{relSpare}, ""); !errors.Is(err, ErrRelocation) {
302 t.Fatalf("StartGuard: %v", err)
303 }
304 if err := MarkCompletedDischarged("rel-1")(copyOf(t, o.Claim)); !errors.Is(err, ErrRelocation) {
305 t.Fatalf("the discharge mark over an unreadable completion: %v", err)
306 }
307 if now, err := Read(ctx, s, o.Claim.Host); err != nil || now.ETag != o.ETag {
308 t.Fatalf("a refused start wrote the claim: %v", err)
309 }
310 })
311 }
312}
313