From cc83c373de3f70735134d9932f7dcd2a089c97a5 Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Tue, 18 Aug 2026 22:47:58 -0700 Subject: [PATCH 1/3] feat(control): add zone snapshot and restore RPCs `SnapshotZone` streams a zone's complete state -- vcpus, guest memory, hypervisor configuration, device topology, and the zone's own control-plane record and workloads -- out as opaque bytes for the caller to store. `RestoreZone` streams them back and returns the restored zone's id. The bytes are opaque on purpose: the client stores a snapshot, it does not interpret one. The backend and architecture tags are the load-bearing part of that exchange, and the only part of the snapshot's own shape this API needs to name, so they live in common.proto next to the virtualization backend they mirror. A snapshot may only be restored on the hypervisor it came from, since the guest-state records are backend-private, and tagging them is what lets a KVM implementation be added later without either backend being able to consume the other's stream. Nothing else about the stream is described here. The records inside it -- the manifest, the device topology, the guest memory -- belong to the daemon and the hypervisor backend that write them, and describing them in the public API would make a change to the format's framing a change to this API. The one thing a caller does get is `total_bytes`, which describes the transfer rather than the format and is what lets it confirm it stored the whole stream. `ZONE_STATE_RESTORING` covers a zone being rebuilt from a snapshot. --- protect/control/v1/common.proto | 23 ++++++++++ protect/control/v1/control.proto | 77 ++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/protect/control/v1/common.proto b/protect/control/v1/common.proto index 0a409ab..2617e17 100644 --- a/protect/control/v1/common.proto +++ b/protect/control/v1/common.proto @@ -132,6 +132,28 @@ enum ZoneVirtualizationBackend { ZONE_VIRTUALIZATION_BACKEND_KVM = 4; } +// Hypervisor family a zone snapshot was captured from. A snapshot may only be +// restored on a host running the same backend: the guest-state records inside +// it are backend-private, so a Xen stream carries nothing a KVM host can act +// on, and vice versa. The tag is checked before any of that state is +// interpreted, which is what lets a second backend be added later without +// either being able to consume the other's stream. +enum ZoneSnapshotBackend { + ZONE_SNAPSHOT_BACKEND_UNKNOWN = 0; + ZONE_SNAPSHOT_BACKEND_XEN_PV = 1; + ZONE_SNAPSHOT_BACKEND_XEN_PVH = 2; + ZONE_SNAPSHOT_BACKEND_KVM = 3; +} + +// Guest instruction set a zone snapshot was captured for. Held separately from +// the backend: one backend spans architectures whose guest state has nothing in +// common. +enum ZoneSnapshotArch { + ZONE_SNAPSHOT_ARCH_UNKNOWN = 0; + ZONE_SNAPSHOT_ARCH_X86_64 = 1; + ZONE_SNAPSHOT_ARCH_AARCH64 = 2; +} + // Strategy used when expanding the selected node set beyond the seed node. // - UNSPECIFIED is treated as COMPACT. // - COMPACT: Expand to the nearest unselected NUMA node by SLIT distance from the already-selected set, tie-breaking by most free memory. @@ -261,6 +283,7 @@ enum ZoneState { ZONE_STATE_SUSPENDED = 9; ZONE_STATE_RESUMING = 10; ZONE_STATE_FORKING = 11; + ZONE_STATE_RESTORING = 12; } enum ZoneNetworkIpVersion { diff --git a/protect/control/v1/control.proto b/protect/control/v1/control.proto index c61dd0a..b6cdb31 100644 --- a/protect/control/v1/control.proto +++ b/protect/control/v1/control.proto @@ -31,6 +31,8 @@ service ControlService { rpc SuspendZone(SuspendZoneRequest) returns (SuspendZoneReply); rpc ResumeZone(ResumeZoneRequest) returns (ResumeZoneReply); rpc ForkZone(ForkZoneRequest) returns (ForkZoneReply); + rpc SnapshotZone(SnapshotZoneRequest) returns (stream SnapshotZoneReply); + rpc RestoreZone(stream RestoreZoneRequest) returns (RestoreZoneReply); rpc ResolveZoneId(ResolveZoneIdRequest) returns (ResolveZoneIdReply); rpc ResolveZoneIds(ResolveZoneIdsRequest) returns (ResolveZoneIdsReply); @@ -145,6 +147,81 @@ message ForkZoneReply { string zone_id = 1; } +// Captures a zone's complete state -- vcpus, guest memory, hypervisor +// configuration, device topology, and the zone's own control-plane record and +// workloads -- as a stream of opaque bytes the caller stores and later hands +// back to `RestoreZone`. +// +// The zone is cooperatively suspended for the capture and resumed afterwards +// unless `leave_suspended` is set, so a snapshot is a consistent point rather +// than a smear of a running guest. +message SnapshotZoneRequest { + string zone_id = 1; + // Leave the zone suspended once the capture completes instead of resuming + // it. Use this when the snapshot is about to become the live copy elsewhere. + bool leave_suspended = 2; + // Compress record payloads. Guest memory compresses well; the cost is host + // CPU during capture. + bool compress = 3; + // Embed the contents of writable disks in the stream. Without it the snapshot + // only references them, so restoring depends on that backing still existing + // and being unchanged. + bool include_disk_contents = 4; +} + +// Streamed reply for `SnapshotZone`. The first message carries `start`, +// describing what is about to be streamed; then `chunk`s carrying the snapshot +// bytes in order; then a final `complete`. The concatenation of every `chunk` +// is the snapshot, and nothing else in the stream belongs in the stored bytes. +message SnapshotZoneReply { + oneof reply { + SnapshotZoneStart start = 1; + bytes chunk = 2; + SnapshotZoneComplete complete = 3; + } +} + +message SnapshotZoneStart { + string zone_id = 1; + ZoneSnapshotBackend backend = 2; + ZoneSnapshotArch arch = 3; + uint32 format_version = 4; +} + +// Closes the reply stream once the capture is complete, so a caller can check +// that what it wrote is as long as what the daemon sent. +message SnapshotZoneComplete { + uint64 total_bytes = 1; +} + +// Recreates a zone from a snapshot produced by `SnapshotZone`. The first +// message must carry `start`; every message after it carries a `chunk` of the +// snapshot, in the order it was received. The daemon rejects a snapshot whose +// backend or architecture tag does not match the host before acting on any of +// the guest state it carries. +message RestoreZoneRequest { + oneof request { + RestoreZoneStart start = 1; + bytes chunk = 2; + } +} + +message RestoreZoneStart { + // Overrides the zone name recorded in the snapshot. Empty keeps it. + string name = 1; + // Restore under this zone id instead of a freshly allocated one. Empty + // allocates a new id, which is what lets one snapshot seed many zones. + string zone_id = 2; + // Restore even when the snapshot's source host differs from this one. + // Backing store the snapshot only references (rather than embeds) may not + // exist here, so this is opt-in. + bool allow_foreign_host = 3; +} + +message RestoreZoneReply { + string zone_id = 1; +} + // Resolves zone "friendly name" to a singular zone UUID, if possible. Matching is exact. // See also `ResolveZoneIdsRequest` (plural). message ResolveZoneIdRequest { From 58dd58526d30b56ed0cc860b26ff01200a323faf Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Thu, 24 Sep 2026 14:40:21 -0700 Subject: [PATCH 2/3] feat(control): rename zone snapshot and restore to export and import `SnapshotZone` becomes `ExportZone` and `RestoreZone` becomes `ImportZone`, with their messages renamed to match. What an export produces is still a snapshot, so the snapshot backend and architecture tags keep their names. `ZONE_STATE_RESTORING` becomes `ZONE_STATE_IMPORTING`. `include_disk_contents` is removed and reserved: an export now always carries the contents of a zone's writable disks. `allow_foreign_host` is narrowed to snapshots that reference read-only images by host path, and the import now checks the CPU, hypervisor and platform a snapshot was exported on against the importing host. --- protect/control/v1/common.proto | 8 ++-- protect/control/v1/control.proto | 76 ++++++++++++++++---------------- 2 files changed, 43 insertions(+), 41 deletions(-) diff --git a/protect/control/v1/common.proto b/protect/control/v1/common.proto index 2617e17..9cf69f1 100644 --- a/protect/control/v1/common.proto +++ b/protect/control/v1/common.proto @@ -132,8 +132,8 @@ enum ZoneVirtualizationBackend { ZONE_VIRTUALIZATION_BACKEND_KVM = 4; } -// Hypervisor family a zone snapshot was captured from. A snapshot may only be -// restored on a host running the same backend: the guest-state records inside +// Hypervisor family a zone snapshot was exported from. A snapshot may only be +// imported on a host running the same backend: the guest-state records inside // it are backend-private, so a Xen stream carries nothing a KVM host can act // on, and vice versa. The tag is checked before any of that state is // interpreted, which is what lets a second backend be added later without @@ -145,7 +145,7 @@ enum ZoneSnapshotBackend { ZONE_SNAPSHOT_BACKEND_KVM = 3; } -// Guest instruction set a zone snapshot was captured for. Held separately from +// Guest instruction set a zone snapshot was exported for. Held separately from // the backend: one backend spans architectures whose guest state has nothing in // common. enum ZoneSnapshotArch { @@ -283,7 +283,7 @@ enum ZoneState { ZONE_STATE_SUSPENDED = 9; ZONE_STATE_RESUMING = 10; ZONE_STATE_FORKING = 11; - ZONE_STATE_RESTORING = 12; + ZONE_STATE_IMPORTING = 12; } enum ZoneNetworkIpVersion { diff --git a/protect/control/v1/control.proto b/protect/control/v1/control.proto index b6cdb31..ea3c07e 100644 --- a/protect/control/v1/control.proto +++ b/protect/control/v1/control.proto @@ -31,8 +31,8 @@ service ControlService { rpc SuspendZone(SuspendZoneRequest) returns (SuspendZoneReply); rpc ResumeZone(ResumeZoneRequest) returns (ResumeZoneReply); rpc ForkZone(ForkZoneRequest) returns (ForkZoneReply); - rpc SnapshotZone(SnapshotZoneRequest) returns (stream SnapshotZoneReply); - rpc RestoreZone(stream RestoreZoneRequest) returns (RestoreZoneReply); + rpc ExportZone(ExportZoneRequest) returns (stream ExportZoneReply); + rpc ImportZone(stream ImportZoneRequest) returns (ImportZoneReply); rpc ResolveZoneId(ResolveZoneIdRequest) returns (ResolveZoneIdReply); rpc ResolveZoneIds(ResolveZoneIdsRequest) returns (ResolveZoneIdsReply); @@ -147,78 +147,80 @@ message ForkZoneReply { string zone_id = 1; } -// Captures a zone's complete state -- vcpus, guest memory, hypervisor -// configuration, device topology, and the zone's own control-plane record and -// workloads -- as a stream of opaque bytes the caller stores and later hands -// back to `RestoreZone`. +// Exports a zone's complete state -- vcpus, guest memory, hypervisor +// configuration, device topology, the contents of its writable disks, and the +// zone's own control-plane record and workloads -- as a snapshot: a stream of +// opaque bytes the caller stores and later hands back to `ImportZone`. // -// The zone is cooperatively suspended for the capture and resumed afterwards +// The zone is cooperatively suspended for the export and resumed afterwards // unless `leave_suspended` is set, so a snapshot is a consistent point rather // than a smear of a running guest. -message SnapshotZoneRequest { +message ExportZoneRequest { + reserved 4; + reserved "include_disk_contents"; + string zone_id = 1; - // Leave the zone suspended once the capture completes instead of resuming - // it. Use this when the snapshot is about to become the live copy elsewhere. + // Leave the zone suspended once the export completes instead of resuming it. + // Use this when the snapshot is about to become the live copy elsewhere. bool leave_suspended = 2; // Compress record payloads. Guest memory compresses well; the cost is host - // CPU during capture. + // CPU during the export. bool compress = 3; - // Embed the contents of writable disks in the stream. Without it the snapshot - // only references them, so restoring depends on that backing still existing - // and being unchanged. - bool include_disk_contents = 4; } -// Streamed reply for `SnapshotZone`. The first message carries `start`, -// describing what is about to be streamed; then `chunk`s carrying the snapshot +// Streamed reply for `ExportZone`. The first message carries `start`, +// describing the snapshot about to be streamed; then `chunk`s carrying its // bytes in order; then a final `complete`. The concatenation of every `chunk` // is the snapshot, and nothing else in the stream belongs in the stored bytes. -message SnapshotZoneReply { +message ExportZoneReply { oneof reply { - SnapshotZoneStart start = 1; + ExportZoneStart start = 1; bytes chunk = 2; - SnapshotZoneComplete complete = 3; + ExportZoneComplete complete = 3; } } -message SnapshotZoneStart { +message ExportZoneStart { string zone_id = 1; ZoneSnapshotBackend backend = 2; ZoneSnapshotArch arch = 3; uint32 format_version = 4; } -// Closes the reply stream once the capture is complete, so a caller can check -// that what it wrote is as long as what the daemon sent. -message SnapshotZoneComplete { +// Closes the reply stream once the export is complete, so a caller can check +// that the snapshot it wrote is as long as what the daemon sent. +message ExportZoneComplete { uint64 total_bytes = 1; } -// Recreates a zone from a snapshot produced by `SnapshotZone`. The first -// message must carry `start`; every message after it carries a `chunk` of the -// snapshot, in the order it was received. The daemon rejects a snapshot whose -// backend or architecture tag does not match the host before acting on any of -// the guest state it carries. -message RestoreZoneRequest { +// Recreates a zone from a snapshot produced by `ExportZone`. The first message +// must carry `start`; every message after it carries a `chunk` of the +// snapshot, in the order it was received. +// +// The daemon checks the snapshot against this host before acting on any of the +// guest state it carries: the backend and architecture must match, and the +// CPU, hypervisor version and platform recorded at export must be ones this +// host can continue the guest on. A refusal names every incompatibility found. +message ImportZoneRequest { oneof request { - RestoreZoneStart start = 1; + ImportZoneStart start = 1; bytes chunk = 2; } } -message RestoreZoneStart { +message ImportZoneStart { // Overrides the zone name recorded in the snapshot. Empty keeps it. string name = 1; - // Restore under this zone id instead of a freshly allocated one. Empty + // Import under this zone id instead of a freshly allocated one. Empty // allocates a new id, which is what lets one snapshot seed many zones. string zone_id = 2; - // Restore even when the snapshot's source host differs from this one. - // Backing store the snapshot only references (rather than embeds) may not - // exist here, so this is opt-in. + // Import a snapshot exported on another host even though it references + // read-only images by host path; those paths must hold the same images here. + // Does not override the CPU or hypervisor compatibility checks. bool allow_foreign_host = 3; } -message RestoreZoneReply { +message ImportZoneReply { string zone_id = 1; } From 4c160214ea4cf5e22b5ad563e1200b9d3d2890ff Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Thu, 24 Sep 2026 16:24:55 -0700 Subject: [PATCH 3/3] feat(control): drop allow_foreign_host from ImportZone The import no longer gates on the exporting host's id: it checks the guest's cpu, clock, agent protocol and hypervisor against the importing host, and that each read-only image the snapshot references exists at the recorded size. The waiver has nothing left to waive, so the field is removed and reserved. --- protect/control/v1/control.proto | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/protect/control/v1/control.proto b/protect/control/v1/control.proto index ea3c07e..ea79621 100644 --- a/protect/control/v1/control.proto +++ b/protect/control/v1/control.proto @@ -199,8 +199,9 @@ message ExportZoneComplete { // // The daemon checks the snapshot against this host before acting on any of the // guest state it carries: the backend and architecture must match, and the -// CPU, hypervisor version and platform recorded at export must be ones this -// host can continue the guest on. A refusal names every incompatibility found. +// guest's CPU, clock, zone agent and hypervisor state must be ones this host +// can continue. Read-only images the snapshot references by path must exist +// here unchanged in size. A refusal names every incompatibility found. message ImportZoneRequest { oneof request { ImportZoneStart start = 1; @@ -209,15 +210,14 @@ message ImportZoneRequest { } message ImportZoneStart { + reserved 3; + reserved "allow_foreign_host"; + // Overrides the zone name recorded in the snapshot. Empty keeps it. string name = 1; // Import under this zone id instead of a freshly allocated one. Empty // allocates a new id, which is what lets one snapshot seed many zones. string zone_id = 2; - // Import a snapshot exported on another host even though it references - // read-only images by host path; those paths must hold the same images here. - // Does not override the CPU or hypervisor compatibility checks. - bool allow_foreign_host = 3; } message ImportZoneReply {