ReviewOS

also looking at this

stacks/ts-cloud

feat(fleet): move a deployed site to another server (#167 operation 2)

#176
Merged glennmichael123 wants to merge feat/site-move into main
4 files +951 -0

Closes #167.

This is the last of the three consolidation operations. For the record on where the other two stand: operation 1 (attach an app as a site) already worked — your own comment on the issue found it, and #174 added its two guardrails; operation 3 (server:rename) shipped in #175 along with the plan/apply scaffolding this builds on. This adds operation 2, moving a deployed app between servers.

cloud site:move bughq --to statushq-box            # plan only
cloud site:move bughq --to statushq-box --apply    # perform it

What it moves

The site's on-box footprint wholesale — the whole /var/www/<slug>-<site> tree (every release, shared/, the current symlink) plus the systemd units that run it.

The units are moved, not regenerated. Rebuilding from the repo on the target would not be a move: it would be a fresh deploy that happens to be preceded by a data copy, picking up whatever the repo says today rather than what is actually running. tar keeps symlinks for the same reason — dereferencing would flatten current into a second copy of the live release and turn every shared path back into a per-release file, which is exactly the failure sharedPaths exists to prevent (and exactly what #172 was about).

The ordering is the design

Every prefix of the plan is a working system, on the old box or the new one:

StepWhy it is there
Stop background work on the sourceA queue worker committing mid-tar produces a torn snapshot. The web service keeps serving — the source is still live, and stopping it here would be an outage taken long before the target can replace it.
Archive → carry → unpack → startThe target comes up, but nothing routes to it yet.
Health gate on the target's loopbackThe public name still points at the source, so asking it would cheerfully report the old box as healthy and wave a broken target through.
Route the site on the targetUses buildRpxFragmentRefreshScript — the same builder the deploy uses, so a moved site is routed byte-identically to a deployed one rather than by a second mechanism that drifts.
Cut DNS overOnly after the target has proved itself. A provider warning stops the run here, rather than continuing to drain the source on the strength of a cutover that may have edited nothing.
Drain the sourceUnits stopped and disabled, gateway fragment removed. Files left in place.

Reversibility

Nothing in the operation deletes anything. A bad cutover is undone by starting the source's units again and pointing DNS back, with the data still sitting on the source — which is the reversibility the issue asks for, lasting until the source server is destroyed. That stays a separate, deliberately separate, destructive command, so site:move declares no destructive step and needs no typed confirmation.

Background units are enabled but not started on the target until the source is drained, so the two boxes can never both run a scheduler against one dataset.

Resume

Re-run the identical command and finished steps skip themselves. Two deliberately never skip:

  • the snapshot — an archive from an earlier attempt predates whatever the source has served since, and silently shipping stale data is worse than copying twice;
  • the health gate — a gate that remembers a previous pass is not a gate.

The satisfied() checks are strictly read-only (there are separate state-reporting scripts from the acting ones), so a plan run changes nothing on either box.

Transfer path

The archive travels through the machine running the command rather than directly between the two boxes. A direct hop would need the target to hold a credential for the source — the same credential-radius problem consolidation already has (#169). Both boxes must be enrolled with pinned host keys; the transport refuses anything else, which is the right bar for copying an application's entire dataset between machines.

Against the issue's checklist

  • An app can be attached to a server that already hosts another (#174 guardrails on the existing path)
  • An app can be moved from server A to server B, including data and scheduled work, without a manual SSH step
  • DNS cutover is part of the operation and reversible until the source server is deleted
  • A server can be renamed without being recreated (#175)
  • The full sequence runs unattended from CI and is safe to re-run after a failure — plan is data, authorization is a flag, nothing reads stdin, --json for both plan and outcome
  • --dry-run shows the complete plan — it is the default; --apply is what performs it

Verification

bun test — 4125 pass, 0 fail. Typecheck and lint clean.

20 new cases in site-move.test.ts: step ordering, the portless-site case, end-to-end execution, the source left serving when the target never goes healthy, the drain refused after a DNS warning, resuming a failed cutover without re-transferring, the health gate re-running every attempt, a plan changing nothing, and the remote scripts (symlinks preserved, units carried, background units not started, loopback-only gate, read-only checks, no rm -rf anywhere near /var/www).

4 changed files on the files tab, with 0 review threads.