package vm import ( "context" "database/sql" "encoding/json" "fmt" "net/http" "forgejo.digital-droplets.de/philschlo/proxui/platform/cluster" "forgejo.digital-droplets.de/philschlo/proxui/platform/jobs" "forgejo.digital-droplets.de/philschlo/proxui/platform/proxmox" "proxui/backend/internal/auth" "proxui/backend/internal/rbac" ) type ProvisionClusterRepository interface { GetCluster(ctx context.Context, id string) (cluster.Cluster, bool, error) } type ProvisionTemplateRepository interface { GetTemplate(ctx context.Context, templateID string) (TemplateInfo, bool, error) } type ProvisionClientFactory func(cluster.Cluster) (ProvisionProxmoxClient, error) type ProvisionProxmoxClient interface { CloneVM(ctx context.Context, node string, templateVMID int, newVMID int, name string) (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) DeleteVM(ctx context.Context, node string, vmid int) (string, error) } type ProvisionTaskEnqueuer interface { EnqueueProxmoxTaskPoll(ctx context.Context, payload jobs.ProxmoxTaskPollPayload) error } type ProvisionAuditWriter interface { WriteVMProvisionAudit(ctx context.Context, event VMProvisionAuditEvent) error } type SSHKeyResolver interface { GetPublicKey(ctx context.Context, profileID string, keyID string) (string, bool, error) } type VMProvisionAuditEvent struct { TenantID string ProfileID string Action string VMID string ClusterID string TemplateID string Node string UPID string ProxmoxVMID int } type ProvisionDependencies struct { ClusterRepo ProvisionClusterRepository TemplateRepo ProvisionTemplateRepository ClientFactory ProvisionClientFactory Tasks ProvisionTaskEnqueuer Audit ProvisionAuditWriter SSHKeyLookup SSHKeyResolver } type TemplateInfo struct { ID string ClusterID string Name string ProxmoxTemplateVMID int ProxmoxNode string } func (h Handler) CreateVM(w http.ResponseWriter, r *http.Request) { if h.provision.ClusterRepo == nil || h.provision.TemplateRepo == nil || h.provision.ClientFactory == nil || h.provision.Tasks == nil || h.provision.Audit == nil || h.provision.SSHKeyLookup == nil { writeError(w, http.StatusServiceUnavailable, "vm_provision_not_configured") return } principal, ok := auth.PrincipalFromRequest(r) if !ok { writeError(w, http.StatusUnauthorized, "unauthorized") return } projectID := r.PathValue("projectID") if projectID == "" { writeError(w, http.StatusBadRequest, "project_id_required") return } project, found, err := h.repository.GetProjectInfo(r.Context(), principal.Subject, projectID) if err != nil { writeError(w, http.StatusInternalServerError, "project_get_failed") return } if !found { writeError(w, http.StatusForbidden, "forbidden") return } if !rbac.Can(rbac.Role(project.Role), rbac.ActionVMCreate) { writeError(w, http.StatusForbidden, "forbidden") return } var req createVMRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_body") return } if req.Name == "" { writeError(w, http.StatusBadRequest, "name_required") return } if req.TemplateID == "" { writeError(w, http.StatusBadRequest, "template_id_required") return } if req.Node == "" { writeError(w, http.StatusBadRequest, "node_required") return } if req.VCPU <= 0 { req.VCPU = 2 } if req.RAMMB <= 0 { req.RAMMB = 2048 } if req.DiskGB <= 0 { req.DiskGB = 10 } template, found, err := h.provision.TemplateRepo.GetTemplate(r.Context(), req.TemplateID) if err != nil { writeError(w, http.StatusInternalServerError, "template_get_failed") return } if !found { writeError(w, http.StatusNotFound, "template_not_found") return } quota, err := h.repository.CheckQuota(r.Context(), projectID) if err != nil { writeError(w, http.StatusInternalServerError, "quota_check_failed") return } if quota.UsedVMs >= quota.MaxVMs { writeError(w, http.StatusConflict, "quota_exceeded_vms") return } if quota.UsedVCPU+req.VCPU > quota.MaxVCPU { writeError(w, http.StatusConflict, "quota_exceeded_vcpu") return } if quota.UsedRAMMB+req.RAMMB > quota.MaxRAMMB { writeError(w, http.StatusConflict, "quota_exceeded_ram") return } if quota.UsedDiskGB+req.DiskGB > quota.MaxDiskGB { writeError(w, http.StatusConflict, "quota_exceeded_disk") return } vmid, err := h.repository.ReserveNextVMID(r.Context(), template.ClusterID) if err != nil { writeError(w, http.StatusInternalServerError, "vmid_reserve_failed") return } 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, }) if err != nil { writeError(w, http.StatusInternalServerError, "vm_insert_failed") return } cluster, found, err := h.provision.ClusterRepo.GetCluster(r.Context(), template.ClusterID) if err != nil { writeError(w, http.StatusInternalServerError, "cluster_get_failed") return } if !found { writeError(w, http.StatusBadGateway, "cluster_not_found") return } proxmoxClient, err := h.provision.ClientFactory(cluster) if err != nil { writeError(w, http.StatusBadGateway, "proxmox_client_failed") return } cloneUPID, err := proxmoxClient.CloneVM(r.Context(), req.Node, template.ProxmoxTemplateVMID, vmid, req.Name) if err != nil { writeError(w, http.StatusBadGateway, "proxmox_clone_failed") return } ciCfg := proxmox.CloudInitConfig{ CIUser: req.CIUser, IPConfig0: req.IPConfig0, Hostname: req.Name, } if req.SSHKeyID != "" { sshKey, found, err := h.provision.SSHKeyLookup.GetPublicKey(r.Context(), principal.Subject, req.SSHKeyID) if err != nil { writeError(w, http.StatusInternalServerError, "ssh_key_get_failed") return } if !found { writeError(w, http.StatusBadRequest, "ssh_key_not_found") return } ciCfg.SSHKeys = sshKey } var startUPID string if _, err := proxmoxClient.ConfigureCloudInit(r.Context(), req.Node, vmid, ciCfg); err != nil { writeError(w, http.StatusBadGateway, "proxmox_cloudinit_failed") return } startUPID, err = proxmoxClient.StartVM(r.Context(), req.Node, vmid) if err != nil { writeError(w, http.StatusBadGateway, "proxmox_start_failed") return } auditAction := "vm.provision" if err := h.provision.Tasks.EnqueueProxmoxTaskPoll(r.Context(), jobs.ProxmoxTaskPollPayload{ ClusterID: template.ClusterID, Node: req.Node, UPID: startUPID, TargetType: "vm", TargetID: vm.ID, TenantID: project.TenantID, ProfileID: principal.Subject, Action: auditAction, SuccessStatus: "running", }); err != nil { writeError(w, http.StatusInternalServerError, "task_enqueue_failed") return } if err := h.provision.Audit.WriteVMProvisionAudit(r.Context(), VMProvisionAuditEvent{ TenantID: project.TenantID, ProfileID: principal.Subject, Action: auditAction, VMID: vm.ID, ClusterID: template.ClusterID, TemplateID: template.ID, Node: req.Node, UPID: cloneUPID, ProxmoxVMID: vmid, }); err != nil { writeError(w, http.StatusInternalServerError, "audit_write_failed") return } writeJSON(w, http.StatusAccepted, map[string]any{ "vm": vm, "upid": startUPID, "clone_upid": cloneUPID, "status": "provisioning", }) } type createVMRequest struct { 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"` } func DefaultProvisionClientFactory(cluster cluster.Cluster) (ProvisionProxmoxClient, error) { return proxmox.NewClient(cluster) } type SQLProvisionAuditWriter struct { db *sql.DB } func NewSQLProvisionAuditWriter(db *sql.DB) SQLProvisionAuditWriter { return SQLProvisionAuditWriter{db: db} } 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, }) if err != nil { return err } _, err = w.db.ExecContext(ctx, ` insert into public.audit_log ( tenant_id, profile_id, action, target_type, target_id, metadata ) values ($1, $2, $3, 'vm', $4, $5::jsonb) `, event.TenantID, event.ProfileID, event.Action, event.VMID, string(metadata)) if err != nil { return fmt.Errorf("insert provision audit: %w", err) } return nil } func (h Handler) DeleteVM(w http.ResponseWriter, r *http.Request) { if h.provision.ClusterRepo == nil || h.provision.ClientFactory == nil || h.provision.Tasks == nil || h.provision.Audit == nil { writeError(w, http.StatusServiceUnavailable, "vm_delete_not_configured") return } principal, ok := auth.PrincipalFromRequest(r) if !ok { writeError(w, http.StatusUnauthorized, "unauthorized") return } vmID := r.PathValue("vmID") if vmID == "" { writeError(w, http.StatusBadRequest, "vm_id_required") return } vm, found, err := h.repository.GetVM(r.Context(), principal.Subject, vmID) if err != nil { writeError(w, http.StatusInternalServerError, "vm_get_failed") return } if !found { writeError(w, http.StatusNotFound, "vm_not_found") return } if !rbac.Can(rbac.Role(vm.MembershipRole), rbac.ActionVMDelete) { writeError(w, http.StatusForbidden, "forbidden") return } cluster, found, err := h.provision.ClusterRepo.GetCluster(r.Context(), vm.ClusterID) if err != nil { writeError(w, http.StatusInternalServerError, "cluster_get_failed") return } if !found { writeError(w, http.StatusBadGateway, "cluster_not_found") return } proxmoxClient, err := h.provision.ClientFactory(cluster) if err != nil { writeError(w, http.StatusBadGateway, "proxmox_client_failed") return } _, err = proxmoxClient.StopVM(r.Context(), vm.Node, vm.ProxmoxVMID) if err != nil { writeError(w, http.StatusBadGateway, "proxmox_stop_failed") return } deleteUPID, err := proxmoxClient.DeleteVM(r.Context(), vm.Node, vm.ProxmoxVMID) if err != nil { writeError(w, http.StatusBadGateway, "proxmox_delete_failed") return } auditAction := "vm.delete" if err := h.provision.Tasks.EnqueueProxmoxTaskPoll(r.Context(), jobs.ProxmoxTaskPollPayload{ ClusterID: vm.ClusterID, Node: vm.Node, UPID: deleteUPID, TargetType: "vm", TargetID: vm.ID, TenantID: vm.TenantID, ProfileID: principal.Subject, Action: auditAction, SuccessStatus: "deleted", }); err != nil { writeError(w, http.StatusInternalServerError, "task_enqueue_failed") return } if err := h.provision.Audit.WriteVMProvisionAudit(r.Context(), VMProvisionAuditEvent{ TenantID: vm.TenantID, ProfileID: principal.Subject, Action: auditAction, VMID: vm.ID, ClusterID: vm.ClusterID, Node: vm.Node, UPID: deleteUPID, ProxmoxVMID: vm.ProxmoxVMID, }); err != nil { writeError(w, http.StatusInternalServerError, "audit_write_failed") return } writeJSON(w, http.StatusAccepted, map[string]string{ "upid": deleteUPID, "status": "deleting", }) }