feat: Annotate workflow, step, run and workload routes

Same treatment: named types replace gin.H literals, and every handler gets
a swaggo doc block. This is the last of the handler files under
server/internal/api/.
This commit is contained in:
2026-08-12 15:22:54 +00:00
parent 9b18d09d9b
commit a85a354e57
2 changed files with 357 additions and 15 deletions
+277 -10
View File
@@ -47,6 +47,20 @@ func registerWorkflowRoutes(g *gin.RouterGroup) {
var uuidLike = regexp.MustCompile(`^[a-zA-Z0-9-]{1,64}$`)
// getServerRunLog godoc
//
// @Summary Get a run's log for one server
// @Description Streams the stored log in pages rather than loading it whole; capped at 200k lines per server-run.
// @Tags workflows
// @Produce plain
// @Param runId path string true "Run ID"
// @Param serverId path string true "Server ID"
// @Success 200 {string} string "plain-text log"
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /runs/{runId}/servers/{serverId}/logs [get]
func getServerRunLog(c *gin.Context) {
runID, serverID := c.Param("runId"), c.Param("serverId")
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
@@ -83,6 +97,20 @@ func getServerRunLog(c *gin.Context) {
// is one or two queries, small enough that no single response buffers much.
const logPageSize = 2000
// streamServerRunLog godoc
//
// @Summary Stream a run's log for one server (SSE)
// @Description Server-sent events; sends new lines every 500ms until the server's run reaches a terminal state.
// @Tags workflows
// @Produce text/event-stream
// @Param runId path string true "Run ID"
// @Param serverId path string true "Server ID"
// @Success 200 {string} string "text/event-stream"
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /runs/{runId}/servers/{serverId}/logs/stream [get]
func streamServerRunLog(c *gin.Context) {
runID, serverID := c.Param("runId"), c.Param("serverId")
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
@@ -166,6 +194,16 @@ func splitSSE(b []byte) []string {
return strings.Split(s, "\n")
}
// listSteps godoc
//
// @Summary List workflow steps
// @Tags workflows
// @Produce json
// @Success 200 {array} models.WorkflowStep
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /steps [get]
func listSteps(c *gin.Context) {
steps, err := services.ListSteps(auth.InstanceID(c))
if err != nil {
@@ -175,6 +213,16 @@ func listSteps(c *gin.Context) {
c.JSON(http.StatusOK, steps)
}
// stepUsage godoc
//
// @Summary Count workflows using each step
// @Tags workflows
// @Produce json
// @Success 200 {object} map[string]int
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /steps/usage [get]
func stepUsage(c *gin.Context) {
counts, err := services.StepUsageCounts(auth.InstanceID(c))
if err != nil {
@@ -184,6 +232,19 @@ func stepUsage(c *gin.Context) {
c.JSON(http.StatusOK, counts)
}
// createStep godoc
//
// @Summary Create a workflow step
// @Tags workflows
// @Accept json
// @Produce json
// @Param body body models.WorkflowStep true "Step to create"
// @Success 201 {object} models.WorkflowStep
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /steps [post]
func createStep(c *gin.Context) {
var s models.WorkflowStep
if err := c.ShouldBindJSON(&s); err != nil {
@@ -199,6 +260,22 @@ func createStep(c *gin.Context) {
c.JSON(http.StatusCreated, out)
}
// updateStep godoc
//
// @Summary Update a workflow step
// @Description A step with source "default" is read-only and refuses with 409, because seeding rewrites it on every boot.
// @Tags workflows
// @Accept json
// @Produce json
// @Param id path string true "Step ID"
// @Param body body models.WorkflowStep true "Step fields"
// @Success 200 {object} UpdatedResponse
// @Failure 400 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /steps/{id} [put]
func updateStep(c *gin.Context) {
var s models.WorkflowStep
if err := c.ShouldBindJSON(&s); err != nil {
@@ -214,9 +291,22 @@ func updateStep(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated")
c.JSON(http.StatusOK, gin.H{"updated": true})
c.JSON(http.StatusOK, UpdatedResponse{Updated: true})
}
// deleteStep godoc
//
// @Summary Delete a workflow step
// @Description A step with source "default" is read-only and refuses with 409.
// @Tags workflows
// @Produce json
// @Param id path string true "Step ID"
// @Success 200 {object} DeletedResponse
// @Failure 409 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /steps/{id} [delete]
func deleteStep(c *gin.Context) {
if err := services.DeleteStep(auth.InstanceID(c), c.Param("id")); err != nil {
if errors.Is(err, services.ErrDefaultStep) {
@@ -227,9 +317,20 @@ func deleteStep(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted")
c.JSON(http.StatusOK, gin.H{"deleted": true})
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
}
// exportStep godoc
//
// @Summary Export a step as a downloadable JSON document
// @Tags workflows
// @Produce json
// @Param id path string true "Step ID"
// @Success 200 {object} models.WorkflowStep
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /steps/{id}/export [get]
func exportStep(c *gin.Context) {
b, err := services.ExportStep(auth.InstanceID(c), c.Param("id"))
if err != nil {
@@ -240,6 +341,16 @@ func exportStep(c *gin.Context) {
c.Data(http.StatusOK, "application/json", b)
}
// seedDefaults godoc
//
// @Summary Sync the default step library
// @Tags workflows
// @Produce json
// @Success 200 {object} SeedDefaultsResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /steps/seed-defaults [post]
func seedDefaults(c *gin.Context) {
created, updated, err := services.SeedDefaultSteps(auth.InstanceID(c))
if err != nil {
@@ -247,11 +358,23 @@ func seedDefaults(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated))
c.JSON(http.StatusOK, gin.H{"created": created, "updated": updated})
c.JSON(http.StatusOK, SeedDefaultsResponse{Created: created, Updated: updated})
}
const maxStepBodyBytes = 1 << 20
// importStep godoc
//
// @Summary Import a step from an exported JSON document
// @Tags workflows
// @Accept json
// @Produce json
// @Param body body models.WorkflowStep true "Exported step document"
// @Success 201 {object} models.WorkflowStep
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /steps/import [post]
func importStep(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
body, err := io.ReadAll(c.Request.Body)
@@ -268,6 +391,18 @@ func importStep(c *gin.Context) {
c.JSON(http.StatusCreated, out)
}
// parseStep godoc
//
// @Summary Parse a step document without saving it
// @Tags workflows
// @Accept json
// @Produce json
// @Param body body models.WorkflowStep true "Step document to parse"
// @Success 200 {object} models.WorkflowStep
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /steps/parse [post]
func parseStep(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
body, err := io.ReadAll(c.Request.Body)
@@ -283,6 +418,16 @@ func parseStep(c *gin.Context) {
c.JSON(http.StatusOK, s)
}
// listWorkflows godoc
//
// @Summary List workflows
// @Tags workflows
// @Produce json
// @Success 200 {array} models.Workflow
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workflows [get]
func listWorkflows(c *gin.Context) {
wfs, err := services.ListWorkflows(auth.InstanceID(c))
if err != nil {
@@ -292,6 +437,19 @@ func listWorkflows(c *gin.Context) {
c.JSON(http.StatusOK, wfs)
}
// createWorkflow godoc
//
// @Summary Create a workflow
// @Tags workflows
// @Accept json
// @Produce json
// @Param body body models.Workflow true "Workflow to create"
// @Success 201 {object} models.Workflow
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workflows [post]
func createWorkflow(c *gin.Context) {
var w models.Workflow
if err := c.ShouldBindJSON(&w); err != nil {
@@ -307,6 +465,17 @@ func createWorkflow(c *gin.Context) {
c.JSON(http.StatusCreated, out)
}
// getWorkflow godoc
//
// @Summary Get a workflow
// @Tags workflows
// @Produce json
// @Param id path string true "Workflow ID"
// @Success 200 {object} models.Workflow
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workflows/{id} [get]
func getWorkflow(c *gin.Context) {
w, err := services.GetWorkflow(auth.InstanceID(c), c.Param("id"))
if err != nil {
@@ -316,6 +485,20 @@ func getWorkflow(c *gin.Context) {
c.JSON(http.StatusOK, w)
}
// updateWorkflow godoc
//
// @Summary Update a workflow
// @Tags workflows
// @Accept json
// @Produce json
// @Param id path string true "Workflow ID"
// @Param body body models.Workflow true "Workflow fields"
// @Success 200 {object} models.Workflow
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workflows/{id} [put]
func updateWorkflow(c *gin.Context) {
var w models.Workflow
if err := c.ShouldBindJSON(&w); err != nil {
@@ -335,15 +518,39 @@ func updateWorkflow(c *gin.Context) {
c.JSON(http.StatusOK, updated)
}
// deleteWorkflow godoc
//
// @Summary Delete a workflow
// @Tags workflows
// @Produce json
// @Param id path string true "Workflow ID"
// @Success 200 {object} DeletedResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workflows/{id} [delete]
func deleteWorkflow(c *gin.Context) {
if err := services.DeleteWorkflow(auth.InstanceID(c), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted")
c.JSON(http.StatusOK, gin.H{"deleted": true})
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
}
// runWorkflow godoc
//
// @Summary Run a workflow
// @Description Snapshots the resolved steps into a WorkflowRun and dispatches to every targeted server.
// @Tags workflows
// @Produce json
// @Param id path string true "Workflow ID"
// @Success 202 {object} RunWorkflowResponse
// @Failure 400 {object} ErrorResponse
// @Failure 503 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workflows/{id}/run [post]
func runWorkflow(c *gin.Context) {
runID, err := services.TriggerWorkflow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c))
if err != nil {
@@ -355,9 +562,21 @@ func runWorkflow(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID))
c.JSON(http.StatusAccepted, gin.H{"run_id": runID})
c.JSON(http.StatusAccepted, RunWorkflowResponse{RunID: runID})
}
// listWorkflowRuns godoc
//
// @Summary List a workflow's runs
// @Tags workflows
// @Produce json
// @Param id path string true "Workflow ID"
// @Param limit query int false "Max runs to return (default 50)"
// @Success 200 {array} models.WorkflowRun
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workflows/{id}/runs [get]
func listWorkflowRuns(c *gin.Context) {
limit := int64(50)
if l := c.Query("limit"); l != "" {
@@ -373,6 +592,17 @@ func listWorkflowRuns(c *gin.Context) {
c.JSON(http.StatusOK, runs)
}
// getRun godoc
//
// @Summary Get a run
// @Tags workflows
// @Produce json
// @Param runId path string true "Run ID"
// @Success 200 {object} models.WorkflowRun
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /runs/{runId} [get]
func getRun(c *gin.Context) {
r, err := services.GetRun(auth.InstanceID(c), c.Param("runId"))
if err != nil {
@@ -382,15 +612,42 @@ func getRun(c *gin.Context) {
c.JSON(http.StatusOK, r)
}
// cancelRun godoc
//
// @Summary Cancel a run
// @Tags workflows
// @Produce json
// @Param runId path string true "Run ID"
// @Success 200 {object} CancelledResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /runs/{runId}/cancel [post]
func cancelRun(c *gin.Context) {
if err := services.CancelRun(auth.InstanceID(c), c.Param("runId")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled")
c.JSON(http.StatusOK, gin.H{"cancelled": true})
c.JSON(http.StatusOK, CancelledResponse{Cancelled: true})
}
// putWorkflowSchedule godoc
//
// @Summary Set a workflow's schedule
// @Description Standard 5-field cron and an IANA zone, both validated at save time.
// @Tags workflows
// @Accept json
// @Produce json
// @Param id path string true "Workflow ID"
// @Param body body models.Schedule true "Schedule"
// @Success 200 {object} ScheduleResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workflows/{id}/schedule [put]
func putWorkflowSchedule(c *gin.Context) {
var body models.Schedule
if err := c.ShouldBindJSON(&body); err != nil {
@@ -415,12 +672,22 @@ func putWorkflowSchedule(c *gin.Context) {
services.LogEvent(instanceID, "workflow.schedule_updated", actorFromCtx(c), "", c.Param("id"),
fmt.Sprintf("schedule %q %s enabled=%v", body.Cron, body.TZ, body.Enabled))
c.JSON(http.StatusOK, gin.H{"schedule": body, "next_run_at": next})
c.JSON(http.StatusOK, ScheduleResponse{Schedule: body, NextRunAt: next})
}
// previewWorkflowSchedule exists so the browser and the scheduler agree on
// what a cron string means. A client-side cron parser that disagrees with the
// server by one field is a bug found in production, at night.
// previewWorkflowSchedule godoc
//
// @Summary Preview the next occurrences of a cron schedule
// @Description Exists so the browser and the scheduler agree on what a cron string means.
// @Tags workflows
// @Produce json
// @Param cron query string true "5-field cron expression"
// @Param tz query string true "IANA time zone name"
// @Success 200 {object} OccurrencesResponse
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workflows/{id}/schedule/preview [get]
func previewWorkflowSchedule(c *gin.Context) {
expr := c.Query("cron")
tz := c.Query("tz")
+80 -5
View File
@@ -18,6 +18,19 @@ import (
//
// A server that has never reported answers an empty list rather than 404: the
// agent may simply not have got there yet, and 404 reads as "no such server".
// getServerWorkloads godoc
//
// @Summary Get a server's workload snapshot
// @Description Returns the stored snapshot. A server that has never reported answers an empty list, not 404.
// @Tags workloads
// @Produce json
// @Param id path string true "Server ID"
// @Success 200 {object} models.ServerWorkloads
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/{id}/workloads [get]
func getServerWorkloads(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
@@ -47,6 +60,19 @@ func getServerWorkloads(c *gin.Context) {
// refreshServerWorkloads nudges the agent to report now. It returns no data:
// the client refetches the stored document once the agent has written it.
// refreshServerWorkloads godoc
//
// @Summary Request a fresh workload report
// @Description Nudges the agent to report now. Returns no data; the client refetches once the agent has written it.
// @Tags workloads
// @Produce json
// @Param id path string true "Server ID"
// @Success 202 {object} MessageResponse
// @Failure 404 {object} ErrorResponse
// @Failure 503 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/{id}/workloads/refresh [post]
func refreshServerWorkloads(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
@@ -63,9 +89,28 @@ func refreshServerWorkloads(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusAccepted, gin.H{"message": "refresh requested"})
c.JSON(http.StatusAccepted, MessageResponse{Message: "refresh requested"})
}
// controlWorkload godoc
//
// @Summary Start, stop or restart a workload
// @Description Owner and admin only. The protected set (vantage-agent.service and the agent's own container) is enforced agent-side and answers 409, not an error.
// @Tags workloads
// @Accept json
// @Produce json
// @Param id path string true "Server ID"
// @Param wid path string true "Workload ID"
// @Param body body object{action=string,kind=string} true "Action (start/stop/restart) and kind (container/unit)"
// @Success 200 {object} MessageResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Failure 502 {object} ErrorResponse
// @Failure 503 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/{id}/workloads/{wid}/action [post]
func controlWorkload(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
@@ -117,9 +162,27 @@ func controlWorkload(c *gin.Context) {
services.LogEvent(instanceID, "workload."+body.Action, actorFromCtx(c), s.ServerID, "",
fmt.Sprintf("%s %s %s on %s", body.Action, body.Kind, wid, s.Hostname))
c.JSON(http.StatusOK, gin.H{"message": body.Action + " ok"})
c.JSON(http.StatusOK, MessageResponse{Message: body.Action + " ok"})
}
// getWorkloadLogs godoc
//
// @Summary Read a workload's logs
// @Description Owner and admin only, and audited: container output is arbitrary and cannot be masked. Capped at 500 lines and 256KB, whichever binds first.
// @Tags workloads
// @Produce json
// @Param id path string true "Server ID"
// @Param wid path string true "Workload ID"
// @Param kind query string false "container or unit (default container)"
// @Param tail query int false "Lines to return, clamped to the cap"
// @Success 200 {object} WorkloadLogsResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 502 {object} ErrorResponse
// @Failure 503 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/{id}/workloads/{wid}/logs [get]
func getWorkloadLogs(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
@@ -163,11 +226,23 @@ func getWorkloadLogs(c *gin.Context) {
services.LogEvent(instanceID, "workload.logs_read", actorFromCtx(c), s.ServerID, "",
fmt.Sprintf("read %s logs for %s on %s", kind, wid, s.Hostname))
c.JSON(http.StatusOK, gin.H{"text": text, "truncated": truncated})
c.JSON(http.StatusOK, WorkloadLogsResponse{Text: text, Truncated: truncated})
}
// listWorkloads answers the fleet-wide question, which is the reason the
// snapshot is stored rather than fetched on demand and discarded.
// listWorkloads godoc
//
// @Summary Search workloads fleet-wide
// @Description Answers the fleet-wide question, which is the reason the snapshot is stored rather than fetched on demand and discarded.
// @Tags workloads
// @Produce json
// @Param image query string false "Filter by image name"
// @Param stack query string false "Filter by compose stack"
// @Param state query string false "Filter by state"
// @Success 200 {array} services.WorkloadHit
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /workloads [get]
func listWorkloads(c *gin.Context) {
hits, err := services.SearchWorkloads(auth.InstanceID(c),
c.Query("image"), c.Query("stack"), c.Query("state"))