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")
return
}
if entries == nil {
entries = []Entry{}
}
writeJSON(w, http.StatusOK, map[string]any{
"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) {
handler := NewHandler(&stubRepository{})
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")
return
}
if templates == nil {
templates = []Template{}
}
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) {
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) {
+3
View File
@@ -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})
}
+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) {
handler := NewHandler(&stubRepository{listFound: false})
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 {
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
+74 -8
View File
@@ -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 {
+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) {
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",