feat: patching REST API, patching scope, run IDs from apply-updates and MCP

This commit is contained in:
2026-09-15 09:31:51 +00:00
parent 17c9f813fc
commit b5bbf28c63
11 changed files with 1697 additions and 28 deletions
+44 -6
View File
@@ -173,6 +173,37 @@ Skips are recorded and surfaced, not just logged: past the 1h grace window is
`missed`, an active run is `already_running`, and a schedule that no longer
parses is disabled rather than left spinning the loop every 30 seconds forever.
### Scheduled patching
Three collections: `maintenance_windows` (cron start, IANA zone, duration),
`patch_policies` (selector, window, `all|security`, `never|if_required`,
concurrency cap, channels) and `patch_runs` (one per firing or manual Apply
updates, one `servers[]` entry per target). All three are in
`ScopedCollections`.
**Runs are driven by database state, not goroutines.** A run can last hours; a
goroutine-driven run is stranded at `running` when its pod dies. `patchsched`
ticks every 30s inside the housekeeping leader: it claims due policies with the
workflowsched `next_run_at` pattern, then advances every running run. Every
decision is a pure function in `internal/patchrun` (`Advance`, `ApplyResult`,
`VerifyReboot`, `Finalize`) and every write is guarded by the server run's
current status, so a result landing mid-tick is never overwritten.
**Results do not cross the bus.** The pod holding the agent's stream writes
`PatchResult` straight into the run, found by `servers.command_id` and the
agent's own server ID. A reboot is settled by the first static inventory report
whose `boot_time_unix` is later than `rebooted_at`; a report during the
one-minute grace does not count.
**Old agents must never receive a scope.** An agent before
`patchrun.MinAgentVersion` ignores `scope` and installs everything, so policy
runs mark it `agent_too_old` and do not dispatch. A manual Apply updates still
sends such an agent the empty command and records "no result reported".
The next window starts after `max(now, windowEnd)`, so windows never overlap,
including across a daylight-saving fall-back. `patchsched` must not import
`services`; its dependencies are injected from `main.go`.
### Server tags and workflow targeting
A server carries `tags map[string]string` - lowercase `[a-z0-9_-]`, key ≤32,
@@ -337,10 +368,12 @@ need a PowerShell Gallery install on every host and fails on an air-gapped
fleet. `CurrentVersion` is empty on Windows and `NewVersion` carries the KB
article ID: a Windows update is not a version bump of a named package.
**The agent never reboots a host.** `ApplyUpdatesCmd` installs and stops there;
`inventory.reboot_required` reports that one is owed, set on the static snapshot
every 15 minutes. Linux fills it too, from `/var/run/reboot-required` or
`dnf needs-restarting -r`.
**The agent reboots a host only when a patch command asks and a reboot is
owed.** `ApplyUpdatesCmd` carries `scope`, `reboot_if_required` and
`deadline_unix`; an empty command still means "everything, no reboot". The
agent answers with `PatchResult` and, when rebooting, sends it first and then
restarts after a one-minute grace. `inventory.reboot_required` is still set on
the static snapshot every 15 minutes and at agent start.
### Package inventory and CVE findings
@@ -887,7 +920,7 @@ service Vantage {
}
```
`CommandStream` is the only streaming RPC: the agent authenticates once with `AgentReady`, then the server pushes `ServerCommand`s and the agent replies with `CommandResult`, `StepResult`, or `StepOutputChunk`.
`CommandStream` is the only streaming RPC: the agent authenticates once with `AgentReady`, then the server pushes `ServerCommand`s and the agent replies with `CommandResult`, `StepResult`, `StepOutputChunk`, or `PatchResult`. `AgentMessage` now also carries `PatchResult`, the answer to `ApplyUpdatesCmd`.
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`, `OpenProxyCmd`, `PingCmd`, `RefreshWorkloadsCmd`, `ControlWorkloadCmd`,
`WorkloadLogsCmd`.
@@ -955,6 +988,11 @@ status-pages GET,POST /status-pages · GET,PUT,DELETE /status-pages/:pageId (ow
GET,POST /status-pages/:pageId/incidents
PUT,DELETE /status-pages/:pageId/incidents/:incidentId
POST /status-pages/:pageId/incidents/:incidentId/updates
patching GET,POST /maintenance-windows · POST /maintenance-windows/preview
GET,PUT,DELETE /maintenance-windows/:id (writes: owner|admin)
GET,POST /patch-policies · GET,PUT,DELETE /patch-policies/:id
POST /patch-policies/:id/run-now (writes: owner|admin)
GET /patch-runs · GET /patch-runs/:runId · POST /patch-runs/:runId/cancel
audit GET /audit
agent GET /agent/latest-version
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
@@ -1015,7 +1053,7 @@ plane, each of which this codebase enforces:
## MongoDB Collections
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `status_pages` · `status_incidents` · `migrations`
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `status_pages` · `status_incidents` · `maintenance_windows` · `patch_policies` · `patch_runs` · `migrations`
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth - see `server/internal/models/`.
File diff suppressed because it is too large Load Diff
+24 -12
View File
@@ -187,6 +187,7 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.GET("/servers/:id/workloads/:wid/logs", auth.RequireRole("owner", "admin"), getWorkloadLogs)
registerStatusPageRoutes(apiGroup)
registerPatchingRoutes(apiGroup)
}
}
@@ -748,30 +749,41 @@ func updateAgent(c *gin.Context) {
// applyUpdates godoc
//
// @Summary Apply pending OS updates on a server
// @Description Dispatches ApplyUpdatesCmd. Exempt from the licence gate: security patching is never paywalled.
// @Description Starts a manual patch run (all updates, no reboot) and returns its ID. Exempt from the licence gate: security patching is never paywalled.
// @Tags servers
// @Produce json
// @Param id path string true "Server ID"
// @Success 202 {object} MessageResponse
// @Failure 404 {object} ErrorResponse
// @Failure 503 {object} ErrorResponse
// @Param id path string true "Server ID"
// @Param source query string false "vulnerabilities when started from the vulnerabilities page"
// @Success 202 {object} ApplyUpdatesResponse
// @Failure 404 {object} ErrorResponse
// @Failure 503 {object} ApplyUpdatesResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /servers/{id}/apply-updates [post]
func applyUpdates(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServerScoped(auth.InstanceID(c), id, auth.ServerScope(c))
instanceID := auth.InstanceID(c)
s, err := services.GetServerScoped(instanceID, c.Param("id"), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if err := services.DispatchApplyUpdates(s.ServerID); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
source := models.PatchSourceServer
if c.Query("source") == models.PatchSourceVulnerabilities {
source = models.PatchSourceVulnerabilities
}
run, err := services.StartManualRun(instanceID, s, actorFromCtx(c), source)
if errors.Is(err, services.ErrAgentOffline) {
// The attempt is still recorded, so it has a run ID to show.
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error(), "run_id": run.RunID})
return
}
services.LogEvent(auth.InstanceID(c), "updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname))
c.JSON(http.StatusAccepted, MessageResponse{Message: "apply updates command sent to agent"})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "updates.applied", actorFromCtx(c), s.ServerID, "",
fmt.Sprintf("package update run %s started on %s", run.RunID, s.Hostname))
c.JSON(http.StatusAccepted, ApplyUpdatesResponse{Message: "apply updates command sent to agent", RunID: run.RunID})
}
// handleUpdateScript serves a dynamically generated shell script that
+447
View File
@@ -0,0 +1,447 @@
package api
import (
"errors"
"fmt"
"net/http"
"strconv"
"time"
"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"
)
// registerPatchingRoutes mounts maintenance windows, patch policies and patch
// runs. Free on every tier: security patching is never paywalled, so no
// RequireFeature here. Writes that decide what reboots and when are owner or
// admin; watching and cancelling a run is open to every role.
func registerPatchingRoutes(g *gin.RouterGroup) {
admin := auth.RequireRole("owner", "admin")
g.GET("/maintenance-windows", listWindows)
g.POST("/maintenance-windows", admin, createWindow)
g.POST("/maintenance-windows/preview", previewWindow)
g.GET("/maintenance-windows/:id", getWindow)
g.PUT("/maintenance-windows/:id", admin, updateWindow)
g.DELETE("/maintenance-windows/:id", admin, deleteWindow)
g.GET("/patch-policies", listPolicies)
g.POST("/patch-policies", admin, createPolicy)
g.GET("/patch-policies/:id", getPolicy)
g.PUT("/patch-policies/:id", admin, updatePolicy)
g.DELETE("/patch-policies/:id", admin, deletePolicy)
g.POST("/patch-policies/:id/run-now", admin, runPolicyNow)
g.GET("/patch-runs", listPatchRuns)
g.GET("/patch-runs/:runId", getPatchRun)
g.POST("/patch-runs/:runId/cancel", cancelPatchRun)
}
// patchError maps every patching service error once.
func patchError(c *gin.Context, err error) {
switch {
case errors.Is(err, services.ErrWindowNotFound), errors.Is(err, services.ErrPolicyNotFound), errors.Is(err, services.ErrPatchRunNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrWindowInUse), errors.Is(err, services.ErrPatchRunActive), errors.Is(err, services.ErrPatchRunFinished):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrWindowInvalid), errors.Is(err, services.ErrPolicyInvalid),
errors.Is(err, services.ErrNoTargets), errors.Is(err, services.ErrInvalidTag),
errors.Is(err, services.ErrWorkflowTargetOutOfScope):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
}
// listWindows godoc
//
// @Summary List maintenance windows
// @Tags patching
// @Produce json
// @Success 200 {array} models.MaintenanceWindow
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /maintenance-windows [get]
func listWindows(c *gin.Context) {
ws, err := services.ListWindows(auth.InstanceID(c))
if err != nil {
patchError(c, err)
return
}
c.JSON(http.StatusOK, ws)
}
// createWindow godoc
//
// @Summary Create a maintenance window
// @Tags patching
// @Accept json
// @Produce json
// @Param body body models.MaintenanceWindow true "Window"
// @Success 201 {object} models.MaintenanceWindow
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /maintenance-windows [post]
func createWindow(c *gin.Context) {
var body models.MaintenanceWindow
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
w, err := services.CreateWindow(auth.InstanceID(c), body)
if err != nil {
patchError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "patch.window_created", actorFromCtx(c), "", "",
fmt.Sprintf("maintenance window %s: %s %s for %d minutes", w.Name, w.Cron, w.TZ, w.DurationMinutes))
c.JSON(http.StatusCreated, w)
}
// previewWindow godoc
//
// @Summary Preview the next three maintenance windows
// @Description Computed by the scheduler's own code, so the editor and the scheduler agree.
// @Tags patching
// @Accept json
// @Produce json
// @Param body body WindowPreviewRequest true "Schedule"
// @Success 200 {array} services.WindowSpan
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /maintenance-windows/preview [post]
func previewWindow(c *gin.Context) {
var body WindowPreviewRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
spans, err := services.PreviewWindow(body.Cron, body.TZ, body.DurationMinutes, time.Now(), 3)
if err != nil {
patchError(c, err)
return
}
c.JSON(http.StatusOK, spans)
}
// getWindow godoc
//
// @Summary Get a maintenance window
// @Tags patching
// @Produce json
// @Param id path string true "Window ID"
// @Success 200 {object} models.MaintenanceWindow
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /maintenance-windows/{id} [get]
func getWindow(c *gin.Context) {
w, err := services.GetWindow(auth.InstanceID(c), c.Param("id"))
if err != nil {
patchError(c, err)
return
}
c.JSON(http.StatusOK, w)
}
// updateWindow godoc
//
// @Summary Update a maintenance window
// @Description Moves the next run of every enabled policy using it.
// @Tags patching
// @Accept json
// @Produce json
// @Param id path string true "Window ID"
// @Param body body models.MaintenanceWindow true "Window"
// @Success 200 {object} models.MaintenanceWindow
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /maintenance-windows/{id} [put]
func updateWindow(c *gin.Context) {
var body models.MaintenanceWindow
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
w, err := services.UpdateWindow(auth.InstanceID(c), c.Param("id"), body)
if err != nil {
patchError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "patch.window_updated", actorFromCtx(c), "", "",
fmt.Sprintf("maintenance window %s: %s %s for %d minutes", w.Name, w.Cron, w.TZ, w.DurationMinutes))
c.JSON(http.StatusOK, w)
}
// deleteWindow godoc
//
// @Summary Delete a maintenance window
// @Tags patching
// @Param id path string true "Window ID"
// @Success 204
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse "window_in_use"
// @Security cookieAuth
// @Security bearerAuth
// @Router /maintenance-windows/{id} [delete]
func deleteWindow(c *gin.Context) {
if err := services.DeleteWindow(auth.InstanceID(c), c.Param("id")); err != nil {
patchError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "patch.window_deleted", actorFromCtx(c), "", "", "maintenance window "+c.Param("id")+" deleted")
c.Status(http.StatusNoContent)
}
// listPolicies godoc
//
// @Summary List patch policies
// @Tags patching
// @Produce json
// @Success 200 {array} models.PatchPolicy
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-policies [get]
func listPolicies(c *gin.Context) {
ps, err := services.ListPolicies(auth.InstanceID(c))
if err != nil {
patchError(c, err)
return
}
c.JSON(http.StatusOK, ps)
}
// createPolicy godoc
//
// @Summary Create a patch policy
// @Tags patching
// @Accept json
// @Produce json
// @Param body body models.PatchPolicy true "Policy"
// @Success 201 {object} models.PatchPolicy
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-policies [post]
func createPolicy(c *gin.Context) {
var body models.PatchPolicy
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
p, err := services.CreatePolicy(auth.InstanceID(c), body, auth.ServerScope(c))
if err != nil {
patchError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "patch.policy_created", actorFromCtx(c), "", "",
fmt.Sprintf("patch policy %s: scope %s, reboot %s, enabled %v", p.Name, p.Scope, p.Reboot, p.Enabled))
c.JSON(http.StatusCreated, p)
}
// getPolicy godoc
//
// @Summary Get a patch policy
// @Tags patching
// @Produce json
// @Param id path string true "Policy ID"
// @Success 200 {object} models.PatchPolicy
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-policies/{id} [get]
func getPolicy(c *gin.Context) {
p, err := services.GetPolicy(auth.InstanceID(c), c.Param("id"))
if err != nil {
patchError(c, err)
return
}
c.JSON(http.StatusOK, p)
}
// updatePolicy godoc
//
// @Summary Update a patch policy
// @Tags patching
// @Accept json
// @Produce json
// @Param id path string true "Policy ID"
// @Param body body models.PatchPolicy true "Policy"
// @Success 200 {object} models.PatchPolicy
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-policies/{id} [put]
func updatePolicy(c *gin.Context) {
var body models.PatchPolicy
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
p, err := services.UpdatePolicy(auth.InstanceID(c), c.Param("id"), body, auth.ServerScope(c))
if err != nil {
patchError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "patch.policy_updated", actorFromCtx(c), "", "",
fmt.Sprintf("patch policy %s: scope %s, reboot %s, enabled %v", p.Name, p.Scope, p.Reboot, p.Enabled))
c.JSON(http.StatusOK, p)
}
// deletePolicy godoc
//
// @Summary Delete a patch policy
// @Tags patching
// @Param id path string true "Policy ID"
// @Success 204
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-policies/{id} [delete]
func deletePolicy(c *gin.Context) {
instanceID := auth.InstanceID(c)
p, err := services.GetPolicy(instanceID, c.Param("id"))
if err != nil {
patchError(c, err)
return
}
if err := services.CheckPolicyScope(instanceID, *p, auth.ServerScope(c)); err != nil {
patchError(c, err)
return
}
if err := services.DeletePolicy(instanceID, p.PolicyID); err != nil {
patchError(c, err)
return
}
services.LogEvent(instanceID, "patch.policy_deleted", actorFromCtx(c), "", "", "patch policy "+p.Name+" deleted")
c.Status(http.StatusNoContent)
}
// runPolicyNow godoc
//
// @Summary Run a patch policy now
// @Description Opens a window of the policy's usual length starting now.
// @Tags patching
// @Produce json
// @Param id path string true "Policy ID"
// @Success 202 {object} models.PatchRun
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-policies/{id}/run-now [post]
func runPolicyNow(c *gin.Context) {
run, err := services.StartRunNow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c), auth.ServerScope(c))
if err != nil {
patchError(c, err)
return
}
c.JSON(http.StatusAccepted, run)
}
// listPatchRuns godoc
//
// @Summary List patch runs
// @Tags patching
// @Produce json
// @Param policy_id query string false "Filter by policy"
// @Param server_id query string false "Filter by server"
// @Param limit query int false "At most 200, default 50"
// @Success 200 {array} models.PatchRun
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-runs [get]
func listPatchRuns(c *gin.Context) {
instanceID := auth.InstanceID(c)
limit, _ := strconv.ParseInt(c.Query("limit"), 10, 64)
runs, err := services.ListPatchRuns(instanceID, c.Query("policy_id"), c.Query("server_id"), limit)
if err != nil {
patchError(c, err)
return
}
out := runs[:0]
for i := range runs {
before := len(runs[i].Servers)
if err := services.ScopePatchRun(instanceID, &runs[i], auth.ServerScope(c)); err != nil {
patchError(c, err)
return
}
if before == 0 || len(runs[i].Servers) > 0 {
out = append(out, runs[i])
}
}
c.JSON(http.StatusOK, out)
}
// getPatchRun godoc
//
// @Summary Get a patch run, with per-server output
// @Tags patching
// @Produce json
// @Param runId path string true "Run ID"
// @Success 200 {object} models.PatchRun
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-runs/{runId} [get]
func getPatchRun(c *gin.Context) {
run, ok := scopedRun(c)
if ok {
c.JSON(http.StatusOK, run)
}
}
// cancelPatchRun godoc
//
// @Summary Cancel a patch run
// @Description Stops further dispatch. Servers already patching finish.
// @Tags patching
// @Param runId path string true "Run ID"
// @Success 204
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /patch-runs/{runId}/cancel [post]
func cancelPatchRun(c *gin.Context) {
run, ok := scopedRun(c)
if !ok {
return
}
if err := services.CancelPatchRun(auth.InstanceID(c), run.RunID); err != nil {
patchError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "patch.cancelled", actorFromCtx(c), "", "", "patch run "+run.RunID+" cancelled")
c.Status(http.StatusNoContent)
}
// scopedRun loads a run for a caller. A tag-restricted token that cannot see
// every server in the run gets a 404, the same answer as a run that does not
// exist, rather than a partial record it could act on.
func scopedRun(c *gin.Context) (*models.PatchRun, bool) {
instanceID := auth.InstanceID(c)
run, err := services.GetPatchRun(instanceID, c.Param("runId"))
if err != nil {
patchError(c, err)
return nil, false
}
before := len(run.Servers)
if err := services.ScopePatchRun(instanceID, run, auth.ServerScope(c)); err != nil {
patchError(c, err)
return nil, false
}
if len(run.Servers) != before {
patchError(c, services.ErrPatchRunNotFound)
return nil, false
}
return run, true
}
@@ -0,0 +1,34 @@
package api
import "testing"
// Every patching route must carry a scope and a server-scope declaration, or
// boot fails. Asserting the exact scope here keeps a copy-paste of
// "patching:read" onto a write route from slipping through.
func TestPatchingRouteScopes(t *testing.T) {
want := map[string]string{
"GET /api/maintenance-windows": "patching:read",
"POST /api/maintenance-windows": "patching:write",
"POST /api/maintenance-windows/preview": "patching:read",
"GET /api/maintenance-windows/:id": "patching:read",
"PUT /api/maintenance-windows/:id": "patching:write",
"DELETE /api/maintenance-windows/:id": "patching:write",
"GET /api/patch-policies": "patching:read",
"POST /api/patch-policies": "patching:write",
"GET /api/patch-policies/:id": "patching:read",
"PUT /api/patch-policies/:id": "patching:write",
"DELETE /api/patch-policies/:id": "patching:write",
"POST /api/patch-policies/:id/run-now": "patching:write",
"GET /api/patch-runs": "patching:read",
"GET /api/patch-runs/:runId": "patching:read",
"POST /api/patch-runs/:runId/cancel": "patching:write",
}
for route, scope := range want {
if got := routeScopes[route]; got != scope {
t.Errorf("%s: scope %q, want %q", route, got, scope)
}
if _, ok := serverScopedRoutes[route]; !ok {
t.Errorf("%s: missing from serverScopedRoutes", route)
}
}
}
+17
View File
@@ -175,6 +175,23 @@ var routeScopes = map[string]string{
"PUT /api/status-pages/:pageId/incidents/:incidentId": "status:write",
"DELETE /api/status-pages/:pageId/incidents/:incidentId": "status:write",
"POST /api/status-pages/:pageId/incidents/:incidentId/updates": "status:write",
// Scheduled patching: maintenance windows, patch policies and patch runs.
"GET /api/maintenance-windows": "patching:read",
"POST /api/maintenance-windows": "patching:write",
"POST /api/maintenance-windows/preview": "patching:read",
"GET /api/maintenance-windows/:id": "patching:read",
"PUT /api/maintenance-windows/:id": "patching:write",
"DELETE /api/maintenance-windows/:id": "patching:write",
"GET /api/patch-policies": "patching:read",
"POST /api/patch-policies": "patching:write",
"GET /api/patch-policies/:id": "patching:read",
"PUT /api/patch-policies/:id": "patching:write",
"DELETE /api/patch-policies/:id": "patching:write",
"POST /api/patch-policies/:id/run-now": "patching:write",
"GET /api/patch-runs": "patching:read",
"GET /api/patch-runs/:runId": "patching:read",
"POST /api/patch-runs/:runId/cancel": "patching:write",
}
// RequireScopes enforces routeScopes for token-authenticated requests and does
+23
View File
@@ -398,6 +398,29 @@ var serverScopedRoutes = map[string]scopeDecl{
// Each tool touching server data applies the caller's selector itself.
"POST /api/mcp": exempt,
"GET /api/mcp": exempt,
// Maintenance windows are a cron expression, a zone and a duration. They
// name no server and return no server data.
"GET /api/maintenance-windows": exempt,
"POST /api/maintenance-windows": exempt,
"POST /api/maintenance-windows/preview": exempt,
"GET /api/maintenance-windows/:id": exempt,
"PUT /api/maintenance-windows/:id": exempt,
"DELETE /api/maintenance-windows/:id": exempt,
// Reading a policy returns its selector (server IDs and tag pairs) and no
// hostname, inventory or state, the same data a workflow's targets carry.
"GET /api/patch-policies": exempt,
"GET /api/patch-policies/:id": exempt,
// Writes and run-now act on the policy's targets, so each is refused when
// those targets reach outside the token's tag restriction.
"POST /api/patch-policies": scoped,
"PUT /api/patch-policies/:id": scoped,
"DELETE /api/patch-policies/:id": scoped,
"POST /api/patch-policies/:id/run-now": scoped,
// Runs name hostnames; ScopePatchRun removes servers the token cannot see.
"GET /api/patch-runs": scoped,
"GET /api/patch-runs/:runId": scoped,
"POST /api/patch-runs/:runId/cancel": scoped,
}
// AssertServerScopeMapComplete refuses to boot when any registered /api route
+16
View File
@@ -297,3 +297,19 @@ type StatusIncidentUpdateRequest struct {
Status string `json:"status" binding:"required"`
Body string `json:"body" binding:"required"`
}
// --- patching ---
// ApplyUpdatesResponse keeps the message existing scripts read and adds the
// run that records what happened.
type ApplyUpdatesResponse struct {
Message string `json:"message"`
RunID string `json:"run_id,omitempty"`
}
// WindowPreviewRequest is the body of POST /maintenance-windows/preview.
type WindowPreviewRequest struct {
Cron string `json:"cron"`
TZ string `json:"tz"`
DurationMinutes int `json:"duration_minutes"`
}
+13 -5
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
@@ -156,6 +157,7 @@ type updateBatchResult struct {
Servers int `json:"servers"`
Succeeded []string `json:"succeeded"`
Failed map[string]string `json:"failed,omitempty"`
RunIDs map[string]string `json:"run_ids,omitempty"` // server ID -> patch run ID
}
// apply_updates. The REST route (internal/api/handlers.go's applyUpdates) is
@@ -178,9 +180,9 @@ func init() {
Write: true,
Scope: "servers:write",
Description: "Apply pending OS package updates on real servers, selected by " +
"server_ids and/or tags. This installs packages on real machines right now and " +
"cannot be undone from here. A server may need a reboot afterward, which this " +
"tool does not do.",
"server_ids and/or tags. Starts one manual patch run per server. This installs " +
"packages on real machines right now and cannot be undone from here. A server " +
"may need a reboot afterward, which this tool does not do.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
ids := stringSliceArg(args, "server_ids")
targets, err := services.ResolveTargetsScoped(c.InstanceID, ids, tagArg(args), c.TokenScope)
@@ -192,12 +194,18 @@ func init() {
}
result := updateBatchResult{Servers: len(targets), Failed: map[string]string{}}
for _, srv := range targets {
if err := services.DispatchApplyUpdates(srv.ServerID); err != nil {
for i := range targets {
srv := targets[i]
run, err := services.StartManualRun(c.InstanceID, &srv, "mcp:"+c.TokenName, models.PatchSourceMCP)
if err != nil {
result.Failed[srv.ServerID] = err.Error()
continue
}
result.Succeeded = append(result.Succeeded, srv.ServerID)
if result.RunIDs == nil {
result.RunIDs = map[string]string{}
}
result.RunIDs[srv.ServerID] = run.RunID
}
if len(result.Failed) == 0 {
result.Failed = nil
@@ -191,3 +191,33 @@ func CountPolicyTargets(p models.PatchPolicy) (int, error) {
}
return len(servers), err
}
var ErrPatchRunActive = errors.New("a run of this policy is already in progress")
// CheckPolicyScope refuses a tag-restricted token acting on a policy whose
// targets reach outside its restriction, with the workflow rule unchanged.
func CheckPolicyScope(instanceID string, p models.PatchPolicy, tokenScope map[string]string) error {
return validateWorkflowTargetScope(instanceID, p.TargetServerIDs, p.TargetTags, tokenScope)
}
// StartRunNow opens a window of the policy's usual length starting now. It is
// how an operator tests a policy on a Tuesday afternoon.
func StartRunNow(instanceID, policyID, actor string, tokenScope map[string]string) (*models.PatchRun, error) {
p, err := GetPolicy(instanceID, policyID)
if err != nil {
return nil, err
}
if err := CheckPolicyScope(instanceID, *p, tokenScope); err != nil {
return nil, err
}
w, err := GetWindow(instanceID, p.WindowID)
if err != nil {
return nil, err
}
ctx, cancel := patchCtx()
defer cancel()
if db.Col("patch_runs").FindOne(ctx, bson.M{"instance_id": instanceID, "policy_id": policyID, "status": models.PatchRunRunning}).Err() == nil {
return nil, ErrPatchRunActive
}
return StartPolicyRun(*p, patchsched.WindowEnd(time.Now(), w.DurationMinutes), models.PatchSourceRunNow, actor)
}
+3 -2
View File
@@ -11,8 +11,8 @@ import (
// the vocabulary below.
var ErrInvalidScope = errors.New("invalid scope")
// ScopeResources is the whole vocabulary. Ten resources, each with :read and
// :write, and write implies read on the same resource.
// ScopeResources is the whole vocabulary. Eleven resources, each with :read
// and :write, and write implies read on the same resource.
//
// It is deliberately coarse. A scope per endpoint is a table nobody maintains,
// and a route added without an entry either fails closed and breaks, or
@@ -27,6 +27,7 @@ var ScopeResources = []string{
"workloads",
"settings",
"status",
"patching",
// mcp:read is permission to reach the MCP endpoint at all; mcp:write is
// permission for its write tools, which are not merely refused without it
// but omitted from tools/list entirely.