diff --git a/backend/internal/audit/handler.go b/backend/internal/audit/handler.go index 4d130c4..e440139 100644 --- a/backend/internal/audit/handler.go +++ b/backend/internal/audit/handler.go @@ -125,6 +125,9 @@ func (h Handler) ListTenantAudit(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "audit_list_failed") return } + if entries == nil { + entries = []Entry{} + } writeJSON(w, http.StatusOK, map[string]any{ "data": entries, @@ -141,4 +144,4 @@ func writeJSON(w http.ResponseWriter, status int, body any) { func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) -} \ No newline at end of file +} diff --git a/backend/internal/audit/handler_test.go b/backend/internal/audit/handler_test.go index 454ec5d..713bcac 100644 --- a/backend/internal/audit/handler_test.go +++ b/backend/internal/audit/handler_test.go @@ -50,6 +50,31 @@ func TestListTenantAuditReturnsEntries(t *testing.T) { } } +func TestListTenantAuditReturnsEmptyArray(t *testing.T) { + handler := NewHandler(&stubRepository{}) + req := requestWithPrincipalAndMembership(http.MethodGet, "/tenants/tenant-1/audit", "tenant-1", "owner") + req.SetPathValue("tenantID", "tenant-1") + rec := httptest.NewRecorder() + + handler.ListTenantAudit(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var response struct { + Data []Entry `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if response.Data == nil { + t.Fatalf("data = nil, want empty array") + } + if len(response.Data) != 0 { + t.Fatalf("len(data) = %d, want 0", len(response.Data)) + } +} + func TestListTenantAuditReturnsForbiddenForViewer(t *testing.T) { handler := NewHandler(&stubRepository{}) req := requestWithPrincipalAndMembership(http.MethodGet, "/tenants/tenant-1/audit", "tenant-1", "viewer") @@ -84,4 +109,4 @@ type stubRepository struct { func (s *stubRepository) ListTenantAudit(_ context.Context, _ string, _ int, _ int) ([]Entry, error) { return s.entries, s.err -} \ No newline at end of file +} diff --git a/backend/internal/template/handler.go b/backend/internal/template/handler.go index b1a1849..88647c1 100644 --- a/backend/internal/template/handler.go +++ b/backend/internal/template/handler.go @@ -28,6 +28,9 @@ func (h Handler) ListTemplates(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "templates_list_failed") return } + if templates == nil { + templates = []Template{} + } writeJSON(w, http.StatusOK, map[string][]Template{"data": templates}) } @@ -186,4 +189,4 @@ func writeJSON(w http.ResponseWriter, status int, body any) { func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) -} \ No newline at end of file +} diff --git a/backend/internal/template/handler_test.go b/backend/internal/template/handler_test.go index 5027690..58de26e 100644 --- a/backend/internal/template/handler_test.go +++ b/backend/internal/template/handler_test.go @@ -48,7 +48,7 @@ func TestListTemplatesReturnsTemplates(t *testing.T) { } func TestListTemplatesReturnsEmptyList(t *testing.T) { - handler := NewHandler(&stubRepository{templates: []Template{}}) + handler := NewHandler(&stubRepository{}) req := httptest.NewRequest(http.MethodGet, "/internal/templates", nil) rec := httptest.NewRecorder() @@ -57,6 +57,9 @@ func TestListTemplatesReturnsEmptyList(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } + if got := rec.Body.String(); got != "{\"data\":[]}\n" { + t.Fatalf("body = %q, want empty data array", got) + } } func TestCreateTemplateReturnsCreated(t *testing.T) { @@ -205,4 +208,4 @@ func (s *stubRepository) Upsert(_ context.Context, _ Template) (Template, error) func (s *stubRepository) Delete(_ context.Context, _ string) (bool, error) { return s.deleteResult, nil -} \ No newline at end of file +} diff --git a/backend/internal/vm/handler.go b/backend/internal/vm/handler.go index 675aec5..bdbf723 100644 --- a/backend/internal/vm/handler.go +++ b/backend/internal/vm/handler.go @@ -68,6 +68,9 @@ func (h Handler) ListProjectVMs(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "project_not_found") return } + if vms == nil { + vms = []VM{} + } writeJSON(w, http.StatusOK, map[string][]VM{"data": vms}) } diff --git a/backend/internal/vm/handler_test.go b/backend/internal/vm/handler_test.go index f404d8a..aa72282 100644 --- a/backend/internal/vm/handler_test.go +++ b/backend/internal/vm/handler_test.go @@ -57,6 +57,22 @@ func TestListProjectVMsReturnsVisibleVMs(t *testing.T) { } } +func TestListProjectVMsReturnsEmptyArray(t *testing.T) { + handler := NewHandler(&stubRepository{listFound: true}) + req := requestWithPrincipal(http.MethodGet, "/projects/project-1/vms") + req.SetPathValue("projectID", "project-1") + rec := httptest.NewRecorder() + + handler.ListProjectVMs(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if got := rec.Body.String(); got != "{\"data\":[]}\n" { + t.Fatalf("body = %q, want empty data array", got) + } +} + func TestListProjectVMsReturnsNotFoundForInaccessibleProject(t *testing.T) { handler := NewHandler(&stubRepository{listFound: false}) req := requestWithPrincipal(http.MethodGet, "/projects/project-1/vms") diff --git a/backend/internal/vm/provision.go b/backend/internal/vm/provision.go index 928e16c..bc6e629 100644 --- a/backend/internal/vm/provision.go +++ b/backend/internal/vm/provision.go @@ -27,6 +27,8 @@ type ProvisionClientFactory func(cluster.Cluster) (ProvisionProxmoxClient, error type ProvisionProxmoxClient interface { CloneVM(ctx context.Context, node string, templateVMID int, newVMID int, name string) (string, error) + ConfigureHardware(ctx context.Context, node string, vmid int, cfg proxmox.HardwareConfig) (string, error) + ResizeDisk(ctx context.Context, node string, vmid int, disk string, sizeGB int) (string, error) ConfigureCloudInit(ctx context.Context, node string, vmid int, cfg proxmox.CloudInitConfig) (string, error) StartVM(ctx context.Context, node string, vmid int) (string, error) StopVM(ctx context.Context, node string, vmid int) (string, error) @@ -174,15 +176,15 @@ func (h Handler) CreateVM(w http.ResponseWriter, r *http.Request) { } vm, err := h.repository.InsertVM(r.Context(), InsertVMRecord{ - ProjectID: projectID, - ClusterID: template.ClusterID, - ProxmoxVMID: vmid, - Node: req.Node, - Name: req.Name, - Status: "provisioning", - VCPU: req.VCPU, - RAMMB: req.RAMMB, - DiskGB: req.DiskGB, + ProjectID: projectID, + ClusterID: template.ClusterID, + ProxmoxVMID: vmid, + Node: req.Node, + Name: req.Name, + Status: "provisioning", + VCPU: req.VCPU, + RAMMB: req.RAMMB, + DiskGB: req.DiskGB, }) if err != nil { writeError(w, http.StatusInternalServerError, "vm_insert_failed") @@ -211,6 +213,19 @@ func (h Handler) CreateVM(w http.ResponseWriter, r *http.Request) { return } + if _, err := proxmoxClient.ConfigureHardware(r.Context(), req.Node, vmid, proxmox.HardwareConfig{ + Cores: req.VCPU, + Memory: req.RAMMB, + }); err != nil { + writeError(w, http.StatusBadGateway, "proxmox_hardware_failed") + return + } + + if _, err := proxmoxClient.ResizeDisk(r.Context(), req.Node, vmid, "scsi0", req.DiskGB); err != nil { + writeError(w, http.StatusBadGateway, "proxmox_resize_failed") + return + } + ciCfg := proxmox.CloudInitConfig{ CIUser: req.CIUser, IPConfig0: req.IPConfig0, @@ -273,23 +288,23 @@ func (h Handler) CreateVM(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusAccepted, map[string]any{ - "vm": vm, - "upid": startUPID, - "clone_upid": cloneUPID, - "status": "provisioning", + "vm": vm, + "upid": startUPID, + "clone_upid": cloneUPID, + "status": "provisioning", }) } type createVMRequest struct { - Name string `json:"name"` + Name string `json:"name"` TemplateID string `json:"template_id"` - Node string `json:"node"` - VCPU int `json:"vcpu"` - RAMMB int `json:"ram_mb"` - DiskGB int `json:"disk_gb"` - SSHKeyID string `json:"ssh_key_id"` - CIUser string `json:"ci_user"` - IPConfig0 string `json:"ip_config0"` + Node string `json:"node"` + VCPU int `json:"vcpu"` + RAMMB int `json:"ram_mb"` + DiskGB int `json:"disk_gb"` + SSHKeyID string `json:"ssh_key_id"` + CIUser string `json:"ci_user"` + IPConfig0 string `json:"ip_config0"` } func DefaultProvisionClientFactory(cluster cluster.Cluster) (ProvisionProxmoxClient, error) { @@ -306,11 +321,11 @@ func NewSQLProvisionAuditWriter(db *sql.DB) SQLProvisionAuditWriter { func (w SQLProvisionAuditWriter) WriteVMProvisionAudit(ctx context.Context, event VMProvisionAuditEvent) error { metadata, err := json.Marshal(map[string]any{ - "cluster_id": event.ClusterID, - "template_id": event.TemplateID, - "node": event.Node, - "upid": event.UPID, - "proxmox_vmid": event.ProxmoxVMID, + "cluster_id": event.ClusterID, + "template_id": event.TemplateID, + "node": event.Node, + "upid": event.UPID, + "proxmox_vmid": event.ProxmoxVMID, }) if err != nil { return err @@ -428,4 +443,4 @@ func (h Handler) DeleteVM(w http.ResponseWriter, r *http.Request) { "upid": deleteUPID, "status": "deleting", }) -} \ No newline at end of file +} diff --git a/platform/proxmox/client.go b/platform/proxmox/client.go index 3d9c225..6136d31 100644 --- a/platform/proxmox/client.go +++ b/platform/proxmox/client.go @@ -98,8 +98,9 @@ func NewClient(cluster cluster.Cluster, opts ...Option) (*Client, error) { Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, - RootCAs: cfg.rootCAs, + MinVersion: tls.VersionTLS12, + RootCAs: cfg.rootCAs, + InsecureSkipVerify: true, VerifyConnection: func(state tls.ConnectionState) error { return verifyFingerprint(state, fingerprint) }, @@ -139,7 +140,7 @@ func (c *Client) GetTaskStatus(ctx context.Context, node string, upid string) (T response, err := c.Get(ctx, fmt.Sprintf( "/nodes/%s/tasks/%s/status", url.PathEscape(node), - url.PathEscape(upid), + upid, )) if err != nil { return TaskStatus{}, err @@ -256,6 +257,66 @@ func (c *Client) CloneVM(ctx context.Context, node string, templateVMID int, new return decodeUPID(response.Body) } +func (c *Client) ConfigureHardware(ctx context.Context, node string, vmid int, cfg HardwareConfig) (string, error) { + requestBody := map[string]any{} + if cfg.Cores > 0 { + requestBody["cores"] = cfg.Cores + } + if cfg.Memory > 0 { + requestBody["memory"] = cfg.Memory + } + + body, err := json.Marshal(requestBody) + if err != nil { + return "", err + } + + response, err := c.Post(ctx, fmt.Sprintf( + "/nodes/%s/qemu/%d/config", + url.PathEscape(node), + vmid, + ), body) + if err != nil { + return "", err + } + defer response.Body.Close() + + if response.StatusCode >= http.StatusBadRequest { + _, _ = io.Copy(io.Discard, response.Body) + return "", fmt.Errorf("proxmox returned %s", response.Status) + } + + return decodeUPID(response.Body) +} + +func (c *Client) ResizeDisk(ctx context.Context, node string, vmid int, disk string, sizeGB int) (string, error) { + requestBody := map[string]any{ + "disk": disk, + "size": fmt.Sprintf("%dG", sizeGB), + } + body, err := json.Marshal(requestBody) + if err != nil { + return "", err + } + + response, err := c.Put(ctx, fmt.Sprintf( + "/nodes/%s/qemu/%d/resize", + url.PathEscape(node), + vmid, + ), body) + if err != nil { + return "", err + } + defer response.Body.Close() + + if response.StatusCode >= http.StatusBadRequest { + _, _ = io.Copy(io.Discard, response.Body) + return "", fmt.Errorf("proxmox returned %s", response.Status) + } + + return decodeUPID(response.Body) +} + func (c *Client) ConfigureCloudInit(ctx context.Context, node string, vmid int, cfg CloudInitConfig) (string, error) { requestBody := map[string]any{ "ciuser": cfg.CIUser, @@ -327,6 +388,11 @@ type CloudInitConfig struct { Hostname string `json:"hostname,omitempty"` } +type HardwareConfig struct { + Cores int + Memory int +} + func decodeUPID(body io.Reader) (string, error) { var response struct { Data string `json:"data"` @@ -364,11 +430,11 @@ func (c *Client) GetVNCTicket(ctx context.Context, node string, vmid int) (VNCIn var result struct { Data struct { - Port int `json:"port"` - Ticket string `json:"ticket"` - User string `json:"user"` - Cert string `json:"cert"` - UPID string `json:"upid"` + Port int `json:"port"` + Ticket string `json:"ticket"` + User string `json:"user"` + Cert string `json:"cert"` + UPID string `json:"upid"` } `json:"data"` } if err := json.NewDecoder(response.Body).Decode(&result); err != nil { diff --git a/platform/proxmox/client_test.go b/platform/proxmox/client_test.go index 28ce142..100dcda 100644 --- a/platform/proxmox/client_test.go +++ b/platform/proxmox/client_test.go @@ -41,6 +41,36 @@ func TestClientAcceptsMatchingFingerprintAndSendsTokenHeader(t *testing.T) { } } +func TestClientAcceptsPinnedSelfSignedCertificate(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api2/json/version" { + t.Fatalf("path = %q, want /api2/json/version", r.URL.Path) + } + _, _ = w.Write([]byte(`{"data":{"version":"9.2"}}`)) + })) + defer server.Close() + + client, err := NewClient(cluster.Cluster{ + APIEndpoint: server.URL + "/api2/json", + TLSFingerprint: fingerprintForServer(server), + TokenID: "root@pam!proxui", + TokenSecret: "secret-token", + }, WithTimeout(time.Second), WithRetries(0)) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + response, err := client.Get(context.Background(), "/version") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK) + } +} + func TestClientRejectsMismatchedFingerprint(t *testing.T) { server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) @@ -86,8 +116,9 @@ func TestClientRetriesServerErrors(t *testing.T) { } func TestClientGetsTaskStatus(t *testing.T) { + upid := "UPID:pve:1:2:3:qmstart:100:root@pam!ui:" server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api2/json/nodes/pve/tasks/UPID:pve:1/status" { + if r.URL.Path != "/api2/json/nodes/pve/tasks/"+upid+"/status" { t.Fatalf("path = %q", r.URL.Path) } _, _ = w.Write([]byte(`{"data":{"status":"stopped","exitstatus":"OK"}}`)) @@ -95,7 +126,7 @@ func TestClientGetsTaskStatus(t *testing.T) { defer server.Close() client := newTestClient(t, server, fingerprintForServer(server)) - status, err := client.GetTaskStatus(context.Background(), "pve", "UPID:pve:1") + status, err := client.GetTaskStatus(context.Background(), "pve", upid) if err != nil { t.Fatalf("GetTaskStatus() error = %v", err) } @@ -149,6 +180,70 @@ func TestClientGetsVMStatus(t *testing.T) { } } +func TestClientConfiguresHardware(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + if r.URL.Path != "/api2/json/nodes/pve/qemu/100/config" { + t.Fatalf("path = %q", r.URL.Path) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if !strings.Contains(string(body), `"cores":4`) { + t.Fatalf("body = %s, want cores", body) + } + if !strings.Contains(string(body), `"memory":6144`) { + t.Fatalf("body = %s, want memory", body) + } + _, _ = w.Write([]byte(`{"data":"UPID:pve:config"}`)) + })) + defer server.Close() + + client := newTestClient(t, server, fingerprintForServer(server)) + upid, err := client.ConfigureHardware(context.Background(), "pve", 100, HardwareConfig{Cores: 4, Memory: 6144}) + if err != nil { + t.Fatalf("ConfigureHardware() error = %v", err) + } + if upid != "UPID:pve:config" { + t.Fatalf("UPID = %q, want UPID:pve:config", upid) + } +} + +func TestClientResizesDisk(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Fatalf("method = %s, want PUT", r.Method) + } + if r.URL.Path != "/api2/json/nodes/pve/qemu/100/resize" { + t.Fatalf("path = %q", r.URL.Path) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if !strings.Contains(string(body), `"disk":"scsi0"`) { + t.Fatalf("body = %s, want disk", body) + } + if !strings.Contains(string(body), `"size":"30G"`) { + t.Fatalf("body = %s, want size", body) + } + _, _ = w.Write([]byte(`{"data":"UPID:pve:resize"}`)) + })) + defer server.Close() + + client := newTestClient(t, server, fingerprintForServer(server)) + upid, err := client.ResizeDisk(context.Background(), "pve", 100, "scsi0", 30) + if err != nil { + t.Fatalf("ResizeDisk() error = %v", err) + } + if upid != "UPID:pve:resize" { + t.Fatalf("UPID = %q, want UPID:pve:resize", upid) + } +} + func TestNewClientRejectsInvalidFingerprint(t *testing.T) { _, err := NewClient(cluster.Cluster{ APIEndpoint: "https://pve.example.test:8006/api2/json",