feat: move jobs to action based

Signed-off-by: allanice001 <allanice001@gmail.com>
This commit is contained in:
allanice001
2025-12-26 00:30:46 +00:00
parent dd0cefc08a
commit dac28d3ea5
23 changed files with 2128 additions and 204 deletions

View File

@@ -22,5 +22,16 @@ func mountAdminRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs, authUser func(ht
archer.Post("/jobs/{id}/cancel", handlers.AdminCancelArcherJob(db))
archer.Get("/queues", handlers.AdminListArcherQueues(db))
})
admin.Route("/actions", func(action chi.Router) {
action.Use(authUser)
action.Use(httpmiddleware.RequirePlatformAdmin())
action.Get("/", handlers.ListActions(db))
action.Post("/", handlers.CreateAction(db))
action.Get("/{actionID}", handlers.GetAction(db))
action.Patch("/{actionID}", handlers.UpdateAction(db))
action.Delete("/{actionID}", handlers.DeleteAction(db))
})
})
}

View File

@@ -33,7 +33,7 @@ func mountAPIRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs) {
mountNodePoolRoutes(v1, db, authOrg)
mountDNSRoutes(v1, db, authOrg)
mountLoadBalancerRoutes(v1, db, authOrg)
mountClusterRoutes(v1, db, authOrg)
mountClusterRoutes(v1, db, jobs, authOrg)
})
})
}

View File

@@ -3,12 +3,13 @@ package api
import (
"net/http"
"github.com/glueops/autoglue/internal/bg"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountClusterRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
func mountClusterRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs, authOrg func(http.Handler) http.Handler) {
r.Route("/clusters", func(c chi.Router) {
c.Use(authOrg)
c.Get("/", handlers.ListClusters(db))
@@ -36,6 +37,10 @@ func mountClusterRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) ht
c.Delete("/{clusterID}/kubeconfig", handlers.ClearClusterKubeconfig(db))
c.Post("/{clusterID}/node-pools", handlers.AttachNodePool(db))
c.Delete("/{clusterID}/node-pools/{nodePoolID}", handlers.DeleteNodePool(db))
c.Delete("/{clusterID}/node-pools/{nodePoolID}", handlers.DetachNodePool(db))
c.Get("/{clusterID}/runs", handlers.ListClusterRuns(db))
c.Get("/{clusterID}/runs/{runID}", handlers.GetClusterRun(db))
c.Post("/{clusterID}/actions/{actionID}/runs", handlers.RunClusterAction(db, jobs))
})
}

View File

