eleven / messenger Public

server: lchat relocate — change a space's home region (multi-edge P1 slice 5) (#1129)

The relocate action moves a space to another region while keeping its stable
address: copy the store to the destination region (a no-op on dev's shared
bucket, a real prefix copy for prod's per-region stores), flip the replicated
home_region, re-fan-out the route so every box re-homes the space, and
optionally move the A record (off by default — the wildcard + proxy already
route to the new owner).

Data-safety rests on the single-writer S3 lease: home_region/DNS only steer
placement, the lease is the one truth of who writes, so a wrong-box request
502/503s and retries onto the owner — never two writers. Idempotent, with a
copy-clobber guard: once a space is already home the destination is
authoritative, so a re-run never re-copies stale source bytes.

- relocate.go: relocationStorePlan (pure skip-vs-copy decision), copyS3Prefix
  (excludes lock.json), relocate() core, runRelocate CLI, optional A-record path.
- registry.go: setHomeRegion (narrow UPDATE).
- mesh.go: regionLive (target-region liveness precondition).
- route53dns.go: upsertA + aRecordUpserter (optional, Route53-only).
- Tests: store-plan decision, copyS3Prefix, home_region flip + idempotency,
  target-region validation, Route53 A upsert.
- docs/multi-edge.md: the relocate command, data-safety, dev-vs-prod, follow-ons.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Paul Campbell 6f66b538f7cd1eb0eeb2d30c13b040729f661dfa parent 4f236f1
8 files changed, +968 −6
  • docs/multi-edge.md +82 −3
  • main.go +3 −0
  • mesh.go +32 −3
  • registry.go +15 −0
  • relocate.go +462 −0
  • relocate_test.go +304 −0
  • route53dns.go +34 −0
  • route53dns_test.go +36 −0
diff --git a/docs/multi-edge.md b/docs/multi-edge.md
index 82708ae..0bb57cf 100644
--- a/docs/multi-edge.md
+++ b/docs/multi-edge.md
@@ -92,6 +92,79 @@ route-replication plumbing) plus the `home_region` field, region-neutral
addressing, and placement-by-region. It is the smallest change that delivers the
stable-address promise.
+### The `lchat relocate` command
+
+The relocate action is a subcommand of the one binary — `lchat relocate <host>
+--to <region>` (`relocate.go`, a reusable `relocate()` core a future controld
+RPC or member-facing "change region" button can call). It runs the three steps
+above in order:
+
+1. **Store copy (or the dev no-op).** A pure decision helper,
+ `relocationStorePlan(src, dst)`, returns `skip=true` exactly when source and
+ destination name the same bytes — same bucket, same per-host prefix, same
+ endpoint. That is the **dev mesh's one-shared-bucket case**, so the copy is a
+ no-op there. In production each region has its own store, so it is a real
+ prefix-to-prefix copy of `s3://<bucket>/<host>/…` (`copyS3Prefix`), run while
+ the space sleeps. The copy deliberately **excludes the lease object**
+ (`lock.json`): a copied, still-valid lease would make the destination's first
+ `AcquireLease` see a held lock and 503 until the TTL expired — the destination
+ must take its own fresh lease.
+2. **Flip `home_region` + re-fan-out.** `setHomeRegion` writes the replicated
+ routes column and `fanOutMeshRoute` pushes the updated route to peers, so every
+ box's `candidatesFor` (`mesh.go`) re-homes the space onto a box in the
+ destination region. Route *edits* aren't auto-replicated by the normal path, so
+ this is the deliberate targeted re-fan-out of the single updated route. The
+ local router needs **no reload** — its mesh reads `home_region` live from the
+ shared registry on each placement decision.
+3. **Optional A-record move.** `--move-dns --dns-target <ip>` repoints the host's
+ A record at the destination box to avoid the proxy hop. **Off by default** — the
+ `*.<domain>` wildcard + proxy-to-owner already route any request to the new
+ owner through the DNS-TTL window, so relocation is correct with zero DNS write
+ perms. Only the Route53 provider implements the A upsert today
+ (`route53dns.go`); DNSimple refuses `--move-dns` with a clear message.
+
+**Preconditions the command enforces:** the space must be an in-scope, `ready`,
+**hibernating** instance (a live systemd instance is box-pinned — the message
+tells you to `lchat hibernate <host> --on` first), and a **live mesh box must
+advertise the target region** (else the flip re-homes onto nothing). Run it on a
+box whose registry has the route (the source region's box) with the mesh env
+sourced (`/data/router.env`) — `sudo` strips `LCHAT_MESH_*`, the same gotcha the
+dev-cutover runbook's Step 5 calls out.
+
+**Why the single-writer lease makes it data-safe.** Relocation never split-brains
+because the S3 lease (`internal/activator`, `lock.json` under the space's prefix)
+is the one truth of who may write; `home_region` and DNS are only placement hints:
+
+- *Shared bucket (dev):* both regions contend on the **same** `lock.json`, so the
+ lease is globally singular. After the flip the destination is the HRW owner; if
+ the source is momentarily still awake it holds the lease, the destination's
+ `AcquireLease` fails loudly (`LeaseExistsError` → 503 + Retry-After), and the
+ client's retry lands once the source sleeps and releases. Never two writers.
+- *Per-region buckets (prod):* the copy runs while the source is **asleep** (lease
+ released, replica fully flushed by the sleep-time `SyncAndWait`), so the
+ destination restores a complete copy and takes a fresh lease in its own bucket.
+ Once `home_region` is flipped the source's `owns()` returns false, so it never
+ wakes there again — a stray request proxies to the new owner instead.
+
+**Idempotency + the copy-clobber guard.** Once `home_region` already equals the
+target, the space's authoritative copy is the **destination's**, so a re-run must
+**not** re-copy source→dest (that would overwrite newer destination writes with
+stale source bytes). The copy + flip therefore run **only on the forward move**
+(`current != target`); an already-home re-run just heals peers by re-fanning-out
+the route. A crash between copy and flip converges safely — the source is still
+authoritative (not yet flipped), so the next run re-copies (an idempotent
+overwrite) and flips.
+
+**Tested vs. needs live proof.** Unit tests cover the pure store-plan decision
+(skip vs copy), `copyS3Prefix` (key layout preserved, `lock.json` excluded), the
+`home_region` flip + idempotency guard, target-region validation, and the Route53
+A upsert. The **dev shared-bucket path (skip) is what a live dev relocation
+exercises**; the **prod cross-bucket copy is unproven in production** — it needs a
+per-region-bucket dev/prod trial, and two design points remain open: region→bucket
+auto-discovery (today the operator names the destination with `--dst-bucket`) and
+cross-*account*/cross-*endpoint* (R2) copies, which need a download+reupload
+variant rather than server-side `CopyObject`. Both are follow-ons.
+
## Phase 2 — multi-edge within a region
Replicate a space's log across the boxes of *one* region. Same-jurisdiction, so
@@ -176,14 +249,20 @@ requirement rules out. It was a useful stepping stone; this model replaces it:
## Status and build order
-Nothing here is operable yet; the pieces it reuses are.
+Phase 1 is now largely built; the pieces it reuses were always running.
- The **append-only, content-blind data model** it rests on: *running today* — it
is how every space already works.
- **Hibernation + single-writer lease + proxy-to-owner** (the machinery Phase 1
reuses): *running today* (`docs/planet-scale.md`).
-- **Region-neutral addressing + `home_region` placement + relocation** (Phase 1):
- *designed, not built*.
+- **Region-neutral addressing + `home_region` placement** (Phase 1): *built +
+ proven live on the dev mesh* — one shared `dev.11ch.at` namespace, a shared
+ wildcard, cross-region serve returning 200 (the dev cutover runbook,
+ `docs/multi-edge-dev-cutover.md`, lands with the cutover PR).
+- **The `lchat relocate` action** (Phase 1's last slice): *built; the dev
+ shared-bucket path (skip-copy) lands on the proven cross-region serve; the prod
+ per-region-bucket copy is implemented + unit-tested but unproven in production*
+ (see [The `lchat relocate` command](#the-lchat-relocate-command)).
- **Multi-edge within a region** (Phase 2) and **cross-region multi-edge**
(Phase 3): *designed, not built*.
diff --git a/main.go b/main.go
index 46e6d91..b27269c 100644
--- a/main.go
+++ b/main.go
@@ -211,6 +211,9 @@ func main() {
case "replication":
runReplication(os.Args[2:])
return
+ case "relocate":
+ runRelocate(os.Args[2:])
+ return
case "admin-link":
runAdminLink(os.Args[2:])
return
diff --git a/mesh.go b/mesh.go
index 52368ef..31d504f 100644
--- a/mesh.go
+++ b/mesh.go
@@ -121,9 +121,9 @@ type meshStore interface {
type meshSvc struct {
self string // this box's peer base URL — its mesh identity
store meshStore
- domains []string // LCHAT_MESH_DOMAINS, lowercased — see inScope. Empty = unrestricted (test convenience only; meshFromEnv always supplies a non-empty list)
- token string // LCHAT_MESH_TOKEN — shared secret authenticating box-to-box route replication (meshreplicate.go); empty = unrestricted (test convenience only)
- region string // LCHAT_MESH_REGION — this box's placement region (e.g. "ie"); "" = region-agnostic (today's behaviour). Advertised in the heartbeat body; peers read it via Regions. See candidatesFor.
+ domains []string // LCHAT_MESH_DOMAINS, lowercased — see inScope. Empty = unrestricted (test convenience only; meshFromEnv always supplies a non-empty list)
+ token string // LCHAT_MESH_TOKEN — shared secret authenticating box-to-box route replication (meshreplicate.go); empty = unrestricted (test convenience only)
+ region string // LCHAT_MESH_REGION — this box's placement region (e.g. "ie"); "" = region-agnostic (today's behaviour). Advertised in the heartbeat body; peers read it via Regions. See candidatesFor.
reg *Registry // local registry handle, for reading a host's replicated home_region (candidatesFor). nil = no lookup (direct-newMesh unit tests) = every space region-agnostic.
interval time.Duration
staleAfter time.Duration
@@ -384,6 +384,35 @@ func (m *meshSvc) homeRegionOf(host string) string {
return r.HomeRegion
}
+// regionLive reports whether any live box currently advertises region — the
+// precondition `lchat relocate` checks before flipping a space's home_region
+// onto a destination region: there is no point re-homing a space to a region
+// no box serves (candidatesFor would just fall back to the whole fleet and the
+// space would never actually move). It reads the same cached membership +
+// peer-region view HRW placement uses, so the caller must beat() first to
+// refresh it (fanOutMeshRoute's pattern). This box's own region is known
+// locally (m.region), never via S3.
+func (m *meshSvc) regionLive(region string) bool {
+ if region == "" {
+ return false
+ }
+ if m.region == region {
+ return true
+ }
+ m.mu.Lock()
+ regions := m.regions
+ m.mu.Unlock()
+ for _, b := range m.liveBoxes() {
+ if b == m.self {
+ continue // handled by m.region above
+ }
+ if regions[b] == region {
+ return true
+ }
+ }
+ return false
+}
+
// inScope reports whether host participates in HRW placement at all. Only
// hosts under a configured LCHAT_MESH_DOMAINS domain are ever hashed across
// boxes; everything else always resolves to whichever box is asked — correct
diff --git a/registry.go b/registry.go
index f7d4a4b..a19371a 100644
--- a/registry.go
+++ b/registry.go
@@ -289,6 +289,21 @@ func (r *Registry) setReplication(host, class string) error {
return err
}
+// setHomeRegion records host's placement region (multi-edge, docs/multi-edge.md)
+// without touching the rest of the route — the same narrow-UPDATE contract as
+// setHibernate/setReplication, so `lchat relocate` can re-home a space onto a
+// new region without clobbering a concurrent next_due write or a deploy's
+// read-modify-write (and vice versa). The mesh reads this replicated column to
+// place the space on a box in the matching region (mesh.go's candidatesFor), so
+// flipping it IS the whole mechanic of relocation — the hash re-homes the space
+// and the single-writer lease still guarantees one writer through the move. An
+// unknown host is a quiet no-op, matching setHibernate; region "" means
+// region-agnostic (today's default, hashed across the whole fleet).
+func (r *Registry) setHomeRegion(host, region string) error {
+ _, err := r.sql.Exec(`UPDATE routes SET home_region = ? WHERE host = ?`, region, host)
+ return err
+}
+
// setVersions records a host's current + previous version, same narrow-UPDATE
// contract as setHibernate/setNextDue. Deploys and update passes hold their
// route reads for a while (minutes, when a download runs) — a full upsert
diff --git a/relocate.go b/relocate.go
new file mode 100644
index 0000000..1d57ae1
--- /dev/null
+++ b/relocate.go
@@ -0,0 +1,462 @@
+package main
+
+// 🤖 `lchat relocate <host> --to <region>` — move a space to another region
+// while keeping its STABLE ADDRESS (multi-edge Phase 1, docs/multi-edge.md).
+//
+// A space's address is its identity (invite links, per-origin keys bind to it),
+// so it must never change; its region is a mutable property (`home_region`), so
+// it must be free to move. Relocation is the operator/admin action that changes
+// where the one copy lives without changing its name. Three moving parts, in the
+// order they run:
+//
+// 1. Store copy. The destination region's box wakes the space by RESTORING its
+// DB from that region's object store, so the replica must exist there first.
+// On the dev mesh both regions share ONE bucket, so the bytes are already
+// visible — the copy is a NO-OP (relocationStorePlan detects same
+// bucket+prefix+endpoint → skip). In production each region has its own
+// store, so it is a real prefix-to-prefix copy of s3://<bucket>/<host>/…
+// (copyS3Prefix), done while the space sleeps.
+// 2. Flip home_region + re-fan-out. setHomeRegion writes the replicated routes
+// column; fanOutMeshRoute pushes the updated route to peers so every box's
+// candidatesFor (mesh.go) re-homes the space onto a box in the destination
+// region. The local router needs no reload — its mesh reads home_region live
+// from the shared registry each placement decision.
+// 3. (Optional) A-record move. Repointing <host>'s A record at the destination
+// box avoids the proxy hop, but is NOT required for correctness: the
+// *.<domain> wildcard + the mesh's proxy-to-owner already route any request
+// to the new owner through the DNS-TTL window. Off by default; --move-dns
+// opts in, and only Route53 implements it today (route53dns.go).
+//
+// WHY THIS IS DATA-SAFE — the single-writer lease. Relocation never
+// split-brains because the S3 lease (internal/activator: lock.json under the
+// space's prefix, acquired via a conditional write) is the ONE truth of who may
+// write, and home_region/DNS are only hints that steer placement:
+//
+// - Shared bucket (dev): source and destination contend on the SAME lock.json,
+// so the lease is globally singular. After the flip the destination becomes
+// the HRW owner; if the source is momentarily still awake it holds the lease,
+// the destination's AcquireLease fails loudly (LeaseExistsError → 503 +
+// Retry-After), and the client's retry lands once the source sleeps and
+// releases. Never two writers, ever.
+// - Per-region buckets (prod): the copy must run while the source is ASLEEP
+// (lease released, replica fully flushed by the sleep-time SyncAndWait), so
+// the destination restores a complete copy and acquires a FRESH lease in its
+// own bucket. Once home_region is flipped the source's owns() returns false,
+// so it never wakes there again — a stray request proxies to the new owner
+// instead. copyS3Prefix deliberately skips lock.json so a copied, live lease
+// can't stall the destination's first wake.
+//
+// IDEMPOTENCY + the copy-clobber guard. Re-running a completed relocation is a
+// no-op: once home_region already equals the target the space's authoritative
+// copy is the DESTINATION's, so relocate must NOT re-copy source→dest (that
+// would overwrite newer destination writes with stale source bytes). So the
+// copy + flip run ONLY on the forward move (current != target); an
+// already-home re-run just heals peers by re-fanning-out the route. A crash
+// between copy and flip converges safely: the source is still authoritative
+// (not yet flipped), so the next run re-copies (idempotent overwrite) and flips.
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "path"
+ "strings"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/credentials"
+ awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
+)
+
+// storeLocation names one region's object store for a space's replica prefix.
+// Endpoint distinguishes same-named buckets across accounts/providers (AWS S3
+// vs an R2 endpoint) so the skip test in relocationStorePlan is never fooled by
+// a bucket-name coincidence.
+type storeLocation struct {
+ Bucket string
+ Prefix string // the per-host key prefix, e.g. "<hibernate-prefix>/<host>"
+ Endpoint string // "" = AWS default endpoint; set for R2 / custom endpoints
+}
+
+// storePlan is relocationStorePlan's decision: whether the cross-region store
+// copy is a no-op (the dev shared bucket — the bytes are already there) or a
+// real prefix-to-prefix copy (prod's per-region stores).
+type storePlan struct {
+ Skip bool
+ Reason string
+ Src storeLocation
+ Dst storeLocation
+}
+
+// relocationStorePlan is the PURE decision at the heart of the store-copy step,
+// split out so it is unit-testable with no S3. It returns skip=true exactly when
+// the source and destination name the same bytes — same bucket, same per-host
+// prefix, and same endpoint — which is the dev mesh's one-shared-bucket case:
+// both regions already read the same replica, so there is nothing to copy.
+// Anything else is a real copy plan the caller executes with copyS3Prefix.
+func relocationStorePlan(src, dst storeLocation) storePlan {
+ if src.Bucket == dst.Bucket && src.Prefix == dst.Prefix && src.Endpoint == dst.Endpoint {
+ return storePlan{
+ Skip: true,
+ Reason: "source and destination share the same bucket, prefix and endpoint — the replica bytes are already visible to the destination region (the dev shared-bucket case)",
+ Src: src, Dst: dst,
+ }
+ }
+ return storePlan{
+ Skip: false,
+ Reason: fmt.Sprintf("cross-region prefix copy: s3://%s/%s/ → s3://%s/%s/", src.Bucket, src.Prefix, dst.Bucket, dst.Prefix),
+ Src: src, Dst: dst,
+ }
+}
+
+// s3CopyAPI is the slice of the S3 client copyS3Prefix needs — an interface so
+// the copy is unit-tested against a fake without reaching AWS (the same shape
+// s3blobstore.go and route53dns.go use for their clients).
+type s3CopyAPI interface {
+ ListObjectsV2(ctx context.Context, in *awss3.ListObjectsV2Input, optFns ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error)
+ CopyObject(ctx context.Context, in *awss3.CopyObjectInput, optFns ...func(*awss3.Options)) (*awss3.CopyObjectOutput, error)
+}
+
+// copyS3Prefix copies every object under src's per-host prefix to dst's,
+// preserving the relative key layout, via server-side CopyObject (no bytes
+// transit this process). It EXCLUDES the lease object (lock.json): a copied,
+// still-valid lease would make the destination's first AcquireLease see a held
+// lock and 503 until the TTL expires — the destination must start with a clean
+// lease and take its own. Returns the count copied.
+//
+// Same-account, same-credential copy is the realistic prod shape (regional
+// buckets in one AWS account; CopyObject works cross-region). Cross-ACCOUNT or
+// cross-ENDPOINT (e.g. an R2 destination) needs a download+reupload variant —
+// a documented follow-on (docs/multi-edge.md), not this MVP.
+func copyS3Prefix(ctx context.Context, client s3CopyAPI, srcBucket, srcPrefix, dstBucket, dstPrefix string) (int, error) {
+ srcPrefix = strings.Trim(srcPrefix, "/")
+ dstPrefix = strings.Trim(dstPrefix, "/")
+ listPrefix := srcPrefix + "/"
+ p := awss3.NewListObjectsV2Paginator(client, &awss3.ListObjectsV2Input{
+ Bucket: aws.String(srcBucket),
+ Prefix: aws.String(listPrefix),
+ })
+ copied := 0
+ for p.HasMorePages() {
+ page, err := p.NextPage(ctx)
+ if err != nil {
+ return copied, fmt.Errorf("relocate: list s3://%s/%s: %w", srcBucket, listPrefix, err)
+ }
+ for _, obj := range page.Contents {
+ srcKey := aws.ToString(obj.Key)
+ rel := strings.TrimPrefix(srcKey, listPrefix)
+ if rel == "" || path.Base(rel) == "lock.json" {
+ continue // never copy the lease — the destination must take a fresh one
+ }
+ dstKey := dstPrefix + "/" + rel
+ // CopySource is "<bucket>/<key>"; our keys are ASCII-safe (host FQDN
+ // prefixes + litestream LTX / base64url leaves), so no URL-escaping
+ // is needed. A cross-account/cross-endpoint copier would encode here.
+ copySource := srcBucket + "/" + srcKey
+ if _, err := client.CopyObject(ctx, &awss3.CopyObjectInput{
+ Bucket: aws.String(dstBucket),
+ Key: aws.String(dstKey),
+ CopySource: aws.String(copySource),
+ }); err != nil {
+ return copied, fmt.Errorf("relocate: copy %s → s3://%s/%s: %w", copySource, dstBucket, dstKey, err)
+ }
+ copied++
+ }
+ }
+ return copied, nil
+}
+
+// aRecordUpserter is the OPTIONAL A-record half of relocation — a sibling to the
+// dnsProvider seam (wildcard.go), kept separate so the ACME-TXT path stays
+// untouched and a provider can support DNS-01 without supporting A moves.
+// route53DNS implements it (route53dns.go); DNSimple does not yet, so --move-dns
+// refuses on DNSimple with a clear message. Relocation is correct WITHOUT it
+// (the wildcard + proxy route to the new owner), so this is never on the MVP's
+// critical path.
+type aRecordUpserter interface {
+ upsertA(ctx context.Context, zone, host, ipv4 string) error
+}
+
+// relocateOpts parameterizes one relocation. Reusable so a future controld RPC
+// or member-facing "change region" button can call relocate() with the same
+// core (the CLI runRelocate is one caller).
+type relocateOpts struct {
+ Host string
+ ToRegion string
+ // Dst names the destination region's object store for the cross-region copy.
+ // The zero value means "the same store as the source" — the dev shared-bucket
+ // case, where the copy is a no-op. Prefix defaults to the source's per-host
+ // prefix when empty.
+ Dst storeLocation
+ // MoveDNS opts into repointing <host>'s A record at DNSTarget (off by
+ // default). DNSZone overrides the hosted zone; empty derives it from the
+ // wildcard/mesh domains the host falls under.
+ MoveDNS bool
+ DNSTarget string
+ DNSZone string
+}
+
+// relocateResult reports what happened, for the CLI to print.
+type relocateResult struct {
+ Host string
+ FromRegion string
+ ToRegion string
+ Plan storePlan
+ Copied int // objects copied (0 on the skip path)
+ AlreadyHome bool // home_region already == ToRegion — a converging no-op
+ DNSMoved bool
+}
+
+// relocate is the shared relocation core behind `lchat relocate` (and any future
+// RPC/button). It validates, does the store copy (or skips it), flips
+// home_region, re-fans-out the route, and optionally moves the A record. mesh
+// answers the "is the destination region live?" precondition and is beaten once
+// here for a fresh view; the actual route replication reuses fanOutMeshRoute.
+func relocate(reg *Registry, mesh *meshSvc, hc *hibernateConfig, opts relocateOpts) (*relocateResult, error) {
+ host := strings.ToLower(strings.TrimSpace(opts.Host))
+ if !validHost(host) {
+ return nil, fmt.Errorf("%w: %q", errInvalidHost, host)
+ }
+ region := strings.TrimSpace(opts.ToRegion)
+ if region == "" {
+ return nil, fmt.Errorf("relocate: --to <region> is required (clearing a space's region back to agnostic is a separate operation, not relocate)")
+ }
+ if hc == nil {
+ return nil, fmt.Errorf("relocate: hibernation is not configured on this box (LCHAT_HIBERNATE_BUCKET unset) — relocation is hibernation-backed: the space's state must live in S3 for the destination region to restore it")
+ }
+ if mesh == nil {
+ return nil, fmt.Errorf("relocate: the activator mesh is not configured (LCHAT_MESH_SELF/_DOMAINS/_TOKEN/_REGION) — placement-by-region needs it; run on a mesh box with /data/router.env sourced")
+ }
+
+ rt, err := reg.get(host)
+ if err != nil {
+ return nil, err
+ }
+ if rt == nil {
+ return nil, fmt.Errorf("%w: %q (run relocate on a box whose registry has the route — typically the source region's box)", errUnknownHost, host)
+ }
+ if rt.Kind != "instance" {
+ return nil, fmt.Errorf("relocate: %q is a %s route, not an instance", host, rt.Kind)
+ }
+ if rt.Status != "ready" {
+ return nil, fmt.Errorf("relocate: %s is %s, not ready — fix the instance first", host, rt.Status)
+ }
+ if !rt.Hibernate {
+ return nil, fmt.Errorf("relocate: %s is systemd-managed, not hibernating — a live systemd instance is pinned to this box; run `lchat hibernate %s --on` first so its state lives in S3 and can be restored in the destination region", host, host)
+ }
+ if !mesh.inScope(host) {
+ return nil, fmt.Errorf("relocate: %s is not under a configured mesh domain (%s) — only in-scope hosts are placed by region", host, strings.Join(mesh.domains, ", "))
+ }
+
+ res := &relocateResult{Host: host, FromRegion: rt.HomeRegion, ToRegion: region}
+
+ // Idempotent + copy-clobber guard: once the space is already homed to the
+ // target, the DESTINATION holds the authoritative copy. Re-copying source→dest
+ // would clobber newer destination writes, so DON'T. Just heal any peer that
+ // missed the earlier flip by re-fanning-out the route, and return.
+ if rt.HomeRegion == region {
+ res.AlreadyHome = true
+ here := storeLocation{Bucket: hc.Bucket, Prefix: hc.hibernatePrefix(host)}
+ res.Plan = relocationStorePlan(here, here) // same store → Skip
+ fanOutMeshRoute(hc, *rt) // best-effort heal; a no-op when the mesh is off
+ return res, nil
+ }
+
+ // The destination region must have a live box, or the flip re-homes the space
+ // onto nothing (candidatesFor would just fall back to the whole fleet and the
+ // space would not actually move). beat() first, exactly like fanOutMeshRoute,
+ // so the membership + peer-region view is fresh.
+ mesh.beat()
+ if !mesh.regionLive(region) {
+ return nil, fmt.Errorf("relocate: no live mesh box advertises region %q — bring up a box with LCHAT_MESH_REGION=%s (and confirm this box has its own LCHAT_MESH_REGION set so it can read peers' regions)", region, region)
+ }
+
+ // Step 1 — the store copy (or the dev no-op).
+ src := storeLocation{Bucket: hc.Bucket, Prefix: hc.hibernatePrefix(host)}
+ dst := opts.Dst
+ if dst.Bucket == "" {
+ dst = src // no destination store named → same store (dev shared bucket)
+ }
+ if dst.Prefix == "" {
+ dst.Prefix = hc.hibernatePrefix(host)
+ }
+ res.Plan = relocationStorePlan(src, dst)
+ if !res.Plan.Skip {
+ copied, err := runStoreCopy(context.Background(), hc.Region, res.Plan)
+ if err != nil {
+ return nil, err
+ }
+ res.Copied = copied
+ }
+
+ // Step 2 — flip home_region and re-fan-out the updated route to peers.
+ if err := reg.setHomeRegion(host, region); err != nil {
+ return nil, fmt.Errorf("relocate: set home_region: %w", err)
+ }
+ updated := *rt
+ updated.HomeRegion = region
+ fanOutMeshRoute(hc, updated)
+
+ // Step 3 — optional A-record move.
+ if opts.MoveDNS {
+ if err := moveARecord(context.Background(), mesh, host, opts); err != nil {
+ // The flip already succeeded, so relocation is correct via the proxy;
+ // surface the DNS failure but don't unwind the (correct) move.
+ return res, fmt.Errorf("relocate: home_region flipped to %s, but the optional A-record move failed: %w (relocation is still correct — the wildcard + proxy route to the new owner)", region, err)
+ }
+ res.DNSMoved = true
+ }
+ return res, nil
+}
+
+// runStoreCopy builds one S3 client and runs copyS3Prefix. Only reached on the
+// cross-bucket (prod) path — the dev shared-bucket case skips before here. Uses
+// the source region as the signing region (a same-account client copies
+// cross-region via CopyObject regardless) and, when the destination names a
+// custom endpoint (e.g. R2), addresses it path-style. Optional static
+// credentials for the destination store ride LCHAT_RELOCATE_DST_ACCESS_KEY /
+// _SECRET_KEY, mirroring s3blobstore.go's mirror-store pattern; unset = the
+// default AWS chain.
+func runStoreCopy(ctx context.Context, srcRegion string, plan storePlan) (int, error) {
+ var opts []func(*awsconfig.LoadOptions) error
+ if srcRegion != "" {
+ opts = append(opts, awsconfig.WithRegion(srcRegion))
+ }
+ if ak := strings.TrimSpace(os.Getenv("LCHAT_RELOCATE_DST_ACCESS_KEY")); ak != "" {
+ opts = append(opts, awsconfig.WithCredentialsProvider(
+ credentials.NewStaticCredentialsProvider(ak, strings.TrimSpace(os.Getenv("LCHAT_RELOCATE_DST_SECRET_KEY")), "")))
+ }
+ cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)
+ if err != nil {
+ return 0, fmt.Errorf("relocate: load aws config for copy: %w", err)
+ }
+ client := awss3.NewFromConfig(cfg, func(o *awss3.Options) {
+ if plan.Dst.Endpoint != "" {
+ o.BaseEndpoint = aws.String(plan.Dst.Endpoint)
+ o.UsePathStyle = true
+ }
+ })
+ log.Printf("relocate: %s", plan.Reason)
+ return copyS3Prefix(ctx, client, plan.Src.Bucket, plan.Src.Prefix, plan.Dst.Bucket, plan.Dst.Prefix)
+}
+
+// moveARecord repoints host's A record via whichever DNS provider is configured,
+// if it supports A upserts. Off unless the caller passed --move-dns.
+func moveARecord(ctx context.Context, mesh *meshSvc, host string, opts relocateOpts) error {
+ if strings.TrimSpace(opts.DNSTarget) == "" {
+ return fmt.Errorf("--move-dns needs --dns-target <ipv4> (the destination box's address)")
+ }
+ prov, reason := dnsProviderFromEnv()
+ if prov == nil {
+ return fmt.Errorf("no DNS provider configured: %s", reason)
+ }
+ up, ok := prov.(aRecordUpserter)
+ if !ok {
+ return fmt.Errorf("the configured DNS provider can't move A records yet (only LCHAT_DNS_PROVIDER=route53 does) — omit --move-dns: relocation is correct without it (the wildcard + proxy route to the new owner)")
+ }
+ zone := strings.TrimSpace(opts.DNSZone)
+ if zone == "" {
+ zone = zoneForHost(host, mesh.domains)
+ }
+ if zone == "" {
+ return fmt.Errorf("could not derive a hosted zone for %s from the mesh domains — pass --dns-zone", host)
+ }
+ return up.upsertA(ctx, zone, host, strings.TrimSpace(opts.DNSTarget))
+}
+
+// zoneForHost picks the hosted zone for host from the configured domains: the
+// longest domain that host is a subdomain of (so a delegated sub-zone wins over
+// its parent). "" when none matches.
+func zoneForHost(host string, domains []string) string {
+ host = strings.ToLower(strings.TrimSuffix(host, "."))
+ best := ""
+ for _, d := range domains {
+ d = strings.ToLower(strings.TrimSpace(d))
+ if d == "" {
+ continue
+ }
+ if strings.HasSuffix(host, "."+d) && len(d) > len(best) {
+ best = d
+ }
+ }
+ return best
+}
+
+// runRelocate: `lchat relocate <host> --to <region> [flags]`.
+func runRelocate(args []string) {
+ fs := flag.NewFlagSet("relocate", flag.ExitOnError)
+ registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
+ to := fs.String("to", "", "destination region (matches a live box's LCHAT_MESH_REGION) — required")
+ dstBucket := fs.String("dst-bucket", "", "destination region's object-store bucket (prod per-region stores); empty = same store as source (the dev shared-bucket no-op)")
+ dstPrefix := fs.String("dst-prefix", "", "destination per-host key prefix; empty = the same prefix the source uses")
+ dstEndpoint := fs.String("dst-endpoint", "", "destination S3 endpoint (e.g. an R2 URL); empty = AWS default")
+ moveDNS := fs.Bool("move-dns", false, "also repoint the host's A record at the destination box (OPTIONAL — off by default; relocation is correct without it, the wildcard + proxy route to the new owner)")
+ dnsTarget := fs.String("dns-target", "", "destination box IPv4 for the A record (required with --move-dns)")
+ dnsZone := fs.String("dns-zone", "", "hosted zone for the A record; empty = derived from the mesh domains")
+ _ = fs.Parse(reorderFlagsFirst(fs, args))
+
+ host := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
+ if !validHost(host) {
+ die("relocate: invalid host %q (usage: lchat relocate <host> --to <region>)", host)
+ }
+ region := strings.TrimSpace(*to)
+ if region == "" {
+ die("relocate: --to <region> is required")
+ }
+
+ hc, err := hibernateConfigFromEnv()
+ if err != nil {
+ die("relocate: %v", err)
+ }
+ mesh, err := meshFromEnv(hc)
+ if err != nil {
+ die("relocate: mesh config: %v", err)
+ }
+
+ reg, err := openRegistry(*registryPath)
+ if err != nil {
+ die("relocate: open registry: %v", err)
+ }
+ if mesh != nil {
+ mesh.reg = reg // so any region read resolves against the local registry
+ }
+
+ res, err := relocate(reg, mesh, hc, relocateOpts{
+ Host: host,
+ ToRegion: region,
+ Dst: storeLocation{
+ Bucket: strings.TrimSpace(*dstBucket),
+ Prefix: strings.TrimSpace(*dstPrefix),
+ Endpoint: strings.TrimSpace(*dstEndpoint),
+ },
+ MoveDNS: *moveDNS,
+ DNSTarget: strings.TrimSpace(*dnsTarget),
+ DNSZone: strings.TrimSpace(*dnsZone),
+ })
+ if err != nil {
+ die("relocate: %v", err)
+ }
+
+ if res.AlreadyHome {
+ fmt.Printf("%s is already homed to %s — re-fanned the route out to heal any peer that missed it (no copy, no re-flip)\n", res.Host, res.ToRegion)
+ return
+ }
+ from := res.FromRegion
+ if from == "" {
+ from = "(agnostic)"
+ }
+ fmt.Printf("relocated %s: %s → %s\n", res.Host, from, res.ToRegion)
+ if res.Plan.Skip {
+ fmt.Printf(" store: no copy — %s\n", res.Plan.Reason)
+ } else {
+ fmt.Printf(" store: copied %d object(s) — %s\n", res.Copied, res.Plan.Reason)
+ }
+ fmt.Printf(" home_region flipped + route re-fanned-out to peers — the mesh now places %s on a %s box (the single-writer lease keeps one writer through the move)\n", res.Host, res.ToRegion)
+ if res.DNSMoved {
+ fmt.Printf(" A record moved to the destination box (%s)\n", strings.TrimSpace(*dnsTarget))
+ } else {
+ fmt.Printf(" A record unchanged — the wildcard + mesh proxy route to the new owner through the DNS-TTL window (use --move-dns --dns-target to avoid the proxy hop)\n")
+ }
+}
diff --git a/relocate_test.go b/relocate_test.go
new file mode 100644
index 0000000..bd109d1
--- /dev/null
+++ b/relocate_test.go
@@ -0,0 +1,304 @@
+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 <region> 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)
+ }
+}
diff --git a/route53dns.go b/route53dns.go
index 0abf637..1799e6e 100644
--- a/route53dns.go
+++ b/route53dns.go
@@ -110,6 +110,40 @@ func (r *route53DNS) present(ctx context.Context, zone, name, value string) (fun
return cleanup, nil
}
+// upsertA satisfies aRecordUpserter (relocate.go): point host's A record at
+// ipv4. It is the OPTIONAL, off-by-default half of relocation — moving the A
+// record to the destination box avoids the mesh's proxy hop, but relocation is
+// correct without it (the *.dev wildcard + proxy-to-owner already route any
+// request to the new owner; docs/multi-edge.md). UPSERT so a re-run is
+// idempotent (overwrites in place, never a duplicate-record error), TTL 60 so
+// the propagation window the proxy covers stays short, and we wait for INSYNC
+// like present so the caller can trust the record is live before returning.
+func (r *route53DNS) upsertA(ctx context.Context, zone, host, ipv4 string) error {
+ zoneID, err := r.hostedZoneID(ctx, zone)
+ if err != nil {
+ return err
+ }
+ rrset := &r53types.ResourceRecordSet{
+ Name: aws.String(dnsFQDN(host)),
+ Type: r53types.RRTypeA,
+ TTL: aws.Int64(60),
+ ResourceRecords: []r53types.ResourceRecord{{Value: aws.String(ipv4)}},
+ }
+ out, err := r.client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{
+ HostedZoneId: aws.String(zoneID),
+ ChangeBatch: &r53types.ChangeBatch{
+ Changes: []r53types.Change{{Action: r53types.ChangeActionUpsert, ResourceRecordSet: rrset}},
+ },
+ })
+ if err != nil {
+ return fmt.Errorf("route53: upsert A %s → %s: %w", host, ipv4, err)
+ }
+ if out.ChangeInfo != nil {
+ r.waitInSync(ctx, aws.ToString(out.ChangeInfo.Id))
+ }
+ return nil
+}
+
// hostedZoneID resolves a zone to its Route53 hosted-zone id, caching the
// lookup. It expects the wildcard domain to have its OWN hosted zone (which is
// how the box's IAM role is scoped — one zone, e.g. dev.11ch.at), so it takes
diff --git a/route53dns_test.go b/route53dns_test.go
index 606f590..2bc1ddc 100644
--- a/route53dns_test.go
+++ b/route53dns_test.go
@@ -90,6 +90,42 @@ func TestRoute53Present(t *testing.T) {
}
}
+// upsertA (the optional relocation A-record move) UPSERTs an A record at the
+// host's FQDN with a short TTL and waits for INSYNC.
+func TestRoute53UpsertA(t *testing.T) {
+ fake := &fakeRoute53{hostedZones: []r53types.HostedZone{
+ {Name: aws.String("dev.11ch.at."), Id: aws.String("/hostedzone/DEV")},
+ }}
+ r := &route53DNS{client: fake, zoneIDs: map[string]string{}}
+
+ if err := r.upsertA(context.Background(), "dev.11ch.at", "8oe.dev.11ch.at", "157.180.123.183"); err != nil {
+ t.Fatal(err)
+ }
+ if len(fake.changes) != 1 {
+ t.Fatalf("want 1 change, got %d", len(fake.changes))
+ }
+ ch := fake.changes[0].ChangeBatch.Changes[0]
+ if ch.Action != r53types.ChangeActionUpsert {
+ t.Fatalf("action = %v, want UPSERT (idempotent re-run)", ch.Action)
+ }
+ rr := ch.ResourceRecordSet
+ if got := aws.ToString(rr.Name); got != "8oe.dev.11ch.at." {
+ t.Fatalf("record name = %q, want the host FQDN with trailing dot", got)
+ }
+ if rr.Type != r53types.RRTypeA {
+ t.Fatalf("record type = %v, want A", rr.Type)
+ }
+ if got := aws.ToInt64(rr.TTL); got != 60 {
+ t.Fatalf("ttl = %d, want 60 (short — the proxy covers the window)", got)
+ }
+ if got := aws.ToString(rr.ResourceRecords[0].Value); got != "157.180.123.183" {
+ t.Fatalf("record value = %q, want the target IP (unquoted — A, not TXT)", got)
+ }
+ if fake.getChangeN == 0 {
+ t.Fatal("upsertA did not wait for INSYNC")
+ }
+}
+
func TestRoute53HostedZoneMatch(t *testing.T) {
// Both the parent and the delegated sub-zone exist: the record for the
// sub-domain must resolve to the most specific (longest) match.