xmass — whole-server move
xmass is the whole-server tool: it moves a complete BOA host — with every account,
database and Solr index — to a freshly installed target in a single operation, keeping
the databases current with live replication so the actual cutover window stays short.
This page is the full runbook. For where xmass sits against the per-account
xoct/xcopy tools, and for the machinery all three share — the storage-aware
DRY/--live gate, the http-off write freeze, and the migration proxy the source
becomes — see the Cross-host migration overview.
Running the migration
xmass moves an entire BOA host — every Octopus account, every Solr index, every
config — from source to target. The DB transport is xtrabackup snapshot +
MySQL GTID replication, not per-account mydumper cycles, so it scales to fleets of
large databases where per-account dump/restore would be impractical.
A common reason to reach for xmass is an OS upgrade by fresh install rather than
in place: instead of walking an ageing host through the in-place OS upgrade
paths — a multi-reboot codename chain whose only rollback is a
VM-snapshot restore — install BOA fresh at the same release on a new host running the
newer OS, xmass the whole estate across, and cut over, leaving the old box as a
migration proxy until DNS is repointed.
The working steps (init, sync, status, cutover) all run on the source and
take the target IP as their argument; replication and rsync flow source → target.
post-mig is run on the target after DNS is updated, and the initial pre-mig is run on
both source and target — pre-mig parks the BOA background runners (runner.sh, owl.sh,
usage.sh, graceful.sh, manage_ltd_users.sh) so the task queue is frozen during the
move, and exchanges root SSH keys between the hosts (the source also learns the target's
SSH host key automatically). On both hosts pre-mig first forces the migration tool set
current — the per-tool control markers are dropped, the housekeeping fetcher runs
synchronously, and each tool's version is logged — so a migration is never run on stale
tooling; the tool executing the command refreshes on its next verb, not mid-run. It then
checks every Ægir root's control panel coherence on the box it runs on, fail-closed:
the hostmaster alias must name a platform that exists with index.php and a site dir
with settings.php, the panel database must resolve a hostmaster site context, and
that site's platform row must name the alias's path — a panel the tools cannot resolve
stops pre-mig here, where it is cheap to repair, instead of failing the rename on the
target. A legacy <panel-fqdn>.alias.drushrc.php symlink to the hostmaster alias is
reported; the ltd-users pass purges it on every
box, and an account that still depended on it is repaired before it migrates. Run in
target mode it additionally opens the firewall for the source (which the source can never
arrange for itself) and removes this box's OWN published root public key from the nginx
web root, not just the fetched copy — a box that was ever a migration source otherwise
serves a root key at the undefined-host URL indefinitely, from which a later migration
could fetch the wrong box's key.
# Phase 0 — pre-mig, run on BOTH source then target
xmass pre-mig <source-fqdn>
# On SOURCE — prepare the target: CSF both ways, the Solr set gated
# against the source's measured use, PHP coverage, one installed+seeded
# Octopus account per eligible source account; a site-less account the
# target carries but the source does not (a clone's own satellite) is
# purged, one that carries sites is refused
xmass prep-target <target-ip> [--fix-php] [--fix-solr]
# On SOURCE — install xtrabackup, enable GTID, snapshot,
# restore on target, start the replica; declares the box-default
# proxy mode and prints each account's resolved mode
xmass init <target-ip> [--proxy-mode=temporary|permanent|ha-switch] [--proxy-deadline=YYYY-MM-DD|+Nd]
# On SOURCE — rsync platforms/files/configs/Solr (repeat freely);
# DRY plan by default, re-run with --live to transfer
xmass sync <target-ip> # DRY plan
xmass sync <target-ip> --live
# On SOURCE — standing-mirror file cadence: arm AFTER the first
# 'sync --live'; minute.sh then repeats the live sync every N minutes
# (default 15, floor 5) until cutover or --off
xmass autosync <target-ip> [--every=N]
xmass autosync --status
xmass autosync --off
# On SOURCE — replication lag + last-sync timestamp (an unreadable
# replica is named, never printed as an empty lag)
xmass status <target-ip>
# On SOURCE — final cutover (block web, drain lag, promote target,
# renameaegirhost on target, convert source to proxy, every panel
# probed by its new name); the DRY run plans storage for all accounts
# + shared and stops before any destructive step
xmass cutover <target-ip> [--proxy-mode=...] [--proxy-deadline=...] # DRY plan
xmass cutover <target-ip> [--proxy-mode=...] [--proxy-deadline=...] --live
# On SOURCE — read-only post-cutover sweep: sites, symlinks, the
# certificate mirror, every control panel by its new name
xmass verify [target-ip]
# On TARGET — finalise after DNS update
xmass post-mig
# On SOURCE — re-enable Solr on a rolled-back source after an
# aborted or reversed cutover (see below)
xmass restore-solr
Dispatch verbs: pre-mig, prep-target, init, sync, autosync, status,
cutover, verify, post-mig, restore-solr. Every state-mutating verb takes a
box-wide owner-PID lock: a concurrent run is refused non-zero naming the owning
process, while status and verify stay unlocked so a migration can always be
inspected mid-run (an automated autosync pass takes the same lock in defer mode —
it quietly waits for the next cadence tick instead of erroring). The
guard checks liveness, not the file, so a killed run leaves nothing to clean up. The
sync and cutover verbs are gated by the DRY/--live model (see
Storage-aware transfers and the DRY/--live gate
on the overview); one DRY plans all accounts plus shared.
Pre-sync is incremental — once init runs, repeat sync over days or weeks until
ready; the GTID replica keeps the target's databases current and sync carries the
file-system deltas. For a standing mirror (a migration deliberately parked at
syncing as a hot standby), xmass autosync <target-ip> automates exactly that:
after your first manual DRY + --live cycle it repeats the live sync legs every N
minutes (default 15) from the active side, so the mirror's files stay minutes — not
days — behind its continuously replicating database. Every automated pass
re-verifies the target still holds its standby marker (at pass start and at every
leg boundary) and stands down loudly, syncing nothing, the moment it is gone — a
promoted mirror is never written to, and unlike a manual live sync an automated
pass never re-creates the marker. Passes defer quietly behind any other xmass verb,
a running barracuda/octopus upgrade, and the cutover window; failures and every
stand-down that needs a human are mailed through the standard incident throttle,
and a daily alarm fires if no pass has completed for six cadences. Cutover
completion disarms the cadence by itself; xmass autosync --off disarms it
manually. Source-side deletions propagate on the data trees during any sync
(manual or automated) — platform codebases, site files, the dump archives,
the Solr data — because a mirror that only ever accretes grows nightly and
can never reclaim: its own nightly cleanup is parked while it holds the
standby marker. Control, credential and target-role legs stay additive, and
so do the cutover legs, since that is the one window where a wrong deletion
is unrecoverable. Four guards make the pruning safe: nothing is removed
before a successful transfer, an oversized deletion is refused rather
than executed (rsync stops at _XMASS_MAX_DELETE, default 5000, and the
pass fails loudly for a human to read), the delete flags are never paired
with the error-tolerance flag whose meaning is "delete through read errors",
and an empty source tree never licenses deletion — an unmounted volume reads
exactly like one. Expect the guard to trip on the first pruning pass against
a mirror that has been accreting for months; that is it working. A fresh target's own
install residue — the pre-pass backup sets under each account's backups/system/ — is
dropped by init before the first sync, so it never trips the guard on a first live
pass.
The cadence you arm is a ceiling on frequency, not a promise: a pass walks a
lot of metadata, so the driver also enforces a duty cycle — the box rests at
least three times the last measured pass before the next one, and
xmass autosync --status prints the effective interval beside the armed one.
See the failover scenarios page for the standing-mirror runbook this belongs to.
Each sync run carries a fixed set of filesystem data to the target: shared BOA data
(/data/all, /data/disk/all, /data/disk/arch, /data/disk/legacy), the static web
root (/var/www/static), DNS zones (/etc/bind), usage logs (/var/log/boa/usage), the
Solr indices, and per-account platforms, source trees (src/), site files, drush
site-aliases, nginx vhosts,
SSL/LE material, the sub-account registry (clients/), backups/ and undo/, the FTP
account's SSH keys (/home/oN.ftp/.ssh) and the client's ruby-gem/npm trees under
/opt/user/. MySQL data is the one thing not rsynced — GTID replication keeps it
current continuously. Optional per-account config directories (pre.d, post.d,
subdir.d, platform.d, the ssl.d trees, tools/le) are skipped with a logged
"nothing to send" when absent rather than failing the run; a genuine transfer failure on
a directory that IS present still aborts before anything destructive.
Each account's identity travels alongside those files: the portable values of
/root/.<oN>.octopus.cnf merged into the target's own copy, the PHP pin files
(static/control/{fpm,cli,multi-fpm}.info and the matching log/*.txt stamps),
the client's shell credentials — the <oN>.ftp password hash paired with
log/pass.txt, the sub-account password store and each sub-user's hash and keys —
and the billing suspension flag, which lives outside the account tree
(/data/conf/suspended/<oN>.pid) precisely so the account owner cannot delete it.
The pin files are force-copied without rsync's skip-newer flag, because the target's
fresh install wrote its own defaults minutes earlier and would otherwise win. That
direction matters: BOA derives the account cnf from fpm.info/cli.info, never the
reverse, so those files are what actually make a pin stick — and an account left on the
target's install-time PHP is a site silently served by the wrong interpreter.
Three files are deliberately excluded from the account log/ sync: proxied.pid and
migproxy.cnf are target-role state that must never be overwritten from the source, and
pass.txt rides the credential carry instead so it can never advertise a password the
target's /etc/shadow does not hold.
Whole-server cutover downtime is typically 1–3 hours.
Matching Octopus accounts must exist on the target before init. Replication brings
the databases, but the files rsync into /data/disk/<oN>/, the cutover renames each Ægir
root and rewires each panel — all of which need a real install (system user, FPM pool,
vhost include), not a directory rsync creates. xmass prep-target installs and seeds
them; init, sync and cutover each re-verify and refuse without them.
The target's Solr set mirrors the source's measured use — gated, not assumed.
A hosted-named box's first full barracuda pass installs all three Solr versions,
but a migration target gets only what its source actually uses. prep-target
classifies each version on the source by index-write recency — the one-year bar:
queries leave no filesystem trace, so the newest data/index write across a
version's real cores (dotted names only) is the measurable use signal, and a
populated estate whose newest write is older than a year is a stale leftover, not a
used version. Two carve-outs: live boa_site_control.ini bindings (or a running
service with its SR* opt-in token) count as used regardless of write age — a bound
service whose sites have not indexed yet — and registered-but-empty cores with zero
bindings are reported for an operator ruling rather than classified either way. Each
version's newest-write date is printed so every verdict is auditable. The gate then
reads the target's install and deny state, reports both sets and refuses on any
mismatch. --fix-solr reconciles: per-version denies
(_DENY_JETTY9/_DENY_SOLR7/_DENY_SOLR9 plus the /etc/boa/.deny.*.cnf markers)
are written for unused versions before any pass runs on the target, the blanket
java pair is written only when the source uses no Solr at all, a denied or parked
used version is un-parked by driving the target's own restore-solr, and a missing
used version installs on one preflighted barracuda system pass — shared with
--fix-php's pass when both are needed. The ordering exists because the timing is
unforgiving both ways: the per-version denies only park a service after it installs
(the 5-minutely autoupboa), and the file sync lands every existing source Solr tree
on the target, where a landed data tree makes BOA's installer treat that version as
already installed forever — data with no service, discovered as broken search only
after promotion.
init, sync and cutover re-gate on the same comparison, refusing while a used
version is not serviceable on the target (missing install, denied, parked, or an
init script left non-executable by a proxy finalize). The classification measured
while the source was healthy is recorded in /data/conf/xmass_solr_used.cnf, and
the re-gates prefer that record once a cutover has denied Solr on the source — a
post-cutover source reads as using nothing and would otherwise misclassify every
version. A version that looks unused but runs (registered cores, all empty, zero
site bindings) is reported for a per-box operator ruling and treated as used — it
is never dropped automatically. For taking unused Solr versions off a box outside
a migration, see Removing unused Solr versions.
xmass init auto-installs the matching percona-xtrabackup-* package and auto-enables
GTID on both servers if not already active.
init also chooses its xtrabackup transfer method automatically from free space on the
source /. With more than 1.5× the data-directory size free it stages — the
backup is written to /var/backups/xmass_stage, prepared, then rsynced to the target.
With less it streams — piped via xbstream over SSH straight to
/var/backups/xmass_restore on the target and prepared there, needing no staging disk on
the source. Either way the snapshot bounds its backup-lock wait to 15 minutes and kills a
query that blocks it after 60 s, so an init cannot sit forever behind one long report.
Both init and prep-target first read the target's /root/.standby.cnf
back over ssh and hard-refuse a role clash: a target already serving as
a DIFFERENT source's standby is never overlaid (the marker records its
source IP; a same-source marker is the normal re-init/repair path and
passes), prep-target refuses ANY standby target (it builds fresh targets —
its account installs would be refused by the standby's own read-only DB),
and a box that itself carries the marker refuses to SOURCE a migration at
all. The probe fails CLOSED — an unreachable target is refused with the ssh
rc rather than assumed clean.
Three more things init does deserve to be known before they surprise you:
- It leaves the target's cron ARMED — a standby is a working BOA box (IDS
and the watchdogs all live), and its passivity comes from per-job gates on
/root/.standby.cnfin every local writer: the task runner and the Ægir dispatch it parks, the nightly and hourly housekeeping, the Solr maintenance, the whole duplicity backup chain and the rest stand down while the marker exists. On top of the gates,initlocks the replica's DB outright: it writes thexmass-standby-holdblock (read_only+super_read_only) into the target'sxmass_gtid.cnf— so the lock survives every restart — and sets it live once the replica threads verify. The appliers are exempt; everything else, root included, is refused at the server. The web tier is held DOWN the whole window (init-script gate, per-minute enforcer, and theBOA_STANDBY_WEBfirewall chain), FTPS is kept dead, and tenant logins flip tonologin— the full roster and the.standby.serve.cnfpreview escape hatch are on Failover and recovery scenarios. The gates exist for the reason cron used to be stopped outright: a writable replica running its own hourly housekeeping would TRUNCATE cache tables locally, which under ROW binlog stops the SQL thread or writes errant transactions into what becomes production. So a target whose cron is running during the sync window is healthy, not broken — do not stop it. (A target quiesced by pre-gate BOA bytes still had its cron stopped; the cutover restarts it regardless, as harmless vintage tolerance, andpost-migis the confirmation point.) - It purges unfinished
deletetasks from every eligible account's panel queue before the databases travel: the whole hostmaster database replicates, the cutover runs the task queue with force on the target, and a stranded delete (xmass's ownpre-migkills the dispatcher, which is exactly how one strands) would execute against brand-new production — andinitnow refuses outright when a pending or interrupted delete cannot be cleared on any panel, naming the accounts, rather than carrying it across. Other task types are only reported — at cutover, per account, by type — so anything you do not want carried across can be cancelled first. - It holds Solr down on the target for the entire replication window — as a
durable disarm, not a stop: each Solr/Jetty service is stopped, then its
init-script exec bit and its rc links are dropped, and the set actually taken
down is recorded in
/var/log/boa/.xmass_solr_disarmed.list. The hold marker is/var/log/boa/.xmass_solr_hold.pid, and the per-minutejava.shwatchdog re-asserts the disarm — and reaps any stray JVM — for as long as that marker exists, so the hold survives a reboot and abarracudapass, and a Solr started by hand is taken back down within a minute. Cutover step 0 re-holds it as well, cutover step 14 andpost-migre-arm (exec bits and rc links restored, then the services started), and the first target-side Solr start happens only after the cores'tlog/transaction logs are cleared, so writes buffered when the source Solr stopped are not double-indexed. Do not "fix" a target whose Solr looks down mid-sync — a non-executable/etc/init.d/solr9there is the hold working, not damage.
xmass cutover invokes xoct proxy per account during the cutover phase to convert the
source vhosts to proxies, plus renameaegirhost on the target for the master and each
Octopus account. Before the rename, the cutover clears any demotion artefacts the
target may still carry from having been demoted itself earlier (a failback: the pair
switched once, now switches back): its own outbound proxy policy record, the proxied
marker, its parked account dispatchers, the source latches the earlier demotion stamped,
and — for every site whose name the rename does not touch — the proxy vhosts, restored
from the dot-prefixed pre-proxy backups the conversion keeps. Without this a promoted box
keeps relaying to its demoted sibling, which relays straight back, and the loop surfaces
as 400 Request Header Or Cookie Too Large on every hop. On a first-time target none of
these exist and the step does nothing.
Failing back — running the pair in reverse
The ha-switch pair is symmetric and the whole reverse pass uses the same verbs: on the
promoted box run prep-target toward the old active, then init, then cutover, and
finally retarget any long-lived migration proxy back with
xoct proxy <oN> <ip> --repair --retarget. Two things are normal and expected on the
reverse pass:
initrefuses once on the box's own state file from its earlier source role, and the refusal names its remedy: remove the state file (back it up first) or usexmass reset-phase. Every failback hits this; it is the tool asking you to confirm you really are starting a new migration from this box.- The GTID history grows by one server identity per promotion, because the restore
gives the promoted box a fresh
server_uuid. The replication machinery carries the whole multi-identity set — do not be surprised by three, four or more UUIDs ingtid_executedafter a few switches, and never health-check by expecting an exact count: check that the replica's executed set contains every identity the master's backup carried.
init also re-proves the source is master-ready — GTID and the binary log — and
restarts MySQL itself if either is missing, so a box that spent time as a replica comes
back as a valid master without manual intervention.
Per-account proxy modes
How long the source keeps forwarding, and what each customer is told about it, is
decided per Octopus account, not per box. Each account carries a root-owned policy
record (/data/disk/oN/log/migproxy.cnf, parsed never sourced) with one of four modes.
Only a source-role record participates in resolution: a record whose role is
target — the inbound half of the peer pair, stamped when this box was itself brought
in by a migration — is never resolved as this box's own proxy mode, so on a chained move
(a box migrated in yesterday, migrating out today) a travelled inbound record can never
outrank the operator's explicit --proxy-mode. The modes:
temporary— forwarding is withdrawn once the migration settles; with a deadline the client mail names the exact date, without one it promises notice before withdrawal.permanent— the old address stays in service; the client is told to move DNS at a normal pace, with no countdown.ha-switch— the old address is presented as the switch point of a live main/mirror pair. For trust and teardown it behaves exactly aspermanent; only the client notification differs.retired— set byxoct proxy-retire; forwarding is (being) withdrawn and the target drops the trust for this account at the next reconcile.
--proxy-mode on init/cutover writes only the box default
(/data/conf/migproxy_mode.txt); an account pinned with xoct proxy-mode oN <mode>
always wins over it, and a box sweep never overwrites a pin. --proxy-deadline sets the
box-default deadline quoted in the client mail (absolute date or +Nd; past dates are
refused, and a stored deadline that has expired by send time is dropped with an alert
rather than mailed). --permanent-proxy remains a deprecated alias for
--proxy-mode=permanent. Flags on cutover override whatever was set at init.
The resolved mode drives three things per account:
- the migration-complete notification
xoct proxysends — each mode has its own honestly-worded arrangement block, plus a shared block stating that only the sites which existed at migration time are forwarded (anything created later needs DNS pointed straight at the new address); - the migration-proxy realip/CSF trust on the target: any account resolving
permanentorha-switchkeeps it pastpost-mig, and the target'smigration_proxy_trust.sh reconcilerecomputes the kept peer set from its per-account records whenever policy changes; xmass cutover --live, which refuses to run while any account would fall back to the built-in default —initand the DRY cutover print every account's resolved mode precisely so this is discovered while it is still free to fix.
A per-account conversion failure at cutover keeps that account's 503 gate up and does
not stamp it proxied.pid (its vhosts would otherwise serve the old local copy against
a database that now lives on the target); the completion banner names the accounts to
fix and re-run.
Inspect or change policy at any time on the source:
xoct proxy-mode --all # the table: mode, deadline, scope, peer, last told
xoct proxy-mode o3 temporary --deadline=+30d # pin one account
xoct proxy-retire o3 # retire + send the withdrawal notice
Every policy change is pushed to the target's record and re-reconciled there, and the
client is mailed a short follow-up whenever the promised arrangement changes
(--no-notify suppresses and logs, --renotify forces).
Requirements
- Identical Percona versions on source and target — series and patch level
(for example both 8.4.13, never 8.4.10 against 8.4.13). GTID replication plus an
xtrabackup snapshot of the live datadir is version-strict, which is the key
difference from
xoct/xcopy, cross-version safe viamydumper. Both halves are enforced gates atinit, not advice. The series gate compares whatmysql -Vreports on each box — the client binary — and refuses a mismatch outright, pointing you atxoctinstead. The patch gate then asks each server forSELECT VERSION(); an unreadable answer on either side is fatal, because a version nobody read cannot be vouched for. A deliberate forward skew — a target newer than the source — is accepted with_XMASS_ALLOW_PATCH_SKEW=YES. A target older than the source is refused and no flag overrides it: the restore puts the source's datadir on the target, and a newer datadir on an older server is an unsupported downgrade. Align both ends from the same repo snapshot instead. - BOA installed on the target at the same release as the source. This is an
enforced gate, not advice:
prep-targetreads the release stamp fromboa infoon both ends at first target contact and refuses with no override when they differ — a target missing a central-map nginx variable a newer release introduced fails the box-wide config test and takes down every migrated site, not only the one that needed it. The tree may legitimately differ (lts vs pro); an unreadable stamp on either side is fatal too. The fix is a full run — barracuda AND octopus — on the older box. - Root SSH key access source → target, set up by
xmass pre-mig. - An installed Octopus account on the target per eligible source account, with every
PHP version those accounts pin — both established by
xmass prep-target. Each account is installed byxoct create, which waits for a quiet target (no barracuda or octopus pass, no install lock, no armedrun-upgrade.pid, two consecutive polls) before it starts, proves the install afterwards, and arms the enforced post-install upgrade only when the seeded control files actually changed — a re-enteredprep-targetnever triggers a second upgrade for nothing.prep-targetitself runs the full target-silence gate (below) before every account create, because each create leaves the target mid-motion and the next launcher refuses on any lock; an account whose target never settles is reported failed, never launched into a refusal. - No account on the target that the source does not have. A golden-master clone
carries its own satellite, which the
initdatadir swap would leave without a panel database — a broken leftover.prep-targetpurges a site-less one itself with BOA's ownboa cleanup(where astatic/tree exists the account'sbackups/,distro/,src/,static/andundo/trees are removed outright, whatever else the home holds is parked under/var/backups/zombie/purged/<oN>/, nginx configtest proven) and refuses one that carries sites;_XMASS_ALLOW_TARGET_ONLY=YESkeeps it knowingly, broken afterinituntil purged. "Site-less" is read from three independent sources on the target, all of which must be empty: the account's registered site aliases (its own control panel set aside), thesites/directory of every registered platform (which also reaches a platform behind a symlink), and a sweep for site directories underdistro/,static/,platforms/andaegir/to the depth BOA itself builds, with or without aweb/ordocroot/level; an inventory that cannot be read is never treated as empty. - CSF allowing the source on the target and the target on the source: the target
dials back to source:3306 for replication, and getting it wrong fails
initafter the target's datadir has already been replaced.prep-targetappends each peer to bothcsf.allowandcsf.ignore(an allow alone still leaves the peer exposed to a guard temp-deny mid-migration), reloads CSF, and proves the reverse path before anything depends on it. The reverse-path proof is fatal by design; if your pair is deliberately firewalled differently, the documented escape hatch is_XMASS_SKIP_REVERSE_CHECK=YES.
init re-checks all of the above, and adds two of its own: the replication server_ids
derived on each side must differ (they come from the last two IP octets only, so two
boxes in one /16 collide and replication simply refuses to start), and the target must
have room for the datadir restore (_XMASS_SKIP_DISK_GATE=YES overrides the
disk-room gate when you know better — e.g. a mount init cannot see).
One more pre-flight worth doing on the source before the seed: this path
replicates every schema on the box, orphans included. A source-side
sqlclean pass first both
shrinks the transfer and removes stale panel schemas the tooling would
otherwise trip over (it refuses to guess between multiple hostmaster
candidates).
The cutover sequence
Before any destructive step, cutover runs an automatic pre-flight: it confirms the
phase is syncing, refuses to run outside screen/tmux (a dropped session mid-cutover
strands the source on 503 — _XMASS_NO_SCREEN=YES overrides the refusal if your session
management genuinely makes it safe), and re-verifies the target's accounts and PHP
coverage. Only
once every refusal is past does it stop cron and park the BOA background runners itself —
the box's own cron restores a park done back at pre-mig time within minutes, so the
park has to happen here, immediately before the destructive sequence.
Three verbs also wait for the target to be silent before they touch it, and the
wait lives in the tool, not in an instruction: prep-target (before every account
create and again before it completes) and init require no BOA pass, no install lock,
no armed run-upgrade.pid and no queued or running panel task on any Ægir root, while
cutover --live requires no pass and no lock only — a standby's runner never consumes
an armed task and its panel rows are the source's, so queued rows are no signal there.
Silence is two consecutive clean probes; a root whose queue cannot be read fails the
gate rather than passing it. The ceiling is _XMASS_TARGET_SILENT_MAX_WAIT seconds
(default 2400), and _XMASS_SKIP_TARGET_SILENCE=YES bypasses the wait knowingly.
Before the freeze, cutover --live also names the panel tasks still queued or running on
each source account — current task revisions only, the dispatcher's own view (every
finished task leaves its creation revision frozen at "queued" in hosting_task, so a
naive count reports years of history as travelling work). Whatever it names will run on
the target once its queue starts; cancel what you do not want. Interrupted delete tasks
were already purged at init.
The cutover itself has built-in safety timings. It drains replica lag to zero, polling
every 15 s up to a ceiling of _XMASS_SYNC_MAX_WAIT seconds (default 7200); on
timeout it reports the first, previous and last lag samples and whether the lag is closing
or growing in the most recent interval, because a growing lag will not be fixed by waiting.
It then takes a final static/files-only rsync to catch last-second uploads. That pass runs
before the write freeze, not under it: the web freeze applied at the
start of the cutover is what stops file writes, and a database lock never gated them,
so holding one across a walk of every store gained nothing.
Only then does it stop writes — durably, not with a database read lock. A read-only
flag is appended to /data/conf/global/global-extra.inc (the previous file kept beside
it as .bak), and the cutover refuses to proceed if the freeze does not take: a
session-scoped FLUSH TABLES WITH READ LOCK cannot hold anything once its client
disconnects, which is why the old procedure's lock is gone — the surviving
FLUSH TABLES only pushes buffers before the replication position is read. With the
freeze in place it triple-checks lag = 0 at 10 s intervals; a failed check aborts
before the promotion holds, so the source is still the only production box: the
abort unlocks source MySQL and thaws the write freeze itself, handing the source
back writable. A failed promotion command is handled by evidence, not by its exit
code (transport can fail after mysql ran): the tool reads the target's replica
state back — a target provably still a replica gets the same unlock-and-thaw
abort; a promotion that actually committed parks resumably; an unreadable target
keeps the source frozen (a thaw could silently lose writes) and the message spells
out how to determine the state and which recovery to run. After a successful
promotion the freeze flag is removed on the target (the new production box must
never come up read-only); it deliberately stays on the source, which serves through
the proxy from here.
Transfers are gated as well. A store that cannot be placed on the target, or an rsync that
fails for any reason other than source files vanishing mid-copy, aborts the cutover before
anything destructive happens. Every abort after the web freeze prints the exact commands to
restore service on the source; when the write freeze is still in place as the recipe
prints (a post-promotion park), the recipe includes the thaw line — restore
/data/conf/global/global-extra.inc from the .bak kept beside it (or delete the
# xmass migration freeze block) — and says when it is safe to use it: thaw only to
abandon the cutover and keep the source as production, because after the promotion,
writes accepted on the source can never reach the target. Clearing the 503 alone leaves
every source site serving read-only.
Before converting the source into a proxy, the cutover proves the target's web
layer: it runs nginx -t on the target and refuses the conversion if the config is
invalid (printing the tail of the test output), then requires a real HTTP answer on the
target's port 80. The proof sits at the head of the cutover tail, so every entry
re-runs it — the first pass and each resume of a parked cutover; nothing later in the
tail gates on the web layer (the rename pass's serve-wait measures and reports, and
nothing else can start a stopped nginx). Either refusal parks resumably at
phase=rename-failed and prints the source-restore recipe: the target stays
promoted, the source stays 503-gated and frozen, and the SQL watchdogs stay paused.
Fix nginx on the target, then re-run xmass cutover <target-ip> --live — the resume
re-runs the proof and starts nginx itself.
Once replication is decoupled and nginx is serving proxied traffic on the target, cutover
rewires panel database access for every Ægir root before the renames run. The init-time
datadir swap replaced the target's MySQL wholesale, so the fresh-install panel databases the
new host's control-panel dirs pointed at no longer exist; for each root xmass rediscovers
the live (replicated) hostmaster database, resets that database user's password across its
host variants, and rewrites the panel dir's stored credentials to match. The same step also
reconciles the panel's platform: an aged source's control panel sits on a higher
platform number than a fresh target's (the panel platform is rebuilt over the years as
PHP pins change), and the replicated database keeps naming the source's path. The rewire
always compares the platform the database names for the panel site with the platform the
target's own hostmaster alias serves from, repoints the platform row at the on-disk one,
and renames its platform_NNN context to the on-disk number when that name is free or
held only by a deleted platform's leftover row, so alias file names keep matching paths.
Left alone, the rename queue verifies a platform that does not exist on the target,
hosting tries to rebuild it, and the panel vhost is rendered with that root — a 404 by
the new name, while a master whose verify never got that far keeps a dead db_name.
This must succeed before renameaegirhost can bootstrap, and it is idempotent — a root
whose panel already names a live database and the on-disk platform is skipped.
The rename pass itself carries more than aliases and vhosts. A tenant site whose URI
embeds the box hostname follows the box: its site directory is moved onto the new
hostname together with every URI-keyed surface — the per-site Drush alias file, the
static files store and the site's files/private symlinks into it, the client
symlink, the URI values inside settings.php (including the backslash-escaped
trusted_host_patterns spelling that produces an HTTP 400 when left stale), and the
site's per-site PHP pin row. A fail-closed gate then refuses to run the Ægir task
queue while any site directory still carries the old hostname — the queue would import
those as brand-new sites with duplicate panel nodes; inside a cutover that refusal parks
at phase=rename-failed. After the renames the tool waits for each renamed site to
actually serve (up to 180 s per site, _RENAME_SERVE_WAIT; the box's catch-all page
is discriminated so an unknown-host 200 never passes) — minutes spent here are the wait,
not a hang, and a site reported NOT SERVING with a 400 usually means its trusted-host
settings. The wait exists because settings.php is rewritten through its existing
inode and PHP resolves it through a per-worker realpath cache, so a single early check
proves nothing either way. The migrated-in http-off gate is cleared on the target
before the renames and again after them, so the serve-wait measures the sites and
never the gate. The rename also parks the old-name panel SSL-proxy include from the
master root's pre.d (the account pass regenerates it for the new name once that
name's certificate exists): left behind, it keeps naming a certificate under the
old-name Let's Encrypt directory and fails nginx's configtest box-wide the day that tree
goes.
Entering the cutover tail, and again on EVERY resume from a parked
rename-failed, step 11.5 unlocks the promoted DB: SET GLOBAL super_read_only=OFF plus read_only=OFF, with the runtime readback
verified (both variables) BEFORE the xmass-standby-hold block is stripped
from xmass_gtid.cnf — a failed unlock parks the cutover rather than
letting the renames march into a read-only DB, and with mysql unreachable
the block deliberately survives as the watchdog's retry key. Step 12 then
removes the BOA_STANDBY_WEB firewall chain on the target and proves the
web tier both on loopback and EXTERNALLY — from the source, with a browser
UA (curl's default lands in BOA's own crawler map), over HTTP and, when the
target has a public 443 listener, HTTPS — because the loopback curl cannot
see an INPUT-chain drop and a surviving firewall hold would otherwise
surface only after the DNS flip, as total outage.
After promotion cutover removes the standby role marker on the target — the act
that hands the promoted box back its task queue and every local writer, since the
per-job gates keyed on that marker keep holding both while it exists. Only then does it
start cron, which is a belt rather than the mechanism: a target quiesced by pre-gate BOA
bytes still has its cron stopped, while a current one ran armed throughout the
replication window. It then restores the parked runner scripts. The removal is
re-checked over a second connection, and if the marker is still present or cron is not
running the run warns and prints the one-line manual fix to apply on the target
(rm -f /root/.standby.cnf; service cron start) rather than failing the cutover. It
also writes a proxied.pid marker for every source account.
Before the final sync, cutover stops every Solr and Jetty service on the source and
disables the stack there permanently, touching /root/.deny.java.cnf and setting
_DENY_JAVA=YES in /root/.barracuda.cnf (on a box carrying no cnf it says so and
writes the marker only). To reverse it on a rolled-back source, run
xmass restore-solr there — it clears both the marker and the cnf value and brings the
stack back up. Clearing the deny by hand is not enough: the file wins while it
exists, and the verb also undoes what the deny switched off.
The order inside the verb is what makes it work. It clears the xmass Solr hold marker
(/var/log/boa/.xmass_solr_hold.pid) first — while that file exists the per-minute
java.sh watchdog disarms and kills whatever has just been started — then clears
/root/.deny.java.cnf and sets _DENY_JAVA=NO, and only then re-arms each service:
exec bit restored, boot links re-enabled (an enable as well as defaults, because the
K links a disable left behind do not flip back on their own), any stale pid file
removed, and the service started and waited on for up to 60 s on its localhost port.
Where a service's init script is gone and no parked copy survives in /var/backups,
restore-solr regenerates one for Solr 9 and Solr 7 from the distribution template
shipped in /opt/<service>/bin/init.d/solr, substituting the install directory,
environment file, run-as user and Provides: name BOA installs with (a service with
neither a parked copy nor a usable template is reported and skipped). It honours each
service's own deny (_DENY_SOLR9/_DENY_SOLR7/_DENY_JETTY9 and their
/etc/boa/.deny.*.cnf markers), so clearing the blanket java deny never re-enables a
version the box deliberately runs without, and it is idempotent — a service already in
place and already listening is reported and left alone.
Two refusals. On a finalized PX0 proxy (/root/.proxy.cnf) it refuses outright:
Solr is down there by design after xtrim finalize. On an active replication
standby (/root/.standby.cnf) it also refuses — Solr is held down for the whole
migration window, and starting it mid-window would open an index still arriving by
rsync; finish or abort the migration first.
BOA does not self-heal any of this. _if_solr_nine (and its Solr 7 counterpart) re-runs
the Solr installer only when the version's data directory or its version stamp is
missing, and a rolled-back source still has both — so no barracuda up-* pass ever
brings the init script back.
Once the target's Solr is up, cutover compares the source's core set against what the
target actually registered and names any core that arrived as data but was never
registered — a core a fresh build cannot regenerate would otherwise leave its search
indexes silently returning wrong results on a cutover the tool called successful. The
comparison asks Solr on the ports BOA actually installs it on, and counts a core as
registered only when Solr reports it as a loaded core: a core whose configuration fails
to resolve on the new box is still echoed by name in Solr's start-up failures, and is
called out separately as arrived-but-unloadable rather than passed as healthy.
Registration itself stays with the operator, because it is core-shape-specific.
It then restores cron and the five runners on the source too, which matters more than
it sounds: a source kept as a long-lived proxy serves the migrated sites' TLS, renewals
stop there by design, and the only thing keeping those certificates fresh is
migration_proxy_certs.sh running from cron. cutover therefore also checks, before
converting anything, that the mirror is both present and actually scheduled.
What the conversion covers is the hosted sites, never the control panels. Each account's panel is identified by its hostmaster alias, not by which alias files exist; it keeps its local vhost and is put into Drupal's own maintenance mode once the conversion is in — an admin can still log in, nobody else can queue tasks against a database that now lives on the target — and the master panel gets the same treatment in a step of its own. Each hold flushes the panel's page cache as well, because a variable change alone leaves the cached anonymous front page in place and the hold would stay invisible until the next cache clear. The old panels stay online on the proxy box as its monitoring canaries; the target never serves a source-named panel, so a proxied panel would only ever answer the new box's catch-all page. The cutover's last step then opens every panel on the target by its new name and names any that does not answer — a cutover that ends with a dead panel says so in its own output rather than reporting success.
Afterwards, xmass verify re-checks the result end to end without changing anything.
Per account it samples two real sites: one under a client domain (its name does not
change at cutover), fetched twice at least 5 s apart against the target (BOA answers fetch
bursts with transient 200 "Page not found" bodies, so spacing is what makes the check
meaningful) and once relayed through the source proxy; and one under the old box's own
subdomain, which cutover renamed to the target's name — fetched direct by its new
name and never relayed (the old name is gone by design, and only the target's catch-all
would answer it). The account's control panel is never a sample; it is probed separately.
An HTTPS probe runs where the site has certificates. A 401 or 403 that the direct and the
relayed request agree on is reported as a protected site serving as configured, not as a
breakage. Then the leftover http-off.pid files, the source's cron and certificate-mirror
state, and every Ægir root's control panel on the target by its new name.
The replication transport is TLS
The replication stream carries every production row between two boxes that, in a
cross-region estate, sit on different providers' networks, so init runs it over TLS: the
xmass_repl user is created REQUIRE SSL (the source refuses a plain connection outright),
the replica is configured with SOURCE_SSL=1 (MASTER_SSL=1 on 5.7/8.0) against the
source's own server certificate (Percona generates one in the datadir; no CA is pinned — the
stream is encrypted against passive capture, which is what the first cross-provider mirror
required), and once the replica runs, init proves the session from both sides
(Source_SSL_Allowed: Yes on the target, connection_type = SSL/TLS in the source's
performance_schema.threads) and refuses a stream that is not TLS. A source whose MySQL has
no TLS material stops before the replication user is created. xmass status prints the
transport it can prove (TLS, PLAIN or unknown). The rsync legs already travel over ssh.
_XMASS_PLAIN_REPLICATION=YES runs the stream in the clear deliberately and loudly — for a
source that cannot serve TLS, never the default.
MySQL credentials and the datadir swap
Because the xtrabackup restore replaces the target's entire /var/lib/mysql — including the
mysql system tables — the target's MySQL root password becomes the source's. xmass
therefore ships /root/.my.pass.txt and /root/.my.cnf from source to target twice: right
after the restore, so client tools work during replica setup, and again after promotion at
cutover. This wholesale swap is also why the panel-database rewire above is required.
Finishing on the target — post-mig
xmass post-mig, run on the target once DNS is updated and traffic flows directly, is
not optional housekeeping. It ensures Solr is running cleanly, reloads nginx, restores
any remaining BOA runner scripts, and reconciles the migration-proxy trust from the
per-account policy records: peers whose accounts resolved temporary are dropped,
permanent/ha-switch peers stay trusted (restricted to the live peer set), and
undeclared accounts leave everything as found and are reported.
It also rebuilds the pinned PHP pools. A migrated account arrives carrying the
source's per-release FPM markers; both boxes run the same release, so without clearing
them the target reads the pool set as already built, never creates pools for versions
that exist only here, and never regenerates the per-site socket includes — every pinned
site is then served by the account's DEFAULT pool, indefinitely, because nothing
re-triggers until the release serial moves. post-mig clears the markers, lets the
normal sweep rebuild, then prints per account either that every pinned pool is live or
an ALRT naming each pin still missing. Treat any such ALRT as a stop: those sites
are running on the wrong interpreter right now, and a 200 with correct content proves
nothing — a Drupal 7 core tolerant of a newer PHP looks healthy while mis-pinned.
Finally, post-mig is what confirms cron is running: the current tool never stops a
standby's cron (its passivity comes from the per-job gates), but the cutover restarts it
regardless — harmless vintage tolerance for a target quiesced by pre-gate bytes — and a box
left with cron down silently stops receiving fleet updates entirely. Verify with
pgrep -x cron before walking away.
State machine and recovery
xmass tracks its progress in /data/conf/xmass_state.cnf (mode 600 — it holds the
replication password), moving through init → syncing → cutover → complete. Each subcommand
checks the current phase and refuses to run out of sequence.
phase=cutover is written only immediately before the write freeze, so anything that aborts
earlier in the cutover leaves the phase at syncing and is recovered by re-running the dry
run and --live. If a phase does end up wrong, correct it with xmass reset-phase <phase>
rather than editing the state file by hand; it warns about the unsafe transitions and is
exempt from the 90% disk precondition, since a stalled migration is a likely reason the disk
filled. To abandon a migration and start over, remove the state file — but only after
replication has been torn down on the target, or the next init collides with a live
replica.
Recovery paths:
initfails before replication starts — remove the state file and retry.initfails after replication starts — on the target runSTOP SLAVE; RESET SLAVE ALL, drop thexmass_repluser on the source, then remove the state file.cutoveraborts mid-flight — the source stays on its 503 gate, and the tool prints the full restore recipe for the source; follow that rather than working from memory. An abort before the write freeze leaves the phase atsyncing, so retrying is a fresh dry run plus--livewith nothing else to undo. An abort after the freeze but before the promotion holds (the post-lock lag checks, or a failed promotion whose read-back proves the target is still a replica) unlocks source MySQL and thaws the write freeze itself; the phase iscutover, so retrying isxmass reset-phase syncing, a fresh dry run, then--live. A promotion failure whose target cannot be read back keeps the freeze — the message spells out how to determine the promotion state and which recovery to run.- The target's web-layer proof, a panel rewire, or a
renameaegirhostpass fails — cutover parks resumably atphase=rename-failedand names the cause; the target is already promoted, so the source stays 503-gated and deliberately frozen. The printed recipe leads with the resume instruction; the restore lines below it, including the thaw, are only for abandoning the cutover. Fix the cause and re-runxmass cutover <target-ip> --liveto resume — the resume first re-asserts the source's 503 gate and write freeze (an operator may have followed the abandon recipe before changing their mind), then the tail drops the replication user and re-runs the web-layer proof (starting the target's nginx itself); already-rewired and already-renamed roots no-op.
Related
- Cross-host migration overview — which tool when, and the
shared storage gate,
http-offfreeze and migration-proxy machineryxmassstands on. - xoct & xcopy — single-account move and copy — the per-account
move and copy tools;
xmass cutoverdrivesxoct proxyper account during cutover. - OS lifecycle & upgrades — the in-place OS upgrade paths
xmassoffers a fresh-install alternative to. - Database (MySQL/Percona) — GTID replication internals and the
Percona-version match
xmassrequires. - Security & isolation — CSF,
ip_access, and the realip layer the migration-proxy trust feeds into. - Abuse Guard —
scan_nginxand the geo/AI bans that key on the recovered client IP after a migration proxy is trusted. - See the Reference appendix for the consolidated variable, command, and control-file tables.