feat: workload registry REST API

This commit is contained in:
2026-08-07 09:01:46 +01:00
parent 1b351cfca4
commit fd4c51f3db
3 changed files with 224 additions and 0 deletions
+9
View File
@@ -132,6 +132,15 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.POST("/vuln-rules", auth.RequireRole("owner", "admin"), createVulnRule)
apiGroup.PUT("/vuln-rules/:id", auth.RequireRole("owner", "admin"), updateVulnRule)
apiGroup.DELETE("/vuln-rules/:id", auth.RequireRole("owner", "admin"), deleteVulnRule)
// Control actions and log reads are owner|admin: container output is
// arbitrary and cannot be masked, so a member who can see the fleet
// still cannot read its logs.
apiGroup.GET("/workloads", listWorkloads)
apiGroup.GET("/servers/:id/workloads", getServerWorkloads)
apiGroup.POST("/servers/:id/workloads/refresh", refreshServerWorkloads)
apiGroup.POST("/servers/:id/workloads/:wid/action", auth.RequireRole("owner", "admin"), controlWorkload)
apiGroup.GET("/servers/:id/workloads/:wid/logs", auth.RequireRole("owner", "admin"), getWorkloadLogs)
}
}
+196
View File
@@ -0,0 +1,196 @@
package api
import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// getServerWorkloads returns the stored snapshot.
//
// 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".
func getServerWorkloads(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
if _, err := services.GetServer(instanceID, id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
sw, err := services.GetWorkloads(instanceID, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if sw == nil {
c.JSON(http.StatusOK, models.ServerWorkloads{
ServerID: id,
Workloads: []models.Workload{},
})
return
}
if sw.Workloads == nil {
sw.Workloads = []models.Workload{}
}
c.JSON(http.StatusOK, sw)
}
// refreshServerWorkloads nudges the agent to report now. It returns no data:
// the client refetches the stored document once the agent has written it.
func refreshServerWorkloads(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
s, err := services.GetServer(instanceID, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if err := services.DispatchRefreshWorkloads(s.ServerID); err != nil {
// Not queued: a command whose owner died must fail loudly, so the
// client can show the stored snapshot as stale rather than pretend.
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusAccepted, gin.H{"message": "refresh requested"})
}
func controlWorkload(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
wid, ok := workloadIDParam(c)
if !ok {
return
}
var body struct {
Action string `json:"action"`
Kind string `json:"kind"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
switch body.Action {
case models.WorkloadStart, models.WorkloadStop, models.WorkloadRestart:
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "action must be start, stop or restart"})
return
}
if body.Kind != models.WorkloadContainer && body.Kind != models.WorkloadUnit {
c.JSON(http.StatusBadRequest, gin.H{"error": "kind must be container or unit"})
return
}
s, err := services.GetServer(instanceID, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
err = services.DispatchControlWorkload(s.ServerID, body.Kind, wid, body.Action)
if err != nil {
switch {
case errors.Is(err, services.ErrAgentNotConnected):
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
case services.IsWorkloadProtected(err):
// Nothing failed — the agent refused, which is the design. 409, not
// 500, and the reason is carried through.
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
return
}
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"})
}
func getWorkloadLogs(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
wid, ok := workloadIDParam(c)
if !ok {
return
}
kind := c.DefaultQuery("kind", models.WorkloadContainer)
if kind != models.WorkloadContainer && kind != models.WorkloadUnit {
c.JSON(http.StatusBadRequest, gin.H{"error": "kind must be container or unit"})
return
}
// Clamped rather than refused: a client asking for more than the cap gets
// the cap, which is what it would have got anyway.
tail, _ := strconv.Atoi(c.Query("tail"))
if tail <= 0 || tail > services.MaxWorkloadLogLines {
tail = services.MaxWorkloadLogLines
}
s, err := services.GetServer(instanceID, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
text, truncated, err := services.DispatchWorkloadLogs(s.ServerID, kind, wid, tail)
if err != nil {
if errors.Is(err, services.ErrAgentNotConnected) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
// Audited because container output is arbitrary and cannot be masked: a
// startup banner or a stack trace may carry credentials nobody declared.
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})
}
// listWorkloads answers the fleet-wide question, which is the reason the
// snapshot is stored rather than fetched on demand and discarded.
func listWorkloads(c *gin.Context) {
hits, err := services.SearchWorkloads(auth.InstanceID(c),
c.Query("image"), c.Query("stack"), c.Query("state"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, hits)
}
// workloadIDParam decodes :wid. Unit names carry dots and '@', so the client
// encodes it and this is where it comes back.
func workloadIDParam(c *gin.Context) (string, bool) {
raw := c.Param("wid")
decoded, err := url.PathUnescape(raw)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workload id"})
return "", false
}
decoded = strings.TrimSpace(decoded)
if decoded == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "workload id is required"})
return "", false
}
return decoded, true
}