From d08e41b0133432bfc0016d3012b378c416df083f Mon Sep 17 00:00:00 2001 From: Philipp Date: Thu, 11 Jun 2026 11:29:29 +0200 Subject: [PATCH] feat: add vm power actions --- CHANGELOG.md | 1 + README.md | 3 + TODO.md | 8 ++ backend/cmd/api/main.go | 19 ++- backend/go.mod | 14 +- backend/go.sum | 20 +++ backend/internal/queue/asynq.go | 27 ++++ backend/internal/vm/handler.go | 17 ++- backend/internal/vm/power.go | 191 ++++++++++++++++++++++++++ backend/internal/vm/power_test.go | 175 +++++++++++++++++++++++ platform/jobs/proxmox_task.go | 15 ++ platform/proxmox/client.go | 30 ++++ platform/proxmox/client_test.go | 22 +++ worker/internal/tasks/proxmox_poll.go | 15 +- 14 files changed, 541 insertions(+), 16 deletions(-) create mode 100644 backend/internal/queue/asynq.go create mode 100644 backend/internal/vm/power.go create mode 100644 backend/internal/vm/power_test.go create mode 100644 platform/jobs/proxmox_task.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d3185f..72f2fe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- VM-Power-Endpunkte fuer start/stop/reboot mit Proxmox-UPID, Queue-Enqueue und Audit-Write angelegt. - VM-Read-Endpunkte fuer Projekt-Listen und VM-Details mit Membership-gefilterten SQL-Queries angelegt. - Cluster-, Krypto- und Proxmox-Client-Bausteine nach `platform/` verschoben und im Worker fuer echte UPID-Polls verdrahtet. - UPID-Polling-Job fuer Proxmox-Tasks mit VM-Status-Update und Audit-Writer angelegt. diff --git a/README.md b/README.md index af1631c..4770030 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,9 @@ Backend-Endpunkte: - `GET /tenants/{tenantID}/membership`: geschuetzt, synchronisiert `profiles`, prueft Tenant-Mitgliedschaft und gibt Rolle/Tenant zurueck - `GET /projects/{projectID}/vms`: geschuetzt, listet nur VMs aus Projekten, deren Tenant der Nutzer angehoert - `GET /vms/{vmID}`: geschuetzt, gibt VM-Details nur bei Tenant-Mitgliedschaft zurueck +- `POST /vms/{vmID}/start`: geschuetzt, prueft `vm.power`, ruft Proxmox auf und queued UPID-Polling +- `POST /vms/{vmID}/stop`: geschuetzt, prueft `vm.power`, ruft Proxmox auf und queued UPID-Polling +- `POST /vms/{vmID}/reboot`: geschuetzt, prueft `vm.power`, ruft Proxmox auf und queued UPID-Polling - `POST /internal/clusters`: intern, legt Cluster an und speichert Token verschluesselt - `PUT /internal/clusters/{clusterID}`: intern, aktualisiert Cluster und rotiert Token - `PATCH /internal/clusters/{clusterID}/status`: intern, setzt Cluster-Status diff --git a/TODO.md b/TODO.md index c738c9b..bc47606 100644 --- a/TODO.md +++ b/TODO.md @@ -144,6 +144,13 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - [x] SQL-Queries joinen immer ueber `projects` und `memberships` - [x] Nicht-Mitglieder erhalten keine VM-Daten - [x] Handler-Tests fuer sichtbare und unsichtbare VMs angelegt +- [x] E5-T02: Power-Aktionen + - [x] `POST /vms/{vmID}/start` angelegt + - [x] `POST /vms/{vmID}/stop` angelegt + - [x] `POST /vms/{vmID}/reboot` angelegt + - [x] `vm.power` wird anhand der Membership-Rolle geprueft + - [x] Proxmox-Call liefert UPID und queued `proxmox.task.poll` + - [x] Audit-Log-Eintrag fuer ausgeloeste Power-Aktion wird geschrieben ## MVP-Backlog @@ -205,3 +212,4 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - 2026-06-11: UPID-Polling-Job mit VM-Status-Update, Audit-Writer und Mock-Proxmox-Tests angelegt. - 2026-06-11: Shared Cluster-/Krypto-/Proxmox-Packages nach `platform/` verschoben und Worker-UPID-Polling an echte Cluster-Aufloesung angeschlossen. - 2026-06-11: VM-Read-Endpunkte fuer Projekt-Listen und Details mit Membership-gefilterten Queries angelegt. +- 2026-06-11: VM-Power-Endpunkte fuer start/stop/reboot mit Proxmox-UPID, Queue-Enqueue und Audit-Write angelegt. diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index f35349d..24e75f7 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -16,6 +16,7 @@ import ( "forgejo.digital-droplets.de/philschlo/proxui/platform/config" "forgejo.digital-droplets.de/philschlo/proxui/platform/encryption" "forgejo.digital-droplets.de/philschlo/proxui/platform/logging" + "github.com/hibiken/asynq" _ "github.com/jackc/pgx/v5/stdlib" "proxui/backend/internal/auth" @@ -24,6 +25,7 @@ import ( "proxui/backend/internal/membership" "proxui/backend/internal/operator" "proxui/backend/internal/profile" + "proxui/backend/internal/queue" "proxui/backend/internal/rbac" "proxui/backend/internal/vm" ) @@ -61,13 +63,25 @@ func main() { } defer db.Close() + queueClient := asynq.NewClient(asynq.RedisClientOpt{Addr: cfg.RedisAddr}) + defer queueClient.Close() + taskEnqueuer := queue.NewEnqueuer(queueClient) + profileMiddleware := profile.NewMiddleware(profile.NewRepository(db), logger) membershipMiddleware := membership.NewMiddleware(membership.NewRepository(db), logger) authorizationMiddleware := authorization.NewMiddleware() clusterRepository := cluster.NewRepository(db, tokenCipher) clusterAdminHandler := clusteradmin.NewHandler(clusterRepository) operatorMiddleware := operator.NewMiddleware(cfg.OperatorToken) - vmHandler := vm.NewHandler(vm.NewSQLRepository(db)) + vmHandler := vm.NewHandler( + vm.NewSQLRepository(db), + vm.WithPower(vm.PowerDependencies{ + Clusters: clusterRepository, + ClientFactory: vm.DefaultPowerClientFactory, + Tasks: taskEnqueuer, + Audit: vm.NewSQLPowerAuditWriter(db), + }), + ) mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { @@ -104,6 +118,9 @@ func main() { "GET /vms/{vmID}", authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(http.HandlerFunc(vmHandler.GetVM))), ) + mux.Handle("POST /vms/{vmID}/start", authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(http.HandlerFunc(vmHandler.StartVM)))) + mux.Handle("POST /vms/{vmID}/stop", authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(http.HandlerFunc(vmHandler.StopVM)))) + mux.Handle("POST /vms/{vmID}/reboot", authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(http.HandlerFunc(vmHandler.RebootVM)))) clusterManageChain := func(handler http.HandlerFunc) http.Handler { return operatorMiddleware.RequireOperator(authorizationMiddleware.Require(rbac.ActionClusterManage, handler)) } diff --git a/backend/go.mod b/backend/go.mod index 8249ed8..392026d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,13 +2,25 @@ module proxui/backend go 1.22 -require github.com/jackc/pgx/v5 v5.7.6 +require ( + github.com/hibiken/asynq v0.26.0 + github.com/jackc/pgx/v5 v5.7.6 +) require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/redis/go-redis/v9 v9.14.1 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/spf13/cast v1.10.0 // indirect golang.org/x/crypto v0.37.0 // indirect golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.24.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 7fa6af1..82416cb 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,4 +1,12 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw= +github.com/hibiken/asynq v0.26.0/go.mod h1:Qk4e57bTnWDoyJ67VkchuV6VzSM9IQW2nPvAGuDyw58= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -8,6 +16,12 @@ github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8 github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.14.1 h1:nDCrEiJmfOWhD76xlaw+HXT0c9hfNWeXgl0vIRYSDvQ= +github.com/redis/go-redis/v9 v9.14.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -15,7 +29,13 @@ golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/queue/asynq.go b/backend/internal/queue/asynq.go new file mode 100644 index 0000000..2edbbc2 --- /dev/null +++ b/backend/internal/queue/asynq.go @@ -0,0 +1,27 @@ +package queue + +import ( + "context" + "encoding/json" + + "forgejo.digital-droplets.de/philschlo/proxui/platform/jobs" + "github.com/hibiken/asynq" +) + +type Enqueuer struct { + client *asynq.Client +} + +func NewEnqueuer(client *asynq.Client) Enqueuer { + return Enqueuer{client: client} +} + +func (e Enqueuer) EnqueueProxmoxTaskPoll(ctx context.Context, payload jobs.ProxmoxTaskPollPayload) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + + _, err = e.client.EnqueueContext(ctx, asynq.NewTask(jobs.TypeProxmoxTaskPoll, body)) + return err +} diff --git a/backend/internal/vm/handler.go b/backend/internal/vm/handler.go index 2a2934d..c04ad7d 100644 --- a/backend/internal/vm/handler.go +++ b/backend/internal/vm/handler.go @@ -16,10 +16,23 @@ type Repository interface { type Handler struct { repository Repository + power PowerDependencies } -func NewHandler(repository Repository) Handler { - return Handler{repository: repository} +type Option func(*Handler) + +func WithPower(dependencies PowerDependencies) Option { + return func(h *Handler) { + h.power = dependencies + } +} + +func NewHandler(repository Repository, opts ...Option) Handler { + handler := Handler{repository: repository} + for _, opt := range opts { + opt(&handler) + } + return handler } func (h Handler) ListProjectVMs(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/vm/power.go b/backend/internal/vm/power.go new file mode 100644 index 0000000..efdd0af --- /dev/null +++ b/backend/internal/vm/power.go @@ -0,0 +1,191 @@ +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 ClusterRepository interface { + GetCluster(ctx context.Context, id string) (cluster.Cluster, bool, error) +} + +type PowerClient interface { + PowerVM(ctx context.Context, node string, vmid int, action string) (string, error) +} + +type PowerClientFactory func(cluster.Cluster) (PowerClient, error) + +type TaskEnqueuer interface { + EnqueueProxmoxTaskPoll(ctx context.Context, payload jobs.ProxmoxTaskPollPayload) error +} + +type PowerAuditWriter interface { + WriteVMPowerAudit(ctx context.Context, event VMPowerAuditEvent) error +} + +type VMPowerAuditEvent struct { + TenantID string + ProfileID string + Action string + VMID string + ClusterID string + Node string + UPID string +} + +type PowerDependencies struct { + Clusters ClusterRepository + ClientFactory PowerClientFactory + Tasks TaskEnqueuer + Audit PowerAuditWriter +} + +func (h Handler) StartVM(w http.ResponseWriter, r *http.Request) { + h.powerVM(w, r, "start", "running") +} + +func (h Handler) StopVM(w http.ResponseWriter, r *http.Request) { + h.powerVM(w, r, "stop", "stopped") +} + +func (h Handler) RebootVM(w http.ResponseWriter, r *http.Request) { + h.powerVM(w, r, "reboot", "running") +} + +func (h Handler) powerVM(w http.ResponseWriter, r *http.Request, action string, successStatus string) { + if h.power.Clusters == nil || h.power.ClientFactory == nil || h.power.Tasks == nil || h.power.Audit == nil { + writeError(w, http.StatusServiceUnavailable, "vm_power_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.ActionVMPower) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + + cluster, found, err := h.power.Clusters.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 + } + + client, err := h.power.ClientFactory(cluster) + if err != nil { + writeError(w, http.StatusBadGateway, "proxmox_client_failed") + return + } + + upid, err := client.PowerVM(r.Context(), vm.Node, vm.ProxmoxVMID, action) + if err != nil { + writeError(w, http.StatusBadGateway, "proxmox_power_failed") + return + } + + auditAction := "vm.power." + action + if err := h.power.Tasks.EnqueueProxmoxTaskPoll(r.Context(), jobs.ProxmoxTaskPollPayload{ + ClusterID: vm.ClusterID, + Node: vm.Node, + UPID: upid, + TargetType: "vm", + TargetID: vm.ID, + TenantID: vm.TenantID, + ProfileID: principal.Subject, + Action: auditAction, + SuccessStatus: successStatus, + }); err != nil { + writeError(w, http.StatusInternalServerError, "task_enqueue_failed") + return + } + + if err := h.power.Audit.WriteVMPowerAudit(r.Context(), VMPowerAuditEvent{ + TenantID: vm.TenantID, + ProfileID: principal.Subject, + Action: auditAction, + VMID: vm.ID, + ClusterID: vm.ClusterID, + Node: vm.Node, + UPID: upid, + }); err != nil { + writeError(w, http.StatusInternalServerError, "audit_write_failed") + return + } + + writeJSON(w, http.StatusAccepted, map[string]string{ + "upid": upid, + "status": "queued", + }) +} + +func DefaultPowerClientFactory(cluster cluster.Cluster) (PowerClient, error) { + return proxmox.NewClient(cluster) +} + +type SQLPowerAuditWriter struct { + db *sql.DB +} + +func NewSQLPowerAuditWriter(db *sql.DB) SQLPowerAuditWriter { + return SQLPowerAuditWriter{db: db} +} + +func (w SQLPowerAuditWriter) WriteVMPowerAudit(ctx context.Context, event VMPowerAuditEvent) error { + metadata, err := json.Marshal(map[string]any{ + "cluster_id": event.ClusterID, + "node": event.Node, + "upid": event.UPID, + }) + 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 power audit: %w", err) + } + return nil +} diff --git a/backend/internal/vm/power_test.go b/backend/internal/vm/power_test.go new file mode 100644 index 0000000..707d9b7 --- /dev/null +++ b/backend/internal/vm/power_test.go @@ -0,0 +1,175 @@ +package vm + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "forgejo.digital-droplets.de/philschlo/proxui/platform/cluster" + "forgejo.digital-droplets.de/philschlo/proxui/platform/jobs" +) + +func TestStartVMCallsProxmoxEnqueuesPollAndWritesAudit(t *testing.T) { + repository := &stubRepository{ + getFound: true, + vm: VM{ + ID: "vm-1", + TenantID: "tenant-1", + ClusterID: "cluster-1", + ProxmoxVMID: 100, + Node: "pve", + MembershipRole: "member", + }, + } + clusters := &stubClusterRepository{ + found: true, + cluster: cluster.Cluster{ + ID: "cluster-1", + }, + } + powerClient := &stubPowerClient{upid: "UPID:pve:1"} + tasks := &stubTaskEnqueuer{} + audit := &stubPowerAudit{} + handler := NewHandler(repository, WithPower(PowerDependencies{ + Clusters: clusters, + ClientFactory: func(cluster.Cluster) (PowerClient, error) { + return powerClient, nil + }, + Tasks: tasks, + Audit: audit, + })) + + req := requestWithPrincipal(http.MethodPost, "/vms/vm-1/start") + req.SetPathValue("vmID", "vm-1") + rec := httptest.NewRecorder() + + handler.StartVM(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusAccepted) + } + if powerClient.action != "start" { + t.Fatalf("action = %q, want start", powerClient.action) + } + if powerClient.vmid != 100 { + t.Fatalf("vmid = %d, want 100", powerClient.vmid) + } + if tasks.payload.UPID != "UPID:pve:1" { + t.Fatalf("UPID = %q, want UPID:pve:1", tasks.payload.UPID) + } + if tasks.payload.SuccessStatus != "running" { + t.Fatalf("SuccessStatus = %q, want running", tasks.payload.SuccessStatus) + } + if audit.event.Action != "vm.power.start" { + t.Fatalf("audit action = %q, want vm.power.start", audit.event.Action) + } +} + +func TestPowerVMRejectsViewer(t *testing.T) { + handler := NewHandler(&stubRepository{ + getFound: true, + vm: VM{ + ID: "vm-1", + MembershipRole: "viewer", + }, + }, WithPower(validPowerDependencies())) + + req := requestWithPrincipal(http.MethodPost, "/vms/vm-1/start") + req.SetPathValue("vmID", "vm-1") + rec := httptest.NewRecorder() + + handler.StartVM(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden) + } +} + +func TestPowerVMReturnsErrorWhenEnqueueFails(t *testing.T) { + deps := validPowerDependencies() + deps.Tasks = &stubTaskEnqueuer{err: errors.New("redis failed")} + handler := NewHandler(&stubRepository{ + getFound: true, + vm: VM{ + ID: "vm-1", + TenantID: "tenant-1", + ClusterID: "cluster-1", + ProxmoxVMID: 100, + Node: "pve", + MembershipRole: "member", + }, + }, WithPower(deps)) + + req := requestWithPrincipal(http.MethodPost, "/vms/vm-1/start") + req.SetPathValue("vmID", "vm-1") + rec := httptest.NewRecorder() + + handler.StartVM(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError) + } +} + +func validPowerDependencies() PowerDependencies { + return PowerDependencies{ + Clusters: &stubClusterRepository{ + found: true, + cluster: cluster.Cluster{ID: "cluster-1"}, + }, + ClientFactory: func(cluster.Cluster) (PowerClient, error) { + return &stubPowerClient{upid: "UPID:pve:1"}, nil + }, + Tasks: &stubTaskEnqueuer{}, + Audit: &stubPowerAudit{}, + } +} + +type stubClusterRepository struct { + id string + cluster cluster.Cluster + found bool + err error +} + +func (s *stubClusterRepository) GetCluster(_ context.Context, id string) (cluster.Cluster, bool, error) { + s.id = id + return s.cluster, s.found, s.err +} + +type stubPowerClient struct { + node string + vmid int + action string + upid string + err error +} + +func (s *stubPowerClient) PowerVM(_ context.Context, node string, vmid int, action string) (string, error) { + s.node = node + s.vmid = vmid + s.action = action + return s.upid, s.err +} + +type stubTaskEnqueuer struct { + payload jobs.ProxmoxTaskPollPayload + err error +} + +func (s *stubTaskEnqueuer) EnqueueProxmoxTaskPoll(_ context.Context, payload jobs.ProxmoxTaskPollPayload) error { + s.payload = payload + return s.err +} + +type stubPowerAudit struct { + event VMPowerAuditEvent + err error +} + +func (s *stubPowerAudit) WriteVMPowerAudit(_ context.Context, event VMPowerAuditEvent) error { + s.event = event + return s.err +} diff --git a/platform/jobs/proxmox_task.go b/platform/jobs/proxmox_task.go new file mode 100644 index 0000000..dae2414 --- /dev/null +++ b/platform/jobs/proxmox_task.go @@ -0,0 +1,15 @@ +package jobs + +const TypeProxmoxTaskPoll = "proxmox.task.poll" + +type ProxmoxTaskPollPayload struct { + ClusterID string `json:"cluster_id"` + Node string `json:"node"` + UPID string `json:"upid"` + TargetType string `json:"target_type"` + TargetID string `json:"target_id"` + TenantID string `json:"tenant_id"` + ProfileID string `json:"profile_id,omitempty"` + Action string `json:"action"` + SuccessStatus string `json:"success_status"` +} diff --git a/platform/proxmox/client.go b/platform/proxmox/client.go index 0927d8a..cc1870c 100644 --- a/platform/proxmox/client.go +++ b/platform/proxmox/client.go @@ -163,6 +163,36 @@ func (c *Client) GetTaskStatus(ctx context.Context, node string, upid string) (T }, nil } +func (c *Client) PowerVM(ctx context.Context, node string, vmid int, action string) (string, error) { + response, err := c.Post(ctx, fmt.Sprintf( + "/nodes/%s/qemu/%d/status/%s", + url.PathEscape(node), + vmid, + url.PathEscape(action), + ), nil) + 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) + } + + var body struct { + Data string `json:"data"` + } + if err := json.NewDecoder(response.Body).Decode(&body); err != nil { + return "", err + } + if body.Data == "" { + return "", fmt.Errorf("proxmox response missing UPID") + } + + return body.Data, nil +} + func (c *Client) Do(ctx context.Context, method string, path string, body []byte) (*http.Response, error) { var lastErr error attempts := c.retries + 1 diff --git a/platform/proxmox/client_test.go b/platform/proxmox/client_test.go index 5b30437..cbbe8f1 100644 --- a/platform/proxmox/client_test.go +++ b/platform/proxmox/client_test.go @@ -108,6 +108,28 @@ func TestClientGetsTaskStatus(t *testing.T) { } } +func TestClientPowersVM(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/status/start" { + t.Fatalf("path = %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"data":"UPID:pve:1"}`)) + })) + defer server.Close() + + client := newTestClient(t, server, fingerprintForServer(server)) + upid, err := client.PowerVM(context.Background(), "pve", 100, "start") + if err != nil { + t.Fatalf("PowerVM() error = %v", err) + } + if upid != "UPID:pve:1" { + t.Fatalf("UPID = %q, want UPID:pve:1", upid) + } +} + func TestNewClientRejectsInvalidFingerprint(t *testing.T) { _, err := NewClient(cluster.Cluster{ APIEndpoint: "https://pve.example.test:8006/api2/json", diff --git a/worker/internal/tasks/proxmox_poll.go b/worker/internal/tasks/proxmox_poll.go index 1e00487..1504914 100644 --- a/worker/internal/tasks/proxmox_poll.go +++ b/worker/internal/tasks/proxmox_poll.go @@ -9,24 +9,15 @@ import ( "log/slog" "time" + "forgejo.digital-droplets.de/philschlo/proxui/platform/jobs" "github.com/hibiken/asynq" ) -const TypeProxmoxTaskPoll = "proxmox.task.poll" +const TypeProxmoxTaskPoll = jobs.TypeProxmoxTaskPoll var ErrClusterNotFound = errors.New("cluster not found") -type ProxmoxTaskPollPayload struct { - ClusterID string `json:"cluster_id"` - Node string `json:"node"` - UPID string `json:"upid"` - TargetType string `json:"target_type"` - TargetID string `json:"target_id"` - TenantID string `json:"tenant_id"` - ProfileID string `json:"profile_id,omitempty"` - Action string `json:"action"` - SuccessStatus string `json:"success_status"` -} +type ProxmoxTaskPollPayload = jobs.ProxmoxTaskPollPayload type ProxmoxTaskStatus struct { Status string