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 "crypto/ed25519"
6 "crypto/rand"
7 "crypto/sha256"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/carlosframework/platform/internal/boxid"
16 "github.com/carlosframework/platform/internal/hostclaim"
17 "github.com/carlosframework/platform/internal/provision"
18 "github.com/carlosframework/platform/internal/release"
19 "github.com/carlosframework/platform/internal/termfence"
20)
21
22type c7ReplayAuthority struct {
23 home string
24 record termfence.Record
25 region string
26}
27
28func (a c7ReplayAuthority) CheckOneBoxRegion(_ context.Context, source, region string) error {
29 if source != a.record.Box || region != a.region {
30 return hostclaim.ErrFence
31 }
32 return nil
33}
34
35func (a c7ReplayAuthority) ValidateFence(_ context.Context, f termfence.Record) (hostclaim.FenceBinding, error) {
36 if f != a.record {
37 return hostclaim.FenceBinding{}, hostclaim.ErrFence
38 }
39 return hostclaim.FenceBinding{FleetHomeRegion: a.home, Region: a.region, RosterRevision: 1, RosterETag: "roster"}, nil
40}
41
42type c7DecisionHook struct {
43 release.Store
44 key string
45 after func() error
46}
47
48func (s *c7DecisionHook) Put(ctx context.Context, key string, b []byte, match string) (string, error) {
49 tag, err := s.Store.Put(ctx, key, b, match)
50 if err == nil && key == s.key && s.after != nil {
51 f := s.after
52 s.after = nil
53 if e := f(); e != nil {
54 return "", e
55 }
56 }
57 return tag, err
58}
59
60type c7World struct {
61 raw release.Store
62 host, key string
63 inc string
64 ref hostclaim.FenceRef
65 authority c7ReplayAuthority
66 now time.Time
67 clock time.Time // the ControlWriter's clock
68 pub ed25519.PublicKey
69 control *ControlWriter
70 mh *StartHandle // the move registration's handle, as the Moves coordinator holds it
71}
72
73var c7Owner = RegistrationOwner{Console: "console", Enrollment: strings.Repeat("e", 32), Controller: "coordinator"}
74
75// newC7World is a moving claim with the intent BeginClaimMove writes, its
76// record in cutting/archived, the exact move registration (owned by
77// c7Owner, live) with its handle, and the source's termination fence.
78func newC7World(t *testing.T, mutate func(*hostclaim.Claim, *provision.Record, *AppAdmission)) *c7World {
79 t.Helper()
80 ctx := context.Background()
81 w := &c7World{raw: release.NewDirStore(t.TempDir()), host: "app.example", now: time.Now().UTC()}
82 w.clock = w.now
83 pub, _, err := ed25519.GenerateKey(rand.Reader)
84 if err != nil {
85 t.Fatal(err)
86 }
87 w.pub = pub
88 if w.control, err = NewControlWriter(w.raw, pub, func() time.Time { return w.clock }); err != nil {
89 t.Fatal(err)
90 }
91 // In-package harness: the handle a RegisterMove call returned for the
92 // seeded registration below.
93 w.mh = &StartHandle{w: w.control, account: "bab", app: "app", appID: "canonical", id: "move", owner: c7Owner, epoch: 1}
94 en := strings.Repeat("a", 32)
95 if w.inc, err = hostclaim.Incarnation("bab", "app", w.host, w.now); err != nil {
96 t.Fatal(err)
97 }
98 w.key = provision.RecordKey("bab", "app", w.host)
99 c := hostclaim.Claim{V: 2, Host: w.host, Gen: 1, Holder: &hostclaim.Holder{Family: "instance", Account: "bab", App: "app", Key: w.key, Acquisition: "acq", Incarnation: w.inc, CreatedAt: w.now, Location: "instances/" + w.host, Stamp: 7, Generation: "generation"},
100 Op: hostclaim.Op{Kind: hostclaim.KindMoving, ID: "move", Phase: "releasing", Exec: "source", Participants: []string{"source", "target"},
101 Intent: map[string]string{"to": "uk", "source": "source", "target": "target", "app_id": "canonical", "admission_id": "move", "source_enrollment": en, "target_enrollment": strings.Repeat("b", 32)}},
102 Obligations: []hostclaim.Obligation{{ID: "ack", Op: "move", Kind: "park-local", Box: "target", Owner: "box:target", Retirement: "retire"}, {ID: "keep", Op: "move", Kind: "park-replica", Region: "uk", Owner: "region:uk", Retirement: "retire-region"}}}
103 rec := provision.Record{Schema: 1, Host: w.host, Slug: "app", Account: "bab", App: "app", Acquisition: "acq", Channel: "edge", Region: "ie", CreatedAt: w.now, Move: &provision.MoveState{ID: "move", Incarnation: w.inc, From: "ie", To: "uk", State: provision.MoveCutting, CutPhase: provision.CutArchived}}
104 adm := AppAdmission{V: 1, AppID: "canonical", Epoch: 1, Revision: 1, State: "open", Effects: map[string]Effect{}, Registrations: map[string]Registration{"move": {Kind: RegistrationMove, Host: w.host, Owner: c7Owner, AppID: "canonical", Epoch: 1, Claim: SavedClaim{ETag: "serving", Gen: 1}, Expires: w.now.Add(RegistrationTTL), CreatedAt: w.now, Incarnation: w.inc}}}
105 if mutate != nil {
106 mutate(&c, &rec, &adm)
107 }
108 cb, _ := json.Marshal(c)
109 ck, _ := hostclaim.Key(w.host)
110 if _, err = w.raw.Put(ctx, ck, cb, ""); err != nil {
111 t.Fatal(err)
112 }
113 rb, _ := json.Marshal(rec)
114 if _, err = w.raw.Put(ctx, w.key, rb, ""); err != nil {
115 t.Fatal(err)
116 }
117 ab, _ := json.Marshal(adm)
118 if _, err = w.raw.Put(ctx, "apps/bab/app/admission.json", ab, ""); err != nil {
119 t.Fatal(err)
120 }
121 f := termfence.Record{V: 1, Box: "source", Enrollment: en, Cloud: boxid.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-source"}, State: "terminated", ObservedAt: w.now.Format(time.RFC3339), Via: "describe", RequestID: "request", WrittenBy: termfence.WrittenBy{Box: "worker", Enrollment: strings.Repeat("c", 32)}}
122 fb, _ := json.Marshal(f)
123 sum := sha256.Sum256(fb)
124 w.ref = hostclaim.FenceRef{Enrollment: en, Key: "control/box-fences/" + en + ".json", SHA256: hex.EncodeToString(sum[:])}
125 if _, err = w.raw.Put(ctx, w.ref.Key, fb, ""); err != nil {
126 t.Fatal(err)
127 }
128 w.authority = c7ReplayAuthority{record: f, region: "ie"}
129 return w
130}
131
132func (w *c7World) claim(t *testing.T) hostclaim.Observed {
133 t.Helper()
134 o, err := hostclaim.Read(context.Background(), w.raw, w.host)
135 if err != nil {
136 t.Fatal(err)
137 }
138 return o
139}
140
141// productionData is ConfigureClaimRuntime's data handle: the mutation
142// boundary over the conditional record store over under (w.raw or a fault
143// hook on it), checked by the production checker with the real ControlWriter.
144func (w *c7World) productionData(t *testing.T, under release.Store) release.Store {
145 t.Helper()
146 records, err := NewConditionalRecordStore(under, w.pub, w.control.homeRegion)
147 if err != nil {
148 t.Fatal(err)
149 }
150 check, err := NewProductionMutationCheck(w.raw, w.control)
151 if err != nil {
152 t.Fatal(err)
153 }
154 data, err := NewMutationBoundary(true, records, check)
155 if err != nil {
156 t.Fatal(err)
157 }
158 return data
159}
160
161func (w *c7World) effects(t *testing.T, action string) []Effect {
162 t.Helper()
163 a, err := ReadAdmission(context.Background(), w.raw, "bab", "app", "canonical")
164 if err != nil {
165 t.Fatal(err)
166 }
167 var out []Effect
168 for _, e := range a.Value.Effects {
169 if action == "" || e.Action == action {
170 out = append(out, e)
171 }
172 }
173 return out
174}
175
176func (w *c7World) recordTag(t *testing.T) string {
177 t.Helper()
178 _, tag, err := provision.GetRecordETag(context.Background(), w.raw, "bab", "app", w.host)
179 if err != nil {
180 t.Fatal(err)
181 }
182 return tag
183}
184
185// A correctly held call through the production data handle: the record
186// decision is one issued effect of the move registration, settled complete,
187// and the conversion CAS passes the boundary under its lifecycle grant.
188func TestC7RecoveryHeldCallSettlesItsDecisionEffect(t *testing.T) {
189 ctx := context.Background()
190 w := newC7World(t, nil)
191 data := w.productionData(t, w.raw)
192 if err := RecoverTerminatedMovingHost(ctx, data, w.mh, w.authority, w.host, w.ref, w.now); err != nil {
193 t.Fatal(err)
194 }
195 if c := w.claim(t).Claim; c.Op.Kind != hostclaim.KindRelocating || c.Op.Intent["source_box"] != "source" || c.Op.Intent["region"] != "ie" {
196 t.Fatalf("not converted: %+v", c.Op)
197 }
198 d := w.effects(t, "move-relocating-decision")
199 if len(d) != 1 || d[0].State != "complete" || d[0].ResultReference != "move" || d[0].OperationID != "move" || d[0].Owner != c7Owner || d[0].Resource != w.key {
200 t.Fatalf("decision effect: %+v", d)
201 }
202}
203
204// RecoverTerminatedMovingHost through the production data handle, held by a
205// writer-minted move registration. Unbound calls refuse before the record
206// decision; the data handle alone writes neither the decision nor the
207// conversion; a crash after the durable decision replays with no second
208// decision and keeps a chore acknowledged in between.
209func TestC7RecoveryThroughProductionBoundary(t *testing.T) {
210 ctx := context.Background()
211 w := newC7World(t, func(_ *hostclaim.Claim, _ *provision.Record, adm *AppAdmission) {
212 delete(adm.Registrations, "move")
213 })
214 o := w.claim(t)
215 reg := Registration{Kind: RegistrationMove, Host: w.host, Owner: c7Owner, AppID: "canonical", Epoch: 1, Claim: SavedObservation(o), Incarnation: w.inc, CreatedAt: w.now}
216 mh, err := w.control.RegisterStart(ctx, "bab", "app", "canonical", "move", reg, w.clock)
217 if err != nil {
218 t.Fatal(err)
219 }
220 reg.Kind = "wake"
221 other, err := w.control.RegisterStart(ctx, "bab", "app", "canonical", "wake-1", reg, w.clock)
222 if err != nil {
223 t.Fatal(err)
224 }
225 hook := &c7DecisionHook{Store: w.raw, key: w.key}
226 data := w.productionData(t, hook)
227 claimBefore, recordBefore := o.ETag, w.recordTag(t)
228 unchanged := func(name string) {
229 t.Helper()
230 if w.claim(t).ETag != claimBefore || w.recordTag(t) != recordBefore || len(w.effects(t, "")) != 0 {
231 t.Fatalf("%s: wrote before refusing", name)
232 }
233 }
234
235 forged := &StartHandle{w: w.control, account: "bab", app: "app", appID: "canonical", id: "move", owner: RegistrationOwner{Console: "console", Enrollment: strings.Repeat("e", 32), Controller: "forged"}, epoch: 1}
236 for _, tc := range []struct {
237 name string
238 h *StartHandle
239 }{{"no-handle", nil}, {"other-registration", other}, {"forged-owner", forged}, {"expired", mh}} {
240 if tc.name == "expired" {
241 w.clock = w.now.Add(RegistrationTTL)
242 }
243 err := RecoverTerminatedMovingHost(ctx, data, tc.h, w.authority, w.host, w.ref, w.now)
244 w.clock = w.now
245 if err == nil {
246 t.Fatalf("%s: recovered without the move registration", tc.name)
247 }
248 unchanged(tc.name)
249 }
250
251 // The data handle alone: no record decision without an issued effect
252 // (a lifecycle grant is not one), and no claim CAS without a grant.
253 moving, holder, err := movingIdentities(o.Claim)
254 if err != nil {
255 t.Fatal(err)
256 }
257 fence, _ := json.Marshal(w.ref)
258 decide := provision.MoveRelocatingDecision(provision.MoveGuard{ID: "move", Incarnation: w.inc}, "source", w.ref.Enrollment, "ie", string(fence), o.Claim.Gen, o.ETag, moving, holder, w.now)
259 for name, c := range map[string]context.Context{"no-grant": ctx, "grant-without-effect": mh.Lifecycle(ctx)} {
260 if _, err := setLifecycleRecordMove(c, data, o, decide); !errors.Is(err, ErrPending) {
261 t.Fatalf("%s: unbound record decision: %v", name, err)
262 }
263 }
264 if _, err := hostclaim.Update(ctx, data, o, func(*hostclaim.Claim) error { return nil }); !errors.Is(err, ErrPending) {
265 t.Fatalf("ungranted claim CAS: %v", err)
266 }
267 unchanged("data handle alone")
268
269 // Held: the decision lands, then its response is lost.
270 hook.after = func() error { return errors.New("crash: response lost after the durable record decision") }
271 if err := RecoverTerminatedMovingHost(ctx, data, mh, w.authority, w.host, w.ref, w.now); err == nil {
272 t.Fatal("missing crash")
273 }
274 if w.claim(t).ETag != claimBefore {
275 t.Fatal("converted despite the crash")
276 }
277 rec, err := provision.GetRecord(ctx, w.raw, "bab", "app", w.host)
278 if err != nil {
279 t.Fatal(err)
280 }
281 if rec.Move.State != provision.MoveRelocating || !rec.Move.Relocation.DecidedAt.Equal(w.now) {
282 t.Fatalf("decision not durable: %+v", rec.Move)
283 }
284 // The write reached the store and its outcome was lost: uncertain, never
285 // "not-applied".
286 if d := w.effects(t, "move-relocating-decision"); len(d) != 1 || d[0].State != "uncertain" || d[0].OperationID != "move" || d[0].Owner != c7Owner {
287 t.Fatalf("decision effect after the crash: %+v", d)
288 }
289 if _, err := hostclaim.ConvertMovingToRelocating(ctx, data, w.claim(t), "move", w.ref, w.authority, movingRecordDecision{store: data, placement: w.authority}); !errors.Is(err, ErrPending) {
290 t.Fatalf("conversion without the registration's grant: %v", err)
291 }
292
293 // Seeded between the runs: the target box acknowledges its chore (its
294 // own write, not this console's data handle).
295 if _, err := hostclaim.Update(ctx, w.raw, w.claim(t), hostclaim.CompleteObligation("move", "ack")); err != nil {
296 t.Fatal(err)
297 }
298 if err := RecoverTerminatedMovingHost(ctx, data, mh, w.authority, w.host, w.ref, w.now.Add(time.Minute)); err != nil {
299 t.Fatal(err)
300 }
301 c := w.claim(t).Claim
302 if c.Op.Kind != hostclaim.KindRelocating || c.Holder.Stamp != 7 || len(c.Obligations) != 1 || c.Obligations[0].ID != "keep" || c.Op.Journal["admission_id"] != "move" {
303 t.Fatalf("replay lost the acknowledgement or the contract: %+v", c)
304 }
305 if rec, err = provision.GetRecord(ctx, w.raw, "bab", "app", w.host); err != nil || !rec.Move.Relocation.DecidedAt.Equal(w.now) {
306 t.Fatalf("replay redecided: %v %+v", err, rec.Move)
307 }
308 if d := w.effects(t, "move-relocating-decision"); len(d) != 1 {
309 t.Fatalf("replay issued a second decision: %+v", d)
310 }
311 converted := w.claim(t).ETag
312 if err := RecoverTerminatedMovingHost(ctx, data, mh, w.authority, w.host, w.ref, w.now.Add(time.Hour)); err != nil {
313 t.Fatal("converted replay", err)
314 }
315 if err := RecoverTerminatedMovingHost(ctx, data, nil, w.authority, w.host, w.ref, w.now.Add(time.Hour)); err == nil {
316 t.Fatal("unbound converted replay")
317 }
318 if w.claim(t).ETag != converted {
319 t.Fatal("converted replay wrote the claim")
320 }
321}
322
323func TestC7DecisionReplayPreservesInterveningChoreAcknowledgement(t *testing.T) {
324 for _, changeOperation := range []bool{false, true} {
325 t.Run(map[bool]string{false: "chore", true: "changed-operation"}[changeOperation], func(t *testing.T) {
326 ctx := context.Background()
327 w := newC7World(t, nil)
328 hook := &c7DecisionHook{Store: w.raw, key: w.key}
329 hook.after = func() error {
330 o, e := hostclaim.Read(ctx, w.raw, w.host)
331 if e != nil {
332 return e
333 }
334 _, e = hostclaim.Update(ctx, w.raw, o, func(next *hostclaim.Claim) error {
335 if changeOperation {
336 next.Op.Intent["to"] = "de"
337 return nil
338 }
339 return hostclaim.CompleteObligation("move", "ack")(next)
340 })
341 if e != nil {
342 return e
343 }
344 return errors.New("crash after durable record decision")
345 }
346 wrapped := conditionalRecordStore{raw: hook}
347 if err := RecoverTerminatedMovingHost(ctx, wrapped, w.mh, w.authority, w.host, w.ref, w.now); err == nil {
348 t.Fatal("missing crash")
349 }
350 before := w.claim(t)
351 err := RecoverTerminatedMovingHost(ctx, wrapped, w.mh, w.authority, w.host, w.ref, w.now.Add(time.Minute))
352 after := w.claim(t)
353 if changeOperation {
354 if err == nil || after.ETag != before.ETag {
355 t.Fatal("changed operation converted", err)
356 }
357 return
358 }
359 if err != nil {
360 t.Fatal(err)
361 }
362 c := after.Claim
363 if c.Op.Kind != hostclaim.KindRelocating || c.Op.Exec != "none" || c.Holder.Stamp != 7 || c.Holder.Generation != "generation" || len(c.Obligations) != 1 || c.Obligations[0].ID != "keep" {
364 t.Fatalf("lost durable acknowledgement or holder: %+v", c)
365 }
366 if c.Op.Intent["source_box"] != "source" || c.Op.Intent["region"] != "ie" || c.Op.Intent["move_to_region"] != "uk" || c.Op.Journal["admission_id"] != "move" || c.Op.Journal["conversion_decision_sha256"] == "" {
367 t.Fatalf("conversion lost its contract: %+v", c.Op)
368 }
369 rec, err := provision.GetRecord(ctx, w.raw, "bab", "app", w.host)
370 if err != nil {
371 t.Fatal(err)
372 }
373 if rec.Move.State != provision.MoveRelocating || rec.Move.Relocation.SourceBox != "source" || rec.Move.Relocation.Region != "ie" || !rec.Move.Relocation.DecidedAt.Equal(w.now) {
374 t.Fatalf("record decision: %+v", rec.Move)
375 }
376 if err = RecoverTerminatedMovingHost(ctx, wrapped, w.mh, w.authority, w.host, w.ref, w.now.Add(time.Hour)); err != nil {
377 t.Fatal("converted replay", err)
378 }
379 saved := w.claim(t)
380 if _, e := hostclaim.Update(ctx, w.raw, saved, func(c *hostclaim.Claim) error {
381 c.Op.Journal["conversion_decision_sha256"] = strings.Repeat("f", 64)
382 return nil
383 }); e != nil {
384 t.Fatal(e)
385 }
386 if err = RecoverTerminatedMovingHost(ctx, wrapped, w.mh, w.authority, w.host, w.ref, w.now.Add(2*time.Hour)); err == nil {
387 t.Fatal("unbound converted claim replayed")
388 }
389 })
390 }
391}
392
393// Every refusal leaves both the record and the claim as they were. The
394// region is the record's and must equal the fence binding; the claim's
395// intent is never a region input.
396func TestC7RecoveryRefusesBeforeAnyWrite(t *testing.T) {
397 for _, tc := range []string{"fence-region", "record-region", "claim-region-ignored", "no-admission", "admission-kind", "admission-epoch", "admission-incarnation", "missing-admission-id", "admission-not-op", "legacy-source-box", "wrong-enrollment", "flipped"} {
398 t.Run(tc, func(t *testing.T) {
399 ctx := context.Background()
400 w := newC7World(t, func(c *hostclaim.Claim, rec *provision.Record, adm *AppAdmission) {
401 reg := adm.Registrations["move"]
402 switch tc {
403 case "record-region":
404 rec.Region, rec.Move.From = "fr", "fr"
405 case "claim-region-ignored":
406 // A pre-C7 claim region cannot steer the decision.
407 c.Op.Intent["source_region"] = "fr"
408 case "no-admission":
409 delete(adm.Registrations, "move")
410 case "admission-kind":
411 reg.Kind = "create"
412 case "admission-epoch":
413 adm.Epoch = 2
414 case "admission-incarnation":
415 reg.Incarnation = strings.Repeat("9", 64)
416 case "missing-admission-id":
417 delete(c.Op.Intent, "admission_id")
418 case "admission-not-op":
419 // A live move registration under another id is drainable.
420 c.Op.Intent["admission_id"] = "other"
421 adm.Registrations["other"] = reg
422 case "legacy-source-box":
423 c.Op.Intent["source_box"] = c.Op.Intent["source"]
424 delete(c.Op.Intent, "source")
425 case "wrong-enrollment":
426 c.Op.Intent["source_enrollment"] = strings.Repeat("d", 32)
427 case "flipped":
428 rec.Region, rec.Move.State, rec.Move.CutPhase = "uk", provision.MoveLanding, provision.CutFlipped
429 }
430 if _, ok := adm.Registrations["move"]; ok {
431 adm.Registrations["move"] = reg
432 }
433 })
434 if tc == "fence-region" {
435 w.authority.region = "uk"
436 }
437 claimBefore := w.claim(t)
438 _, recordTag, err := provision.GetRecordETag(ctx, w.raw, "bab", "app", w.host)
439 if err != nil {
440 t.Fatal(err)
441 }
442 err = RecoverTerminatedMovingHost(ctx, conditionalRecordStore{raw: w.raw}, w.mh, w.authority, w.host, w.ref, w.now)
443 if tc == "claim-region-ignored" {
444 if err != nil {
445 t.Fatal("a stale claim region blocked a valid conversion", err)
446 }
447 return
448 }
449 if err == nil {
450 t.Fatal("accepted")
451 }
452 _, recordAfter, _ := provision.GetRecordETag(ctx, w.raw, "bab", "app", w.host)
453 if w.claim(t).ETag != claimBefore.ETag || recordAfter != recordTag {
454 t.Fatal("refusal wrote the claim or the record")
455 }
456 })
457 }
458}
459
460func TestC7TargetParkedAtWaitsForRegionAndBoxChores(t *testing.T) {
461 for _, kind := range []string{"none", "box", "region", "participant", "executor", "region-mismatch"} {
462 t.Run(kind, func(t *testing.T) {
463 w := newC7World(t, func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) {
464 c.Op.Kind, c.Op.Phase, c.Op.Exec = hostclaim.KindRestoring, hostclaim.PhaseReleased, "none"
465 c.Op.Participants = []string{"source"}
466 c.Lost = []string{"target"}
467 c.Obligations = nil
468 switch kind {
469 case "box":
470 c.Obligations = []hostclaim.Obligation{{ID: "local", Op: "move", Kind: "park-local", Box: "target", Owner: "box:target", Retirement: "r"}}
471 case "region":
472 c.Obligations = []hostclaim.Obligation{{ID: "region", Op: "move", Kind: "park-replica", Region: "uk", Owner: "region:uk", Retirement: "r"}}
473 case "participant":
474 c.Op.Participants = append(c.Op.Participants, "target")
475 case "executor":
476 c.Op.Exec = "target"
477 case "region-mismatch":
478 c.Op.Intent["to"] = "ie"
479 }
480 r.Move.State = provision.MoveRollingBack
481 r.Move.Rollback = &provision.MoveRollback{ID: "rb_abcdefghijklmnopqrstuvwxyz", TargetPhase: provision.RbTargetReleased}
482 })
483 stamp := func() error {
484 return w.control.RunRecovery(t.Context(), func(ctx context.Context) error {
485 return runMoveRecovery(ctx, w.claim(t), func(ctx context.Context, g *MoveRecoveryGrant) error { return g.StampLostMoveTargetParked(ctx) })
486 })
487 }
488 err := stamp()
489 if kind != "none" {
490 if !errors.Is(err, ErrPending) {
491 t.Fatalf("pending: %v", err)
492 }
493 } else if err != nil {
494 t.Fatal(err)
495 }
496 after, err := provision.GetRecord(t.Context(), w.raw, "bab", "app", w.host)
497 if err != nil {
498 t.Fatal(err)
499 }
500 if (after.Move.Rollback.TargetParkedAt != nil) != (kind == "none") {
501 t.Fatal("wrong park stamp")
502 }
503 if kind == "none" {
504 tag := w.recordTag(t)
505 w.clock = w.now.Add(time.Hour)
506 if err := stamp(); err != nil {
507 t.Fatal(err)
508 }
509 if w.recordTag(t) != tag {
510 t.Fatal("changed original park time")
511 }
512 }
513 })
514 }
515}
516