Excloud legacy-VM live migration
How a running legacy VM's local root disk becomes a bootable network volume behind a brand-new VM — with the workload running the whole time except a sub-minute frozen window. Every claim on this page is pinned to a file and line so you can open the code and point at it.
Section 1TL;DR
An operator in the admin org runs exc compute migrate --source-vm-id N --new-name X. That single command drives everything:
- computeapi creates a destination volume (source root size, rounded up to GiB) and a disposable receiver VM (t1a.micro) in the customer's own org and subnet, locked down by a temporary security group that admits only the source VM's IP on TCP 7443.
- The CLI installs the pinned, SHA-256-verified
excloud-migratebinary on the source VM over instance-connect exec. The agent streams the live root disk to the receiver in 4 MiB BLAKE3-hashed chunks over fingerprint-pinned TLS 1.3, then keeps reconciling drift in hash-compare passes. - On seal, the source freezes its filesystems (a detached watchdog process guarantees an emergency thaw within ~30 s if anything dies), runs one final verified pass that both sides hash into a 32-byte manifest, thaws, and uninstalls itself.
- The receiver then sanitizes the cloned disk in a private mount namespace — new machine identity, no SSH host keys, no stale netplan — and reports done via its outbound heartbeat. Nothing ever connects into the receiver except the source.
- Launch: computeapi detaches the destination volume, marks it root-eligible, and creates the replacement VM from it — same image record, mapped instance type (
n1.* → m1a.*,t1.* → t1a.*), same security groups, deliberately new private IP + MAC and no public IPv4.
excloud-migrate-0.1.4.Section 2System map & repo responsibilities
flowchart TB OP(["Operator (admin org 1576)"]):::cli CLI["exc compute migrate
gen-cli-sdk"]:::cli API["computeapi
/compute/internal/migration/*"]:::api SRC["source VM (legacy)
excloud-migrate source"]:::src RCV["receiver VM (t1a.micro)
excloud-migrate receiver"]:::rcv VOL[("destination volume
GP2, RoundGiB(root)")]:::vol NEW["replacement VM
m1a.* / t1a.*"]:::api OP --> CLI CLI -- "REST + bearer token" --> API CLI -- "instance-connect exec:
install agent, preflight" --> SRC SRC -- "chunks + hashes
TLS1.3 pinned, :7443" --> RCV RCV -- "writes, verifies" --> VOL RCV -- "heartbeat POST /2s
status ⇄ action" --> API API -- "create / attach / detach" --> VOL API -- "creates at launch" --> NEW VOL -. "boots as root disk" .-> NEW classDef cli fill:#332b1a,stroke:#f0b429,color:#f5deb3 classDef api fill:#241f38,stroke:#a78bfa,color:#ddd3ff classDef src fill:#14291c,stroke:#4ade80,color:#c9f7d8 classDef rcv fill:#122735,stroke:#38bdf8,color:#c8e9fb classDef vol fill:#331f2a,stroke:#f472b6,color:#fbd1e5
Responsibilities per repo
| Repo | Owns | Never does |
|---|---|---|
| computeapi | Orchestration + admin API: eligibility checks, resource lifecycle (volume, receiver VM, security group), migration record (vm_clones table), desired-action relay (SEAL/ABORT via heartbeat responses), launch of the replacement VM, cleanup. Imports security + control packages from the agent repo. | Never touches disk bytes. Never connects to the receiver. |
| excloud-migrate (one static binary, three modes) | source: discover + stream + reconcile + freeze + final verified pass + self-uninstall. receiver: verify + write + checkpoint + sanitize + heartbeat. preflight: read-only readiness probe. | Never calls excloud APIs other than the heartbeat URL it was configured with. No credentials beyond its per-migration HMAC token. |
| gen-cli-sdk | Operator UX: exc compute migrate (+ status/watch/cutover). Sequences the API calls, runs commands on the source via instance-connect exec, renders progress, guards with prompts. | Holds no privileged logic — everything it does, you could do with curl + ssh. Auth is the operator's own admin-org token. |
The compatibility surface between computeapi and any given agent binary is deliberately tiny: the two imported Go packages (security for HMAC tokens + pinned TLS, control for the Status struct), the source→receiver /v1/* protocol, and the heartbeat contract. computeapi pins the exact binary via MIGRATION_AGENT_URL + MIGRATION_AGENT_SHA256, so an agent upgrade is always an explicit operator action (excloud-migrate/README.md).
Section 3The hot path — what actually runs, in order
This is the exact code path of a successful migration with default flags (--auto-cutover=true). Actor and file:line for every step.
Exec + sudo probe
Before touching anything, the CLI proves it can run commands on the source: sudo -n true via instance-connect exec as --source-user (default ubuntu). exec.Run is a new wrapper that reuses the exc compute exec command in-process with an org override to the admin org — no child process, so command contents never appear in an argv list.
migrate/command.go:207-211 exec/command.go:158-185
API preflight: is this VM migratable?
POST /compute/internal/migration/preflight runs loadMigrationSource, the single gate used by both preflight and prepare. It requires: VM exists, RUNNING, instance type has a mapping in targetInstanceTypes, a legacy local root disk (Manifest.RootVolumeId == 0 && RootFSSize > 0), an active interface with parseable IPv4, and org_id == owner_id (customer-owned, not platform-managed). It returns the target type and a ready-made guest preflight command.
if source.Manifest.RootVolumeId != 0 || source.Manifest.RootFSSize <= 0 {
return migrationSource{}, exclouderrors.IntErrBadRequest.
WithMessagef("source VM must use a legacy local root disk")
}Guest preflight on the source
The CLI executes the returned command on the source: download agent → verify SHA-256 → run excloud-migrate preflight. That discovers the whole disk backing / from /proc/self/mountinfo + sysfs, proves it can open it with O_DIRECT, and lists swap devices. The CLI cross-checks the reported disk size against what the destination volume will be (RoundGiB(root_disk_bytes)) and refuses if the guest disk is larger.
cmd/excloud-migrate/main_linux.go:81-102 migrate/command.go:373-390 helpers/migrationtemplates.go:88-97
Prepare: build the landing zone
One synchronous request (the CLI allows it 30 minutes and prints a progress line every 10 s) that:
- Re-validates the source, checks the image's min CPU/RAM against the target type, snapshots the source's security-group IDs.
- Generates a random 63-bit migration ID (confusingly stored as
staging_vm_id— see review). - Creates the destination volume:
RoundGiB(RootFSSize), GP2, min IOPS/throughput. - Creates the temporary security group — ingress TCP 7443 from
sourceIP/32only, deliberately not a managed group (vmengine auto-applies managed groups to every interface in the org — that was Friday's outage-grade bug, fix 8756171). - Mints the receiver's self-signed cert (RSA-3072, 7-day validity) and its SHA-256 fingerprint, plus the HMAC token binding
migrationID:volumeID. - Creates the receiver VM via the normal
createVMpath with cloud-init user-data that installs the agent (same SHA pinning) with its JSON config + cert. - Inserts the migration record into
vm_clones, then waits (≤15 min) for receiver RUNNING + volume AVAILABLE, and attaches the volume to the receiver. Record-before-wait ordering is itself a bugfix (0d6c6b1).
A deferred cleanup handles any failure after resource creation began — see the edge case.
handlers/migrationprepare.go:19-192 repository/migration.go:256-279 security/tls.go:18-39
Install the source agent
The CLI runs the prepare response's source_install_command on the source: download pinned binary, write /etc/excloud-migrate.json (receiver URL https://<receiverIP>:7443, token, fingerprint, self_uninstall: true), install and start the excloud-migrate.service systemd unit (Restart=on-failure).
helpers/templates/migration-source-install.sh.tmpl migrate/command.go:254-257
Initial copy + reconcile loop
The source opens the whole disk with O_DIRECT (page-cache coherence — see device layer), asks the receiver for status (crash resume), sends its disk identity, then runs the initial pass: read 4 MiB chunks sequentially, BLAKE3-hash each, and stream them through 4 parallel uploaders. After pass 1 it loops reconcile passes: hash 256 chunks at a time, POST /v1/compare, re-read and retransmit only mismatched chunks. Every loop iteration re-checks the disk size and hard-fails if it changed.
for {
if size, e := device.Size(r.Disk.Path); e != nil || size != r.Disk.Size { // resize guard }
status, e := r.Client.Status(ctx)
if e != nil { time.Sleep(time.Second); continue }
if status.Aborted { return r.Client.Thawed(ctx) }
if status.Sealing { return r.seal(ctx, rep, pass) }
if _, e = rep.Reconcile(ctx, pass); e != nil { return e }
pass++
}Verify, write, checkpoint, heartbeat
For every chunk the receiver re-hashes the payload and rejects on mismatch before writing to the destination volume (found by exact size match, locked with flock). It fsyncs every 256 MiB, persists a checkpoint JSON atomically (tmp + fsync + rename), and — in parallel — POSTs its full control.Status to computeapi every 2 s. The heartbeat response carries the desired action (SEAL/ABORT), which is how control ever reaches the receiver.
internal/reconcile/transfer.go:36-60 cmd/excloud-migrate/main_linux.go:181-222 handlers/migrationagentheartbeat.go:13-26
Cutover part 1: seal
Once completed_passes ≥ 1, auto-cutover posts /seal, which just sets DesiredAction = "SEAL" on the record (refusing if already launched). Within ~2 s the heartbeat delivers it; the receiver flips Sealing=true; within ~1 s the source's status poll sees it and enters the freeze sequence — detailed in §3b.
handlers/migrationseal.go:9-28 repository/migration.go:153-161
Sanitize the clone
After the source reports "thawed", the receiver closes its destination file descriptor and rewrites the clone's identity in a private mount namespace: blank machine-id, drop SSH host keys (with a paranoid re-check that none remain), remove cloud-init instance state and the source-generated netplan, disable any requested systemd units, then repair what a frozen copy can't avoid: fsck.fat -a on the EFI partition and sfdisk --relocate gpt-bak-std to move the backup GPT to the (larger, GiB-rounded) disk's end. Result: Sanitized=true, DestinationClosed=true → state READY.
internal/receiver/sanitize_linux.go:26-127 internal/receiver/sanitize.go:10-53
Cutover part 2: launch
/launch guards (sealed? sanitized? closed? already launched?), detaches the volume from the receiver, waits up to 15 min for it to be AVAILABLE with zero rows in vol_vm_mappings (fix e4414a9), sets is_root=true, and creates the replacement VM through the standard createVM path with RootVolume.VolumeID pointing at the clone. Then it records NewVMID under a transactional launch guard, terminates the receiver VM, and schedules security-group cleanup.
createReq := &types.ComputeCreatePostRequest{
Name: r.NewName, OrgId: source.OrgId, ZoneId: int(source.ZoneId),
SubnetId: source.SubnetId, ImageId: int32(source.Manifest.ImageId),
InstanceType: r.TargetInstanceType, SSHPubKey: r.SSHPubKey,
SecurityGroupIds: r.SourceSecurityGroupIDs,
AllocatePublicIPv4: false,
RootVolume: types.VolumeCreatePostRequest{ VolumeID: r.Clone.DestinationVolID },
}Self-uninstall
When runSource returns cleanly (seal delivered or abort acknowledged) and config says self_uninstall, the agent disables its unit, deletes its binary, config, and state, and exits. The source VM keeps running untouched — decommissioning it is a human decision.
cmd/excloud-migrate/main_linux.go:224-237
The same thing as a sequence diagram
sequenceDiagram autonumber participant CLI as exc CLI participant API as computeapi participant SRC as source agent participant RCV as receiver agent CLI->>SRC: exec: sudo -n true CLI->>API: POST /preflight API-->>CLI: target type + guest command CLI->>SRC: exec: agent preflight (O_DIRECT probe) CLI->>API: POST /prepare Note over API: volume + secgroup + cert/token
+ receiver VM, wait ready, attach API-->>CLI: migration id + install command CLI->>SRC: exec: install agent (systemd) SRC->>RCV: /v1/identify, /v1/chunk ×N (pass 1) loop until seal SRC->>RCV: /v1/compare + retransmit drift RCV->>API: heartbeat (status) → action end CLI->>API: POST /seal → DesiredAction=SEAL RCV->>API: heartbeat → "SEAL" SRC->>RCV: sees Sealing: freeze + final verified pass SRC->>RCV: /v1/seal-complete (manifest proof) SRC->>RCV: /v1/thawed (after thaw) Note over RCV: close fd → sanitize clone
Sanitized + DestinationClosed RCV->>API: heartbeat → state READY CLI->>API: POST /launch Note over API: detach vol, wait mappings clear,
is_root=true, create replacement VM,
terminate receiver API-->>CLI: LAUNCHED as VM id
§3b — Seal & freeze, the riskiest 30 seconds
Freezing a production VM's filesystems is the one step that can hurt the customer, so it is wrapped in three independent safety layers:
- A detached watchdog process. Before freezing, the source re-execs itself with
EXCLOUD_MIGRATE_WATCHDOG=<config>and releases it. The main process touches a heartbeat file everytimeout/3; the watchdog every second checks (a) that heartbeat file and (b) that the receiver is still reachable. If either is stale beyond 30 s it emergency-thaws every mount and re-enables swap, then notifies the receiver. - A deferred thaw in the seal function itself — any error path (or success) thaws, writes the
freeze.donefile that dismisses the watchdog, and notifies/v1/thawed. - Restart recovery. The systemd unit restarts a crashed agent; on startup it asks the receiver for status, and if the migration is already sealed/aborted it only emergency-thaws and reports — it will never re-freeze or write again (fix 415f0be).
func watchdogExpired(now, lastHeartbeat, lastReceiverContact time.Time, timeout time.Duration) bool {
return now.Sub(lastHeartbeat) > timeout || now.Sub(lastReceiverContact) > timeout
}The freeze itself: swapoff for swap on the same disk, syncfs on every mount, then FIFREEZE ioctl deepest-mount-first. Filesystems that don't support the ioctl (the vfat EFI partition) are tolerated — syncfs already flushed them — and kept out of the thaw set. internal/source/freeze_linux.go:27-66
The final pass is the integrity centerpiece. Under freeze, the source hashes every chunk in order and streams compare batches marked final. The receiver enforces contiguity from offset 0, feeds every (offset, length, hash) into a running BLAKE3 manifest, requires any repaired chunk to arrive with exactly the hash the compare promised (a frozen disk cannot legitimately change), and verifies each repair by reading it back. seal-complete then must present the same pass number and manifest the receiver computed itself:
if proof.Pass != r.verifiedPass || subtle.ConstantTimeCompare(proof.Manifest[:], r.verifiedManifest[:]) != 1 {
return fmt.Errorf("seal proof does not match the latest verified final pass")
}
r.status.Sealed = trueSection 4State machine
There is no state column. State is derived on every read from the migration record + the latest heartbeat, in one small pure function — so there is exactly one place state logic can be wrong:
func migrationState(c repository.VMClone) string {
if c.NewVMID > 0 { return "LAUNCHED" }
if c.Sealed && c.AgentStatus.Sanitized && c.AgentStatus.DestinationClosed { return "READY" }
if c.DesiredAction == "ABORT" { return "ABORTING" }
if c.DesiredAction == "SEAL" { return "SEALING" }
if c.Sealed { return "SEALING" }
return "REPLICATING"
}stateDiagram-v2 direction TB [*] --> REPLICATING : prepare + install REPLICATING --> SEALING : POST /seal → DesiredAction=SEAL SEALING --> READY : Sealed ∧ Sanitized ∧ DestinationClosed READY --> LAUNCHED : POST /launch (guarded, idempotent) REPLICATING --> ABORTING : POST /abort SEALING --> ABORTING : POST /abort ABORTING --> [*] : thaw confirmed, receiver terminated LAUNCHED --> [*] : receiver terminated, secgroup deleted
Section 5computeapi endpoint reference
All seven live under /compute/internal/migration/, registered in router/router.go:85-99, hidden from OpenAPI. Six require a bearer token whose org is the admin org (1576) via requireMigrationAdmin; the heartbeat is the exception — no platform auth (DisableSecurity), HMAC instead.
POST/preflightValidate a source VM and return the guest preflight command · handler MigrationPreflight
Body {source_vm_id}. Runs loadMigrationSource (RUNNING, type mapping, legacy local root, IPv4, customer-owned) and requires migration config to be present in the environment. Returns org, names, target type, root_disk_bytes (= RootFSSize MiB → bytes) and source_preflight_command. Read-only, safe to repeat. handlers/migrationpreflight.go
POST/prepareCreate volume + secgroup + receiver VM, record the migration · handler MigrationPrepare
Body {source_vm_id, new_name, ssh_pubkey, disable_units_on_clone[]}. new_name: ≤43 chars, lowercase DNS-ish regex. Units must be bare *.service names (no /, no ..). Synchronous — holds the request through a ≤15-min readiness wait. Returns {staging_vm_id, destination_volume_id, target_instance_type, source_install_command}. Failure after resource creation triggers async ordered cleanup. handlers/migrationprepare.go helpers/migrationcleanup.go:33-121
GET/status?staging_vm_id=NRecord + derived state + last agent heartbeat · handler MigrationStatus
Returns IDs, derived state, and the raw embedded control.Status (bytes copied, passes, throughput, sealing/sealed/sanitized/closed flags, last_error) — zeroed once launched. This is what exc compute migrate status/watch renders. handlers/migrationstatus.go
POST/sealSet DesiredAction=SEAL — delivered by the next heartbeat · handler MigrationSeal
Body {staging_vm_id}. Pure intent write; refuses if already launched (both in the handler and again inside the transactional update). Idempotent — the receiver ignores repeat SEALs once sealing. handlers/migrationseal.go repository/migration.go:153-161
POST/launchDetach clone volume, boot replacement VM, tear down staging · handler MigrationLaunch
Body {staging_vm_id}. Guards: already-launched → returns the existing VM (idempotent) and re-runs cleanup; not sealed → 409; not sanitized/closed → 409. Then detach → wait AVAILABLE + zero attachments (≤15 min) → is_root=true → createVM → transactional SetMigrationNewVM (launch guard) → terminate receiver → async secgroup cleanup. Response includes validation_posture: "no public IPv4; new private IP and MAC; source security groups retained". handlers/migrationlaunch.go
POST/abortRoll back a migration that hasn't launched · handler MigrationAbort
Body {staging_vm_id, delete_destination_volume}. Sets DesiredAction=ABORT, then waits for the source to confirm it is thawed (≤2 min) before touching any resource — if that fails, it returns 503 and leaves everything intact rather than yanking a volume out from under a frozen source. Then detach, wait, terminate receiver, optionally delete the volume (only after re-verifying ownership via the source VM). See §10. handlers/migrationabort.go
POST/agent/heartbeatReceiver's outbound status pipe; response carries SEAL/ABORT · handler MigrationAgentHeartbeat
Body {staging_vm_id, destination_volume_id, status}. Auth: Authorization: Bearer <HMAC(secret, stagingID:volumeID)> — constant-time verified, no platform token. The update transaction rejects a volume-ID mismatch, stores the status verbatim, latches Sealed, and returns {action}. This is the only inbound path from a receiver, and it can only ever update its own record. handlers/migrationagentheartbeat.go repository/migration.go:163-177
Section 6Receiver /v1 protocol (source → receiver, TLS 1.3 pinned)
Served by the receiver on :7443, reachable only from the source IP (security group) and only with the shared HMAC token (constant-time compared). Every non-status handler clears/sets last_error, which flows to the CLI via heartbeat → status. internal/receiver/receiver.go:118-147
| Route | Purpose | Notable guards |
|---|---|---|
POST /v1/identify | Declare source disk name + size | Size must fit the destination (0 < size ≤ dest) |
POST /v1/chunk | Length-prefixed JSON header + raw bytes | Range validated (4 MiB-aligned, in-bounds); payload re-hashed, mismatch rejected; during a final pass only promised repair chunks are accepted, and each is verified by read-back; ack echoes offset in X-Ack-Offset; refused once sealed/aborted |
POST /v1/compare | Batch hash compare (≤1024 items) | final batches must be contiguous from 0, feed the manifest, and register expected repair hashes |
POST /v1/pass | Pass barrier + fsync | final requires: full-disk coverage, zero unrepaired chunks, manifest equality — else the pass doesn't count |
GET /v1/status | Full control.Status | Also the source's crash-resume and watchdog liveness probe |
POST /v1/seal-complete | Source proves the final pass | Pass + manifest must match receiver's verified copy → Sealed |
POST /v1/thawed | Source confirms filesystems thawed | Only meaningful sealed/aborted; idempotent; triggers sanitation (sealed) or fd close (aborted) |
POST /v1/seal, /v1/abort, GET /v1/digest | — | Live but unused by the current flow (pre-heartbeat leftovers — see review) |
Section 7Agent internals — excloud-migrate
One static Go binary (~3.4k lines incl. tests), CGO-disabled, distributed only as a compiled artifact from repo.excloud.in. Mode is os.Args[1]: source, receiver, or preflight; a fourth hidden personality — the watchdog — is selected by the EXCLOUD_MIGRATE_WATCHDOG env var so it survives even if argv parsing changes. cmd/excloud-migrate/main_linux.go:39-71
Source mode
Replication engine (internal/source/replicate.go): one sequential reader (disk-friendly), 4 concurrent HTTP uploaders for the initial pass. Reconcile passes are memory-bounded on purpose — hashes for 256 chunks (1 GiB of disk) are sent, and only mismatched chunks are re-read and retransmitted, re-hashed at send time because the live disk may have moved on:
// Do not retain every 4 MiB block while waiting for the mismatch
// bitmap. Re-read only mismatches and hash the retransmitted bytes
// again because the live source may have changed since the scan.
b := make([]byte, items[i].Length)
if e = readExactAt(r.Source, b, items[i].Offset); e != nil { … }
h := protocol.ChunkHeader{Offset: …, Hash: blake3.Sum256(b)}
if final && !bytes.Equal(h.Hash[:], items[i].Hash[:]) {
return [32]byte{}, 0, fmt.Errorf("source chunk at offset %d changed while frozen", h.Offset)
}Crash resume: on start the runner asks the receiver's status. Sealed/aborted → emergency-thaw + report only. Otherwise replicationResume skips the initial pass iff the receiver already counted one, and continues at completedPasses + 1. internal/source/source.go:32-54,81-86
Receiver mode
Startup discovers its own root disk, enumerates all mounts, and finds the destination as the exactly-one unmounted, partitionless disk of the expected size — then locks it exclusively (flock). The HTTP server (TLS 1.3, the prepare-minted cert) is wrapped so every handler is token-authed. All progress lives in an atomically-saved checkpoint, so a receiver reboot resumes exactly where it was, including re-triggering sanitation if it died between thaw and sanitize (internal/receiver/receiver.go:72-84), and re-closing the destination fd if it had already closed it (receiver.go:61-66). Throughput is deliberately reset per process — a persisted byte counter over fresh uptime would fake a spike (receiver.go:56-59).
Sanitation — making the clone a different machine
flowchart TD A["unshare(CLONE_NEWNS)
private mount ns, pinned OS thread"]:::rcv --> B["blockdev --rereadpt · udevadm settle
lsblk until partitions appear"]:::rcv B --> C{"classify partitions"}:::rcv C -->|"ext4/xfs"| D["mount each ro (noload/norecovery)
keep if /etc/os-release + /etc/fstab"]:::rcv C -->|"ESP by GUID c12a7328…"| E{"exactly 1 ESP?"}:::rcv E -->|no| X1(["fail: unsupported-firmware-layout"]):::bad D --> F{"exactly 1 root candidate?"}:::rcv F -->|no| X2(["fail: found 0 / ≥2 roots"]):::bad F -->|yes| G["remount rw · mount ESP at boot/efi
require bootx64/shimx64/grubx64 .efi"]:::rcv G --> H["SanitizeMountedRoot:
blank machine-id · rm dbus id ·
rm cloud-init instance state ·
rm persistent-net rules · rm 50-cloud-init.yaml ·
rm ssh_host_* (verify gone) ·
mask requested units → /dev/null"]:::rcv H --> I["syncfs · unmount ·
fsck.fat -a ESP ·
sfdisk --relocate gpt-bak-std · sync"]:::rcv I --> J(["Sanitized=true · DestinationClosed=true
→ state READY"]):::good classDef rcv fill:#122735,stroke:#38bdf8,color:#c8e9fb classDef bad fill:#3a2530,stroke:#f87171,color:#ffd7d7 classDef good fill:#1c3129,stroke:#34d399,color:#c9f7d8
Two subtleties worth knowing cold: the whole routine runs on a locked OS thread because Linux mount namespaces are thread-local, and Go migrates goroutines between threads — the tainted thread is discarded when the goroutine exits (fix 4c35bb4, the newest agent commit). And netplan removal has a why-comment: the source's 50-cloud-init.yaml embeds its MAC/IP; the clone's first cloud-init run regenerates it from the clone's own metadata before network-online.target. internal/receiver/sanitize.go:21-24
Device layer — why O_DIRECT and exact sizes
Root disk discovery resolves the mount's major:minor through sysfs to the whole disk, and refuses anything layered — md, dm, nbd, rbd, loop, zram, or any device with slaves. Simple topologies only, by design. internal/device/discover_linux.go:88-131
O_DIRECT: reading a mounted filesystem's underlying block device through the page cache is not coherent with writes made through the filesystem. The final pass must see exactly the blocks that FIFREEZE + syncfs made durable, so all source reads bypass the cache, 4096-aligned via a manually aligned buffer. internal/device/direct_linux.go:16-31
Destination matching: excloud EBS NVMe namespaces present 3 MiB beyond the API size for storage metadata, so the receiver accepts a disk that is exactly the expected size or exactly expected+3 MiB — nothing else, so an unrelated bigger disk can never be clobbered. internal/device/destination_linux.go:14-26
Section 8Security model
Identity & auth
Operator → computeapi: normal platform bearer token, org must equal ADMIN_ORG_ID = 1576 (handlers/migrationcommon.go:165-171). Source → receiver and receiver → computeapi share one per-migration credential: base64url(HMAC-SHA256(secret, "stagingID:volumeID")). It authorizes exactly one migration's chunk stream and exactly one record's heartbeat — nothing else on the platform. Constant-time compares on both verifiers. security/token.go
Transport
TLS 1.3 minimum. The receiver's cert is self-signed, minted per-migration inside prepare, valid 7 days; the source doesn't do PKI — it pins the exact SHA-256 fingerprint from its config (InsecureSkipVerify + VerifyConnection fingerprint check, constant-time). MITM requires stealing that exact keypair. security/tls.go:41-52
Network
The receiver VM gets a dedicated security group with a single ingress rule: TCP 7443 from sourceIP/32. No public IPv4. The group is intentionally not "managed" — vmengine applies managed groups to every active interface in the org, which would have opened 7443 org-wide (fix 8756171). repository/migration.go:256-279
Supply chain
Both VMs download the agent over HTTPS and refuse to run it unless its SHA-256 matches the value computeapi pinned at deploy time. Config rejects a secret-less or mis-sized pin outright: len(AgentSHA256) != 64 → error. Upgrades are release-then-repin, never automatic. helpers/migrationtemplates.go:32-55
Section 9Edge cases — the questions your boss will ask
Each is phrased as the question, with the mechanism and the code that answers it. Green = handled gracefully; red = fails safe with a documented recovery.
The source agent crashes mid-replication. Do we restart from zero?
No. systemd restarts it (Restart=on-failure), it asks the receiver for status, and replicationResume skips the initial pass iff the receiver has already counted one, resuming at the next pass number. The receiver's data is already hash-verified per chunk, so a reconcile pass converges on whatever was missed. internal/source/source.go:81-86
The receiver VM reboots mid-migration. What survives?
Everything that matters: the checkpoint (status, passes, bytes) is saved atomically — write tmp, fsync, rename — on every state change, and reloaded on boot. Written chunks are on the destination volume, fsynced every 256 MiB; anything in the unsynced window simply fails the next compare pass and is retransmitted. If it died after thaw but before finishing sanitation, SetThawedHandler notices Sealed ∧ SourceThawed ∧ ¬Sanitized and re-runs sanitation on startup. internal/receiver/checkpoint.go:36-63 receiver.go:72-84
What if the source hangs while its filesystems are frozen? That's a production outage.
This is the top-of-mind risk and it has a dedicated process: before freezing, the agent spawns a detached watchdog (same binary, env-selected). The frozen-side process touches a heartbeat file every 10 s; the watchdog checks that file and receiver reachability every second, and if either goes stale >30 s it thaws every filesystem, re-enables swap, and tells the receiver. So the worst-case frozen window if the migration process is SIGKILLed is ~30 s. The watchdog reads/writes only local state — it works even with the network down (the receiver probe failing is a reason to thaw, not a dependency). internal/source/watchdog.go:24-67 source.go:96-128
A block changes while the disk is supposedly frozen. Would we ship silent corruption?
No — it's caught twice, on both sides of the wire. Source: before retransmitting a mismatched final-pass chunk, it re-reads and compares to the hash it just advertised; any difference aborts the seal ("changed while frozen"). Receiver: a repair chunk must arrive bearing exactly the promised hash, is re-hashed on receipt, and is read back and verified after write. And the whole pass must reproduce the manifest bit-for-bit. replicate.go:74-76 receiver.go:192-208
How do we know the receiver wrote to the right disk?
Deterministic elimination, not device names: rescan NVMe, list all whole disks, drop the receiver's own root disk, anything mounted, anything with mounted partitions, anything partitioned at all — then require the survivor's size to be exactly the expected size (or +3 MiB NVMe presentation overhead), and require exactly one survivor or fail. The winner is opened and flock-ed exclusively. internal/device/destination_linux.go:28-92
Someone resizes the source disk mid-migration.
Hard fail, immediately: the reconcile loop re-reads the block device size every iteration and errors out on any change ("source disk size changed from X to Y"). systemd restarts the agent; the size check in discovery fails the same way; the operator aborts. Deliberate: a resized source invalidates the destination sizing contract. internal/source/source.go:57-62
An operator double-clicks launch, or re-runs cutover after a timeout.
Idempotent at two layers. The handler: if NewVMID > 0, it fetches and returns that VM (and re-runs staging cleanup — a free retry for cleanup). The record update: ApplyLaunchGuard runs inside a SELECT … FOR UPDATE transaction and refuses if not sealed, already launched, or an invalid ID — so two racing launches cannot both win. handlers/migrationlaunch.go:28-38 repository/migration.go:184-230
Prepare fails halfway — volume created, receiver half-up. What's left behind?
A deferred hook fires unless prepare fully completed, and hands cleanup to a background goroutine with a 30-min budget and 2-s retries. Order depends on how far it got: staging VM FAILED → terminate first, then delete the volume; staging up → detach volume, delete it, then terminate; nothing created → just delete the volume. The migration security group is deleted last in every path. Every failure is logged with IDs, so leftovers are grep-able. helpers/migrationcleanup.go:33-121
The destination volume won't detach at launch time.
Launch waits up to 15 min for the volume to be AVAILABLE and for its attachment rows in vol_vm_mappings to clear — state alone isn't trusted, because volume state flips before the mapping row is gone, and creating a VM on a still-mapped volume fails. On timeout: 503, nothing destroyed, launch is retryable. This exact gap was Friday's bug (e4414a9). handlers/migrationlaunch.go:56-71
Abort is requested but the source is frozen / the receiver is dead.
Abort's first move is to wait (≤2 min) for the heartbeat to confirm SourceThawed ∧ DestinationClosed. If confirmation never comes, it returns 503 with "source thaw confirmation failed; staging resources were left intact" — it will not detach a volume a frozen source might still be replicating to. Recovery is the source watchdog (thaws within 30 s of receiver death) plus a retried abort. handlers/migrationabort.go:43-52
The customer's VM has swap. Frozen swap deadlocks?
Swap devices on the migrated disk are swapoff-ed before the freeze (swap writes don't go through a filesystem, so FIFREEZE can't stop them) and swapon-ed again on thaw — including by the watchdog's emergency path. Swap on other disks is untouched. internal/source/freeze_linux.go:29-34,85-90 main_linux.go:112-121
The EFI partition can't be frozen (vfat doesn't support FIFREEZE).
Known and handled: EOPNOTSUPP is tolerated — syncfs already flushed it — and the partition stays out of the thaw set. Any dirtiness a copy of a mounted vfat can still have is repaired on the clone by fsck.fat -a (exit code 1 = "repaired" is accepted as success). freeze_linux.go:52-63 sanitize_linux.go:135-148
The destination is bigger than the source (GiB rounding). Doesn't GPT break?
Yes — the backup GPT lives at the end of the disk, and the clone's disk end moved. Sanitation runs sfdisk --relocate gpt-bak-std to move it to the standard position on the new disk. Without this the clone boots with a degraded partition table warning. sanitize_linux.go:139-142
Could the clone come up with the source's identity and fight it on the network?
The specific collision vectors are each addressed: machine-id blanked (regenerated on first boot), SSH host keys removed (and their absence re-verified — sanitation fails if any remain), cloud-init instance state removed so first boot is a fresh instance, the MAC/IP-bearing netplan removed, persistent-net rules removed. Platform side: new private IP + MAC by construction (it's a new interface), and no public IPv4 is ever allocated. What is kept, deliberately: hostname, users, app state, and the source's security groups. The source VM itself keeps running — parallel operation is the announced posture, not an accident. sanitize.go:10-53 handlers/migrationcommon.go:185
What if the same source VM gets two concurrent migrations?
Nothing prevents it today — prepare has no per-source uniqueness check, so you'd get two receivers and two volumes, and the second agent install would overwrite the first's config, orphaning migration #1 (its receiver would idle until aborted). Not dangerous (each receiver only accepts its own token) but wasteful and confusing. Flagged in the review; the operational rule for now is one migration per source at a time, and status makes it visible. handlers/migrationprepare.go
The receiver silently dies after sealing. How long until anyone notices?
Honest answer: computeapi's status just goes stale — there is no last-heartbeat timestamp exposed, so watch would sit at the last reported state until its own timeout (default 24 h). The CLI's agent_error tolerance counter was built for this, but that field is never populated since the heartbeat refactor (see review — the one real gap found). Mitigation today: the receiver's systemd unit is Restart=always and its checkpoint makes restarts safe, so "silently dead forever" requires the whole VM to be gone — visible in the console.
Why doesn't a huge dirty page cache on the source corrupt the copy?
Because the source never reads through the page cache at all (O_DIRECT), and the freeze sequence is syncfs-then-FIFREEZE, so by final-pass time every durable block is on the platter and every read sees it. Reconcile passes before the freeze don't need coherence — any block that was mid-flight simply mismatches again next pass. internal/device/direct_linux.go:16-20
Section 10Abort — the rollback path
sequenceDiagram
autonumber
participant CLI as operator
participant API as computeapi
participant RCV as receiver
participant SRC as source agent
CLI->>API: POST /abort {delete_destination_volume?}
API->>API: DesiredAction=ABORT (refused if launched)
RCV->>API: heartbeat → "ABORT"
RCV->>RCV: Aborted=true · stop accepting writes
SRC->>RCV: status poll sees Aborted
SRC->>RCV: POST /v1/thawed (thaw if frozen)
RCV->>RCV: DestinationClosed=true · close fd
RCV->>API: heartbeat: SourceThawed ∧ DestinationClosed
Note over API: waits ≤2 min for that confirmation —
else 503, nothing touched
API->>API: detach volume · wait mappings clear (≤15 min)
API->>API: terminate receiver VM
API->>API: delete volume (only if requested,
ownership re-verified) · delete secgroup
API-->>CLI: aborted:true
Section 11Recent fixes — what already went wrong once
The migration feature landed 2026-07-17 (c5f20a3 feat: migrate!) and hardened fast. These are the commits your boss may have seen fly by, decoded:
| Commit | Repo | What actually happened |
|---|---|---|
8756171do not create Managed SecGroups | computeapi | The migration security group was being flagged is_managed=true. vmengine applies managed groups to every active interface in the org — so the "source-only, port 7443" rule was silently attaching to unrelated customer VMs. Fix: plain group, explicit binding to the receiver only; the NOTE comment in CreateMigrationSecurityGroup now guards the lesson. |
e4414a9wait for vol detach in vol_vm_mappings | computeapi | Launch trusted volume state alone; the volume read AVAILABLE while its vol_vm_mappings row still existed, and creating the replacement VM on a still-mapped volume failed. Fix: launch (and abort) now also wait for zero attachment rows. Same commit tightened migrationState so "sealed but not sanitized" reads SEALING, not READY — the CLI was launching before sanitation finished. Also bumped agent pin to 0.1.4. |
0d6c6b1crash on null secgroup | computeapi | Prepare waited for receiver readiness before inserting the migration record; a crash in that window left resources with no record (and a nil-secgroup panic on the cleanup path). Fix: record is written first, then the wait — so anything discoverable is always attributable. |
5e56296heartbeat instead of receiver connect | computeapi | The original design had computeapi dialing the receiver (helpers/migrationcontrol.go, now deleted) to poll status and push seal/abort. That required control-plane → customer-subnet reachability. Replaced with the outbound heartbeat + action-in-response pattern; deleted 66 lines of control client, added the 32-line heartbeat handler. The unused /v1/seal, /v1/abort, /v1/digest receiver endpoints are the fossils of this era. |
4c35bb4pin sanitation to mount namespace thread | excloud-migrate | Sanitation calls unshare(CLONE_NEWNS), which affects the calling OS thread — but Go moves goroutines across threads, so mounts could land in the wrong namespace or leak into the host's. Fix: runtime.LockOSThread in a dedicated goroutine that exits without unlocking, so the runtime destroys the tainted thread. |
415f0bemake migration thaw recovery idempotent | excloud-migrate | A restarted source agent whose migration was already sealed/aborted must only thaw and report — never re-freeze, never write. That startup branch (source.go:33-45) is this fix. |
c516e3doutbound migration control heartbeat | excloud-migrate | Agent side of 5e56296: the receiverControlLoop that POSTs status every 2 s and executes the returned action. |
Section 12CLI — exc compute migrate
Added in e131837 (gen-cli-sdk). Registered under compute in cli.json; talks to computeapi with the operator's admin-org token from ~/.exc/config (org 1576) or EXCLOUD_ACCESS_TOKEN; base URL https://compute.excloud.dev unless EXCLOUD_COMPUTE_BASE_URL overrides. internal/pkg/compute/migrate/command.go
| Command | Does |
|---|---|
exc compute migrate --source-vm-id N --new-name X | Full pipeline: exec probe → API preflight → guest preflight (cross-checks disk size) → confirm prompt → prepare (with 10 s progress ticks) → install source agent → wait for pass 1 → auto-cutover (default). --auto-cutover=false keeps replicating until a manual cutover. --dry-run stops after preflights. --disable-unit svc.service masks units on the clone (e.g. a second postgres). --yes skips the prompt, --timeout caps the whole run (24 h default). |
… migrate status --id M | One-shot status table (or --output json): state, passes, copied/total %, rate, ETA (first pass only), mismatched bytes, new VM ID. |
… migrate watch --id M | Polls every 3 s, prints on change, exits at LAUNCHED. Fails fast if the agent reports last_error. |
… migrate cutover --id M | Seal → wait READY (sanitized + closed) → launch. The recovery/manual half of --auto-cutover=false; also the retry after a failed launch. |
The consequential UX choice is the warning before consent: "writes made on the source after the final seal will not exist on the replacement." Cutover is a data-divergence point and the CLI makes the operator own it. migrate/command.go:232-243
Section 13Ops — config, releases, testing
computeapi environment
| Variable | Meaning |
|---|---|
MIGRATION_TOKEN_SECRET | HMAC key for agent tokens. Required — prepare/preflight 503 without it. |
BASE_URL | Public computeapi base; becomes the receiver's heartbeat URL. Required; https assumed if scheme-less. |
MIGRATION_AGENT_URL / MIGRATION_AGENT_SHA256 | Pinned binary + hash. Defaults compiled in: excloud-migrate-0.1.4 / c711d6d4…. Bump both together. constants/constants.go:12-14 |
Fixed constants: staging image MIGRATION_STAGING_IMAGE_ID = 10, receiver instance type t1a.micro with a 16 GiB root, admin org 1576.
Agent release flow
Tag vX.Y.Z in excloud-migrate → Gitea Actions builds the static binary + SHA256SUMS → run ubuntu-repo deploy with that version → binary appears at repo.excloud.in/bin/excloud-migrate-X.Y.Z → update the two pins in computeapi and deploy. Agent, CLI, and computeapi releases are fully independent. README.md
End-to-end verification harness
scripts/manual-migration-source-workload turns any test VM into a migration guinea pig: it snapshots identity (machine-id, boot-id, hostname, network, block layout, SSH host key fingerprints), drops a 16 MiB random file with a recorded checksum, and starts a systemd writer appending a fsynced sequence log every second. After migrating, you diff the before-files on the clone: checksum must match, identity files must differ, and the sequence log shows exactly where the final seal cut the timeline. scripts/manual-migration-source-workload
Section 14Code health review — an honest read
Overall: this is a small, disciplined codebase — ~1.7k lines of orchestration, ~2.6k of agent (excl. tests), and the complexity lives where the risk lives (freeze safety, verification, sanitation), which is exactly the right KISS trade. The findings below are ranked; none are fires.
agent_error is a ghost field. MigrationStatusResponse.AgentError is declared (handlers/migrationcommon.go:66) but never assigned anywhere — it's a leftover from the pre-heartbeat design where computeapi dialed the receiver and could fail. The CLI still carries a whole tolerance mechanism for it (maxConsecutiveAgentErrors, migrate/command.go:354-361) that can never fire. Consequence: there is currently no staleness signal at all if a receiver goes silent. Recommendation: repurpose it honestly — have /status set agent_error when updated_at is older than ~30 s — or delete the field and the CLI counter.POST /v1/seal, POST /v1/abort, GET /v1/digest (plus Client.Digest and the whole-disk digest walker it fronts) are served but nothing calls them since 5e56296. They're token-gated and harmless, but they're attack/maintenance surface with zero callers. Also literal dead weight: var _ = blake3.Sum256 at receiver.go:502. Delete all four.SELECT against vm_clones for an unlaunched record with the same source_vm_id would close it. handlers/migrationprepare.gostaging_vm_id is not a VM ID. It's a random 63-bit migration identifier (helpers/migration.go:16-27); the actual staging VM is receiver_vm_id. The misnomer is load-bearing across the DB column, the API contract, and the CLI flag — every future reader will trip on it once. If a rename is too costly, at least the docs (this page) should keep saying "migration ID" loudly.exec.Run mutates package-global cobra flags — saves five globals, swaps, restores under defer (exec/command.go:158-185). Correct today because the CLI is single-threaded, and the comment owns the hack; but it's the pattern most likely to bite whoever adds parallelism. Extracting the exec body into a params-struct function would cost ~20 lines.ADMIN_ORG_ID = 1576 in computeapi and the string "1576" in the CLI; the CLI re-implements RoundGiB inline (migrate/command.go:384-385). All fine individually; worth a comment linking each pair so they drift loudly, not silently.Section 15File map — find anything in 10 seconds
| You're looking for… | Go to |
|---|---|
| Route registration + auth mode | computeapi/internal/router/router.go:85-99 |
| Request/response DTOs, instance-type map, state derivation, admin check | computeapi/internal/handlers/migrationcommon.go |
| Prepare / launch / abort / seal / status / preflight / heartbeat handlers | computeapi/internal/handlers/migration<name>.go (one file each) |
| DB: vm_clones record, clone JSON, FOR UPDATE mutations, migration secgroup | computeapi/internal/repository/migration.go |
| Install-script templates + env config + preflight command | computeapi/internal/helpers/migrationtemplates.go + helpers/templates/migration-* |
| Cleanup goroutines (failed prepare, post-launch) | computeapi/internal/helpers/migrationcleanup.go |
| RoundGiB, migration ID, WaitFor poller | computeapi/internal/helpers/migration.go |
| Agent pins, staging image, admin org | computeapi/internal/constants/constants.go:11-14 |
| Agent entrypoint, mode dispatch, heartbeat loop, self-uninstall | excloud-migrate/cmd/excloud-migrate/main_linux.go |
| Source: run loop, resume, seal/freeze sequence | excloud-migrate/internal/source/source.go |
| Source: chunking, passes, final verified pass | excloud-migrate/internal/source/replicate.go |
| Freeze/thaw ioctls, swap handling | excloud-migrate/internal/source/freeze_linux.go |
| Watchdog | excloud-migrate/internal/source/watchdog.go |
| Receiver HTTP handlers, proof/manifest logic, checkpoint wiring | excloud-migrate/internal/receiver/receiver.go |
| Sanitation (mount ns, EFI checks, identity scrub, GPT/fsck repair) | excloud-migrate/internal/receiver/sanitize_linux.go + sanitize.go + firmware.go |
| Checkpoint store (atomic save) | excloud-migrate/internal/receiver/checkpoint.go |
| Disk discovery, O_DIRECT, destination matching, mountinfo | excloud-migrate/internal/device/*.go |
| Chunk verify/write/sync, whole-disk digest | excloud-migrate/internal/reconcile/transfer.go |
| Wire protocol types + range validation + client | excloud-migrate/internal/protocol/*.go |
| HMAC token, receiver cert, pinned TLS | excloud-migrate/security/*.go |
| Heartbeat status contract | excloud-migrate/control/status.go |
| CLI command + subcommands + API client + token loading | gen-cli-sdk/internal/pkg/compute/migrate/command.go |
| In-process exec reuse (org override) | gen-cli-sdk/internal/pkg/compute/exec/command.go:158-185 |
| Migration verification workload | computeapi/scripts/manual-migration-source-workload |
Generated 2026-07-18 from computeapi@8756171, excloud-migrate@4c35bb4, gen-cli-sdk@e131837 — every line reference checked against those commits. If the code moved, git log -S the symbol.