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")
+15
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)
@@ -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,
+67 -1
View File
@@ -100,6 +100,7 @@ func NewClient(cluster cluster.Cluster, opts ...Option) (*Client, error) {
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"`
+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",