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 !repository.IsClientRequestableTier(t.Tier) { c.JSON(http.StatusBadRequest, gin.H{ "error": "dedicated tier templates cannot be created; dedicated workers are allocated automatically by the control plane", "code": "tier_not_allowed", }) 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 !repository.IsClientRequestableTier(t.Tier) { c.JSON(http.StatusBadRequest, gin.H{ "error": "templates cannot be set to the dedicated tier; dedicated workers are allocated automatically by the control plane", "code": "tier_not_allowed", }) 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 } if !repository.IsClientRequestableTier(t.Tier) { c.JSON(http.StatusBadRequest, gin.H{ "error": "this template targets the dedicated tier, which is allocated automatically by the control plane; pick a shared-tier template", "code": "tier_not_allowed", }) 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}) if !repository.IsClientRequestableTier(snap.Tier) { c.JSON(http.StatusBadRequest, gin.H{ "error": "dedicated tier cannot be provisioned directly; dedicated workers are allocated automatically by the control plane", "code": "tier_not_allowed", }) return } 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) }