package main // Tests for relocate.go — the multi-edge Phase 1 "relocate" action. Three // concerns, matching the load-bearing decisions: // // - relocationStorePlan: the PURE skip-vs-copy decision (dev's shared bucket // is a no-op; anything differing is a real copy). // - copyS3Prefix: the cross-region prefix copy — key layout preserved, the // lease (lock.json) EXCLUDED, tested against a fake S3 client. // - relocate: target-region validation, the home_region flip, and the // idempotent + copy-clobber guard (an already-home re-run never re-copies). import ( "context" "path/filepath" "strings" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" awss3 "github.com/aws/aws-sdk-go-v2/service/s3" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" ) // --- relocationStorePlan (pure) ------------------------------------------------ func TestRelocationStorePlan(t *testing.T) { base := storeLocation{Bucket: "shared", Prefix: "hib/space1.dev.11ch.at", Endpoint: ""} // Same bucket + prefix + endpoint = the dev shared-bucket case: the bytes are // already visible to both regions, so the copy is a no-op. if p := relocationStorePlan(base, base); !p.Skip { t.Fatalf("identical src/dst should skip, got copy: %+v", p) } // A different bucket is a real copy (prod's per-region stores). dstBucket := base dstBucket.Bucket = "fi-bucket" if p := relocationStorePlan(base, dstBucket); p.Skip { t.Fatal("different bucket must NOT skip — it needs a real copy") } // A different prefix is a real copy. dstPrefix := base dstPrefix.Prefix = "hib2/space1.dev.11ch.at" if p := relocationStorePlan(base, dstPrefix); p.Skip { t.Fatal("different prefix must NOT skip") } // A same-named bucket at a DIFFERENT endpoint (S3 vs R2) is different bytes — // the endpoint must be part of the identity test. dstEndpoint := base dstEndpoint.Endpoint = "https://acct.r2.cloudflarestorage.com" if p := relocationStorePlan(base, dstEndpoint); p.Skip { t.Fatal("different endpoint must NOT skip — a bucket-name coincidence is not the same bytes") } } // --- copyS3Prefix -------------------------------------------------------------- // fakeS3Copy is an in-memory stand-in for the S3 client copyS3Prefix drives: // ListObjectsV2 returns the seeded keys under the requested prefix (one page), // CopyObject records every call. type fakeS3Copy struct { objects []string copies []*awss3.CopyObjectInput } func (f *fakeS3Copy) ListObjectsV2(_ context.Context, in *awss3.ListObjectsV2Input, _ ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error) { prefix := aws.ToString(in.Prefix) var contents []s3types.Object for _, k := range f.objects { if strings.HasPrefix(k, prefix) { contents = append(contents, s3types.Object{Key: aws.String(k)}) } } return &awss3.ListObjectsV2Output{Contents: contents}, nil } func (f *fakeS3Copy) CopyObject(_ context.Context, in *awss3.CopyObjectInput, _ ...func(*awss3.Options)) (*awss3.CopyObjectOutput, error) { f.copies = append(f.copies, in) return &awss3.CopyObjectOutput{}, nil } func TestCopyS3Prefix(t *testing.T) { src := "hib/space1.dev.11ch.at" dst := "hib/space1.dev.11ch.at" // same per-host prefix, different bucket fake := &fakeS3Copy{objects: []string{ src + "/lock.json", // the lease — MUST be skipped src + "/ltx/0001.ltx", // replica data src + "/snapshots/000.snap", // replica data "other/space2.dev.11ch.at/ltx/0001.ltx", // a different host — out of prefix }} n, err := copyS3Prefix(context.Background(), fake, "src-bucket", src, "fi-bucket", dst) if err != nil { t.Fatalf("copyS3Prefix: %v", err) } if n != 2 { t.Fatalf("copied %d objects, want 2 (lock.json excluded, other host out of prefix)", n) } if len(fake.copies) != 2 { t.Fatalf("recorded %d CopyObject calls, want 2", len(fake.copies)) } got := map[string]string{} // dst key -> CopySource for _, c := range fake.copies { if aws.ToString(c.Bucket) != "fi-bucket" { t.Fatalf("copy dst bucket = %q, want fi-bucket", aws.ToString(c.Bucket)) } got[aws.ToString(c.Key)] = aws.ToString(c.CopySource) } // The lease was never copied. for k := range got { if strings.HasSuffix(k, "lock.json") { t.Fatalf("lock.json was copied (%q) — a copied lease stalls the destination's first wake", k) } } // Keys map into the destination prefix, CopySource points at the source. if cs, ok := got["hib/space1.dev.11ch.at/ltx/0001.ltx"]; !ok || cs != "src-bucket/hib/space1.dev.11ch.at/ltx/0001.ltx" { t.Fatalf("ltx object: dstKey/CopySource wrong (%q -> %q)", "hib/space1.dev.11ch.at/ltx/0001.ltx", cs) } if _, ok := got["hib/space1.dev.11ch.at/snapshots/000.snap"]; !ok { t.Fatalf("snapshot object was not copied to the right dst key; got %v", got) } } // --- relocate: validation + flip + idempotency --------------------------------- // relocTestReg seeds an in-memory registry with one hibernating, ready, // in-scope instance route homed to fromRegion. func relocTestReg(t *testing.T, host, fromRegion string, hibernate bool, kind string) *Registry { t.Helper() reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatalf("open registry: %v", err) } if err := reg.upsert(Route{ Host: host, Kind: kind, Status: "ready", Hibernate: hibernate, HomeRegion: fromRegion, }); err != nil { t.Fatalf("seed route: %v", err) } return reg } // relocTestMesh builds a mesh over a fake heartbeat store: this box is // selfRegion, and each peer in peers advertises its region. relocate.beat() // refreshes membership + regions off this store. func relocTestMesh(reg *Registry, self, selfRegion string, peers map[string]string) *meshSvc { now := time.Now() store := newFakeMeshStore(func() time.Time { return now }) for peer, region := range peers { store.setBeat(peer, now) store.setRegion(peer, region) } m := newMesh(self, store, []string{"dev.11ch.at"}) m.region = selfRegion m.reg = reg m.now = func() time.Time { return now } return m } func TestRelocateFlipAndIdempotency(t *testing.T) { host := "space1.dev.11ch.at" reg := relocTestReg(t, host, "ie", true, "instance") mesh := relocTestMesh(reg, "https://ie-box", "ie", map[string]string{"https://fi-box": "fi"}) hc := &hibernateConfig{Bucket: "shared", Prefix: "hib"} // one shared bucket → skip path // Forward move ie → fi: flips home_region, skip copy (shared bucket). res, err := relocate(reg, mesh, hc, relocateOpts{Host: host, ToRegion: "fi"}) if err != nil { t.Fatalf("relocate: %v", err) } if res.AlreadyHome { t.Fatal("first move should not be AlreadyHome") } if !res.Plan.Skip || res.Copied != 0 { t.Fatalf("shared bucket should skip the copy, got plan=%+v copied=%d", res.Plan, res.Copied) } if rt, _ := reg.get(host); rt == nil || rt.HomeRegion != "fi" { t.Fatalf("home_region not flipped to fi: %+v", rt) } // Re-run to the same region: AlreadyHome no-op, home_region unchanged, and — // critically — NO copy even though we now point at a DIFFERENT bucket (the // copy-clobber guard: the destination is authoritative once home, so // re-copying stale source bytes would lose newer writes). res2, err := relocate(reg, mesh, hc, relocateOpts{ Host: host, ToRegion: "fi", Dst: storeLocation{Bucket: "some-other-bucket"}, // would force a copy if reached }) if err != nil { t.Fatalf("idempotent relocate: %v", err) } if !res2.AlreadyHome { t.Fatal("re-run to the same region must be AlreadyHome") } if res2.Copied != 0 || !res2.Plan.Skip { t.Fatalf("already-home re-run must NOT copy (copy-clobber guard), got copied=%d plan=%+v", res2.Copied, res2.Plan) } if rt, _ := reg.get(host); rt.HomeRegion != "fi" { t.Fatalf("home_region changed on idempotent re-run: %q", rt.HomeRegion) } } func TestRelocateValidation(t *testing.T) { host := "space1.dev.11ch.at" hc := &hibernateConfig{Bucket: "shared", Prefix: "hib"} liveFi := map[string]string{"https://fi-box": "fi"} cases := []struct { name string reg *Registry mesh *meshSvc hc *hibernateConfig opts relocateOpts wantSub string }{ { name: "missing target region", reg: relocTestReg(t, host, "ie", true, "instance"), hc: hc, opts: relocateOpts{Host: host, ToRegion: ""}, wantSub: "--to is required", }, { name: "hibernation not configured", reg: relocTestReg(t, host, "ie", true, "instance"), hc: nil, opts: relocateOpts{Host: host, ToRegion: "fi"}, wantSub: "hibernation is not configured", }, { name: "mesh not configured", reg: relocTestReg(t, host, "ie", true, "instance"), hc: hc, mesh: nil, opts: relocateOpts{Host: host, ToRegion: "fi"}, wantSub: "activator mesh is not configured", }, { name: "unknown host", reg: relocTestReg(t, host, "ie", true, "instance"), hc: hc, opts: relocateOpts{Host: "nope.dev.11ch.at", ToRegion: "fi"}, wantSub: "unknown host", }, { name: "not an instance", reg: relocTestReg(t, host, "ie", true, "service"), hc: hc, opts: relocateOpts{Host: host, ToRegion: "fi"}, wantSub: "not an instance", }, { name: "not hibernating (box-pinned)", reg: relocTestReg(t, host, "ie", false, "instance"), hc: hc, opts: relocateOpts{Host: host, ToRegion: "fi"}, wantSub: "systemd-managed", }, { name: "no live box in target region", reg: relocTestReg(t, host, "ie", true, "instance"), hc: hc, opts: relocateOpts{Host: host, ToRegion: "zz"}, wantSub: "no live mesh box advertises region", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { mesh := tc.mesh // A nil mesh in the case means "test the nil-mesh guard"; otherwise // build a live-fi mesh. The "mesh not configured" case sets mesh:nil // explicitly and we honour it. if mesh == nil && tc.name != "mesh not configured" { mesh = relocTestMesh(tc.reg, "https://ie-box", "ie", liveFi) } _, err := relocate(tc.reg, mesh, tc.hc, tc.opts) if err == nil { t.Fatalf("want error containing %q, got nil", tc.wantSub) } if !strings.Contains(err.Error(), tc.wantSub) { t.Fatalf("error %q does not contain %q", err.Error(), tc.wantSub) } }) } } // zoneForHost picks the longest matching configured domain (a delegated // sub-zone wins over its parent). func TestZoneForHost(t *testing.T) { domains := []string{"11ch.at", "dev.11ch.at"} if z := zoneForHost("space1.dev.11ch.at", domains); z != "dev.11ch.at" { t.Fatalf("zone = %q, want dev.11ch.at (most specific)", z) } if z := zoneForHost("chat.11ch.at", domains); z != "11ch.at" { t.Fatalf("zone = %q, want 11ch.at", z) } if z := zoneForHost("space1.example.com", domains); z != "" { t.Fatalf("zone = %q, want empty (no configured domain matches)", z) } }