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
}
+19
View File
@@ -3,6 +3,7 @@ package services
import (
"context"
"fmt"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
@@ -20,6 +21,24 @@ import (
// agent reports its real error rather than being cut off by this side.
const workloadResultTimeout = 120 * time.Second
// MaxWorkloadLogLines mirrors the agent's own cap. It is declared again here
// rather than imported: agent/ is a separate module with an internal/ tree, so
// the two cannot share a constant. Change one, change the other — the same
// shape of hazard as the mirrored token blocks in the web apps.
const MaxWorkloadLogLines = 500
// workloadProtectedMarker is the text the agent's ErrProtected carries. The
// refusal crosses the wire as a string, so this is how the control plane knows
// a 409 is owed rather than a 502.
const workloadProtectedMarker = "workload is protected"
// IsWorkloadProtected reports whether an agent refused because the target is
// protected — the agent's own guard, which is the boundary. Nothing failed, so
// the API answers 409 rather than an error status.
func IsWorkloadProtected(err error) bool {
return err != nil && strings.Contains(err.Error(), workloadProtectedMarker)
}
func HasWorkloadHash(instanceID, serverID, hash string) (bool, error) {
err := db.Col("server_workloads").FindOne(context.Background(), bson.M{
"instance_id": instanceID,