vm state: split transient kernel/process handles off the durable schema

Separates what a VM IS (durable intent + identity + deterministic
derived paths — `VMRuntime`) from what is CURRENTLY TRUE about it
(firecracker PID, tap device, loop devices, dm-snapshot target — new
`VMHandles`). The durable state lives in the SQLite `vms` row; the
transient state lives in an in-memory cache on the daemon plus a
per-VM `handles.json` scratch file inside VMDir, rebuilt at startup
from OS inspection. Nothing kernel-level rides the SQLite schema
anymore.

Why:

  Persisting ephemeral process handles to SQLite forced reconcile to
  treat "running with a stale PID" as a first-class case and mix it
  with real state transitions. The schema described what we last
  observed, not what the VM is. Every time the observation model
  shifted (tap pool, DM naming, pgrep fallback) the reconcile logic
  grew a new branch. Splitting lets each layer own what it's good at:
  durable records describe intent, in-memory cache + scratch file
  describe momentary reality.

Shape:

  - `model.VMHandles` = PID, TapDevice, BaseLoop, COWLoop, DMName,
    DMDev. Never in SQLite.
  - `VMRuntime` keeps: State, GuestIP, APISockPath, VSockPath,
    VSockCID, LogPath, MetricsPath, DNSName, VMDir, SystemOverlay,
    WorkDiskPath, LastError. All durable or deterministic.
  - `handleCache` on `*Daemon` — mutex-guarded map + scratch-file
    plumbing (`writeHandlesFile` / `readHandlesFile` /
    `rediscoverHandles`). See `internal/daemon/vm_handles.go`.
  - `d.vmAlive(vm)` replaces the 20+ inline
    `vm.State==Running && ProcessRunning(vm.Runtime.PID, apiSock)`
    spreads. Single source of truth for liveness.
  - Startup reconcile: per running VM, load the scratch file, pgrep
    the api sock, either keep (cache seeded from scratch) or demote
    to stopped (scratch handles passed to cleanupRuntime first so DM
    / loops / tap actually get torn down).

Verification:

  - `go test ./...` green.
  - Live: `banger vm run --name handles-test -- cat /etc/hostname`
    starts; `handles.json` appears in VMDir with the expected PID,
    tap, loops, DM.
  - `kill -9 $(pgrep bangerd)` while the VM is running, re-invoke the
    CLI, daemon auto-starts, reconcile recognises the VM as alive,
    `banger vm ssh` still connects, `banger vm delete` cleans up.

Tests added:

  - vm_handles_test.go: scratch-file roundtrip, missing/corrupt file
    behaviour, cache concurrency, rediscoverHandles prefers pgrep
    over scratch, returns scratch contents even when process is
    dead (so cleanup can tear down kernel state).
  - vm_test.go: reconcile test rewritten to exercise the new flow
    (write scratch → reconcile reads it → verifies process is gone →
    issues dmsetup/losetup teardown).

ARCHITECTURE.md updated; `handles` added to Daemon field docs.
This commit is contained in:
Thales Maciel 2026-04-19 14:18:13 -03:00
parent 2e6e64bc04
commit 687fcf0b59
No known key found for this signature in database
GPG key ID: 33112E6833C34679
27 changed files with 688 additions and 152 deletions

View file

