feat: complete MVP epics E7-E10 (provisioning, console proxy, audit, frontend)

This commit is contained in:
Philipp
2026-06-12 10:45:13 +02:00
parent 137c13fa98
commit ac30a19960
33 changed files with 3625 additions and 263 deletions
+144
View File
@@ -0,0 +1,144 @@
package audit
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"time"
"proxui/backend/internal/auth"
"proxui/backend/internal/membership"
"proxui/backend/internal/rbac"
)
type Repository interface {
ListTenantAudit(ctx context.Context, tenantID string, limit int, offset int) ([]Entry, error)
}
type SQLRepository struct {
db *sql.DB
}
func NewSQLRepository(db *sql.DB) SQLRepository {
return SQLRepository{db: db}
}
func (r SQLRepository) ListTenantAudit(ctx context.Context, tenantID string, limit int, offset int) ([]Entry, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
rows, err := r.db.QueryContext(ctx, `
select
id::text,
tenant_id::text,
coalesce(profile_id::text, ''),
action,
target_type,
coalesce(target_id::text, ''),
metadata,
created_at
from public.audit_log
where tenant_id = $1
order by created_at desc
limit $2 offset $3
`, tenantID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []Entry
for rows.Next() {
var e Entry
var metaBytes []byte
if err := rows.Scan(&e.ID, &e.TenantID, &e.ProfileID, &e.Action, &e.TargetType, &e.TargetID, &metaBytes, &e.CreatedAt); err != nil {
return nil, err
}
if metaBytes != nil {
e.Metadata = metaBytes
} else {
e.Metadata = json.RawMessage("{}")
}
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
return entries, nil
}
type Entry struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
ProfileID string `json:"profile_id"`
Action string `json:"action"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
Metadata json.RawMessage `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
}
type Handler struct {
repository Repository
}
func NewHandler(repository Repository) Handler {
return Handler{repository: repository}
}
func (h Handler) ListTenantAudit(w http.ResponseWriter, r *http.Request) {
_, ok := auth.PrincipalFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
tenantID := r.PathValue("tenantID")
if tenantID == "" {
writeError(w, http.StatusBadRequest, "tenant_id_required")
return
}
membershipID, ok := membership.FromRequest(r)
if !ok {
writeError(w, http.StatusForbidden, "forbidden")
return
}
if membershipID.TenantID != tenantID {
writeError(w, http.StatusForbidden, "forbidden")
return
}
if !rbac.Can(rbac.Role(membershipID.Role), rbac.ActionAuditRead) {
writeError(w, http.StatusForbidden, "forbidden")
return
}
limit := 50
offset := 0
entries, err := h.repository.ListTenantAudit(r.Context(), tenantID, limit, offset)
if err != nil {
writeError(w, http.StatusInternalServerError, "audit_list_failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"data": entries,
"limit": limit,
"offset": offset,
})
}
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})
}
+87
View File
@@ -0,0 +1,87 @@
package audit
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"proxui/backend/internal/auth"
"proxui/backend/internal/membership"
"proxui/backend/internal/rbac"
)
func TestListTenantAuditReturnsEntries(t *testing.T) {
repository := &stubRepository{
entries: []Entry{{
ID: "audit-1",
TenantID: "tenant-1",
ProfileID: "profile-1",
Action: "vm.power.start",
TargetType: "vm",
TargetID: "vm-1",
Metadata: json.RawMessage(`{"cluster_id":"c-1"}`),
CreatedAt: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
}},
}
handler := NewHandler(repository)
req := requestWithPrincipalAndMembership(http.MethodGet, "/tenants/tenant-1/audit", "tenant-1", "owner")
req.SetPathValue("tenantID", "tenant-1")
rec := httptest.NewRecorder()
handler.ListTenantAudit(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var response struct {
Data []Entry `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 != "audit-1" {
t.Fatalf("entry ID = %q, want audit-1", response.Data[0].ID)
}
}
func TestListTenantAuditReturnsForbiddenForViewer(t *testing.T) {
handler := NewHandler(&stubRepository{})
req := requestWithPrincipalAndMembership(http.MethodGet, "/tenants/tenant-1/audit", "tenant-1", "viewer")
req.SetPathValue("tenantID", "tenant-1")
rec := httptest.NewRecorder()
handler.ListTenantAudit(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
}
func requestWithPrincipalAndMembership(method string, target string, tenantID string, role string) *http.Request {
req := httptest.NewRequest(method, target, nil)
ctx := auth.ContextWithPrincipal(req.Context(), auth.Principal{
Subject: "profile-1",
Email: "user@example.test",
Role: "authenticated",
})
ctx = membership.ContextWithMembership(ctx, membership.Membership{
TenantID: tenantID,
Role: rbac.Role(role),
})
return req.WithContext(ctx)
}
type stubRepository struct {
entries []Entry
err error
}
func (s *stubRepository) ListTenantAudit(_ context.Context, _ string, _ int, _ int) ([]Entry, error) {
return s.entries, s.err
}
+209
View File
@@ -0,0 +1,209 @@
package console
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"time"
"forgejo.digital-droplets.de/philschlo/proxui/platform/cluster"
"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 VMRepository interface {
GetVM(ctx context.Context, profileID string, vmID string) (VMInfo, bool, error)
}
type VMInfo struct {
ID string
TenantID string
ClusterID string
ProxmoxVMID int
Node string
MembershipRole string
}
type ProxyClientFactory func(cluster.Cluster) (ProxyClient, error)
type ProxyClient interface {
GetVNCTicket(ctx context.Context, node string, vmid int) (proxmox.VNCInfo, error)
}
type Handler struct {
clusters ClusterRepository
vmRepo VMRepository
clientFactory ProxyClientFactory
signingKey []byte
}
func NewHandler(clusters ClusterRepository, vmRepo VMRepository, clientFactory ProxyClientFactory, signingKey string) Handler {
return Handler{
clusters: clusters,
vmRepo: vmRepo,
clientFactory: clientFactory,
signingKey: []byte(signingKey),
}
}
func (h Handler) CreateConsoleTicket(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.vmRepo.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.ActionVMConsole) {
writeError(w, http.StatusForbidden, "forbidden")
return
}
cluster, found, err := h.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.clientFactory(cluster)
if err != nil {
writeError(w, http.StatusBadGateway, "proxmox_client_failed")
return
}
vncInfo, err := client.GetVNCTicket(r.Context(), vm.Node, vm.ProxmoxVMID)
if err != nil {
writeError(w, http.StatusBadGateway, "proxmox_vnc_ticket_failed")
return
}
proxyTicket, err := h.signProxyTicket(proxyTicketPayload{
ClusterID: vm.ClusterID,
Node: vm.Node,
VMID: vm.ProxmoxVMID,
TenantID: vm.TenantID,
Endpoint: cluster.APIEndpoint,
VNC: vncTicket{VNCInfo: vncInfo},
ExpiresAt: time.Now().Add(15 * time.Minute),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "ticket_sign_failed")
return
}
writeJSON(w, http.StatusOK, consoleResponse{
Ticket: proxyTicket,
})
}
type vncTicket struct {
proxmox.VNCInfo
}
type proxyTicketPayload struct {
ClusterID string `json:"cluster_id"`
Node string `json:"node"`
VMID int `json:"vmid"`
TenantID string `json:"tenant_id"`
Endpoint string `json:"endpoint"`
VNC vncTicket `json:"vnc"`
ExpiresAt time.Time `json:"expires_at"`
Signature string `json:"signature"`
}
func (h Handler) signProxyTicket(payload proxyTicketPayload) (string, error) {
payload.Signature = ""
data, err := json.Marshal(payload)
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, h.signingKey)
mac.Write(data)
payload.Signature = base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
result, err := json.Marshal(payload)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(result), nil
}
func VerifyProxyTicket(ticket string, signingKey []byte) (proxyTicketPayload, error) {
data, err := base64.RawURLEncoding.DecodeString(ticket)
if err != nil {
return proxyTicketPayload{}, fmt.Errorf("invalid ticket encoding")
}
var payload proxyTicketPayload
if err := json.Unmarshal(data, &payload); err != nil {
return proxyTicketPayload{}, fmt.Errorf("invalid ticket payload")
}
if time.Now().After(payload.ExpiresAt) {
return proxyTicketPayload{}, fmt.Errorf("ticket expired")
}
receivedSig := payload.Signature
payload.Signature = ""
dataToVerify, _ := json.Marshal(payload)
mac := hmac.New(sha256.New, signingKey)
mac.Write(dataToVerify)
expectedSig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(receivedSig), []byte(expectedSig)) {
return proxyTicketPayload{}, fmt.Errorf("invalid ticket signature")
}
payload.Signature = receivedSig
return payload, nil
}
type consoleResponse struct {
Ticket string `json:"ticket"`
}
func DefaultProxyClientFactory(cluster cluster.Cluster) (ProxyClient, error) {
return proxmox.NewClient(cluster)
}
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,34 @@
package consoleadapter
import (
"context"
"proxui/backend/internal/console"
"proxui/backend/internal/vm"
)
type VMRepository struct {
repo vm.SQLRepository
}
func NewVMRepository(repo vm.SQLRepository) VMRepository {
return VMRepository{repo: repo}
}
func (r VMRepository) GetVM(ctx context.Context, profileID string, vmID string) (console.VMInfo, bool, error) {
result, found, err := r.repo.GetVM(ctx, profileID, vmID)
if err != nil {
return console.VMInfo{}, false, err
}
if !found {
return console.VMInfo{}, false, nil
}
return console.VMInfo{
ID: result.ID,
TenantID: result.TenantID,
ClusterID: result.ClusterID,
ProxmoxVMID: result.ProxmoxVMID,
Node: result.Node,
MembershipRole: result.MembershipRole,
}, true, nil
}
+205
View File
@@ -0,0 +1,205 @@
package sshkey
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"proxui/backend/internal/auth"
"proxui/backend/internal/membership"
"proxui/backend/internal/rbac"
)
type Repository interface {
List(ctx context.Context, profileID string, tenantID string) ([]SSHKey, bool, error)
Get(ctx context.Context, profileID string, keyID string) (SSHKey, bool, error)
Create(ctx context.Context, profileID string, tenantID string, name string, publicKey string) (SSHKey, bool, error)
Delete(ctx context.Context, profileID string, keyID string) (bool, bool, error)
}
type Handler struct {
repository Repository
}
func NewHandler(repository Repository) Handler {
return Handler{repository: repository}
}
func (h Handler) ListTenantKeys(w http.ResponseWriter, r *http.Request) {
principal, ok := auth.PrincipalFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
tenantID := r.PathValue("tenantID")
if tenantID == "" {
writeError(w, http.StatusBadRequest, "tenant_id_required")
return
}
keys, found, err := h.repository.List(r.Context(), principal.Subject, tenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "ssh_keys_list_failed")
return
}
if !found {
writeError(w, http.StatusNotFound, "tenant_not_found")
return
}
writeJSON(w, http.StatusOK, map[string][]SSHKey{"data": keys})
}
func (h Handler) GetKey(w http.ResponseWriter, r *http.Request) {
principal, ok := auth.PrincipalFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
keyID := r.PathValue("keyID")
if keyID == "" {
writeError(w, http.StatusBadRequest, "key_id_required")
return
}
key, found, err := h.repository.Get(r.Context(), principal.Subject, keyID)
if err != nil {
writeError(w, http.StatusInternalServerError, "ssh_key_get_failed")
return
}
if !found {
writeError(w, http.StatusNotFound, "ssh_key_not_found")
return
}
membershipID, _ := membership.FromRequest(r)
if membershipID.TenantID != key.TenantID {
writeError(w, http.StatusForbidden, "forbidden")
return
}
if !rbac.Can(rbac.Role(membershipID.Role), rbac.ActionSSHKeyRead) {
writeError(w, http.StatusForbidden, "forbidden")
return
}
writeJSON(w, http.StatusOK, key)
}
func (h Handler) CreateKey(w http.ResponseWriter, r *http.Request) {
principal, ok := auth.PrincipalFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
tenantID := r.PathValue("tenantID")
if tenantID == "" {
writeError(w, http.StatusBadRequest, "tenant_id_required")
return
}
membershipID, _ := membership.FromRequest(r)
if membershipID.TenantID != tenantID {
writeError(w, http.StatusForbidden, "forbidden")
return
}
if !rbac.Can(rbac.Role(membershipID.Role), rbac.ActionSSHKeyManage) {
writeError(w, http.StatusForbidden, "forbidden")
return
}
var req createRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name_required")
return
}
req.PublicKey = strings.TrimSpace(req.PublicKey)
if req.PublicKey == "" {
writeError(w, http.StatusBadRequest, "public_key_required")
return
}
key, found, err := h.repository.Create(r.Context(), principal.Subject, tenantID, req.Name, req.PublicKey)
if err != nil {
writeError(w, http.StatusInternalServerError, "ssh_key_create_failed")
return
}
if !found {
writeError(w, http.StatusNotFound, "tenant_not_found")
return
}
writeJSON(w, http.StatusCreated, key)
}
func (h Handler) DeleteKey(w http.ResponseWriter, r *http.Request) {
principal, ok := auth.PrincipalFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
keyID := r.PathValue("keyID")
if keyID == "" {
writeError(w, http.StatusBadRequest, "key_id_required")
return
}
deleted, member, err := h.repository.Delete(r.Context(), principal.Subject, keyID)
if err != nil {
writeError(w, http.StatusInternalServerError, "ssh_key_delete_failed")
return
}
if !member {
writeError(w, http.StatusNotFound, "ssh_key_not_found")
return
}
if !deleted {
writeError(w, http.StatusNotFound, "ssh_key_not_found")
return
}
membershipID, _ := membership.FromRequest(r)
if !rbac.Can(rbac.Role(membershipID.Role), rbac.ActionSSHKeyManage) {
writeError(w, http.StatusForbidden, "forbidden")
return
}
w.WriteHeader(http.StatusNoContent)
}
type SSHKey struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Name string `json:"name"`
PublicKey string `json:"public_key"`
CreatedAt time.Time `json:"created_at"`
}
type createRequest struct {
Name string `json:"name"`
PublicKey string `json:"public_key"`
}
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})
}
+310
View File
@@ -0,0 +1,310 @@
package sshkey
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"proxui/backend/internal/auth"
"proxui/backend/internal/membership"
"proxui/backend/internal/rbac"
)
func TestListTenantKeysReturnsKeys(t *testing.T) {
repository := &stubRepository{
listFound: true,
keys: []SSHKey{{
ID: "key-1",
TenantID: "tenant-1",
Name: "laptop",
PublicKey: "ssh-ed25519 AAAAC3...",
CreatedAt: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
}},
}
handler := NewHandler(repository)
req := requestWithPrincipal(http.MethodGet, "/tenants/tenant-1/ssh-keys")
req.SetPathValue("tenantID", "tenant-1")
rec := httptest.NewRecorder()
handler.ListTenantKeys(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.tenantID != "tenant-1" {
t.Fatalf("tenantID = %q, want tenant-1", repository.tenantID)
}
var response struct {
Data []SSHKey `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 != "key-1" {
t.Fatalf("key ID = %q, want key-1", response.Data[0].ID)
}
}
func TestListTenantKeysReturnsEmptyListForTenantWithNoKeys(t *testing.T) {
handler := NewHandler(&stubRepository{listFound: true, keys: []SSHKey{}})
req := requestWithPrincipal(http.MethodGet, "/tenants/tenant-1/ssh-keys")
req.SetPathValue("tenantID", "tenant-1")
rec := httptest.NewRecorder()
handler.ListTenantKeys(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var response struct {
Data []SSHKey `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if response.Data == nil {
t.Fatal("data should be an empty slice, not nil")
}
}
func TestListTenantKeysReturnsNotFound(t *testing.T) {
handler := NewHandler(&stubRepository{listFound: false})
req := requestWithPrincipal(http.MethodGet, "/tenants/tenant-1/ssh-keys")
req.SetPathValue("tenantID", "tenant-1")
rec := httptest.NewRecorder()
handler.ListTenantKeys(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestGetKeyReturnsKey(t *testing.T) {
repository := &stubRepository{
getFound: true,
key: SSHKey{
ID: "key-1",
TenantID: "tenant-1",
Name: "laptop",
PublicKey: "ssh-ed25519 AAAAC3...",
},
}
handler := NewHandler(repository)
req := requestWithPrincipalAndMembership(http.MethodGet, "/ssh-keys/key-1", "tenant-1", "owner")
req.SetPathValue("keyID", "key-1")
rec := httptest.NewRecorder()
handler.GetKey(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if repository.keyID != "key-1" {
t.Fatalf("keyID = %q, want key-1", repository.keyID)
}
var response SSHKey
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if response.ID != "key-1" {
t.Fatalf("key ID = %q, want key-1", response.ID)
}
}
func TestGetKeyReturnsNotFound(t *testing.T) {
handler := NewHandler(&stubRepository{getFound: false})
req := requestWithPrincipalAndMembership(http.MethodGet, "/ssh-keys/key-1", "tenant-1", "owner")
req.SetPathValue("keyID", "key-1")
rec := httptest.NewRecorder()
handler.GetKey(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestCreateKeyReturnsCreated(t *testing.T) {
repository := &stubRepository{
createFound: true,
createResult: SSHKey{
ID: "key-1",
TenantID: "tenant-1",
Name: "laptop",
PublicKey: "ssh-ed25519 AAAAC3...",
},
}
handler := NewHandler(repository)
body := `{"name":"laptop","public_key":"ssh-ed25519 AAAAC3..."}`
req := requestWithPrincipalAndMembership(http.MethodPost, "/tenants/tenant-1/ssh-keys", "tenant-1", "owner")
req.SetPathValue("tenantID", "tenant-1")
req.Body = io.NopCloser(strings.NewReader(body))
rec := httptest.NewRecorder()
handler.CreateKey(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusCreated)
}
var response SSHKey
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if response.ID != "key-1" {
t.Fatalf("key ID = %q, want key-1", response.ID)
}
}
func TestCreateKeyReturnsForbiddenForViewer(t *testing.T) {
handler := NewHandler(&stubRepository{})
body := `{"name":"laptop","public_key":"ssh-ed25519 AAAAC3..."}`
req := requestWithPrincipalAndMembership(http.MethodPost, "/tenants/tenant-1/ssh-keys", "tenant-1", "viewer")
req.SetPathValue("tenantID", "tenant-1")
req.Body = io.NopCloser(strings.NewReader(body))
rec := httptest.NewRecorder()
handler.CreateKey(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
}
func TestDeleteKeyReturnsNoContent(t *testing.T) {
repository := &stubRepository{
deleteResult: true,
deleteMember: true,
}
handler := NewHandler(repository)
req := requestWithPrincipalAndMembership(http.MethodDelete, "/ssh-keys/key-1", "tenant-1", "owner")
req.SetPathValue("keyID", "key-1")
rec := httptest.NewRecorder()
handler.DeleteKey(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
}
if repository.deleteKeyID != "key-1" {
t.Fatalf("keyID = %q, want key-1", repository.deleteKeyID)
}
}
func TestDeleteKeyReturnsNotFound(t *testing.T) {
repo := &stubRepository{deleteResult: false, deleteMember: true}
handler := NewHandler(repo)
req := requestWithPrincipalAndMembership(http.MethodDelete, "/ssh-keys/key-1", "tenant-1", "owner")
req.SetPathValue("keyID", "key-1")
rec := httptest.NewRecorder()
handler.DeleteKey(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestCreateKeyRejectsEmptyName(t *testing.T) {
handler := NewHandler(&stubRepository{})
body := `{"name":" ","public_key":"ssh-ed25519 AAAAC3..."}`
req := requestWithPrincipalAndMembership(http.MethodPost, "/tenants/tenant-1/ssh-keys", "tenant-1", "owner")
req.SetPathValue("tenantID", "tenant-1")
req.Body = io.NopCloser(strings.NewReader(body))
rec := httptest.NewRecorder()
handler.CreateKey(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestListTenantKeysServerError(t *testing.T) {
handler := NewHandler(&stubRepository{err: errors.New("db failed")})
req := requestWithPrincipal(http.MethodGet, "/tenants/tenant-1/ssh-keys")
req.SetPathValue("tenantID", "tenant-1")
rec := httptest.NewRecorder()
handler.ListTenantKeys(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",
}))
}
func requestWithPrincipalAndMembership(method string, target string, tenantID string, role string) *http.Request {
req := httptest.NewRequest(method, target, nil)
ctx := auth.ContextWithPrincipal(req.Context(), auth.Principal{
Subject: "profile-1",
Email: "user@example.test",
Role: "authenticated",
})
ctx = membership.ContextWithMembership(ctx, membership.Membership{
TenantID: tenantID,
Role: rbac.Role(role),
})
return req.WithContext(ctx)
}
type stubRepository struct {
profileID string
tenantID string
keyID string
deleteKeyID string
keys []SSHKey
key SSHKey
createResult SSHKey
listFound bool
getFound bool
createFound bool
deleteResult bool
deleteMember bool
err error
}
func (s *stubRepository) List(_ context.Context, profileID string, tenantID string) ([]SSHKey, bool, error) {
s.profileID = profileID
s.tenantID = tenantID
return s.keys, s.listFound, s.err
}
func (s *stubRepository) Get(_ context.Context, profileID string, keyID string) (SSHKey, bool, error) {
s.profileID = profileID
s.keyID = keyID
return s.key, s.getFound, s.err
}
func (s *stubRepository) Create(_ context.Context, profileID string, tenantID string, name string, publicKey string) (SSHKey, bool, error) {
s.profileID = profileID
s.tenantID = tenantID
return s.createResult, s.createFound, s.err
}
func (s *stubRepository) Delete(_ context.Context, profileID string, keyID string) (bool, bool, error) {
s.profileID = profileID
s.deleteKeyID = keyID
return s.deleteResult, s.deleteMember, s.err
}
+172
View File
@@ -0,0 +1,172 @@
package sshkey
import (
"context"
"database/sql"
"errors"
)
type SQLRepository struct {
db *sql.DB
}
func NewSQLRepository(db *sql.DB) SQLRepository {
return SQLRepository{db: db}
}
func (r SQLRepository) List(ctx context.Context, profileID string, tenantID string) ([]SSHKey, bool, error) {
rows, err := r.db.QueryContext(ctx, `
select
sk.id::text,
sk.tenant_id::text,
sk.name,
sk.public_key,
sk.created_at
from public.tenants t
join public.memberships m
on m.tenant_id = t.id
and m.profile_id = $1
join public.ssh_keys sk
on sk.tenant_id = t.id
where t.id = $2
order by sk.created_at desc, sk.name asc
`, profileID, tenantID)
if err != nil {
return nil, false, err
}
defer rows.Close()
var keys []SSHKey
for rows.Next() {
var k SSHKey
if err := rows.Scan(&k.ID, &k.TenantID, &k.Name, &k.PublicKey, &k.CreatedAt); err != nil {
return nil, false, err
}
keys = append(keys, k)
}
if err := rows.Err(); err != nil {
return nil, false, err
}
if len(keys) == 0 {
exists, err := r.tenantExists(ctx, profileID, tenantID)
if err != nil {
return nil, false, err
}
return keys, exists, nil
}
return keys, true, nil
}
func (r SQLRepository) Get(ctx context.Context, profileID string, keyID string) (SSHKey, bool, error) {
var k SSHKey
err := r.db.QueryRowContext(ctx, `
select
sk.id::text,
sk.tenant_id::text,
sk.name,
sk.public_key,
sk.created_at
from public.ssh_keys sk
join public.memberships m
on m.tenant_id = sk.tenant_id
and m.profile_id = $1
where sk.id = $2
`, profileID, keyID).Scan(&k.ID, &k.TenantID, &k.Name, &k.PublicKey, &k.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return SSHKey{}, false, nil
}
if err != nil {
return SSHKey{}, false, err
}
return k, true, nil
}
func (r SQLRepository) Create(ctx context.Context, profileID string, tenantID string, name string, publicKey string) (SSHKey, bool, error) {
exists, err := r.tenantMemberExists(ctx, profileID, tenantID)
if err != nil {
return SSHKey{}, false, err
}
if !exists {
return SSHKey{}, false, nil
}
var k SSHKey
err = r.db.QueryRowContext(ctx, `
insert into public.ssh_keys (tenant_id, name, public_key)
values ($1, $2, $3)
returning id::text, tenant_id::text, name, public_key, created_at
`, tenantID, name, publicKey).Scan(&k.ID, &k.TenantID, &k.Name, &k.PublicKey, &k.CreatedAt)
if err != nil {
return SSHKey{}, false, err
}
return k, true, nil
}
func (r SQLRepository) Delete(ctx context.Context, profileID string, keyID string) (bool, bool, error) {
var tenantID string
err := r.db.QueryRowContext(ctx, `
select sk.tenant_id::text
from public.ssh_keys sk
where sk.id = $1
`, keyID).Scan(&tenantID)
if errors.Is(err, sql.ErrNoRows) {
return false, false, nil
}
if err != nil {
return false, false, err
}
member, err := r.tenantMemberExists(ctx, profileID, tenantID)
if err != nil {
return false, false, err
}
if !member {
return false, true, nil
}
res, err := r.db.ExecContext(ctx, `
delete from public.ssh_keys
where id = $1
`, keyID)
if err != nil {
return false, false, err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return false, false, err
}
return rowsAffected > 0, true, nil
}
func (r SQLRepository) tenantExists(ctx context.Context, profileID string, tenantID string) (bool, error) {
var exists bool
err := r.db.QueryRowContext(ctx, `
select exists(
select 1
from public.tenants t
join public.memberships m
on m.tenant_id = t.id
and m.profile_id = $1
where t.id = $2
)
`, profileID, tenantID).Scan(&exists)
return exists, err
}
func (r SQLRepository) tenantMemberExists(ctx context.Context, profileID string, tenantID string) (bool, error) {
var exists bool
err := r.db.QueryRowContext(ctx, `
select exists(
select 1
from public.memberships
where profile_id = $1
and tenant_id = $2
)
`, profileID, tenantID).Scan(&exists)
return exists, err
}
+22
View File
@@ -0,0 +1,22 @@
package sshkey
import "context"
type PublicKeyResolver struct {
repository Repository
}
func NewPublicKeyResolver(repository Repository) PublicKeyResolver {
return PublicKeyResolver{repository: repository}
}
func (r PublicKeyResolver) GetPublicKey(ctx context.Context, profileID string, keyID string) (string, bool, error) {
key, found, err := r.repository.Get(ctx, profileID, keyID)
if err != nil {
return "", false, err
}
if !found {
return "", false, nil
}
return key.PublicKey, true, nil
}
+189
View File
@@ -0,0 +1,189 @@
package template
import (
"context"
"encoding/json"
"net/http"
"strings"
)
type Repository interface {
List(ctx context.Context) ([]Template, error)
Get(ctx context.Context, id string) (Template, bool, error)
Upsert(ctx context.Context, t Template) (Template, error)
Delete(ctx context.Context, id string) (bool, error)
}
type Handler struct {
repository Repository
}
func NewHandler(repository Repository) Handler {
return Handler{repository: repository}
}
func (h Handler) ListTemplates(w http.ResponseWriter, r *http.Request) {
templates, err := h.repository.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "templates_list_failed")
return
}
writeJSON(w, http.StatusOK, map[string][]Template{"data": templates})
}
func (h Handler) CreateTemplate(w http.ResponseWriter, r *http.Request) {
var request createTemplateRequest
if !decodeJSON(w, r, &request) {
return
}
request.Name = strings.TrimSpace(request.Name)
if request.Name == "" {
writeError(w, http.StatusBadRequest, "name_required")
return
}
if request.ClusterID == "" {
writeError(w, http.StatusBadRequest, "cluster_id_required")
return
}
if request.ProxmoxTemplateVMID <= 0 {
writeError(w, http.StatusBadRequest, "proxmox_template_vmid_required")
return
}
saved, err := h.repository.Upsert(r.Context(), Template{
ClusterID: request.ClusterID,
Name: request.Name,
Description: request.Description,
ProxmoxTemplateVMID: request.ProxmoxTemplateVMID,
ProxmoxNode: request.ProxmoxNode,
})
if err != nil {
writeError(w, http.StatusBadRequest, "template_upsert_failed")
return
}
writeJSON(w, http.StatusCreated, saved)
}
func (h Handler) UpdateTemplate(w http.ResponseWriter, r *http.Request) {
templateID := r.PathValue("templateID")
if templateID == "" {
writeError(w, http.StatusBadRequest, "template_id_required")
return
}
existing, found, err := h.repository.Get(r.Context(), templateID)
if err != nil {
writeError(w, http.StatusInternalServerError, "template_get_failed")
return
}
if !found {
writeError(w, http.StatusNotFound, "template_not_found")
return
}
var request updateTemplateRequest
if !decodeJSON(w, r, &request) {
return
}
if request.ClusterID == nil {
request.ClusterID = &existing.ClusterID
}
if request.Name == nil {
request.Name = &existing.Name
} else {
trimmed := strings.TrimSpace(*request.Name)
request.Name = &trimmed
if *request.Name == "" {
writeError(w, http.StatusBadRequest, "name_required")
return
}
}
if request.Description == nil {
request.Description = &existing.Description
}
if request.ProxmoxTemplateVMID == nil {
request.ProxmoxTemplateVMID = &existing.ProxmoxTemplateVMID
} else if *request.ProxmoxTemplateVMID <= 0 {
writeError(w, http.StatusBadRequest, "proxmox_template_vmid_required")
return
}
if request.ProxmoxNode == nil {
request.ProxmoxNode = &existing.ProxmoxNode
}
saved, err := h.repository.Upsert(r.Context(), Template{
ID: existing.ID,
ClusterID: *request.ClusterID,
Name: *request.Name,
Description: *request.Description,
ProxmoxTemplateVMID: *request.ProxmoxTemplateVMID,
ProxmoxNode: *request.ProxmoxNode,
})
if err != nil {
writeError(w, http.StatusBadRequest, "template_upsert_failed")
return
}
writeJSON(w, http.StatusOK, saved)
}
func (h Handler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
templateID := r.PathValue("templateID")
if templateID == "" {
writeError(w, http.StatusBadRequest, "template_id_required")
return
}
deleted, err := h.repository.Delete(r.Context(), templateID)
if err != nil {
writeError(w, http.StatusInternalServerError, "template_delete_failed")
return
}
if !deleted {
writeError(w, http.StatusNotFound, "template_not_found")
return
}
w.WriteHeader(http.StatusNoContent)
}
type createTemplateRequest struct {
ClusterID string `json:"cluster_id"`
Name string `json:"name"`
Description string `json:"description"`
ProxmoxTemplateVMID int `json:"proxmox_template_vmid"`
ProxmoxNode string `json:"proxmox_node"`
}
type updateTemplateRequest struct {
ClusterID *string `json:"cluster_id"`
Name *string `json:"name"`
Description *string `json:"description"`
ProxmoxTemplateVMID *int `json:"proxmox_template_vmid"`
ProxmoxNode *string `json:"proxmox_node"`
}
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
defer r.Body.Close()
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json")
return false
}
return true
}
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})
}
+208
View File
@@ -0,0 +1,208 @@
package template
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestListTemplatesReturnsTemplates(t *testing.T) {
repository := &stubRepository{
templates: []Template{{
ID: "tmpl-1",
ClusterID: "cluster-1",
Name: "ubuntu-24.04",
Description: "Ubuntu 24.04 LTS",
ProxmoxTemplateVMID: 9000,
ProxmoxNode: "pve1",
CreatedAt: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
}},
}
handler := NewHandler(repository)
req := httptest.NewRequest(http.MethodGet, "/internal/templates", nil)
rec := httptest.NewRecorder()
handler.ListTemplates(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var response struct {
Data []Template `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 != "tmpl-1" {
t.Fatalf("template ID = %q, want tmpl-1", response.Data[0].ID)
}
}
func TestListTemplatesReturnsEmptyList(t *testing.T) {
handler := NewHandler(&stubRepository{templates: []Template{}})
req := httptest.NewRequest(http.MethodGet, "/internal/templates", nil)
rec := httptest.NewRecorder()
handler.ListTemplates(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
}
func TestCreateTemplateReturnsCreated(t *testing.T) {
repository := &stubRepository{
upsertResult: Template{
ID: "tmpl-1",
ClusterID: "cluster-1",
Name: "ubuntu-24.04",
Description: "Ubuntu 24.04 LTS",
ProxmoxTemplateVMID: 9000,
ProxmoxNode: "pve1",
},
}
handler := NewHandler(repository)
body := `{"cluster_id":"cluster-1","name":"ubuntu-24.04","description":"Ubuntu 24.04 LTS","proxmox_template_vmid":9000,"proxmox_node":"pve1"}`
req := httptest.NewRequest(http.MethodPost, "/internal/templates", io.NopCloser(strings.NewReader(body)))
rec := httptest.NewRecorder()
handler.CreateTemplate(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusCreated)
}
var response Template
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if response.ID != "tmpl-1" {
t.Fatalf("template ID = %q, want tmpl-1", response.ID)
}
}
func TestCreateTemplateRejectsEmptyName(t *testing.T) {
handler := NewHandler(&stubRepository{})
body := `{"cluster_id":"cluster-1","name":"","proxmox_template_vmid":9000}`
req := httptest.NewRequest(http.MethodPost, "/internal/templates", io.NopCloser(strings.NewReader(body)))
rec := httptest.NewRecorder()
handler.CreateTemplate(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestUpdateTemplateReturnsUpdated(t *testing.T) {
repository := &stubRepository{
getFound: true,
getResult: Template{
ID: "tmpl-1",
ClusterID: "cluster-1",
Name: "ubuntu-24.04",
Description: "Ubuntu 24.04 LTS",
ProxmoxTemplateVMID: 9000,
ProxmoxNode: "pve1",
},
upsertResult: Template{
ID: "tmpl-1",
ClusterID: "cluster-1",
Name: "ubuntu-24.04-updated",
Description: "Updated",
ProxmoxTemplateVMID: 9000,
ProxmoxNode: "pve1",
},
}
handler := NewHandler(repository)
body := `{"name":"ubuntu-24.04-updated","description":"Updated"}`
req := httptest.NewRequest(http.MethodPut, "/internal/templates/tmpl-1", io.NopCloser(strings.NewReader(body)))
req.SetPathValue("templateID", "tmpl-1")
rec := httptest.NewRecorder()
handler.UpdateTemplate(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var response Template
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if response.Name != "ubuntu-24.04-updated" {
t.Fatalf("name = %q, want ubuntu-24.04-updated", response.Name)
}
}
func TestUpdateTemplateReturnsNotFound(t *testing.T) {
handler := NewHandler(&stubRepository{getFound: false})
body := `{"name":"updated"}`
req := httptest.NewRequest(http.MethodPut, "/internal/templates/tmpl-xxx", io.NopCloser(strings.NewReader(body)))
req.SetPathValue("templateID", "tmpl-xxx")
rec := httptest.NewRecorder()
handler.UpdateTemplate(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestDeleteTemplateReturnsNoContent(t *testing.T) {
handler := NewHandler(&stubRepository{deleteResult: true})
req := httptest.NewRequest(http.MethodDelete, "/internal/templates/tmpl-1", nil)
req.SetPathValue("templateID", "tmpl-1")
rec := httptest.NewRecorder()
handler.DeleteTemplate(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
}
}
func TestDeleteTemplateReturnsNotFound(t *testing.T) {
handler := NewHandler(&stubRepository{deleteResult: false})
req := httptest.NewRequest(http.MethodDelete, "/internal/templates/tmpl-xxx", nil)
req.SetPathValue("templateID", "tmpl-xxx")
rec := httptest.NewRecorder()
handler.DeleteTemplate(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
type stubRepository struct {
templates []Template
getFound bool
getResult Template
upsertResult Template
deleteResult bool
}
func (s *stubRepository) List(_ context.Context) ([]Template, error) {
return s.templates, nil
}
func (s *stubRepository) Get(_ context.Context, _ string) (Template, bool, error) {
return s.getResult, s.getFound, nil
}
func (s *stubRepository) Upsert(_ context.Context, _ Template) (Template, error) {
return s.upsertResult, nil
}
func (s *stubRepository) Delete(_ context.Context, _ string) (bool, error) {
return s.deleteResult, nil
}
+113
View File
@@ -0,0 +1,113 @@
package template
import (
"context"
"database/sql"
"errors"
"time"
)
type SQLRepository struct {
db *sql.DB
}
func NewSQLRepository(db *sql.DB) SQLRepository {
return SQLRepository{db: db}
}
func (r SQLRepository) List(ctx context.Context) ([]Template, error) {
rows, err := r.db.QueryContext(ctx, `
select
id::text,
cluster_id::text,
name,
description,
proxmox_template_vmid,
proxmox_node,
created_at
from public.templates
order by name asc
`)
if err != nil {
return nil, err
}
defer rows.Close()
var templates []Template
for rows.Next() {
var t Template
if err := rows.Scan(&t.ID, &t.ClusterID, &t.Name, &t.Description, &t.ProxmoxTemplateVMID, &t.ProxmoxNode, &t.CreatedAt); err != nil {
return nil, err
}
templates = append(templates, t)
}
if err := rows.Err(); err != nil {
return nil, err
}
return templates, nil
}
func (r SQLRepository) Get(ctx context.Context, id string) (Template, bool, error) {
var t Template
err := r.db.QueryRowContext(ctx, `
select
id::text,
cluster_id::text,
name,
description,
proxmox_template_vmid,
proxmox_node,
created_at
from public.templates
where id = $1
`, id).Scan(&t.ID, &t.ClusterID, &t.Name, &t.Description, &t.ProxmoxTemplateVMID, &t.ProxmoxNode, &t.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Template{}, false, nil
}
if err != nil {
return Template{}, false, err
}
return t, true, nil
}
func (r SQLRepository) Upsert(ctx context.Context, t Template) (Template, error) {
err := r.db.QueryRowContext(ctx, `
insert into public.templates (cluster_id, name, description, proxmox_template_vmid, proxmox_node)
values ($1, $2, $3, $4, $5)
on conflict (cluster_id, proxmox_node, proxmox_template_vmid)
do update set name = $2, description = $3, updated_at = now()
returning id::text, name, description, proxmox_template_vmid, proxmox_node, created_at, coalesce(updated_at, created_at)
`, t.ClusterID, t.Name, t.Description, t.ProxmoxTemplateVMID, t.ProxmoxNode).Scan(
&t.ID, &t.Name, &t.Description, &t.ProxmoxTemplateVMID, &t.ProxmoxNode, &t.CreatedAt, &t.UpdatedAt,
)
if err != nil {
return Template{}, err
}
return t, nil
}
func (r SQLRepository) Delete(ctx context.Context, id string) (bool, error) {
res, err := r.db.ExecContext(ctx, `delete from public.templates where id = $1`, id)
if err != nil {
return false, err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return false, err
}
return rowsAffected > 0, nil
}
type Template struct {
ID string `json:"id"`
ClusterID string `json:"cluster_id"`
Name string `json:"name"`
Description string `json:"description"`
ProxmoxTemplateVMID int `json:"proxmox_template_vmid"`
ProxmoxNode string `json:"proxmox_node"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+33
View File
@@ -0,0 +1,33 @@
package template
import (
"context"
"proxui/backend/internal/vm"
)
type ProvisionResolver struct {
repository Repository
}
func NewProvisionResolver(repository Repository) ProvisionResolver {
return ProvisionResolver{repository: repository}
}
func (r ProvisionResolver) GetTemplate(ctx context.Context, templateID string) (vm.TemplateInfo, bool, error) {
t, found, err := r.repository.Get(ctx, templateID)
if err != nil {
return vm.TemplateInfo{}, false, err
}
if !found {
return vm.TemplateInfo{}, false, nil
}
return vm.TemplateInfo{
ID: t.ID,
ClusterID: t.ClusterID,
Name: t.Name,
ProxmoxTemplateVMID: t.ProxmoxTemplateVMID,
ProxmoxNode: t.ProxmoxNode,
}, true, nil
}
+11
View File
@@ -12,11 +12,16 @@ import (
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)
GetProjectInfo(ctx context.Context, profileID string, projectID string) (ProjectInfo, bool, error)
CheckQuota(ctx context.Context, projectID string) (QuotaInfo, error)
ReserveNextVMID(ctx context.Context, clusterID string) (int, error)
InsertVM(ctx context.Context, vm InsertVMRecord) (VM, error)
}
type Handler struct {
repository Repository
power PowerDependencies
provision ProvisionDependencies
}
type Option func(*Handler)
@@ -27,6 +32,12 @@ func WithPower(dependencies PowerDependencies) Option {
}
}
func WithProvision(dependencies ProvisionDependencies) Option {
return func(h *Handler) {
h.provision = dependencies
}
}
func NewHandler(repository Repository, opts ...Option) Handler {
handler := Handler{repository: repository}
for _, opt := range opts {
+16
View File
@@ -152,3 +152,19 @@ func (s *stubRepository) GetVM(_ context.Context, profileID string, vmID string)
s.vmID = vmID
return s.vm, s.getFound, s.err
}
func (s *stubRepository) GetProjectInfo(_ context.Context, profileID string, projectID string) (ProjectInfo, bool, error) {
return ProjectInfo{}, false, nil
}
func (s *stubRepository) CheckQuota(_ context.Context, _ string) (QuotaInfo, error) {
return QuotaInfo{}, nil
}
func (s *stubRepository) ReserveNextVMID(_ context.Context, _ string) (int, error) {
return 0, nil
}
func (s *stubRepository) InsertVM(_ context.Context, _ InsertVMRecord) (VM, error) {
return VM{}, nil
}
+431
View File
@@ -0,0 +1,431 @@
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 ProvisionClusterRepository interface {
GetCluster(ctx context.Context, id string) (cluster.Cluster, bool, error)
}
type ProvisionTemplateRepository interface {
GetTemplate(ctx context.Context, templateID string) (TemplateInfo, bool, error)
}
type ProvisionClientFactory func(cluster.Cluster) (ProvisionProxmoxClient, error)
type ProvisionProxmoxClient interface {
CloneVM(ctx context.Context, node string, templateVMID int, newVMID int, name string) (string, error)
ConfigureCloudInit(ctx context.Context, node string, vmid int, cfg proxmox.CloudInitConfig) (string, error)
StartVM(ctx context.Context, node string, vmid int) (string, error)
StopVM(ctx context.Context, node string, vmid int) (string, error)
DeleteVM(ctx context.Context, node string, vmid int) (string, error)
}
type ProvisionTaskEnqueuer interface {
EnqueueProxmoxTaskPoll(ctx context.Context, payload jobs.ProxmoxTaskPollPayload) error
}
type ProvisionAuditWriter interface {
WriteVMProvisionAudit(ctx context.Context, event VMProvisionAuditEvent) error
}
type SSHKeyResolver interface {
GetPublicKey(ctx context.Context, profileID string, keyID string) (string, bool, error)
}
type VMProvisionAuditEvent struct {
TenantID string
ProfileID string
Action string
VMID string
ClusterID string
TemplateID string
Node string
UPID string
ProxmoxVMID int
}
type ProvisionDependencies struct {
ClusterRepo ProvisionClusterRepository
TemplateRepo ProvisionTemplateRepository
ClientFactory ProvisionClientFactory
Tasks ProvisionTaskEnqueuer
Audit ProvisionAuditWriter
SSHKeyLookup SSHKeyResolver
}
type TemplateInfo struct {
ID string
ClusterID string
Name string
ProxmoxTemplateVMID int
ProxmoxNode string
}
func (h Handler) CreateVM(w http.ResponseWriter, r *http.Request) {
if h.provision.ClusterRepo == nil || h.provision.TemplateRepo == nil || h.provision.ClientFactory == nil || h.provision.Tasks == nil || h.provision.Audit == nil || h.provision.SSHKeyLookup == nil {
writeError(w, http.StatusServiceUnavailable, "vm_provision_not_configured")
return
}
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
}
project, found, err := h.repository.GetProjectInfo(r.Context(), principal.Subject, projectID)
if err != nil {
writeError(w, http.StatusInternalServerError, "project_get_failed")
return
}
if !found {
writeError(w, http.StatusForbidden, "forbidden")
return
}
if !rbac.Can(rbac.Role(project.Role), rbac.ActionVMCreate) {
writeError(w, http.StatusForbidden, "forbidden")
return
}
var req createVMRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name_required")
return
}
if req.TemplateID == "" {
writeError(w, http.StatusBadRequest, "template_id_required")
return
}
if req.Node == "" {
writeError(w, http.StatusBadRequest, "node_required")
return
}
if req.VCPU <= 0 {
req.VCPU = 2
}
if req.RAMMB <= 0 {
req.RAMMB = 2048
}
if req.DiskGB <= 0 {
req.DiskGB = 10
}
template, found, err := h.provision.TemplateRepo.GetTemplate(r.Context(), req.TemplateID)
if err != nil {
writeError(w, http.StatusInternalServerError, "template_get_failed")
return
}
if !found {
writeError(w, http.StatusNotFound, "template_not_found")
return
}
quota, err := h.repository.CheckQuota(r.Context(), projectID)
if err != nil {
writeError(w, http.StatusInternalServerError, "quota_check_failed")
return
}
if quota.UsedVMs >= quota.MaxVMs {
writeError(w, http.StatusConflict, "quota_exceeded_vms")
return
}
if quota.UsedVCPU+req.VCPU > quota.MaxVCPU {
writeError(w, http.StatusConflict, "quota_exceeded_vcpu")
return
}
if quota.UsedRAMMB+req.RAMMB > quota.MaxRAMMB {
writeError(w, http.StatusConflict, "quota_exceeded_ram")
return
}
if quota.UsedDiskGB+req.DiskGB > quota.MaxDiskGB {
writeError(w, http.StatusConflict, "quota_exceeded_disk")
return
}
vmid, err := h.repository.ReserveNextVMID(r.Context(), template.ClusterID)
if err != nil {
writeError(w, http.StatusInternalServerError, "vmid_reserve_failed")
return
}
vm, err := h.repository.InsertVM(r.Context(), InsertVMRecord{
ProjectID: projectID,
ClusterID: template.ClusterID,
ProxmoxVMID: vmid,
Node: req.Node,
Name: req.Name,
Status: "provisioning",
VCPU: req.VCPU,
RAMMB: req.RAMMB,
DiskGB: req.DiskGB,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "vm_insert_failed")
return
}
cluster, found, err := h.provision.ClusterRepo.GetCluster(r.Context(), template.ClusterID)
if err != nil {
writeError(w, http.StatusInternalServerError, "cluster_get_failed")
return
}
if !found {
writeError(w, http.StatusBadGateway, "cluster_not_found")
return
}
proxmoxClient, err := h.provision.ClientFactory(cluster)
if err != nil {
writeError(w, http.StatusBadGateway, "proxmox_client_failed")
return
}
cloneUPID, err := proxmoxClient.CloneVM(r.Context(), req.Node, template.ProxmoxTemplateVMID, vmid, req.Name)
if err != nil {
writeError(w, http.StatusBadGateway, "proxmox_clone_failed")
return
}
ciCfg := proxmox.CloudInitConfig{
CIUser: req.CIUser,
IPConfig0: req.IPConfig0,
Hostname: req.Name,
}
if req.SSHKeyID != "" {
sshKey, found, err := h.provision.SSHKeyLookup.GetPublicKey(r.Context(), principal.Subject, req.SSHKeyID)
if err != nil {
writeError(w, http.StatusInternalServerError, "ssh_key_get_failed")
return
}
if !found {
writeError(w, http.StatusBadRequest, "ssh_key_not_found")
return
}
ciCfg.SSHKeys = sshKey
}
var startUPID string
if _, err := proxmoxClient.ConfigureCloudInit(r.Context(), req.Node, vmid, ciCfg); err != nil {
writeError(w, http.StatusBadGateway, "proxmox_cloudinit_failed")
return
}
startUPID, err = proxmoxClient.StartVM(r.Context(), req.Node, vmid)
if err != nil {
writeError(w, http.StatusBadGateway, "proxmox_start_failed")
return
}
auditAction := "vm.provision"
if err := h.provision.Tasks.EnqueueProxmoxTaskPoll(r.Context(), jobs.ProxmoxTaskPollPayload{
ClusterID: template.ClusterID,
Node: req.Node,
UPID: startUPID,
TargetType: "vm",
TargetID: vm.ID,
TenantID: project.TenantID,
ProfileID: principal.Subject,
Action: auditAction,
SuccessStatus: "running",
}); err != nil {
writeError(w, http.StatusInternalServerError, "task_enqueue_failed")
return
}
if err := h.provision.Audit.WriteVMProvisionAudit(r.Context(), VMProvisionAuditEvent{
TenantID: project.TenantID,
ProfileID: principal.Subject,
Action: auditAction,
VMID: vm.ID,
ClusterID: template.ClusterID,
TemplateID: template.ID,
Node: req.Node,
UPID: cloneUPID,
ProxmoxVMID: vmid,
}); err != nil {
writeError(w, http.StatusInternalServerError, "audit_write_failed")
return
}
writeJSON(w, http.StatusAccepted, map[string]any{
"vm": vm,
"upid": startUPID,
"clone_upid": cloneUPID,
"status": "provisioning",
})
}
type createVMRequest struct {
Name string `json:"name"`
TemplateID string `json:"template_id"`
Node string `json:"node"`
VCPU int `json:"vcpu"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
SSHKeyID string `json:"ssh_key_id"`
CIUser string `json:"ci_user"`
IPConfig0 string `json:"ip_config0"`
}
func DefaultProvisionClientFactory(cluster cluster.Cluster) (ProvisionProxmoxClient, error) {
return proxmox.NewClient(cluster)
}
type SQLProvisionAuditWriter struct {
db *sql.DB
}
func NewSQLProvisionAuditWriter(db *sql.DB) SQLProvisionAuditWriter {
return SQLProvisionAuditWriter{db: db}
}
func (w SQLProvisionAuditWriter) WriteVMProvisionAudit(ctx context.Context, event VMProvisionAuditEvent) error {
metadata, err := json.Marshal(map[string]any{
"cluster_id": event.ClusterID,
"template_id": event.TemplateID,
"node": event.Node,
"upid": event.UPID,
"proxmox_vmid": event.ProxmoxVMID,
})
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 provision audit: %w", err)
}
return nil
}
func (h Handler) DeleteVM(w http.ResponseWriter, r *http.Request) {
if h.provision.ClusterRepo == nil || h.provision.ClientFactory == nil || h.provision.Tasks == nil || h.provision.Audit == nil {
writeError(w, http.StatusServiceUnavailable, "vm_delete_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.ActionVMDelete) {
writeError(w, http.StatusForbidden, "forbidden")
return
}
cluster, found, err := h.provision.ClusterRepo.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
}
proxmoxClient, err := h.provision.ClientFactory(cluster)
if err != nil {
writeError(w, http.StatusBadGateway, "proxmox_client_failed")
return
}
_, err = proxmoxClient.StopVM(r.Context(), vm.Node, vm.ProxmoxVMID)
if err != nil {
writeError(w, http.StatusBadGateway, "proxmox_stop_failed")
return
}
deleteUPID, err := proxmoxClient.DeleteVM(r.Context(), vm.Node, vm.ProxmoxVMID)
if err != nil {
writeError(w, http.StatusBadGateway, "proxmox_delete_failed")
return
}
auditAction := "vm.delete"
if err := h.provision.Tasks.EnqueueProxmoxTaskPoll(r.Context(), jobs.ProxmoxTaskPollPayload{
ClusterID: vm.ClusterID,
Node: vm.Node,
UPID: deleteUPID,
TargetType: "vm",
TargetID: vm.ID,
TenantID: vm.TenantID,
ProfileID: principal.Subject,
Action: auditAction,
SuccessStatus: "deleted",
}); err != nil {
writeError(w, http.StatusInternalServerError, "task_enqueue_failed")
return
}
if err := h.provision.Audit.WriteVMProvisionAudit(r.Context(), VMProvisionAuditEvent{
TenantID: vm.TenantID,
ProfileID: principal.Subject,
Action: auditAction,
VMID: vm.ID,
ClusterID: vm.ClusterID,
Node: vm.Node,
UPID: deleteUPID,
ProxmoxVMID: vm.ProxmoxVMID,
}); err != nil {
writeError(w, http.StatusInternalServerError, "audit_write_failed")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{
"upid": deleteUPID,
"status": "deleting",
})
}
+136
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
)
type SQLRepository struct {
@@ -171,5 +172,140 @@ func (r nullableVMRecord) vm() VM {
UpdatedAt: r.UpdatedAt.Time,
ProjectName: r.ProjectName,
MembershipRole: r.MembershipRole,
}
}
type ProjectInfo struct {
TenantID string
Name string
Role string
}
type QuotaInfo struct {
UsedVMs int
UsedVCPU int
UsedRAMMB int
UsedDiskGB int
MaxVMs int
MaxVCPU int
MaxRAMMB int
MaxDiskGB int
}
func (r SQLRepository) GetProjectInfo(ctx context.Context, profileID string, projectID string) (ProjectInfo, bool, error) {
var info ProjectInfo
err := r.db.QueryRowContext(ctx, `
select
p.tenant_id::text,
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
where p.id = $2
`, profileID, projectID).Scan(&info.TenantID, &info.Name, &info.Role)
if errors.Is(err, sql.ErrNoRows) {
return ProjectInfo{}, false, nil
}
if err != nil {
return ProjectInfo{}, false, err
}
return info, true, nil
}
func (r SQLRepository) CheckQuota(ctx context.Context, projectID string) (QuotaInfo, error) {
var q QuotaInfo
err := r.db.QueryRowContext(ctx, `
select
coalesce(sum(case when v.status <> 'failed' and v.status <> 'deleting' and v.status <> 'deleted' then 1 else 0 end), 0) as used_vms,
coalesce(sum(case when v.status <> 'failed' and v.status <> 'deleting' and v.status <> 'deleted' then v.vcpu else 0 end), 0) as used_vcpu,
coalesce(sum(case when v.status <> 'failed' and v.status <> 'deleting' and v.status <> 'deleted' then v.ram_mb else 0 end), 0) as used_ram_mb,
coalesce(sum(case when v.status <> 'failed' and v.status <> 'deleting' and v.status <> 'deleted' then v.disk_gb else 0 end), 0) as used_disk_gb,
coalesce(pq.max_vms, 10),
coalesce(pq.max_vcpu, 8),
coalesce(pq.max_ram_mb, 16384),
coalesce(pq.max_disk_gb, 200)
from public.projects p
left join public.vms v on v.project_id = p.id
left join public.project_quotas pq on pq.project_id = p.id
where p.id = $1
group by pq.max_vms, pq.max_vcpu, pq.max_ram_mb, pq.max_disk_gb
`, projectID).Scan(&q.UsedVMs, &q.UsedVCPU, &q.UsedRAMMB, &q.UsedDiskGB, &q.MaxVMs, &q.MaxVCPU, &q.MaxRAMMB, &q.MaxDiskGB)
if errors.Is(err, sql.ErrNoRows) {
q.MaxVMs = 10
q.MaxVCPU = 8
q.MaxRAMMB = 16384
q.MaxDiskGB = 200
return q, nil
}
if err != nil {
return QuotaInfo{}, err
}
return q, nil
}
func (r SQLRepository) ReserveNextVMID(ctx context.Context, clusterID string) (int, error) {
var vmid int
err := r.db.QueryRowContext(ctx, `
select public.reserve_next_vmid($1::uuid)
`, clusterID).Scan(&vmid)
if err != nil {
return 0, fmt.Errorf("reserve vmid: %w", err)
}
return vmid, nil
}
func (r SQLRepository) InsertVM(ctx context.Context, vm InsertVMRecord) (VM, error) {
var result VM
err := r.db.QueryRowContext(ctx, `
insert into public.vms (project_id, cluster_id, proxmox_vmid, node, name, status, vcpu, ram_mb, disk_gb)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
returning
id::text,
project_id::text,
cluster_id::text,
proxmox_vmid,
node,
name,
status::text,
vcpu,
ram_mb,
disk_gb,
created_at,
updated_at
`, vm.ProjectID, vm.ClusterID, vm.ProxmoxVMID, vm.Node, vm.Name, vm.Status, vm.VCPU, vm.RAMMB, vm.DiskGB).Scan(
&result.ID,
&result.ProjectID,
&result.ClusterID,
&result.ProxmoxVMID,
&result.Node,
&result.Name,
&result.Status,
&result.VCPU,
&result.RAMMB,
&result.DiskGB,
&result.CreatedAt,
&result.UpdatedAt,
)
if err != nil {
return VM{}, fmt.Errorf("insert vm: %w", err)
}
return result, nil
}
type InsertVMRecord struct {
ProjectID string
ClusterID string
ProxmoxVMID int
Node string
Name string
Status string
VCPU int
RAMMB int
DiskGB int
}