Files
metacircular/docs/hypervisor_migration.md
kyleandClaude Fable 5.1 868ade3f5a Add hypervisor design docs and multinode work log; housekeeping
- docs/hypervisor-design.md, docs/hypervisor_migration.md: unikernel
  runtime design and migration plan (previously untracked)
- log/2026-03-29-mcp-multinode.md: work log (previously untracked)
- CLAUDE.md: mcq lives at a sibling path
- engineering-standards.md: document the push target
- .gitignore: mcdoc checkout; local configs that have held credentials

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 15:06:18 -07:00

853 lines
30 KiB
Markdown

# Unikernel Migration Plan
> **Status**: Detailed work plan. Not an active work item. Agents
> should ignore this document unless specifically asked to consider it.
>
> **Prerequisite**: MCP v2 phase 6 complete -- master running, agents
> on all nodes, edge routing, snapshots, and migration all operational.
## Starting Point
The MCP agent already has a `runtime.Runtime` interface
(`mcp/internal/runtime/runtime.go`) with methods for Pull, Run, Stop,
Remove, Inspect, List, Build, Push, ImageExists, and Logs. The only
implementation is `Podman` (`mcp/internal/runtime/podman.go`). The
agent struct holds `Runtime runtime.Runtime` and all lifecycle
operations (deploy, stop, start, undeploy, status) go through this
interface.
This means the runtime abstraction layer is already in place. The
migration is primarily: implement a QEMU/Nanos backend for the
existing interface, add isolated networking, and extend service
definitions with runtime-specific fields.
## Terminology
| Term | Meaning |
|------|---------|
| **VM** | A QEMU/KVM virtual machine running a Nanos unikernel |
| **bridge** | A Linux bridge device (`mcp-br0`) on the host for VM networking |
| **TAP** | A TAP device attached to the bridge, one per VM |
| **9p mount** | QEMU's `-virtfs` passthrough for host directory access |
| **ops** | The Nanos toolchain CLI for building unikernel images |
---
## Phase 1: QEMU Runtime Implementation
**Goal**: A second `runtime.Runtime` implementation that can start and
stop Nanos unikernel VMs with basic networking. No isolation
enforcement yet -- VMs get host-forwarded ports like containers do.
### 1.1 NixOS Host Prerequisites
Add QEMU/KVM packages to the NixOS configuration on rift and orion.
svc (Debian) gets equivalent packages via apt.
Required on all nodes that will run unikernels:
- `qemu` (specifically `qemu-system-x86_64`)
- `ops` CLI (Nanos toolchain) -- install from GitHub release or build
from source
- KVM access: the `mcp` user needs `/dev/kvm` access. On NixOS, add
the user to the `kvm` group. On Debian, same.
- `bridge-utils` or `iproute2` for bridge management (Phase 2)
Verify KVM works: `qemu-system-x86_64 -enable-kvm -nographic
-no-reboot` should boot and exit.
**Deliverable**: All amd64 nodes can run QEMU with KVM acceleration.
RPi nodes (arm64, no KVM) are excluded from unikernel support.
### 1.2 Image Building Pipeline
The agent needs to produce a Nanos `.img` file from a Go binary. Two
paths, implemented in order:
**1.2a -- Local build from OCI image (initial approach)**
The agent already pulls OCI images via `Runtime.Pull()`. For
unikernels:
1. Pull the OCI image from MCR (reuse existing podman pull or use
`skopeo copy` to a local directory).
2. Extract the ELF binary from the image. Convention: the binary is at
`/usr/local/bin/<service>` in the image (same path the Dockerfiles
use).
3. Run `ops build <binary> -c <config.json>` to produce a `.img` file.
4. Store the image at `/srv/mcp/images/<service>-<component>.img`.
The `ops` config JSON specifies:
```json
{
"Args": ["server", "--config", "/srv/mcq/mcq.toml"],
"Dirs": ["srv"],
"Mounts": {
"/srv/<service>": "/srv/<service>"
},
"ManifestPassthrough": {
"mem": "256m",
"smp": 1
}
}
```
**1.2b -- Pre-built unikernel images in MCR (later)**
Store `.img` files as OCI artifacts in MCR with a distinct media type
(`application/vnd.metacircular.unikernel.nanos.v1`). The agent pulls
the artifact and writes it directly to
`/srv/mcp/images/<service>-<component>.img`. This skips the
extract-and-build step and ensures the deployed image is identical to
what was built.
MCR already stores OCI artifacts; this requires adding the media type
to MCR's accepted list and adding an `mcp build --unikernel` command
that builds the image locally and pushes it.
**Deliverable**: Agent can produce a bootable Nanos image from an
existing OCI container image.
### 1.3 QEMU Runtime Type
Implement `QEMURuntime` satisfying `runtime.Runtime`:
```go
type QEMURuntime struct {
imageDir string // /srv/mcp/images/
stateDir string // /srv/mcp/vm-state/
opsPath string // path to ops binary
qemuPath string // path to qemu-system-x86_64
logger *slog.Logger
mu sync.Mutex
vms map[string]*vmState // name → running VM state
}
type vmState struct {
pid int
qmpSocket string // QMP control socket
serial string // serial console log path
ip string // VM IP on bridge (Phase 2)
ports map[int]int // guest port → host port
}
```
**Method mapping:**
| Runtime Method | QEMU Implementation |
|---|---|
| `Pull(image)` | Pull OCI image, extract ELF, run `ops build`, store `.img` |
| `Run(spec)` | Start `qemu-system-x86_64` with KVM, virtio-net, 9p mounts, QMP socket |
| `Stop(name)` | Send `system_powerdown` via QMP, wait 10s, then SIGKILL |
| `Remove(name)` | Kill process if running, remove state files |
| `Inspect(name)` | Check process liveness + read QMP status |
| `List()` | Enumerate `/srv/mcp/vm-state/*/qemu.pid`, check liveness |
| `Build(...)` | Not applicable for unikernels (image built during Pull) |
| `Push(...)` | Not applicable (future: push `.img` to MCR as OCI artifact) |
| `ImageExists(image)` | Check if `.img` file exists in imageDir |
| `Logs(name)` | Read serial console log file |
**QEMU invocation** (Phase 1 -- user-mode networking with port
forwards, no bridge yet):
```
qemu-system-x86_64 \
-enable-kvm \
-m 256 \
-smp 1 \
-nographic \
-serial file:/srv/mcp/vm-state/<name>/console.log \
-qmp unix:/srv/mcp/vm-state/<name>/qmp.sock,server,nowait \
-drive file=/srv/mcp/images/<name>.img,format=raw,if=virtio \
-virtfs local,path=/srv/<service>,mount_tag=srvdata,security_model=mapped-xattr,id=srvdata \
-device virtio-net-pci,netdev=net0 \
-netdev user,id=net0,hostfwd=tcp:127.0.0.1:<host_port>-:<guest_port>
```
This gives user-mode networking with port forwards to localhost --
functionally identical to how rootless podman works. mc-proxy routes
to `127.0.0.1:<host_port>` the same way it does for containers.
**Deliverable**: A `QEMURuntime` that passes the same interface as
`Podman`. Agent can deploy, stop, inspect, and undeploy unikernel
services using QEMU user-mode networking.
### 1.4 Service Definition Changes
Add `runtime` field to service definitions, proto specs, and registry
schema.
**TOML** (`servicedef.go`):
```toml
name = "mcq"
runtime = "unikernel"
tier = "worker"
active = true
[[components]]
name = "mcq"
image = "mcr.svc.mcp.metacircular.net:8443/mcq:v0.4.0"
memory = 256 # MB, required for unikernels
vcpus = 1 # default 1
volumes = ["/srv/mcq:/srv/mcq"]
cmd = ["server", "--config", "/srv/mcq/mcq.toml"]
```
**Proto** (`mcp.proto`):
```protobuf
message ServiceSpec {
string name = 1;
bool active = 2;
repeated ComponentSpec components = 3;
string comment = 4;
string runtime = 5; // "container" (default) or "unikernel"
}
message ComponentSpec {
// ... existing fields ...
int32 memory_mb = 11; // required for unikernel runtime
int32 vcpus = 12; // default 1
}
```
**Registry schema** (new migration):
```sql
ALTER TABLE components ADD COLUMN runtime TEXT NOT NULL DEFAULT 'container';
ALTER TABLE components ADD COLUMN memory_mb INTEGER NOT NULL DEFAULT 0;
ALTER TABLE components ADD COLUMN vcpus INTEGER NOT NULL DEFAULT 1;
```
**Agent runtime selection**: In `agent.go`, the agent holds both
runtimes:
```go
type Agent struct {
// ... existing fields ...
ContainerRuntime runtime.Runtime // podman
UnikernelRuntime runtime.Runtime // qemu (nil if not configured)
}
func (a *Agent) runtimeFor(comp *registry.Component) runtime.Runtime {
if comp.Runtime == "unikernel" {
return a.UnikernelRuntime
}
return a.ContainerRuntime
}
```
All lifecycle operations call `a.runtimeFor(comp)` instead of
`a.Runtime` directly.
**Validation rules**:
- `runtime = "unikernel"` requires `memory_mb > 0`.
- `runtime = "unikernel"` requires the node to have KVM
(`/dev/kvm` exists). Agent rejects deploys on nodes without KVM.
- `runtime = "unikernel"` is incompatible with `exec:` and `cli`
snapshot methods. Validation rejects these combinations.
**Deliverable**: Service definitions can declare `runtime =
"unikernel"`. The agent selects the correct runtime per component.
Container services are completely unaffected.
### 1.5 Resource Tracking
The agent needs to track allocated VM resources to avoid overcommit.
The v2 heartbeat already reports CPU, memory, and disk. Add tracking
of allocated-to-VMs resources:
```go
type ResourceTracker struct {
mu sync.Mutex
totalMemMB int64 // from /proc/meminfo
totalCPUs int32 // from runtime.NumCPU()
allocMemMB int64 // sum of running VM memory_mb
allocCPUs int32 // sum of running VM vcpus
}
func (r *ResourceTracker) CanFit(memMB int64, vcpus int32) bool
func (r *ResourceTracker) Allocate(memMB int64, vcpus int32)
func (r *ResourceTracker) Release(memMB int64, vcpus int32)
```
The master's placement algorithm gains a resource check: before
placing a unikernel service on a node, verify the node has enough
unallocated memory and CPUs. Container services continue to use
container-count placement.
**Deliverable**: Agent tracks VM resource allocation. Master rejects
placements that would overcommit a node.
### 1.6 Phase 1 Validation
Deploy a test service (a minimal Go HTTP server, not a real platform
service) as a unikernel:
1. Build a trivial Go binary that serves HTTP on port 8080.
2. Package it as an OCI image, push to MCR.
3. Write a service definition with `runtime = "unikernel"`.
4. `mcp deploy test-unikernel` -- verify it starts, mc-proxy routes
to it, health checks pass.
5. `mcp undeploy test-unikernel` -- verify clean shutdown.
6. Verify container services are completely unaffected.
**Phase 1 complete when**: A unikernel service can be deployed,
health-checked, and undeployed through the normal `mcp deploy`/
`mcp undeploy` flow, alongside running container services.
---
## Phase 2: Isolated Networking
**Goal**: Replace QEMU user-mode networking with a host-only bridge.
VMs can only communicate through mc-proxy. This is the phase that
delivers the security properties -- without it, unikernels are just
heavier containers.
### 2.1 Bridge Setup
Create a persistent Linux bridge on each unikernel-capable node:
**NixOS** (`networking.bridges` in NixOS config):
```nix
networking.bridges.mcp-br0.interfaces = [];
networking.interfaces.mcp-br0.ipv4.addresses = [{
address = "10.99.0.1";
prefixLength = 24;
}];
```
**Debian** (svc -- if svc ever runs unikernels, which is unlikely
given its edge role, but document for completeness):
```
# /etc/network/interfaces.d/mcp-br0
auto mcp-br0
iface mcp-br0 inet static
address 10.99.0.1/24
bridge_ports none
bridge_stp off
```
The bridge uses the `10.99.0.0/24` subnet. This is a host-only
network -- no default route, no NAT to the internet or Tailnet. VMs
can only reach `10.99.0.1` (the agent/mc-proxy host).
**Deliverable**: Each unikernel-capable node has a `mcp-br0` bridge
with address `10.99.0.1/24`.
### 2.2 TAP Device Management
Each VM gets a TAP device attached to the bridge. The agent creates
and destroys TAP devices as part of the VM lifecycle:
```go
func (q *QEMURuntime) createTAP(name string) (string, error) {
tapName := fmt.Sprintf("tap-%s", name) // max 15 chars for IFNAMSIZ
// ip tuntap add dev <tap> mode tap user mcp
// ip link set <tap> master mcp-br0
// ip link set <tap> up
return tapName, nil
}
func (q *QEMURuntime) destroyTAP(name string) error {
tapName := fmt.Sprintf("tap-%s", name)
// ip link del <tap>
return nil
}
```
TAP creation requires `CAP_NET_ADMIN` or `ip tuntap` permissions for
the `mcp` user. On NixOS, grant this via a udev rule or by running
the agent with ambient capabilities:
```nix
systemd.services.mcp-agent.serviceConfig.AmbientCapabilities = [
"CAP_NET_ADMIN"
];
```
**QEMU invocation changes** (bridge networking replaces user-mode):
```
qemu-system-x86_64 \
... \
-device virtio-net-pci,netdev=net0,mac=52:54:00:xx:xx:xx \
-netdev tap,id=net0,ifname=tap-<name>,script=no,downscript=no
```
Each VM gets a deterministic MAC address derived from the service
name (e.g., SHA-256 of service name, take 5 bytes, prepend `52:54:00`).
### 2.3 VM IP Assignment
VMs need static IPs on the bridge. No DHCP server -- the agent
assigns IPs and passes them to Nanos via the ops config.
```go
type IPAllocator struct {
mu sync.Mutex
subnet net.IPNet // 10.99.0.0/24
gateway net.IP // 10.99.0.1
assigned map[string]net.IP // service name → IP
next byte // next octet to try (2-254)
}
```
The ops config passes networking to Nanos:
```json
{
"RunConfig": {
"IPAddress": "10.99.0.5",
"NetMask": "255.255.255.0",
"Gateway": "10.99.0.1"
}
}
```
Assigned IPs are persisted in the agent's registry:
```sql
ALTER TABLE components ADD COLUMN vm_ip TEXT;
```
**Deliverable**: Each VM gets a static IP on the bridge. The agent
tracks assignments in its registry.
### 2.4 mc-proxy Route Update
With bridge networking, mc-proxy routes change from
`127.0.0.1:<host_port>` to `10.99.0.<n>:<guest_port>`:
- L7 routes: mc-proxy terminates TLS, forwards to
`10.99.0.<n>:<port>` (plaintext on the bridge).
- L4 routes: mc-proxy passes through to `10.99.0.<n>:<port>` (TLS
end-to-end).
The `ProxyRouter.RegisterRoutes()` method needs to use the VM's
bridge IP instead of `127.0.0.1` for unikernel components. Port
allocation changes: unikernel VMs expose their actual service port
on the bridge (no random host port needed), so `host_port` equals
the route's declared port.
### 2.5 Firewall Rules
The bridge must be locked down so VMs can only reach mc-proxy:
```bash
# Allow established connections back to VMs
iptables -A FORWARD -i mcp-br0 -o mcp-br0 -m state --state ESTABLISHED,RELATED -j ACCEPT
# Allow VMs to reach the host (mc-proxy) on the bridge IP
iptables -A INPUT -i mcp-br0 -d 10.99.0.1 -j ACCEPT
# Block VM-to-VM traffic on the bridge
ebtables -A FORWARD -i tap-+ -o tap-+ -j DROP
# Block VMs from reaching anything outside the bridge
iptables -A FORWARD -i mcp-br0 ! -o mcp-br0 -j DROP
```
These rules enforce mandatory mediation: VMs can reach the host
(where mc-proxy listens) but nothing else. No Tailnet, no internet,
no other VMs. All inter-service communication goes through mc-proxy.
On NixOS, these rules go in `networking.firewall` or
`networking.nftables`. On Debian, `/etc/iptables/rules.v4`.
**Deliverable**: VMs are network-isolated. They can only reach
mc-proxy on the host. VM-to-VM and VM-to-Tailnet traffic is blocked.
### 2.6 Phase 2 Validation
1. Deploy the test unikernel from Phase 1 with bridge networking.
2. Verify mc-proxy routes to it via the bridge IP.
3. From inside the VM (via the service's own gRPC or HTTP endpoint),
attempt to reach a Tailnet IP directly -- must fail.
4. Attempt to reach another VM on the bridge -- must fail.
5. Verify the service can reach its dependencies (MCIAS, Metacrypt)
only via mc-proxy on the host.
6. Verify container services are completely unaffected by the bridge.
**Phase 2 complete when**: Unikernel VMs are fully network-isolated
and can only communicate through mc-proxy. The agent enforces this
structurally, not cooperatively.
---
## Phase 3: Snapshots and Observability
**Goal**: Ensure unikernel services participate in the snapshot and
monitoring systems. Adapt debugging tools for the no-shell environment.
### 3.1 Snapshot Adaptation
The default snapshot method (tar `*.toml`, `*.db`, `*.pem` from the
host-side `/srv/<service>/`) works unchanged for unikernels because
the agent tars the host directory, not the VM filesystem. The 9p
passthrough means writes from the VM appear on the host immediately.
The `grpc` snapshot method also works unchanged -- the agent calls the
service's `SnapshotService.Snapshot` RPC over mc-proxy, which reaches
the VM the same way any other gRPC call does.
**What doesn't work**: `cli` and `exec:` methods, because there is
no shell inside the VM. Validation (from Phase 1.4) already rejects
these combinations, but the snapshot scheduler should also log a
warning if it encounters a unikernel service with an incompatible
snapshot method.
**Deliverable**: Snapshots work for unikernel services using the
default or `grpc` methods.
### 3.2 Serial Console Log Collection
QEMU writes serial console output to
`/srv/mcp/vm-state/<name>/console.log`. The `Logs()` method on
`QEMURuntime` reads this file. But the agent's `Logs` gRPC RPC
currently streams from podman/journalctl.
Extend the `Logs` RPC to detect the component's runtime and read from
the serial console log instead:
```go
func (a *Agent) Logs(req *pb.LogsRequest, stream pb.McpAgent_LogsServer) error {
comp := a.registryComponent(req)
if comp.Runtime == "unikernel" {
return a.streamSerialLog(comp, req, stream)
}
return a.streamContainerLog(comp, req, stream)
}
```
For Nanos, configure the Go binary's logging to write to stdout/stderr
(which Nanos routes to the serial console). This is the default Go
behavior, so no changes needed in the services themselves.
**Deliverable**: `mcp logs <service>` works for unikernel services,
streaming the serial console output.
### 3.3 Health Check Adaptation
The v2 health check types (tcp, grpc, http) all work over the network
and don't require shell access. No changes needed -- the agent's
monitoring loop connects to the VM's port via mc-proxy or the bridge
IP the same way it does for containers.
### 3.4 Drift Detection
The agent's `LiveCheck()` currently calls `Runtime.List()` and
reconciles with the registry. The QEMU `List()` implementation
enumerates running VMs by checking PIDs in
`/srv/mcp/vm-state/*/qemu.pid`. This needs to be reliable:
- On agent restart, rebuild the `vms` map from the state directory.
- QEMU processes started with `--daemonize` survive agent restarts.
- The QMP socket reconnects on agent restart.
**Deliverable**: Drift detection works for unikernel VMs. Agent
restart does not lose track of running VMs.
### 3.5 Phase 3 Validation
1. Deploy a unikernel service with `[snapshot] method = "grpc"`.
2. `mcp snapshot <service>` -- verify snapshot succeeds.
3. Verify scheduled snapshots include the unikernel service.
4. `mcp logs <service>` -- verify serial console output streams.
5. Kill the QEMU process manually. Verify drift detection catches it
and reports the service as unhealthy.
6. Restart the agent. Verify it rediscovers running VMs.
---
## Phase 4: Image Attestation
**Goal**: The agent verifies that the image it boots matches what the
operator deployed. The master records expected image hashes.
### 4.1 Image Hashing
After building the `.img` file (Phase 1.2), the agent computes its
SHA-256 hash and stores it in the registry:
```sql
ALTER TABLE components ADD COLUMN image_hash TEXT;
```
Before every VM boot, the agent re-hashes the `.img` file and
compares against the stored value. If they don't match, the deploy
fails with an attestation error. This detects:
- Accidental image corruption.
- Tampering with the image file on disk.
- Stale images from a previous deploy.
### 4.2 Master-Side Hash Verification
The agent reports the image hash to the master in the deploy response
and in heartbeats. The master stores expected hashes in its placements
table:
```sql
ALTER TABLE placements ADD COLUMN image_hash TEXT;
```
On reconciliation, the master compares the agent-reported hash against
its stored value. Mismatches are flagged in `mcp status` output.
### 4.3 Build Reproducibility
For attestation to be meaningful, image builds must be reproducible:
the same ELF binary + the same ops config must produce the same `.img`
hash. Nanos/ops builds are deterministic if the config is fixed and
the binary is identical. Document and test this property.
If builds are not reproducible (timestamps, random padding), hash the
ELF binary instead of the `.img` and accept that the image-level hash
is a weaker check.
### 4.4 Phase 4 Validation
1. Deploy a unikernel service. Note the image hash in `mcp status`.
2. Manually modify the `.img` file on disk.
3. Attempt to restart the service -- must fail with attestation error.
4. Redeploy (rebuilds the image) -- must succeed with a new hash.
5. Verify master reconciliation flags hash mismatches.
---
## Phase 5: Service Migration
**Goal**: Convert real platform services from containers to
unikernels, starting with the lowest-risk services and working toward
core infrastructure.
### 5.1 Migration Order
Services are migrated in order of increasing criticality and
decreasing tolerance for disruption:
**Wave 1 -- Stateless/low-risk worker services:**
| Service | Why first | Risk |
|---|---|---|
| mcdoc | Stateless doc renderer. No database. Public-facing but read-only. Failure means docs are down, not data loss. | Very low |
| mcat | MCIAS policy tester. Internal only. No persistent state. | Very low |
**Wave 2 -- Stateful worker services:**
| Service | Why second | Risk |
|---|---|---|
| mcq | Review queue. SQLite database. Has gRPC snapshot support. Good test of 9p + SQLite under unikernel. | Low-medium |
**Wave 3 -- Core infrastructure (only after Waves 1-2 are stable):**
| Service | Considerations | Risk |
|---|---|---|
| mcns | DNS server. Failure affects all name resolution. Must validate that Nanos's network stack handles DNS UDP correctly. | Medium |
| metacrypt | Seal/unseal lifecycle. Sensitive key material in memory. The reduced TCB is most valuable here. | Medium-high |
| mcr | Container registry. Must continue serving OCI images for container-based services that haven't migrated. | Medium |
| mcias | Root dependency. Every other service authenticates through it. Last to migrate. Must be thoroughly validated. | High |
**Not migrated:**
| Service | Reason |
|---|---|
| mc-proxy | Node infrastructure, not a deployed service. Runs on the host. |
| mcp-agent | Node infrastructure. Must have host access. Unikernel isolation is the opposite of what it needs. |
| mcp-master | Same as agent -- needs full host/network access. |
### 5.2 Per-Service Migration Procedure
For each service:
1. **Validate the binary under Nanos locally.** Before touching the
control plane, run `ops run <binary> -c config.json` on a dev
machine. Verify:
- The service starts and passes health checks.
- SQLite opens in WAL mode (if applicable).
- TLS connections work (Nanos's TLS stack handles the Metacrypt CA
cert).
- 9p-mounted files are readable and writable.
2. **Deploy as unikernel on a worker node alongside the container
version.** Use a different service name (e.g., `mcq-uk`) to run
both versions simultaneously. Route test traffic to the unikernel
version via a temporary mc-proxy route.
3. **Validate under real traffic.**
- Health checks pass consistently.
- gRPC and HTTP endpoints respond correctly.
- Snapshots succeed.
- Logs are readable via `mcp logs`.
- SQLite performance is acceptable under 9p (benchmark IOPS).
4. **Cut over.** Update the real service definition to `runtime =
"unikernel"` and redeploy. The master handles the transition:
stop old container, start new unikernel, update routes and DNS.
5. **Soak.** Run for at least one full snapshot cycle (24h) before
declaring stable. Monitor for:
- Memory growth (unikernels have fixed memory, no swap).
- 9p filesystem performance under sustained writes.
- Clock drift (Nanos uses KVM clock, should be fine).
6. **Remove the container fallback.** Once stable, remove the
parallel container deployment.
### 5.3 Rollback
If a unikernel service fails in production:
1. Change `runtime` back to `"container"` in the service definition.
2. `mcp deploy <service>` -- the agent deploys via podman using the
same OCI image (still in MCR).
3. Routes and DNS update automatically.
Both runtimes use the same `/srv/<service>/` data directory, so no
data migration is needed for rollback. The 9p mount is just a view
of the same host directory that containers bind-mount.
### 5.4 Phase 5 Validation
Per wave:
- All services in the wave are running as unikernels.
- Snapshots complete successfully for all migrated services.
- `mcp status` shows all services healthy.
- Edge routing works for public services (mcdoc, mcq).
- No performance regression in SQLite operations.
- Successful `mcp migrate` of a unikernel service between nodes.
---
## Phase 6: Hardening and Long-Term
**Goal**: Operational maturity. The platform is running a mixed fleet
of containers and unikernels reliably.
### 6.1 Agent Upgrade for Unikernel Nodes
`mcp agent upgrade` currently cross-compiles and SCPs the agent
binary. No changes needed -- the agent is host software, not a
unikernel. Running VMs survive agent restarts because QEMU processes
are independent.
### 6.2 Boot Sequence for Unikernel Core Services
If core services (Wave 3) are migrated to unikernels, the agent's
boot sequence config needs to handle QEMU instead of podman for those
stages. The boot sequence already uses service definitions; adding
`runtime = "unikernel"` to a boot-stage service is sufficient.
**Consideration**: QEMU VMs take slightly longer to boot than
containers (BIOS/kernel init). Adjust stage timeouts if needed.
### 6.3 MCR Unikernel Image Storage (Phase 1.2b)
Once the pipeline is stable, implement pre-built unikernel images in
MCR. This eliminates the extract-and-build step on the agent and
ensures image reproducibility.
Add `mcp build` subcommand:
```
mcp build mcq --unikernel # build .img, push to MCR as OCI artifact
mcp build mcq --container # existing behavior
mcp build mcq --all # both
```
### 6.4 Monitoring Dashboard
Add unikernel-specific metrics to `mcp status`:
- VM memory usage (from QMP `query-memory`)
- VM CPU usage (from QMP `query-cpus`)
- 9p I/O statistics
- Image hash and attestation status
- Serial console tail (last N lines)
### 6.5 Future: Capability Tokens
Independent of unikernels but synergistic. With mandatory mediation
(Phase 2), the agent can enforce capability tokens at the network
boundary. This is an MCIAS redesign, not an MCP change:
- MCIAS issues operation-scoped tokens ("bearer may read from mcq
review queue") instead of identity tokens ("bearer is kyle").
- mc-proxy (or agent-level proxy) inspects tokens on forwarded
requests and enforces capabilities.
- Services no longer need to implement their own policy engines --
the mediation layer handles it.
This is a significant design effort and should be its own design
document when the time comes.
---
## Dependency Graph
```
Phase 1.1 (NixOS/KVM setup)
├── Phase 1.2 (image building)
│ │
│ └── Phase 1.3 (QEMU runtime)
│ │
│ ├── Phase 1.4 (service def changes)
│ │
│ └── Phase 1.5 (resource tracking)
│ │
│ └── Phase 1.6 (validation) ─── PHASE 1 DONE
└── Phase 2.1 (bridge setup)
├── Phase 2.2 (TAP management)
│ │
│ └── Phase 2.3 (IP assignment)
│ │
│ └── Phase 2.4 (mc-proxy routes)
└── Phase 2.5 (firewall) ─── Phase 2.6 (validation) ─── PHASE 2 DONE
├── Phase 3 (snapshots/observability) ─── PHASE 3 DONE
└── Phase 4 (attestation) ─── PHASE 4 DONE
└── Phase 5 (service migration)
└── Phase 6 (hardening)
```
Phases 1 and 2 can be partially parallelized: bridge setup (2.1) only
depends on the NixOS/KVM setup (1.1), not on the QEMU runtime being
complete. However, Phase 2 validation requires Phase 1 to be done.
## Risks and Mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| Nanos doesn't support a Go stdlib feature a service uses | Service won't start | Validate each binary under Nanos before committing to migration (Phase 5.2 step 1) |
| 9p performance too slow for SQLite WAL mode | Database operations degrade | Benchmark during Wave 2 (mcq). Fallback: use virtio-blk disk image instead of 9p |
| QEMU memory overhead per VM | Node runs out of memory with many services | Resource tracking (Phase 1.5) prevents overcommit. Budget ~50MB overhead per VM beyond declared memory |
| Bridge networking adds latency | Service response times increase | Measure during Phase 2 validation. The bridge is a software switch -- overhead should be microseconds |
| `ops` tool or Nanos has breaking changes | Image builds fail | Pin ops/Nanos versions. Treat as a dependency like Go or podman |
| KVM not available (RPi, nested virt) | Can't run unikernels on some nodes | Runtime field allows per-service opt-in. Container remains the default. Nodes without KVM simply don't get unikernel placements |
## Non-Goals
- **Replacing containers entirely.** Containers remain the default
runtime. Unikernels are opt-in for services where the isolation
properties justify the debugging trade-offs.
- **Multi-process unikernels.** Services that need sidecars (none
currently) stay as containers.
- **Custom Nanos kernel builds.** Use stock Nanos. If a service needs
kernel customization, it stays as a container.
- **Internet access from VMs.** VMs communicate only through mc-proxy.
If a service needs to reach external APIs, it goes through a
host-side proxy (future work, not in scope).