@ -81,7 +81,6 @@ func TestExportVMWorkspace_HappyPath(t *testing.T) {
vm := testVM("exportbox", "image-export", "172.16.0.100")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.PID = firecracker.Process.Pid
vm.Runtime.APISockPath = apiSock
patch := []byte("diff --git a/file.go b/file.go\nindex 0000000..1111111 100644\n")
@ -95,6 +94,7 @@ func TestExportVMWorkspace_HappyPath(t *testing.T) {
}
d := newExportTestDaemonStore(t, fake)
upsertDaemonVM(t, ctx, d.store, vm)
d.setVMHandlesInMemory(vm.ID, model.VMHandles{PID: firecracker.Process.Pid})
result, err := d.ExportVMWorkspace(ctx, api.WorkspaceExportParams{
IDOrName: vm.Name,
@ -139,7 +139,6 @@ func TestExportVMWorkspace_WithBaseCommit(t *testing.T) {
vm := testVM("exportbox-base", "image-export", "172.16.0.105")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.PID = firecracker.Process.Pid
vm.Runtime.APISockPath = apiSock
// Simulate: worker committed inside the VM. Without base_commit the diff
@ -156,6 +155,7 @@ func TestExportVMWorkspace_WithBaseCommit(t *testing.T) {
}
d := newExportTestDaemonStore(t, fake)
upsertDaemonVM(t, ctx, d.store, vm)
d.setVMHandlesInMemory(vm.ID, model.VMHandles{PID: firecracker.Process.Pid})
const prepareCommit = "abc1234deadbeef"
result, err := d.ExportVMWorkspace(ctx, api.WorkspaceExportParams{
@ -192,7 +192,6 @@ func TestExportVMWorkspace_BaseCommitFallsBackToHEAD(t *testing.T) {
vm := testVM("exportbox-nobase", "image-export", "172.16.0.106")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.PID = firecracker.Process.Pid
vm.Runtime.APISockPath = apiSock
fake := &exportGuestClient{
@ -203,6 +202,7 @@ func TestExportVMWorkspace_BaseCommitFallsBackToHEAD(t *testing.T) {
}
d := newExportTestDaemonStore(t, fake)
upsertDaemonVM(t, ctx, d.store, vm)
d.setVMHandlesInMemory(vm.ID, model.VMHandles{PID: firecracker.Process.Pid})
result, err := d.ExportVMWorkspace(ctx, api.WorkspaceExportParams{
IDOrName: vm.Name,
@ -231,7 +231,6 @@ func TestExportVMWorkspace_NoChanges(t *testing.T) {
vm := testVM("exportbox-empty", "image-export", "172.16.0.101")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.PID = firecracker.Process.Pid
vm.Runtime.APISockPath = apiSock
// Both scripts return empty output (no changes).
@ -243,6 +242,7 @@ func TestExportVMWorkspace_NoChanges(t *testing.T) {
}
d := newExportTestDaemonStore(t, fake)
upsertDaemonVM(t, ctx, d.store, vm)
d.setVMHandlesInMemory(vm.ID, model.VMHandles{PID: firecracker.Process.Pid})
result, err := d.ExportVMWorkspace(ctx, api.WorkspaceExportParams{
IDOrName: vm.Name,
@ -271,7 +271,6 @@ func TestExportVMWorkspace_DefaultGuestPath(t *testing.T) {
vm := testVM("exportbox-default", "image-export", "172.16.0.102")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.PID = firecracker.Process.Pid
vm.Runtime.APISockPath = apiSock
fake := &exportGuestClient{
@ -282,6 +281,7 @@ func TestExportVMWorkspace_DefaultGuestPath(t *testing.T) {
}
d := newExportTestDaemonStore(t, fake)
upsertDaemonVM(t, ctx, d.store, vm)
d.setVMHandlesInMemory(vm.ID, model.VMHandles{PID: firecracker.Process.Pid})
// GuestPath omitted — should default to /root/repo.
result, err := d.ExportVMWorkspace(ctx, api.WorkspaceExportParams{
@ -305,6 +305,7 @@ func TestExportVMWorkspace_VMNotRunning(t *testing.T) {
fake := &exportGuestClient{}
d := newExportTestDaemonStore(t, fake)
upsertDaemonVM(t, ctx, d.store, vm)
// VM is stopped — no handle seed; vmAlive must return false.
_, err := d.ExportVMWorkspace(ctx, api.WorkspaceExportParams{
IDOrName: vm.Name,
@ -327,7 +328,6 @@ func TestExportVMWorkspace_MultipleChangedFiles(t *testing.T) {
vm := testVM("exportbox-multi", "image-export", "172.16.0.104")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.PID = firecracker.Process.Pid
vm.Runtime.APISockPath = apiSock
patch := []byte("diff --git a/a.go b/a.go\n--- a/a.go\n+++ b/a.go\n")
@ -341,6 +341,7 @@ func TestExportVMWorkspace_MultipleChangedFiles(t *testing.T) {
}
d := newExportTestDaemonStore(t, fake)
upsertDaemonVM(t, ctx, d.store, vm)
d.setVMHandlesInMemory(vm.ID, model.VMHandles{PID: firecracker.Process.Pid})
result, err := d.ExportVMWorkspace(ctx, api.WorkspaceExportParams{
IDOrName: vm.Name,
@ -380,7 +381,6 @@ func TestPrepareVMWorkspace_ReleasesVMLockDuringGuestIO(t *testing.T) {
vm := testVM("lockbox", "image-x", "172.16.0.210")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.PID = firecracker.Process.Pid
vm.Runtime.APISockPath = apiSock
d := &Daemon{
@ -393,6 +393,7 @@ func TestPrepareVMWorkspace_ReleasesVMLockDuringGuestIO(t *testing.T) {
return &exportGuestClient{}, nil
}
upsertDaemonVM(t, ctx, d.store, vm)
d.setVMHandlesInMemory(vm.ID, model.VMHandles{PID: firecracker.Process.Pid})
// Replace the seams. InspectRepo returns a trivial spec so the
// real filesystem isn't touched; Import blocks until we say go.
@ -473,7 +474,6 @@ func TestPrepareVMWorkspace_SerialisesConcurrentPreparesOnSameVM(t *testing.T) {
vm := testVM("serialbox", "image-x", "172.16.0.211")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.PID = firecracker.Process.Pid
vm.Runtime.APISockPath = apiSock
d := &Daemon{
@ -486,6 +486,7 @@ func TestPrepareVMWorkspace_SerialisesConcurrentPreparesOnSameVM(t *testing.T) {
return &exportGuestClient{}, nil
}
upsertDaemonVM(t, ctx, d.store, vm)
d.setVMHandlesInMemory(vm.ID, model.VMHandles{PID: firecracker.Process.Pid})
origInspect := workspaceInspectRepoFunc
origImport := workspaceImportFunc
@ -569,7 +570,6 @@ func TestExportVMWorkspace_DoesNotMutateRealIndex(t *testing.T) {
vm := testVM("exportbox-readonly", "image-export", "172.16.0.107")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.PID = firecracker.Process.Pid
vm.Runtime.APISockPath = apiSock
fake := &exportGuestClient{
@ -580,6 +580,7 @@ func TestExportVMWorkspace_DoesNotMutateRealIndex(t *testing.T) {
}
d := newExportTestDaemonStore(t, fake)
upsertDaemonVM(t, ctx, d.store, vm)
d.setVMHandlesInMemory(vm.ID, model.VMHandles{PID: firecracker.Process.Pid})
if _, err := d.ExportVMWorkspace(ctx, api.WorkspaceExportParams{IDOrName: vm.Name}); err != nil {
t.Fatalf("ExportVMWorkspace: %v", err)