feat: add vm power actions
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## Unreleased
|
## 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.
|
- 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.
|
- 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.
|
- UPID-Polling-Job fuer Proxmox-Tasks mit VM-Status-Update und Audit-Writer angelegt.
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ Backend-Endpunkte:
|
|||||||
- `GET /tenants/{tenantID}/membership`: geschuetzt, synchronisiert `profiles`, prueft Tenant-Mitgliedschaft und gibt Rolle/Tenant zurueck
|
- `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 /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
|
- `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
|
- `POST /internal/clusters`: intern, legt Cluster an und speichert Token verschluesselt
|
||||||
- `PUT /internal/clusters/{clusterID}`: intern, aktualisiert Cluster und rotiert Token
|
- `PUT /internal/clusters/{clusterID}`: intern, aktualisiert Cluster und rotiert Token
|
||||||
- `PATCH /internal/clusters/{clusterID}/status`: intern, setzt Cluster-Status
|
- `PATCH /internal/clusters/{clusterID}/status`: intern, setzt Cluster-Status
|
||||||
|
|||||||
@@ -144,6 +144,13 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda
|
|||||||
- [x] SQL-Queries joinen immer ueber `projects` und `memberships`
|
- [x] SQL-Queries joinen immer ueber `projects` und `memberships`
|
||||||
- [x] Nicht-Mitglieder erhalten keine VM-Daten
|
- [x] Nicht-Mitglieder erhalten keine VM-Daten
|
||||||
- [x] Handler-Tests fuer sichtbare und unsichtbare VMs angelegt
|
- [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
|
## 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: 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: 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-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.
|
||||||
|
|||||||
+18
-1
@@ -16,6 +16,7 @@ import (
|
|||||||
"forgejo.digital-droplets.de/philschlo/proxui/platform/config"
|
"forgejo.digital-droplets.de/philschlo/proxui/platform/config"
|
||||||
"forgejo.digital-droplets.de/philschlo/proxui/platform/encryption"
|
"forgejo.digital-droplets.de/philschlo/proxui/platform/encryption"
|
||||||
"forgejo.digital-droplets.de/philschlo/proxui/platform/logging"
|
"forgejo.digital-droplets.de/philschlo/proxui/platform/logging"
|
||||||
|
"github.com/hibiken/asynq"
|
||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
|
||||||
"proxui/backend/internal/auth"
|
"proxui/backend/internal/auth"
|
||||||
@@ -24,6 +25,7 @@ import (
|
|||||||
"proxui/backend/internal/membership"
|
"proxui/backend/internal/membership"
|
||||||
"proxui/backend/internal/operator"
|
"proxui/backend/internal/operator"
|
||||||
"proxui/backend/internal/profile"
|
"proxui/backend/internal/profile"
|
||||||
|
"proxui/backend/internal/queue"
|
||||||
"proxui/backend/internal/rbac"
|
"proxui/backend/internal/rbac"
|
||||||
"proxui/backend/internal/vm"
|
"proxui/backend/internal/vm"
|
||||||
)
|
)
|
||||||
@@ -61,13 +63,25 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
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)
|
profileMiddleware := profile.NewMiddleware(profile.NewRepository(db), logger)
|
||||||
membershipMiddleware := membership.NewMiddleware(membership.NewRepository(db), logger)
|
membershipMiddleware := membership.NewMiddleware(membership.NewRepository(db), logger)
|
||||||
authorizationMiddleware := authorization.NewMiddleware()
|
authorizationMiddleware := authorization.NewMiddleware()
|
||||||
clusterRepository := cluster.NewRepository(db, tokenCipher)
|
clusterRepository := cluster.NewRepository(db, tokenCipher)
|
||||||
clusterAdminHandler := clusteradmin.NewHandler(clusterRepository)
|
clusterAdminHandler := clusteradmin.NewHandler(clusterRepository)
|
||||||
operatorMiddleware := operator.NewMiddleware(cfg.OperatorToken)
|
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 := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -104,6 +118,9 @@ func main() {
|
|||||||
"GET /vms/{vmID}",
|
"GET /vms/{vmID}",
|
||||||
authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(http.HandlerFunc(vmHandler.GetVM))),
|
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 {
|
clusterManageChain := func(handler http.HandlerFunc) http.Handler {
|
||||||
return operatorMiddleware.RequireOperator(authorizationMiddleware.Require(rbac.ActionClusterManage, handler))
|
return operatorMiddleware.RequireOperator(authorizationMiddleware.Require(rbac.ActionClusterManage, handler))
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -2,13 +2,25 @@ module proxui/backend
|
|||||||
|
|
||||||
go 1.22
|
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 (
|
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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // 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/crypto v0.37.0 // indirect
|
||||||
golang.org/x/sync v0.13.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/text v0.24.0 // indirect
|
||||||
|
golang.org/x/time v0.14.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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/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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
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/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 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
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/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=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -16,10 +16,23 @@ type Repository interface {
|
|||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
repository Repository
|
repository Repository
|
||||||
|
power PowerDependencies
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(repository Repository) Handler {
|
type Option func(*Handler)
|
||||||
return Handler{repository: repository}
|
|
||||||
|
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) {
|
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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -163,6 +163,36 @@ func (c *Client) GetTaskStatus(ctx context.Context, node string, upid string) (T
|
|||||||
}, nil
|
}, 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) {
|
func (c *Client) Do(ctx context.Context, method string, path string, body []byte) (*http.Response, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
attempts := c.retries + 1
|
attempts := c.retries + 1
|
||||||
|
|||||||
@@ -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) {
|
func TestNewClientRejectsInvalidFingerprint(t *testing.T) {
|
||||||
_, err := NewClient(cluster.Cluster{
|
_, err := NewClient(cluster.Cluster{
|
||||||
APIEndpoint: "https://pve.example.test:8006/api2/json",
|
APIEndpoint: "https://pve.example.test:8006/api2/json",
|
||||||
|
|||||||
@@ -9,24 +9,15 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"forgejo.digital-droplets.de/philschlo/proxui/platform/jobs"
|
||||||
"github.com/hibiken/asynq"
|
"github.com/hibiken/asynq"
|
||||||
)
|
)
|
||||||
|
|
||||||
const TypeProxmoxTaskPoll = "proxmox.task.poll"
|
const TypeProxmoxTaskPoll = jobs.TypeProxmoxTaskPoll
|
||||||
|
|
||||||
var ErrClusterNotFound = errors.New("cluster not found")
|
var ErrClusterNotFound = errors.New("cluster not found")
|
||||||
|
|
||||||
type ProxmoxTaskPollPayload struct {
|
type ProxmoxTaskPollPayload = jobs.ProxmoxTaskPollPayload
|
||||||
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 ProxmoxTaskStatus struct {
|
type ProxmoxTaskStatus struct {
|
||||||
Status string
|
Status string
|
||||||
|
|||||||
Reference in New Issue
Block a user