Live Migration — Field Manual

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.

exc CLI gen-cli-sdk computeapi orchestrator source agent excloud-migrate receiver agent excloud-migrate

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:

  1. 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.
  2. The CLI installs the pinned, SHA-256-verified excloud-migrate binary 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.
  3. 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.
  4. 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.
  5. 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.
Key numbers: 4 MiB chunk · BLAKE3 hashes · TLS 1.3 pinned by cert fingerprint · heartbeat every 2 s · watchdog thaw ≤ 30 s · destination sync every 256 MiB · prepare/launch waits up to 15 min · agent pinned at 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
Who talks to whom. Note the two arrows that do NOT exist: computeapi never dials the receiver, and the receiver never accepts connections from anything but the source's IP.

Responsibilities per repo

RepoOwnsNever does
computeapiOrchestration + 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-sdkOperator 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.

CLI · runMigration

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

CLIcomputeapi · MigrationPreflight

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")
}
handlers/migrationcommon.go:113-115
CLIsource VM · runPreflight

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

computeapi · MigrationPrepare

Prepare: build the landing zone

One synchronous request (the CLI allows it 30 minutes and prints a progress line every 10 s) that:

  1. Re-validates the source, checks the image's min CPU/RAM against the target type, snapshots the source's security-group IDs.
  2. Generates a random 63-bit migration ID (confusingly stored as staging_vm_id — see review).
  3. Creates the destination volume: RoundGiB(RootFSSize), GP2, min IOPS/throughput.
  4. Creates the temporary security group — ingress TCP 7443 from sourceIP/32 only, 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).
  5. Mints the receiver's self-signed cert (RSA-3072, 7-day validity) and its SHA-256 fingerprint, plus the HMAC token binding migrationID:volumeID.
  6. Creates the receiver VM via the normal createVM path with cloud-init user-data that installs the agent (same SHA pinning) with its JSON config + cert.
  7. 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

CLIsource VM

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

source agent · Runner.Run

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++
}
internal/source/source.go:56-79
receiver agent

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

CLIcomputeapi · MigrationSeal

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

receiver agent · SanitizeDisk

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

computeapi · MigrationLaunch

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 },
}
handlers/migrationlaunch.go:75-88 — the clone boots with the customer's image record and security groups, but a new private IP and MAC, and never a public IPv4.
source agent

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
Hot path, auto-cutover. Steps 9–12 are where all the bytes move; everything else is orchestration.

§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:

  1. 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 every timeout/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.
  2. A deferred thaw in the seal function itself — any error path (or success) thaws, writes the freeze.done file that dismisses the watchdog, and notifies /v1/thawed.
  3. 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
}
internal/source/watchdog.go:69-71 — process death or a dead receiver both cap the frozen window.

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 = true
internal/receiver/receiver.go:374-379 — two independent BLAKE3 manifests, computed on opposite ends of the wire, must agree bit-for-bit before anything is sealed.

Section 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"
}
handlers/migrationcommon.go:146-163 — note "sealed but not yet sanitized" still reads SEALING (fixed in e4414a9's companion change so the CLI doesn't launch early).
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
Derived states. ABORT after launch is refused; SEAL after launch is refused; launch before READY is refused.

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=truecreateVM → 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

RoutePurposeNotable guards
POST /v1/identifyDeclare source disk name + sizeSize must fit the destination (0 < size ≤ dest)
POST /v1/chunkLength-prefixed JSON header + raw bytesRange 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/compareBatch hash compare (≤1024 items)final batches must be contiguous from 0, feed the manifest, and register expected repair hashes
POST /v1/passPass barrier + fsyncfinal requires: full-disk coverage, zero unrepaired chunks, manifest equality — else the pass doesn't count
GET /v1/statusFull control.StatusAlso the source's crash-resume and watchdog liveness probe
POST /v1/seal-completeSource proves the final passPass + manifest must match receiver's verified copy → Sealed
POST /v1/thawedSource confirms filesystems thawedOnly meaningful sealed/aborted; idempotent; triggers sanitation (sealed) or fd close (aborted)
POST /v1/seal, /v1/abort, GET /v1/digestLive 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)
}
internal/source/replicate.go:66-76 — during the frozen final pass, drift is impossible, so drift is treated as corruption and kills the seal.

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
SanitizeDisk — internal/receiver/sanitize_linux.go. Every "fail" lands in last_error via heartbeat, which the CLI surfaces immediately.

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

Design consequence worth saying out loud to a boss: the receiver is unreachable by the control plane on purpose. All control flows out from the receiver as heartbeats and comes back as a one-word action. That removes any need for computeapi→customer-subnet routing, receiver-side API auth, or firewall exceptions — the 2-second polling latency is the entire cost.

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
handlers/migrationabort.go — the source thaw always comes before any resource teardown. The source agent self-uninstalls after acknowledging the abort.

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:

