mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 08:01:16 +00:00
d2414ad29f
Go CI fails on golangci-lint's gofmt check. Ran gofmt -w against every file the linter named plus a handful of others that drifted during the autonomous-fleet work. No semantic changes — alignment of struct field whitespace and one mis-indented import block. gofmt -l ./... is now empty; go build + go vet are clean.
513 lines
16 KiB
Go
513 lines
16 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/cloudprovider"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/cloudprovider/hetzner"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
// Admin endpoints under /admin for autonomous fleet management:
|
|
//
|
|
// /admin/cloud-credentials CRUD + test
|
|
// /admin/cloud-providers/:provider/... discovery (locations, server_types, images)
|
|
// /admin/provisioning-templates CRUD
|
|
// /admin/provisioning-jobs list + create + detail
|
|
// /admin/provisioning-policy per-provider budget caps
|
|
//
|
|
// All gated by AdminPermManageSettings via the route registration.
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cloud credentials
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type CloudCredentialResponse struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Provider string `json:"provider"`
|
|
Name string `json:"name"`
|
|
TokenMasked string `json:"token_masked"`
|
|
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
|
LastTestAt *time.Time `json:"last_test_at,omitempty"`
|
|
LastTestOK *bool `json:"last_test_ok,omitempty"`
|
|
LastTestError *string `json:"last_test_error,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func toCredResponse(c *repository.CloudCredential) CloudCredentialResponse {
|
|
return CloudCredentialResponse{
|
|
ID: c.ID,
|
|
Provider: c.Provider,
|
|
Name: c.Name,
|
|
TokenMasked: maskToken(c.EncryptedToken),
|
|
LastUsedAt: c.LastUsedAt,
|
|
LastTestAt: c.LastTestAt,
|
|
LastTestOK: c.LastTestOK,
|
|
LastTestError: c.LastTestError,
|
|
CreatedAt: c.CreatedAt,
|
|
}
|
|
}
|
|
|
|
func maskToken(t string) string {
|
|
if len(t) <= 8 {
|
|
return "***"
|
|
}
|
|
return t[:4] + "***" + t[len(t)-4:]
|
|
}
|
|
|
|
func (h *Handler) AdminListCloudCredentials(c *gin.Context) {
|
|
if h.CloudCredentialRepo == nil {
|
|
c.JSON(http.StatusOK, gin.H{"credentials": []any{}})
|
|
return
|
|
}
|
|
rows, err := h.CloudCredentialRepo.List(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
out := make([]CloudCredentialResponse, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, toCredResponse(&r))
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"credentials": out})
|
|
}
|
|
|
|
type CreateCloudCredentialRequest struct {
|
|
Provider string `json:"provider"`
|
|
Name string `json:"name"`
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
func (h *Handler) AdminCreateCloudCredential(c *gin.Context) {
|
|
if h.CloudCredentialRepo == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud credentials repo not configured"})
|
|
return
|
|
}
|
|
var req CreateCloudCredentialRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
|
return
|
|
}
|
|
if req.Provider == "" || req.Token == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "provider and token required"})
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
req.Name = req.Provider + "-default"
|
|
}
|
|
|
|
// TODO: cipher-encrypt the token via h.CipherService. For now we store
|
|
// as-is so the wiring works end-to-end; flag this in audit log.
|
|
row := &repository.CloudCredential{
|
|
Provider: req.Provider,
|
|
Name: req.Name,
|
|
EncryptedToken: req.Token,
|
|
}
|
|
if err := h.CloudCredentialRepo.Create(c.Request.Context(), row); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, toCredResponse(row))
|
|
}
|
|
|
|
func (h *Handler) AdminDeleteCloudCredential(c *gin.Context) {
|
|
if h.CloudCredentialRepo == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud credentials repo not configured"})
|
|
return
|
|
}
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
return
|
|
}
|
|
if err := h.CloudCredentialRepo.Delete(c.Request.Context(), id); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) AdminTestCloudCredential(c *gin.Context) {
|
|
if h.CloudCredentialRepo == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud credentials repo not configured"})
|
|
return
|
|
}
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
return
|
|
}
|
|
cred, err := h.CloudCredentialRepo.Get(c.Request.Context(), id)
|
|
if err != nil || cred == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
|
|
return
|
|
}
|
|
provider, err := buildProvider(cred)
|
|
if err != nil {
|
|
_ = h.CloudCredentialRepo.UpdateTestResult(c.Request.Context(), id, false, err.Error())
|
|
c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
|
defer cancel()
|
|
if err := provider.Verify(ctx); err != nil {
|
|
_ = h.CloudCredentialRepo.UpdateTestResult(ctx, id, false, err.Error())
|
|
c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
_ = h.CloudCredentialRepo.UpdateTestResult(ctx, id, true, "")
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
// buildProvider picks the right cloudprovider.Provider implementation for a
|
|
// credential row. Today only Hetzner; adding OVH/Vultr means another case.
|
|
func buildProvider(c *repository.CloudCredential) (cloudprovider.Provider, error) {
|
|
switch c.Provider {
|
|
case "hetzner":
|
|
return hetzner.New(c.EncryptedToken)
|
|
default:
|
|
return nil, errors.New("unsupported provider: " + c.Provider)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Provider catalog discovery (used by admin UI dropdowns)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (h *Handler) AdminListProviderLocations(c *gin.Context) {
|
|
provider, err := h.providerByName(c)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
|
defer cancel()
|
|
locs, err := provider.Locations(ctx)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"locations": locs})
|
|
}
|
|
|
|
func (h *Handler) AdminListProviderServerTypes(c *gin.Context) {
|
|
provider, err := h.providerByName(c)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
|
defer cancel()
|
|
types, err := provider.ServerTypes(ctx)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"server_types": types})
|
|
}
|
|
|
|
func (h *Handler) AdminListProviderImages(c *gin.Context) {
|
|
provider, err := h.providerByName(c)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
|
defer cancel()
|
|
images, err := provider.Images(ctx)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"images": images})
|
|
}
|
|
|
|
// providerByName resolves a provider name to a Provider client, looking up
|
|
// the most recent credential row for that provider.
|
|
func (h *Handler) providerByName(c *gin.Context) (cloudprovider.Provider, error) {
|
|
if h.CloudCredentialRepo == nil {
|
|
return nil, errors.New("cloud credentials not configured")
|
|
}
|
|
name := c.Param("provider")
|
|
if name == "" {
|
|
return nil, errors.New("provider path param required")
|
|
}
|
|
cred, err := h.CloudCredentialRepo.GetByProvider(c.Request.Context(), name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cred == nil {
|
|
return nil, errors.New("no credential configured for provider " + name)
|
|
}
|
|
return buildProvider(cred)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Provisioning templates
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (h *Handler) AdminListProvisioningTemplates(c *gin.Context) {
|
|
if h.ProvisioningTemplateRepo == nil {
|
|
c.JSON(http.StatusOK, gin.H{"templates": []any{}})
|
|
return
|
|
}
|
|
rows, err := h.ProvisioningTemplateRepo.List(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"templates": rows})
|
|
}
|
|
|
|
func (h *Handler) AdminGetProvisioningTemplate(c *gin.Context) {
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
return
|
|
}
|
|
row, err := h.ProvisioningTemplateRepo.Get(c.Request.Context(), id)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if row == nil {
|
|
c.Status(http.StatusNotFound)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, row)
|
|
}
|
|
|
|
func (h *Handler) AdminCreateProvisioningTemplate(c *gin.Context) {
|
|
if h.ProvisioningTemplateRepo == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "template repo not configured"})
|
|
return
|
|
}
|
|
var t repository.ProvisioningTemplate
|
|
if err := c.ShouldBindJSON(&t); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body: " + err.Error()})
|
|
return
|
|
}
|
|
if t.Name == "" || t.Provider == "" || t.Location == "" || t.ServerType == "" || t.Tier == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name, provider, location, server_type, tier required"})
|
|
return
|
|
}
|
|
if t.Image == "" {
|
|
t.Image = "ubuntu-22.04"
|
|
}
|
|
if t.ServerCount == 0 {
|
|
t.ServerCount = 1
|
|
}
|
|
if t.IPv4PerServer == 0 {
|
|
t.IPv4PerServer = 1
|
|
}
|
|
if t.IPv6PerServer == 0 {
|
|
t.IPv6PerServer = 1
|
|
}
|
|
if t.EgressKind == "" {
|
|
t.EgressKind = "cold_smtp"
|
|
}
|
|
if t.Labels == nil {
|
|
t.Labels = map[string]string{}
|
|
}
|
|
if err := h.ProvisioningTemplateRepo.Create(c.Request.Context(), &t); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, t)
|
|
}
|
|
|
|
func (h *Handler) AdminUpdateProvisioningTemplate(c *gin.Context) {
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
return
|
|
}
|
|
var t repository.ProvisioningTemplate
|
|
if err := c.ShouldBindJSON(&t); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
|
return
|
|
}
|
|
t.ID = id
|
|
if err := h.ProvisioningTemplateRepo.Update(c.Request.Context(), &t); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, t)
|
|
}
|
|
|
|
func (h *Handler) AdminDeleteProvisioningTemplate(c *gin.Context) {
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
return
|
|
}
|
|
if err := h.ProvisioningTemplateRepo.Delete(c.Request.Context(), id); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Provisioning jobs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type CreateProvisioningJobRequest struct {
|
|
TemplateID *uuid.UUID `json:"template_id,omitempty"`
|
|
Custom json.RawMessage `json:"custom,omitempty"`
|
|
TriggeredBy string `json:"triggered_by,omitempty"`
|
|
}
|
|
|
|
func (h *Handler) AdminListProvisioningJobs(c *gin.Context) {
|
|
if h.ProvisioningJobRepo == nil {
|
|
c.JSON(http.StatusOK, gin.H{"jobs": []any{}})
|
|
return
|
|
}
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
|
rows, err := h.ProvisioningJobRepo.List(c.Request.Context(), limit)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"jobs": rows})
|
|
}
|
|
|
|
func (h *Handler) AdminGetProvisioningJob(c *gin.Context) {
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
return
|
|
}
|
|
row, err := h.ProvisioningJobRepo.Get(c.Request.Context(), id)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if row == nil {
|
|
c.Status(http.StatusNotFound)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, row)
|
|
}
|
|
|
|
func (h *Handler) AdminCreateProvisioningJob(c *gin.Context) {
|
|
if h.ProvisioningJobRepo == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "provisioning jobs repo not configured"})
|
|
return
|
|
}
|
|
var req CreateProvisioningJobRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
|
return
|
|
}
|
|
|
|
// Resolve template or custom config into the JSONB config column.
|
|
var (
|
|
config json.RawMessage
|
|
templateID *uuid.UUID
|
|
provider string
|
|
)
|
|
if req.TemplateID != nil {
|
|
if h.ProvisioningTemplateRepo == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "template repo not configured"})
|
|
return
|
|
}
|
|
t, err := h.ProvisioningTemplateRepo.Get(c.Request.Context(), *req.TemplateID)
|
|
if err != nil || t == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "template not found"})
|
|
return
|
|
}
|
|
// Snapshot the template into the job's config column.
|
|
b, _ := json.Marshal(t)
|
|
config = b
|
|
templateID = &t.ID
|
|
provider = t.Provider
|
|
} else {
|
|
if len(req.Custom) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "either template_id or custom config required"})
|
|
return
|
|
}
|
|
config = req.Custom
|
|
var stub struct {
|
|
Provider string `json:"provider"`
|
|
}
|
|
_ = json.Unmarshal(req.Custom, &stub)
|
|
provider = stub.Provider
|
|
}
|
|
|
|
if provider == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "provider missing from config"})
|
|
return
|
|
}
|
|
|
|
triggeredBy := req.TriggeredBy
|
|
if triggeredBy == "" {
|
|
triggeredBy = "admin"
|
|
}
|
|
|
|
job := &repository.ProvisioningJob{
|
|
State: models.ProvJobPending,
|
|
TriggeredBy: triggeredBy,
|
|
Provider: provider,
|
|
TemplateID: templateID,
|
|
Config: config,
|
|
}
|
|
if err := h.ProvisioningJobRepo.Create(c.Request.Context(), job); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// The state machine pickup is asynchronous: a background worker on the
|
|
// backend (see internal/app/provisioning Service.Run) processes jobs in
|
|
// state != completed/failed. The admin UI polls GET /admin/provisioning-jobs/:id
|
|
// for live status.
|
|
|
|
c.JSON(http.StatusAccepted, job)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Provisioning policy
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (h *Handler) AdminListProvisioningPolicy(c *gin.Context) {
|
|
if h.ProvisioningPolicyRepo == nil {
|
|
c.JSON(http.StatusOK, gin.H{"policies": []any{}})
|
|
return
|
|
}
|
|
rows, err := h.ProvisioningPolicyRepo.List(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"policies": rows})
|
|
}
|
|
|
|
func (h *Handler) AdminUpdateProvisioningPolicy(c *gin.Context) {
|
|
if h.ProvisioningPolicyRepo == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "policy repo not configured"})
|
|
return
|
|
}
|
|
var p repository.ProvisioningPolicy
|
|
if err := c.ShouldBindJSON(&p); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
|
return
|
|
}
|
|
if p.Provider == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "provider required"})
|
|
return
|
|
}
|
|
if err := h.ProvisioningPolicyRepo.Update(c.Request.Context(), &p); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, p)
|
|
}
|