Files
2026-06-12 14:25:34 +02:00

447 lines
12 KiB
Go

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)
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)
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
}
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,
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",
})
}