@@ -44,6 +44,9 @@ func NewRuntime() *Runtime {
&models.RecordSet{},
&models.LoadBalancer{},
&models.Cluster{},
&models.Action{},
&models.Cluster{},
&models.ClusterRun{},
)
if err != nil {

View File

@@ -107,27 +107,29 @@ func NewJobs(gdb *gorm.DB, dbUrl string) (*Jobs, error) {
archer.WithInstances(1),
archer.WithTimeout(2*time.Minute),
)
/*
c.Register(
"prepare_cluster",
ClusterPrepareWorker(gdb, jobs),
archer.WithInstances(1),
archer.WithTimeout(2*time.Minute),
)
c.Register(
"prepare_cluster",
ClusterPrepareWorker(gdb, jobs),
archer.WithInstances(1),
archer.WithTimeout(2*time.Minute),
)
c.Register(
"cluster_setup",
ClusterSetupWorker(gdb, jobs),
archer.WithInstances(1),
archer.WithTimeout(2*time.Minute),
)
c.Register(
"cluster_setup",
ClusterSetupWorker(gdb, jobs),
archer.WithInstances(1),
archer.WithTimeout(2*time.Minute),
)
c.Register(
"cluster_bootstrap",
ClusterBootstrapWorker(gdb, jobs),
archer.WithInstances(1),
archer.WithTimeout(60*time.Minute),
)
c.Register(
"cluster_bootstrap",
ClusterBootstrapWorker(gdb, jobs),
archer.WithInstances(1),
archer.WithTimeout(60*time.Minute),
)
*/
c.Register(
"org_key_sweeper",
@@ -135,6 +137,8 @@ func NewJobs(gdb *gorm.DB, dbUrl string) (*Jobs, error) {
archer.WithInstances(1),
archer.WithTimeout(5*time.Minute),
)
c.Register("cluster_action", ClusterActionWorker(gdb))
return jobs, nil
}

View File

@@ -0,0 +1,166 @@
package bg
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/dyaksa/archer"
"github.com/dyaksa/archer/job"
"github.com/glueops/autoglue/internal/mapper"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
type ClusterActionArgs struct {
OrgID uuid.UUID `json:"org_id"`
ClusterID uuid.UUID `json:"cluster_id"`
Action string `json:"action"`
MakeTarget string `json:"make_target"`
}
type ClusterActionResult struct {
Status string `json:"status"`
Action string `json:"action"`
ClusterID string `json:"cluster_id"`
ElapsedMs int `json:"elapsed_ms"`
}
func ClusterActionWorker(db *gorm.DB) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
start := time.Now()
var args ClusterActionArgs
_ = j.ParseArguments(&args)
logger := log.With().
Str("job", j.ID).
Str("cluster_id", args.ClusterID.String()).
Str("action", args.Action).
Logger()
var c models.Cluster
if err := db.
Preload("BastionServer.SshKey").
Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers.SshKey").
Where("id = ? AND organization_id = ?", args.ClusterID, args.OrgID).
First(&c).Error; err != nil {
return nil, fmt.Errorf("load cluster: %w", err)
}
// ---- Step 1: Prepare (mostly lifted from ClusterPrepareWorker)
if err := setClusterStatus(db, c.ID, clusterStatusBootstrapping, ""); err != nil {
return nil, fmt.Errorf("mark bootstrapping: %w", err)
}
c.Status = clusterStatusBootstrapping
if err := validateClusterForPrepare(&c); err != nil {
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
return nil, fmt.Errorf("validate: %w", err)
}
allServers := flattenClusterServers(&c)
keyPayloads, sshConfig, err := buildSSHAssetsForCluster(db, &c, allServers)
if err != nil {
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
return nil, fmt.Errorf("build ssh assets: %w", err)
}
dtoCluster := mapper.ClusterToDTO(c)
if c.EncryptedKubeconfig != "" && c.KubeIV != "" && c.KubeTag != "" {
kubeconfig, err := utils.DecryptForOrg(
c.OrganizationID,
c.EncryptedKubeconfig,
c.KubeIV,
c.KubeTag,
db,
)
if err != nil {
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
return nil, fmt.Errorf("decrypt kubeconfig: %w", err)
}
dtoCluster.Kubeconfig = &kubeconfig
}
orgKey, orgSecret, err := findOrCreateClusterAutomationKey(db, c.OrganizationID, c.ID, 24*time.Hour)
if err != nil {
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
return nil, fmt.Errorf("org key: %w", err)
}
dtoCluster.OrgKey = &orgKey
dtoCluster.OrgSecret = &orgSecret
payloadJSON, err := json.MarshalIndent(dtoCluster, "", " ")
if err != nil {
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
return nil, fmt.Errorf("marshal payload: %w", err)
}
{
runCtx, cancel := context.WithTimeout(ctx, 8*time.Minute)
err := pushAssetsToBastion(runCtx, db, &c, sshConfig, keyPayloads, payloadJSON)
cancel()
if err != nil {
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
return nil, fmt.Errorf("push assets: %w", err)
}
}
if err := setClusterStatus(db, c.ID, clusterStatusPending, ""); err != nil {
return nil, fmt.Errorf("mark pending: %w", err)
}
c.Status = clusterStatusPending
// ---- Step 2: Setup (ping-servers)
{
runCtx, cancel := context.WithTimeout(ctx, 30*time.Minute)
out, err := runMakeOnBastion(runCtx, db, &c, "ping-servers")
cancel()
if err != nil {
logger.Error().Err(err).Str("output", out).Msg("ping-servers failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make ping-servers: %v", err))
return nil, fmt.Errorf("ping-servers: %w", err)
}
}
if err := setClusterStatus(db, c.ID, clusterStatusProvisioning, ""); err != nil {
return nil, fmt.Errorf("mark provisioning: %w", err)
}
c.Status = clusterStatusProvisioning
// ---- Step 3: Bootstrap (parameterized target)
{
runCtx, cancel := context.WithTimeout(ctx, 60*time.Minute)
out, err := runMakeOnBastion(runCtx, db, &c, args.MakeTarget)
cancel()
if err != nil {
logger.Error().Err(err).Str("output", out).Msg("bootstrap target failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make %s: %v", args.MakeTarget, err))
return nil, fmt.Errorf("make %s: %w", args.MakeTarget, err)
}
}
if err := setClusterStatus(db, c.ID, clusterStatusReady, ""); err != nil {
return nil, fmt.Errorf("mark ready: %w", err)
}
return ClusterActionResult{
Status: "ok",
Action: args.Action,
ClusterID: c.ID.String(),
ElapsedMs: int(time.Since(start).Milliseconds()),
}, nil
}
}

View File

