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 "bytes"
5 "context"
6 "crypto/sha256"
7 "encoding/hex"
8 "errors"
9 "fmt"
10 "io"
11 "os"
12 "path/filepath"
13 "strings"
14 "sync"
15 "testing"
16 "time"
17
18 "github.com/carlosframework/platform/internal/activator"
19 "github.com/carlosframework/platform/internal/appconfig"
20 "github.com/carlosframework/platform/internal/hostbox"
21 "github.com/carlosframework/platform/internal/hostclaim"
22 "github.com/carlosframework/platform/internal/lease"
23 "github.com/carlosframework/platform/internal/moves"
24 "github.com/carlosframework/platform/internal/park"
25 "github.com/carlosframework/platform/internal/provision"
26 "github.com/carlosframework/platform/internal/registry"
27 "github.com/carlosframework/platform/internal/release"
28 "github.com/superfly/ltx"
29)
30
31// The Phase 1 harness: the legacy moveHarness, its fake mirror extended
32// with the staging writer's guarded At methods (F17) and a call log, a
33// lease.VersionedStore over the same buckets, a staging-lease fake, a
34// stepping clock, a MoveStart'ed record, and the host claim the request
35// door would have written. fakeMoveMirror deliberately does NOT gain
36// CopyVersioned: MirrorPrefix would then run the legacy tests in its
37// versioned mode.
38
39// stagingCall is one guarded staging request the fake received.
40type stagingCall struct {
41 Op, Bucket, Key string
42 SignAt time.Time
43}
44
45func (f *fakeMoveMirror) logAt(op string, signAt time.Time, bucket, key string) error {
46 f.mu.Lock()
47 defer f.mu.Unlock()
48 f.at = append(f.at, stagingCall{op, bucket, key, signAt})
49 if f.failAt != nil {
50 return f.failAt(op, key)
51 }
52 return nil
53}
54
55func (f *fakeMoveMirror) nextVersion() string {
56 f.mu.Lock()
57 defer f.mu.Unlock()
58 f.versions++
59 return fmt.Sprintf("ver-%04d", f.versions)
60}
61
62func (f *fakeMoveMirror) CopyVersionedAt(ctx context.Context, signAt time.Time, srcBucket, srcKey, srcETag, dstBucket, dstKey string) (string, string, error) {
63 if err := f.logAt("copy", signAt, dstBucket, dstKey); err != nil {
64 return "", "", err
65 }
66 etag, err := f.Copy(ctx, srcBucket, srcKey, srcETag, dstBucket, dstKey)
67 if err != nil {
68 return "", "", err
69 }
70 ver := f.nextVersion()
71 f.setVersion(dstBucket, dstKey, ver)
72 return etag, ver, nil
73}
74
75func (f *fakeMoveMirror) putAt(op string, signAt time.Time, bucket, key string, body []byte, createOnly bool) (string, error) {
76 if err := f.logAt(op, signAt, bucket, key); err != nil {
77 return "", err
78 }
79 f.bmu.Lock()
80 _, exists := f.buckets[bucket][key]
81 f.bmu.Unlock()
82 if exists && createOnly {
83 return "", lease.ErrExists
84 }
85 ver := f.nextVersion()
86 f.store(bucket, key, fakeMoveObject{lease.MirrorObject{ETag: "etag-" + ver, Size: int64(len(body))}, string(body)})
87 f.setVersion(bucket, key, ver)
88 return ver, nil
89}
90
91// setVersion records the current version of bucket/key. The legacy
92// fakeMoveObject has no version field (its positional literals stay as
93// they are), so versions live beside it.
94func (f *fakeMoveMirror) setVersion(bucket, key, ver string) {
95 f.mu.Lock()
96 defer f.mu.Unlock()
97 if f.vers == nil {
98 f.vers = map[string]string{}
99 }
100 f.vers[bucket+"/"+key] = ver
101 if f.keepHistory {
102 if f.history == nil {
103 f.history = map[string]fakeMoveObject{}
104 }
105 f.bmu.Lock()
106 f.history[bucket+"/"+key+"@"+ver] = f.buckets[bucket][key]
107 f.bmu.Unlock()
108 }
109}
110
111// older is a non-current version of bucket/key the fake kept (keepHistory),
112// as a versioned bucket keeps every version a later write superseded.
113func (f *fakeMoveMirror) older(bucket, key, ver string) (fakeMoveObject, bool) {
114 f.mu.Lock()
115 defer f.mu.Unlock()
116 o, ok := f.history[bucket+"/"+key+"@"+ver]
117 return o, ok && f.keepHistory
118}
119
120func (f *fakeMoveMirror) versionOf(bucket, key string) string {
121 f.mu.Lock()
122 defer f.mu.Unlock()
123 return f.vers[bucket+"/"+key]
124}
125
126func (f *fakeMoveMirror) PutVersionedAt(_ context.Context, signAt time.Time, bucket, key string, body []byte) (string, error) {
127 return f.putAt("put", signAt, bucket, key, body, false)
128}
129
130func (f *fakeMoveMirror) PutReaderVersionedAt(_ context.Context, signAt time.Time, bucket, key string, r io.Reader, size int64, sha256Hex string) (string, error) {
131 body, err := io.ReadAll(io.LimitReader(r, size+1))
132 if err != nil {
133 return "", err
134 }
135 if int64(len(body)) != size {
136 return "", fmt.Errorf("fake: body is %d bytes, declared %d", len(body), size)
137 }
138 if sum := sha256.Sum256(body); hex.EncodeToString(sum[:]) != sha256Hex {
139 return "", errors.New("fake: XAmzContentSHA256Mismatch")
140 }
141 return f.putAt("put", signAt, bucket, key, body, false)
142}
143
144func (f *fakeMoveMirror) PutIfAbsentVersionedAt(_ context.Context, signAt time.Time, bucket, key string, body []byte) (string, error) {
145 return f.putAt("put-if-absent", signAt, bucket, key, body, true)
146}
147
148func (f *fakeMoveMirror) DeleteAt(ctx context.Context, signAt time.Time, bucket, key string) error {
149 if err := f.logAt("delete", signAt, bucket, key); err != nil {
150 return err
151 }
152 return f.Delete(ctx, bucket, key)
153}
154
155var _ lease.StagingWriter = (*fakeMoveMirror)(nil)
156
157// stagingCalls is the guarded call log, by op and key.
158func (f *fakeMoveMirror) stagingCalls() []stagingCall {
159 f.mu.Lock()
160 defer f.mu.Unlock()
161 return append([]stagingCall(nil), f.at...)
162}
163
164// fakeVersioned is a lease.VersionedStore over the fake mirror's buckets,
165// reading by exact version. deny refuses a call the way an IAM policy does.
166type fakeVersioned struct {
167 f *fakeMoveMirror
168 mu sync.Mutex
169 calls []string
170 deny func(op, bucket, key string) error
171 // skipDelete, when it answers true for a key, makes DeleteVersion
172 // return success without removing the version: a delete the store
173 // accepted but whose listing still shows the version (Plan 2 Task 10,
174 // the landing's Parked gate).
175 skipDelete func(key string) bool
176}
177
178func (v *fakeVersioned) call(op, bucket, key string) error {
179 v.mu.Lock()
180 v.calls = append(v.calls, op+" "+bucket+"/"+key)
181 v.mu.Unlock()
182 if v.deny != nil {
183 return v.deny(op, bucket, key)
184 }
185 return nil
186}
187
188func (v *fakeVersioned) ListVersions(_ context.Context, bucket, prefix string) ([]lease.ObjectVersion, error) {
189 if err := v.call("list-versions", bucket, prefix); err != nil {
190 return nil, err
191 }
192 var out []lease.ObjectVersion
193 for k, o := range v.f.buckets[bucket] {
194 if strings.HasPrefix(k, prefix) {
195 out = append(out, lease.ObjectVersion{Key: k, VersionID: v.versionID(bucket, k), ETag: o.ETag, Size: o.Size, IsLatest: true})
196 }
197 }
198 return out, nil
199}
200
201// versionID is an object's version as S3 lists it: one written with no
202// version (the source's replica, stored directly by a test) is "null",
203// as S3 lists an object written while a bucket was not versioning.
204func (v *fakeVersioned) versionID(bucket, key string) string {
205 if ver := v.f.versionOf(bucket, key); ver != "" {
206 return ver
207 }
208 return "null"
209}
210
211func (v *fakeVersioned) GetVersion(_ context.Context, bucket, key, versionID string) ([]byte, error) {
212 if err := v.call("get-version", bucket, key); err != nil {
213 return nil, err
214 }
215 o, ok := v.f.buckets[bucket][key]
216 if !ok || versionID == "" || v.versionID(bucket, key) != versionID {
217 if old, kept := v.f.older(bucket, key, versionID); kept && versionID != "" {
218 return []byte(old.body), nil
219 }
220 return nil, lease.ErrMirrorNotFound
221 }
222 return []byte(o.body), nil
223}
224
225func (v *fakeVersioned) OpenVersion(ctx context.Context, bucket, key, versionID string) (io.ReadCloser, error) {
226 b, err := v.GetVersion(ctx, bucket, key, versionID)
227 if err != nil {
228 return nil, err
229 }
230 return io.NopCloser(bytes.NewReader(b)), nil
231}
232
233// CopyVersion copies one exact version within the fake's buckets, as the
234// park engine archives a landing's residue (Plan 2 Task 10). createOnly
235// refuses an existing destination with lease.ErrDestinationExists.
236func (v *fakeVersioned) CopyVersion(_ context.Context, srcBucket, srcKey, srcVersionID, dstBucket, dstKey string, createOnly bool) (string, string, error) {
237 if err := v.call("copy-version", dstBucket, dstKey); err != nil {
238 return "", "", err
239 }
240 o, ok := v.f.buckets[srcBucket][srcKey]
241 if !ok || srcVersionID == "" || v.versionID(srcBucket, srcKey) != srcVersionID {
242 old, kept := v.f.older(srcBucket, srcKey, srcVersionID)
243 if !kept || srcVersionID == "" {
244 return "", "", lease.ErrMirrorNotFound
245 }
246 o = old
247 }
248 if _, exists := v.f.buckets[dstBucket][dstKey]; exists && createOnly {
249 return "", "", lease.ErrDestinationExists
250 }
251 ver := v.f.nextVersion()
252 v.f.store(dstBucket, dstKey, fakeMoveObject{lease.MirrorObject{ETag: "etag-" + ver, Size: o.Size}, o.body})
253 v.f.setVersion(dstBucket, dstKey, ver)
254 return ver, "etag-" + ver, nil
255}
256
257func (v *fakeVersioned) DeleteVersion(_ context.Context, bucket, key, versionID string) error {
258 if err := v.call("delete-version", bucket, key); err != nil {
259 return err
260 }
261 if v.skipDelete != nil && v.skipDelete(key) {
262 return nil
263 }
264 // "null" is how S3 names an object written while the bucket was not
265 // versioning (versionID), and deleting that version removes it.
266 if _, ok := v.f.buckets[bucket][key]; ok && v.versionID(bucket, key) == versionID {
267 delete(v.f.buckets[bucket], key)
268 }
269 return nil
270}
271
272func (v *fakeVersioned) PutIfAbsent(_ context.Context, bucket, key string, body []byte) (string, error) {
273 if err := v.call("put-if-absent", bucket, key); err != nil {
274 return "", err
275 }
276 if _, ok := v.f.buckets[bucket][key]; ok {
277 return "", lease.ErrExists
278 }
279 ver := v.f.nextVersion()
280 v.f.store(bucket, key, fakeMoveObject{lease.MirrorObject{ETag: "etag-" + ver, Size: int64(len(body))}, string(body)})
281 v.f.setVersion(bucket, key, ver)
282 return ver, nil
283}
284
285func (v *fakeVersioned) callsOn(prefix string) []string {
286 v.mu.Lock()
287 defer v.mu.Unlock()
288 var out []string
289 for _, c := range v.calls {
290 if strings.Contains(c, prefix) {
291 out = append(out, c)
292 }
293 }
294 return out
295}
296
297// fakeStagingLease is the staging-work lease: safe until `until`.
298type fakeStagingLease struct {
299 mu sync.Mutex
300 until time.Time
301 acquired int
302 released int
303 // unsafeAfter, when positive, makes every SafeUntil read after that
304 // many answer the lease's start: too close to its end to sign.
305 reads int
306 unsafeAfter int
307}
308
309func (l *fakeStagingLease) SafeUntil() (time.Time, error) {
310 l.mu.Lock()
311 defer l.mu.Unlock()
312 l.reads++
313 if l.unsafeAfter > 0 && l.reads > l.unsafeAfter {
314 return l.until.Add(-moves.StagingLeaseTTL), nil
315 }
316 return l.until, nil
317}
318
319// stepClock advances a millisecond each read, so every timed span is
320// positive and the order of events is the order of reads.
321type stepClock struct {
322 mu sync.Mutex
323 now time.Time
324}
325
326func (c *stepClock) Now() time.Time {
327 c.mu.Lock()
328 defer c.mu.Unlock()
329 c.now = c.now.Add(time.Millisecond)
330 return c.now
331}
332
333func (c *stepClock) Advance(d time.Duration) {
334 c.mu.Lock()
335 c.now = c.now.Add(d)
336 c.mu.Unlock()
337}
338
339const (
340 p1Host = "a.go.example.com"
341 p1ID = "mv_abcdefghijklmnopqrstuvwxyz"
342 p1ID2 = "mv_bcdefghijklmnopqrstuvwxyza"
343)
344
345var p1Created = time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
346
347type phase1Harness struct {
348 *moveHarness
349 vs *fakeVersioned
350 lease *fakeStagingLease
351 clock *stepClock
352 inc string
353 route registry.Route
354 env map[string]string // the source's launch environment
355 optDir string
356 probe moves.Check
357 probePaths []string
358 strict moves.Check
359 // worker1 is the source box's worker and worker2 the target's, each
360 // kept across passes as one agent keeps its own.
361 worker1, worker2 *movesWorker
362 // src is the source box's placement code: "ie", or the home's "" for a
363 // move out of home (fromHome).
364 src string
365 // The source box, its data bucket and the bucket's AWS region: ie-box,
366 // data-ie and eu-west-1 by default.
367 srcBox, srcBucket, srcAWS string
368 // dst is the target box's placement code: "uk", or the home's "" for a
369 // move into home (intoHome). dstBox, dstBucket and dstAWS name the
370 // target box, its data bucket and the bucket's AWS region.
371 dst, dstBox, dstBucket, dstAWS string
372 // dstEngine is the target box's claim engine region, its roster name:
373 // "uk", or the home box's roster name "ie" for a move into home (claim
374 // Plan 13 keeps the roster name as a home box's engine region and grants
375 // it the @home alias).
376 dstEngine string
377 // homeRegion is the fleet's signed home designation, "" when the
378 // fixture names none. With it the claim's op carries home_region and the
379 // target's ParkHomeRegion is set, as armMovesParks sets it.
380 homeRegion string
381 // admitted starts the move as the claim-mode door does
382 // (hostclaim.BeginAdmittedMove with the roster's enrollments), not the
383 // plain BeginMove.
384 admitted bool
385 // roster is the signed roster the fixture's boxes resolve through.
386 roster p1Roster
387 // retain is the claim state seeded at start as an earlier stay left it
388 // (retainTouched, retainQuiet, retainChore).
389 retain p1Retained
390 // realEnv composes both sides' environments through the production
391 // composer (realLaunchEnv): the source's launch report is its
392 // ExecBackend's LaunchEnvironment, the target's held env composeHeldEnv
393 // with the target's own region, each box's agent-owned variables its
394 // own.
395 realEnv bool
396 // srcEnvFile is the source's own <host>.env, the instance layer the
397 // archive carries: "APP_MODE=prod\n" by default (sourceEnvFile).
398 srcEnvFile string
399}
400
401// sourceEnvFile sets the source's own <host>.env.
402func sourceEnvFile(body string) p1Option { return func(h *phase1Harness) { h.srcEnvFile = body } }
403
404// realLaunchEnv composes both boxes' environments through the production
405// composer instead of the h.env fixture (home part 2, Task 2b): the source
406// pins what its ExecBackend would launch, and the target composes its own,
407// so the box-owned variables differ between the two exactly as they do in
408// production.
409func realLaunchEnv(h *phase1Harness) { h.realEnv = true }
410
411// heldEnv is the target's held environment for rec: the h.env fixture with
412// the target's region rule, or under realLaunchEnv the production
413// composition over the carried env with the target's region.
414func (h *phase1Harness) heldEnv(ctx context.Context, s release.Store, rec provision.Record, carried []byte) (map[string]string, map[string]bool, error) {
415 if h.realEnv {
416 env, instance, _, _, err := composeHeldEnv(h.cfg.SecretsPriv, h.dst, false)(ctx, s, rec, carried)
417 return env, instance, err
418 }
419 env := map[string]string{}
420 for k, v := range h.env {
421 env[k] = v
422 }
423 setHeldRegion(env, h.dst)
424 return env, nil, nil
425}
426
427// p1Roster is the fixture's signed roster: each box's actor, as
428// hostbox.Roster answers it.
429type p1Roster map[string]hostbox.Actor
430
431func (r p1Roster) Actor(_ context.Context, box string) (hostbox.Actor, error) {
432 a, ok := r[box]
433 if !ok {
434 return hostbox.Actor{}, hostbox.ErrChanged
435 }
436 return a, nil
437}
438
439// p1Retained is claim state an earlier stay left, seeded at start in
440// whichever spelling of home the option names: touched.regions, a
441// quiet_after key, and an earlier residue chore owed in the home region.
442type p1Retained struct {
443 touched, quiet, chore string
444}
445
446// retainedChoreOp is the earlier op whose residue chore retainChore seeds,
447// and retainedChoreGeneration the earlier stay's generation it parks.
448const (
449 retainedChoreOp = "mv_earlierstayearlierstayearlie"
450 retainedChoreGeneration = "old"
451)
452
453// The fixture's enrollments (the roster's 32-hex tokens).
454const (
455 p1SrcEnrollment = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"
456 p1DstEnrollment = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2"
457)
458
459// p1Option adjusts a Phase 1 harness before it is built.
460type p1Option func(*phase1Harness)
461
462// fromHome builds the harness for a move out of the home placement: the
463// source box, its record and its move name the home's code, "" (home-region
464// parks, part 1). The source box keeps its name and bucket.
465func fromHome(h *phase1Harness) { h.src = "" }
466
467// intoHome builds the harness for a move into the home placement, the
468// mirror of fromHome for the target (home part 2). The source is placed in a
469// named code that is not the home's name (ca-box in ca, bucket data-ca), so
470// no fixture name suggests home. The target is home-box: placement "",
471// bucket data-home in eu-west-1, an edges file with a data bucket and no
472// placement, and a held environment with no CARLOS_REGION. The roster names
473// home-box in region ie and designates ie the fleet's home; the claim's op
474// carries home_region ie (BeginAdmittedMove), and the target's
475// ParkHomeRegion is ie, as armMovesParks sets it.
476func intoHome(h *phase1Harness) {
477 h.src, h.srcBox, h.srcBucket, h.srcAWS = "ca", "ca-box", "data-ca", "ca-central-1"
478 h.dst, h.dstBox, h.dstBucket, h.dstAWS, h.dstEngine = "", "home-box", "data-home", "eu-west-1", "ie"
479 h.homeRegion, h.admitted = "ie", true
480}
481
482// retainTouched seeds touched.regions with region, an earlier stay's
483// spelling of home ("ie" or "@home").
484func retainTouched(region string) p1Option {
485 return func(h *phase1Harness) { h.retain.touched = region }
486}
487
488// retainQuiet seeds a past quiet_after key under region, either spelling.
489func retainQuiet(region string) p1Option { return func(h *phase1Harness) { h.retain.quiet = region } }
490
491// retainChore seeds an earlier op's residue chore owed under region (the
492// roster name "ie" for home), after the start, as an earlier stay left it:
493// a park-generation of the generation retainedChoreGeneration, scoped to
494// the holder's location, the shape hostclaim.QueueResidueParks queues.
495func retainChore(region string) p1Option { return func(h *phase1Harness) { h.retain.chore = region } }
496
497func newPhase1Harness(t *testing.T, opts ...p1Option) *phase1Harness {
498 t.Helper()
499 h := &phase1Harness{moveHarness: newMoveHarness(t), lease: &fakeStagingLease{}, clock: &stepClock{now: time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)}, src: "ie",
500 srcEnvFile: "APP_MODE=prod\n", srcBox: "ie-box", srcBucket: "data-ie", srcAWS: "eu-west-1", dst: "uk", dstBox: "uk-box", dstBucket: "data-uk", dstAWS: "eu-west-2", dstEngine: "uk"}
501 for _, o := range opts {
502 o(h)
503 }
504 h.cfg.Region = h.src
505 h.cfg.OwnBucket, h.cfg.OwnBucketRegion = h.srcBucket, h.srcAWS
506 h.roster = p1Roster{
507 h.srcBox: {Box: h.srcBox, Enrollment: p1SrcEnrollment, Region: park.PlacementRegion(h.src), FleetHomeRegion: h.homeRegion, Bucket: h.srcBucket, Eligible: true},
508 h.dstBox: {Box: h.dstBox, Enrollment: p1DstEnrollment, Region: h.dstEngine, FleetHomeRegion: h.homeRegion, Bucket: h.dstBucket, Eligible: true},
509 }
510 if h.dst == "" {
511 // The home box's roster entry equals the fleet's home designation:
512 // the claim grants it the @home alias (claim Plan 13).
513 a := h.roster[h.dstBox]
514 a.HomeRegion = h.homeRegion
515 h.roster[h.dstBox] = a
516 }
517 h.vs = &fakeVersioned{f: h.f}
518 h.lease.until = h.clock.now.Add(moves.StagingLeaseTTL)
519 inc, err := hostclaim.Incarnation("bab", "app", p1Host, p1Created)
520 if err != nil {
521 t.Fatal(err)
522 }
523 h.inc = inc
524 privB64, _, err := appconfig.GenerateKey()
525 if err != nil {
526 t.Fatal(err)
527 }
528 priv, err := appconfig.ParsePrivateKey(privB64, "test")
529 if err != nil {
530 t.Fatal(err)
531 }
532 h.optDir = t.TempDir()
533 h.route = registry.Route{Host: p1Host, Kind: "instance", App: "app", Account: "bab", Hibernate: true, Status: "ready",
534 Socket: provision.DefaultSocket(p1Host), DBPath: provision.DefaultDBPath(h.cfg.DataDir, p1Host), Provisioned: true}
535 h.env = map[string]string{"CARLOS_REGION": h.src, "CARLOS_CONTROL_SOCKET": "/run/carlos/" + p1Host + ".sock.control", "APP_MODE": "prod"}
536 h.probe, h.strict = moves.Check{OK: true}, moves.Check{OK: true}
537 ctx := context.Background()
538
539 h.cfg.Enabled = true
540 h.cfg.Box = h.srcBox
541 h.cfg.Now = h.clock.Now
542 h.cfg.OptDir = h.optDir
543 h.cfg.DeployBucket = "deploy"
544 h.cfg.StagingLease = func(context.Context, string, string) (moves.SafeUntilGuard, func(context.Context) error, error) {
545 h.lease.mu.Lock()
546 h.lease.acquired++
547 h.lease.mu.Unlock()
548 return h.lease, func(context.Context) error {
549 h.lease.mu.Lock()
550 h.lease.released++
551 h.lease.mu.Unlock()
552 return nil
553 }, nil
554 }
555 h.cfg.Staging = func(context.Context, string) (lease.StagingWriter, error) { return h.f, nil }
556 h.cfg.Versioned = func(context.Context, string, string) (lease.VersionedStore, error) { return h.vs, nil }
557 h.cfg.SecretsKey, h.cfg.SecretsPriv, h.cfg.SecretsPub = privB64, priv, priv.PublicKey()
558 h.cfg.LaunchEnv = func(string) (map[string]string, map[string]bool, error) { return h.env, nil, nil }
559 if h.realEnv {
560 src := &activator.ExecBackend{DataDir: h.cfg.DataDir, Region: h.src}
561 h.cfg.LaunchEnv = func(string) (map[string]string, map[string]bool, error) {
562 env, instance, _, err := src.LaunchLayers(h.route)
563 return env, instance, err
564 }
565 }
566 h.cfg.FilesDir = func(host string) (string, string) {
567 return filepath.Join(h.cfg.DataDir, host), filepath.Join(h.cfg.DataDir, host+".env")
568 }
569 h.cfg.ReadyProbe = func(_ context.Context, _, path string) moves.Check {
570 h.probePaths = append(h.probePaths, path)
571 return h.probe
572 }
573 h.cfg.StrictNext = func(string) moves.Check { return h.strict }
574 h.cfg.Route = func(host string) (*registry.Route, error) {
575 if host != p1Host {
576 return nil, nil
577 }
578 rt := h.route
579 return &rt, nil
580 }
581
582 // The source's files and carried .env, its live release v7, and its
583 // replica: two generations under the scoped location (g-old and the
584 // live g-cur), plus a generation.json and a lock.json beside them.
585 files := filepath.Join(h.cfg.DataDir, p1Host)
586 if err := os.MkdirAll(files, 0o755); err != nil {
587 t.Fatal(err)
588 }
589 if err := os.WriteFile(filepath.Join(files, "upload.txt"), []byte("hello"), 0o644); err != nil {
590 t.Fatal(err)
591 }
592 if err := os.WriteFile(filepath.Join(h.cfg.DataDir, p1Host+".env"), []byte(h.srcEnvFile), 0o600); err != nil {
593 t.Fatal(err)
594 }
595 pointLiveForTest(t, h.optDir, p1Host, "v7")
596 loc := "instances/" + p1Host + "/" + inc + "/"
597 h.f.store(h.srcBucket, loc+"g-cur/0000/0000000000000001-0000000000000002.ltx",
598 fakeMoveObject{lease.MirrorObject{ETag: "cur-a", Size: 2 << 20}, string(ltxForTest(t, 4096, 4, 1, 2))})
599 h.f.store(h.srcBucket, loc+"g-cur/0001/0000000000000001-0000000000000002.ltx",
600 fakeMoveObject{lease.MirrorObject{ETag: "cur-b", Size: 4096}, string(ltxForTest(t, 4096, 4, 1, 2))})
601 h.f.putSized(h.srcBucket, loc+"g-old/0000/0000000000000001-0000000000000001.ltx", "old-a", 4096)
602 h.f.put(h.srcBucket, loc+"generation.json", "gen")
603 h.f.put(h.srcBucket, loc+"g-cur/lock.json", "lock")
604
605 // The source's edges file: ie publishes its data bucket too (T10).
606 if _, err := h.s.Put(ctx, "control/edges/"+h.srcBox+".json",
607 []byte(`{"v":1,"box":"`+h.srcBox+`","eip":"1.2.3.5","placement":"`+h.src+`","data_bucket":"`+h.srcBucket+`","data_bucket_region":"`+h.srcAWS+`","hosts":{}}`), ""); err != nil {
608 t.Fatal(err)
609 }
610 // The target's edges file, when it is not newMoveHarness's uk-box: a
611 // home box publishes its data bucket and no placement.
612 if h.dstBox != "uk-box" {
613 if err := h.s.Delete(ctx, "control/edges/uk-box.json"); err != nil {
614 t.Fatal(err)
615 }
616 placement := ""
617 if h.dst != "" {
618 placement = `"placement":"` + h.dst + `",`
619 }
620 if _, err := h.s.Put(ctx, "control/edges/"+h.dstBox+".json",
621 []byte(`{"v":1,"box":"`+h.dstBox+`","eip":"1.2.3.4",`+placement+`"data_bucket":"`+h.dstBucket+`","data_bucket_region":"`+h.dstAWS+`","hosts":{}}`), ""); err != nil {
622 t.Fatal(err)
623 }
624 }
625 // The app's instances manifest: it declares no readiness path.
626 if err := provision.PutManifest(ctx, h.s, "bab", "app", provision.Manifest{Schema: 1}); err != nil {
627 t.Fatal(err)
628 }
629 // The console's record (T2).
630 if _, err := h.s.Put(ctx, moves.ConsoleKey, []byte(`{"v":1,"protocol":1,"secrets_key_fp":"`+appconfig.KeyFingerprint(priv.PublicKey())+`"}`), ""); err != nil {
631 t.Fatal(err)
632 }
633 return h
634}
635
636// pointLiveForTest makes optDir/live/<host> point at version v, the shape
637// release.LiveVersion reads.
638func pointLiveForTest(t *testing.T, optDir, host, v string) {
639 t.Helper()
640 target := filepath.Join(optDir, "versions", v, "bab", "app")
641 if err := os.MkdirAll(target, 0o755); err != nil {
642 t.Fatal(err)
643 }
644 if err := os.MkdirAll(filepath.Join(optDir, "live"), 0o755); err != nil {
645 t.Fatal(err)
646 }
647 link := filepath.Join(optDir, "live", host)
648 os.Remove(link)
649 if err := os.Symlink(target, link); err != nil {
650 t.Fatal(err)
651 }
652}
653
654// start writes the record (MoveStart'ed, id p1ID, cut mode cut) and the
655// claim the door's CAS leaves: serving under exec ie-box, then moving with
656// target uk-box. The claim has touched ie and ie-box only. Under intoHome
657// the source is ca-box, the target home-box and the move's region the
658// placement-derived @home, begun with the admission as the claim-mode door
659// begins it (BeginAdmittedMove, home_region ie). The retained options add
660// the claim state an earlier stay left.
661func (h *phase1Harness) start(t *testing.T, cut string) provision.Record {
662 t.Helper()
663 ctx := context.Background()
664 rec := provision.Record{Schema: 1, Host: p1Host, Slug: "s", App: "app", Account: "bab", Region: h.src, Channel: "stable", CreatedAt: p1Created}
665 if err := provision.CreateRecord(ctx, h.s, rec); err != nil {
666 t.Fatal(err)
667 }
668 if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveStartTransition(provision.MoveStart{
669 ID: p1ID, Incarnation: h.inc, From: h.src, To: h.dst, Cut: cut, By: "op", Now: h.clock.Now()})); err != nil {
670 t.Fatal(err)
671 }
672 obs, err := hostclaim.Update(ctx, h.s, hostclaim.Observed{Host: p1Host, Absent: true}, func(c *hostclaim.Claim) error {
673 c.Holder = &hostclaim.Holder{Family: "instance", Key: provision.RecordKey("bab", "app", p1Host), Account: "bab", App: "app",
674 Incarnation: h.inc, CreatedAt: p1Created, Location: "instances/" + p1Host + "/" + h.inc, Generation: "cur", Stamp: 1}
675 c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: h.srcBox}
676 c.Touched = hostclaim.Touched{Complete: true, Regions: []string{park.PlacementRegion(h.src)}, Boxes: []string{h.srcBox}}
677 if h.retain.touched != "" {
678 c.Touched.Regions = append(c.Touched.Regions, h.retain.touched)
679 }
680 if h.retain.quiet != "" {
681 c.QuietAfter = map[string]time.Time{h.retain.quiet: h.clock.Now().Add(-time.Hour).UTC()}
682 }
683 return nil
684 })
685 if err != nil {
686 t.Fatal(err)
687 }
688 to := park.PlacementRegion(h.dst)
689 begin := hostclaim.BeginMove(p1ID, "op", h.srcBox, h.dstBox, to, h.clock.Now())
690 if h.admitted {
691 begin = hostclaim.BeginAdmittedMove(p1ID, "op", h.srcBox, h.dstBox, to, h.clock.Now(), hostclaim.MoveAdmission{HomeRegion: h.homeRegion,
692 AppID: "app_" + p1Host, AdmissionID: p1ID, SourceEnrollment: h.roster[h.srcBox].Enrollment, TargetEnrollment: h.roster[h.dstBox].Enrollment})
693 }
694 if _, err := hostclaim.Update(ctx, h.s, obs, begin); err != nil {
695 t.Fatal(err)
696 }
697 if h.retain.chore != "" {
698 // An earlier stay's residue chore in the home region, owed under
699 // the spelling the earlier op wrote: one generation of the earlier
700 // stay, parked under the location's scope, as QueueResidueParks
701 // queues a landing's residue. Seeded after the start: the start
702 // guard refuses a move into a region with a chore owed.
703 rewriteP1Claim(t, h.s, func(c *hostclaim.Claim) {
704 c.Obligations = append(c.Obligations, hostclaim.Obligation{ID: "chore-earlier", Op: retainedChoreOp, Kind: "park-generation", Region: h.retain.chore,
705 Generation: retainedChoreGeneration, Owner: "region:" + h.retain.chore, Retirement: "retirement-earlier",
706 Park: &hostclaim.ParkScope{Location: c.Holder.Location, Incarnation: h.inc, EvidenceAt: h.clock.Now().Add(-time.Hour).UTC(), RequestLimit: moves.ParkRequestLimit}})
707 })
708 }
709 return h.read(t, p1Host)
710}
711
712func (h *phase1Harness) claim(t *testing.T) *hostclaim.Claim {
713 t.Helper()
714 obs, err := hostclaim.Read(context.Background(), h.s, p1Host)
715 if err != nil || obs.Absent {
716 t.Fatalf("claim: %v absent=%v", err, obs.Absent)
717 }
718 return obs.Claim
719}
720
721// pass runs one source pass.
722func (h *phase1Harness) pass(t *testing.T) {
723 t.Helper()
724 if err := movesPass(context.Background(), h.s, h.w()); err != nil {
725 t.Fatal(err)
726 }
727}
728
729// w is one worker kept across the harness's passes, as one agent keeps it.
730func (h *phase1Harness) w() *movesWorker {
731 if h.worker1 == nil {
732 h.worker1 = &movesWorker{cfg: h.cfg, mirrors: map[string]lease.MirrorStore{}}
733 }
734 h.worker1.cfg = h.cfg
735 return h.worker1
736}
737
738// targetCfg is the uk box's configuration over the same store and buckets,
739// its hooks stubbed to pass (each test turns one off). Under intoHome it is
740// home-box's: placement "", ParkHomeRegion ie, and a held environment with
741// no CARLOS_REGION.
742func (h *phase1Harness) targetCfg() moveCfg {
743 c := h.cfg
744 c.Region, c.Box, c.OwnBucket, c.OwnBucketRegion = h.dst, h.dstBox, h.dstBucket, h.dstAWS
745 if h.dst == "" {
746 c.ParkHomeRegion = h.homeRegion
747 }
748 c.OwnsRoute = func(string) bool { return false }
749 c.Route = func(string) (*registry.Route, error) { return nil, nil }
750 c.ComposeHeldEnv = func(ctx context.Context, s release.Store, rec provision.Record, carried []byte) (map[string]string, map[string]bool, []string, provision.MoveInputs, error) {
751 env, instance, err := h.heldEnv(ctx, s, rec, carried)
752 return env, instance, nil, provision.MoveInputs{BundleETags: map[string]string{"default": "e1", "environment": ""}}, err
753 }
754 c.ProbeStore = func(context.Context, map[string]string, string) (string, error) { return "", nil }
755 c.DiskFree = func(string) (uint64, uint64, error) { return 100 << 30, 90 << 30, nil }
756 c.ReleaseReady = func(context.Context, release.Store, provision.Record, string) moves.Check {
757 return moves.Check{OK: true}
758 }
759 c.LaneProbe = func(context.Context, string, string, string) error { return nil }
760 c.MeasureS3 = func(context.Context, string) (moves.MeasuredRate, error) {
761 return moves.MeasuredRate{MiBPerS: 250, At: h.clock.Now(), Bytes: 32 << 20}, nil
762 }
763 c.MeasureDisk = func(context.Context) (moves.MeasuredRate, moves.FilesRate, error) {
764 now := h.clock.Now()
765 return moves.MeasuredRate{MiBPerS: 500, At: now, Bytes: 128 << 20}, moves.FilesRate{PerS: 1600, At: now, Files: 512}, nil
766 }
767 // The cut guard's T13: the target states it can land and roll back,
768 // as every box will once the wake-source gate opens (armStandIns).
769 armStandIns(&c, h.s, roleTarget)
770 return c
771}
772
773// setHeldRegion is the agent's rule for the target's held environment
774// (agentOwnedVars): CARLOS_REGION is the box's placement when it has one,
775// and absent on a home box.
776func setHeldRegion(env map[string]string, placement string) {
777 if placement == "" {
778 delete(env, "CARLOS_REGION")
779 return
780 }
781 env["CARLOS_REGION"] = placement
782}
783
784// targetPass runs one pass as the target box.
785func (h *phase1Harness) targetPass(t *testing.T, c moveCfg) {
786 t.Helper()
787 if h.worker2 == nil {
788 h.worker2 = &movesWorker{mirrors: map[string]lease.MirrorStore{}}
789 }
790 h.worker2.cfg = c
791 if err := movesPass(context.Background(), h.s, h.worker2); err != nil {
792 t.Fatal(err)
793 }
794}
795
796func (h *phase1Harness) targetReport(t *testing.T) *moves.TargetReport {
797 t.Helper()
798 r, err := readTargetReport(context.Background(), h.s, h.read(t, p1Host))
799 if err != nil {
800 t.Fatal(err)
801 }
802 return r
803}
804
805func (h *phase1Harness) sourceReport(t *testing.T) *moves.SourceReport {
806 t.Helper()
807 r, err := readSourceReport(context.Background(), h.s, h.read(t, p1Host))
808 if err != nil {
809 t.Fatal(err)
810 }
811 return r
812}
813
814func (h *phase1Harness) ledger(t *testing.T) moveLedger {
815 t.Helper()
816 l, err := readMoveLedger(context.Background(), h.f, h.srcBucket, moves.LedgerKey(p1Host, p1ID))
817 if err != nil {
818 t.Fatal(err)
819 }
820 return l
821}
822
823// ltxForTest is a real LTX file (lz4-compressed pages) for a database of
824// commit pages of pageSize bytes, covering minTX to maxTX. A snapshot
825// (minTX 1) carries every page; an increment carries page 1 alone. Pages
826// are zeros, so the file is far smaller than the database it describes.
827func ltxForTest(t *testing.T, pageSize, commit uint32, minTX, maxTX uint64) []byte {
828 t.Helper()
829 var buf bytes.Buffer
830 enc, err := ltx.NewEncoder(&buf)
831 if err != nil {
832 t.Fatal(err)
833 }
834 hdr := ltx.Header{Version: ltx.Version, PageSize: pageSize, Commit: commit, MinTXID: ltx.TXID(minTX), MaxTXID: ltx.TXID(maxTX), Timestamp: 1000}
835 if minTX > 1 {
836 hdr.PreApplyChecksum = ltx.ChecksumFlag | 1
837 }
838 if err := enc.EncodeHeader(hdr); err != nil {
839 t.Fatal(err)
840 }
841 pages := uint32(1)
842 if minTX == 1 {
843 pages = commit
844 }
845 page := make([]byte, pageSize)
846 for pgno := uint32(1); pgno <= pages; pgno++ {
847 if pgno == ltx.LockPgno(pageSize) {
848 continue
849 }
850 if err := enc.EncodePage(ltx.PageHeader{Pgno: pgno}, page); err != nil {
851 t.Fatal(err)
852 }
853 }
854 enc.SetPostApplyChecksum(ltx.ChecksumFlag | 2)
855 if err := enc.Close(); err != nil {
856 t.Fatal(err)
857 }
858 return buf.Bytes()
859}
860