CommitRepoWhat actually happened
8756171
do not create Managed SecGroups
computeapiThe 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.
e4414a9
wait for vol detach in vol_vm_mappings
computeapiLaunch 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.
0d6c6b1
crash on null secgroup
computeapiPrepare 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.
5e56296
heartbeat instead of receiver connect
computeapiThe 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.
4c35bb4
pin sanitation to mount namespace thread
excloud-migrateSanitation 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.
415f0be
make migration thaw recovery idempotent
excloud-migrateA 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.
c516e3d
outbound migration control heartbeat
excloud-migrateAgent 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

CommandDoes
exc compute migrate --source-vm-id N --new-name XFull 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 MOne-shot status table (or --output json): state, passes, copied/total %, rate, ETA (first pass only), mismatched bytes, new VM ID.
… migrate watch --id MPolls every 3 s, prints on change, exits at LAUNCHED. Fails fast if the agent reports last_error.
… migrate cutover --id MSeal → 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

VariableMeaning
MIGRATION_TOKEN_SECRETHMAC key for agent tokens. Required — prepare/preflight 503 without it.
BASE_URLPublic computeapi base; becomes the receiver's heartbeat URL. Required; https assumed if scheme-less.
MIGRATION_AGENT_URL / MIGRATION_AGENT_SHA256Pinned 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.

DEADagent_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.
DEADPre-heartbeat fossils in the receiver. 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.
GAPNo per-source concurrency guard in prepare. Two prepares for the same source VM happily create two full staging environments, and the second agent install bricks the first migration (config overwrite). One SELECT against vm_clones for an unlaunched record with the same source_vm_id would close it. handlers/migrationprepare.go
NAMINGstaging_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.
FRAGILEexec.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.
DUPTriple-written validation, double-written constants. The systemd-unit sanity check exists in the CLI, computeapi, and the agent (defensible defense-in-depth across trust boundaries — but keep them identical); the admin org appears as 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.
DESIGNPrepare is a 15-minute synchronous HTTP request. Simple and honest for an operator tool (the CLI compensates with 30-min timeouts and progress ticks), but it ties up a connection and a worker, and a dropped connection mid-wait leaves the CLI unsure whether prepare "happened" (it did — status by source VM would tell, but there's no lookup-by-source endpoint). Fine for now; revisit only if this becomes customer-facing.
GOODThings worth defending in front of anyone: the double-manifest seal proof (two independent BLAKE3 manifests must agree bit-for-bit before "sealed" exists); the watchdog's dual liveness condition; derived state in one pure function instead of a status column; record-before-wait ordering; the abort path's refusal to touch resources before thaw confirmation; every "clever" line in the agent carrying a why-comment (O_DIRECT coherence, NVMe +3 MiB, netplan regeneration, thread-pinned namespaces). This code explains itself unusually well.

Section 15File map — find anything in 10 seconds

You're looking for…Go to
Route registration + auth modecomputeapi/internal/router/router.go:85-99
Request/response DTOs, instance-type map, state derivation, admin checkcomputeapi/internal/handlers/migrationcommon.go
Prepare / launch / abort / seal / status / preflight / heartbeat handlerscomputeapi/internal/handlers/migration<name>.go (one file each)
DB: vm_clones record, clone JSON, FOR UPDATE mutations, migration secgroupcomputeapi/internal/repository/migration.go
Install-script templates + env config + preflight commandcomputeapi/internal/helpers/migrationtemplates.go + helpers/templates/migration-*
Cleanup goroutines (failed prepare, post-launch)computeapi/internal/helpers/migrationcleanup.go
RoundGiB, migration ID, WaitFor pollercomputeapi/internal/helpers/migration.go
Agent pins, staging image, admin orgcomputeapi/internal/constants/constants.go:11-14
Agent entrypoint, mode dispatch, heartbeat loop, self-uninstallexcloud-migrate/cmd/excloud-migrate/main_linux.go
Source: run loop, resume, seal/freeze sequenceexcloud-migrate/internal/source/source.go
Source: chunking, passes, final verified passexcloud-migrate/internal/source/replicate.go
Freeze/thaw ioctls, swap handlingexcloud-migrate/internal/source/freeze_linux.go
Watchdogexcloud-migrate/internal/source/watchdog.go
Receiver HTTP handlers, proof/manifest logic, checkpoint wiringexcloud-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, mountinfoexcloud-migrate/internal/device/*.go
Chunk verify/write/sync, whole-disk digestexcloud-migrate/internal/reconcile/transfer.go
Wire protocol types + range validation + clientexcloud-migrate/internal/protocol/*.go
HMAC token, receiver cert, pinned TLSexcloud-migrate/security/*.go
Heartbeat status contractexcloud-migrate/control/status.go
CLI command + subcommands + API client + token loadinggen-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 workloadcomputeapi/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.