@@ -0,0 +1,256 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListActions godoc
//
// @ID ListActions
// @Summary List available actions
// @Description Returns all admin-configured actions.
// @Tags Actions
// @Produce json
// @Success 200 {array} dto.ActionResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 500 {string} string "db error"
// @Router /admin/actions [get]
// @Security BearerAuth
func ListActions(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var rows []models.Action
if err := db.Order("label ASC").Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.ActionResponse, 0, len(rows))
for _, a := range rows {
out = append(out, actionToDTO(a))
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetAction godoc
//
// @ID GetAction
// @Summary Get a single action by ID
// @Description Returns a single action.
// @Tags Actions
// @Produce json
// @Param actionID path string true "Action ID"
// @Success 200 {object} dto.ActionResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "db error"
// @Router /admin/actions/{actionID} [get]
// @Security BearerAuth
func GetAction(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
return
}
var row models.Action
if err := db.Where("id = ?", actionID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "action not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, actionToDTO(row))
}
}
// CreateAction godoc
//
// @ID CreateAction
// @Summary Create an action
// @Description Creates a new admin-configured action.
// @Tags Actions
// @Accept json
// @Produce json
// @Param body body dto.CreateActionRequest true "payload"
// @Success 201 {object} dto.ActionResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 500 {string} string "db error"
// @Router /admin/actions [post]
// @Security BearerAuth
func CreateAction(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var in dto.CreateActionRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
label := strings.TrimSpace(in.Label)
desc := strings.TrimSpace(in.Description)
target := strings.TrimSpace(in.MakeTarget)
if label == "" {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "label is required")
return
}
if desc == "" {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "description is required")
return
}
if target == "" {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "make_target is required")
return
}
row := models.Action{
Label: label,
Description: desc,
MakeTarget: target,
}
if err := db.Create(&row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusCreated, actionToDTO(row))
}
}
// UpdateAction godoc
//
// @ID UpdateAction
// @Summary Update an action
// @Description Updates an action. Only provided fields are modified.
// @Tags Actions
// @Accept json
// @Produce json
// @Param actionID path string true "Action ID"
// @Param body body dto.UpdateActionRequest true "payload"
// @Success 200 {object} dto.ActionResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "db error"
// @Router /admin/actions/{actionID} [patch]
// @Security BearerAuth
func UpdateAction(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
return
}
var in dto.UpdateActionRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
var row models.Action
if err := db.Where("id = ?", actionID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "action not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if in.Label != nil {
v := strings.TrimSpace(*in.Label)
if v == "" {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "label cannot be empty")
return
}
row.Label = v
}
if in.Description != nil {
v := strings.TrimSpace(*in.Description)
if v == "" {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "description cannot be empty")
return
}
row.Description = v
}
if in.MakeTarget != nil {
v := strings.TrimSpace(*in.MakeTarget)
if v == "" {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "make_target cannot be empty")
return
}
row.MakeTarget = v
}
if err := db.Save(&row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, actionToDTO(row))
}
}
// DeleteAction godoc
//
// @ID DeleteAction
// @Summary Delete an action
// @Description Deletes an action.
// @Tags Actions
// @Produce json
// @Param actionID path string true "Action ID"
// @Success 204 {string} string "deleted"
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "db error"
// @Router /admin/actions/{actionID} [delete]
// @Security BearerAuth
func DeleteAction(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
return
}
tx := db.Where("id = ?", actionID).Delete(&models.Action{})
if tx.Error != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if tx.RowsAffected == 0 {
utils.WriteError(w, http.StatusNotFound, "not_found", "action not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func actionToDTO(a models.Action) dto.ActionResponse {
return dto.ActionResponse{
ID: a.ID,
Label: a.Label,
Description: a.Description,
MakeTarget: a.MakeTarget,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
}
}

View File

@@ -0,0 +1,257 @@
package handlers
import (
"errors"
"net/http"
"time"
"github.com/dyaksa/archer"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/bg"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListClusterRuns godoc
//
// @ID ListClusterRuns
// @Summary List cluster runs (org scoped)
// @Description Returns runs for a cluster within the organization in X-Org-ID.
// @Tags ClusterRuns
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Success 200 {array} dto.ClusterRunResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/runs [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListClusterRuns(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
// Ensure cluster exists + org scoped
if err := db.Select("id").
Where("id = ? AND organization_id = ?", clusterID, orgID).
First(&models.Cluster{}).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var rows []models.ClusterRun
if err := db.
Where("organization_id = ? AND cluster_id = ?", orgID, clusterID).
Order("created_at DESC").
Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.ClusterRunResponse, 0, len(rows))
for _, cr := range rows {
out = append(out, clusterRunToDTO(cr))
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetClusterRun godoc
//
// @ID GetClusterRun
// @Summary Get a cluster run (org scoped)
// @Description Returns a single run for a cluster within the organization in X-Org-ID.
// @Tags ClusterRuns
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param runID path string true "Run ID"
// @Success 200 {object} dto.ClusterRunResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/runs/{runID} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetClusterRun(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
runID, err := uuid.Parse(chi.URLParam(r, "runID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_run_id", "invalid run id")
return
}
var row models.ClusterRun
if err := db.
Where("id = ? AND organization_id = ? AND cluster_id = ?", runID, orgID, clusterID).
First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "run not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterRunToDTO(row))
}
}
// RunClusterAction godoc
//
// @ID RunClusterAction
// @Summary Run an admin-configured action on a cluster (org scoped)
// @Description Creates a ClusterRun record for the cluster/action. Execution is handled asynchronously by workers.
// @Tags ClusterRuns
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param actionID path string true "Action ID"
// @Success 201 {object} dto.ClusterRunResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster or action not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/actions/{actionID}/runs [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func RunClusterAction(db *gorm.DB, jobs *bg.Jobs) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
return
}
// cluster must exist + org scoped
var cluster models.Cluster
if err := db.Select("id", "organization_id").
Where("id = ? AND organization_id = ?", clusterID, orgID).
First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
// action is global/admin-configured (not org scoped)
var action models.Action
if err := db.Where("id = ?", actionID).First(&action).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "action_not_found", "action not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
run := models.ClusterRun{
OrganizationID: orgID,
ClusterID: clusterID,
Action: action.MakeTarget, // this is what you actually execute
Status: models.ClusterRunStatusQueued,
Error: "",
FinishedAt: time.Time{},
}
if err := db.Create(&run).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
// Enqueue with run.ID as the job ID so the worker can look it up.
_, enqueueErr := jobs.Enqueue(
r.Context(),
run.ID.String(),
"cluster_action",
bg.ClusterActionWorker,
archer.WithMaxRetries(3),
)
if enqueueErr != nil {
_ = db.Model(&models.ClusterRun{}).
Where("id = ?", run.ID).
Updates(map[string]any{
"status": models.ClusterRunStatusFailed,
"error": "failed to enqueue job",
"finished_at": time.Now().UTC(),
}).Error
utils.WriteError(w, http.StatusInternalServerError, "job_error", "failed to enqueue cluster action")
return
}
utils.WriteJSON(w, http.StatusCreated, clusterRunToDTO(run))
}
}
func clusterRunToDTO(cr models.ClusterRun) dto.ClusterRunResponse {
var finished *time.Time
if !cr.FinishedAt.IsZero() {
t := cr.FinishedAt
finished = &t
}
return dto.ClusterRunResponse{
ID: cr.ID,
OrganizationID: cr.OrganizationID,
ClusterID: cr.ClusterID,
Action: cr.Action,
Status: cr.Status,
Error: cr.Error,
CreatedAt: cr.CreatedAt,
UpdatedAt: cr.UpdatedAt,
FinishedAt: finished,
}
}

