feat: patching REST API, patching scope, run IDs from apply-updates and MCP
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user