feat: add vm power actions
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user