diff --git a/internal/daemon/lifecycle_flow_test.go b/internal/daemon/lifecycle_flow_test.go new file mode 100644 index 0000000..82e39e6 --- /dev/null +++ b/internal/daemon/lifecycle_flow_test.go @@ -0,0 +1,143 @@ +package daemon + +import ( + "context" + "errors" + "os" + "testing" + + "banger/internal/api" + "banger/internal/model" +) + +// TestVMCreateNoStartDeleteFlow is the end-to-end lifecycle harness +// test: one test that drives VMService.CreateVM → VMService.DeleteVM +// through the real code path, using newTestDaemon to stand up +// infrastructure. If a future refactor breaks store persistence, +// VM dir creation, or delete-side cleanup for a never-booted VM, +// this test fails. +// +// Scope: everything except the firecracker boot step. CreateVM is +// called with NoStart: true so we skip machine.Start (the upstream +// SDK boundary we can't cross without a real firecracker binary + +// KVM). The flow still exercises image resolution, name/IP +// reservation, VMDir creation, store round-trip, per-VM lock +// lifecycle, handle cache, and the delete-side cleanupRuntime path +// that runs against a never-started VM. +// +// This is the bar for "can we catch a full-lifecycle regression +// without real KVM?" — subsequent harness tests can exercise +// individual error branches (delete while running, create with +// duplicate name, etc.) against the same fixture. +func TestVMCreateNoStartDeleteFlow(t *testing.T) { + d := newTestDaemon(t) + ctx := context.Background() + + // Pre-seed an image record so findOrAutoPullImage finds it + // locally and doesn't try to hit the embedded catalog. + image := testImage("flow-img") + if err := d.store.UpsertImage(ctx, image); err != nil { + t.Fatalf("UpsertImage: %v", err) + } + + // CreateVM with NoStart → reserves name + IP, mkdirs VMDir, + // persists row in state Stopped. Returns the persisted record. + created, err := d.vm.CreateVM(ctx, api.VMCreateParams{ + Name: "flow-vm", + ImageName: image.Name, + NoStart: true, + }) + if err != nil { + t.Fatalf("CreateVM: %v", err) + } + + if created.Name != "flow-vm" { + t.Fatalf("created.Name = %q, want flow-vm", created.Name) + } + if created.ImageID != image.ID { + t.Fatalf("created.ImageID = %q, want %q", created.ImageID, image.ID) + } + if created.State != model.VMStateStopped || created.Runtime.State != model.VMStateStopped { + t.Fatalf("created states = (%q, %q), want both stopped", created.State, created.Runtime.State) + } + if created.Runtime.GuestIP == "" { + t.Fatal("created.Runtime.GuestIP empty — reservation didn't allocate an IP") + } + if created.Runtime.VMDir == "" { + t.Fatal("created.Runtime.VMDir empty — reservation didn't pick a per-VM dir") + } + + // VMDir must exist on disk — reserveVM creates it during the + // reservation window so subsequent lifecycle steps can drop + // handles.json, firecracker.log, etc. inside. + info, err := os.Stat(created.Runtime.VMDir) + if err != nil { + t.Fatalf("VMDir missing after CreateVM: %v", err) + } + if !info.IsDir() { + t.Fatalf("VMDir %q is not a directory", created.Runtime.VMDir) + } + + // Store round-trip: FindVM must return the same record. + found, err := d.vm.FindVM(ctx, created.ID) + if err != nil { + t.Fatalf("FindVM: %v", err) + } + if found.ID != created.ID || found.Name != created.Name { + t.Fatalf("FindVM mismatch: got %+v, created %+v", found, created) + } + + // Duplicate-name rejection: a second CreateVM with the same + // name must fail with a useful error, not persist a second row. + if _, err := d.vm.CreateVM(ctx, api.VMCreateParams{ + Name: "flow-vm", + ImageName: image.Name, + NoStart: true, + }); err == nil { + t.Fatal("second CreateVM with duplicate name succeeded; reserveVM's exact-name check didn't fire") + } + + // DeleteVM against a never-started VM: takes the per-VM lock, + // calls cleanupRuntime (no-op on zero handles), removes the + // store row and the VMDir. Because vmCaps is empty in the + // harness default, capability Cleanup hooks don't fire real + // side effects. + deleted, err := d.vm.DeleteVM(ctx, created.ID) + if err != nil { + t.Fatalf("DeleteVM: %v", err) + } + if deleted.ID != created.ID { + t.Fatalf("DeleteVM returned %+v, want ID %q", deleted, created.ID) + } + + // After delete: store has no record. + if _, err := d.vm.FindVM(ctx, created.ID); err == nil { + t.Fatal("FindVM succeeded after DeleteVM — store row wasn't removed") + } + + // VMDir is gone. + if _, err := os.Stat(created.Runtime.VMDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("VMDir %q still present after DeleteVM (stat err = %v)", created.Runtime.VMDir, err) + } +} + +// TestVMCreateWithUnknownImageFails pins the error branch when the +// requested image isn't local and isn't in the embedded catalog. +// The failure must come before any state mutation — in particular, +// no VM row should linger. +func TestVMCreateWithUnknownImageFails(t *testing.T) { + d := newTestDaemon(t) + ctx := context.Background() + + if _, err := d.vm.CreateVM(ctx, api.VMCreateParams{ + Name: "ghostly", + ImageName: "nothing-called-this-image", + NoStart: true, + }); err == nil { + t.Fatal("CreateVM: want error for unknown image, got nil") + } + + if _, err := d.vm.FindVM(ctx, "ghostly"); err == nil { + t.Fatal("FindVM found a record for a VM that should never have been persisted") + } +}