feat: complete MVP epics E7-E10 (provisioning, console proxy, audit, frontend)

This commit is contained in:
Philipp
2026-06-12 10:45:13 +02:00
parent 137c13fa98
commit ac30a19960
33 changed files with 3625 additions and 263 deletions
+11
View File
@@ -12,11 +12,16 @@ import (
type Repository interface {
ListProjectVMs(ctx context.Context, profileID string, projectID string) ([]VM, bool, error)
GetVM(ctx context.Context, profileID string, vmID string) (VM, bool, error)
GetProjectInfo(ctx context.Context, profileID string, projectID string) (ProjectInfo, bool, error)
CheckQuota(ctx context.Context, projectID string) (QuotaInfo, error)
ReserveNextVMID(ctx context.Context, clusterID string) (int, error)
InsertVM(ctx context.Context, vm InsertVMRecord) (VM, error)
}
type Handler struct {
repository Repository
power PowerDependencies
provision ProvisionDependencies
}
type Option func(*Handler)
@@ -27,6 +32,12 @@ func WithPower(dependencies PowerDependencies) Option {
}
}
func WithProvision(dependencies ProvisionDependencies) Option {
return func(h *Handler) {
h.provision = dependencies
}
}
func NewHandler(repository Repository, opts ...Option) Handler {
handler := Handler{repository: repository}
for _, opt := range opts {
+16
View File
@@ -152,3 +152,19 @@ func (s *stubRepository) GetVM(_ context.Context, profileID string, vmID string)
s.vmID = vmID
return s.vm, s.getFound, s.err
}
func (s *stubRepository) GetProjectInfo(_ context.Context, profileID string, projectID string) (ProjectInfo, bool, error) {
return ProjectInfo{}, false, nil
}
func (s *stubRepository) CheckQuota(_ context.Context, _ string) (QuotaInfo, error) {
return QuotaInfo{}, nil
}
func (s *stubRepository) ReserveNextVMID(_ context.Context, _ string) (int, error) {
return 0, nil
}
func (s *stubRepository) InsertVM(_ context.Context, _ InsertVMRecord) (VM, error) {
return VM{}, nil
}
+431
View File
@@ -0,0 +1,431 @@
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",
})
}
+136
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
)
type SQLRepository struct {
@@ -171,5 +172,140 @@ func (r nullableVMRecord) vm() VM {
UpdatedAt: r.UpdatedAt.Time,
ProjectName: r.ProjectName,
MembershipRole: r.MembershipRole,
}
}
type ProjectInfo struct {
TenantID string
Name string
Role string
}
type QuotaInfo struct {
UsedVMs int
UsedVCPU int
UsedRAMMB int
UsedDiskGB int
MaxVMs int
MaxVCPU int
MaxRAMMB int
MaxDiskGB int
}
func (r SQLRepository) GetProjectInfo(ctx context.Context, profileID string, projectID string) (ProjectInfo, bool, error) {
var info ProjectInfo
err := r.db.QueryRowContext(ctx, `
select
p.tenant_id::text,
p.name,
m.role::text
from public.projects p
join public.memberships m
on m.tenant_id = p.tenant_id
and m.profile_id = $1
where p.id = $2
`, profileID, projectID).Scan(&info.TenantID, &info.Name, &info.Role)
if errors.Is(err, sql.ErrNoRows) {
return ProjectInfo{}, false, nil
}
if err != nil {
return ProjectInfo{}, false, err
}
return info, true, nil
}
func (r SQLRepository) CheckQuota(ctx context.Context, projectID string) (QuotaInfo, error) {
var q QuotaInfo
err := r.db.QueryRowContext(ctx, `
select
coalesce(sum(case when v.status <> 'failed' and v.status <> 'deleting' and v.status <> 'deleted' then 1 else 0 end), 0) as used_vms,
coalesce(sum(case when v.status <> 'failed' and v.status <> 'deleting' and v.status <> 'deleted' then v.vcpu else 0 end), 0) as used_vcpu,
coalesce(sum(case when v.status <> 'failed' and v.status <> 'deleting' and v.status <> 'deleted' then v.ram_mb else 0 end), 0) as used_ram_mb,
coalesce(sum(case when v.status <> 'failed' and v.status <> 'deleting' and v.status <> 'deleted' then v.disk_gb else 0 end), 0) as used_disk_gb,
coalesce(pq.max_vms, 10),
coalesce(pq.max_vcpu, 8),
coalesce(pq.max_ram_mb, 16384),
coalesce(pq.max_disk_gb, 200)
from public.projects p
left join public.vms v on v.project_id = p.id
left join public.project_quotas pq on pq.project_id = p.id
where p.id = $1
group by pq.max_vms, pq.max_vcpu, pq.max_ram_mb, pq.max_disk_gb
`, projectID).Scan(&q.UsedVMs, &q.UsedVCPU, &q.UsedRAMMB, &q.UsedDiskGB, &q.MaxVMs, &q.MaxVCPU, &q.MaxRAMMB, &q.MaxDiskGB)
if errors.Is(err, sql.ErrNoRows) {
q.MaxVMs = 10
q.MaxVCPU = 8
q.MaxRAMMB = 16384
q.MaxDiskGB = 200
return q, nil
}
if err != nil {
return QuotaInfo{}, err
}
return q, nil
}
func (r SQLRepository) ReserveNextVMID(ctx context.Context, clusterID string) (int, error) {
var vmid int
err := r.db.QueryRowContext(ctx, `
select public.reserve_next_vmid($1::uuid)
`, clusterID).Scan(&vmid)
if err != nil {
return 0, fmt.Errorf("reserve vmid: %w", err)
}
return vmid, nil
}
func (r SQLRepository) InsertVM(ctx context.Context, vm InsertVMRecord) (VM, error) {
var result VM
err := r.db.QueryRowContext(ctx, `
insert into public.vms (project_id, cluster_id, proxmox_vmid, node, name, status, vcpu, ram_mb, disk_gb)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
returning
id::text,
project_id::text,
cluster_id::text,
proxmox_vmid,
node,
name,
status::text,
vcpu,
ram_mb,
disk_gb,
created_at,
updated_at
`, vm.ProjectID, vm.ClusterID, vm.ProxmoxVMID, vm.Node, vm.Name, vm.Status, vm.VCPU, vm.RAMMB, vm.DiskGB).Scan(
&result.ID,
&result.ProjectID,
&result.ClusterID,
&result.ProxmoxVMID,
&result.Node,
&result.Name,
&result.Status,
&result.VCPU,
&result.RAMMB,
&result.DiskGB,
&result.CreatedAt,
&result.UpdatedAt,
)
if err != nil {
return VM{}, fmt.Errorf("insert vm: %w", err)
}
return result, nil
}
type InsertVMRecord struct {
ProjectID string
ClusterID string
ProxmoxVMID int
Node string
Name string
Status string
VCPU int
RAMMB int
DiskGB int
}