diff --git a/server/internal/api/workflows.go b/server/internal/api/workflows.go index 62bd5cc..4ea8edb 100644 --- a/server/internal/api/workflows.go +++ b/server/internal/api/workflows.go @@ -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") diff --git a/server/internal/api/workloads.go b/server/internal/api/workloads.go index fc158cd..8256cb7 100644 --- a/server/internal/api/workloads.go +++ b/server/internal/api/workloads.go @@ -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"))