diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bb7062..3d3185f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 929f44d..af1631c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/TODO.md b/TODO.md index f20f3a6..c738c9b 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index b059202..f35349d 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -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)) } diff --git a/backend/internal/vm/handler.go b/backend/internal/vm/handler.go new file mode 100644 index 0000000..2a2934d --- /dev/null +++ b/backend/internal/vm/handler.go @@ -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}) +} diff --git a/backend/internal/vm/handler_test.go b/backend/internal/vm/handler_test.go new file mode 100644 index 0000000..6cc090e --- /dev/null +++ b/backend/internal/vm/handler_test.go @@ -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 +} diff --git a/backend/internal/vm/repository.go b/backend/internal/vm/repository.go new file mode 100644 index 0000000..514888d --- /dev/null +++ b/backend/internal/vm/repository.go @@ -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, + } +}