tested real proxmox connection

--> passed
This commit is contained in:
Philipp
2026-06-12 14:25:34 +02:00
parent 1bfc46f03b
commit 452d1fb8b3
9 changed files with 271 additions and 42 deletions
+3
View File
@@ -125,6 +125,9 @@ func (h Handler) ListTenantAudit(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "audit_list_failed") writeError(w, http.StatusInternalServerError, "audit_list_failed")
return return
} }
if entries == nil {
entries = []Entry{}
}
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"data": entries, "data": entries,
+25
View File
@@ -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) { func TestListTenantAuditReturnsForbiddenForViewer(t *testing.T) {
handler := NewHandler(&stubRepository{}) handler := NewHandler(&stubRepository{})
req := requestWithPrincipalAndMembership(http.MethodGet, "/tenants/tenant-1/audit", "tenant-1", "viewer") req := requestWithPrincipalAndMembership(http.MethodGet, "/tenants/tenant-1/audit", "tenant-1", "viewer")
+3
View File
@@ -28,6 +28,9 @@ func (h Handler) ListTemplates(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "templates_list_failed") writeError(w, http.StatusInternalServerError, "templates_list_failed")
return return
} }
if templates == nil {
templates = []Template{}
}
writeJSON(w, http.StatusOK, map[string][]Template{"data": templates}) writeJSON(w, http.StatusOK, map[string][]Template{"data": templates})
} }
+4 -1
View File
@@ -48,7 +48,7 @@ func TestListTemplatesReturnsTemplates(t *testing.T) {
} }
func TestListTemplatesReturnsEmptyList(t *testing.T) { func TestListTemplatesReturnsEmptyList(t *testing.T) {
handler := NewHandler(&stubRepository{templates: []Template{}}) handler := NewHandler(&stubRepository{})
req := httptest.NewRequest(http.MethodGet, "/internal/templates", nil) req := httptest.NewRequest(http.MethodGet, "/internal/templates", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -57,6 +57,9 @@ func TestListTemplatesReturnsEmptyList(t *testing.T) {
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", 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) { func TestCreateTemplateReturnsCreated(t *testing.T) {
+3
View File
@@ -68,6 +68,9 @@ func (h Handler) ListProjectVMs(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "project_not_found") writeError(w, http.StatusNotFound, "project_not_found")
return return
} }
if vms == nil {
vms = []VM{}
}
writeJSON(w, http.StatusOK, map[string][]VM{"data": vms}) writeJSON(w, http.StatusOK, map[string][]VM{"data": vms})
} }
+16
View File
@@ -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) { func TestListProjectVMsReturnsNotFoundForInaccessibleProject(t *testing.T) {
handler := NewHandler(&stubRepository{listFound: false}) handler := NewHandler(&stubRepository{listFound: false})
req := requestWithPrincipal(http.MethodGet, "/projects/project-1/vms") req := requestWithPrincipal(http.MethodGet, "/projects/project-1/vms")
+41 -26
View File
@@ -27,6 +27,8 @@ type ProvisionClientFactory func(cluster.Cluster) (ProvisionProxmoxClient, error
type ProvisionProxmoxClient interface { type ProvisionProxmoxClient interface {
CloneVM(ctx context.Context, node string, templateVMID int, newVMID int, name string) (string, error) 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) ConfigureCloudInit(ctx context.Context, node string, vmid int, cfg proxmox.CloudInitConfig) (string, error)
StartVM(ctx context.Context, node string, vmid int) (string, error) StartVM(ctx context.Context, node string, vmid int) (string, error)
StopVM(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{ vm, err := h.repository.InsertVM(r.Context(), InsertVMRecord{
ProjectID: projectID, ProjectID: projectID,
ClusterID: template.ClusterID, ClusterID: template.ClusterID,
ProxmoxVMID: vmid, ProxmoxVMID: vmid,
Node: req.Node, Node: req.Node,
Name: req.Name, Name: req.Name,
Status: "provisioning", Status: "provisioning",
VCPU: req.VCPU, VCPU: req.VCPU,
RAMMB: req.RAMMB, RAMMB: req.RAMMB,
DiskGB: req.DiskGB, DiskGB: req.DiskGB,
}) })
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "vm_insert_failed") writeError(w, http.StatusInternalServerError, "vm_insert_failed")
@@ -211,6 +213,19 @@ func (h Handler) CreateVM(w http.ResponseWriter, r *http.Request) {
return 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{ ciCfg := proxmox.CloudInitConfig{
CIUser: req.CIUser, CIUser: req.CIUser,
IPConfig0: req.IPConfig0, IPConfig0: req.IPConfig0,
@@ -273,23 +288,23 @@ func (h Handler) CreateVM(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusAccepted, map[string]any{ writeJSON(w, http.StatusAccepted, map[string]any{
"vm": vm, "vm": vm,
"upid": startUPID, "upid": startUPID,
"clone_upid": cloneUPID, "clone_upid": cloneUPID,
"status": "provisioning", "status": "provisioning",
}) })
} }
type createVMRequest struct { type createVMRequest struct {
Name string `json:"name"` Name string `json:"name"`
TemplateID string `json:"template_id"` TemplateID string `json:"template_id"`
Node string `json:"node"` Node string `json:"node"`
VCPU int `json:"vcpu"` VCPU int `json:"vcpu"`
RAMMB int `json:"ram_mb"` RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"` DiskGB int `json:"disk_gb"`
SSHKeyID string `json:"ssh_key_id"` SSHKeyID string `json:"ssh_key_id"`
CIUser string `json:"ci_user"` CIUser string `json:"ci_user"`
IPConfig0 string `json:"ip_config0"` IPConfig0 string `json:"ip_config0"`
} }
func DefaultProvisionClientFactory(cluster cluster.Cluster) (ProvisionProxmoxClient, error) { 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 { func (w SQLProvisionAuditWriter) WriteVMProvisionAudit(ctx context.Context, event VMProvisionAuditEvent) error {
metadata, err := json.Marshal(map[string]any{ metadata, err := json.Marshal(map[string]any{
"cluster_id": event.ClusterID, "cluster_id": event.ClusterID,
"template_id": event.TemplateID, "template_id": event.TemplateID,
"node": event.Node, "node": event.Node,
"upid": event.UPID, "upid": event.UPID,
"proxmox_vmid": event.ProxmoxVMID, "proxmox_vmid": event.ProxmoxVMID,
}) })
if err != nil { if err != nil {
return err return err
+74 -8
View File
@@ -98,8 +98,9 @@ func NewClient(cluster cluster.Cluster, opts ...Option) (*Client, error) {
Transport: &http.Transport{ Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment, Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{ TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
RootCAs: cfg.rootCAs, RootCAs: cfg.rootCAs,
InsecureSkipVerify: true,
VerifyConnection: func(state tls.ConnectionState) error { VerifyConnection: func(state tls.ConnectionState) error {
return verifyFingerprint(state, fingerprint) 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( response, err := c.Get(ctx, fmt.Sprintf(
"/nodes/%s/tasks/%s/status", "/nodes/%s/tasks/%s/status",
url.PathEscape(node), url.PathEscape(node),
url.PathEscape(upid), upid,
)) ))
if err != nil { if err != nil {
return TaskStatus{}, err return TaskStatus{}, err
@@ -256,6 +257,66 @@ func (c *Client) CloneVM(ctx context.Context, node string, templateVMID int, new
return decodeUPID(response.Body) 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) { func (c *Client) ConfigureCloudInit(ctx context.Context, node string, vmid int, cfg CloudInitConfig) (string, error) {
requestBody := map[string]any{ requestBody := map[string]any{
"ciuser": cfg.CIUser, "ciuser": cfg.CIUser,
@@ -327,6 +388,11 @@ type CloudInitConfig struct {
Hostname string `json:"hostname,omitempty"` Hostname string `json:"hostname,omitempty"`
} }
type HardwareConfig struct {
Cores int
Memory int
}
func decodeUPID(body io.Reader) (string, error) { func decodeUPID(body io.Reader) (string, error) {
var response struct { var response struct {
Data string `json:"data"` Data string `json:"data"`
@@ -364,11 +430,11 @@ func (c *Client) GetVNCTicket(ctx context.Context, node string, vmid int) (VNCIn
var result struct { var result struct {
Data struct { Data struct {
Port int `json:"port"` Port int `json:"port"`
Ticket string `json:"ticket"` Ticket string `json:"ticket"`
User string `json:"user"` User string `json:"user"`
Cert string `json:"cert"` Cert string `json:"cert"`
UPID string `json:"upid"` UPID string `json:"upid"`
} `json:"data"` } `json:"data"`
} }
if err := json.NewDecoder(response.Body).Decode(&result); err != nil { if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
+97 -2
View File
@@ -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) { func TestClientRejectsMismatchedFingerprint(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -86,8 +116,9 @@ func TestClientRetriesServerErrors(t *testing.T) {
} }
func TestClientGetsTaskStatus(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) { 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) t.Fatalf("path = %q", r.URL.Path)
} }
_, _ = w.Write([]byte(`{"data":{"status":"stopped","exitstatus":"OK"}}`)) _, _ = w.Write([]byte(`{"data":{"status":"stopped","exitstatus":"OK"}}`))
@@ -95,7 +126,7 @@ func TestClientGetsTaskStatus(t *testing.T) {
defer server.Close() defer server.Close()
client := newTestClient(t, server, fingerprintForServer(server)) 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 { if err != nil {
t.Fatalf("GetTaskStatus() error = %v", err) 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) { func TestNewClientRejectsInvalidFingerprint(t *testing.T) {
_, err := NewClient(cluster.Cluster{ _, err := NewClient(cluster.Cluster{
APIEndpoint: "https://pve.example.test:8006/api2/json", APIEndpoint: "https://pve.example.test:8006/api2/json",