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 "path/filepath"
7 "reflect"
8 "testing"
9
10 "github.com/carlosframework/platform/internal/activator"
11 "github.com/carlosframework/platform/internal/hostbox"
12 "github.com/carlosframework/platform/internal/hostclaim"
13 "github.com/carlosframework/platform/internal/hostguard"
14 "github.com/carlosframework/platform/internal/hostwrite"
15 "github.com/carlosframework/platform/internal/lease"
16 "github.com/carlosframework/platform/internal/provision"
17 "github.com/carlosframework/platform/internal/registry"
18)
19
20const (
21 mpHost = "shop.example"
22 mpOp = "mv_policy"
23 mpInc = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
24 mpTarget = "uk-box"
25 mpSource = "ie-box"
26)
27
28// mpClaim is the claim after the flip: landing for mpOp at phase, the
29// target executing, the holder at stamp 7.
30func mpClaim(kind, phase string) *hostclaim.Claim {
31 return &hostclaim.Claim{V: 1, Host: mpHost, Gen: 9,
32 Holder: &hostclaim.Holder{Family: "instance", Account: "bab", App: "titogo", Incarnation: mpInc, Stamp: 7, Generation: "g-src", Session: 3, Location: "instances/" + mpHost + "/" + mpInc},
33 Op: hostclaim.Op{Kind: kind, ID: mpOp, Phase: phase, Exec: mpTarget, Participants: []string{mpSource, mpTarget},
34 Intent: map[string]string{"to": "uk", "source": mpSource, "target": mpTarget, "source_enrollment": "enr-ie"}}}
35}
36
37// mpSelection is the selection hostbox.WorkSource would capture for that
38// claim on the target.
39func mpSelection(c *hostclaim.Claim, action string) hostbox.Selection {
40 return hostbox.Selection{Host: mpHost, Action: action, Box: mpTarget, Region: "uk",
41 Captured: hostguard.Captured{Incarnation: c.Holder.Incarnation, Stamp: c.Holder.Stamp},
42 Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: c.Holder.Generation, Session: c.Holder.Session}
43}
44
45// mpPins are the inputs the move pinned at readiness.
46func mpPins() *provision.MoveInputs {
47 return &provision.MoveInputs{BundleETags: map[string]string{"default": "e-default", "environment": "e-env"}, OverlayETag: "e-overlay", Release: "v1.2.3"}
48}
49
50// mpRecord is the landing record the reader answers: this move, this
51// incarnation, landing, with the pins.
52func mpRecord() *provision.Record {
53 return &provision.Record{Host: mpHost, Account: "bab", App: "titogo", Region: "uk",
54 Move: &provision.MoveState{ID: mpOp, Incarnation: mpInc, From: "ie", To: "uk", State: provision.MoveLanding, LandingPhase: provision.LandConfigured, Inputs: mpPins()}}
55}
56
57// mpReader counts its reads and answers rec, or err.
58type mpReader struct {
59 reads int
60 rec *provision.Record
61 err error
62 args []string
63}
64
65func (r *mpReader) read(_ context.Context, acct, app, host string) (*provision.Record, error) {
66 r.reads++
67 r.args = []string{acct, app, host}
68 if r.err != nil {
69 return nil, r.err
70 }
71 return r.rec, nil
72}
73
74// mpPolicy is the policy over a reader answering the landing record.
75func mpPolicy() (func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error, *mpReader) {
76 r := &mpReader{rec: mpRecord()}
77 return MovesClaimPolicy(r.read), r
78}
79
80// mpIdentity is the identity that fits the action: the pinned config and
81// release for the held writes, nothing for the rest.
82func mpIdentity(action string) hostwrite.Identity {
83 switch action {
84 case "held-config":
85 return HeldConfigIdentity(mpPins().BundleETags, mpPins().OverlayETag)
86 case "held-release":
87 return hostwrite.Identity{Release: "v1.2.3"}
88 }
89 return hostwrite.Identity{}
90}
91
92var mpActions = []string{"held-config", "held-release", "held-timers", "restore", "spawn", "seed", "writer", "install"}
93
94// Every action against every landing phase, including the probe's and the
95// commit's: granted exactly where the table says, refused with the typed
96// error everywhere else. The same cells through hostbox.Policy.Check with
97// the registry hold the landing has.
98func TestMovesClaimPolicyGrantsByPhase(t *testing.T) {
99 granted := map[string]map[string]bool{
100 provision.LandLocal: {"held-timers": true},
101 provision.LandConfigured: {"held-config": true, "held-release": true, "restore": true, "install": true},
102 // The probe's rows (Plan 3 Task 5): its spawn needs the launch
103 // identity, which mpIdentity does not give it (movesprobe_policy_test).
104 provision.LandProbing: {"held-config": true, "held-release": true},
105 }
106 phases := append(provision.LandingOrder(), "committed")
107 moves, reader := mpPolicy()
108 policy := hostbox.Policy{Moves: moves, Held: func(context.Context, string) (string, error) { return mpOp, nil }}
109 grants := 0
110 for _, phase := range phases {
111 for _, action := range mpActions {
112 c := mpClaim(hostclaim.KindLanding, phase)
113 s := mpSelection(c, action)
114 err := moves(t.Context(), c, s, mpIdentity(action))
115 want := granted[phase][action]
116 if (err == nil) != want {
117 t.Errorf("%s/%s: err %v, want granted %v", phase, action, err, want)
118 continue
119 }
120 if err != nil {
121 var r *MovesPolicyRefusal
122 if !errors.As(err, &r) || !errors.Is(err, ErrMovesRefused) || r.Action != action || r.Phase != phase || r.Kind != hostclaim.KindLanding || r.Reason == "" {
123 t.Errorf("%s/%s: not the typed refusal: %#v", phase, action, err)
124 }
125 }
126 if chk := policy.Check(t.Context(), c, s, mpIdentity(action)); (chk == nil) != want {
127 t.Errorf("%s/%s through Policy.Check: %v, want granted %v", phase, action, chk, want)
128 }
129 if want {
130 grants++
131 }
132 }
133 }
134 if grants != 7 {
135 t.Fatalf("%d cells granted, want 7", grants)
136 }
137 // The record is read for the held config and release only: twice per
138 // pass over the table (the direct call and Policy.Check), at configured
139 // and at probing.
140 if reader.reads != 8 || !reflect.DeepEqual(reader.args, []string{"bab", "titogo", mpHost}) {
141 t.Fatalf("record reads: %d %v", reader.reads, reader.args)
142 }
143}
144
145// The claim's role and the selection's shape: a non-move op, the other move
146// kinds, a mismatched op, box or region, the source after the flip, a lost
147// box, a stale capture, a mismatched kind or phase and a wrong identity are
148// all refused, and nothing but the exact landing cell passes.
149func TestMovesClaimPolicyRefusesOtherRoles(t *testing.T) {
150 base := func() (*hostclaim.Claim, hostbox.Selection) {
151 c := mpClaim(hostclaim.KindLanding, provision.LandLocal)
152 return c, mpSelection(c, "held-timers")
153 }
154 cases := map[string]func(c *hostclaim.Claim, s *hostbox.Selection, id *hostwrite.Identity){
155 "serving op": func(c *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) {
156 c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: mpTarget}
157 s.Kind, s.Op, s.Phase = c.Op.Kind, "", ""
158 },
159 "creating op": func(c *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) {
160 c.Op.Kind = hostclaim.KindCreating
161 s.Kind = c.Op.Kind
162 },
163 "moving, the source executing": func(c *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) {
164 c.Op.Kind, c.Op.Phase, c.Op.Exec = hostclaim.KindMoving, hostclaim.PhaseSealed, mpSource
165 s.Kind, s.Phase, s.Box, s.Region = c.Op.Kind, c.Op.Phase, mpSource, "ie"
166 },
167 "moving, the target asking": func(c *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) {
168 c.Op.Kind, c.Op.Phase = hostclaim.KindMoving, provision.LandLocal
169 s.Kind = c.Op.Kind
170 },
171 "reverting": func(c *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) {
172 c.Op.Kind = hostclaim.KindReverting
173 s.Kind = c.Op.Kind
174 },
175 "restoring": func(c *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) {
176 c.Op.Kind = hostclaim.KindRestoring
177 s.Kind = c.Op.Kind
178 },
179 "another op selected": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { s.Op = "mv_other" },
180 "the claim under another op": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *hostwrite.Identity) {
181 c.Op.ID = "mv_other"
182 },
183 "another host's claim": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *hostwrite.Identity) { c.Host = "other.example" },
184 "the source box after the flip": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) {
185 s.Box, s.Region = mpSource, "ie"
186 },
187 "the source named executor": func(c *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) {
188 c.Op.Exec = mpSource
189 s.Box, s.Region = mpSource, "ie"
190 },
191 "exec none": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *hostwrite.Identity) { c.Op.Exec = "" },
192 "a third box executing": func(c *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) {
193 c.Op.Exec = "de-box"
194 s.Box = "de-box"
195 },
196 "the target not the second participant": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *hostwrite.Identity) {
197 c.Op.Participants = []string{mpTarget, mpSource}
198 },
199 "the intent names another target": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *hostwrite.Identity) {
200 c.Op.Intent["target"] = "de-box"
201 },
202 "the intent names this box as source too": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *hostwrite.Identity) {
203 c.Op.Intent["source"] = mpTarget
204 },
205 "another region": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { s.Region = "ie" },
206 "no region": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { s.Region = "" },
207 "the box is lost": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *hostwrite.Identity) {
208 c.Lost = []string{mpTarget}
209 },
210 "a stale capture": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { s.Captured.Stamp = 6 },
211 "an empty capture": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) {
212 s.Captured = hostguard.Captured{}
213 },
214 "another incarnation captured": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) {
215 s.Captured.Incarnation = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
216 },
217 "the claim moved on since the selection": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *hostwrite.Identity) {
218 c.Op.Phase = provision.LandConfigured
219 },
220 "the selection's kind is not the claim's": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) {
221 s.Kind = hostclaim.KindServing
222 },
223 "no holder": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *hostwrite.Identity) { c.Holder = nil },
224 "no action": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) { s.Action = "" },
225 "an ordinary action": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *hostwrite.Identity) {
226 s.Action = "timer-delivery"
227 },
228 "a config identity on the timers": func(_ *hostclaim.Claim, _ *hostbox.Selection, id *hostwrite.Identity) {
229 id.Config = "sha256:abc"
230 },
231 "a release identity on the timers": func(_ *hostclaim.Claim, _ *hostbox.Selection, id *hostwrite.Identity) {
232 id.Release = "v1"
233 },
234 }
235 for name, mutate := range cases {
236 t.Run(name, func(t *testing.T) {
237 c, s := base()
238 id := hostwrite.Identity{}
239 mutate(c, &s, &id)
240 moves, _ := mpPolicy()
241 err := moves(t.Context(), c, s, id)
242 if err == nil {
243 t.Fatal("granted")
244 }
245 if !errors.Is(err, ErrMovesRefused) {
246 t.Fatalf("not the typed refusal: %v", err)
247 }
248 })
249 }
250 t.Run("the base cell is granted", func(t *testing.T) {
251 c, s := base()
252 moves, _ := mpPolicy()
253 if err := moves(t.Context(), c, s, hostwrite.Identity{}); err != nil {
254 t.Fatal(err)
255 }
256 })
257 t.Run("held-config needs the pinned config identity and no release", func(t *testing.T) {
258 c := mpClaim(hostclaim.KindLanding, provision.LandConfigured)
259 s := mpSelection(c, "held-config")
260 moves, _ := mpPolicy()
261 pinned := mpIdentity("held-config").Config
262 for _, id := range []hostwrite.Identity{{}, {Release: "v1.2.3"}, {Config: pinned, Release: "v1.2.3"}} {
263 if err := moves(t.Context(), c, s, id); !errors.Is(err, ErrMovesRefused) {
264 t.Fatalf("%+v: %v", id, err)
265 }
266 }
267 if err := moves(t.Context(), c, s, hostwrite.Identity{Config: pinned}); err != nil {
268 t.Fatal(err)
269 }
270 })
271 t.Run("held-release needs the pinned release and no config", func(t *testing.T) {
272 c := mpClaim(hostclaim.KindLanding, provision.LandConfigured)
273 s := mpSelection(c, "held-release")
274 moves, _ := mpPolicy()
275 pinned := mpIdentity("held-config").Config
276 for _, id := range []hostwrite.Identity{{}, {Config: pinned}, {Config: pinned, Release: "v1.2.3"}} {
277 if err := moves(t.Context(), c, s, id); !errors.Is(err, ErrMovesRefused) {
278 t.Fatalf("%+v: %v", id, err)
279 }
280 }
281 if err := moves(t.Context(), c, s, hostwrite.Identity{Release: "v1.2.3"}); err != nil {
282 t.Fatal(err)
283 }
284 })
285 t.Run("a nil claim or a dead context", func(t *testing.T) {
286 c, s := base()
287 moves, _ := mpPolicy()
288 if err := moves(t.Context(), nil, s, hostwrite.Identity{}); !errors.Is(err, ErrMovesRefused) {
289 t.Fatal(err)
290 }
291 ctx, cancel := context.WithCancel(t.Context())
292 cancel()
293 if err := moves(ctx, c, s, hostwrite.Identity{}); !errors.Is(err, context.Canceled) {
294 t.Fatal(err)
295 }
296 //lint:ignore SA1012 the nil context is the case under test
297 if err := moves(nil, c, s, hostwrite.Identity{}); !errors.Is(err, ErrMovesRefused) { //nolint:staticcheck
298 t.Fatal(err)
299 }
300 })
301 t.Run("the refusal says what and why", func(t *testing.T) {
302 c, s := base()
303 s.Region = "ie"
304 moves, _ := mpPolicy()
305 err := moves(t.Context(), c, s, hostwrite.Identity{})
306 var r *MovesPolicyRefusal
307 if !errors.As(err, &r) || r.Host != mpHost || r.Op != mpOp || r.Action != "held-timers" || r.Box != mpTarget || r.Kind != hostclaim.KindLanding || r.Phase != provision.LandLocal || r.Reason == "" || err.Error() == "" {
308 t.Fatalf("%#v", err)
309 }
310 })
311}
312
313// The held config and release are the move's pinned inputs (spec §9.1,
314// §9.2): at configured the policy reads the record and grants a held-release
315// identity equal to Inputs.Release and a held-config identity equal to the
316// pinned etags' canonical form, and nothing else. Missing pins, a record
317// that is not this landing, a read error or no reader refuse. The record is
318// not read for any other action.
319func TestMovesClaimPolicyBindsHeldInputsToThePins(t *testing.T) {
320 at := func(action string) (*hostclaim.Claim, hostbox.Selection) {
321 c := mpClaim(hostclaim.KindLanding, provision.LandConfigured)
322 return c, mpSelection(c, action)
323 }
324 refused := func(t *testing.T, err error, reason string) {
325 t.Helper()
326 var r *MovesPolicyRefusal
327 if err == nil || !errors.As(err, &r) || !errors.Is(err, ErrMovesRefused) {
328 t.Fatalf("not the typed refusal: %v", err)
329 }
330 if reason != "" && r.Reason != reason {
331 t.Fatalf("reason %q, want %q", r.Reason, reason)
332 }
333 }
334 t.Run("the pinned identities are granted", func(t *testing.T) {
335 moves, reader := mpPolicy()
336 c, s := at("held-release")
337 if err := moves(t.Context(), c, s, hostwrite.Identity{Release: "v1.2.3"}); err != nil {
338 t.Fatal(err)
339 }
340 c, s = at("held-config")
341 if err := moves(t.Context(), c, s, HeldConfigIdentity(map[string]string{"default": "e-default", "environment": "e-env"}, "e-overlay")); err != nil {
342 t.Fatal(err)
343 }
344 if reader.reads != 2 {
345 t.Fatalf("%d reads", reader.reads)
346 }
347 })
348 t.Run("a mismatched release", func(t *testing.T) {
349 moves, _ := mpPolicy()
350 c, s := at("held-release")
351 for _, v := range []string{"v1.2.4", "v1.2.3 ", "V1.2.3", "sha256:v1.2.3"} {
352 refused(t, moves(t.Context(), c, s, hostwrite.Identity{Release: v}), "the release is not the pinned one")
353 }
354 })
355 t.Run("a mismatched config", func(t *testing.T) {
356 moves, _ := mpPolicy()
357 c, s := at("held-config")
358 for _, id := range []hostwrite.Identity{
359 HeldConfigIdentity(map[string]string{"default": "e-other", "environment": "e-env"}, "e-overlay"),
360 HeldConfigIdentity(map[string]string{"default": "e-default"}, "e-overlay"),
361 HeldConfigIdentity(map[string]string{"default": "e-default", "environment": "e-env", "extra": "e"}, "e-overlay"),
362 HeldConfigIdentity(map[string]string{"default": "e-default", "environment": "e-env"}, "e-other"),
363 HeldConfigIdentity(map[string]string{"default": "e-default", "environment": "e-env"}, ""),
364 {Config: "sha256:abc;default=true;environment=true;instance=true"},
365 {Config: "etags:"},
366 } {
367 refused(t, moves(t.Context(), c, s, id), "the config is not the pinned one")
368 }
369 })
370 t.Run("missing pins", func(t *testing.T) {
371 for name, mutate := range map[string]func(*provision.Record){
372 "no inputs": func(r *provision.Record) { r.Move.Inputs = nil },
373 "no release pinned": func(r *provision.Record) { r.Move.Inputs.Release = "" },
374 } {
375 t.Run(name, func(t *testing.T) {
376 rec := mpRecord()
377 mutate(rec)
378 moves := MovesClaimPolicy((&mpReader{rec: rec}).read)
379 c, s := at("held-release")
380 errRelease := moves(t.Context(), c, s, hostwrite.Identity{Release: "v1.2.3"})
381 c, s = at("held-config")
382 errConfig := moves(t.Context(), c, s, mpIdentity("held-config"))
383 switch name {
384 case "no release pinned":
385 refused(t, errRelease, "no pinned release")
386 if errConfig != nil {
387 t.Fatalf("the config pins are intact: %v", errConfig)
388 }
389 default:
390 refused(t, errRelease, "no pinned inputs")
391 refused(t, errConfig, "no pinned inputs")
392 }
393 })
394 }
395 })
396 // An app without a default bundle or an overlay pins empty etags at
397 // readiness (movesheldenv.go): that is a configuration, with its own
398 // identity, granted when the render names the same empty set. Missing
399 // Move.Inputs is still refused.
400 t.Run("an explicitly pinned empty configuration is granted", func(t *testing.T) {
401 for name, pins := range map[string]*provision.MoveInputs{
402 "nil etags": {Release: "v1.2.3"},
403 "empty map": {BundleETags: map[string]string{}, Release: "v1.2.3"},
404 "all-empty etags": {BundleETags: map[string]string{"default": "", "environment": ""}, OverlayETag: "", Release: "v1.2.3"},
405 } {
406 t.Run(name, func(t *testing.T) {
407 rec := mpRecord()
408 rec.Move.Inputs = pins
409 moves := MovesClaimPolicy((&mpReader{rec: rec}).read)
410 c, s := at("held-config")
411 empty := HeldConfigIdentity(nil, "")
412 if empty.Config == "" {
413 t.Fatal("the empty configuration has no identity")
414 }
415 if err := moves(t.Context(), c, s, empty); err != nil {
416 t.Fatal(err)
417 }
418 if err := moves(t.Context(), c, s, HeldConfigIdentity(map[string]string{"default": "", "environment": ""}, "")); err != nil {
419 t.Fatalf("all-empty etags are the empty configuration: %v", err)
420 }
421 refused(t, moves(t.Context(), c, s, HeldConfigIdentity(map[string]string{"default": "e-default"}, "")), "the config is not the pinned one")
422 refused(t, moves(t.Context(), c, s, HeldConfigIdentity(nil, "e-overlay")), "the config is not the pinned one")
423 refused(t, moves(t.Context(), c, s, hostwrite.Identity{}), "the identity does not fit the action")
424 c, s = at("held-release")
425 if err := moves(t.Context(), c, s, hostwrite.Identity{Release: "v1.2.3"}); err != nil {
426 t.Fatal(err)
427 }
428 })
429 }
430 })
431 // The encoding is structural: a value that contains the delimiters of
432 // a flat encoding cannot collide with another set of etags.
433 t.Run("delimiters in an etag do not collide", func(t *testing.T) {
434 collisions := [][2]struct {
435 etags map[string]string
436 overlay string
437 }{
438 {{map[string]string{"default": "a;environment=b"}, ""}, {map[string]string{"default": "a", "environment": "b"}, ""}},
439 {{map[string]string{"default": "a"}, "x;environment=b"}, {map[string]string{"default": "a", "environment": "b"}, "x"}},
440 {{map[string]string{"default": "a;overlay=o"}, ""}, {map[string]string{"default": "a"}, "o"}},
441 {{map[string]string{"default=x": "a"}, ""}, {map[string]string{"default": "x=a"}, ""}},
442 {{map[string]string{"default": `a","environment":"b`}, ""}, {map[string]string{"default": "a", "environment": "b"}, ""}},
443 }
444 for i, pair := range collisions {
445 a := HeldConfigIdentity(pair[0].etags, pair[0].overlay)
446 b := HeldConfigIdentity(pair[1].etags, pair[1].overlay)
447 if a == b {
448 t.Fatalf("pair %d collides: %q", i, a.Config)
449 }
450 rec := mpRecord()
451 rec.Move.Inputs.BundleETags, rec.Move.Inputs.OverlayETag = pair[1].etags, pair[1].overlay
452 moves := MovesClaimPolicy((&mpReader{rec: rec}).read)
453 c, s := at("held-config")
454 refused(t, moves(t.Context(), c, s, a), "the config is not the pinned one")
455 if err := moves(t.Context(), c, s, b); err != nil {
456 t.Fatalf("pair %d: the pinned set refused: %v", i, err)
457 }
458 }
459 })
460 t.Run("a record read error", func(t *testing.T) {
461 cause := errors.New("the store is down")
462 moves := MovesClaimPolicy((&mpReader{err: cause}).read)
463 for _, action := range []string{"held-release", "held-config"} {
464 c, s := at(action)
465 err := moves(t.Context(), c, s, mpIdentity(action))
466 refused(t, err, "")
467 if !errors.Is(err, cause) {
468 t.Fatalf("the cause is not carried: %v", err)
469 }
470 }
471 })
472 t.Run("a record that is not this landing", func(t *testing.T) {
473 for name, mutate := range map[string]func(*provision.Record) *provision.Record{
474 "absent": func(*provision.Record) *provision.Record { return nil },
475 "no move": func(r *provision.Record) *provision.Record { r.Move = nil; return r },
476 "another move": func(r *provision.Record) *provision.Record { r.Move.ID = "mv_other"; return r },
477 "another incarnation": func(r *provision.Record) *provision.Record { r.Move.Incarnation = "0"; return r },
478 "not landing": func(r *provision.Record) *provision.Record { r.Move.State = provision.MoveRollingBack; return r },
479 "another host": func(r *provision.Record) *provision.Record { r.Host = "other.example"; return r },
480 } {
481 t.Run(name, func(t *testing.T) {
482 moves := MovesClaimPolicy((&mpReader{rec: mutate(mpRecord())}).read)
483 for _, action := range []string{"held-release", "held-config"} {
484 c, s := at(action)
485 refused(t, moves(t.Context(), c, s, mpIdentity(action)), "the record is not this landing")
486 }
487 })
488 }
489 })
490 t.Run("a holder without account or app", func(t *testing.T) {
491 moves, reader := mpPolicy()
492 for _, action := range []string{"held-release", "held-config"} {
493 c, s := at(action)
494 c.Holder.App = ""
495 refused(t, moves(t.Context(), c, s, mpIdentity(action)), "the holder names no account or app")
496 c, s = at(action)
497 c.Holder.Account = ""
498 refused(t, moves(t.Context(), c, s, mpIdentity(action)), "the holder names no account or app")
499 }
500 if reader.reads != 0 {
501 t.Fatalf("%d reads", reader.reads)
502 }
503 })
504 t.Run("no reader", func(t *testing.T) {
505 moves := MovesClaimPolicy(nil)
506 for _, action := range []string{"held-release", "held-config"} {
507 c, s := at(action)
508 refused(t, moves(t.Context(), c, s, mpIdentity(action)), "no record reader")
509 }
510 // The other grants need no record.
511 c := mpClaim(hostclaim.KindLanding, provision.LandLocal)
512 if err := moves(t.Context(), c, mpSelection(c, "held-timers"), hostwrite.Identity{}); err != nil {
513 t.Fatal(err)
514 }
515 })
516 t.Run("the record is read for the held config and release only", func(t *testing.T) {
517 moves, reader := mpPolicy()
518 for phase, action := range map[string]string{provision.LandLocal: "held-timers", provision.LandConfigured: "restore", provision.LandRestored: "install", provision.LandChecking: "spawn"} {
519 c := mpClaim(hostclaim.KindLanding, phase)
520 moves(t.Context(), c, mpSelection(c, action), hostwrite.Identity{})
521 }
522 if reader.reads != 0 {
523 t.Fatalf("%d reads", reader.reads)
524 }
525 })
526 t.Run("the canonical config identity", func(t *testing.T) {
527 a := HeldConfigIdentity(map[string]string{"environment": "e-env", "default": "e-default", "instance": ""}, "e-overlay")
528 b := HeldConfigIdentity(map[string]string{"default": "e-default", "environment": "e-env"}, "e-overlay")
529 if a != b || a.Release != "" || a.Config == "" {
530 t.Fatalf("%+v vs %+v", a, b)
531 }
532 // The empty configuration has one identity, whichever way it is spelt.
533 e1, e2, e3 := HeldConfigIdentity(nil, ""), HeldConfigIdentity(map[string]string{}, ""), HeldConfigIdentity(map[string]string{"default": ""}, "")
534 if e1.Config == "" || e1 != e2 || e2 != e3 || e1 == b {
535 t.Fatalf("%+v %+v %+v", e1, e2, e3)
536 }
537 if HeldConfigIdentity(nil, "o") == e1 || HeldConfigIdentity(map[string]string{"default": "d"}, "") == e1 {
538 t.Fatal("an overlay or an etag alone is the empty configuration")
539 }
540 })
541}
542
543// The held-timers import with CARLOS_HOST_CLAIM off is the registry's own
544// ReplaceTimers: the rows land exactly as before, bookkeeping included,
545// another host's rows untouched. With the flag on, the write runs inside the
546// selected Work, or not at all.
547func TestHeldTimersReplacerPassesThroughWithTheFlagOff(t *testing.T) {
548 t.Parallel()
549 open := func(t *testing.T) *registry.Registry {
550 t.Helper()
551 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
552 if err != nil {
553 t.Fatal(err)
554 }
555 t.Cleanup(func() { reg.Close() })
556 for _, tm := range []registry.Timer{
557 {Host: mpHost, Name: "old", Kind: "schedule", Spec: "@every 1m", Path: "/t", NextAt: 100, CreatedAt: 1},
558 {Host: "other.example", Name: "other", Kind: "schedule", Spec: "@every 1m", Path: "/t", NextAt: 100, CreatedAt: 1},
559 } {
560 if err := reg.UpsertTimer(tm); err != nil {
561 t.Fatal(err)
562 }
563 }
564 return reg
565 }
566 rows := []registry.Timer{
567 {Host: mpHost, Name: "nightly", Kind: "schedule", Spec: "0 3 * * *", Path: "/n", NextAt: 5000, LastAt: 4000, LastStatus: "ok", LastMs: 12, Attempts: 2, CreatedAt: 10, DueAt: 4900, Registration: "reg-nightly"},
568 {Host: mpHost, Name: "once-1", Kind: "once", Spec: "2026-10-06T00:00:00Z", Path: "/o", NextAt: 6000, CreatedAt: 11, DueAt: 6000, Registration: "reg-once"},
569 }
570 unchanged := func(t *testing.T, reg *registry.Registry) {
571 t.Helper()
572 got, _ := reg.TimersFor(mpHost)
573 if len(got) != 1 || got[0].Name != "old" {
574 t.Fatalf("the rows changed: %+v", got)
575 }
576 }
577 t.Run("flag off: the registry's replacement, exactly", func(t *testing.T) {
578 reg, want := open(t), open(t)
579 if err := heldTimersReplacer(hostwrite.Config{}, reg)(t.Context(), mpHost, rows); err != nil {
580 t.Fatal(err)
581 }
582 if err := want.ReplaceTimers(mpHost, rows); err != nil {
583 t.Fatal(err)
584 }
585 got, _ := reg.TimersFor(mpHost)
586 ref, _ := want.TimersFor(mpHost)
587 if len(got) != 2 || !reflect.DeepEqual(got, ref) {
588 t.Fatalf("through the replacer:\n%+v\nthrough the registry:\n%+v", got, ref)
589 }
590 if other, _ := reg.TimersFor("other.example"); len(other) != 1 {
591 t.Fatalf("another host's rows: %+v", other)
592 }
593 })
594 t.Run("flag off: a row for another host writes nothing", func(t *testing.T) {
595 reg := open(t)
596 bad := append([]registry.Timer{{Host: "other.example", Name: "x", Kind: "schedule", Spec: "@every 1m", Path: "/t", NextAt: 1, CreatedAt: 1}}, rows...)
597 if err := heldTimersReplacer(hostwrite.Config{}, reg)(t.Context(), mpHost, bad); err == nil {
598 t.Fatal("accepted")
599 }
600 unchanged(t, reg)
601 })
602 t.Run("flag on, no selector: refused, nothing written", func(t *testing.T) {
603 reg := open(t)
604 if err := heldTimersReplacer(hostwrite.Config{Enabled: true}, reg)(t.Context(), mpHost, rows); !errors.Is(err, hostwrite.ErrUnavailable) {
605 t.Fatal(err)
606 }
607 unchanged(t, reg)
608 })
609 t.Run("flag on: the write runs inside the selected held-timers Work", func(t *testing.T) {
610 reg := open(t)
611 sel := &mpSelector{}
612 if err := heldTimersReplacer(hostwrite.Config{Enabled: true, Selector: sel}, reg)(t.Context(), mpHost, rows); err != nil {
613 t.Fatal(err)
614 }
615 if sel.host != mpHost || sel.action != "held-timers" || sel.with != 1 || sel.identity != (hostwrite.Identity{}) {
616 t.Fatalf("selection: %+v", sel)
617 }
618 if got, _ := reg.TimersFor(mpHost); len(got) != 2 {
619 t.Fatalf("rows: %+v", got)
620 }
621 })
622 t.Run("flag on: a refused selection writes nothing", func(t *testing.T) {
623 reg := open(t)
624 sel := &mpSelector{deny: errors.New("refused")}
625 if err := heldTimersReplacer(hostwrite.Config{Enabled: true, Selector: sel}, reg)(t.Context(), mpHost, rows); err == nil || !errors.Is(err, sel.deny) {
626 t.Fatal(err)
627 }
628 unchanged(t, reg)
629 })
630 t.Run("no registry: refused", func(t *testing.T) {
631 if err := heldTimersReplacer(hostwrite.Config{}, nil)(t.Context(), mpHost, rows); !errors.Is(err, hostwrite.ErrUnavailable) {
632 t.Fatal(err)
633 }
634 })
635}
636
637// mpSelector records the selection the replacer asks for and runs, or
638// refuses, the write.
639type mpSelector struct {
640 host, action string
641 with int
642 identity hostwrite.Identity
643 deny error
644}
645
646func (s *mpSelector) Select(_ context.Context, host, action string) (hostwrite.Work, error) {
647 s.host, s.action = host, action
648 return s, nil
649}
650func (s *mpSelector) With(_ context.Context, id hostwrite.Identity, fn func() error) error {
651 s.with++
652 s.identity = id
653 if s.deny != nil {
654 return s.deny
655 }
656 return fn()
657}
658
659// armPhase1 wires the landing's timers import through the replacer: with
660// the flag off it is the registry's replacement, as before.
661func TestArmPhase1WiresTheHeldTimersImport(t *testing.T) {
662 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
663 if err != nil {
664 t.Fatal(err)
665 }
666 defer reg.Close()
667 var cfg moveCfg
668 hib := activator.Config{Bucket: "data-uk", Region: "eu-west-2", Prefix: "instances"}
669 armPhase1(&cfg, &movesWorker{mirrors: map[string]lease.MirrorStore{}}, hib, t.TempDir(), reg, nil, nil, hostwrite.Config{}, func(string) string { return "" })
670 if cfg.ReplaceTimers == nil {
671 t.Fatal("no timers import")
672 }
673 rows := []registry.Timer{{Host: mpHost, Name: "nightly", Kind: "schedule", Spec: "0 3 * * *", Path: "/n", NextAt: 5000, CreatedAt: 10, DueAt: 4900, Registration: "reg-nightly"}}
674 if err := cfg.ReplaceTimers(t.Context(), mpHost, rows); err != nil {
675 t.Fatal(err)
676 }
677 if got, _ := reg.TimersFor(mpHost); len(got) != 1 || got[0].Name != "nightly" || got[0].Registration != "reg-nightly" {
678 t.Fatalf("rows: %+v", got)
679 }
680 // The flag on without a selector: the import refuses rather than
681 // writing outside the claim.
682 var on moveCfg
683 armPhase1(&on, &movesWorker{mirrors: map[string]lease.MirrorStore{}}, hib, t.TempDir(), reg, nil, nil, hostwrite.Config{Enabled: true}, func(string) string { return "" })
684 if err := on.ReplaceTimers(t.Context(), mpHost, nil); !errors.Is(err, hostwrite.ErrUnavailable) {
685 t.Fatal(err)
686 }
687 if got, _ := reg.TimersFor(mpHost); len(got) != 1 {
688 t.Fatalf("a refused import wrote: %+v", got)
689 }
690}
691
692// The recovery fixtures (Plan 4 Task 3b, ruling J12): the source executing
693// again under the move's op, its row held by the rollback id (restoring) or
694// the move id (reverting).
695const (
696 rpRB = "rb_abcdefghijklmnopqrstuvwxyz"
697 rpStamp = 7 // the source installation's stamp S the flip journalled
698)
699
700// rpRecovery are the inputs the return rendered at configured: another
701// bundle etag and another release than the move pinned.
702func rpRecovery() *provision.MoveInputs {
703 return &provision.MoveInputs{BundleETags: map[string]string{"default": "e-default-2", "environment": "e-env"}, OverlayETag: "e-overlay", Release: "v1.3.0"}
704}
705
706// rpRestoringClaim is the claim of a rollback after the return: restoring at
707// phase, the source executing at S, the rollback id in the intent.
708func rpRestoringClaim(phase string) *hostclaim.Claim {
709 c := mpClaim(hostclaim.KindRestoring, phase)
710 c.Op.Exec = mpSource
711 c.Op.Intent["rollback"] = rpRB
712 c.Op.Journal = map[string]string{hostclaim.JournalSourceStamp: "7", hostclaim.JournalSrcGen: "g-src"}
713 c.Holder.Stamp = rpStamp
714 return c
715}
716
717// rpRevertingClaim is the claim of a revert: reverting, the source
718// executing, rev_phase in the journal and op.phase mirrored from probing on
719// (J1); before it, the sealed moving phase the revert began from.
720func rpRevertingClaim(revPhase string) *hostclaim.Claim {
721 phase := hostclaim.PhaseSealed
722 switch revPhase {
723 case hostclaim.PhaseProbing, hostclaim.PhaseProbed, hostclaim.PhaseOpened:
724 phase = revPhase
725 }
726 c := mpClaim(hostclaim.KindReverting, phase)
727 c.Op.Exec = mpSource
728 c.Op.Journal = map[string]string{hostclaim.JournalRevPhase: revPhase, hostclaim.JournalEvidenceSource: "moves/ev"}
729 return c
730}
731
732// rpSelection is the selection the source captures for that claim.
733func rpSelection(c *hostclaim.Claim, action string) hostbox.Selection {
734 s := mpSelection(c, action)
735 s.Box, s.Region = mpSource, "ie"
736 return s
737}
738
739// rpRestoringRecord is the dirty rollback with the source at phase; the
740// recovery inputs are recorded from the CAS that ends configured on.
741func rpRestoringRecord(phase string) *provision.Record {
742 rec := mpRecord()
743 rec.Move.State, rec.Move.LandingPhase = provision.MoveRollingBack, provision.LandRestored
744 rec.Move.Rollback = &provision.MoveRollback{ID: rpRB, Dirty: true, TargetPhase: provision.RbTargetReturned, SourcePhase: phase}
745 switch phase {
746 case provision.RbSourceRestored, provision.RbSourceProbing, provision.RbSourceProbed, provision.RbSourceRecovered, provision.RbSourceOpened:
747 rec.Move.Rollback.RecoveryInputs = rpRecovery()
748 }
749 return rec
750}
751
752// rpRevertingRecord is the revert's record: reverting, the region still the
753// source's, with the inputs its probe recorded (MoveRecordRevertInputs).
754func rpRevertingRecord() *provision.Record {
755 rec := mpRecord()
756 rec.Region, rec.Move.State, rec.Move.LandingPhase = "ie", provision.MoveReverting, ""
757 rec.Move.RecoveryInputs = rpRecovery()
758 return rec
759}
760
761// rpLaunch is the launch identity a probe's spawn carries (Plan 3 H4).
762func rpLaunch(in *provision.MoveInputs) hostwrite.Identity {
763 return hostwrite.Identity{Config: HeldConfigIdentity(in.BundleETags, in.OverlayETag).Config, Release: in.Release}
764}
765
766// rpIdentity fits the action against the recovery inputs.
767func rpIdentity(action string) hostwrite.Identity {
768 in := rpRecovery()
769 switch action {
770 case "held-config":
771 return HeldConfigIdentity(in.BundleETags, in.OverlayETag)
772 case "held-release":
773 return hostwrite.Identity{Release: in.Release}
774 case "spawn":
775 return rpLaunch(in)
776 }
777 return hostwrite.Identity{}
778}
779
780// rpClaimPhase is the claim phase a rollback's source phase runs under: the
781// claim stays at claimed through the source's install (Task 3) and mirrors
782// probing, probed and opened (J1).
783func rpClaimPhase(sourcePhase string) string {
784 switch sourcePhase {
785 case provision.RbSourceWaiting:
786 return hostclaim.PhaseReleased
787 case provision.RbSourceProbing, provision.RbSourceProbed, provision.RbSourceOpened:
788 return sourcePhase
789 case provision.RbSourceRecovered:
790 return hostclaim.PhaseProbed
791 }
792 return hostclaim.PhaseClaimed
793}
794
795var rpSourcePhases = []string{
796 provision.RbSourceWaiting, provision.RbSourceClaimed, provision.RbSourceRestoring, provision.RbSourceVerified,
797 provision.RbSourceParking, provision.RbSourceBuilding, provision.RbSourcePublished, provision.RbSourceLocal,
798 provision.RbSourceConfigured, provision.RbSourceRestored, provision.RbSourceProbing, provision.RbSourceProbed,
799 provision.RbSourceRecovered, provision.RbSourceOpened,
800}
801
802func rpRefused(t *testing.T, err error, reason string) {
803 t.Helper()
804 var r *MovesPolicyRefusal
805 if err == nil || !errors.As(err, &r) || !errors.Is(err, ErrMovesRefused) {
806 t.Fatalf("not the typed refusal: %v", err)
807 }
808 if reason != "" && r.Reason != reason {
809 t.Fatalf("reason %q, want %q", r.Reason, reason)
810 }
811}
812
813// restoring, the source's return (J12): every action against every source
814// phase of the rollback, under the claim phase that phase runs under. The
815// dirty install's held writes are granted at the phases that write them
816// (timers at local; config, release, restore and install at configured,
817// whose closing CAS writes restored, G8 and the final review's finding 3),
818// and the held config render, the release materialization and the spawn at
819// probing (§7.7); nothing else, restored included. A record phase under the
820// wrong claim phase is refused.
821func TestMovesClaimPolicyRestoringRows(t *testing.T) {
822 granted := map[string]map[string]bool{
823 provision.RbSourceLocal: {"held-timers": true},
824 provision.RbSourceConfigured: {"held-config": true, "held-release": true, "restore": true, "install": true},
825 provision.RbSourceProbing: {"held-config": true, "held-release": true, "spawn": true},
826 }
827 grants := 0
828 for _, phase := range rpSourcePhases {
829 reader := &mpReader{rec: rpRestoringRecord(phase)}
830 moves := MovesClaimPolicy(reader.read)
831 for _, action := range mpActions {
832 c := rpRestoringClaim(rpClaimPhase(phase))
833 err := moves(t.Context(), c, rpSelection(c, action), rpIdentity(action))
834 want := granted[phase][action]
835 if (err == nil) != want {
836 t.Errorf("%s/%s: err %v, want granted %v", phase, action, err, want)
837 continue
838 }
839 if err != nil {
840 var r *MovesPolicyRefusal
841 if !errors.As(err, &r) || r.Kind != hostclaim.KindRestoring || r.Box != mpSource || r.Reason == "" {
842 t.Errorf("%s/%s: not the typed refusal: %#v", phase, action, err)
843 }
844 } else {
845 grants++
846 }
847 }
848 if reader.reads == 0 || !reflect.DeepEqual(reader.args, []string{"bab", "titogo", mpHost}) {
849 t.Errorf("%s: the rows are keyed by the record: %d reads %v", phase, reader.reads, reader.args)
850 }
851 }
852 if grants != 8 {
853 t.Fatalf("%d cells granted, want 8", grants)
854 }
855 t.Run("the wrong claim phase", func(t *testing.T) {
856 for _, tc := range []struct{ record, claim, action string }{
857 {provision.RbSourceLocal, hostclaim.PhaseProbing, "held-timers"},
858 {provision.RbSourceConfigured, hostclaim.PhaseReleased, "held-config"},
859 {provision.RbSourceConfigured, hostclaim.PhaseProbed, "install"},
860 {provision.RbSourceProbing, hostclaim.PhaseClaimed, "spawn"},
861 {provision.RbSourceProbing, hostclaim.PhaseProbed, "held-config"},
862 } {
863 moves := MovesClaimPolicy((&mpReader{rec: rpRestoringRecord(tc.record)}).read)
864 c := rpRestoringClaim(tc.claim)
865 rpRefused(t, moves(t.Context(), c, rpSelection(c, tc.action), rpIdentity(tc.action)), "")
866 }
867 })
868}
869
870// reverting, the source's pre-flip recovery (J12): the rows are keyed by the
871// claim's rev_phase. Only probing grants: the held config render and the
872// release materialization before each probe (§7.7), and the spawn. The same cells through hostbox.Policy.Check with the
873// revert's hold, the move id.
874func TestMovesClaimPolicyRevertingRows(t *testing.T) {
875 reader := &mpReader{rec: rpRevertingRecord()}
876 moves := MovesClaimPolicy(reader.read)
877 policy := hostbox.Policy{Moves: moves, Held: func(context.Context, string) (string, error) { return mpOp, nil }}
878 grants := 0
879 for _, rev := range []string{hostclaim.RevPhaseEvidence, hostclaim.RevPhaseVerify, hostclaim.PhaseProbing, hostclaim.PhaseProbed, hostclaim.PhaseOpened} {
880 for _, action := range mpActions {
881 c := rpRevertingClaim(rev)
882 s := rpSelection(c, action)
883 err := moves(t.Context(), c, s, rpIdentity(action))
884 want := rev == hostclaim.PhaseProbing && (action == "held-config" || action == "held-release" || action == "spawn")
885 if (err == nil) != want {
886 t.Errorf("%s/%s: err %v, want granted %v", rev, action, err, want)
887 continue
888 }
889 if err != nil && !errors.Is(err, ErrMovesRefused) {
890 t.Errorf("%s/%s: not the typed refusal: %v", rev, action, err)
891 }
892 if chk := policy.Check(t.Context(), c, s, rpIdentity(action)); (chk == nil) != want {
893 t.Errorf("%s/%s through Policy.Check: %v, want granted %v", rev, action, chk, want)
894 }
895 if want {
896 grants++
897 }
898 }
899 }
900 if grants != 3 {
901 t.Fatalf("%d cells granted, want 3", grants)
902 }
903 t.Run("rev_phase and op.phase disagree", func(t *testing.T) {
904 c := rpRevertingClaim(hostclaim.PhaseProbing)
905 c.Op.Phase = hostclaim.PhaseSealed
906 rpRefused(t, moves(t.Context(), c, rpSelection(c, "spawn"), rpIdentity("spawn")), "")
907 c = rpRevertingClaim(hostclaim.RevPhaseVerify)
908 c.Op.Phase = hostclaim.PhaseProbing
909 rpRefused(t, moves(t.Context(), c, rpSelection(c, "spawn"), rpIdentity("spawn")), "")
910 })
911}
912
913// M11 (Q11): a revert's held render, held release and spawn are bound to the
914// inputs its probe recorded before rendering them (Move.RecoveryInputs,
915// MoveRecordRevertInputs at probing), as a rollback's are bound to
916// Rollback.RecoveryInputs: never to the move's pins, never by shape alone,
917// and nothing before the record.
918func TestMovesClaimPolicyRevertBindsRecordedInputs(t *testing.T) {
919 recovered, pinned := rpRecovery(), mpPins()
920 c := rpRevertingClaim(hostclaim.PhaseProbing)
921 rec := rpRevertingRecord()
922 moves := MovesClaimPolicy((&mpReader{rec: rec}).read)
923 try := func(action string, id hostwrite.Identity) error {
924 return moves(t.Context(), c, rpSelection(c, action), id)
925 }
926 cfgOf := func(in *provision.MoveInputs) hostwrite.Identity {
927 return HeldConfigIdentity(in.BundleETags, in.OverlayETag)
928 }
929 for action, id := range map[string]hostwrite.Identity{"held-config": cfgOf(recovered), "held-release": {Release: recovered.Release}, "spawn": rpLaunch(recovered)} {
930 if err := try(action, id); err != nil {
931 t.Fatalf("%s with the recorded inputs: %v", action, err)
932 }
933 }
934 rpRefused(t, try("held-config", cfgOf(pinned)), "the config is not the recovery's")
935 rpRefused(t, try("held-release", hostwrite.Identity{Release: pinned.Release}), "the release is not the recovery's")
936 rpRefused(t, try("spawn", rpLaunch(pinned)), "the launch is not the recovery's")
937 rpRefused(t, try("spawn", hostwrite.Identity{Config: cfgOf(recovered).Config, Release: pinned.Release}), "the launch is not the recovery's")
938
939 // Nothing recorded: the probe records before it renders, so every row
940 // refuses; the move's pins never stand in.
941 rec.Move.RecoveryInputs = nil
942 for action, id := range map[string]hostwrite.Identity{"held-config": cfgOf(pinned), "held-release": {Release: pinned.Release}, "spawn": rpLaunch(pinned)} {
943 rpRefused(t, try(action, id), "no recorded recovery inputs")
944 }
945 rec.Move.RecoveryInputs = &provision.MoveInputs{BundleETags: recovered.BundleETags, OverlayETag: recovered.OverlayETag}
946 rpRefused(t, try("held-release", hostwrite.Identity{Release: recovered.Release}), "no recorded recovery release")
947 rpRefused(t, try("spawn", rpLaunch(recovered)), "no recorded recovery release")
948}
949
950// Only the claim op's executor, the move's source, in the source's region,
951// for this rollback's record: the target (the cleaning's ReplaceTimers
952// included, which is a plain registry call and never a policy row), a third
953// box, a lost source and every record that is not this recovery are refused.
954func TestMovesClaimPolicyRecoveryRefusesOtherRoles(t *testing.T) {
955 type cell struct {
956 claim func() *hostclaim.Claim
957 record func() *provision.Record
958 action string
959 }
960 restoring := func(sourcePhase, action string) cell {
961 return cell{func() *hostclaim.Claim { return rpRestoringClaim(rpClaimPhase(sourcePhase)) },
962 func() *provision.Record { return rpRestoringRecord(sourcePhase) }, action}
963 }
964 reverting := cell{func() *hostclaim.Claim { return rpRevertingClaim(hostclaim.PhaseProbing) }, rpRevertingRecord, "spawn"}
965 bases := map[string]cell{
966 "restoring local": restoring(provision.RbSourceLocal, "held-timers"),
967 "restoring configured": restoring(provision.RbSourceConfigured, "held-config"),
968 "restoring install": restoring(provision.RbSourceConfigured, "install"),
969 "restoring probing": restoring(provision.RbSourceProbing, "spawn"),
970 "reverting probing": reverting,
971 }
972 type mutation func(c *hostclaim.Claim, s *hostbox.Selection, rec *provision.Record)
973 cases := map[string]mutation{
974 "the target asking, the source executing": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *provision.Record) {
975 s.Box, s.Region = mpTarget, "uk"
976 },
977 "the target executing and asking": func(c *hostclaim.Claim, s *hostbox.Selection, _ *provision.Record) {
978 c.Op.Exec = mpTarget
979 s.Box, s.Region = mpTarget, "uk"
980 },
981 "the target executing, the source asking": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *provision.Record) {
982 c.Op.Exec = mpTarget
983 },
984 "exec none": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *provision.Record) { c.Op.Exec = "" },
985 "a third box executing and asking": func(c *hostclaim.Claim, s *hostbox.Selection, _ *provision.Record) {
986 c.Op.Exec, s.Box = "de-box", "de-box"
987 },
988 "the source not the first participant": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *provision.Record) {
989 c.Op.Participants = []string{mpTarget, mpSource}
990 },
991 "no participants": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *provision.Record) { c.Op.Participants = nil },
992 "the intent names another source": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *provision.Record) {
993 c.Op.Intent["source"] = "de-box"
994 },
995 "the intent names this box as target too": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *provision.Record) {
996 c.Op.Intent["target"] = mpSource
997 },
998 "the source is lost": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *provision.Record) { c.Lost = []string{mpSource} },
999 "the target's region": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *provision.Record) { s.Region = "uk" },
1000 "a third region": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *provision.Record) { s.Region = "de" },
1001 "the record names another source region": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) {
1002 rec.Move.From = "de"
1003 },
1004 "a stale capture": func(_ *hostclaim.Claim, s *hostbox.Selection, _ *provision.Record) { s.Captured.Stamp = 6 },
1005 "another move's record": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) {
1006 rec.Move.ID = "mv_other"
1007 },
1008 "another incarnation's record": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) {
1009 rec.Move.Incarnation = "0"
1010 },
1011 "another host's record": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) { rec.Host = "other.example" },
1012 "no move": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) { rec.Move = nil },
1013 "a stuck record": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) {
1014 rec.Move.State = provision.MoveStuck
1015 },
1016 "a landing record": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) {
1017 rec.Move.State = provision.MoveLanding
1018 },
1019 }
1020 for baseName, base := range bases {
1021 for name, mutate := range cases {
1022 t.Run(baseName+"/"+name, func(t *testing.T) {
1023 c, rec := base.claim(), base.record()
1024 s := rpSelection(c, base.action)
1025 mutate(c, &s, rec)
1026 moves := MovesClaimPolicy((&mpReader{rec: rec}).read)
1027 rpRefused(t, moves(t.Context(), c, s, rpIdentity(base.action)), "")
1028 })
1029 }
1030 t.Run(baseName+"/the base cell is granted", func(t *testing.T) {
1031 c := base.claim()
1032 moves := MovesClaimPolicy((&mpReader{rec: base.record()}).read)
1033 if err := moves(t.Context(), c, rpSelection(c, base.action), rpIdentity(base.action)); err != nil {
1034 t.Fatal(err)
1035 }
1036 })
1037 }
1038 restoringOnly := map[string]mutation{
1039 "another rollback's record": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) {
1040 rec.Move.Rollback.ID = "rb_zzzzzzzzzzzzzzzzzzzzzzzzzz"
1041 },
1042 "the claim names no rollback": func(c *hostclaim.Claim, _ *hostbox.Selection, _ *provision.Record) {
1043 delete(c.Op.Intent, "rollback")
1044 },
1045 "no rollback journal": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) { rec.Move.Rollback = nil },
1046 "a reverting record": func(_ *hostclaim.Claim, _ *hostbox.Selection, rec *provision.Record) {
1047 rec.Move.State = provision.MoveReverting
1048 },
1049 }
1050 for name, mutate := range restoringOnly {
1051 for _, phase := range []string{provision.RbSourceLocal, provision.RbSourceProbing} {
1052 t.Run("restoring "+phase+"/"+name, func(t *testing.T) {
1053 base := restoring(phase, map[string]string{provision.RbSourceLocal: "held-timers", provision.RbSourceProbing: "spawn"}[phase])
1054 c, rec := base.claim(), base.record()
1055 s := rpSelection(c, base.action)
1056 mutate(c, &s, rec)
1057 moves := MovesClaimPolicy((&mpReader{rec: rec}).read)
1058 rpRefused(t, moves(t.Context(), c, s, rpIdentity(base.action)), "")
1059 })
1060 }
1061 }
1062 t.Run("a reverting claim over a rolling_back record", func(t *testing.T) {
1063 c := rpRevertingClaim(hostclaim.PhaseProbing)
1064 moves := MovesClaimPolicy((&mpReader{rec: rpRestoringRecord(provision.RbSourceProbing)}).read)
1065 rpRefused(t, moves(t.Context(), c, rpSelection(c, "spawn"), rpIdentity("spawn")), "")
1066 })
1067 t.Run("the dirty install rows need a dirty rollback", func(t *testing.T) {
1068 for _, tc := range []struct{ phase, action string }{
1069 {provision.RbSourceLocal, "held-timers"}, {provision.RbSourceConfigured, "held-config"},
1070 {provision.RbSourceConfigured, "restore"}, {provision.RbSourceConfigured, "install"},
1071 } {
1072 phase, action := tc.phase, tc.action
1073 rec := rpRestoringRecord(phase)
1074 rec.Move.Rollback.Dirty = false
1075 c := rpRestoringClaim(hostclaim.PhaseClaimed)
1076 moves := MovesClaimPolicy((&mpReader{rec: rec}).read)
1077 rpRefused(t, moves(t.Context(), c, rpSelection(c, action), rpIdentity(action)), "")
1078 }
1079 })
1080 t.Run("the install runs at the source's pre-move stamp", func(t *testing.T) {
1081 for _, mutate := range []func(*hostclaim.Claim){
1082 func(c *hostclaim.Claim) { c.Op.Journal[hostclaim.JournalSourceStamp] = "8" },
1083 func(c *hostclaim.Claim) { delete(c.Op.Journal, hostclaim.JournalSourceStamp) },
1084 } {
1085 c := rpRestoringClaim(hostclaim.PhaseClaimed)
1086 mutate(c)
1087 moves := MovesClaimPolicy((&mpReader{rec: rpRestoringRecord(provision.RbSourceLocal)}).read)
1088 rpRefused(t, moves(t.Context(), c, rpSelection(c, "held-timers"), hostwrite.Identity{}), "")
1089 }
1090 // The probe's cold admission issues a new stamp (J14): probing is not
1091 // bound to S.
1092 c := rpRestoringClaim(hostclaim.PhaseProbing)
1093 c.Holder.Stamp = 12
1094 s := rpSelection(c, "spawn")
1095 moves := MovesClaimPolicy((&mpReader{rec: rpRestoringRecord(provision.RbSourceProbing)}).read)
1096 if err := moves(t.Context(), c, s, rpIdentity("spawn")); err != nil {
1097 t.Fatal(err)
1098 }
1099 })
1100 t.Run("the target's cleaning timers are no policy row", func(t *testing.T) {
1101 for _, phase := range []string{hostclaim.PhaseReleasing, hostclaim.PhaseReleased, hostclaim.PhaseClaimed, hostclaim.PhaseProbing, hostclaim.PhaseOpened} {
1102 for _, exec := range []string{mpTarget, "", mpSource} {
1103 c := rpRestoringClaim(phase)
1104 c.Op.Exec = exec
1105 s := rpSelection(c, "held-timers")
1106 s.Box, s.Region = mpTarget, "uk"
1107 moves := MovesClaimPolicy((&mpReader{rec: rpRestoringRecord(provision.RbSourceLocal)}).read)
1108 rpRefused(t, moves(t.Context(), c, s, hostwrite.Identity{}), "")
1109 }
1110 }
1111 })
1112 t.Run("no reader, a read error, no account", func(t *testing.T) {
1113 for _, b := range []cell{restoring(provision.RbSourceLocal, "held-timers"), reverting} {
1114 c := b.claim()
1115 rpRefused(t, MovesClaimPolicy(nil)(t.Context(), c, rpSelection(c, b.action), rpIdentity(b.action)), "no record reader")
1116 cause := errors.New("the store is down")
1117 err := MovesClaimPolicy((&mpReader{err: cause}).read)(t.Context(), c, rpSelection(c, b.action), rpIdentity(b.action))
1118 rpRefused(t, err, "the record cannot be read")
1119 if !errors.Is(err, cause) {
1120 t.Fatalf("the cause is not carried: %v", err)
1121 }
1122 c.Holder.Account = ""
1123 rpRefused(t, MovesClaimPolicy((&mpReader{rec: b.record()}).read)(t.Context(), c, rpSelection(c, b.action), rpIdentity(b.action)), "the holder names no account or app")
1124 }
1125 })
1126 t.Run("a role refusal reads no record", func(t *testing.T) {
1127 reader := &mpReader{rec: rpRestoringRecord(provision.RbSourceLocal)}
1128 c := rpRestoringClaim(hostclaim.PhaseClaimed)
1129 s := rpSelection(c, "held-timers")
1130 s.Box, s.Region = mpTarget, "uk"
1131 rpRefused(t, MovesClaimPolicy(reader.read)(t.Context(), c, s, hostwrite.Identity{}), "")
1132 s = rpSelection(c, "held-timers")
1133 s.Region = "uk"
1134 rpRefused(t, MovesClaimPolicy(reader.read)(t.Context(), c, s, hostwrite.Identity{}), "this region is the move's target region")
1135 c.Lost = []string{mpSource}
1136 rpRefused(t, MovesClaimPolicy(reader.read)(t.Context(), c, rpSelection(c, "held-timers"), hostwrite.Identity{}), "this box is declared lost")
1137 if reader.reads != 0 {
1138 t.Fatalf("%d reads", reader.reads)
1139 }
1140 })
1141}
1142
1143// A recovery renders the current inputs and records them as
1144// Rollback.RecoveryInputs; it is never bound to Move.Inputs (spec §7.7,
1145// J2-R2-5). The held config and release at configured are that render
1146// (nothing is recorded until the CAS that ends configured); from then on
1147// every held render and the spawn name the recorded recovery inputs. A
1148// revert has no recorded recovery inputs: its render and spawn are bound by
1149// shape and never to Move.Inputs. The same difference on a landing is
1150// refused.
1151func TestMovesClaimPolicyBindsRecoveryInputs(t *testing.T) {
1152 pinned, recovered := mpPins(), rpRecovery()
1153 try := func(phase, action string, rec *provision.Record, id hostwrite.Identity) error {
1154 c := rpRestoringClaim(rpClaimPhase(phase))
1155 return MovesClaimPolicy((&mpReader{rec: rec}).read)(t.Context(), c, rpSelection(c, action), id)
1156 }
1157 cfgOf := func(in *provision.MoveInputs) hostwrite.Identity {
1158 return HeldConfigIdentity(in.BundleETags, in.OverlayETag)
1159 }
1160 t.Run("configured: the render that records them", func(t *testing.T) {
1161 rec := rpRestoringRecord(provision.RbSourceConfigured)
1162 for _, in := range []*provision.MoveInputs{recovered, pinned} {
1163 if err := try(provision.RbSourceConfigured, "held-config", rec, cfgOf(in)); err != nil {
1164 t.Fatalf("%+v: %v", in, err)
1165 }
1166 if err := try(provision.RbSourceConfigured, "held-release", rec, hostwrite.Identity{Release: in.Release}); err != nil {
1167 t.Fatalf("%+v: %v", in, err)
1168 }
1169 }
1170 rpRefused(t, try(provision.RbSourceConfigured, "held-config", rec, hostwrite.Identity{Config: cfgOf(recovered).Config, Release: "v1.3.0"}), "the identity does not fit the action")
1171 rpRefused(t, try(provision.RbSourceConfigured, "held-release", rec, hostwrite.Identity{}), "the identity does not fit the action")
1172 // A replay of configured that finds them recorded is bound to them.
1173 rec.Move.Rollback.RecoveryInputs = rpRecovery()
1174 if err := try(provision.RbSourceConfigured, "held-config", rec, cfgOf(recovered)); err != nil {
1175 t.Fatal(err)
1176 }
1177 rpRefused(t, try(provision.RbSourceConfigured, "held-config", rec, cfgOf(pinned)), "the config is not the recovery's")
1178 rpRefused(t, try(provision.RbSourceConfigured, "held-release", rec, hostwrite.Identity{Release: pinned.Release}), "the release is not the recovery's")
1179 })
1180 t.Run("probing, dirty: the recorded recovery inputs, not the pins", func(t *testing.T) {
1181 rec := rpRestoringRecord(provision.RbSourceProbing)
1182 if err := try(provision.RbSourceProbing, "held-config", rec, cfgOf(recovered)); err != nil {
1183 t.Fatal(err)
1184 }
1185 if err := try(provision.RbSourceProbing, "spawn", rec, rpLaunch(recovered)); err != nil {
1186 t.Fatal(err)
1187 }
1188 rpRefused(t, try(provision.RbSourceProbing, "held-config", rec, cfgOf(pinned)), "the config is not the recovery's")
1189 rpRefused(t, try(provision.RbSourceProbing, "spawn", rec, rpLaunch(pinned)), "the launch is not the recovery's")
1190 rpRefused(t, try(provision.RbSourceProbing, "spawn", rec, hostwrite.Identity{Config: cfgOf(recovered).Config, Release: pinned.Release}), "the launch is not the recovery's")
1191 for _, id := range []hostwrite.Identity{{}, {Config: cfgOf(recovered).Config}, {Release: recovered.Release}} {
1192 rpRefused(t, try(provision.RbSourceProbing, "spawn", rec, id), "the identity does not fit the action")
1193 }
1194 rec.Move.Rollback.RecoveryInputs = nil
1195 rpRefused(t, try(provision.RbSourceProbing, "held-config", rec, cfgOf(recovered)), "no recorded recovery inputs")
1196 rpRefused(t, try(provision.RbSourceProbing, "spawn", rec, rpLaunch(recovered)), "no recorded recovery inputs")
1197 rec.Move.Rollback.RecoveryInputs = &provision.MoveInputs{BundleETags: recovered.BundleETags}
1198 rpRefused(t, try(provision.RbSourceProbing, "spawn", rec, rpLaunch(recovered)), "no recorded recovery release")
1199 })
1200 t.Run("probing, clean: the render before the record, the spawn after it", func(t *testing.T) {
1201 rec := rpRestoringRecord(provision.RbSourceProbing)
1202 rec.Move.Rollback.Dirty, rec.Move.Rollback.RecoveryInputs = false, nil
1203 if err := try(provision.RbSourceProbing, "held-config", rec, cfgOf(recovered)); err != nil {
1204 t.Fatal(err)
1205 }
1206 rpRefused(t, try(provision.RbSourceProbing, "spawn", rec, rpLaunch(recovered)), "no recorded recovery inputs")
1207 rec.Move.Rollback.RecoveryInputs = rpRecovery()
1208 if err := try(provision.RbSourceProbing, "spawn", rec, rpLaunch(recovered)); err != nil {
1209 t.Fatal(err)
1210 }
1211 rpRefused(t, try(provision.RbSourceProbing, "held-config", rec, cfgOf(pinned)), "the config is not the recovery's")
1212 })
1213 t.Run("reverting: the recorded inputs, never the pins (M11)", func(t *testing.T) {
1214 moves := MovesClaimPolicy((&mpReader{rec: rpRevertingRecord()}).read)
1215 c := rpRevertingClaim(hostclaim.PhaseProbing)
1216 if err := moves(t.Context(), c, rpSelection(c, "held-config"), cfgOf(recovered)); err != nil {
1217 t.Fatal(err)
1218 }
1219 if err := moves(t.Context(), c, rpSelection(c, "spawn"), rpLaunch(recovered)); err != nil {
1220 t.Fatal(err)
1221 }
1222 rpRefused(t, moves(t.Context(), c, rpSelection(c, "held-config"), cfgOf(pinned)), "the config is not the recovery's")
1223 rpRefused(t, moves(t.Context(), c, rpSelection(c, "spawn"), rpLaunch(pinned)), "the launch is not the recovery's")
1224 for _, id := range []hostwrite.Identity{{}, {Config: cfgOf(recovered).Config}, {Release: "v1.3.0"}} {
1225 rpRefused(t, moves(t.Context(), c, rpSelection(c, "spawn"), id), "the identity does not fit the action")
1226 }
1227 rpRefused(t, moves(t.Context(), c, rpSelection(c, "held-config"), rpLaunch(recovered)), "the identity does not fit the action")
1228 if err := moves(t.Context(), c, rpSelection(c, "held-release"), hostwrite.Identity{Release: recovered.Release}); err != nil {
1229 t.Fatal(err)
1230 }
1231 rpRefused(t, moves(t.Context(), c, rpSelection(c, "held-release"), hostwrite.Identity{Release: pinned.Release}), "the release is not the recovery's")
1232 rpRefused(t, moves(t.Context(), c, rpSelection(c, "held-release"), rpLaunch(recovered)), "the identity does not fit the action")
1233 })
1234 t.Run("a landing: the same difference is refused", func(t *testing.T) {
1235 moves, _ := mpPolicy()
1236 c := mpClaim(hostclaim.KindLanding, provision.LandConfigured)
1237 rpRefused(t, moves(t.Context(), c, mpSelection(c, "held-config"), cfgOf(recovered)), "the config is not the pinned one")
1238 rpRefused(t, moves(t.Context(), c, mpSelection(c, "held-release"), hostwrite.Identity{Release: recovered.Release}), "the release is not the pinned one")
1239 // A landing record carrying a rollback's recovery inputs still binds
1240 // to the pins.
1241 rec := mpRecord()
1242 rec.Move.Rollback = &provision.MoveRollback{ID: rpRB, RecoveryInputs: rpRecovery()}
1243 moves = MovesClaimPolicy((&mpReader{rec: rec}).read)
1244 rpRefused(t, moves(t.Context(), c, mpSelection(c, "held-config"), cfgOf(recovered)), "the config is not the pinned one")
1245 })
1246}
1247
1248// End to end (Task 3b fix round 1): the source's return writes through
1249// hostbox.Policy.Check with its row held by the rollback id, as the J3
1250// hand-over leaves it, and MovesClaimPolicy as Policy.Moves. Policy.Check
1251// takes the hold's owner from the claim's intent.rollback; the move id's
1252// hold, another rollback's, the target asking and the wrong phase refuse.
1253func TestMovesClaimPolicyRestoringThroughPolicyCheck(t *testing.T) {
1254 through := func(hold string, rec *provision.Record, c *hostclaim.Claim, s hostbox.Selection, id hostwrite.Identity) error {
1255 p := hostbox.Policy{Moves: MovesClaimPolicy((&mpReader{rec: rec}).read), Held: func(context.Context, string) (string, error) { return hold, nil }}
1256 return p.Check(t.Context(), c, s, id)
1257 }
1258 for _, tc := range []struct{ phase, action string }{
1259 {provision.RbSourceLocal, "held-timers"},
1260 {provision.RbSourceConfigured, "held-config"},
1261 {provision.RbSourceConfigured, "restore"},
1262 {provision.RbSourceConfigured, "install"},
1263 {provision.RbSourceProbing, "spawn"},
1264 } {
1265 phase, action := tc.phase, tc.action
1266 c := rpRestoringClaim(rpClaimPhase(phase))
1267 s := rpSelection(c, action)
1268 if err := through(rpRB, rpRestoringRecord(phase), c, s, rpIdentity(action)); err != nil {
1269 t.Errorf("%s/%s held by the rollback: %v", phase, action, err)
1270 }
1271 for _, hold := range []string{mpOp, "rb_zzzzzzzzzzzzzzzzzzzzzzzzzz"} {
1272 if err := through(hold, rpRestoringRecord(phase), c, s, rpIdentity(action)); !errors.Is(err, hostbox.ErrChanged) {
1273 t.Errorf("%s/%s held by %s: %v", phase, action, hold, err)
1274 }
1275 }
1276 }
1277 c := rpRestoringClaim(hostclaim.PhaseClaimed)
1278 s := rpSelection(c, "held-timers")
1279 s.Box, s.Region = mpTarget, "uk"
1280 if err := through(rpRB, rpRestoringRecord(provision.RbSourceLocal), c, s, hostwrite.Identity{}); err == nil {
1281 t.Fatal("the target's timers granted under the rollback's hold")
1282 }
1283 s = rpSelection(c, "held-timers")
1284 if err := through(rpRB, rpRestoringRecord(provision.RbSourceConfigured), c, s, hostwrite.Identity{}); !errors.Is(err, ErrMovesRefused) {
1285 t.Fatalf("held-timers at configured: %v", err)
1286 }
1287 // restored is written by the CAS that ends configured (G8): the install
1288 // is done, and nothing is restored or installed there.
1289 for _, action := range []string{"restore", "install"} {
1290 s = rpSelection(c, action)
1291 if err := through(rpRB, rpRestoringRecord(provision.RbSourceRestored), c, s, hostwrite.Identity{}); !errors.Is(err, ErrMovesRefused) {
1292 t.Fatalf("%s at restored: %v", action, err)
1293 }
1294 }
1295}
1296
1297// The release is materialized before every recovery probe (§7.7; Task 3b
1298// fix round 2), through hostbox.Policy.Check with each recovery's own hold:
1299// held-release at probing is granted to the executing source, bound to the
1300// recorded recovery inputs on a return and on a revert (M11), and
1301// refused at another phase, to another box, and for a release that is not
1302// the recorded one.
1303func TestMovesClaimPolicyRecoveryReleaseAtProbing(t *testing.T) {
1304 through := func(hold string, rec *provision.Record, c *hostclaim.Claim, s hostbox.Selection, id hostwrite.Identity) error {
1305 p := hostbox.Policy{Moves: MovesClaimPolicy((&mpReader{rec: rec}).read), Held: func(context.Context, string) (string, error) { return hold, nil }}
1306 return p.Check(t.Context(), c, s, id)
1307 }
1308 recovered, pinned := rpRecovery(), mpPins()
1309 t.Run("restoring", func(t *testing.T) {
1310 c := rpRestoringClaim(hostclaim.PhaseProbing)
1311 s := rpSelection(c, "held-release")
1312 rec := rpRestoringRecord(provision.RbSourceProbing)
1313 if err := through(rpRB, rec, c, s, hostwrite.Identity{Release: recovered.Release}); err != nil {
1314 t.Fatalf("the executor at probing: %v", err)
1315 }
1316 for _, v := range []string{pinned.Release, "v1.3.1", ""} {
1317 if err := through(rpRB, rec, c, s, hostwrite.Identity{Release: v}); err == nil {
1318 t.Fatalf("release %q granted against the recorded %q", v, recovered.Release)
1319 }
1320 }
1321 rpRefused(t, through(rpRB, rec, c, s, hostwrite.Identity{Release: pinned.Release}), "the release is not the recovery's")
1322 // The wrong phase: the record past probing, or the claim behind it.
1323 for _, tc := range []struct{ record, claim string }{
1324 {provision.RbSourceProbed, hostclaim.PhaseProbed},
1325 {provision.RbSourceProbing, hostclaim.PhaseClaimed},
1326 {provision.RbSourceRestored, hostclaim.PhaseClaimed},
1327 } {
1328 c := rpRestoringClaim(tc.claim)
1329 if err := through(rpRB, rpRestoringRecord(tc.record), c, rpSelection(c, "held-release"), hostwrite.Identity{Release: recovered.Release}); err == nil {
1330 t.Fatalf("record %s, claim %s: granted", tc.record, tc.claim)
1331 }
1332 }
1333 // The wrong box: the target asking, or a third box executing.
1334 t2 := rpSelection(c, "held-release")
1335 t2.Box, t2.Region = mpTarget, "uk"
1336 if err := through(rpRB, rec, c, t2, hostwrite.Identity{Release: recovered.Release}); err == nil {
1337 t.Fatal("the target granted")
1338 }
1339 c3 := rpRestoringClaim(hostclaim.PhaseProbing)
1340 c3.Op.Exec = "de-box"
1341 s3 := rpSelection(c3, "held-release")
1342 s3.Box = "de-box"
1343 if err := through(rpRB, rec, c3, s3, hostwrite.Identity{Release: recovered.Release}); err == nil {
1344 t.Fatal("a third box granted")
1345 }
1346 // A clean return materializes before its inputs are recorded; a dirty
1347 // one needs them.
1348 clean := rpRestoringRecord(provision.RbSourceProbing)
1349 clean.Move.Rollback.Dirty, clean.Move.Rollback.RecoveryInputs = false, nil
1350 if err := through(rpRB, clean, c, s, hostwrite.Identity{Release: "v9"}); err != nil {
1351 t.Fatalf("a clean return before the record: %v", err)
1352 }
1353 dirty := rpRestoringRecord(provision.RbSourceProbing)
1354 dirty.Move.Rollback.RecoveryInputs = nil
1355 rpRefused(t, through(rpRB, dirty, c, s, hostwrite.Identity{Release: recovered.Release}), "no recorded recovery inputs")
1356 })
1357 t.Run("reverting", func(t *testing.T) {
1358 c := rpRevertingClaim(hostclaim.PhaseProbing)
1359 s := rpSelection(c, "held-release")
1360 rec := rpRevertingRecord()
1361 if err := through(mpOp, rec, c, s, hostwrite.Identity{Release: recovered.Release}); err != nil {
1362 t.Fatalf("the executor at probing: %v", err)
1363 }
1364 rpRefused(t, through(mpOp, rec, c, s, hostwrite.Identity{Release: pinned.Release}), "the release is not the recovery's")
1365 rpRefused(t, through(mpOp, rec, c, s, hostwrite.Identity{Release: recovered.Release, Config: "x"}), "the identity does not fit the action")
1366 for _, rev := range []string{hostclaim.RevPhaseEvidence, hostclaim.RevPhaseVerify, hostclaim.PhaseProbed, hostclaim.PhaseOpened} {
1367 c := rpRevertingClaim(rev)
1368 if err := through(mpOp, rec, c, rpSelection(c, "held-release"), hostwrite.Identity{Release: recovered.Release}); err == nil {
1369 t.Fatalf("rev_phase %s: granted", rev)
1370 }
1371 }
1372 t2 := rpSelection(c, "held-release")
1373 t2.Box, t2.Region = mpTarget, "uk"
1374 if err := through(mpOp, rec, c, t2, hostwrite.Identity{Release: recovered.Release}); err == nil {
1375 t.Fatal("the target granted")
1376 }
1377 // A revert's row is held by the move id, never a rollback's.
1378 if err := through(rpRB, rec, c, s, hostwrite.Identity{Release: recovered.Release}); !errors.Is(err, hostbox.ErrChanged) {
1379 t.Fatalf("a rollback hold on a revert: %v", err)
1380 }
1381 })
1382}
1383