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") } }