package api import ( "bytes" "context" "net/http" "net/http/httptest" "path/filepath" "testing" "time" "github.com/go-chi/chi/v5" "jagacloud/node-agent/pkg/compute/libvirt" "jagacloud/node-agent/pkg/config" "jagacloud/node-agent/pkg/containers/lxc" "jagacloud/node-agent/pkg/containers/podman" "jagacloud/node-agent/pkg/state" "jagacloud/node-agent/pkg/tasks" ) func TestVMCreateAndListUsesStore(t *testing.T) { tmpDir := t.TempDir() store := state.NewStore(filepath.Join(tmpDir, "vm"), filepath.Join(tmpDir, "ct")) cfg := config.Config{ StoragePools: []config.StoragePool{{Name: "local"}}, Bridges: []config.Bridge{{Name: "vmbr0"}}, } t.Setenv("JAGACLOUD_SKIP_BRIDGE_CHECK", "1") svc := Services{ Tasks: tasks.NewRegistry(), Libvirt: &fakeLibvirt{}, LXC: &fakeLXC{}, Podman: &fakePodman{}, Store: store, } go svc.Tasks.StartWorker(testCtx(t)) r := chi.NewRouter() RegisterRoutes(r, cfg, svc) body := []byte(`{"id":"vm-1","name":"vm-1","cpus":1,"memory_mb":512,"disks":[{"name":"root","size_gb":1,"pool":"local"}],"nics":[{"bridge":"vmbr0"}]}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/vms", bytes.NewBuffer(body)) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusAccepted { t.Fatalf("expected 202, got %d body=%s", rec.Code, rec.Body.String()) } // allow worker to process time.Sleep(50 * time.Millisecond) listReq := httptest.NewRequest(http.MethodGet, "/api/v1/vms", nil) listRec := httptest.NewRecorder() r.ServeHTTP(listRec, listReq) if listRec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", listRec.Code) } } func TestCTCreateAndListUsesStore(t *testing.T) { tmpDir := t.TempDir() store := state.NewStore(filepath.Join(tmpDir, "vm"), filepath.Join(tmpDir, "ct")) cfg := config.Config{ StoragePools: []config.StoragePool{{Name: "local"}}, Bridges: []config.Bridge{{Name: "vmbr0"}}, } t.Setenv("JAGACLOUD_SKIP_BRIDGE_CHECK", "1") svc := Services{ Tasks: tasks.NewRegistry(), Libvirt: &fakeLibvirt{}, LXC: &fakeLXC{}, Podman: &fakePodman{}, Store: store, } go svc.Tasks.StartWorker(testCtx(t)) r := chi.NewRouter() RegisterRoutes(r, cfg, svc) body := []byte(`{"id":"ct-1","name":"ct-1","template":"debian","rootfs_pool":"local","rootfs_size_g":1,"nics":[{"bridge":"vmbr0"}],"limits":{"cpus":1,"memory_mb":256}}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/containers", bytes.NewBuffer(body)) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusAccepted { t.Fatalf("expected 202, got %d", rec.Code) } time.Sleep(50 * time.Millisecond) listReq := httptest.NewRequest(http.MethodGet, "/api/v1/containers", nil) listRec := httptest.NewRecorder() r.ServeHTTP(listRec, listReq) if listRec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", listRec.Code) } } func TestVMLifecycleMissingID(t *testing.T) { tmpDir := t.TempDir() store := state.NewStore(filepath.Join(tmpDir, "vm"), filepath.Join(tmpDir, "ct")) cfg := config.Config{} svc := Services{ Tasks: tasks.NewRegistry(), Libvirt: &fakeLibvirt{}, LXC: &fakeLXC{}, Podman: &fakePodman{}, Store: store, } r := chi.NewRouter() RegisterRoutes(r, cfg, svc) req := httptest.NewRequest(http.MethodPost, "/api/v1/vms/missing/start", nil) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", rec.Code) } } func TestVMLifecycleDeleteCleansSpec(t *testing.T) { tmpDir := t.TempDir() store := state.NewStore(filepath.Join(tmpDir, "vm"), filepath.Join(tmpDir, "ct")) cfg := config.Config{ StoragePools: []config.StoragePool{{Name: "local"}}, Bridges: []config.Bridge{{Name: "vmbr0"}}, } t.Setenv("JAGACLOUD_SKIP_BRIDGE_CHECK", "1") svc := Services{ Tasks: tasks.NewRegistry(), Libvirt: &fakeLibvirt{}, LXC: &fakeLXC{}, Podman: &fakePodman{}, Store: store, } go svc.Tasks.StartWorker(testCtx(t)) r := chi.NewRouter() RegisterRoutes(r, cfg, svc) // create spec directly _ = store.SaveVM(libvirt.VMSpec{ID: "vm-del", Name: "vm-del", CPU: 1, MemoryMB: 512, Disks: []libvirt.DiskSpec{{Name: "root", Pool: "local", SizeGB: 1}}}) req := httptest.NewRequest(http.MethodPost, "/api/v1/vms/vm-del/delete", nil) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusAccepted { t.Fatalf("expected 202, got %d", rec.Code) } time.Sleep(50 * time.Millisecond) if _, err := store.LoadVM("vm-del"); err == nil { t.Fatalf("expected spec to be deleted") } } func TestVMLifecycleStartStop(t *testing.T) { tmpDir := t.TempDir() store := state.NewStore(filepath.Join(tmpDir, "vm"), filepath.Join(tmpDir, "ct")) cfg := config.Config{ StoragePools: []config.StoragePool{{Name: "local"}}, Bridges: []config.Bridge{{Name: "vmbr0"}}, } t.Setenv("JAGACLOUD_SKIP_BRIDGE_CHECK", "1") svc := Services{ Tasks: tasks.NewRegistry(), Libvirt: &fakeLibvirt{}, LXC: &fakeLXC{}, Podman: &fakePodman{}, Store: store, } go svc.Tasks.StartWorker(testCtx(t)) r := chi.NewRouter() RegisterRoutes(r, cfg, svc) _ = store.SaveVM(libvirt.VMSpec{ID: "vm-run", Name: "vm-run", CPU: 1, MemoryMB: 512, Disks: []libvirt.DiskSpec{{Name: "root", Pool: "local", SizeGB: 1}}}) for _, action := range []string{"start", "stop"} { req := httptest.NewRequest(http.MethodPost, "/api/v1/vms/vm-run/"+action, nil) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusAccepted { t.Fatalf("expected 202 for %s, got %d", action, rec.Code) } } } func TestCTLifecycleStartStop(t *testing.T) { tmpDir := t.TempDir() store := state.NewStore(filepath.Join(tmpDir, "vm"), filepath.Join(tmpDir, "ct")) cfg := config.Config{ StoragePools: []config.StoragePool{{Name: "local"}}, Bridges: []config.Bridge{{Name: "vmbr0"}}, } t.Setenv("JAGACLOUD_SKIP_BRIDGE_CHECK", "1") svc := Services{ Tasks: tasks.NewRegistry(), Libvirt: &fakeLibvirt{}, LXC: &fakeLXC{}, Podman: &fakePodman{}, Store: store, } go svc.Tasks.StartWorker(testCtx(t)) r := chi.NewRouter() RegisterRoutes(r, cfg, svc) _ = store.SaveCT(lxc.Spec{ID: "ct-run", Name: "ct-run", Template: "debian", RootfsPool: "local", RootfsSizeG: 1, Limits: lxc.Limits{CPU: 1, MemoryMB: 256}}) for _, action := range []string{"start", "stop"} { req := httptest.NewRequest(http.MethodPost, "/api/v1/containers/ct-run/"+action, nil) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusAccepted { t.Fatalf("expected 202 for %s, got %d", action, rec.Code) } } } func TestCTLifecycleDeleteCleansSpec(t *testing.T) { tmpDir := t.TempDir() store := state.NewStore(filepath.Join(tmpDir, "vm"), filepath.Join(tmpDir, "ct")) cfg := config.Config{ StoragePools: []config.StoragePool{{Name: "local"}}, Bridges: []config.Bridge{{Name: "vmbr0"}}, } t.Setenv("JAGACLOUD_SKIP_BRIDGE_CHECK", "1") svc := Services{ Tasks: tasks.NewRegistry(), Libvirt: &fakeLibvirt{}, LXC: &fakeLXC{}, Podman: &fakePodman{}, Store: store, } go svc.Tasks.StartWorker(testCtx(t)) r := chi.NewRouter() RegisterRoutes(r, cfg, svc) _ = store.SaveCT(lxc.Spec{ID: "ct-del", Name: "ct-del", Template: "debian", RootfsPool: "local", RootfsSizeG: 1, Limits: lxc.Limits{CPU: 1, MemoryMB: 256}}) req := httptest.NewRequest(http.MethodPost, "/api/v1/containers/ct-del/delete", nil) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusAccepted { t.Fatalf("expected 202, got %d", rec.Code) } time.Sleep(50 * time.Millisecond) if _, err := store.LoadCT("ct-del"); err == nil { t.Fatalf("expected CT spec to be deleted") } } // testCtx returns a cancellable context for workers. func testCtx(t *testing.T) context.Context { t.Helper() ctx, _ := context.WithCancel(context.Background()) return ctx } type fakeLibvirt struct{} func (f *fakeLibvirt) ListVMs() ([]libvirt.VM, error) { return []libvirt.VM{{ID: "vm-1", Name: "vm-1", Status: "running"}}, nil } func (f *fakeLibvirt) CreateVM(spec libvirt.VMSpec) (libvirt.VM, error) { return libvirt.VM{ID: spec.ID, Name: spec.Name, Status: "running"}, nil } func (f *fakeLibvirt) StartVM(id string) error { return nil } func (f *fakeLibvirt) StopVM(id string) error { return nil } func (f *fakeLibvirt) RebootVM(id string) error { return nil } func (f *fakeLibvirt) DeleteVM(id string) error { return nil } type fakeLXC struct{} func (f *fakeLXC) List() ([]lxc.Container, error) { return []lxc.Container{{ID: "ct-1", Name: "ct-1", Status: "running", Unpriv: true}}, nil } func (f *fakeLXC) Create(spec lxc.Spec) (lxc.Container, error) { return lxc.Container{ID: spec.ID, Name: spec.Name, Status: "stopped", Unpriv: spec.Unprivileged}, nil } func (f *fakeLXC) Start(id string) error { return nil } func (f *fakeLXC) Stop(id string) error { return nil } func (f *fakeLXC) Delete(id string) error { return nil } type fakePodman struct{} func (f *fakePodman) List(ctID string) ([]podman.OCIContainer, error) { return nil, nil } func (f *fakePodman) Create(ctID string, spec podman.CreateSpec) (podman.OCIContainer, error) { return podman.OCIContainer{ID: "oci-1", Image: spec.Image, Status: "created"}, nil } func (f *fakePodman) Start(ctID, cid string) error { return nil } func (f *fakePodman) Stop(ctID, cid string) error { return nil } func (f *fakePodman) Delete(ctID, cid string) error { return nil }