mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 08:01:24 +00:00
Add richer backend models, handlers, services, and repository queries for admin campaigns, discounts, outreach, organizations, limit requests, and provisioning surfaces.
787 lines
25 KiB
Go
787 lines
25 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"`
|
|
TokenRedacted string `json:"token_redacted"`
|
|
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"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
func toCredResponse(c *repository.CloudCredential) CloudCredentialResponse {
|
|
return CloudCredentialResponse{
|
|
ID: c.ID,
|
|
Provider: c.Provider,
|
|
Name: c.Name,
|
|
TokenRedacted: maskToken(c.EncryptedToken),
|
|
LastUsedAt: c.LastUsedAt,
|
|
LastTestAt: c.LastTestAt,
|
|
LastTestOK: c.LastTestOK,
|
|
LastTestError: c.LastTestError,
|
|
CreatedAt: c.CreatedAt,
|
|
UpdatedAt: c.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
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{"data": []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{"data": 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{"data": 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{"data": 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{"data": 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
|
|
//
|
|
// The admin UI works in a nested {name, description, config:{...},
|
|
// auto_provision_tier, is_draft} shape; the repository row is flat. The DTO
|
|
// helpers below translate between the two so the two sides agree on the wire
|
|
// format (they did not previously, which silently 400'd every template save).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type provLabelDTO struct {
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
type provTemplateConfigDTO struct {
|
|
Provider string `json:"provider"`
|
|
CredentialID *uuid.UUID `json:"credential_id,omitempty"`
|
|
Location string `json:"location"`
|
|
ServerType string `json:"server_type"`
|
|
ServerCount int `json:"server_count"`
|
|
IPv4PerServer int `json:"ipv4_per_server"`
|
|
IPv6PerServer int `json:"ipv6_per_server"`
|
|
WorkerTier string `json:"worker_tier"`
|
|
WorkerProfileID *uuid.UUID `json:"worker_profile_id,omitempty"`
|
|
EgressKind string `json:"egress_kind"`
|
|
Image string `json:"image"`
|
|
Datacenter string `json:"datacenter,omitempty"`
|
|
PlacementGroup string `json:"placement_group,omitempty"`
|
|
PrivateNetwork string `json:"private_network,omitempty"`
|
|
Firewall string `json:"firewall"`
|
|
Labels []provLabelDTO `json:"labels"`
|
|
}
|
|
|
|
type provTemplateDTO struct {
|
|
ID uuid.UUID `json:"id,omitempty"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Config provTemplateConfigDTO `json:"config"`
|
|
AutoProvisionTier string `json:"auto_provision_tier,omitempty"`
|
|
IsDraft bool `json:"is_draft"`
|
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
|
}
|
|
|
|
func labelsToDTO(m map[string]string) []provLabelDTO {
|
|
out := make([]provLabelDTO, 0, len(m))
|
|
for k, v := range m {
|
|
out = append(out, provLabelDTO{Key: k, Value: v})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func labelsFromDTO(in []provLabelDTO) map[string]string {
|
|
m := map[string]string{}
|
|
for _, l := range in {
|
|
if l.Key != "" {
|
|
m[l.Key] = l.Value
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func toTemplateDTO(t *repository.ProvisioningTemplate) provTemplateDTO {
|
|
auto := ""
|
|
if t.IsAutoTemplate {
|
|
auto = t.Tier
|
|
}
|
|
return provTemplateDTO{
|
|
ID: t.ID,
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
Config: provTemplateConfigDTO{
|
|
Provider: t.Provider,
|
|
Location: t.Location,
|
|
ServerType: t.ServerType,
|
|
ServerCount: t.ServerCount,
|
|
IPv4PerServer: t.IPv4PerServer,
|
|
IPv6PerServer: t.IPv6PerServer,
|
|
WorkerTier: t.Tier,
|
|
WorkerProfileID: t.WorkerProfileID,
|
|
EgressKind: t.EgressKind,
|
|
Image: t.Image,
|
|
Datacenter: t.Datacenter,
|
|
PlacementGroup: t.PlacementGroup,
|
|
PrivateNetwork: t.PrivateNetwork,
|
|
Firewall: t.Firewall,
|
|
Labels: labelsToDTO(t.Labels),
|
|
},
|
|
AutoProvisionTier: auto,
|
|
IsDraft: t.IsDraft,
|
|
CreatedAt: t.CreatedAt,
|
|
UpdatedAt: t.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
// fromTemplateDTO maps the UI shape onto the flat repo model and applies the
|
|
// field defaults the create path used to apply inline.
|
|
func fromTemplateDTO(d *provTemplateDTO) *repository.ProvisioningTemplate {
|
|
cfg := d.Config
|
|
t := &repository.ProvisioningTemplate{
|
|
ID: d.ID,
|
|
Name: d.Name,
|
|
Description: d.Description,
|
|
Provider: cfg.Provider,
|
|
Location: cfg.Location,
|
|
Datacenter: cfg.Datacenter,
|
|
ServerType: cfg.ServerType,
|
|
Image: cfg.Image,
|
|
ServerCount: cfg.ServerCount,
|
|
IPv4PerServer: cfg.IPv4PerServer,
|
|
IPv6PerServer: cfg.IPv6PerServer,
|
|
WorkerProfileID: cfg.WorkerProfileID,
|
|
Tier: cfg.WorkerTier,
|
|
EgressKind: cfg.EgressKind,
|
|
Labels: labelsFromDTO(cfg.Labels),
|
|
PlacementGroup: cfg.PlacementGroup,
|
|
PrivateNetwork: cfg.PrivateNetwork,
|
|
Firewall: cfg.Firewall,
|
|
IsDraft: d.IsDraft,
|
|
// A draft is never eligible as the tier's auto-provision template.
|
|
IsAutoTemplate: !d.IsDraft && d.AutoProvisionTier != "",
|
|
}
|
|
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"
|
|
}
|
|
return t
|
|
}
|
|
|
|
func (h *Handler) AdminListProvisioningTemplates(c *gin.Context) {
|
|
if h.ProvisioningTemplateRepo == nil {
|
|
c.JSON(http.StatusOK, gin.H{"data": []any{}})
|
|
return
|
|
}
|
|
rows, err := h.ProvisioningTemplateRepo.List(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
out := make([]provTemplateDTO, 0, len(rows))
|
|
for i := range rows {
|
|
out = append(out, toTemplateDTO(&rows[i]))
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": out})
|
|
}
|
|
|
|
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, toTemplateDTO(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 d provTemplateDTO
|
|
if err := c.ShouldBindJSON(&d); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body: " + err.Error()})
|
|
return
|
|
}
|
|
t := fromTemplateDTO(&d)
|
|
if t.Name == "" || t.Provider == "" || t.Location == "" || t.ServerType == "" || t.Tier == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name, provider, location, server_type and worker_tier are required"})
|
|
return
|
|
}
|
|
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, toTemplateDTO(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 d provTemplateDTO
|
|
if err := c.ShouldBindJSON(&d); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
|
return
|
|
}
|
|
t := fromTemplateDTO(&d)
|
|
t.ID = id
|
|
if t.Name == "" || t.Provider == "" || t.Location == "" || t.ServerType == "" || t.Tier == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name, provider, location, server_type and worker_tier are required"})
|
|
return
|
|
}
|
|
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, toTemplateDTO(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"`
|
|
}
|
|
|
|
// Job rows are flat in the repo and the UI works in a nested {config:{...}}
|
|
// shape with snake_case keys — the same translation problem the templates have.
|
|
// toJobDTO maps the row (and decodes the stored flat config snapshot back into
|
|
// the nested ProvisioningConfig) so the jobs list / detail / progress panel get
|
|
// the fields they read.
|
|
type provJobStepDTO struct {
|
|
Key string `json:"key"`
|
|
Label string `json:"label"`
|
|
Done int `json:"done"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
type provJobTimelineDTO struct {
|
|
State string `json:"state"`
|
|
At time.Time `json:"at"`
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
type provJobDTO struct {
|
|
ID uuid.UUID `json:"id"`
|
|
State string `json:"state"`
|
|
TriggeredBy string `json:"triggered_by,omitempty"`
|
|
Provider string `json:"provider"`
|
|
TemplateID *uuid.UUID `json:"template_id,omitempty"`
|
|
TemplateName string `json:"template_name,omitempty"`
|
|
Config provTemplateConfigDTO `json:"config"`
|
|
Progress []provJobStepDTO `json:"progress"`
|
|
Timeline []provJobTimelineDTO `json:"timeline"`
|
|
CreatedWorkerIDs []uuid.UUID `json:"created_worker_ids,omitempty"`
|
|
LastError *string `json:"last_error,omitempty"`
|
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
func toJobDTO(j *repository.ProvisioningJob) provJobDTO {
|
|
cfg := provTemplateConfigDTO{Labels: []provLabelDTO{}}
|
|
templateName := ""
|
|
if len(j.Config) > 0 {
|
|
var flat repository.ProvisioningTemplate
|
|
if err := json.Unmarshal(j.Config, &flat); err == nil {
|
|
cfg = toTemplateDTO(&flat).Config
|
|
// The snapshot carries the template name for template-launched jobs
|
|
// (custom jobs snapshot under the placeholder name "custom").
|
|
if j.TemplateID != nil {
|
|
templateName = flat.Name
|
|
}
|
|
}
|
|
}
|
|
return provJobDTO{
|
|
ID: j.ID,
|
|
State: string(j.State),
|
|
TriggeredBy: j.TriggeredBy,
|
|
Provider: j.Provider,
|
|
TemplateID: j.TemplateID,
|
|
TemplateName: templateName,
|
|
Config: cfg,
|
|
Progress: []provJobStepDTO{},
|
|
Timeline: []provJobTimelineDTO{},
|
|
CreatedWorkerIDs: j.WorkerIDs,
|
|
LastError: j.Error,
|
|
CompletedAt: j.CompletedAt,
|
|
CreatedAt: j.CreatedAt,
|
|
UpdatedAt: j.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func (h *Handler) AdminListProvisioningJobs(c *gin.Context) {
|
|
if h.ProvisioningJobRepo == nil {
|
|
c.JSON(http.StatusOK, gin.H{"data": []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
|
|
}
|
|
out := make([]provJobDTO, 0, len(rows))
|
|
for i := range rows {
|
|
out = append(out, toJobDTO(&rows[i]))
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": out})
|
|
}
|
|
|
|
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, toJobDTO(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 flat template into the job's config column. Its field
|
|
// names line up with provisioning.JobConfig, so the state machine reads
|
|
// it directly.
|
|
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
|
|
}
|
|
// The UI sends the nested config shape (worker_tier, {key,value} label
|
|
// rows). Normalize it through the same mapping templates use so the
|
|
// snapshot matches provisioning.JobConfig.
|
|
var cfgDTO provTemplateConfigDTO
|
|
if err := json.Unmarshal(req.Custom, &cfgDTO); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid custom config: " + err.Error()})
|
|
return
|
|
}
|
|
if cfgDTO.Provider == "" || cfgDTO.Location == "" || cfgDTO.ServerType == "" || cfgDTO.WorkerTier == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "custom config requires provider, location, server_type and worker_tier"})
|
|
return
|
|
}
|
|
snap := fromTemplateDTO(&provTemplateDTO{Name: "custom", Config: cfgDTO})
|
|
b, _ := json.Marshal(snap)
|
|
config = b
|
|
provider = cfgDTO.Provider
|
|
}
|
|
|
|
if provider == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "provider missing from config"})
|
|
return
|
|
}
|
|
|
|
// Cloud-first gate: a provisioning job for a provider can only be created
|
|
// once a cloud credential for that provider exists — otherwise the job
|
|
// would sit in 'pending' forever with no way to reach the provider API.
|
|
var credentialID *uuid.UUID
|
|
if h.CloudCredentialRepo != nil {
|
|
cred, err := h.CloudCredentialRepo.GetByProvider(c.Request.Context(), provider)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if cred == nil {
|
|
c.JSON(http.StatusConflict, gin.H{
|
|
"error": "no cloud provider connected for " + provider + " — add one under Settings → Cloud Providers first",
|
|
"code": "cloud_provider_required",
|
|
})
|
|
return
|
|
}
|
|
credentialID = &cred.ID
|
|
}
|
|
|
|
triggeredBy := req.TriggeredBy
|
|
if triggeredBy == "" {
|
|
triggeredBy = "admin"
|
|
}
|
|
|
|
job := &repository.ProvisioningJob{
|
|
State: models.ProvJobPending,
|
|
TriggeredBy: triggeredBy,
|
|
Provider: provider,
|
|
CredentialID: credentialID,
|
|
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: the provisioning runner
|
|
// (internal/app/provisioning Runner) processes jobs in state !=
|
|
// completed/failed. The admin UI polls GET /admin/provisioning-jobs/:id
|
|
// for live status.
|
|
|
|
c.JSON(http.StatusAccepted, toJobDTO(job))
|
|
}
|
|
|
|
// AdminRetryProvisioningJob resets a failed job back to pending so the runner
|
|
// re-attempts it.
|
|
func (h *Handler) AdminRetryProvisioningJob(c *gin.Context) {
|
|
if h.ProvisioningJobRepo == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "provisioning jobs repo not configured"})
|
|
return
|
|
}
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
return
|
|
}
|
|
job, err := h.ProvisioningJobRepo.Get(c.Request.Context(), id)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if job == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "job not found"})
|
|
return
|
|
}
|
|
if job.State != models.ProvJobFailed {
|
|
c.JSON(http.StatusConflict, gin.H{
|
|
"error": "only failed jobs can be retried",
|
|
"code": "job_not_retryable",
|
|
})
|
|
return
|
|
}
|
|
if err := h.ProvisioningJobRepo.Retry(c.Request.Context(), id); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
job, err = h.ProvisioningJobRepo.Get(c.Request.Context(), id)
|
|
if err != nil || job == nil {
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, toJobDTO(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)
|
|
}
|