feat: add vm read endpoints
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 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.
|
||||
- Worker-Grundgeruest mit asynq, Redis-Anbindung, DB-Ping und Dummy-Job-Handler angelegt.
|
||||
|
||||
@@ -72,6 +72,8 @@ Backend-Endpunkte:
|
||||
- `GET /healthz`: oeffentlicher Healthcheck
|
||||
- `GET /me`: geschuetzt, synchronisiert `profiles` und gibt den authentifizierten Principal aus dem JWT 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 /vms/{vmID}`: geschuetzt, gibt VM-Details nur bei Tenant-Mitgliedschaft zurueck
|
||||
- `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
|
||||
|
||||
@@ -138,6 +138,12 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda
|
||||
- [x] Krypto-Layer nach `platform/encryption` verschoben
|
||||
- [x] Proxmox-Client nach `platform/proxmox` verschoben
|
||||
- [x] Backend und Worker nutzen dieselben Implementierungen
|
||||
- [x] E5-T01: VM-Liste & Detail
|
||||
- [x] `GET /projects/{projectID}/vms` angelegt
|
||||
- [x] `GET /vms/{vmID}` angelegt
|
||||
- [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
|
||||
|
||||
## MVP-Backlog
|
||||
|
||||
@@ -198,3 +204,4 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda
|
||||
- 2026-06-11: Worker-Grundgeruest mit asynq, Redis-Anbindung, DB-Ping und Dummy-Job-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: VM-Read-Endpunkte fuer Projekt-Listen und Details mit Membership-gefilterten Queries angelegt.
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"proxui/backend/internal/operator"
|
||||
"proxui/backend/internal/profile"
|
||||
"proxui/backend/internal/rbac"
|
||||
"proxui/backend/internal/vm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -66,6 +67,7 @@ func main() {
|
||||
clusterRepository := cluster.NewRepository(db, tokenCipher)
|
||||
clusterAdminHandler := clusteradmin.NewHandler(clusterRepository)
|
||||
operatorMiddleware := operator.NewMiddleware(cfg.OperatorToken)
|
||||
vmHandler := vm.NewHandler(vm.NewSQLRepository(db))
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -94,6 +96,14 @@ func main() {
|
||||
),
|
||||
),
|
||||
)
|
||||
mux.Handle(
|
||||
"GET /projects/{projectID}/vms",
|
||||
authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(http.HandlerFunc(vmHandler.ListProjectVMs))),
|
||||
)
|
||||
mux.Handle(
|
||||
"GET /vms/{vmID}",
|
||||
authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(http.HandlerFunc(vmHandler.GetVM))),
|
||||
)
|
||||
clusterManageChain := func(handler http.HandlerFunc) http.Handler {
|
||||
return operatorMiddleware.RequireOperator(authorizationMiddleware.Require(rbac.ActionClusterManage, handler))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"proxui/backend/internal/auth"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
repository Repository
|
||||
}
|
||||
|
||||
func NewHandler(repository Repository) Handler {
|
||||
return Handler{repository: repository}
|
||||
}
|
||||
|
||||
func (h Handler) ListProjectVMs(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
|
||||
vms, found, err := h.repository.ListProjectVMs(r.Context(), principal.Subject, projectID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "vm_list_failed")
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "project_not_found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string][]VM{"data": vms})
|
||||
}
|
||||
|
||||
func (h Handler) GetVM(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
type VM struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
ClusterID string `json:"cluster_id"`
|
||||
ProxmoxVMID int `json:"proxmox_vmid"`
|
||||
Node string `json:"node"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
VCPU int `json:"vcpu"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ProjectName string `json:"project_name"`
|
||||
MembershipRole string `json:"membership_role"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"proxui/backend/internal/auth"
|
||||
)
|
||||
|
||||
func TestListProjectVMsReturnsVisibleVMs(t *testing.T) {
|
||||
repository := &stubRepository{
|
||||
listFound: true,
|
||||
vms: []VM{{
|
||||
ID: "vm-1",
|
||||
ProjectID: "project-1",
|
||||
TenantID: "tenant-1",
|
||||
Name: "web-1",
|
||||
Status: "running",
|
||||
MembershipRole: "viewer",
|
||||
CreatedAt: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
|
||||
}},
|
||||
}
|
||||
handler := NewHandler(repository)
|
||||
req := requestWithPrincipal(http.MethodGet, "/projects/project-1/vms")
|
||||
req.SetPathValue("projectID", "project-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ListProjectVMs(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if repository.profileID != "profile-1" {
|
||||
t.Fatalf("profileID = %q, want profile-1", repository.profileID)
|
||||
}
|
||||
if repository.projectID != "project-1" {
|
||||
t.Fatalf("projectID = %q, want project-1", repository.projectID)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Data []VM `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if len(response.Data) != 1 {
|
||||
t.Fatalf("len(data) = %d, want 1", len(response.Data))
|
||||
}
|
||||
if response.Data[0].ID != "vm-1" {
|
||||
t.Fatalf("VM ID = %q, want vm-1", response.Data[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProjectVMsReturnsNotFoundForInaccessibleProject(t *testing.T) {
|
||||
handler := NewHandler(&stubRepository{listFound: false})
|
||||
req := requestWithPrincipal(http.MethodGet, "/projects/project-1/vms")
|
||||
req.SetPathValue("projectID", "project-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ListProjectVMs(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetVMReturnsVisibleVM(t *testing.T) {
|
||||
repository := &stubRepository{
|
||||
getFound: true,
|
||||
vm: VM{
|
||||
ID: "vm-1",
|
||||
TenantID: "tenant-1",
|
||||
Name: "web-1",
|
||||
Status: "running",
|
||||
},
|
||||
}
|
||||
handler := NewHandler(repository)
|
||||
req := requestWithPrincipal(http.MethodGet, "/vms/vm-1")
|
||||
req.SetPathValue("vmID", "vm-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.GetVM(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if repository.vmID != "vm-1" {
|
||||
t.Fatalf("vmID = %q, want vm-1", repository.vmID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetVMReturnsNotFoundForInvisibleVM(t *testing.T) {
|
||||
handler := NewHandler(&stubRepository{getFound: false})
|
||||
req := requestWithPrincipal(http.MethodGet, "/vms/vm-1")
|
||||
req.SetPathValue("vmID", "vm-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.GetVM(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetVMReturnsServerError(t *testing.T) {
|
||||
handler := NewHandler(&stubRepository{err: errors.New("db failed")})
|
||||
req := requestWithPrincipal(http.MethodGet, "/vms/vm-1")
|
||||
req.SetPathValue("vmID", "vm-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.GetVM(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func requestWithPrincipal(method string, target string) *http.Request {
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
return req.WithContext(auth.ContextWithPrincipal(req.Context(), auth.Principal{
|
||||
Subject: "profile-1",
|
||||
Email: "user@example.test",
|
||||
Role: "authenticated",
|
||||
}))
|
||||
}
|
||||
|
||||
type stubRepository struct {
|
||||
profileID string
|
||||
projectID string
|
||||
vmID string
|
||||
vms []VM
|
||||
vm VM
|
||||
listFound bool
|
||||
getFound bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubRepository) ListProjectVMs(_ context.Context, profileID string, projectID string) ([]VM, bool, error) {
|
||||
s.profileID = profileID
|
||||
s.projectID = projectID
|
||||
return s.vms, s.listFound, s.err
|
||||
}
|
||||
|
||||
func (s *stubRepository) GetVM(_ context.Context, profileID string, vmID string) (VM, bool, error) {
|
||||
s.profileID = profileID
|
||||
s.vmID = vmID
|
||||
return s.vm, s.getFound, s.err
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type SQLRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSQLRepository(db *sql.DB) SQLRepository {
|
||||
return SQLRepository{db: db}
|
||||
}
|
||||
|
||||
func (r SQLRepository) ListProjectVMs(ctx context.Context, profileID string, projectID string) ([]VM, bool, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
select
|
||||
v.id::text,
|
||||
v.project_id::text,
|
||||
p.tenant_id::text,
|
||||
v.cluster_id::text,
|
||||
v.proxmox_vmid,
|
||||
v.node,
|
||||
v.name,
|
||||
v.status::text,
|
||||
v.vcpu,
|
||||
v.ram_mb,
|
||||
v.disk_gb,
|
||||
v.created_at,
|
||||
v.updated_at,
|
||||
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
|
||||
left join public.vms v
|
||||
on v.project_id = p.id
|
||||
where p.id = $2
|
||||
order by v.created_at desc nulls last, v.name asc
|
||||
`, profileID, projectID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var vms []VM
|
||||
foundProject := false
|
||||
for rows.Next() {
|
||||
var record nullableVMRecord
|
||||
if err := rows.Scan(
|
||||
&record.ID,
|
||||
&record.ProjectID,
|
||||
&record.TenantID,
|
||||
&record.ClusterID,
|
||||
&record.ProxmoxVMID,
|
||||
&record.Node,
|
||||
&record.Name,
|
||||
&record.Status,
|
||||
&record.VCPU,
|
||||
&record.RAMMB,
|
||||
&record.DiskGB,
|
||||
&record.CreatedAt,
|
||||
&record.UpdatedAt,
|
||||
&record.ProjectName,
|
||||
&record.MembershipRole,
|
||||
); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
foundProject = true
|
||||
if !record.ID.Valid {
|
||||
continue
|
||||
}
|
||||
vms = append(vms, record.vm())
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return vms, foundProject, nil
|
||||
}
|
||||
|
||||
func (r SQLRepository) GetVM(ctx context.Context, profileID string, vmID string) (VM, bool, error) {
|
||||
var vm VM
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
select
|
||||
v.id::text,
|
||||
v.project_id::text,
|
||||
p.tenant_id::text,
|
||||
v.cluster_id::text,
|
||||
v.proxmox_vmid,
|
||||
v.node,
|
||||
v.name,
|
||||
v.status::text,
|
||||
v.vcpu,
|
||||
v.ram_mb,
|
||||
v.disk_gb,
|
||||
v.created_at,
|
||||
v.updated_at,
|
||||
p.name,
|
||||
m.role::text
|
||||
from public.vms v
|
||||
join public.projects p
|
||||
on p.id = v.project_id
|
||||
join public.memberships m
|
||||
on m.tenant_id = p.tenant_id
|
||||
and m.profile_id = $1
|
||||
where v.id = $2
|
||||
`, profileID, vmID).Scan(
|
||||
&vm.ID,
|
||||
&vm.ProjectID,
|
||||
&vm.TenantID,
|
||||
&vm.ClusterID,
|
||||
&vm.ProxmoxVMID,
|
||||
&vm.Node,
|
||||
&vm.Name,
|
||||
&vm.Status,
|
||||
&vm.VCPU,
|
||||
&vm.RAMMB,
|
||||
&vm.DiskGB,
|
||||
&vm.CreatedAt,
|
||||
&vm.UpdatedAt,
|
||||
&vm.ProjectName,
|
||||
&vm.MembershipRole,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return VM{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return VM{}, false, err
|
||||
}
|
||||
|
||||
return vm, true, nil
|
||||
}
|
||||
|
||||
type nullableVMRecord struct {
|
||||
ID sql.NullString
|
||||
ProjectID sql.NullString
|
||||
TenantID sql.NullString
|
||||
ClusterID sql.NullString
|
||||
ProxmoxVMID sql.NullInt64
|
||||
Node sql.NullString
|
||||
Name sql.NullString
|
||||
Status sql.NullString
|
||||
VCPU sql.NullInt64
|
||||
RAMMB sql.NullInt64
|
||||
DiskGB sql.NullInt64
|
||||
CreatedAt sql.NullTime
|
||||
UpdatedAt sql.NullTime
|
||||
ProjectName string
|
||||
MembershipRole string
|
||||
}
|
||||
|
||||
func (r nullableVMRecord) vm() VM {
|
||||
return VM{
|
||||
ID: r.ID.String,
|
||||
ProjectID: r.ProjectID.String,
|
||||
TenantID: r.TenantID.String,
|
||||
ClusterID: r.ClusterID.String,
|
||||
ProxmoxVMID: int(r.ProxmoxVMID.Int64),
|
||||
Node: r.Node.String,
|
||||
Name: r.Name.String,
|
||||
Status: r.Status.String,
|
||||
VCPU: int(r.VCPU.Int64),
|
||||
RAMMB: int(r.RAMMB.Int64),
|
||||
DiskGB: int(r.DiskGB.Int64),
|
||||
CreatedAt: r.CreatedAt.Time,
|
||||
UpdatedAt: r.UpdatedAt.Time,
|
||||
ProjectName: r.ProjectName,
|
||||
MembershipRole: r.MembershipRole,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user