This is the integration contract. Anything that speaks it — the stock
guestd, or your own workload's harness — interoperates with sleepwalk
without depending on our source. The Rust types in the proto crate mirror this
document; where they ever disagree, this document is the spec.
Version: v1-draft (proto::PROTOCOL_VERSION). Pre-1.0 the wire shape may
change with a CHANGELOG entry and a version bump; it is frozen at the v0.1.0
release.
There are two ways to put a workload under sleepwalk, both producing the same
wire messages below:
-
Wrap mode (zero code). The stock
guestdsupervises an arbitrary command and infers turn boundaries from its stdout: a line equal to a configured start marker opens a turn (TurnStarted), a line equal to the end marker closes it (TurnEnded). SetSLEEPWALK_WRAP_CMDto the command — or, for a baked rootfs with no shell, drop it in/etc/sleepwalk/wrap-cmd. The command is exec'd directly (argv split on whitespace; the minimal guest has no shell).SLEEPWALK_WRAP_START/SLEEPWALK_WRAP_ENDoverride the default markers (@@TURN_START@@/@@TURN_END@@). Any other output is passed through to guestd's log. Good for job-shaped workloads that can print a line at the edges of a unit of work. The wrapped process keeps running across a migration — its in-RAM state is carried in the snapshot — so it must not assume a stable host clock or local-only network state across a turn boundary.Wrap mode only observes: guestd cannot defer a turn the child has already begun. Drain is therefore passive — the host waits until the child is between turns (no turn in flight) before snapshotting. New turns are not gated or queued; that is the native-mode guarantee.
Boot secrets in wrap mode. By default the wrapped child starts at boot, so it gets no
Secrets(those arrive on the first handshake). A workload that needs a secret at exec — e.g. a coding agent's model API key — sets/etc/sleepwalk/wrap-await-secrets(orSLEEPWALK_WRAP_AWAIT_SECRETS) in the rootfs: guestd then defers the child until the first handshake deliversSecrets, and spawns it with them in its environment. The host supplies the values from its own environment (never the rootfs image or kernel cmdline). The same key persists in the running process across a migration. -
Native mode (exact boundaries). The workload (or its harness) speaks the vsock messages directly —
TurnStarted/TurnEnded/DrainAck— for exact turn boundaries and active gating: new turns that arrive after aDrainRequestare queued in-guest and replayed after resume (the race rule below). This is what an agent/turn-shaped integration uses.
Newline-delimited JSON over vsock. Each VM has its own vsock context id (CID);
the host listens on a fixed port. One JSON object per line, UTF-8. Messages are
internally tagged: every message is a flat object with a type field naming
the message, plus that message's fields. A fieldless message is just {"type":"<Name>"}.
Two channels, same messages. guestd serves the protocol on both vsock and
a TCP port on the guest network (GUEST_DRAIN_TCP_PORT). vsock is the boot/turn
path; the TCP channel exists because Firecracker's vsock device stops servicing
connections after a snapshot restore (both directions), while the guest network
survives. So hostd drains a restored VM over TCP — which is what makes draining,
and therefore re-migrating (moving an already-migrated VM again), possible.
A networked VM is drained over TCP; a non-networked VM uses vsock and is one-way.
{"type":"Hello","vm_id":"7e57...","guestd_version":"0.1.0"}
{"type":"TurnStarted","turn_id":7,"ts":1700000000000000000}
{"type":"Ping"}
Direction is fixed per message; there is no message a guest may send to itself or a host may send in the wrong direction.
| Message | Fields | Meaning |
|---|---|---|
Hello |
vm_id (UUID string), guestd_version (string) |
Boot handshake; binds the connection to a VM and declares the guestd version. Must be first. |
TurnStarted |
turn_id (int), ts (int ns) |
A unit of guest work began. The VM is now non-quiescent at the app layer. |
TurnEnded |
turn_id (int), ts (int ns) |
That turn finished. |
DrainAck |
in_flight (int turn id or null) |
Reply to DrainRequest. null ⇒ new turns gated and none running (app-layer quiescent). A turn id ⇒ wait for it (or time out). The null is always present, never omitted. |
Resumed |
ts (int ns) |
First message after a restore on the target host. Triggers guest clock fix-up. |
Ping / Pong |
— | Liveness. |
| Message | Fields | Meaning |
|---|---|---|
Secrets |
env (object: string → string) |
API keys / secrets injected at boot. Never in the rootfs or kernel cmdline — see Secrets below. |
DrainRequest |
deadline_ms (int) |
Gate new turns and report what's in flight. deadline_ms is how long the host will wait for an in-flight turn before aborting the migration. |
DrainCancel |
— | Migration aborted; un-gate and release any queued turns. |
RunTurn |
turn_id (int) |
Drive one unit of guest work; the guest echoes the matching TurnStarted / TurnEnded with the same turn_id. Subject to the drain gate exactly like a self-driven turn. |
Ping / Pong |
— | Liveness. |
- Durations cross the wire as whole milliseconds (
deadline_ms). The Rust side holds aDuration; the_mssuffix is the unit on the wire. - Timestamps (
ts) are integer nanoseconds since the Unix epoch, as the guest observed them. The guest clock freezes at snapshot and resyncs onResumed; timestamps that straddle a migration are not comparable until then. turn_idis a monotonic per-VM counter starting at 0. Absence of an in-flight turn isnull, never turn0.
Rebalancer-owned. The proto crate encodes the legal transitions as a
typestate, so e.g. snapshotting before quiescence does not compile.
Stable ─▶ Intent ─▶ Draining ─▶ Quiescent ─▶ Snapshotting ─▶ Transferring
▲ │ │ │
│ └─ abort ──┴── (timeout / turn-in-flight too long) │
│ ▼
Cleanup ◀── CutOver ◀── Restoring ◀────────────────────────────────┘
- Abort is legal from any phase before
Snapshotting(Intent,Draining,Quiescent) and returns the VM toStableon the source host. OnceSnapshottingbegins, the migration runs to completion or fails over to resume-on-source — there is no abort, and the type system enforces it. - Every transition is emitted as a structured JSON event into the run transcript
(
results/) and surfaced on the rebalancer's/metricsas an FSM gauge.
Precedence: in-flight turn > migration > queued turn. A turn already running
when a DrainRequest arrives wins — the migration waits up to deadline_ms,
then DrainCancels back to Stable. New turns that arrive after the drain are
queued in-guest, not dropped: each is held as backlog and replayed once
the gate reopens — after Resumed on the target host (the common path) or after
a DrainCancel un-gates on the source (abort path). "Zero dropped turns" is
exactly this: a gated turn is deferred, never lost. The turn is never sacrificed
to a migration. A turn-start that races the drain in the same instant resolves by
the guest's local processing order: if TurnStarted was emitted before
DrainRequest was handled, it counts as in-flight and wins.
Validated by the turn-vs-drain chaos test: a drain dropped at random offsets
across a stream of turns, over many seeded interleavings, asserting (1) every
attempted turn eventually runs (zero dropped), (2) the DrainAck's in_flight
matches the turn actually running at the drain instant, and (3) no turn starts
while the gate is closed. The KVM wall-clock version (100 runs on /dev/kvm)
is the integration-tier counterpart.
Secrets carries API keys to the guest at boot only over this vsock channel
— never baked into the rootfs image, never on the kernel cmdline (both are
world-readable from the host). Snapshots are RAM dumps and therefore contain any
secret in guest memory: snapshot dirs are 0700, transferred only over
sleepwalk's own channel, and deleted at Cleanup. Use a dedicated,
spend-limited, revocable key. The production answer (a credentials broker that
keeps the key out of guest memory entirely) is ADR-005.