feat: complete MVP epics E7-E10 (provisioning, console proxy, audit, frontend)
This commit is contained in:
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user