View File

@@ -0,0 +1,28 @@
package dto
import (
"time"
"github.com/google/uuid"
)
type ActionResponse struct {
ID uuid.UUID `json:"id" format:"uuid"`
Label string `json:"label"`
Description string `json:"description"`
MakeTarget string `json:"make_target"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
}
type CreateActionRequest struct {
Label string `json:"label"`
Description string `json:"description"`
MakeTarget string `json:"make_target"`
}
type UpdateActionRequest struct {
Label *string `json:"label,omitempty"`
Description *string `json:"description,omitempty"`
MakeTarget *string `json:"make_target,omitempty"`
}

View File

@@ -0,0 +1,19 @@
package dto
import (
"time"
"github.com/google/uuid"
)
type ClusterRunResponse struct {
ID uuid.UUID `json:"id" format:"uuid"`
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
ClusterID uuid.UUID `json:"cluster_id" format:"uuid"`
Action string `json:"action"`
Status string `json:"status"`
Error string `json:"error"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
FinishedAt *time.Time `json:"finished_at,omitempty" format:"date-time"`
}

16
internal/models/action.go Normal file
View File

@@ -0,0 +1,16 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Action struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
Label string `gorm:"type:varchar(255);not null;uniqueIndex" json:"label"`
Description string `gorm:"type:text;not null" json:"description"`
MakeTarget string `gorm:"type:varchar(255);not null;uniqueIndex" json:"make_target"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
}

View File

@@ -0,0 +1,27 @@
package models
import (
"time"
"github.com/google/uuid"
)
const (
ClusterRunStatusQueued = "queued"
ClusterRunStatusRunning = "running"
ClusterRunStatusSuccess = "success"
ClusterRunStatusFailed = "failed"
ClusterRunStatusCanceled = "canceled"
)
type ClusterRun struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
OrganizationID uuid.UUID `json:"organization_id" gorm:"type:uuid;index"`
ClusterID uuid.UUID `json:"cluster_id" gorm:"type:uuid;index"`
Action string `json:"action" gorm:"type:text;not null"`
Status string `json:"status" gorm:"type:text;not null"`
Error string `json:"error" gorm:"type:text;not null"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
FinishedAt time.Time `json:"finished_at,omitempty" gorm:"type:timestamptz" format:"date-time"`
}