447 lines
12 KiB
Go
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",
|
|
})
|
|
}
|