From b5bbf28c63e26a2fdc6683dae9adb546240b9a60 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 15 Sep 2026 09:31:51 +0000 Subject: [PATCH] feat: patching REST API, patching scope, run IDs from apply-updates and MCP --- CLAUDE.md | 50 +- server/internal/api/docs/openapi.json | 1049 +++++++++++++++++++- server/internal/api/handlers.go | 36 +- server/internal/api/patching.go | 447 +++++++++ server/internal/api/patching_scope_test.go | 34 + server/internal/api/scopes.go | 17 + server/internal/api/serverscope.go | 23 + server/internal/api/types.go | 16 + server/internal/mcp/tools_write.go | 18 +- server/internal/services/patch_policies.go | 30 + server/internal/services/scopes.go | 5 +- 11 files changed, 1697 insertions(+), 28 deletions(-) create mode 100644 server/internal/api/patching.go create mode 100644 server/internal/api/patching_scope_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 67f9668..cccf68a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/`. diff --git a/server/internal/api/docs/openapi.json b/server/internal/api/docs/openapi.json index 52e3931..9a3d3ec 100644 --- a/server/internal/api/docs/openapi.json +++ b/server/internal/api/docs/openapi.json @@ -17,6 +17,17 @@ }, "type": "object" }, + "api.ApplyUpdatesResponse": { + "properties": { + "message": { + "type": "string" + }, + "run_id": { + "type": "string" + } + }, + "type": "object" + }, "api.AuditEventsResponse": { "properties": { "events": { @@ -598,6 +609,20 @@ }, "type": "object" }, + "api.WindowPreviewRequest": { + "properties": { + "cron": { + "type": "string" + }, + "duration_minutes": { + "type": "integer" + }, + "tz": { + "type": "string" + } + }, + "type": "object" + }, "api.WorkloadLogsResponse": { "properties": { "text": { @@ -1124,6 +1149,37 @@ }, "type": "object" }, + "models.MaintenanceWindow": { + "properties": { + "created_at": { + "type": "string" + }, + "cron": { + "description": "5-field, window start", + "type": "string" + }, + "duration_minutes": { + "type": "integer" + }, + "instance_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "tz": { + "description": "IANA name", + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "window_id": { + "type": "string" + } + }, + "type": "object" + }, "models.MemInfo": { "properties": { "total_bytes": { @@ -1342,6 +1398,166 @@ }, "type": "object" }, + "models.PatchPolicy": { + "properties": { + "created_at": { + "type": "string" + }, + "disabled_reason": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "instance_id": { + "type": "string" + }, + "last_run_at": { + "type": "string" + }, + "last_skipped": { + "$ref": "#/components/schemas/models.Skip" + }, + "max_concurrent": { + "description": "0 = no cap", + "type": "integer" + }, + "name": { + "type": "string" + }, + "next_run_at": { + "type": "string" + }, + "notify_channel_ids": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "policy_id": { + "type": "string" + }, + "reboot": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "target_server_ids": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "target_tags": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "updated_at": { + "type": "string" + }, + "window_id": { + "type": "string" + } + }, + "type": "object" + }, + "models.PatchRun": { + "properties": { + "cancelled_at": { + "type": "string" + }, + "finished_at": { + "type": "string" + }, + "instance_id": { + "type": "string" + }, + "max_concurrent": { + "type": "integer" + }, + "policy_id": { + "type": "string" + }, + "policy_name": { + "type": "string" + }, + "reboot": { + "type": "string" + }, + "run_id": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "servers": { + "items": { + "$ref": "#/components/schemas/models.PatchServerRun" + }, + "type": "array", + "uniqueItems": false + }, + "source": { + "type": "string" + }, + "started_at": { + "type": "string" + }, + "status": { + "type": "string" + }, + "triggered_by": { + "type": "string" + }, + "window_end": { + "type": "string" + } + }, + "type": "object" + }, + "models.PatchServerRun": { + "properties": { + "error": { + "type": "string" + }, + "finished_at": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "output": { + "type": "string" + }, + "pending_after": { + "type": "integer" + }, + "pending_before": { + "type": "integer" + }, + "rebooted_at": { + "type": "string" + }, + "server_id": { + "type": "string" + }, + "started_at": { + "type": "string" + }, + "status": { + "type": "string" + }, + "verified_at": { + "type": "string" + } + }, + "type": "object" + }, "models.ResolvedStep": { "properties": { "inputs": { @@ -2426,6 +2642,17 @@ }, "type": "object" }, + "services.WindowSpan": { + "properties": { + "end": { + "type": "string" + }, + "start": { + "type": "string" + } + }, + "type": "object" + }, "services.WorkloadHit": { "properties": { "server_id": { @@ -4396,6 +4623,342 @@ ] } }, + "/maintenance-windows": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/models.MaintenanceWindow" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "List maintenance windows", + "tags": [ + "patching" + ] + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/models.MaintenanceWindow", + "summary": "body", + "description": "Window" + } + ] + } + } + }, + "description": "Window", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.MaintenanceWindow" + } + } + }, + "description": "Created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Create a maintenance window", + "tags": [ + "patching" + ] + } + }, + "/maintenance-windows/preview": { + "post": { + "description": "Computed by the scheduler's own code, so the editor and the scheduler agree.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/api.WindowPreviewRequest", + "summary": "body", + "description": "Schedule" + } + ] + } + } + }, + "description": "Schedule", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/services.WindowSpan" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Preview the next three maintenance windows", + "tags": [ + "patching" + ] + } + }, + "/maintenance-windows/{id}": { + "delete": { + "parameters": [ + { + "description": "Window ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "window_in_use" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Delete a maintenance window", + "tags": [ + "patching" + ] + }, + "get": { + "parameters": [ + { + "description": "Window ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.MaintenanceWindow" + } + } + }, + "description": "OK" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Not Found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Get a maintenance window", + "tags": [ + "patching" + ] + }, + "put": { + "description": "Moves the next run of every enabled policy using it.", + "parameters": [ + { + "description": "Window ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/models.MaintenanceWindow", + "summary": "body", + "description": "Window" + } + ] + } + } + }, + "description": "Window", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.MaintenanceWindow" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Not Found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Update a maintenance window", + "tags": [ + "patching" + ] + } + }, "/monitors": { "get": { "responses": { @@ -5001,6 +5564,478 @@ ] } }, + "/patch-policies": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/models.PatchPolicy" + }, + "type": "array" + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "List patch policies", + "tags": [ + "patching" + ] + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/models.PatchPolicy", + "summary": "body", + "description": "Policy" + } + ] + } + } + }, + "description": "Policy", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.PatchPolicy" + } + } + }, + "description": "Created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Create a patch policy", + "tags": [ + "patching" + ] + } + }, + "/patch-policies/{id}": { + "delete": { + "parameters": [ + { + "description": "Policy ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Not Found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Delete a patch policy", + "tags": [ + "patching" + ] + }, + "get": { + "parameters": [ + { + "description": "Policy ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.PatchPolicy" + } + } + }, + "description": "OK" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Not Found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Get a patch policy", + "tags": [ + "patching" + ] + }, + "put": { + "parameters": [ + { + "description": "Policy ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/models.PatchPolicy", + "summary": "body", + "description": "Policy" + } + ] + } + } + }, + "description": "Policy", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.PatchPolicy" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Not Found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Update a patch policy", + "tags": [ + "patching" + ] + } + }, + "/patch-policies/{id}/run-now": { + "post": { + "description": "Opens a window of the policy's usual length starting now.", + "parameters": [ + { + "description": "Policy ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.PatchRun" + } + } + }, + "description": "Accepted" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Conflict" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Run a patch policy now", + "tags": [ + "patching" + ] + } + }, + "/patch-runs": { + "get": { + "parameters": [ + { + "description": "Filter by policy", + "in": "query", + "name": "policy_id", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by server", + "in": "query", + "name": "server_id", + "schema": { + "type": "string" + } + }, + { + "description": "At most 200, default 50", + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/models.PatchRun" + }, + "type": "array" + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "List patch runs", + "tags": [ + "patching" + ] + } + }, + "/patch-runs/{runId}": { + "get": { + "parameters": [ + { + "description": "Run ID", + "in": "path", + "name": "runId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.PatchRun" + } + } + }, + "description": "OK" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Not Found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Get a patch run, with per-server output", + "tags": [ + "patching" + ] + } + }, + "/patch-runs/{runId}/cancel": { + "post": { + "description": "Stops further dispatch. Servers already patching finish.", + "parameters": [ + { + "description": "Run ID", + "in": "path", + "name": "runId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Conflict" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Cancel a patch run", + "tags": [ + "patching" + ] + } + }, "/runs/{runId}": { "get": { "parameters": [ @@ -6123,7 +7158,7 @@ }, "/servers/{id}/apply-updates": { "post": { - "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.", "parameters": [ { "description": "Server ID", @@ -6133,6 +7168,14 @@ "schema": { "type": "string" } + }, + { + "description": "vulnerabilities when started from the vulnerabilities page", + "in": "query", + "name": "source", + "schema": { + "type": "string" + } } ], "responses": { @@ -6140,7 +7183,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/api.MessageResponse" + "$ref": "#/components/schemas/api.ApplyUpdatesResponse" } } }, @@ -6160,7 +7203,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/api.ErrorResponse" + "$ref": "#/components/schemas/api.ApplyUpdatesResponse" } } }, diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 2cd33c2..25884a7 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -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 diff --git a/server/internal/api/patching.go b/server/internal/api/patching.go new file mode 100644 index 0000000..38c7331 --- /dev/null +++ b/server/internal/api/patching.go @@ -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 +} diff --git a/server/internal/api/patching_scope_test.go b/server/internal/api/patching_scope_test.go new file mode 100644 index 0000000..570ac38 --- /dev/null +++ b/server/internal/api/patching_scope_test.go @@ -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) + } + } +} diff --git a/server/internal/api/scopes.go b/server/internal/api/scopes.go index 8a458c0..d0a939d 100644 --- a/server/internal/api/scopes.go +++ b/server/internal/api/scopes.go @@ -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 diff --git a/server/internal/api/serverscope.go b/server/internal/api/serverscope.go index ce13f07..b9236e5 100644 --- a/server/internal/api/serverscope.go +++ b/server/internal/api/serverscope.go @@ -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 diff --git a/server/internal/api/types.go b/server/internal/api/types.go index 6d2c7ca..c0d748f 100644 --- a/server/internal/api/types.go +++ b/server/internal/api/types.go @@ -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"` +} diff --git a/server/internal/mcp/tools_write.go b/server/internal/mcp/tools_write.go index 894a306..58c0c1f 100644 --- a/server/internal/mcp/tools_write.go +++ b/server/internal/mcp/tools_write.go @@ -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 diff --git a/server/internal/services/patch_policies.go b/server/internal/services/patch_policies.go index 241f1d5..c8ddd6f 100644 --- a/server/internal/services/patch_policies.go +++ b/server/internal/services/patch_policies.go @@ -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) +} diff --git a/server/internal/services/scopes.go b/server/internal/services/scopes.go index bd54131..3c5c220 100644 --- a/server/internal/services/scopes.go +++ b/server/internal/services/scopes.go @@ -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.