diff --git a/docs/superpowers/plans/2026-09-14-scheduled-patching.md b/docs/superpowers/plans/2026-09-14-scheduled-patching.md new file mode 100644 index 0000000..3775f1a --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-scheduled-patching.md @@ -0,0 +1,5912 @@ +# Maintenance Windows and Scheduled Patching Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Operators define maintenance windows and patch policies; Vantage patches the targeted servers inside the window (all or security-only, optional reboot), records a per-server result for every patch it performs, and alerts when a run is not clean. + +**Architecture:** The agent's `ApplyUpdatesCmd` gains scope, reboot and deadline, and answers with a new `PatchResult` message. The control plane stores `maintenance_windows`, `patch_policies` and `patch_runs` in MongoDB. A `patchsched` loop under the housekeeping leader fires due policies and advances running runs from database state, using pure functions in `internal/patchrun` for every transition so they are testable without a database. + +**Tech Stack:** Go 1.26 (server, agent, shared), gin, MongoDB driver v2, robfig/cron v3 (parser only), Next.js 16 + React 18 + TanStack Query + Tailwind 3 (web), Docusaurus 3 (docs). + +**Spec:** `vantage-app/docs/superpowers/specs/2026-09-14-scheduled-patching-design.md` + +## Global Constraints + +- Never use the em dash character anywhere: code, comments, UI copy, docs, commit messages. Use ` - `, a comma or a colon. +- Four repositories, all under `/go-projects/vantage/`: `vantage-shared` (wire types), `vantage-agent`, `vantage-app` (server + web), `vantage-docs`. +- A wire change ships in this order: release `vantage-shared`, bump the pin in `vantage-app/server/go.mod`, bump the pin in `vantage-agent/go.mod`. +- New shared release: `v0.5.0`. Current pins: server `v0.3.3`, agent `v0.2.1`. +- First agent release with patch results: `1.4.0` (latest today is `agent/v1.3.5`). `servers.agent_version` is stored without a leading `v`. +- Scope values: `all`, `security`. Reboot values: `never`, `if_required`. +- Window duration: 15 to 720 minutes. Output tail cap: 64KB (`65536` bytes), newest bytes kept. +- Timeouts: no-result `deadline + 10m`; manual run `start + 2h + 10m`; reboot verification `RebootedAt + 20m`; agent refuses to reboot with under 5 minutes left; apt index refresh 5 minutes; agent upgrade default cap 2 hours. +- Scheduler tick: 30s, inside `bus.RunAsLeader("housekeeping", ...)`. `patchsched` must not import `services`. +- Missed rule: skip if `now >= windowEnd` or `now - due > 1h` (`workflowsched.GraceWindow`). The next occurrence is computed from `max(now, windowEnd)`. +- Security-only never falls back to `all`. apk and pacman always report `unsupported` for security-only. +- Patching is free on every tier: no `RequireFeature` on any patching route. +- Owner or admin for window and policy writes and run-now. Every role may view and cancel runs. +- Audit events all start with `patch.` except the existing `updates.applied`. +- Web: no hex colours in components, tokens only; state pills carry a text label, not colour alone. +- Tests in this codebase are pure-function tests with no database. Keep logic in pure functions; the Mongo layer stays thin. + +## File map + +`vantage-shared` +- Modify `grpc/pb/vantage.pb.go`: `ApplyUpdatesCmd` fields, `PatchResult`, `AgentMessage.PatchResult`, `InventoryReport.BootTimeUnix`, patch status constants. +- Modify `proto/vantage/v1/vantage.proto`: the same, as documentation. + +`vantage-agent` +- Create `internal/updates/tailbuf.go` (+ test): 64KB tail buffer. +- Create `internal/updates/aptsec.go` (+ test): pure apt security-source filter. +- Create `internal/updates/winscript.go` (+ test): build-tag-free Windows apply script builder. +- Modify `internal/updates/updates.go`, `updates_linux.go`, `updates_windows.go`, `updates_other.go`: `Apply(ApplyOptions)`, `ScheduleReboot()`. +- Create `internal/inventory/btime.go` (+ test); modify `collect_linux.go`, `collect_windows.go`. +- Create `internal/sync/patch.go` (+ test): `shouldReboot`, new `handleApplyUpdates`. +- Modify `internal/sync/sync.go`: dispatch wiring, pending-report helper. +- Modify `CLAUDE.md`. + +`vantage-app/server` +- Create `internal/models/patch.go`. +- Create `internal/patchrun/patchrun.go` (+ test): pure state machine. +- Create `internal/patchsched/decide.go` (+ test), `internal/patchsched/sched.go`. +- Create `internal/services/patch_windows.go`, `patch_policies.go`, `patch_runs.go`, `patch_indexes.go`, `patch_validate_test.go`. +- Modify `internal/services/migrate_instance.go` (ScopedCollections), `steplogs.go` (retention sweep), `dispatch.go`, `scopes.go`. +- Modify `internal/notify/dispatch.go` (patch event type). +- Modify `internal/grpc/server.go`. +- Create `internal/api/patching.go`; modify `handlers.go`, `scopes.go`, `serverscope.go`, `types.go`, `internal/mcp/tools_write.go`, `cmd/main.go`, `internal/api/docs/openapi.json` (regenerated). + +`vantage-app/web` +- Modify `lib/api.ts`, `lib/auditEvents.ts`, `components/Sidebar.tsx`, `components/servers/tabs/MaintenanceTab.tsx`, `app/(app)/servers/[id]/page.tsx`, `app/(app)/vulnerabilities/page.tsx`. +- Create `components/patching/status.ts`, `PolicyModal.tsx`, `WindowModal.tsx`, `PolicyList.tsx`, `WindowList.tsx`, `RunList.tsx`, `app/(app)/patching/page.tsx`, `app/(app)/patching/runs/[runId]/page.tsx`. + +`vantage-docs` +- Create `docs/vantage/patching.md`; modify `sidebars.ts`, `docs/vantage/servers.md`, `docs/vantage/vulnerabilities.md`, `docs/hq/licensing-and-entitlements.md`, `docs/reference/api-tokens.md`. + +--- + +## Phase A: wire contract (`vantage-shared`) + +### Task 1: Patch wire types, released as v0.5.0 + +**Files:** +- Modify: `/go-projects/vantage/vantage-shared/grpc/pb/vantage.pb.go` (`InventoryReport` ~line 115, `ApplyUpdatesCmd` ~line 164, `AgentMessage` ~line 234) +- Modify: `/go-projects/vantage/vantage-shared/proto/vantage/v1/vantage.proto` (`InventoryReport`, `ApplyUpdatesCmd` ~line 222, `AgentMessage` ~line 103) +- Test: `/go-projects/vantage/vantage-shared/grpc/pb/patch_test.go` + +**Interfaces:** +- Produces: `pb.ApplyUpdatesCmd{Scope string; RebootIfRequired bool; DeadlineUnix int64}`, `pb.PatchResult{CommandId, Status, Message, OutputTail string; PendingAfter int32; RebootRequired, Rebooting bool}`, `pb.AgentMessage.PatchResult *pb.PatchResult`, `pb.InventoryReport.BootTimeUnix int64`, constants `pb.PatchStatusOK = "ok"`, `pb.PatchStatusFailed = "failed"`, `pb.PatchStatusUnsupported = "unsupported"`, `pb.PatchStatusBusy = "busy"`, `pb.PatchScopeAll = "all"`, `pb.PatchScopeSecurity = "security"`. + +- [ ] **Step 1: Write the failing test** + +```go +package pb + +import ( + "encoding/json" + "testing" +) + +// An empty ApplyUpdatesCmd must stay an empty object on the wire, so an old +// agent and a new server, or a new agent and an old server, agree that it +// means "install everything, no reboot, no deadline". +func TestApplyUpdatesCmdEmptyIsEmptyObject(t *testing.T) { + b, err := json.Marshal(ApplyUpdatesCmd{}) + if err != nil { + t.Fatal(err) + } + if string(b) != "{}" { + t.Fatalf("got %s, want {}", b) + } +} + +func TestPatchResultRoundTrip(t *testing.T) { + in := AgentMessage{PatchResult: &PatchResult{ + CommandId: "c1", Status: PatchStatusOK, OutputTail: "done", + PendingAfter: 0, RebootRequired: true, Rebooting: true, + }} + b, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + var out AgentMessage + if err := json.Unmarshal(b, &out); err != nil { + t.Fatal(err) + } + if out.PatchResult == nil || *out.PatchResult != *in.PatchResult { + t.Fatalf("round trip lost data: %+v", out.PatchResult) + } +} + +func TestInventoryBootTimeOnWire(t *testing.T) { + b, _ := json.Marshal(InventoryReport{BootTimeUnix: 1757800000}) + var m map[string]any + _ = json.Unmarshal(b, &m) + if m["boot_time_unix"] != float64(1757800000) { + t.Fatalf("boot_time_unix missing: %s", b) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /go-projects/vantage/vantage-shared && go test ./grpc/pb/ -run 'Patch|ApplyUpdates|BootTime' -v` +Expected: FAIL to compile: `unknown field PatchResult`, `undefined: PatchStatusOK`, `unknown field BootTimeUnix`. + +- [ ] **Step 3: Implement the types** + +Replace `type ApplyUpdatesCmd struct{}` with: + +```go +// ApplyUpdatesCmd installs pending OS updates. The zero value means what the +// command always meant: every pending update, no reboot, no deadline. That is +// what keeps old servers and new agents, and new servers and old agents, +// compatible - but only in that direction for Scope: an agent that predates +// these fields installs everything even when asked for security only, which +// is why the control plane gates on agent version before sending a scope. +type ApplyUpdatesCmd struct { + Scope string `json:"scope,omitempty"` // "" or PatchScopeAll | PatchScopeSecurity + RebootIfRequired bool `json:"reboot_if_required,omitempty"` // reboot only if the OS reports one is owed + DeadlineUnix int64 `json:"deadline_unix,omitempty"` // 0 = none; the agent caps the upgrade at 2h +} + +const ( + PatchScopeAll = "all" + PatchScopeSecurity = "security" + + PatchStatusOK = "ok" + PatchStatusFailed = "failed" + PatchStatusUnsupported = "unsupported" + PatchStatusBusy = "busy" +) + +// PatchResult answers an ApplyUpdatesCmd. Rebooting is sent immediately before +// the agent restarts the host, so the control plane knows to wait for a +// post-boot inventory report rather than a second result. +type PatchResult struct { + CommandId string `json:"command_id"` + Status string `json:"status"` + Message string `json:"message,omitempty"` + OutputTail string `json:"output_tail,omitempty"` // at most 64KB, newest bytes + PendingAfter int32 `json:"pending_after"` // -1 when the post-apply check failed + RebootRequired bool `json:"reboot_required,omitempty"` + Rebooting bool `json:"rebooting,omitempty"` +} +``` + +Add to `AgentMessage`, after `WorkloadLogsResult`: + +```go + PatchResult *PatchResult `json:"patch_result,omitempty"` +``` + +Add to `InventoryReport`, after `RebootRequired`: + +```go + BootTimeUnix int64 `json:"boot_time_unix,omitempty"` // every report; proves a reboot happened +``` + +- [ ] **Step 4: Mirror in the proto documentation** + +In `vantage.proto`, replace the empty `message ApplyUpdatesCmd { }` with: + +```proto +message ApplyUpdatesCmd { + string scope = 1; // "" or "all" | "security" + bool reboot_if_required = 2; // reboot only if the OS reports one is owed + int64 deadline_unix = 3; // 0 = none; the agent caps the upgrade at 2h +} + +message PatchResult { + string command_id = 1; + string status = 2; // ok | failed | unsupported | busy + string message = 3; + string output_tail = 4; // at most 64KB, newest bytes kept + int32 pending_after = 5; // -1 when the post-apply check failed + bool reboot_required = 6; + bool rebooting = 7; // sent just before the agent reboots itself +} +``` + +In `AgentMessage`'s `oneof payload`, after `workload_logs_result = 7;` add `PatchResult patch_result = 8;`. + +In `InventoryReport`, replace the comment above `reboot_required = 10;` with +`// Set on static snapshots only. The agent reboots a host only when an ApplyUpdatesCmd asks it to and the OS reports a reboot is owed.` +and add after it `int64 boot_time_unix = 11;`. + +- [ ] **Step 5: Run tests** + +Run: `cd /go-projects/vantage/vantage-shared && go test ./... && go vet ./...` +Expected: PASS. + +- [ ] **Step 6: Commit and tag** + +```bash +cd /go-projects/vantage/vantage-shared +git add grpc/pb/vantage.pb.go grpc/pb/patch_test.go proto/vantage/v1/vantage.proto +git commit -m "feat: patch results on the wire - ApplyUpdatesCmd scope, reboot and deadline, PatchResult, inventory boot time" +git tag v0.5.0 +git push origin HEAD --tags +``` + +--- + +## Phase B: agent (`vantage-agent`) + +Work on a branch: `cd /go-projects/vantage/vantage-agent && git checkout -b feat/patch-results`. Nothing is tagged until Task 21. + +### Task 2: Bump the shared pin and add the output tail buffer + +The agent pins `vantage-shared v0.2.1`, well behind. Bumping it may surface compile errors from intervening shared changes; fix those in this task before adding anything. + +**Files:** +- Modify: `/go-projects/vantage/vantage-agent/go.mod`, `go.sum` +- Create: `/go-projects/vantage/vantage-agent/internal/updates/tailbuf.go` +- Test: `/go-projects/vantage/vantage-agent/internal/updates/tailbuf_test.go` + +**Interfaces:** +- Produces: `const outputTailMax = 64 << 10`; `func newTailBuffer(max int) *tailBuffer`; `(*tailBuffer).Write([]byte) (int, error)`; `(*tailBuffer).String() string`. + +- [ ] **Step 1: Bump the pin** + +Run: +```bash +cd /go-projects/vantage/vantage-agent +GOPRIVATE=gitea.hostxtra.co.uk/* go get gitea.hostxtra.co.uk/vantage/vantage-shared@v0.5.0 +go mod tidy +go build ./... && GOOS=windows go build ./... +``` +Expected: builds. If any shared API changed between v0.2.1 and v0.5.0, fix each compile error at its call site before continuing, and run `go test ./...` to confirm nothing regressed. + +- [ ] **Step 2: Write the failing test** + +```go +package updates + +import ( + "strings" + "testing" +) + +func TestTailBufferKeepsNewestBytes(t *testing.T) { + b := newTailBuffer(10) + _, _ = b.Write([]byte("0123456789")) + _, _ = b.Write([]byte("abcde")) + if got := b.String(); got != "56789abcde" { + t.Fatalf("got %q, want %q", got, "56789abcde") + } +} + +func TestTailBufferSingleWriteLargerThanMax(t *testing.T) { + b := newTailBuffer(4) + n, err := b.Write([]byte("abcdefgh")) + if err != nil || n != 8 { + t.Fatalf("Write must report the full length consumed, got %d, %v", n, err) + } + if got := b.String(); got != "efgh" { + t.Fatalf("got %q", got) + } +} + +func TestTailBufferDefaultCap(t *testing.T) { + b := newTailBuffer(outputTailMax) + _, _ = b.Write([]byte(strings.Repeat("x", outputTailMax+100))) + if len(b.String()) != outputTailMax { + t.Fatalf("len %d, want %d", len(b.String()), outputTailMax) + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cd /go-projects/vantage/vantage-agent && go test ./internal/updates/ -run TailBuffer -v` +Expected: FAIL: `undefined: newTailBuffer`. + +- [ ] **Step 4: Implement** + +```go +package updates + +import "sync" + +// outputTailMax bounds the package-manager output carried back in a +// PatchResult. The end of the output is where apt and dnf say what failed, so +// the newest bytes are the ones kept. +const outputTailMax = 64 << 10 + +// tailBuffer is an io.Writer that retains only the last max bytes written. +// Stdout and stderr are both pointed at one, so it is safe for concurrent use. +type tailBuffer struct { + mu sync.Mutex + max int + buf []byte +} + +func newTailBuffer(max int) *tailBuffer { return &tailBuffer{max: max} } + +func (t *tailBuffer) Write(p []byte) (int, error) { + t.mu.Lock() + defer t.mu.Unlock() + t.buf = append(t.buf, p...) + if over := len(t.buf) - t.max; over > 0 { + t.buf = append([]byte(nil), t.buf[over:]...) + } + return len(p), nil +} + +func (t *tailBuffer) String() string { + t.mu.Lock() + defer t.mu.Unlock() + return string(t.buf) +} +``` + +- [ ] **Step 5: Run tests** + +Run: `go test ./internal/updates/ -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add go.mod go.sum internal/updates/tailbuf.go internal/updates/tailbuf_test.go +git commit -m "feat: pin vantage-shared v0.5.0, add package-manager output tail buffer" +``` + +### Task 3: apt security-source filter + +**Files:** +- Create: `/go-projects/vantage/vantage-agent/internal/updates/aptsec.go` +- Test: `/go-projects/vantage/vantage-agent/internal/updates/aptsec_test.go` + +**Interfaces:** +- Produces: `func securitySources(files map[string]string) (list string, deb822 string, ok bool)`. Keys are file paths; a path ending `.sources` is parsed as deb822, anything else as one-line format. `list` is one-line entries, `deb822` is paragraphs, `ok` is false when neither holds a security suite. + +- [ ] **Step 1: Write the failing test** + +```go +package updates + +import ( + "strings" + "testing" +) + +func TestSecuritySourcesOneLine(t *testing.T) { + files := map[string]string{ + "/etc/apt/sources.list": `# comment +deb http://deb.debian.org/debian bookworm main +deb http://deb.debian.org/debian bookworm-updates main +deb http://security.debian.org/debian-security bookworm-security main contrib +deb [arch=amd64 signed-by=/usr/share/keyrings/x.gpg] http://archive.ubuntu.com/ubuntu jammy-security main +deb-src http://security.debian.org/debian-security bookworm-security main +`, + } + list, d822, ok := securitySources(files) + if !ok { + t.Fatal("ok = false, want true") + } + if d822 != "" { + t.Fatalf("deb822 = %q, want empty", d822) + } + want := "deb http://security.debian.org/debian-security bookworm-security main contrib\n" + + "deb [arch=amd64 signed-by=/usr/share/keyrings/x.gpg] http://archive.ubuntu.com/ubuntu jammy-security main\n" + if list != want { + t.Fatalf("list =\n%s\nwant\n%s", list, want) + } +} + +func TestSecuritySourcesDeb822(t *testing.T) { + files := map[string]string{ + "/etc/apt/sources.list.d/ubuntu.sources": `Types: deb +URIs: http://archive.ubuntu.com/ubuntu/ +Suites: noble noble-updates noble-backports +Components: main restricted +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg + +Types: deb +URIs: http://security.ubuntu.com/ubuntu/ +Suites: noble-security +Components: main restricted +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg +`, + } + list, d822, ok := securitySources(files) + if !ok || list != "" { + t.Fatalf("ok=%v list=%q", ok, list) + } + if !strings.Contains(d822, "Suites: noble-security") || strings.Contains(d822, "noble-updates") { + t.Fatalf("deb822 wrong:\n%s", d822) + } + if !strings.Contains(d822, "Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg") { + t.Fatalf("Signed-By must be kept verbatim:\n%s", d822) + } +} + +// A paragraph listing several suites keeps only the security ones. +func TestSecuritySourcesDeb822MixedSuites(t *testing.T) { + files := map[string]string{"/x.sources": "Types: deb\nURIs: http://a/\nSuites: noble noble-security\nComponents: main\n"} + _, d822, ok := securitySources(files) + if !ok || !strings.Contains(d822, "Suites: noble-security\n") || strings.Contains(d822, "Suites: noble noble") { + t.Fatalf("got ok=%v\n%s", ok, d822) + } +} + +func TestSecuritySourcesDisabledParagraphIgnored(t *testing.T) { + files := map[string]string{"/x.sources": "Types: deb\nURIs: http://a/\nSuites: noble-security\nComponents: main\nEnabled: no\n"} + if _, _, ok := securitySources(files); ok { + t.Fatal("a disabled paragraph must not count") + } +} + +func TestSecuritySourcesNone(t *testing.T) { + files := map[string]string{"/etc/apt/sources.list": "deb http://mirror/debian bookworm main\n"} + if _, _, ok := securitySources(files); ok { + t.Fatal("ok = true with no security suite, want false") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/updates/ -run SecuritySources -v` +Expected: FAIL: `undefined: securitySources`. + +- [ ] **Step 3: Implement** + +```go +package updates + +import ( + "sort" + "strings" +) + +// isSecuritySuite reports whether an apt suite carries security fixes. Debian +// 11+ and every supported Ubuntu name them "-security". +func isSecuritySuite(s string) bool { return strings.HasSuffix(s, "-security") } + +// securitySources reduces a host's apt source files to only the entries that +// point at a security suite, so an upgrade run against them installs security +// fixes and nothing else. +// +// It is a pure function of file contents so it is tested on any platform. The +// caller writes list to a *.list file and deb822 to a *.sources file in a +// temporary SourceParts directory: keeping deb822 paragraphs as deb822 means +// an inline Signed-By key block survives verbatim, which a conversion to +// one-line format could not carry. +// +// ok is false when no security suite exists at all. The caller must then +// report unsupported, never fall back to installing everything. +func securitySources(files map[string]string) (list string, deb822 string, ok bool) { + paths := make([]string, 0, len(files)) + for p := range files { + paths = append(paths, p) + } + sort.Strings(paths) // deterministic output + + var lb, db strings.Builder + for _, p := range paths { + if strings.HasSuffix(p, ".sources") { + db.WriteString(filterDeb822(files[p])) + } else { + lb.WriteString(filterOneLine(files[p])) + } + } + list, deb822 = lb.String(), db.String() + return list, deb822, list != "" || deb822 != "" +} + +func filterOneLine(content string) string { + var b strings.Builder + for _, raw := range strings.Split(content, "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Fields(line) + if len(fields) < 3 || fields[0] != "deb" { + continue + } + i := 1 + if strings.HasPrefix(fields[i], "[") { + // Options run until the token that closes the bracket. + for i < len(fields) && !strings.HasSuffix(fields[i], "]") { + i++ + } + i++ + } + // fields[i] is the URI, fields[i+1] the suite. + if i+1 < len(fields) && isSecuritySuite(fields[i+1]) { + b.WriteString(line) + b.WriteString("\n") + } + } + return b.String() +} + +func filterDeb822(content string) string { + var b strings.Builder + for _, para := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n\n") { + lines := strings.Split(strings.Trim(para, "\n"), "\n") + var out []string + isDeb, enabled, kept := false, true, false + for _, l := range lines { + key, val, found := strings.Cut(l, ":") + k := strings.ToLower(strings.TrimSpace(key)) + v := strings.TrimSpace(val) + switch { + case found && k == "types": + for _, t := range strings.Fields(v) { + if t == "deb" { + isDeb = true + } + } + case found && k == "enabled": + enabled = strings.ToLower(v) != "no" + case found && k == "suites": + var sec []string + for _, s := range strings.Fields(v) { + if isSecuritySuite(s) { + sec = append(sec, s) + } + } + if len(sec) == 0 { + continue // drop the line; the paragraph is dropped below + } + kept = true + l = "Suites: " + strings.Join(sec, " ") + } + out = append(out, l) + } + if isDeb && enabled && kept { + b.WriteString(strings.Join(out, "\n")) + b.WriteString("\n\n") + } + } + return b.String() +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/updates/ -run SecuritySources -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/updates/aptsec.go internal/updates/aptsec_test.go +git commit -m "feat: filter apt sources down to security suites for security-only patching" +``` + +### Task 4: `updates.Apply` with scope, deadline, busy guard and captured output (Linux) + +**Files:** +- Modify: `/go-projects/vantage/vantage-agent/internal/updates/updates.go` +- Modify: `/go-projects/vantage/vantage-agent/internal/updates/updates_linux.go` (replace `applyAll`, lines 47-70) +- Modify: `/go-projects/vantage/vantage-agent/internal/updates/updates_other.go` +- Test: `/go-projects/vantage/vantage-agent/internal/updates/apply_test.go` + +**Interfaces:** +- Consumes: `newTailBuffer`, `outputTailMax` (Task 2), `securitySources` (Task 3). +- Produces: `type ApplyOptions struct{ SecurityOnly bool; Deadline time.Time }`, `type Result struct{ Output string; Unsupported bool; Reason string }`, `var ErrBusy error`, `func Apply(ApplyOptions) (Result, error)`, `func ScheduleReboot() error`. Per-OS hooks: `func apply(securityOnly bool, deadline time.Time) (Result, error)`, `func scheduleReboot() error`. `ApplyAll` is removed. + +- [ ] **Step 1: Write the failing test** + +```go +package updates + +import ( + "errors" + "testing" + "time" +) + +// Two package managers running at once corrupt each other's locks. The second +// caller must be told, not queued. +func TestApplyRefusesWhileBusy(t *testing.T) { + applyMu.Lock() + defer applyMu.Unlock() + _, err := Apply(ApplyOptions{Deadline: time.Now().Add(time.Minute)}) + if !errors.Is(err, ErrBusy) { + t.Fatalf("err = %v, want ErrBusy", err) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/updates/ -run ApplyRefuses -v` +Expected: FAIL: `undefined: applyMu`, `undefined: Apply`. + +- [ ] **Step 3: Replace the public API in `updates.go`** + +Replace the `ApplyAll` declaration and its comment with: + +```go +// ApplyOptions selects what an Apply run installs and when it must stop. +type ApplyOptions struct { + // SecurityOnly installs security fixes only. A host with no security + // metadata reports Unsupported and installs nothing: it never falls back + // to installing everything. + SecurityOnly bool + // Deadline is when the upgrade must be finished, normally the end of the + // maintenance window. Zero means defaultApplyCap from now. + Deadline time.Time +} + +// Result is what one Apply run did. Output is the tail of the package +// manager's combined output, for the operator to read when something failed. +type Result struct { + Output string + Unsupported bool + Reason string // why Unsupported, in words for the run page +} + +// ErrBusy means another Apply is already running on this host. +var ErrBusy = errors.New("an update run is already in progress on this host") + +const defaultApplyCap = 2 * time.Hour + +var applyMu sync.Mutex + +// Apply installs pending updates. It never reboots: ScheduleReboot is a +// separate decision taken by the caller, and only when the command asked. +func Apply(opts ApplyOptions) (Result, error) { + if !applyMu.TryLock() { + return Result{}, ErrBusy + } + defer applyMu.Unlock() + deadline := opts.Deadline + if deadline.IsZero() { + deadline = time.Now().Add(defaultApplyCap) + } + return apply(opts.SecurityOnly, deadline) +} + +// ScheduleReboot restarts the host after a short grace period, so a result +// sent just before it has time to leave. +func ScheduleReboot() error { return scheduleReboot() } +``` + +Update the file's imports to `errors`, `sync`, `time`. Keep `PackageUpdate`, `CheckAvailable` and `RebootRequired` unchanged. + +- [ ] **Step 4: Replace `applyAll` in `updates_linux.go`** + +Delete `func applyAll() error { ... }` and add (adding `errors`, `fmt`, `io`, `path/filepath` to imports): + +```go +// aptRefreshTimeout bounds the index refresh alone. The upgrade itself runs +// until the caller's deadline: one shared five-minute limit used to kill large +// upgrades partway through. +const aptRefreshTimeout = 5 * time.Minute + +func run(ctx context.Context, out io.Writer, env []string, name string, args ...string) error { + fmt.Fprintf(out, "$ %s %s\n", name, strings.Join(args, " ")) + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdout, cmd.Stderr = out, out + cmd.Env = append(os.Environ(), env...) + return cmd.Run() +} + +func apply(securityOnly bool, deadline time.Time) (Result, error) { + out := newTailBuffer(outputTailMax) + ctx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + + var err error + switch pm := detectPM(); pm { + case "apt": + var res Result + res, err = applyApt(ctx, out, securityOnly) + if res.Unsupported { + res.Output = out.String() + return res, nil + } + case "dnf", "yum": + args := []string{"upgrade", "-y"} + if securityOnly { + args = append(args, "--security") + } + err = run(ctx, out, nil, pm, args...) + case "zypper": + if securityOnly { + err = run(ctx, out, nil, "zypper", "--non-interactive", "patch", "--category", "security") + } else { + err = run(ctx, out, nil, "zypper", "--non-interactive", "update") + } + // 102 and 103 mean "installed, and a reboot or restart is now needed". + // That is success; RebootRequired reports the rest. + var ee *exec.ExitError + if errors.As(err, &ee) && (ee.ExitCode() == 102 || ee.ExitCode() == 103) { + err = nil + } + case "pacman": + if securityOnly { + return Result{Unsupported: true, Reason: "pacman publishes no security metadata"}, nil + } + err = run(ctx, out, nil, "pacman", "-Syu", "--noconfirm") + case "apk": + if securityOnly { + return Result{Unsupported: true, Reason: "apk publishes no security metadata"}, nil + } + if err = run(ctx, out, nil, "apk", "update"); err == nil { + err = run(ctx, out, nil, "apk", "upgrade") + } + default: + return Result{Unsupported: true, Reason: "no supported package manager found"}, nil + } + if ctx.Err() == context.DeadlineExceeded { + err = fmt.Errorf("stopped at the end of the maintenance window: %w", err) + } + return Result{Output: out.String()}, err +} + +func applyApt(ctx context.Context, out io.Writer, securityOnly bool) (Result, error) { + env := []string{"DEBIAN_FRONTEND=noninteractive"} + var srcOpts []string + if securityOnly { + dir, res, err := writeSecuritySourceParts() + if err != nil || res.Unsupported { + return res, err + } + defer os.RemoveAll(dir) + srcOpts = []string{ + "-o", "Dir::Etc::SourceList=/dev/null", + "-o", "Dir::Etc::SourceParts=" + dir, + // Without this, an update against the reduced source set deletes + // every other list file and the next normal apt call sees nothing. + "-o", "APT::Get::List-Cleanup=0", + } + } + rctx, rcancel := context.WithTimeout(ctx, aptRefreshTimeout) + defer rcancel() + if err := run(rctx, out, env, "apt-get", append([]string{"update", "-q"}, srcOpts...)...); err != nil { + return Result{}, fmt.Errorf("apt-get update: %w", err) + } + args := []string{"upgrade", "-y", "-q", + "-o", "Dpkg::Options::=--force-confdef", + "-o", "Dpkg::Options::=--force-confold"} + return Result{}, run(ctx, out, env, "apt-get", append(args, srcOpts...)...) +} + +// writeSecuritySourceParts writes the security-only sources to a temporary +// directory for Dir::Etc::SourceParts. The caller removes the directory. +func writeSecuritySourceParts() (string, Result, error) { + files := map[string]string{} + paths := []string{"/etc/apt/sources.list"} + for _, pat := range []string{"/etc/apt/sources.list.d/*.list", "/etc/apt/sources.list.d/*.sources"} { + m, _ := filepath.Glob(pat) + paths = append(paths, m...) + } + for _, p := range paths { + if b, err := os.ReadFile(p); err == nil { + files[p] = string(b) + } + } + list, d822, ok := securitySources(files) + if !ok { + return "", Result{Unsupported: true, Reason: "no security suites found in apt sources"}, nil + } + dir, err := os.MkdirTemp("", "vantage-apt-security-") + if err != nil { + return "", Result{}, err + } + if list != "" { + if err := os.WriteFile(filepath.Join(dir, "security.list"), []byte(list), 0o644); err != nil { + os.RemoveAll(dir) + return "", Result{}, err + } + } + if d822 != "" { + if err := os.WriteFile(filepath.Join(dir, "security.sources"), []byte(d822), 0o644); err != nil { + os.RemoveAll(dir) + return "", Result{}, err + } + } + return dir, Result{}, nil +} + +// scheduleReboot gives the host one minute, so the PatchResult announcing the +// reboot is on the wire before the network goes down. +func scheduleReboot() error { + return exec.Command("shutdown", "-r", "+1", "Vantage patch policy").Run() +} +``` + +- [ ] **Step 5: Update `updates_other.go`** + +Replace its `applyAll` stub with: + +```go +func apply(securityOnly bool, deadline time.Time) (Result, error) { + return Result{Unsupported: true, Reason: "OS updates are not supported on this platform"}, nil +} + +func scheduleReboot() error { return errors.New("reboot is not supported on this platform") } +``` + +Add `errors` and `time` to its imports. + +- [ ] **Step 6: Run tests and build all platforms** + +Run: `go test ./internal/updates/ -v && go vet ./internal/updates/ && GOOS=darwin go build ./...` +Expected: tests PASS; the darwin build proves `updates_other.go` compiles. The Windows build will fail until Task 5 because `updates_windows.go` still defines `applyAll`; that is expected here. + +- [ ] **Step 7: Commit** + +```bash +git add internal/updates/ +git commit -m "feat: updates.Apply with security scope, window deadline, busy guard and captured output" +``` + +### Task 5: Windows security-only apply and reboot + +**Files:** +- Create: `/go-projects/vantage/vantage-agent/internal/updates/winscript.go` (no build tag, so it is tested on Linux) +- Test: `/go-projects/vantage/vantage-agent/internal/updates/winscript_test.go` +- Modify: `/go-projects/vantage/vantage-agent/internal/updates/updates_windows.go` + +**Interfaces:** +- Consumes: `Result` (Task 4). +- Produces: `func applyScriptFor(securityOnly bool) string`; Windows `apply` and `scheduleReboot`. + +- [ ] **Step 1: Write the failing test** + +```go +package updates + +import ( + "strings" + "testing" +) + +const ( + catSecurity = "0FA1201D-4330-4FA8-8AE9-B877473B6441" + catCritical = "E6CF1350-C01B-414D-A61F-263D14D133B4" +) + +func TestApplyScriptSecurityOnly(t *testing.T) { + s := applyScriptFor(true) + if !strings.HasPrefix(strings.TrimSpace(s), "$SecurityOnly = $true") { + t.Fatalf("script must open with the flag set:\n%s", s) + } + for _, id := range []string{catSecurity, catCritical} { + if !strings.Contains(s, id) { + t.Errorf("script missing category %s", id) + } + } +} + +func TestApplyScriptAll(t *testing.T) { + if !strings.HasPrefix(strings.TrimSpace(applyScriptFor(false)), "$SecurityOnly = $false") { + t.Fatal("script must open with the flag cleared") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/updates/ -run ApplyScript -v` +Expected: FAIL: `undefined: applyScriptFor`. + +- [ ] **Step 3: Implement `winscript.go`** + +Move the body of `applyScript` out of `updates_windows.go` into this file and add the category filter: + +```go +package updates + +// applyScriptFor builds the Windows Update install script. It lives in a file +// with no build tag so its content is tested on Linux: this module has no +// Windows CI. +// +// Security-only keeps updates in the Security Updates or Critical Updates +// classifications. Those two GUIDs are fixed by Microsoft and identical on +// every Windows Update and WSUS server. +func applyScriptFor(securityOnly bool) string { + flag := "$false" + if securityOnly { + flag = "$true" + } + return "$SecurityOnly = " + flag + "\n" + applyScriptBody +} + +const applyScriptBody = ` +$ErrorActionPreference = 'Stop' +$securityCats = @('0FA1201D-4330-4FA8-8AE9-B877473B6441', 'E6CF1350-C01B-414D-A61F-263D14D133B4') +$session = New-Object -ComObject Microsoft.Update.Session +$result = $session.CreateUpdateSearcher().Search("IsInstalled=0 and Type='Software' and IsHidden=0") + +$batch = New-Object -ComObject Microsoft.Update.UpdateColl +foreach ($u in $result.Updates) { + if ($u.InstallationBehavior.CanRequestUserInput) { continue } + if ($SecurityOnly) { + $isSec = $false + foreach ($c in $u.Categories) { if ($securityCats -contains $c.CategoryID.ToUpper()) { $isSec = $true } } + if (-not $isSec) { continue } + } + if (-not $u.EulaAccepted) { + try { $u.AcceptEula() } catch { continue } + } + Write-Output ('selected: ' + $u.Title) + $null = $batch.Add($u) +} + +if ($batch.Count -eq 0) { Write-Output 'nothing-to-install'; exit 0 } + +$downloader = $session.CreateUpdateDownloader() +$downloader.Updates = $batch +$null = $downloader.Download() + +$installer = $session.CreateUpdateInstaller() +$installer.Updates = $batch +$r = $installer.Install() + +Write-Output ('resultcode=' + $r.ResultCode) +# 2 = succeeded, 3 = succeeded with errors. Anything else failed, and this +# process must exit non-zero so the agent reports a failure rather than an ack. +if ($r.ResultCode -ne 2 -and $r.ResultCode -ne 3) { exit 1 } +exit 0 +` +``` + +- [ ] **Step 4: Update `updates_windows.go`** + +Delete the `applyScript` constant, the `applyTimeout` constant and `func applyAll()`. Add (imports: `os/exec`, `time`): + +```go +func apply(securityOnly bool, deadline time.Time) (Result, error) { + ctx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + + out, err := winexec.Run(ctx, applyScriptFor(securityOnly)) + tail := newTailBuffer(outputTailMax) + _, _ = tail.Write([]byte(out)) + if err != nil { + return Result{Output: tail.String()}, fmt.Errorf("windows update install: %w", err) + } + return Result{Output: tail.String()}, nil +} + +// scheduleReboot gives the host sixty seconds, so the PatchResult announcing +// the reboot is sent before the service stops. +func scheduleReboot() error { + return exec.Command("shutdown", "/r", "/t", "60", "/c", "Vantage patch policy").Run() +} +``` + +- [ ] **Step 5: Run tests and build Windows** + +Run: `go test ./internal/updates/ -v && GOOS=windows go build ./... && go build ./...` +Expected: PASS and both builds succeed. + +- [ ] **Step 6: Commit** + +```bash +git add internal/updates/ +git commit -m "feat: Windows security-only updates and agent-initiated reboot" +``` + +### Task 6: Report boot time in every inventory report + +**Files:** +- Create: `/go-projects/vantage/vantage-agent/internal/inventory/btime.go` +- Test: `/go-projects/vantage/vantage-agent/internal/inventory/btime_test.go` +- Modify: `/go-projects/vantage/vantage-agent/internal/inventory/collect_linux.go` (`collect`, line 14) +- Modify: `/go-projects/vantage/vantage-agent/internal/inventory/collect_windows.go` (`collect`, line 22) + +**Interfaces:** +- Consumes: `pb.InventoryReport.BootTimeUnix` (Task 1). +- Produces: `func parseBtime(stat string) int64`. + +- [ ] **Step 1: Write the failing test** + +```go +package inventory + +import "testing" + +func TestParseBtime(t *testing.T) { + stat := "cpu 1 2 3 4\ncpu0 1 2 3 4\nintr 1\nctxt 99\nbtime 1757800000\nprocesses 5\n" + if got := parseBtime(stat); got != 1757800000 { + t.Fatalf("got %d", got) + } +} + +func TestParseBtimeMissing(t *testing.T) { + if got := parseBtime("cpu 1 2 3\n"); got != 0 { + t.Fatalf("got %d, want 0", got) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/inventory/ -run Btime -v` +Expected: FAIL: `undefined: parseBtime`. + +- [ ] **Step 3: Implement** + +`btime.go`: + +```go +package inventory + +import ( + "strconv" + "strings" +) + +// parseBtime reads the boot time, in Unix seconds, from /proc/stat. The +// control plane compares it with the moment it asked for a reboot: a report +// is only proof the host restarted if its boot time is later. +func parseBtime(stat string) int64 { + for _, line := range strings.Split(stat, "\n") { + if rest, ok := strings.CutPrefix(line, "btime "); ok { + v, _ := strconv.ParseInt(strings.TrimSpace(rest), 10, 64) + return v + } + } + return 0 +} +``` + +In `collect_linux.go`, add as the first line of `collect`: + +```go + r.BootTimeUnix = parseBtime(readProc("/proc/stat")) +``` + +In `collect_windows.go`, add as the first line of `collect`: + +```go + // GetTickCount64 is milliseconds since boot and does not wrap. + r.BootTimeUnix = time.Now().Add(-time.Duration(windows.GetTickCount64()) * time.Millisecond).Unix() +``` + +- [ ] **Step 4: Run tests and builds** + +Run: `go test ./internal/inventory/ -v && GOOS=windows go build ./... && go build ./...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/inventory/ +git commit -m "feat: report boot time with every inventory report" +``` + +### Task 7: Answer ApplyUpdatesCmd with a PatchResult, and reboot when asked + +**Files:** +- Create: `/go-projects/vantage/vantage-agent/internal/sync/patch.go` +- Test: `/go-projects/vantage/vantage-agent/internal/sync/patch_test.go` +- Modify: `/go-projects/vantage/vantage-agent/internal/sync/sync.go` (dispatch at line ~343; delete old `handleApplyUpdates` at line ~483) +- Modify: `/go-projects/vantage/vantage-agent/CLAUDE.md` + +**Interfaces:** +- Consumes: `updates.Apply`, `updates.ErrBusy`, `updates.ScheduleReboot`, `updates.CheckAvailable`, `updates.RebootRequired` (Tasks 4-5); `pb.PatchResult`, `pb.PatchStatus*`, `pb.PatchScopeSecurity` (Task 1). +- Produces: `func shouldReboot(requested, owed bool, now, deadline time.Time) bool`; `func handleApplyUpdates(send func(*pb.AgentMessage) error, cfg *config.Config, cmd *pb.ServerCommand)`; `const minRebootLeeway = 5 * time.Minute`. + +- [ ] **Step 1: Write the failing test** + +```go +package sync + +import ( + "testing" + "time" +) + +func TestShouldReboot(t *testing.T) { + now := time.Date(2026, 9, 20, 2, 30, 0, 0, time.UTC) + cases := []struct { + name string + requested, owed bool + deadline time.Time + want bool + }{ + {"asked, owed, plenty of time", true, true, now.Add(time.Hour), true}, + {"not asked", false, true, now.Add(time.Hour), false}, + {"nothing owed", true, false, now.Add(time.Hour), false}, + {"exactly five minutes left", true, true, now.Add(5 * time.Minute), true}, + {"under five minutes left", true, true, now.Add(4*time.Minute + 59*time.Second), false}, + {"no deadline (manual run)", true, true, time.Time{}, false}, + } + for _, c := range cases { + if got := shouldReboot(c.requested, c.owed, now, c.deadline); got != c.want { + t.Errorf("%s: got %v, want %v", c.name, got, c.want) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/sync/ -run ShouldReboot -v` +Expected: FAIL: `undefined: shouldReboot`. + +- [ ] **Step 3: Implement `patch.go`** + +```go +package sync + +import ( + "errors" + "log" + "time" + + "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/config" + grpcclient "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/grpc" + "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/updates" + "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb" +) + +// minRebootLeeway is the least time that must remain in the window for the +// agent to start a reboot. A reboot that lands after the window closes is the +// outage the window existed to prevent. +const minRebootLeeway = 5 * time.Minute + +// shouldReboot is the whole reboot decision. The agent reboots a host only +// when the command asked, the OS reports a reboot is owed, and the window +// still has room for it. A command with no deadline is a manual run, which +// never reboots. +func shouldReboot(requested, owed bool, now, deadline time.Time) bool { + if !requested || !owed || deadline.IsZero() { + return false + } + return deadline.Sub(now) >= minRebootLeeway +} + +func handleApplyUpdates(send func(*pb.AgentMessage) error, cfg *config.Config, cmd *pb.ServerCommand) { + c := cmd.ApplyUpdates + var deadline time.Time + if c.DeadlineUnix > 0 { + deadline = time.Unix(c.DeadlineUnix, 0) + } + log.Printf("applying OS updates (cmd=%s scope=%q reboot=%v)", cmd.CommandId, c.Scope, c.RebootIfRequired) + + res, err := updates.Apply(updates.ApplyOptions{SecurityOnly: c.Scope == pb.PatchScopeSecurity, Deadline: deadline}) + pr := &pb.PatchResult{CommandId: cmd.CommandId, OutputTail: res.Output, PendingAfter: -1} + switch { + case errors.Is(err, updates.ErrBusy): + pr.Status, pr.Message = pb.PatchStatusBusy, err.Error() + case err != nil: + pr.Status, pr.Message = pb.PatchStatusFailed, err.Error() + case res.Unsupported: + pr.Status, pr.Message = pb.PatchStatusUnsupported, res.Reason + default: + pr.Status = pb.PatchStatusOK + } + + // Refresh the pending list whether the run succeeded or not, so the counts + // the operator sees are this host's real state. A busy refusal changed + // nothing, and the run in progress will report for itself. + if pr.Status != pb.PatchStatusBusy { + pr.PendingAfter = int32(reportPendingUpdates(cfg)) + } + pr.RebootRequired = updates.RebootRequired() + pr.Rebooting = pr.Status == pb.PatchStatusOK && shouldReboot(c.RebootIfRequired, pr.RebootRequired, time.Now(), deadline) + + if err := send(&pb.AgentMessage{ServerId: cfg.ServerID, AgentToken: cfg.AgentToken, PatchResult: pr}); err != nil { + log.Printf("send patch result (cmd=%s): %v", cmd.CommandId, err) + // A reboot nobody was told about looks like a crash. Without a + // delivered result, do not reboot. + return + } + log.Printf("patch result sent (cmd=%s status=%s pending_after=%d rebooting=%v)", cmd.CommandId, pr.Status, pr.PendingAfter, pr.Rebooting) + if pr.Rebooting { + if err := updates.ScheduleReboot(); err != nil { + log.Printf("schedule reboot (cmd=%s): %v", cmd.CommandId, err) + } + } +} + +// reportPendingUpdates re-checks pending updates and reports them, returning +// the count, or -1 if either step failed. +func reportPendingUpdates(cfg *config.Config) int { + pkgs, err := updates.CheckAvailable() + if err != nil { + log.Printf("post-apply update check: %v", err) + return -1 + } + list := make([]pb.PackageUpdate, len(pkgs)) + for i, p := range pkgs { + list[i] = pb.PackageUpdate{Name: p.Name, CurrentVersion: p.CurrentVersion, NewVersion: p.NewVersion} + } + client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) + if err != nil { + log.Printf("post-apply report dial: %v", err) + return len(pkgs) + } + defer client.Close() + if err := client.ReportUpdates(cfg.ServerID, cfg.AgentToken, list); err != nil { + log.Printf("post-apply ReportUpdates: %v", err) + } + return len(pkgs) +} +``` + +Check the import alias matches what `sync.go` already uses for the gRPC client (`grpcclient`) and the config package path; copy them from `sync.go`'s import block if they differ. + +- [ ] **Step 4: Rewire `sync.go`** + +Change the dispatch branch to pass `send`: + +```go + if cmd.ApplyUpdates != nil { + go handleApplyUpdates(send, cfg, cmd) + } +``` + +Delete the old `func handleApplyUpdates(cfg *config.Config, cmd *pb.ServerCommand)` further down the file. + +- [ ] **Step 5: Run tests and build** + +Run: `go test ./... && go vet ./... && GOOS=windows go build ./... && go build ./...` +Expected: PASS. + +- [ ] **Step 6: Update `CLAUDE.md`** + +In "What the agent will not do", replace the first bullet with: + +```markdown +- **It reboots a host only when told to and only when owed.** An + `ApplyUpdatesCmd` with `reboot_if_required` set, on a host whose OS reports a + reboot is owed, with at least 5 minutes left before `deadline_unix`, reboots + after a one-minute grace period (`shutdown -r +1`, `shutdown /r /t 60`), and + only once the `PatchResult` announcing it has been sent. Anything else + installs and stops there, and `inventory.reboot_required` reports what is + owed. +``` + +Add a section after "Platform split": + +```markdown +## Patching + +`updates.Apply` takes a scope and a deadline and answers with the tail of the +package manager's output. Security-only uses `--security` on dnf/yum, +`zypper patch --category security`, the Security and Critical classifications +on Windows, and for apt a temporary `SourceParts` directory holding only the +`-security` suites (with `APT::Get::List-Cleanup=0`, or the reduced update +deletes every other list file). apk and pacman have no security metadata and +report `unsupported`; security-only never falls back to installing +everything. One run at a time: a second command answers `busy`. +``` + +- [ ] **Step 7: Commit (do not tag yet; the release is Task 21)** + +```bash +git add internal/sync/ CLAUDE.md +git commit -m "feat: answer ApplyUpdatesCmd with a PatchResult and reboot when the command asks" +``` + +--- + +## Phase C: control plane (`vantage-app/server`) + +All paths below are relative to `/go-projects/vantage/vantage-app/server` unless absolute. Work on branch `feat/scheduled-patching`. + +### Task 8: Pin shared v0.5.0, patch models, scoped collections, indexes + +**Files:** +- Modify: `go.mod`, `go.sum` +- Create: `internal/models/patch.go` +- Create: `internal/services/patch_indexes.go` +- Modify: `internal/services/migrate_instance.go` (`ScopedCollections`, ends after `"status_incidents",`) +- Modify: `cmd/main.go` (index builders, after the `EnsureStatusPageIndexes` block ~line 178) +- Test: `internal/services/patch_scoped_test.go` + +**Interfaces:** +- Produces (package `models`): `MaintenanceWindow`, `PatchPolicy`, `PatchRun`, `PatchServerRun` exactly as below, and the constants `PatchScopeAll`, `PatchScopeSecurity`, `PatchRebootNever`, `PatchRebootIfRequired`, `PatchRun*` (run statuses), `PatchSrv*` (server statuses), `PatchSource*`. +- Produces (package `services`): `func EnsurePatchIndexes() error`. + +- [ ] **Step 1: Bump the pin** + +```bash +cd /go-projects/vantage/vantage-app/server +GOPRIVATE=gitea.hostxtra.co.uk/* go get gitea.hostxtra.co.uk/vantage/vantage-shared@v0.5.0 +go mod tidy && go build ./... && go test ./... +``` +Expected: builds and tests pass. The pin jumps from v0.3.3 over v0.4.0; fix any compile error at its call site before continuing. + +- [ ] **Step 2: Write the failing test** + +```go +package services + +import "testing" + +// A tenant-scoped collection missing from ScopedCollections outlives its +// instance when the instance is purged. +func TestPatchCollectionsAreScoped(t *testing.T) { + for _, name := range []string{"maintenance_windows", "patch_policies", "patch_runs"} { + found := false + for _, got := range ScopedCollections { + if got == name { + found = true + break + } + } + if !found { + t.Errorf("ScopedCollections is missing %q", name) + } + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `go test ./internal/services/ -run PatchCollectionsAreScoped -v` +Expected: FAIL: `ScopedCollections is missing "maintenance_windows"` (and the other two). + +- [ ] **Step 4: Add the collections** + +In `ScopedCollections`, after `"status_incidents",` add: + +```go + "maintenance_windows", + "patch_policies", + "patch_runs", +``` + +- [ ] **Step 5: Create `internal/models/patch.go`** + +```go +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +const ( + PatchScopeAll = "all" + PatchScopeSecurity = "security" + + PatchRebootNever = "never" + PatchRebootIfRequired = "if_required" + + PatchRunRunning = "running" + PatchRunSucceeded = "succeeded" + PatchRunPartial = "partial" + PatchRunFailed = "failed" + PatchRunCancelled = "cancelled" + + PatchSrvQueued = "queued" + PatchSrvWaitingOffline = "waiting_offline" + PatchSrvPatching = "patching" + PatchSrvRebooting = "rebooting" + PatchSrvSucceeded = "succeeded" + PatchSrvFailed = "failed" + PatchSrvUnsupported = "unsupported" + PatchSrvAgentTooOld = "agent_too_old" + PatchSrvMissedOffline = "missed_offline" + PatchSrvWindowClosed = "window_closed" + PatchSrvCancelled = "cancelled" + + PatchSourceSchedule = "schedule" + PatchSourceRunNow = "run_now" + PatchSourceServer = "server" + PatchSourceVulnerabilities = "vulnerabilities" + PatchSourceMCP = "mcp" +) + +// MaintenanceWindow answers "when" and nothing else. Policies reference it by +// ID, so one window can later serve alert muting and status page maintenance +// without a second definition of the same Sunday morning. +type MaintenanceWindow struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + InstanceID string `bson:"instance_id" json:"instance_id"` + WindowID string `bson:"window_id" json:"window_id"` + Name string `bson:"name" json:"name"` + Cron string `bson:"cron" json:"cron"` // 5-field, window start + TZ string `bson:"tz" json:"tz"` // IANA name + DurationMinutes int `bson:"duration_minutes" json:"duration_minutes"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +type PatchPolicy struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + InstanceID string `bson:"instance_id" json:"instance_id"` + PolicyID string `bson:"policy_id" json:"policy_id"` + Name string `bson:"name" json:"name"` + Enabled bool `bson:"enabled" json:"enabled"` + WindowID string `bson:"window_id" json:"window_id"` + TargetServerIDs []string `bson:"target_server_ids" json:"target_server_ids"` + TargetTags map[string]string `bson:"target_tags,omitempty" json:"target_tags,omitempty"` + Scope string `bson:"scope" json:"scope"` + Reboot string `bson:"reboot" json:"reboot"` + MaxConcurrent int `bson:"max_concurrent" json:"max_concurrent"` // 0 = no cap + NotifyChannelIDs []string `bson:"notify_channel_ids,omitempty" json:"notify_channel_ids,omitempty"` + NextRunAt *time.Time `bson:"next_run_at,omitempty" json:"next_run_at,omitempty"` + LastRunAt *time.Time `bson:"last_run_at,omitempty" json:"last_run_at,omitempty"` + LastSkipped *Skip `bson:"last_skipped,omitempty" json:"last_skipped,omitempty"` + DisabledReason string `bson:"disabled_reason,omitempty" json:"disabled_reason,omitempty"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +// PatchRun is one firing of a policy, or one manual Apply updates. Scope, +// reboot and concurrency are copied from the policy at fire time so editing +// the policy never rewrites what a past run shows. +type PatchRun struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + InstanceID string `bson:"instance_id" json:"instance_id"` + RunID string `bson:"run_id" json:"run_id"` + PolicyID string `bson:"policy_id,omitempty" json:"policy_id,omitempty"` + PolicyName string `bson:"policy_name,omitempty" json:"policy_name,omitempty"` + TriggeredBy string `bson:"triggered_by" json:"triggered_by"` + Source string `bson:"source" json:"source"` + Scope string `bson:"scope" json:"scope"` + Reboot string `bson:"reboot" json:"reboot"` + MaxConcurrent int `bson:"max_concurrent" json:"max_concurrent"` + WindowEnd *time.Time `bson:"window_end,omitempty" json:"window_end,omitempty"` + Status string `bson:"status" json:"status"` + CancelledAt *time.Time `bson:"cancelled_at,omitempty" json:"cancelled_at,omitempty"` + StartedAt time.Time `bson:"started_at" json:"started_at"` + FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"` + Servers []PatchServerRun `bson:"servers" json:"servers"` +} + +type PatchServerRun struct { + ServerID string `bson:"server_id" json:"server_id"` + Hostname string `bson:"hostname" json:"hostname"` + Status string `bson:"status" json:"status"` + CommandID string `bson:"command_id,omitempty" json:"-"` + PendingBefore int `bson:"pending_before" json:"pending_before"` + PendingAfter *int `bson:"pending_after,omitempty" json:"pending_after,omitempty"` + RebootedAt *time.Time `bson:"rebooted_at,omitempty" json:"rebooted_at,omitempty"` + VerifiedAt *time.Time `bson:"verified_at,omitempty" json:"verified_at,omitempty"` + Output string `bson:"output,omitempty" json:"output,omitempty"` + Error string `bson:"error,omitempty" json:"error,omitempty"` + StartedAt *time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"` + FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"` +} +``` + +- [ ] **Step 6: Create `internal/services/patch_indexes.go`** + +```go +package services + +import ( + "context" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// EnsurePatchIndexes builds the patching indexes. The command_id index is the +// one that matters: every PatchResult is matched to its server run through it. +func EnsurePatchIndexes() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if _, err := db.Col("maintenance_windows").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "window_id", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return err + } + if _, err := db.Col("patch_policies").Indexes().CreateMany(ctx, []mongo.IndexModel{ + {Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "policy_id", Value: 1}}, Options: options.Index().SetUnique(true)}, + {Keys: bson.D{{Key: "enabled", Value: 1}, {Key: "next_run_at", Value: 1}}}, + {Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "window_id", Value: 1}}}, + }); err != nil { + return err + } + _, err := db.Col("patch_runs").Indexes().CreateMany(ctx, []mongo.IndexModel{ + {Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "run_id", Value: 1}}, Options: options.Index().SetUnique(true)}, + {Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "policy_id", Value: 1}, {Key: "started_at", Value: -1}}}, + {Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "servers.server_id", Value: 1}, {Key: "started_at", Value: -1}}}, + {Keys: bson.D{{Key: "status", Value: 1}}}, + {Keys: bson.D{{Key: "servers.command_id", Value: 1}}}, + }) + return err +} +``` + +- [ ] **Step 7: Wire it at boot** + +In `cmd/main.go`, after the `EnsureStatusPageIndexes` block, add a block in the same shape as the neighbouring non-fatal index builders: + +```go + if err := services.EnsurePatchIndexes(); err != nil { + log.Printf("warning: patch indexes: %v", err) + } +``` + +- [ ] **Step 8: Run tests and build** + +Run: `go build ./... && go test ./internal/services/ -v -run PatchCollections` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add go.mod go.sum internal/models/patch.go internal/services/patch_indexes.go internal/services/patch_scoped_test.go internal/services/migrate_instance.go cmd/main.go +git commit -m "feat: patch models, scoped collections and indexes; pin vantage-shared v0.5.0" +``` + +### Task 9: `patchrun`, the pure run state machine + +**Files:** +- Create: `internal/patchrun/patchrun.go` +- Test: `internal/patchrun/patchrun_test.go` + +**Interfaces:** +- Consumes: `models.PatchRun`, `models.PatchServerRun`, `models.PatchSrv*`, `models.PatchRun*` (Task 8); `pb.PatchResult`, `pb.PatchStatus*` (Task 1). +- Produces: + - `const MinAgentVersion = "1.4.0"`, `ResultGrace = 10*time.Minute`, `ManualTimeout = 2*time.Hour`, `RebootTimeout = 20*time.Minute` + - `func AgentSupportsPatchResults(version string) bool` + - `func IsTerminal(status string) bool` + - `type Transition struct{ ServerID, From, To, Error string; Dispatch bool }` + - `func Advance(run models.PatchRun, now time.Time, connected map[string]bool) []Transition` + - `func ApplyResult(s models.PatchServerRun, r *pb.PatchResult, now time.Time) (models.PatchServerRun, bool)` + - `func VerifyReboot(s models.PatchServerRun, bootTime time.Time, rebootRequired bool, now time.Time) (models.PatchServerRun, bool)` + - `func Finalize(run models.PatchRun) (status string, done bool)` + - `func Summary(run models.PatchRun) string` + +- [ ] **Step 1: Write the failing tests** + +```go +package patchrun + +import ( + "testing" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb" +) + +var t0 = time.Date(2026, 9, 20, 2, 0, 0, 0, time.UTC) + +func tp(t time.Time) *time.Time { return &t } + +func srv(id, status string) models.PatchServerRun { + return models.PatchServerRun{ServerID: id, Hostname: id, Status: status} +} + +func windowRun(max int, servers ...models.PatchServerRun) models.PatchRun { + return models.PatchRun{Status: models.PatchRunRunning, StartedAt: t0, WindowEnd: tp(t0.Add(2 * time.Hour)), MaxConcurrent: max, Servers: servers} +} + +func find(ts []Transition, id string) *Transition { + for i := range ts { + if ts[i].ServerID == id { + return &ts[i] + } + } + return nil +} + +func TestAgentSupportsPatchResults(t *testing.T) { + cases := map[string]bool{ + "1.4.0": true, "v1.4.0": true, "1.4.1": true, "1.10.0": true, "2.0.0": true, + "1.3.5": false, "1.4.0-rc1": false, "1.5.0-rc1": true, + "": false, "dev": false, "1.4": false, "x.y.z": false, + } + for v, want := range cases { + if got := AgentSupportsPatchResults(v); got != want { + t.Errorf("%q: got %v, want %v", v, got, want) + } + } +} + +func TestAdvanceRespectsConcurrencyCountingRebooting(t *testing.T) { + run := windowRun(2, + srv("a", models.PatchSrvRebooting), + srv("b", models.PatchSrvQueued), + srv("c", models.PatchSrvQueued), + ) + run.Servers[0].RebootedAt = tp(t0) + ts := Advance(run, t0.Add(time.Minute), map[string]bool{"b": true, "c": true}) + if tr := find(ts, "b"); tr == nil || !tr.Dispatch || tr.To != models.PatchSrvPatching { + t.Fatalf("b should dispatch, got %+v", tr) + } + if tr := find(ts, "c"); tr != nil { + t.Fatalf("c must wait for a slot, got %+v", tr) + } +} + +func TestAdvanceUnlimitedWhenZero(t *testing.T) { + run := windowRun(0, srv("a", models.PatchSrvQueued), srv("b", models.PatchSrvQueued)) + ts := Advance(run, t0, map[string]bool{"a": true, "b": true}) + if len(ts) != 2 || !ts[0].Dispatch || !ts[1].Dispatch { + t.Fatalf("both should dispatch: %+v", ts) + } +} + +func TestAdvanceOfflineWaitsThenDispatches(t *testing.T) { + run := windowRun(0, srv("a", models.PatchSrvQueued)) + ts := Advance(run, t0, map[string]bool{}) + if len(ts) != 1 || ts[0].To != models.PatchSrvWaitingOffline || ts[0].Dispatch { + t.Fatalf("offline should wait: %+v", ts) + } + run.Servers[0].Status = models.PatchSrvWaitingOffline + if ts := Advance(run, t0.Add(time.Minute), map[string]bool{}); len(ts) != 0 { + t.Fatalf("still offline must be a no-op, got %+v", ts) + } + ts = Advance(run, t0.Add(2*time.Minute), map[string]bool{"a": true}) + if len(ts) != 1 || !ts[0].Dispatch { + t.Fatalf("back online should dispatch: %+v", ts) + } +} + +func TestAdvanceWindowCloses(t *testing.T) { + run := windowRun(1, srv("q", models.PatchSrvQueued), srv("w", models.PatchSrvWaitingOffline)) + ts := Advance(run, t0.Add(2*time.Hour), map[string]bool{"q": true, "w": true}) + if tr := find(ts, "q"); tr == nil || tr.To != models.PatchSrvWindowClosed || tr.Dispatch { + t.Fatalf("queued at window end: %+v", tr) + } + if tr := find(ts, "w"); tr == nil || tr.To != models.PatchSrvMissedOffline { + t.Fatalf("waiting at window end: %+v", tr) + } +} + +func TestAdvanceNoResultTimeout(t *testing.T) { + run := windowRun(0, srv("a", models.PatchSrvPatching)) + if ts := Advance(run, t0.Add(2*time.Hour+9*time.Minute), nil); len(ts) != 0 { + t.Fatalf("inside grace: %+v", ts) + } + ts := Advance(run, t0.Add(2*time.Hour+11*time.Minute), nil) + if len(ts) != 1 || ts[0].To != models.PatchSrvFailed || ts[0].Error == "" { + t.Fatalf("past grace: %+v", ts) + } +} + +func TestAdvanceManualRunTimeout(t *testing.T) { + run := models.PatchRun{Status: models.PatchRunRunning, StartedAt: t0, Servers: []models.PatchServerRun{srv("a", models.PatchSrvPatching)}} + run.Servers[0].StartedAt = tp(t0) + if ts := Advance(run, t0.Add(2*time.Hour+9*time.Minute), nil); len(ts) != 0 { + t.Fatalf("manual inside timeout: %+v", ts) + } + if ts := Advance(run, t0.Add(2*time.Hour+11*time.Minute), nil); len(ts) != 1 || ts[0].To != models.PatchSrvFailed { + t.Fatalf("manual past timeout: %+v", ts) + } +} + +func TestAdvanceRebootTimeout(t *testing.T) { + run := windowRun(0, srv("a", models.PatchSrvRebooting)) + run.Servers[0].RebootedAt = tp(t0) + if ts := Advance(run, t0.Add(19*time.Minute), nil); len(ts) != 0 { + t.Fatalf("inside reboot timeout: %+v", ts) + } + ts := Advance(run, t0.Add(21*time.Minute), nil) + if len(ts) != 1 || ts[0].To != models.PatchSrvFailed { + t.Fatalf("past reboot timeout: %+v", ts) + } +} + +func TestAdvanceCancelledDispatchesNothing(t *testing.T) { + run := windowRun(0, srv("a", models.PatchSrvQueued), srv("b", models.PatchSrvWaitingOffline), srv("c", models.PatchSrvPatching)) + run.CancelledAt = tp(t0) + ts := Advance(run, t0.Add(time.Minute), map[string]bool{"a": true, "b": true}) + for _, id := range []string{"a", "b"} { + if tr := find(ts, id); tr == nil || tr.To != models.PatchSrvCancelled || tr.Dispatch { + t.Errorf("%s: %+v", id, tr) + } + } + if find(ts, "c") != nil { + t.Error("an in-flight server must be left to finish") + } +} + +func TestAdvanceIgnoresFinishedRun(t *testing.T) { + run := windowRun(0, srv("a", models.PatchSrvQueued)) + run.Status = models.PatchRunSucceeded + if ts := Advance(run, t0, map[string]bool{"a": true}); ts != nil { + t.Fatalf("got %+v", ts) + } +} + +func TestApplyResult(t *testing.T) { + now := t0.Add(10 * time.Minute) + cases := []struct { + r pb.PatchResult + want string + }{ + {pb.PatchResult{Status: pb.PatchStatusOK, PendingAfter: 0}, models.PatchSrvSucceeded}, + {pb.PatchResult{Status: pb.PatchStatusOK, Rebooting: true}, models.PatchSrvRebooting}, + {pb.PatchResult{Status: pb.PatchStatusFailed, Message: "apt broke"}, models.PatchSrvFailed}, + {pb.PatchResult{Status: pb.PatchStatusBusy, Message: "busy"}, models.PatchSrvFailed}, + {pb.PatchResult{Status: pb.PatchStatusUnsupported, Message: "no metadata"}, models.PatchSrvUnsupported}, + } + for _, c := range cases { + got, ok := ApplyResult(srv("a", models.PatchSrvPatching), &c.r, now) + if !ok || got.Status != c.want { + t.Errorf("%s: got %s ok=%v, want %s", c.r.Status, got.Status, ok, c.want) + } + if c.want == models.PatchSrvRebooting && (got.RebootedAt == nil || got.FinishedAt != nil) { + t.Errorf("rebooting must set RebootedAt and leave FinishedAt nil: %+v", got) + } + } + if _, ok := ApplyResult(srv("a", models.PatchSrvSucceeded), &pb.PatchResult{Status: pb.PatchStatusOK}, now); ok { + t.Error("a result for a server not patching must be ignored") + } + got, _ := ApplyResult(srv("a", models.PatchSrvPatching), &pb.PatchResult{Status: pb.PatchStatusOK, PendingAfter: -1}, now) + if got.PendingAfter != nil { + t.Error("PendingAfter -1 means unknown and must stay nil") + } +} + +func TestVerifyReboot(t *testing.T) { + s := srv("a", models.PatchSrvRebooting) + s.RebootedAt = tp(t0) + now := t0.Add(5 * time.Minute) + if _, ok := VerifyReboot(s, t0.Add(-time.Hour), false, now); ok { + t.Error("boot before the reboot command is not proof") + } + if _, ok := VerifyReboot(s, t0, false, now); ok { + t.Error("boot equal to the reboot command is not proof") + } + got, ok := VerifyReboot(s, t0.Add(2*time.Minute), false, now) + if !ok || got.Status != models.PatchSrvSucceeded || got.VerifiedAt == nil { + t.Errorf("clean reboot: %+v", got) + } + got, ok = VerifyReboot(s, t0.Add(2*time.Minute), true, now) + if !ok || got.Status != models.PatchSrvFailed || got.Error == "" { + t.Errorf("still owed: %+v", got) + } + if _, ok := VerifyReboot(srv("a", models.PatchSrvPatching), t0.Add(time.Hour), false, now); ok { + t.Error("only rebooting servers verify") + } +} + +func TestFinalize(t *testing.T) { + mk := func(statuses ...string) models.PatchRun { + r := windowRun(0) + for i, s := range statuses { + r.Servers = append(r.Servers, srv(string(rune('a'+i)), s)) + } + return r + } + cases := []struct { + run models.PatchRun + want string + done bool + }{ + {mk(models.PatchSrvSucceeded, models.PatchSrvSucceeded), models.PatchRunSucceeded, true}, + {mk(models.PatchSrvSucceeded, models.PatchSrvFailed), models.PatchRunPartial, true}, + {mk(models.PatchSrvSucceeded, models.PatchSrvUnsupported), models.PatchRunPartial, true}, + {mk(models.PatchSrvSucceeded, models.PatchSrvAgentTooOld), models.PatchRunPartial, true}, + {mk(models.PatchSrvMissedOffline, models.PatchSrvWindowClosed), models.PatchRunFailed, true}, + {mk(models.PatchSrvSucceeded, models.PatchSrvPatching), "", false}, + {mk(), models.PatchRunFailed, true}, + } + for i, c := range cases { + got, done := Finalize(c.run) + if got != c.want || done != c.done { + t.Errorf("case %d: got %q/%v, want %q/%v", i, got, done, c.want, c.done) + } + } + cancelled := mk(models.PatchSrvSucceeded, models.PatchSrvCancelled) + cancelled.CancelledAt = tp(t0) + if got, done := Finalize(cancelled); got != models.PatchRunCancelled || !done { + t.Errorf("cancelled: %q/%v", got, done) + } +} + +func TestSummary(t *testing.T) { + r := windowRun(0, + srv("a", models.PatchSrvSucceeded), srv("b", models.PatchSrvSucceeded), + srv("c", models.PatchSrvFailed), srv("d", models.PatchSrvMissedOffline)) + if got := Summary(r); got != "2 succeeded, 1 failed, 1 missed offline" { + t.Fatalf("got %q", got) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/patchrun/ -v` +Expected: FAIL: package does not compile, `undefined: Advance` and the rest. + +- [ ] **Step 3: Implement `internal/patchrun/patchrun.go`** + +```go +// Package patchrun is the patch run state machine as pure functions. Nothing +// here touches the database: the services layer loads a run, asks this +// package what should change, and writes that change guarded by the status it +// expected. That split is what makes the rules testable without MongoDB. +package patchrun + +import ( + "fmt" + "strconv" + "strings" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb" +) + +// MinAgentVersion is the first agent release that honours ApplyUpdatesCmd's +// scope and answers with a PatchResult. An older agent ignores the scope and +// installs everything, so a policy must never dispatch to one. +const MinAgentVersion = "1.4.0" + +const ( + ResultGrace = 10 * time.Minute // after the window deadline, before "no result" + ManualTimeout = 2 * time.Hour // the agent's own cap when no deadline is sent + RebootTimeout = 20 * time.Minute // for a post-boot inventory report +) + +// AgentSupportsPatchResults compares major.minor.patch. Empty, "dev" and +// anything unparseable count as too old, and a pre-release of exactly the +// minimum version counts as older than it, as semver orders them. +func AgentSupportsPatchResults(version string) bool { + have, pre, ok := parseVersion(version) + if !ok { + return false + } + want, _, _ := parseVersion(MinAgentVersion) + for i := 0; i < 3; i++ { + if have[i] != want[i] { + return have[i] > want[i] + } + } + return !pre +} + +func parseVersion(v string) ([3]int, bool, bool) { + var out [3]int + v = strings.TrimPrefix(strings.TrimSpace(v), "v") + v, _, _ = strings.Cut(v, "+") + core, pre, hasPre := strings.Cut(v, "-") + parts := strings.Split(core, ".") + if len(parts) != 3 { + return out, false, false + } + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 { + return out, false, false + } + out[i] = n + } + return out, hasPre && pre != "", true +} + +// IsTerminal reports whether a server run has finished. +func IsTerminal(status string) bool { + switch status { + case models.PatchSrvQueued, models.PatchSrvWaitingOffline, models.PatchSrvPatching, models.PatchSrvRebooting: + return false + } + return true +} + +// Transition is one change Advance wants made. The caller writes it guarded +// by From, so a result that arrived meanwhile is never overwritten. Dispatch +// means: send ApplyUpdatesCmd, and set To only once the command is on its way. +type Transition struct { + ServerID string + From string + To string + Error string + Dispatch bool +} + +// Advance returns what should change on this tick. connected says which +// agents hold a command stream right now. +func Advance(run models.PatchRun, now time.Time, connected map[string]bool) []Transition { + if run.Status != models.PatchRunRunning { + return nil + } + windowOpen := run.WindowEnd == nil || now.Before(*run.WindowEnd) + + inFlight := 0 + for _, s := range run.Servers { + if s.Status == models.PatchSrvPatching || s.Status == models.PatchSrvRebooting { + inFlight++ + } + } + + var out []Transition + for _, s := range run.Servers { + switch s.Status { + case models.PatchSrvQueued, models.PatchSrvWaitingOffline: + switch { + case run.CancelledAt != nil: + out = append(out, Transition{ServerID: s.ServerID, From: s.Status, To: models.PatchSrvCancelled}) + case !windowOpen: + to := models.PatchSrvWindowClosed + if s.Status == models.PatchSrvWaitingOffline { + to = models.PatchSrvMissedOffline + } + out = append(out, Transition{ServerID: s.ServerID, From: s.Status, To: to}) + case run.MaxConcurrent > 0 && inFlight >= run.MaxConcurrent: + // No slot this tick. + case !connected[s.ServerID]: + if s.Status == models.PatchSrvQueued { + out = append(out, Transition{ServerID: s.ServerID, From: s.Status, To: models.PatchSrvWaitingOffline}) + } + default: + out = append(out, Transition{ServerID: s.ServerID, From: s.Status, To: models.PatchSrvPatching, Dispatch: true}) + inFlight++ + } + case models.PatchSrvPatching: + if now.After(resultDeadline(run, s)) { + out = append(out, Transition{ServerID: s.ServerID, From: s.Status, To: models.PatchSrvFailed, Error: "no result from agent"}) + } + case models.PatchSrvRebooting: + if s.RebootedAt != nil && now.After(s.RebootedAt.Add(RebootTimeout)) { + out = append(out, Transition{ServerID: s.ServerID, From: s.Status, To: models.PatchSrvFailed, Error: "did not come back within 20 minutes"}) + } + } + } + return out +} + +func resultDeadline(run models.PatchRun, s models.PatchServerRun) time.Time { + if run.WindowEnd != nil { + return run.WindowEnd.Add(ResultGrace) + } + start := run.StartedAt + if s.StartedAt != nil { + start = *s.StartedAt + } + return start.Add(ManualTimeout + ResultGrace) +} + +// ApplyResult folds an agent's PatchResult into the server run. It only acts +// on a server that is patching; anything else is a late or duplicate result. +func ApplyResult(s models.PatchServerRun, r *pb.PatchResult, now time.Time) (models.PatchServerRun, bool) { + if s.Status != models.PatchSrvPatching { + return s, false + } + s.Output = r.OutputTail + if r.PendingAfter >= 0 { + v := int(r.PendingAfter) + s.PendingAfter = &v + } + switch r.Status { + case pb.PatchStatusOK: + if r.Rebooting { + s.Status = models.PatchSrvRebooting + s.RebootedAt = &now + return s, true + } + s.Status = models.PatchSrvSucceeded + case pb.PatchStatusUnsupported: + s.Status = models.PatchSrvUnsupported + s.Error = r.Message + default: + s.Status = models.PatchSrvFailed + s.Error = r.Message + if s.Error == "" { + s.Error = "agent reported a failure" + } + } + s.FinishedAt = &now + return s, true +} + +// VerifyReboot settles a rebooting server from a static inventory report. Only +// a boot time later than the reboot command proves the host restarted: a +// snapshot sent during the one-minute grace period must not count. +func VerifyReboot(s models.PatchServerRun, bootTime time.Time, rebootRequired bool, now time.Time) (models.PatchServerRun, bool) { + if s.Status != models.PatchSrvRebooting || s.RebootedAt == nil || !bootTime.After(*s.RebootedAt) { + return s, false + } + if rebootRequired { + s.Status = models.PatchSrvFailed + s.Error = "still requires a reboot after restarting" + } else { + s.Status = models.PatchSrvSucceeded + s.VerifiedAt = &now + } + s.FinishedAt = &now + return s, true +} + +// Finalize says whether the run is over and how it ended. Only succeeded +// counts as success: unsupported, agent_too_old and the window outcomes did +// not patch anything. +func Finalize(run models.PatchRun) (string, bool) { + ok := 0 + for _, s := range run.Servers { + if !IsTerminal(s.Status) { + return "", false + } + if s.Status == models.PatchSrvSucceeded { + ok++ + } + } + switch { + case run.CancelledAt != nil: + return models.PatchRunCancelled, true + case len(run.Servers) > 0 && ok == len(run.Servers): + return models.PatchRunSucceeded, true + case ok == 0: + return models.PatchRunFailed, true + default: + return models.PatchRunPartial, true + } +} + +var summaryOrder = []struct{ status, label string }{ + {models.PatchSrvSucceeded, "succeeded"}, + {models.PatchSrvFailed, "failed"}, + {models.PatchSrvUnsupported, "unsupported"}, + {models.PatchSrvAgentTooOld, "need an agent update"}, + {models.PatchSrvMissedOffline, "missed offline"}, + {models.PatchSrvWindowClosed, "window closed"}, + {models.PatchSrvCancelled, "cancelled"}, +} + +// Summary is the one-line count used in alerts, e.g. "38 succeeded, 2 failed". +func Summary(run models.PatchRun) string { + counts := map[string]int{} + for _, s := range run.Servers { + counts[s.Status]++ + } + var parts []string + for _, o := range summaryOrder { + if n := counts[o.status]; n > 0 { + parts = append(parts, fmt.Sprintf("%d %s", n, o.label)) + } + } + return strings.Join(parts, ", ") +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/patchrun/ -v && go vet ./internal/patchrun/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/patchrun/ +git commit -m "feat: patchrun - pure state machine for patch runs" +``` + +### Task 10: `patchsched.Decide` and window arithmetic + +**Files:** +- Create: `internal/patchsched/decide.go` +- Test: `internal/patchsched/decide_test.go` + +**Interfaces:** +- Consumes: `workflowsched.NextOccurrence`, `workflowsched.GraceWindow`. +- Produces: `type Decision string`; `Fire`, `SkipMissed`, `SkipRunning`, `SkipNoTargets`; `func Decide(due, windowEnd, now time.Time, runActive bool, targets int) Decision`; `func WindowEnd(start time.Time, durationMinutes int) time.Time`; `func NextStart(cron, tz string, from time.Time) (time.Time, error)`; `func Later(a, b time.Time) time.Time`. + +- [ ] **Step 1: Write the failing tests** + +```go +package patchsched + +import ( + "testing" + "time" +) + +var due = time.Date(2026, 9, 20, 2, 0, 0, 0, time.UTC) + +func TestDecide(t *testing.T) { + end := due.Add(2 * time.Hour) + cases := []struct { + name string + now time.Time + end time.Time + running bool + targets int + want Decision + }{ + {"on time", due, end, false, 3, Fire}, + {"late within grace", due.Add(59 * time.Minute), end, false, 3, Fire}, + {"past grace", due.Add(61 * time.Minute), end, false, 3, SkipMissed}, + {"window already over", due.Add(20 * time.Minute), due.Add(15 * time.Minute), false, 3, SkipMissed}, + {"missed wins over running", due.Add(2 * time.Hour), end, true, 3, SkipMissed}, + {"previous run active", due, end, true, 3, SkipRunning}, + {"no targets", due, end, false, 0, SkipNoTargets}, + } + for _, c := range cases { + if got := Decide(due, c.end, c.now, c.running, c.targets); got != c.want { + t.Errorf("%s: got %s, want %s", c.name, got, c.want) + } + } +} + +// Europe/London falls back on 25 October 2026, so 01:30 happens twice. The +// next window is computed from the end of the current one, so a two-hour +// window starting at the first 01:30 cannot fire again at the second. +func TestNextStartAcrossFallBack(t *testing.T) { + loc, _ := time.LoadLocation("Europe/London") + from := time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC) + first, err := NextStart("30 1 * * 0", "Europe/London", from) + if err != nil { + t.Fatal(err) + } + if d := first.In(loc); d.Day() != 25 || d.Month() != time.October || d.Hour() != 1 || d.Minute() != 30 { + t.Fatalf("first = %s", d) + } + end := WindowEnd(first, 120) + next, err := NextStart("30 1 * * 0", "Europe/London", Later(first.Add(time.Minute), end)) + if err != nil { + t.Fatal(err) + } + if d := next.In(loc); d.Day() != 1 || d.Month() != time.November || d.Hour() != 1 || d.Minute() != 30 { + t.Fatalf("next = %s, want 2026-11-01 01:30 London", d) + } +} + +func TestWindowEndAndLater(t *testing.T) { + if got := WindowEnd(due, 90); !got.Equal(due.Add(90 * time.Minute)) { + t.Fatalf("WindowEnd = %s", got) + } + a, b := due, due.Add(time.Second) + if !Later(a, b).Equal(b) || !Later(b, a).Equal(b) { + t.Fatal("Later must return the later time") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/patchsched/ -v` +Expected: FAIL: `undefined: Decide`. + +- [ ] **Step 3: Implement `internal/patchsched/decide.go`** + +```go +// Package patchsched fires patch policies at the start of their maintenance +// window and advances running patch runs. Like workflowsched it runs under the +// housekeeping leader lock and must not import services: services imports +// this package for NextStart and WindowEnd. +package patchsched + +import ( + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched" +) + +type Decision string + +const ( + Fire Decision = "fire" + SkipMissed Decision = "missed" + SkipRunning Decision = "already_running" + SkipNoTargets Decision = "no_targets" +) + +// Decide is the whole fire or skip rule for one due policy. Missed comes +// first, as in workflowsched: a stale occurrence is recorded as missed +// whatever else is true. A window that has already closed is missed even +// inside the hour of grace, because there is no time left to patch in. +func Decide(due, windowEnd, now time.Time, runActive bool, targets int) Decision { + if !now.Before(windowEnd) || now.Sub(due) > workflowsched.GraceWindow { + return SkipMissed + } + if runActive { + return SkipRunning + } + if targets == 0 { + return SkipNoTargets + } + return Fire +} + +func WindowEnd(start time.Time, durationMinutes int) time.Time { + return start.Add(time.Duration(durationMinutes) * time.Minute) +} + +// NextStart is the first window start strictly after from. Callers pass +// Later(now, currentWindowEnd) so windows never overlap, including across a +// daylight-saving fall-back where the same wall-clock time occurs twice. +func NextStart(cron, tz string, from time.Time) (time.Time, error) { + return workflowsched.NextOccurrence(cron, tz, from) +} + +func Later(a, b time.Time) time.Time { + if a.After(b) { + return a + } + return b +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/patchsched/ -v` +Expected: PASS. If `TestNextStartAcrossFallBack` fails on `first`, robfig chose the second 01:30 for the first occurrence: that is acceptable, so relax only the `first` assertion to the date (25 October) and keep the `next` assertion unchanged, because `next` is the double-fire guard. + +- [ ] **Step 5: Commit** + +```bash +git add internal/patchsched/decide.go internal/patchsched/decide_test.go +git commit -m "feat: patchsched fire/skip decision and window arithmetic" +``` + +### Task 11: Windows and policies services, with validation + +**Files:** +- Create: `internal/services/patch_windows.go` +- Create: `internal/services/patch_policies.go` +- Test: `internal/services/patch_validate_test.go` + +**Interfaces:** +- Consumes: models (Task 8); `patchsched.NextStart`, `patchsched.WindowEnd`, `patchsched.Later` (Task 10); existing `workflowsched.ParseSchedule`, `ValidateTags`, `validateTargetServers`, `validateWorkflowTargetScope`, `ResolveTargets`, `ErrNoTargets`. +- Produces: + - `ErrWindowInvalid`, `ErrWindowNotFound`, `ErrWindowInUse`, `ErrPolicyInvalid`, `ErrPolicyNotFound` + - `func ValidateWindow(w models.MaintenanceWindow) error` + - `type WindowSpan struct{ Start, End time.Time }` (json `start`, `end`) + - `func PreviewWindow(cron, tz string, durationMinutes int, from time.Time, n int) ([]WindowSpan, error)` + - `func ListWindows(instanceID string) ([]models.MaintenanceWindow, error)` + - `func GetWindow(instanceID, windowID string) (*models.MaintenanceWindow, error)` (`ErrWindowNotFound`) + - `func LookupWindow(instanceID, windowID string) (*models.MaintenanceWindow, error)` (nil, nil when absent: for the scheduler) + - `func CreateWindow(instanceID string, w models.MaintenanceWindow) (*models.MaintenanceWindow, error)` + - `func UpdateWindow(instanceID, windowID string, w models.MaintenanceWindow) (*models.MaintenanceWindow, error)` + - `func DeleteWindow(instanceID, windowID string) error` + - `func ValidatePolicy(p models.PatchPolicy) error` + - `func ListPolicies(instanceID string) ([]models.PatchPolicy, error)` + - `func GetPolicy(instanceID, policyID string) (*models.PatchPolicy, error)` + - `func CreatePolicy(instanceID string, p models.PatchPolicy, tokenScope map[string]string) (*models.PatchPolicy, error)` + - `func UpdatePolicy(instanceID, policyID string, p models.PatchPolicy, tokenScope map[string]string) (*models.PatchPolicy, error)` + - `func DeletePolicy(instanceID, policyID string) error` + - `func CountPolicyTargets(p models.PatchPolicy) (int, error)` + +- [ ] **Step 1: Write the failing tests** + +```go +package services + +import ( + "errors" + "strings" + "testing" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" +) + +func goodWindow() models.MaintenanceWindow { + return models.MaintenanceWindow{Name: "Sunday", Cron: "0 2 * * 0", TZ: "Europe/London", DurationMinutes: 120} +} + +func TestValidateWindow(t *testing.T) { + if err := ValidateWindow(goodWindow()); err != nil { + t.Fatalf("good window: %v", err) + } + bad := map[string]func(*models.MaintenanceWindow){ + "empty name": func(w *models.MaintenanceWindow) { w.Name = " " }, + "long name": func(w *models.MaintenanceWindow) { w.Name = strings.Repeat("a", 101) }, + "too short": func(w *models.MaintenanceWindow) { w.DurationMinutes = 14 }, + "too long": func(w *models.MaintenanceWindow) { w.DurationMinutes = 721 }, + "bad cron": func(w *models.MaintenanceWindow) { w.Cron = "every sunday" }, + "six-field cron": func(w *models.MaintenanceWindow) { w.Cron = "0 0 2 * * 0" }, + "bad tz": func(w *models.MaintenanceWindow) { w.TZ = "Mars/Olympus" }, + "no tz": func(w *models.MaintenanceWindow) { w.TZ = "" }, + } + for name, mut := range bad { + w := goodWindow() + mut(&w) + if err := ValidateWindow(w); !errors.Is(err, ErrWindowInvalid) { + t.Errorf("%s: err = %v, want ErrWindowInvalid", name, err) + } + } +} + +func TestPreviewWindowSpansDoNotOverlap(t *testing.T) { + from := time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC) + spans, err := PreviewWindow("0 * * * *", "UTC", 90, from, 3) + if err != nil { + t.Fatal(err) + } + if len(spans) != 3 { + t.Fatalf("got %d spans", len(spans)) + } + for i := 1; i < len(spans); i++ { + if spans[i].Start.Before(spans[i-1].End) { + t.Fatalf("span %d starts %s before previous ends %s", i, spans[i].Start, spans[i-1].End) + } + } + if !spans[0].End.Equal(spans[0].Start.Add(90 * time.Minute)) { + t.Fatal("end must be start + duration") + } +} + +func goodPolicy() models.PatchPolicy { + return models.PatchPolicy{ + Name: "Sunday prod", WindowID: "w1", TargetTags: map[string]string{"env": "prod"}, + Scope: models.PatchScopeSecurity, Reboot: models.PatchRebootNever, + } +} + +func TestValidatePolicy(t *testing.T) { + if err := ValidatePolicy(goodPolicy()); err != nil { + t.Fatalf("good policy: %v", err) + } + bad := map[string]func(*models.PatchPolicy){ + "empty name": func(p *models.PatchPolicy) { p.Name = "" }, + "no window": func(p *models.PatchPolicy) { p.WindowID = "" }, + "bad scope": func(p *models.PatchPolicy) { p.Scope = "everything" }, + "bad reboot": func(p *models.PatchPolicy) { p.Reboot = "always" }, + "negative cap": func(p *models.PatchPolicy) { p.MaxConcurrent = -1 }, + "huge cap": func(p *models.PatchPolicy) { p.MaxConcurrent = 1001 }, + "uppercase tag": func(p *models.PatchPolicy) { p.TargetTags = map[string]string{"Env": "prod"} }, + } + for name, mut := range bad { + p := goodPolicy() + mut(&p) + if err := ValidatePolicy(p); err == nil { + t.Errorf("%s: want an error", name) + } + } + p := goodPolicy() + p.TargetTags = nil + if err := ValidatePolicy(p); !errors.Is(err, ErrNoTargets) { + t.Errorf("empty selector: err = %v, want ErrNoTargets", err) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/services/ -run 'ValidateWindow|PreviewWindow|ValidatePolicy' -v` +Expected: FAIL: `undefined: ValidateWindow`. + +- [ ] **Step 3: Implement `internal/services/patch_windows.go`** + +```go +package services + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/patchsched" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched" + "github.com/google/uuid" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +var ( + ErrWindowInvalid = errors.New("invalid maintenance window") + ErrWindowNotFound = errors.New("maintenance window not found") + ErrWindowInUse = errors.New("maintenance window is used by a patch policy") +) + +const ( + minWindowMinutes = 15 + maxWindowMinutes = 720 +) + +func patchCtx() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), 10*time.Second) +} + +func ValidateWindow(w models.MaintenanceWindow) error { + if n := strings.TrimSpace(w.Name); n == "" || len(n) > 100 { + return fmt.Errorf("%w: name must be 1 to 100 characters", ErrWindowInvalid) + } + if w.DurationMinutes < minWindowMinutes || w.DurationMinutes > maxWindowMinutes { + return fmt.Errorf("%w: duration must be between %d and %d minutes", ErrWindowInvalid, minWindowMinutes, maxWindowMinutes) + } + if _, err := workflowsched.ParseSchedule(w.Cron, w.TZ); err != nil { + return fmt.Errorf("%w: %v", ErrWindowInvalid, err) + } + return nil +} + +type WindowSpan struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` +} + +// PreviewWindow returns the next n windows, computed exactly as the scheduler +// computes them, so the editor cannot disagree with what will fire. +func PreviewWindow(cron, tz string, durationMinutes int, from time.Time, n int) ([]WindowSpan, error) { + if err := ValidateWindow(models.MaintenanceWindow{Name: "preview", Cron: cron, TZ: tz, DurationMinutes: durationMinutes}); err != nil { + return nil, err + } + out := make([]WindowSpan, 0, n) + for len(out) < n { + start, err := patchsched.NextStart(cron, tz, from) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrWindowInvalid, err) + } + end := patchsched.WindowEnd(start, durationMinutes) + out = append(out, WindowSpan{Start: start, End: end}) + from = patchsched.Later(start, end) + } + return out, nil +} + +func ListWindows(instanceID string) ([]models.MaintenanceWindow, error) { + ctx, cancel := patchCtx() + defer cancel() + cur, err := db.Col("maintenance_windows").Find(ctx, bson.M{"instance_id": instanceID}, options.Find().SetSort(bson.M{"name": 1})) + if err != nil { + return nil, err + } + out := []models.MaintenanceWindow{} + return out, cur.All(ctx, &out) +} + +func GetWindow(instanceID, windowID string) (*models.MaintenanceWindow, error) { + w, err := LookupWindow(instanceID, windowID) + if err == nil && w == nil { + return nil, ErrWindowNotFound + } + return w, err +} + +// LookupWindow is GetWindow for the scheduler, which must tell "gone" (nil, +// nil: disable the policy) from a database error (retry next tick). +func LookupWindow(instanceID, windowID string) (*models.MaintenanceWindow, error) { + ctx, cancel := patchCtx() + defer cancel() + var w models.MaintenanceWindow + err := db.Col("maintenance_windows").FindOne(ctx, bson.M{"instance_id": instanceID, "window_id": windowID}).Decode(&w) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + if err != nil { + return nil, err + } + return &w, nil +} + +func CreateWindow(instanceID string, w models.MaintenanceWindow) (*models.MaintenanceWindow, error) { + w.Name = strings.TrimSpace(w.Name) + if err := ValidateWindow(w); err != nil { + return nil, err + } + ctx, cancel := patchCtx() + defer cancel() + w.InstanceID, w.WindowID = instanceID, uuid.New().String() + w.CreatedAt = time.Now() + w.UpdatedAt = w.CreatedAt + if _, err := db.Col("maintenance_windows").InsertOne(ctx, w); err != nil { + return nil, err + } + return &w, nil +} + +// UpdateWindow saves the window and moves next_run_at on every enabled policy +// that uses it, so an edited Sunday becomes the next Sunday everywhere at once. +func UpdateWindow(instanceID, windowID string, w models.MaintenanceWindow) (*models.MaintenanceWindow, error) { + w.Name = strings.TrimSpace(w.Name) + if err := ValidateWindow(w); err != nil { + return nil, err + } + ctx, cancel := patchCtx() + defer cancel() + res, err := db.Col("maintenance_windows").UpdateOne(ctx, + bson.M{"instance_id": instanceID, "window_id": windowID}, + bson.M{"$set": bson.M{"name": w.Name, "cron": w.Cron, "tz": w.TZ, "duration_minutes": w.DurationMinutes, "updated_at": time.Now()}}) + if err != nil { + return nil, err + } + if res.MatchedCount == 0 { + return nil, ErrWindowNotFound + } + saved, err := GetWindow(instanceID, windowID) + if err != nil { + return nil, err + } + return saved, recomputePolicySchedules(ctx, *saved) +} + +func DeleteWindow(instanceID, windowID string) error { + ctx, cancel := patchCtx() + defer cancel() + n, err := db.Col("patch_policies").CountDocuments(ctx, bson.M{"instance_id": instanceID, "window_id": windowID}) + if err != nil { + return err + } + if n > 0 { + return ErrWindowInUse + } + res, err := db.Col("maintenance_windows").DeleteOne(ctx, bson.M{"instance_id": instanceID, "window_id": windowID}) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return ErrWindowNotFound + } + return nil +} +``` + +- [ ] **Step 4: Implement `internal/services/patch_policies.go`** + +```go +package services + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/patchsched" + "github.com/google/uuid" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +var ( + ErrPolicyInvalid = errors.New("invalid patch policy") + ErrPolicyNotFound = errors.New("patch policy not found") +) + +// ValidatePolicy checks a policy without touching the database. The window's +// existence and the token's tag scope are checked by Create and Update. +func ValidatePolicy(p models.PatchPolicy) error { + if n := strings.TrimSpace(p.Name); n == "" || len(n) > 100 { + return fmt.Errorf("%w: name must be 1 to 100 characters", ErrPolicyInvalid) + } + if p.WindowID == "" { + return fmt.Errorf("%w: a maintenance window is required", ErrPolicyInvalid) + } + if p.Scope != models.PatchScopeAll && p.Scope != models.PatchScopeSecurity { + return fmt.Errorf("%w: scope must be %q or %q", ErrPolicyInvalid, models.PatchScopeAll, models.PatchScopeSecurity) + } + if p.Reboot != models.PatchRebootNever && p.Reboot != models.PatchRebootIfRequired { + return fmt.Errorf("%w: reboot must be %q or %q", ErrPolicyInvalid, models.PatchRebootNever, models.PatchRebootIfRequired) + } + if p.MaxConcurrent < 0 || p.MaxConcurrent > 1000 { + return fmt.Errorf("%w: max concurrent must be between 0 and 1000", ErrPolicyInvalid) + } + // Same rule as workflows: an empty selector matches nothing, and saying so + // at save time beats a policy that silently patches nobody every Sunday. + if len(p.TargetServerIDs) == 0 && len(p.TargetTags) == 0 { + return ErrNoTargets + } + return ValidateTags(p.TargetTags) +} + +func nextRunFor(p models.PatchPolicy, w models.MaintenanceWindow, now time.Time) *time.Time { + if !p.Enabled { + return nil + } + start, err := patchsched.NextStart(w.Cron, w.TZ, now) + if err != nil { + return nil + } + return &start +} + +func ListPolicies(instanceID string) ([]models.PatchPolicy, error) { + ctx, cancel := patchCtx() + defer cancel() + cur, err := db.Col("patch_policies").Find(ctx, bson.M{"instance_id": instanceID}, options.Find().SetSort(bson.M{"name": 1})) + if err != nil { + return nil, err + } + out := []models.PatchPolicy{} + return out, cur.All(ctx, &out) +} + +func GetPolicy(instanceID, policyID string) (*models.PatchPolicy, error) { + ctx, cancel := patchCtx() + defer cancel() + var p models.PatchPolicy + err := db.Col("patch_policies").FindOne(ctx, bson.M{"instance_id": instanceID, "policy_id": policyID}).Decode(&p) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrPolicyNotFound + } + return &p, err +} + +// preparePolicy runs every check a save needs and returns the policy's window. +func preparePolicy(instanceID string, p *models.PatchPolicy, tokenScope map[string]string) (*models.MaintenanceWindow, error) { + p.Name = strings.TrimSpace(p.Name) + if p.TargetServerIDs == nil { + p.TargetServerIDs = []string{} + } + if err := ValidatePolicy(*p); err != nil { + return nil, err + } + w, err := GetWindow(instanceID, p.WindowID) + if errors.Is(err, ErrWindowNotFound) { + return nil, fmt.Errorf("%w: that maintenance window does not exist", ErrPolicyInvalid) + } + if err != nil { + return nil, err + } + // validateTargetServers returns a plain "target server X not found"; wrap + // it so the handler can answer 400 without matching on text. + if err := validateTargetServers(instanceID, p.TargetServerIDs, tokenScope); err != nil { + return nil, fmt.Errorf("%w: %v", ErrPolicyInvalid, err) + } + if err := validateWorkflowTargetScope(instanceID, p.TargetServerIDs, p.TargetTags, tokenScope); err != nil { + return nil, err + } + return w, nil +} + +func CreatePolicy(instanceID string, p models.PatchPolicy, tokenScope map[string]string) (*models.PatchPolicy, error) { + w, err := preparePolicy(instanceID, &p, tokenScope) + if err != nil { + return nil, err + } + ctx, cancel := patchCtx() + defer cancel() + now := time.Now() + p.InstanceID, p.PolicyID = instanceID, uuid.New().String() + p.CreatedAt, p.UpdatedAt = now, now + p.NextRunAt = nextRunFor(p, *w, now) + p.LastRunAt, p.LastSkipped, p.DisabledReason = nil, nil, "" + if _, err := db.Col("patch_policies").InsertOne(ctx, p); err != nil { + return nil, err + } + return &p, nil +} + +func UpdatePolicy(instanceID, policyID string, p models.PatchPolicy, tokenScope map[string]string) (*models.PatchPolicy, error) { + // Missing policy answers 404 before any validation error. + if _, err := GetPolicy(instanceID, policyID); err != nil { + return nil, err + } + w, err := preparePolicy(instanceID, &p, tokenScope) + if err != nil { + return nil, err + } + ctx, cancel := patchCtx() + defer cancel() + now := time.Now() + set := bson.M{ + "name": p.Name, "enabled": p.Enabled, "window_id": p.WindowID, + "target_server_ids": p.TargetServerIDs, "target_tags": p.TargetTags, + "scope": p.Scope, "reboot": p.Reboot, "max_concurrent": p.MaxConcurrent, + "notify_channel_ids": p.NotifyChannelIDs, "updated_at": now, + } + update := bson.M{"$set": set} + if next := nextRunFor(p, *w, now); next != nil { + set["next_run_at"] = *next + // Re-enabling is the operator's answer to whatever disabled it. + update["$unset"] = bson.M{"disabled_reason": ""} + } else { + update["$unset"] = bson.M{"next_run_at": ""} + } + if _, err := db.Col("patch_policies").UpdateOne(ctx, bson.M{"instance_id": instanceID, "policy_id": policyID}, update); err != nil { + return nil, err + } + return GetPolicy(instanceID, policyID) +} + +func DeletePolicy(instanceID, policyID string) error { + ctx, cancel := patchCtx() + defer cancel() + res, err := db.Col("patch_policies").DeleteOne(ctx, bson.M{"instance_id": instanceID, "policy_id": policyID}) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return ErrPolicyNotFound + } + return nil +} + +// recomputePolicySchedules moves every enabled policy on this window to the +// window's next start. +func recomputePolicySchedules(ctx context.Context, w models.MaintenanceWindow) error { + start, err := patchsched.NextStart(w.Cron, w.TZ, time.Now()) + if err != nil { + return err + } + _, err = db.Col("patch_policies").UpdateMany(ctx, + bson.M{"instance_id": w.InstanceID, "window_id": w.WindowID, "enabled": true}, + bson.M{"$set": bson.M{"next_run_at": start}}) + return err +} + +// CountPolicyTargets resolves the selector as a run would, at this moment. +func CountPolicyTargets(p models.PatchPolicy) (int, error) { + servers, err := ResolveTargets(p.InstanceID, p.TargetServerIDs, p.TargetTags) + if errors.Is(err, ErrNoTargets) { + return 0, nil + } + return len(servers), err +} +``` + +- [ ] **Step 5: Run tests** + +Run: `go test ./internal/services/ -run 'ValidateWindow|PreviewWindow|ValidatePolicy' -v && go vet ./internal/services/` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/services/patch_windows.go internal/services/patch_policies.go internal/services/patch_validate_test.go +git commit -m "feat: maintenance window and patch policy services" +``` + +### Task 12: Patch run service: start, dispatch, results, reboots, cancel, notify, retention + +**Files:** +- Create: `internal/services/patch_runs.go` +- Modify: `internal/services/steplogs.go` (`StartLogSweeper`: call `sweepPatchRuns()` next to both `sweepLogs()` calls) +- Modify: `internal/notify/dispatch.go` (`TypePatch`, `title()`) +- Test: `internal/notify/patch_test.go` + +**Interfaces:** +- Consumes: `patchrun.*` (Task 9); policy and window services (Task 11); existing `Dispatcher`, `DispatchApplyUpdates`, `ResolveTargets`, `ResolveTargetsScoped`, `GetChannels`, `GetWorkflowLogRetentionDays`, `LogEvent`, `notify.Dispatch`. +- Produces: + - `ErrPatchRunNotFound`, `ErrPatchRunFinished`, `ErrAgentOffline` + - `func StartPolicyRun(p models.PatchPolicy, windowEnd time.Time, source, actor string) (*models.PatchRun, error)` + - `func StartManualRun(instanceID string, srv *models.Server, actor, source string) (*models.PatchRun, error)` + - `func AdvancePatchRuns(ctx context.Context)` + - `func RecordPatchResult(instanceID, serverID string, r *pb.PatchResult)` + - `func VerifyPatchReboots(instanceID, serverID string, bootTime time.Time, rebootRequired bool)` + - `func CancelPatchRun(instanceID, runID string) error` + - `func GetPatchRun(instanceID, runID string) (*models.PatchRun, error)` + - `func ListPatchRuns(instanceID, policyID, serverID string, limit int64) ([]models.PatchRun, error)` + - `func ScopePatchRun(instanceID string, run *models.PatchRun, tokenScope map[string]string) error` + - `notify.TypePatch = "patch"` + +- [ ] **Step 1: Write the failing notify test** + +```go +package notify + +import ( + "testing" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" +) + +// A patch summary is not a transition: it must not read "is DOWN" or +// "recovered". +func TestPatchEventTitle(t *testing.T) { + ev := Event{MonitorName: `Patch policy "Sunday prod" partial`, Type: TypePatch, NewStatus: models.PatchRunPartial, Message: "38 succeeded, 2 failed"} + want := `[Vantage] Patch policy "Sunday prod" partial: 38 succeeded, 2 failed` + if got := ev.title(); got != want { + t.Fatalf("got %q, want %q", got, want) + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/notify/ -run PatchEventTitle -v` +Expected: FAIL: `undefined: TypePatch`. + +- [ ] **Step 3: Add the patch event type** + +In `internal/notify/dispatch.go`, after `const TypeServer = "server"` add: + +```go +// TypePatch marks a patch run summary. Like a vulnerability digest it is a +// headline, not a transition, so title() adds no verb. +const TypePatch = "patch" +``` + +and change `if e.Type == TypeVuln {` to `if e.Type == TypeVuln || e.Type == TypePatch {`. + +Run: `go test ./internal/notify/ -v` +Expected: PASS. + +- [ ] **Step 4: Implement `internal/services/patch_runs.go`** + +```go +package services + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/notify" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/patchrun" + "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb" + "github.com/google/uuid" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +var ( + ErrPatchRunNotFound = errors.New("patch run not found") + ErrPatchRunFinished = errors.New("patch run has already finished") + ErrAgentOffline = errors.New("agent is not connected to the command stream") +) + +const patchRunsCol = "patch_runs" + +func newServerRun(s models.Server, now time.Time) models.PatchServerRun { + r := models.PatchServerRun{ServerID: s.ServerID, Hostname: s.Hostname, Status: models.PatchSrvQueued, PendingBefore: len(s.AvailableUpdates)} + if !patchrun.AgentSupportsPatchResults(s.AgentVersion) { + v := s.AgentVersion + if v == "" { + v = "unknown" + } + r.Status = models.PatchSrvAgentTooOld + r.Error = fmt.Sprintf("agent %s predates patch results; update it to %s or later", v, patchrun.MinAgentVersion) + r.FinishedAt = &now + } + return r +} + +// StartPolicyRun records a run for a policy and dispatches its first batch +// immediately. The run document is written before any command leaves, so a +// fast agent's result always finds it. +func StartPolicyRun(p models.PatchPolicy, windowEnd time.Time, source, actor string) (*models.PatchRun, error) { + targets, err := ResolveTargets(p.InstanceID, p.TargetServerIDs, p.TargetTags) + if err != nil { + return nil, err + } + now := time.Now() + run := models.PatchRun{ + InstanceID: p.InstanceID, RunID: uuid.New().String(), PolicyID: p.PolicyID, PolicyName: p.Name, + TriggeredBy: actor, Source: source, Scope: p.Scope, Reboot: p.Reboot, MaxConcurrent: p.MaxConcurrent, + WindowEnd: &windowEnd, Status: models.PatchRunRunning, StartedAt: now, + } + for _, s := range targets { + run.Servers = append(run.Servers, newServerRun(s, now)) + } + ctx, cancel := patchCtx() + defer cancel() + if _, err := db.Col(patchRunsCol).InsertOne(ctx, run); err != nil { + return nil, err + } + LogEvent(p.InstanceID, "patch.run_started", actor, "", "", + fmt.Sprintf("patch policy %s started on %d servers (%s, reboot %s)", p.Name, len(run.Servers), p.Scope, p.Reboot)) + advanceRun(ctx, run.RunID) + return &run, nil +} + +// StartManualRun is Apply updates on one server. An agent too old to answer +// still gets the command it always got, and the run says it cannot know the +// outcome rather than claiming one. +func StartManualRun(instanceID string, srv *models.Server, actor, source string) (*models.PatchRun, error) { + now := time.Now() + sr := newServerRun(*srv, now) + legacy := sr.Status == models.PatchSrvAgentTooOld + sr.Status, sr.Error, sr.FinishedAt, sr.StartedAt = models.PatchSrvPatching, "", nil, &now + run := models.PatchRun{ + InstanceID: instanceID, RunID: uuid.New().String(), TriggeredBy: actor, Source: source, + Scope: models.PatchScopeAll, Reboot: models.PatchRebootNever, Status: models.PatchRunRunning, StartedAt: now, + } + + ctx, cancel := patchCtx() + defer cancel() + + var dispatchErr error + if !Dispatcher.IsConnected(srv.ServerID) { + dispatchErr = ErrAgentOffline + } + if dispatchErr == nil && legacy { + dispatchErr = DispatchApplyUpdates(srv.ServerID) + if dispatchErr == nil { + sr.Status = models.PatchSrvSucceeded + sr.Error = "no result reported: agent predates patch results" + sr.FinishedAt = &now + } + } + if dispatchErr != nil { + sr.Status, sr.Error, sr.FinishedAt = models.PatchSrvFailed, dispatchErr.Error(), &now + } else if !legacy { + sr.CommandID = uuid.New().String() + } + run.Servers = []models.PatchServerRun{sr} + if _, err := db.Col(patchRunsCol).InsertOne(ctx, run); err != nil { + return nil, err + } + if dispatchErr == nil && !legacy { + if err := dispatchPatch(srv.ServerID, sr.CommandID, run); err != nil { + _, _ = setServer(ctx, run.RunID, srv.ServerID, models.PatchSrvPatching, + bson.M{"status": models.PatchSrvFailed, "error": err.Error(), "finished_at": time.Now()}) + dispatchErr = ErrAgentOffline + } + } + finalizeRun(ctx, run.RunID) + if dispatchErr != nil { + return &run, ErrAgentOffline + } + return &run, nil +} + +func dispatchPatch(serverID, commandID string, run models.PatchRun) error { + cmd := &pb.ApplyUpdatesCmd{Scope: run.Scope, RebootIfRequired: run.Reboot == models.PatchRebootIfRequired} + if run.WindowEnd != nil { + cmd.DeadlineUnix = run.WindowEnd.Unix() + } + return Dispatcher.dispatch(serverID, &pb.ServerCommand{CommandId: commandID, ApplyUpdates: cmd}) +} + +// setServer updates one server run, but only if it is still in status from. +// That guard is what stops a tick from overwriting a result that landed while +// the tick was working. +func setServer(ctx context.Context, runID, serverID, from string, set bson.M) (bool, error) { + fields := bson.M{} + for k, v := range set { + fields["servers.$."+k] = v + } + res, err := db.Col(patchRunsCol).UpdateOne(ctx, + bson.M{"run_id": runID, "servers": bson.M{"$elemMatch": bson.M{"server_id": serverID, "status": from}}}, + bson.M{"$set": fields}) + if err != nil { + return false, err + } + return res.MatchedCount > 0, nil +} + +func loadRun(ctx context.Context, filter bson.M) (*models.PatchRun, error) { + var run models.PatchRun + err := db.Col(patchRunsCol).FindOne(ctx, filter).Decode(&run) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrPatchRunNotFound + } + return &run, err +} + +// AdvancePatchRuns is the scheduler's second job each tick. +func AdvancePatchRuns(ctx context.Context) { + cur, err := db.Col(patchRunsCol).Find(ctx, bson.M{"status": models.PatchRunRunning}, options.Find().SetProjection(bson.M{"run_id": 1})) + if err != nil { + log.Printf("patch runs: find running: %v", err) + return + } + var ids []struct { + RunID string `bson:"run_id"` + } + if err := cur.All(ctx, &ids); err != nil { + log.Printf("patch runs: decode running: %v", err) + return + } + for _, r := range ids { + if ctx.Err() != nil { + return + } + advanceRun(ctx, r.RunID) + } +} + +func advanceRun(ctx context.Context, runID string) { + run, err := loadRun(ctx, bson.M{"run_id": runID}) + if err != nil { + log.Printf("patch run %s: load: %v", runID, err) + return + } + now := time.Now() + connected := map[string]bool{} + for _, s := range run.Servers { + if !patchrun.IsTerminal(s.Status) { + connected[s.ServerID] = Dispatcher.IsConnected(s.ServerID) + } + } + for _, tr := range patchrun.Advance(*run, now, connected) { + if tr.Dispatch { + cmdID := uuid.New().String() + ok, err := setServer(ctx, runID, tr.ServerID, tr.From, + bson.M{"status": models.PatchSrvPatching, "command_id": cmdID, "started_at": now}) + if err != nil || !ok { + continue + } + if err := dispatchPatch(tr.ServerID, cmdID, *run); err != nil { + // The agent dropped between the connection check and the send. + // Back to waiting: the next tick tries again while the window + // is open. + _, _ = setServer(ctx, runID, tr.ServerID, models.PatchSrvPatching, + bson.M{"status": models.PatchSrvWaitingOffline, "command_id": ""}) + } + continue + } + set := bson.M{"status": tr.To} + if tr.Error != "" { + set["error"] = tr.Error + } + if patchrun.IsTerminal(tr.To) { + set["finished_at"] = now + } + if _, err := setServer(ctx, runID, tr.ServerID, tr.From, set); err != nil { + log.Printf("patch run %s: server %s: %v", runID, tr.ServerID, err) + } + } + finalizeRun(ctx, runID) +} + +func finalizeRun(ctx context.Context, runID string) { + run, err := loadRun(ctx, bson.M{"run_id": runID}) + if err != nil || run.Status != models.PatchRunRunning { + return + } + status, done := patchrun.Finalize(*run) + if !done { + return + } + now := time.Now() + res, err := db.Col(patchRunsCol).UpdateOne(ctx, + bson.M{"run_id": runID, "status": models.PatchRunRunning}, + bson.M{"$set": bson.M{"status": status, "finished_at": now}}) + if err != nil || res.MatchedCount == 0 { + return // finalised by someone else + } + run.Status = status + name := run.PolicyName + if name == "" { + name = "manual update" + } + LogEvent(run.InstanceID, "patch.run_finished", run.TriggeredBy, "", "", + fmt.Sprintf("patch run %s (%s) %s: %s", runID, name, status, patchrun.Summary(*run))) + if status == models.PatchRunPartial || status == models.PatchRunFailed { + notifyPatchRun(*run) + } +} + +func notifyPatchRun(run models.PatchRun) { + if run.PolicyID == "" { + return // a manual run was watched by the person who clicked + } + p, err := GetPolicy(run.InstanceID, run.PolicyID) + if err != nil || len(p.NotifyChannelIDs) == 0 { + return + } + chs, err := GetChannels(run.InstanceID, p.NotifyChannelIDs) + if err != nil { + log.Printf("patch run %s: channels: %v", run.RunID, err) + return + } + ev := notify.Event{ + MonitorName: fmt.Sprintf("Patch policy %q %s", run.PolicyName, run.Status), + Type: notify.TypePatch, + NewStatus: run.Status, + Message: fmt.Sprintf("%s (run %s)", patchrun.Summary(run), run.RunID), + Time: time.Now(), + } + for _, ch := range chs { + if err := notify.Dispatch(ch, ev); err != nil { + log.Printf("patch run %s: notify %s: %v", run.RunID, ch.Name, err) + } + } +} + +// RecordPatchResult is called by whichever pod holds the agent's stream. The +// filter names this agent's own server, so one agent cannot answer for +// another's command. +func RecordPatchResult(instanceID, serverID string, r *pb.PatchResult) { + ctx, cancel := patchCtx() + defer cancel() + run, err := loadRun(ctx, bson.M{"instance_id": instanceID, + "servers": bson.M{"$elemMatch": bson.M{"server_id": serverID, "command_id": r.CommandId}}}) + if err != nil { + log.Printf("patch result %s from %s matches no run: %v", r.CommandId, serverID, err) + return + } + for _, s := range run.Servers { + if s.ServerID != serverID || s.CommandID != r.CommandId { + continue + } + updated, ok := patchrun.ApplyResult(s, r, time.Now()) + if !ok { + return + } + set := bson.M{"status": updated.Status, "output": updated.Output, "error": updated.Error} + if updated.PendingAfter != nil { + set["pending_after"] = *updated.PendingAfter + } + if updated.RebootedAt != nil { + set["rebooted_at"] = *updated.RebootedAt + } + if updated.FinishedAt != nil { + set["finished_at"] = *updated.FinishedAt + } + if ok, _ := setServer(ctx, run.RunID, serverID, models.PatchSrvPatching, set); ok && updated.Status == models.PatchSrvRebooting { + LogEvent(instanceID, "patch.reboot", "schedule", serverID, "", + fmt.Sprintf("%s rebooting for patch policy %s", s.Hostname, run.PolicyName)) + } + } + finalizeRun(ctx, run.RunID) +} + +// VerifyPatchReboots settles any rebooting server run for this server from a +// static inventory report. +func VerifyPatchReboots(instanceID, serverID string, bootTime time.Time, rebootRequired bool) { + ctx, cancel := patchCtx() + defer cancel() + cur, err := db.Col(patchRunsCol).Find(ctx, bson.M{"instance_id": instanceID, "status": models.PatchRunRunning, + "servers": bson.M{"$elemMatch": bson.M{"server_id": serverID, "status": models.PatchSrvRebooting}}}) + if err != nil { + return + } + var runs []models.PatchRun + if err := cur.All(ctx, &runs); err != nil { + return + } + now := time.Now() + for _, run := range runs { + for _, s := range run.Servers { + if s.ServerID != serverID { + continue + } + updated, ok := patchrun.VerifyReboot(s, bootTime, rebootRequired, now) + if !ok { + continue + } + set := bson.M{"status": updated.Status, "error": updated.Error, "finished_at": now} + if updated.VerifiedAt != nil { + set["verified_at"] = *updated.VerifiedAt + } + _, _ = setServer(ctx, run.RunID, serverID, models.PatchSrvRebooting, set) + } + finalizeRun(ctx, run.RunID) + } +} + +// CancelPatchRun stops further dispatch. Servers already patching finish: +// killing a package manager mid-transaction is worse than letting it end. +func CancelPatchRun(instanceID, runID string) error { + ctx, cancel := patchCtx() + defer cancel() + res, err := db.Col(patchRunsCol).UpdateOne(ctx, + bson.M{"instance_id": instanceID, "run_id": runID, "status": models.PatchRunRunning, "cancelled_at": bson.M{"$exists": false}}, + bson.M{"$set": bson.M{"cancelled_at": time.Now()}}) + if err != nil { + return err + } + if res.MatchedCount == 0 { + if _, err := GetPatchRun(instanceID, runID); err != nil { + return err + } + return ErrPatchRunFinished + } + advanceRun(ctx, runID) + return nil +} + +func GetPatchRun(instanceID, runID string) (*models.PatchRun, error) { + ctx, cancel := patchCtx() + defer cancel() + return loadRun(ctx, bson.M{"instance_id": instanceID, "run_id": runID}) +} + +// ListPatchRuns omits output: a list of fifty runs would otherwise carry up +// to 64KB per server. +func ListPatchRuns(instanceID, policyID, serverID string, limit int64) ([]models.PatchRun, error) { + ctx, cancel := patchCtx() + defer cancel() + filter := bson.M{"instance_id": instanceID} + if policyID != "" { + filter["policy_id"] = policyID + } + if serverID != "" { + filter["servers.server_id"] = serverID + } + if limit <= 0 || limit > 200 { + limit = 50 + } + cur, err := db.Col(patchRunsCol).Find(ctx, filter, options.Find(). + SetSort(bson.M{"started_at": -1}).SetLimit(limit).SetProjection(bson.M{"servers.output": 0})) + if err != nil { + return nil, err + } + out := []models.PatchRun{} + return out, cur.All(ctx, &out) +} + +// ScopePatchRun removes servers outside a tag-restricted token's reach, so a +// run record cannot name a host the caller could not otherwise see. +func ScopePatchRun(instanceID string, run *models.PatchRun, tokenScope map[string]string) error { + if len(tokenScope) == 0 { + return nil + } + ids := make([]string, 0, len(run.Servers)) + for _, s := range run.Servers { + ids = append(ids, s.ServerID) + } + visible, err := ResolveTargetsScoped(instanceID, ids, nil, tokenScope) + if err != nil && !errors.Is(err, ErrNoTargets) { + return err + } + keep := map[string]bool{} + for _, s := range visible { + keep[s.ServerID] = true + } + kept := run.Servers[:0] + for _, s := range run.Servers { + if keep[s.ServerID] { + kept = append(kept, s) + } + } + run.Servers = kept + return nil +} + +// sweepPatchRuns deletes finished runs past their instance's workflow log +// retention: they are the same kind of record, and one setting governs both. +func sweepPatchRuns() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + cur, err := db.Col(patchRunsCol).Find(ctx, bson.M{"finished_at": bson.M{"$ne": nil}}, + options.Find().SetProjection(bson.M{"run_id": 1, "instance_id": 1, "finished_at": 1})) + if err != nil { + log.Printf("patch run sweep: %v", err) + return + } + defer cur.Close(ctx) + cache := map[string]int{} + now := time.Now() + for cur.Next(ctx) { + var r struct { + RunID string `bson:"run_id"` + InstanceID string `bson:"instance_id"` + FinishedAt *time.Time `bson:"finished_at"` + } + if cur.Decode(&r) != nil || r.FinishedAt == nil { + continue + } + days, ok := cache[r.InstanceID] + if !ok { + days = defaultRetentionDays + if v, err := GetWorkflowLogRetentionDays(r.InstanceID); err == nil { + days = v + } + cache[r.InstanceID] = days + } + if days <= 0 || !r.FinishedAt.Before(now.AddDate(0, 0, -days)) { + continue + } + _, _ = db.Col(patchRunsCol).DeleteOne(ctx, bson.M{"run_id": r.RunID}) + } +} +``` + +In `StartLogSweeper` (`steplogs.go` ~line 332), change both `sweepLogs()` calls to: + +```go + sweepLogs() + sweepPatchRuns() +``` + +- [ ] **Step 5: Build and run all server tests** + +Run: `go build ./... && go vet ./internal/services/ ./internal/notify/ && go test ./internal/...` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/services/patch_runs.go internal/services/steplogs.go internal/notify/dispatch.go internal/notify/patch_test.go +git commit -m "feat: patch run service - dispatch, results, reboot verification, cancel, alerts, retention" +``` + +### Task 13: Scheduler loop, gRPC wiring and boot + +**Files:** +- Create: `internal/patchsched/sched.go` +- Modify: `internal/grpc/server.go` (`ReportInventory` ~line 231, `CommandStream` receive loop ~line 322) +- Modify: `cmd/main.go` (leader block ~line 246) + +**Interfaces:** +- Consumes: `Decide`, `NextStart`, `WindowEnd`, `Later` (Task 10); `services.LookupWindow`, `services.CountPolicyTargets` (Task 11); `services.StartPolicyRun`, `services.AdvancePatchRuns`, `services.RecordPatchResult`, `services.VerifyPatchReboots` (Task 12). +- Produces: `type Deps struct{...}`, `func Start(ctx context.Context, deps Deps)`. + +This task is wiring around already-tested pure functions. Its check is the build plus the manual verification in Task 21. + +- [ ] **Step 1: Create `internal/patchsched/sched.go`** + +```go +package patchsched + +import ( + "context" + "log" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +const tickInterval = 30 * time.Second + +// Deps are injected from main.go: services imports this package, so this +// package cannot import services. +type Deps struct { + // LookupWindow returns nil, nil when the window no longer exists. + LookupWindow func(instanceID, windowID string) (*models.MaintenanceWindow, error) + CountTargets func(p models.PatchPolicy) (int, error) + StartPolicyRun func(p models.PatchPolicy, windowEnd time.Time) error + AdvanceRuns func(ctx context.Context) + LogEvent func(instanceID, eventType, actor, serverID, keyID, details string) +} + +// Start runs until ctx is cancelled, inside bus.RunAsLeader("housekeeping"). +// Each tick fires due policies, then advances every running run. +func Start(ctx context.Context, deps Deps) { + go func() { + t := time.NewTicker(tickInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + fireDue(ctx, deps, time.Now()) + deps.AdvanceRuns(ctx) + } + } + }() +} + +func fireDue(ctx context.Context, deps Deps, now time.Time) { + cur, err := db.Col("patch_policies").Find(ctx, bson.M{"enabled": true, "next_run_at": bson.M{"$lte": now}}) + if err != nil { + log.Printf("patchsched: find due: %v", err) + return + } + var due []models.PatchPolicy + if err := cur.All(ctx, &due); err != nil { + log.Printf("patchsched: decode due: %v", err) + return + } + for _, p := range due { + if ctx.Err() != nil { + return + } + process(ctx, deps, p, now) + } +} + +func process(ctx context.Context, deps Deps, p models.PatchPolicy, now time.Time) { + if p.NextRunAt == nil { + return + } + due := *p.NextRunAt + + w, err := deps.LookupWindow(p.InstanceID, p.WindowID) + if err != nil { + log.Printf("patchsched: policy %s: load window: %v", p.PolicyID, err) + return // a database error is retried next tick, not treated as "gone" + } + if w == nil { + disable(ctx, deps, p, "its maintenance window no longer exists") + return + } + end := WindowEnd(due, w.DurationMinutes) + next, err := NextStart(w.Cron, w.TZ, Later(now, end)) + if err != nil { + disable(ctx, deps, p, "its maintenance window schedule is no longer valid: "+err.Error()) + return + } + + // The claim, as in workflowsched: matching the current next_run_at means a + // second process reaching this policy matches nothing. + res, err := db.Col("patch_policies").UpdateOne(ctx, + bson.M{"policy_id": p.PolicyID, "next_run_at": due}, + bson.M{"$set": bson.M{"next_run_at": next}}) + if err != nil || res.MatchedCount == 0 { + return + } + + n, err := deps.CountTargets(p) + if err != nil { + recordSkip(ctx, deps, p, "error: "+err.Error(), due, now) + return + } + switch d := Decide(due, end, now, hasActiveRun(ctx, p), n); d { + case Fire: + if err := deps.StartPolicyRun(p, end); err != nil { + recordSkip(ctx, deps, p, "error: "+err.Error(), due, now) + return + } + _, _ = db.Col("patch_policies").UpdateOne(ctx, bson.M{"policy_id": p.PolicyID}, + bson.M{"$set": bson.M{"last_run_at": now}, "$unset": bson.M{"last_skipped": ""}}) + default: + recordSkip(ctx, deps, p, string(d), due, now) + } +} + +func hasActiveRun(ctx context.Context, p models.PatchPolicy) bool { + err := db.Col("patch_runs").FindOne(ctx, + bson.M{"instance_id": p.InstanceID, "policy_id": p.PolicyID, "status": models.PatchRunRunning}, + options.FindOne().SetProjection(bson.M{"_id": 1})).Err() + return err == nil +} + +func recordSkip(ctx context.Context, deps Deps, p models.PatchPolicy, reason string, due, at time.Time) { + _, _ = db.Col("patch_policies").UpdateOne(ctx, bson.M{"policy_id": p.PolicyID}, + bson.M{"$set": bson.M{"last_skipped": models.Skip{Reason: reason, Due: due, At: at}}}) + deps.LogEvent(p.InstanceID, "patch.skipped", "schedule", "", "", + "patch policy "+p.Name+" skipped "+due.Format(time.RFC3339)+": "+reason) +} + +func disable(ctx context.Context, deps Deps, p models.PatchPolicy, reason string) { + _, _ = db.Col("patch_policies").UpdateOne(ctx, bson.M{"policy_id": p.PolicyID}, bson.M{ + "$set": bson.M{"enabled": false, "disabled_reason": reason}, + "$unset": bson.M{"next_run_at": ""}, + }) + deps.LogEvent(p.InstanceID, "patch.policy_disabled", "schedule", "", "", "patch policy "+p.Name+" disabled: "+reason) +} +``` + +- [ ] **Step 2: Wire results and reboot verification in `internal/grpc/server.go`** + +In `ReportInventory`, after the `StoreInventory` call: + +```go + // Only static snapshots compute reboot_required, so only they can settle a + // reboot. The agent sends one at start, so the first report after a + // reboot qualifies. + if req.IncludeStatic && req.BootTimeUnix > 0 { + services.VerifyPatchReboots(srv.InstanceID, srv.ServerID, time.Unix(req.BootTimeUnix, 0), req.RebootRequired) + } +``` + +In `CommandStream`'s receive goroutine, after the `WorkloadLogsResult` branch: + +```go + if m.PatchResult != nil { + services.RecordPatchResult(srv.InstanceID, srv.ServerID, m.PatchResult) + } +``` + +- [ ] **Step 3: Start the scheduler in `cmd/main.go`** + +Inside the `bus.RunAsLeader(ctx, "housekeeping", ...)` function, after `workflowsched.Start(...)`: + +```go + patchsched.Start(jobCtx, patchsched.Deps{ + LookupWindow: services.LookupWindow, + CountTargets: services.CountPolicyTargets, + StartPolicyRun: func(p models.PatchPolicy, windowEnd time.Time) error { + _, err := services.StartPolicyRun(p, windowEnd, models.PatchSourceSchedule, "schedule") + return err + }, + AdvanceRuns: services.AdvancePatchRuns, + LogEvent: services.LogEvent, + }) +``` + +Add imports `gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/patchsched` and, if absent, `.../internal/models`. + +- [ ] **Step 4: Build and test** + +Run: `go build ./... && go vet ./... && go test ./...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/patchsched/sched.go internal/grpc/server.go cmd/main.go +git commit -m "feat: patch scheduler loop; record patch results and verify reboots from the agent stream" +``` + +### Task 14: REST API, scopes, MCP and generated OpenAPI + +**Files:** +- Create: `internal/api/patching.go` +- Modify: `internal/api/handlers.go` (register routes beside `registerStatusPageRoutes(apiGroup)` ~line 189; rewrite `applyUpdates` ~line 761) +- Modify: `internal/api/scopes.go` (`routeScopes`), `internal/api/serverscope.go` (`serverScopedRoutes`), `internal/api/types.go` +- Modify: `internal/services/scopes.go` (`ScopeResources`), `internal/services/patch_policies.go` (append `StartRunNow`, `CheckPolicyScope`, `ErrPatchRunActive`) +- Modify: `internal/mcp/tools_write.go` (`apply_updates` handler ~line 185) +- Regenerate: `internal/api/docs/openapi.json` +- Modify: `/go-projects/vantage/vantage-app/CLAUDE.md` +- Test: `internal/api/patching_scope_test.go` + +**Interfaces:** +- Consumes: everything in Tasks 11 and 12. +- Produces: the routes below; `services.StartRunNow(instanceID, policyID, actor string, tokenScope map[string]string) (*models.PatchRun, error)`; `services.CheckPolicyScope(instanceID string, p models.PatchPolicy, tokenScope map[string]string) error`; `services.ErrPatchRunActive`; `api.ApplyUpdatesResponse{Message, RunID string}`. + +| Route | Scope | Role | Server scope | +| --- | --- | --- | --- | +| `GET /api/maintenance-windows` | `patching:read` | any | exempt | +| `POST /api/maintenance-windows` | `patching:write` | owner, admin | exempt | +| `POST /api/maintenance-windows/preview` | `patching:read` | any | exempt | +| `GET /api/maintenance-windows/:id` | `patching:read` | any | exempt | +| `PUT /api/maintenance-windows/:id` | `patching:write` | owner, admin | exempt | +| `DELETE /api/maintenance-windows/:id` | `patching:write` | owner, admin | exempt | +| `GET /api/patch-policies` | `patching:read` | any | exempt | +| `POST /api/patch-policies` | `patching:write` | owner, admin | scoped | +| `GET /api/patch-policies/:id` | `patching:read` | any | exempt | +| `PUT /api/patch-policies/:id` | `patching:write` | owner, admin | scoped | +| `DELETE /api/patch-policies/:id` | `patching:write` | owner, admin | scoped | +| `POST /api/patch-policies/:id/run-now` | `patching:write` | owner, admin | scoped | +| `GET /api/patch-runs` | `patching:read` | any | scoped | +| `GET /api/patch-runs/:runId` | `patching:read` | any | scoped | +| `POST /api/patch-runs/:runId/cancel` | `patching:write` | any | scoped | + +- [ ] **Step 1: Write the failing test** + +```go +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) + } + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/api/ -run PatchingRouteScopes -v` +Expected: FAIL: every route reports scope `""`. + +- [ ] **Step 3: Add the `patching` scope resource** + +In `internal/services/scopes.go`, add `"patching",` to `ScopeResources` after `"status",` and change the comment "Ten resources" to "Eleven resources". + +- [ ] **Step 4: Add route scopes and server-scope declarations** + +In `routeScopes` (`internal/api/scopes.go`), after the status-pages block, add the fifteen entries from the test's `want` map verbatim. + +In `serverScopedRoutes` (`internal/api/serverscope.go`), add: + +```go + // 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, +``` + +- [ ] **Step 5: Append run-now and scope checks to `internal/services/patch_policies.go`** + +```go +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) +} +``` + +- [ ] **Step 6: Add the response type to `internal/api/types.go`** + +```go +// 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"` +} +``` + +- [ ] **Step 7: Create `internal/api/patching.go`** + +```go +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 +} +``` + +- [ ] **Step 8: Register the routes and rewrite `applyUpdates`** + +In `handlers.go`, after `registerStatusPageRoutes(apiGroup)` add `registerPatchingRoutes(apiGroup)`. + +Replace the body and annotations of `applyUpdates`: + +```go +// applyUpdates godoc +// +// @Summary Apply pending OS updates on a server +// @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" +// @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) { + 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 + } + 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 + } + 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}) +} +``` + +Add `errors` and `.../internal/models` to `handlers.go` imports if missing. + +- [ ] **Step 9: MCP `apply_updates` returns run IDs** + +In `internal/mcp/tools_write.go`, update the tool's comment to say it starts one manual patch run per server, and replace the dispatch loop: + +```go + result := updateBatchResult{Servers: len(targets), Failed: map[string]string{}} + 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 + } +``` + +Add to `updateBatchResult` (defined in the same package; find it with `grep -n "type updateBatchResult" internal/mcp/*.go`): + +```go + RunIDs map[string]string `json:"run_ids,omitempty"` // server ID -> patch run ID +``` + +Run: `go test ./internal/mcp/ -v`. If a schema snapshot test (`registry_schema_test.go`) fails because the tool description changed, update the expected text in that test to the new description. + +- [ ] **Step 10: Run all tests** + +Run: `go build ./... && go vet ./... && go test ./...` +Expected: PASS, including `TestPatchingRouteScopes` and `TestServerScopeMapCoversEveryScopedRoute`. + +- [ ] **Step 11: Regenerate the OpenAPI document** + +CI fails if the committed document drifts from the annotations. + +```bash +cd /go-projects/vantage/vantage-app/server +go install github.com/swaggo/swag/v2/cmd/swag@v2.0.0-rc5 +SHARED_DIR=$(go list -m -f '{{.Dir}}' gitea.hostxtra.co.uk/vantage/vantage-shared) +swag init --generalInfo cmd/main.go --dir "./,$SHARED_DIR" --output internal/api/docs --outputTypes json --v3.1 +mv -f internal/api/docs/swagger.json internal/api/docs/openapi.json +git diff --stat internal/api/docs/openapi.json +``` +Expected: the diff shows the new `patching` paths and schemas. + +- [ ] **Step 12: Document the subsystem in `/go-projects/vantage/vantage-app/CLAUDE.md`** + +1. In "Inventory and OS updates", replace the paragraph starting "**The agent never reboots a host.**" with: + +```markdown +**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. +``` + +2. Add a new subsystem section after "Scheduled workflows": + +```markdown +### 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`. +``` + +3. Add `maintenance_windows · patch_policies · patch_runs` to the "MongoDB Collections" list. +4. In the REST section add: + +``` +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 +``` + +5. In the gRPC section, note that `AgentMessage` now also carries `PatchResult`. + +- [ ] **Step 13: Commit** + +```bash +git add internal/api/ internal/services/scopes.go internal/services/patch_policies.go internal/mcp/ ../CLAUDE.md +git commit -m "feat: patching REST API, patching scope, run IDs from apply-updates and MCP" +``` + +--- + +## Phase D: web (`vantage-app/web`) + +Paths are relative to `/go-projects/vantage/vantage-app/web`. `web/` has no unit-test runner: each task's check is `npm run lint` and `npm run build` (both must pass with no new warnings), then the browser pass in Task 21. Reuse the existing `inputClass` string and component patterns exactly as shown; tokens only, no hex. + +### Task 15: API client, status vocabulary, navigation, audit category + +**Files:** +- Modify: `lib/api.ts` (types after `WorkflowRun` ~line 418; methods inside `export const api = {` beside `applyUpdates` ~line 845) +- Create: `components/patching/status.ts` +- Modify: `components/Sidebar.tsx` (Fleet group ~line 181; add a `PatchIcon`) +- Modify: `lib/auditEvents.ts` (`AUDIT_CATEGORIES`) + +**Interfaces:** +- Produces (TypeScript): `PatchScope`, `PatchReboot`, `PatchRunStatus`, `PatchServerStatus`, `MaintenanceWindow`, `MaintenanceWindowInput`, `WindowSpan`, `PatchPolicy`, `PatchPolicyInput`, `PatchServerRun`, `PatchRun`; `api.listMaintenanceWindows`, `api.createMaintenanceWindow`, `api.updateMaintenanceWindow`, `api.deleteMaintenanceWindow`, `api.previewMaintenanceWindow`, `api.listPatchPolicies`, `api.createPatchPolicy`, `api.updatePatchPolicy`, `api.deletePatchPolicy`, `api.runPatchPolicyNow`, `api.listPatchRuns`, `api.getPatchRun`, `api.cancelPatchRun`; `api.applyUpdates(serverId, source?)`; from `status.ts`: `RUN_STATUS`, `SERVER_STATUS`, `MIN_AGENT_VERSION`, `agentSupportsPatchResults`, `describeCron`, `formatDuration`. + +- [ ] **Step 1: Add the types to `lib/api.ts`** + +```ts +export type PatchScope = "all" | "security"; +export type PatchReboot = "never" | "if_required"; +export type PatchRunStatus = "running" | "succeeded" | "partial" | "failed" | "cancelled"; +export type PatchServerStatus = + | "queued" + | "waiting_offline" + | "patching" + | "rebooting" + | "succeeded" + | "failed" + | "unsupported" + | "agent_too_old" + | "missed_offline" + | "window_closed" + | "cancelled"; + +export interface MaintenanceWindow { + window_id: string; + name: string; + cron: string; + tz: string; + duration_minutes: number; + created_at: string; + updated_at: string; +} + +export interface MaintenanceWindowInput { + name: string; + cron: string; + tz: string; + duration_minutes: number; +} + +export interface WindowSpan { + start: string; + end: string; +} + +export interface PatchPolicy { + policy_id: string; + name: string; + enabled: boolean; + window_id: string; + target_server_ids: string[]; + target_tags?: Record; + scope: PatchScope; + reboot: PatchReboot; + max_concurrent: number; + notify_channel_ids?: string[]; + next_run_at?: string; + last_run_at?: string; + last_skipped?: Skip; + disabled_reason?: string; + created_at: string; + updated_at: string; +} + +export type PatchPolicyInput = Pick< + PatchPolicy, + "name" | "enabled" | "window_id" | "target_server_ids" | "target_tags" | "scope" | "reboot" | "max_concurrent" | "notify_channel_ids" +>; + +export interface PatchServerRun { + server_id: string; + hostname: string; + status: PatchServerStatus; + pending_before: number; + pending_after?: number; + rebooted_at?: string; + verified_at?: string; + output?: string; + error?: string; + started_at?: string; + finished_at?: string; +} + +export interface PatchRun { + run_id: string; + policy_id?: string; + policy_name?: string; + triggered_by: string; + source: "schedule" | "run_now" | "server" | "vulnerabilities" | "mcp"; + scope: PatchScope; + reboot: PatchReboot; + max_concurrent: number; + window_end?: string; + status: PatchRunStatus; + cancelled_at?: string; + started_at: string; + finished_at?: string; + servers: PatchServerRun[]; +} +``` + +- [ ] **Step 2: Add the methods inside `export const api = {`** + +Replace `applyUpdates` with: + +```ts + applyUpdates(serverId: string, source?: "vulnerabilities"): Promise<{ message: string; run_id?: string }> { + const qs = source ? `?source=${source}` : ""; + return request<{ message: string; run_id?: string }>(`/servers/${serverId}/apply-updates${qs}`, { + method: "POST", + }); + }, + + listMaintenanceWindows(): Promise { + return request("/maintenance-windows"); + }, + + createMaintenanceWindow(input: MaintenanceWindowInput): Promise { + return request("/maintenance-windows", { method: "POST", body: JSON.stringify(input) }); + }, + + updateMaintenanceWindow(windowId: string, input: MaintenanceWindowInput): Promise { + return request(`/maintenance-windows/${windowId}`, { method: "PUT", body: JSON.stringify(input) }); + }, + + deleteMaintenanceWindow(windowId: string): Promise { + return request(`/maintenance-windows/${windowId}`, { method: "DELETE" }); + }, + + previewMaintenanceWindow(input: Omit): Promise { + return request("/maintenance-windows/preview", { method: "POST", body: JSON.stringify(input) }); + }, + + listPatchPolicies(): Promise { + return request("/patch-policies"); + }, + + createPatchPolicy(input: PatchPolicyInput): Promise { + return request("/patch-policies", { method: "POST", body: JSON.stringify(input) }); + }, + + updatePatchPolicy(policyId: string, input: PatchPolicyInput): Promise { + return request(`/patch-policies/${policyId}`, { method: "PUT", body: JSON.stringify(input) }); + }, + + deletePatchPolicy(policyId: string): Promise { + return request(`/patch-policies/${policyId}`, { method: "DELETE" }); + }, + + runPatchPolicyNow(policyId: string): Promise { + return request(`/patch-policies/${policyId}/run-now`, { method: "POST" }); + }, + + listPatchRuns(params: { policy_id?: string; server_id?: string; limit?: number } = {}): Promise { + const qs = new URLSearchParams(); + if (params.policy_id) qs.set("policy_id", params.policy_id); + if (params.server_id) qs.set("server_id", params.server_id); + if (params.limit) qs.set("limit", String(params.limit)); + const suffix = qs.toString(); + return request(`/patch-runs${suffix ? `?${suffix}` : ""}`); + }, + + getPatchRun(runId: string): Promise { + return request(`/patch-runs/${runId}`); + }, + + cancelPatchRun(runId: string): Promise { + return request(`/patch-runs/${runId}/cancel`, { method: "POST" }); + }, +``` + +- [ ] **Step 3: Create `components/patching/status.ts`** + +```ts +import type { PatchRunStatus, PatchServerStatus } from "@/lib/api"; + +type Variant = "success" | "warning" | "danger" | "neutral" | "accent"; + +/* + * One place that says how every patch state reads. Badge adds a dot to the + * three state variants, so each label here is also readable without colour. + */ +export const RUN_STATUS: Record = { + running: { label: "running", variant: "accent" }, + succeeded: { label: "succeeded", variant: "success" }, + partial: { label: "partial", variant: "warning" }, + failed: { label: "failed", variant: "danger" }, + cancelled: { label: "cancelled", variant: "neutral" }, +}; + +export const SERVER_STATUS: Record = { + queued: { label: "queued", variant: "neutral" }, + waiting_offline: { label: "waiting for agent", variant: "warning" }, + patching: { label: "patching", variant: "accent" }, + rebooting: { label: "rebooting", variant: "accent" }, + succeeded: { label: "succeeded", variant: "success" }, + failed: { label: "failed", variant: "danger" }, + unsupported: { label: "unsupported", variant: "warning" }, + agent_too_old: { label: "agent too old", variant: "warning" }, + missed_offline: { label: "missed, offline", variant: "danger" }, + window_closed: { label: "window closed", variant: "danger" }, + cancelled: { label: "cancelled", variant: "neutral" }, +}; + +/* + * Mirrors patchrun.MinAgentVersion and AgentSupportsPatchResults in the + * server. The server is the authority; this only lets the policy editor warn + * before saving. Change both together. + */ +export const MIN_AGENT_VERSION = "1.4.0"; + +export function agentSupportsPatchResults(version?: string): boolean { + const m = /^v?(\d+)\.(\d+)\.(\d+)(-[^+]+)?/.exec((version ?? "").trim()); + if (!m) return false; + const have = [Number(m[1]), Number(m[2]), Number(m[3])]; + const want = MIN_AGENT_VERSION.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if (have[i] !== want[i]) return have[i] > want[i]; + } + return !m[4]; +} + +const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/** Plain words for the common shapes; anything else shows the expression itself. */ +export function describeCron(cron: string): string { + const f = cron.trim().split(/\s+/); + if (f.length !== 5 || !/^\d+$/.test(f[0]) || !/^\d+$/.test(f[1])) return cron; + const at = `${f[1].padStart(2, "0")}:${f[0].padStart(2, "0")}`; + const [, , dom, mon, dow] = f; + if (dom === "*" && mon === "*" && dow === "*") return `Daily at ${at}`; + if (dom === "*" && mon === "*" && /^[0-6]$/.test(dow)) return `${DAYS[Number(dow)]}s at ${at}`; + if (/^\d+$/.test(dom) && mon === "*" && dow === "*") return `Day ${dom} of each month at ${at}`; + return cron; +} + +export function formatDuration(minutes: number): string { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (h === 0) return `${m} min`; + return m === 0 ? `${h} h` : `${h} h ${m} min`; +} +``` + +- [ ] **Step 4: Add Patching to the sidebar** + +In `components/Sidebar.tsx`, add beside the other icon components: + +```tsx +function PatchIcon() { + return ( + + + + ); +} +``` + +and in the Fleet group, after the Vulnerabilities item: + +```tsx + { href: "/patching", label: "Patching", icon: }, +``` + +- [ ] **Step 5: Add the audit category** + +In `lib/auditEvents.ts`, add to `AUDIT_CATEGORIES` after the `updates` entry: + +```ts + { value: "patch", label: "Patching" }, +``` + +- [ ] **Step 6: Lint and build** + +Run: `cd /go-projects/vantage/vantage-app/web && npm run lint && npm run build` +Expected: both succeed. The vulnerabilities and server pages still compile, because `applyUpdates`' new parameter is optional. + +- [ ] **Step 7: Commit** + +```bash +git add lib/api.ts lib/auditEvents.ts components/patching/status.ts components/Sidebar.tsx +git commit -m "feat(web): patching API client, status vocabulary and navigation" +``` + +### Task 16: Window and policy editors + +**Files:** +- Create: `components/patching/WindowModal.tsx` +- Create: `components/patching/PolicyModal.tsx` + +**Interfaces:** +- Consumes: Task 15 types and methods; `DualListBox` (`components/workflows/DualListBox.tsx`: props `items`, `selected`, `onChange`, `selectedLabel`, `emptyAvailable`, `emptySelected`); `resolveTargets` from `lib/targets.ts`; `Modal`, `Button`, `friendlyMessage`, `useToast` from `components/ui`. +- Produces: `WindowModal({ initial?, onClose, onSaved? })`, `PolicyModal({ initial?, onClose })`. + +- [ ] **Step 1: Create `components/patching/WindowModal.tsx`** + +```tsx +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, MaintenanceWindow } from "@/lib/api"; +import { Button, Modal, friendlyMessage, useToast } from "@/components/ui"; + +/* + * Presets write cron underneath, as the workflow schedule card does, and the + * next three windows come from the server, so the editor cannot disagree with + * the scheduler about when a window opens. + */ +const PRESETS: { label: string; cron: string }[] = [ + { label: "Nightly, 02:00", cron: "0 2 * * *" }, + { label: "Sunday, 02:00", cron: "0 2 * * 0" }, + { label: "Saturday, 22:00", cron: "0 22 * * 6" }, + { label: "Monthly, 1st 02:00", cron: "0 2 1 * *" }, +]; + +const ZONES = ["UTC", "Europe/London", "Europe/Berlin", "America/New_York", "America/Los_Angeles", "Asia/Singapore", "Australia/Sydney"]; + +const inputClass = "w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"; + +export function WindowModal({ initial, onClose, onSaved }: { initial?: MaintenanceWindow; onClose: () => void; onSaved?: (w: MaintenanceWindow) => void }) { + const queryClient = useQueryClient(); + const toast = useToast(); + const [name, setName] = useState(initial?.name ?? ""); + const [cron, setCron] = useState(initial?.cron ?? "0 2 * * 0"); + const [tz, setTz] = useState(initial?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC"); + const [duration, setDuration] = useState(initial?.duration_minutes ?? 120); + const [error, setError] = useState(null); + + const { data: preview, isError: previewFailed, error: previewError } = useQuery({ + queryKey: ["window-preview", cron, tz, duration], + queryFn: () => api.previewMaintenanceWindow({ cron, tz, duration_minutes: duration }), + retry: false, + }); + + const { mutate: save, isPending } = useMutation({ + mutationFn: () => { + const input = { name, cron, tz, duration_minutes: duration }; + return initial ? api.updateMaintenanceWindow(initial.window_id, input) : api.createMaintenanceWindow(input); + }, + onSuccess: (w) => { + queryClient.invalidateQueries({ queryKey: ["maintenance-windows"] }); + queryClient.invalidateQueries({ queryKey: ["patch-policies"] }); + toast.success(initial ? `Saved ${w.name}. Policies using it now follow the new times.` : `Created ${w.name}.`); + onSaved?.(w); + onClose(); + }, + onError: (e) => setError(friendlyMessage(e)), + }); + + return ( + +
+ {error && ( +
+ {error} +
+ )} + + +
+ {PRESETS.map((p) => ( + + ))} +
+ +
+ + + +
+ +
+

Next three windows

+ {previewFailed ? ( +

{friendlyMessage(previewError)}

+ ) : preview ? ( +
    + {preview.map((s) => ( +
  • + {new Date(s.start).toLocaleString()} to {new Date(s.end).toLocaleTimeString()} +
  • + ))} +
+ ) : ( +

Working it out…

+ )} +
+ +
+ + +
+
+
+ ); +} +``` + +- [ ] **Step 2: Create `components/patching/PolicyModal.tsx`** + +```tsx +"use client"; + +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, PatchPolicy, PatchReboot, PatchScope } from "@/lib/api"; +import { resolveTargets } from "@/lib/targets"; +import { Button, Modal, friendlyMessage, useToast } from "@/components/ui"; +import { DualListBox } from "@/components/workflows/DualListBox"; +import { WindowModal } from "./WindowModal"; +import { agentSupportsPatchResults, describeCron, formatDuration, MIN_AGENT_VERSION } from "./status"; + +const inputClass = "w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"; + +function Radio({ name, value, current, onChange, title, hint }: { name: string; value: T; current: T; onChange: (v: T) => void; title: string; hint: string }) { + return ( + + ); +} + +export function PolicyModal({ initial, onClose }: { initial?: PatchPolicy; onClose: () => void }) { + const queryClient = useQueryClient(); + const toast = useToast(); + const [name, setName] = useState(initial?.name ?? ""); + const [enabled, setEnabled] = useState(initial?.enabled ?? true); + const [windowId, setWindowId] = useState(initial?.window_id ?? ""); + const [targets, setTargets] = useState(initial?.target_server_ids ?? []); + const [tagRows, setTagRows] = useState<[string, string][]>(Object.entries(initial?.target_tags ?? {})); + const [scope, setScope] = useState(initial?.scope ?? "security"); + const [reboot, setReboot] = useState(initial?.reboot ?? "never"); + const [maxConcurrent, setMaxConcurrent] = useState(initial?.max_concurrent ?? 0); + const [channels, setChannels] = useState(initial?.notify_channel_ids ?? []); + const [newWindow, setNewWindow] = useState(false); + const [error, setError] = useState(null); + + const { data: windows } = useQuery({ queryKey: ["maintenance-windows"], queryFn: () => api.listMaintenanceWindows() }); + const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() }); + const { data: knownTags } = useQuery({ queryKey: ["server-tags"], queryFn: () => api.listKnownTags(), staleTime: 60_000 }); + const { data: allChannels } = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() }); + + const tags = useMemo(() => Object.fromEntries(tagRows.filter(([k, v]) => k && v)), [tagRows]); + const resolved = useMemo(() => resolveTargets(servers ?? [], targets, tags), [servers, targets, tags]); + const tooOld = resolved.filter((s) => !agentSupportsPatchResults(s.agent_version)); + + const { mutate: save, isPending } = useMutation({ + mutationFn: () => { + const input = { name, enabled, window_id: windowId, target_server_ids: targets, target_tags: tags, scope, reboot, max_concurrent: maxConcurrent, notify_channel_ids: channels }; + return initial ? api.updatePatchPolicy(initial.policy_id, input) : api.createPatchPolicy(input); + }, + onSuccess: (p) => { + queryClient.invalidateQueries({ queryKey: ["patch-policies"] }); + toast.success(initial ? `Saved ${p.name}.` : `Created ${p.name}.`); + onClose(); + }, + onError: (e) => setError(friendlyMessage(e)), + }); + + return ( + <> + +
+ {error && ( +
+ {error} +
+ )} + +
+ + +
+ +
+ Target servers + ({ id: s.server_id, label: s.hostname, hint: s.status === "active" ? undefined : s.status }))} + selected={targets} + onChange={setTargets} + selectedLabel="Targets" + emptyAvailable="Every server is a target." + emptySelected="No servers named." + /> +
+ +
+ Target tags +

Servers carrying every tag below are patched too. Tags are read when the window opens, so a server tagged later is included.

+ + {Object.keys(knownTags ?? {}).map((k) => ( + +
+ {tagRows.map(([k, v], i) => ( +
+ setTagRows(tagRows.map((r, j): [string, string] => (j === i ? [e.target.value, r[1]] : r)))} /> + setTagRows(tagRows.map((r, j): [string, string] => (j === i ? [r[0], e.target.value] : r)))} /> + +
+ ))} +
+ +
+
+

+ {resolved.length} server{resolved.length === 1 ? "" : "s"} targeted now. + {tooOld.length > 0 && ( + + {" "} + {tooOld.length} of {resolved.length} need an agent update to {MIN_AGENT_VERSION} or later and will be skipped until then. + + )} +

+
+ +
+
+ What to install + + +
+
+ Reboots + + + {reboot === "if_required" && resolved.length > 0 && ( +

+ Up to {maxConcurrent > 0 ? Math.min(maxConcurrent, resolved.length) : resolved.length} server{resolved.length === 1 ? "" : "s"} may be rebooting at the same time during this window. +

+ )} +
+
+ +
+ +
+ Alert when a run is not clean +
+ {(allChannels ?? []).length === 0 &&

No notification channels yet.

} + {(allChannels ?? []).map((ch) => ( + + ))} +
+
+
+ + + +
+ + +
+
+
+ {newWindow && setNewWindow(false)} onSaved={(w) => setWindowId(w.window_id)} />} + + ); +} +``` + +- [ ] **Step 3: Lint and build** + +Run: `npm run lint && npm run build` +Expected: both succeed. If `Server` in `lib/api.ts` has no `agent_version` field, add `agent_version?: string;` to it (the server already sends it). + +- [ ] **Step 4: Commit** + +```bash +git add components/patching/WindowModal.tsx components/patching/PolicyModal.tsx lib/api.ts +git commit -m "feat(web): maintenance window and patch policy editors" +``` + +### Task 17: The Patching page: policies, windows and runs + +**Files:** +- Create: `components/patching/PolicyList.tsx`, `components/patching/WindowList.tsx`, `components/patching/RunList.tsx` +- Create: `app/(app)/patching/page.tsx` + +**Interfaces:** +- Consumes: Tasks 15 and 16; `useAuth()` from `components/AuthProvider` (`isAdmin`); `resolveTargets`; `Badge`, `Button`, `Card`, `AsyncBoundary`, `EmptyState`, `TableSkeleton`, `Table`, `Thead`, `Tbody`, `Tr`, `Th`, `Td`, `ConfirmDialog`, `friendlyMessage`, `useToast`. +- Produces: `PolicyList`, `WindowList`, `RunList({ policyId? })`, route `/patching?tab=policies|windows|runs`. + +- [ ] **Step 1: Create `components/patching/RunList.tsx`** + +```tsx +"use client"; + +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { AsyncBoundary, Badge, EmptyState, Table, TableSkeleton, Tbody, Td, Th, Thead, Tr } from "@/components/ui"; +import { RUN_STATUS } from "./status"; + +const SOURCE_LABEL: Record = { + schedule: "window", + run_now: "run now", + server: "server page", + vulnerabilities: "vulnerabilities", + mcp: "agent (MCP)", +}; + +export function RunList({ policyId }: { policyId?: string }) { + const { data: runs, isLoading, error, refetch } = useQuery({ + queryKey: ["patch-runs", policyId ?? "all"], + queryFn: () => api.listPatchRuns({ policy_id: policyId, limit: 50 }), + // A run in progress changes on every scheduler tick. + refetchInterval: (q) => (q.state.data?.some((r) => r.status === "running") ? 10_000 : false), + }); + + return ( + } + isEmpty={!runs || runs.length === 0} + empty={} + > + + + + + + + + + + + + {(runs ?? []).map((r) => { + const ok = r.servers.filter((s) => s.status === "succeeded").length; + return ( + + + + + + + + ); + })} + +
StartedPolicyStatusServersStarted by
+ + {new Date(r.started_at).toLocaleString()} + + {r.policy_name ?? manual} + {RUN_STATUS[r.status].label} + + + {ok}/{r.servers.length} + + + + {r.triggered_by} via {SOURCE_LABEL[r.source] ?? r.source} + +
+
+ ); +} +``` + +- [ ] **Step 2: Create `components/patching/WindowList.tsx`** + +```tsx +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, MaintenanceWindow } from "@/lib/api"; +import { AsyncBoundary, Button, ConfirmDialog, EmptyState, Table, TableSkeleton, Tbody, Td, Th, Thead, Tr, friendlyMessage, useToast } from "@/components/ui"; +import { WindowModal } from "./WindowModal"; +import { describeCron, formatDuration } from "./status"; + +export function WindowList({ canEdit }: { canEdit: boolean }) { + const queryClient = useQueryClient(); + const toast = useToast(); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + const { data: windows, isLoading, error, refetch } = useQuery({ queryKey: ["maintenance-windows"], queryFn: () => api.listMaintenanceWindows() }); + const { data: policies } = useQuery({ queryKey: ["patch-policies"], queryFn: () => api.listPatchPolicies() }); + + const { mutate: remove, isPending } = useMutation({ + mutationFn: (w: MaintenanceWindow) => api.deleteMaintenanceWindow(w.window_id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["maintenance-windows"] }); + toast.success("Window deleted."); + setDeleting(null); + }, + // 409 window_in_use arrives here with the server's own sentence. + onError: (e) => { + toast.error(friendlyMessage(e)); + setDeleting(null); + }, + }); + + const usedBy = (id: string) => (policies ?? []).filter((p) => p.window_id === id).length; + + return ( + <> + {canEdit && ( +
+ +
+ )} + } + isEmpty={!windows || windows.length === 0} + empty={} + > + + + + + + + + {canEdit && + + + {(windows ?? []).map((w) => ( + + + + + + {canEdit && ( + + )} + + ))} + +
NameWhenLengthUsed by} +
{w.name} + {describeCron(w.cron)} ({w.tz}) + {formatDuration(w.duration_minutes)} + {usedBy(w.window_id)} {usedBy(w.window_id) === 1 ? "policy" : "policies"} + +
+ + +
+
+
+ {editing && setEditing(null)} />} + {deleting && ( + remove(deleting)} + onClose={() => setDeleting(null)} + /> + )} + + ); +} +``` + +- [ ] **Step 3: Create `components/patching/PolicyList.tsx`** + +```tsx +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { api, PatchPolicy } from "@/lib/api"; +import { resolveTargets } from "@/lib/targets"; +import { AsyncBoundary, Badge, Button, ConfirmDialog, EmptyState, Table, TableSkeleton, Tbody, Td, Th, Thead, Tr, friendlyMessage, useToast } from "@/components/ui"; +import { PolicyModal } from "./PolicyModal"; +import { RUN_STATUS } from "./status"; + +const SKIP_REASON: Record = { + missed: "the control plane was not running when the window opened", + already_running: "the previous run was still going", + no_targets: "no servers matched", +}; + +export function PolicyList({ canEdit }: { canEdit: boolean }) { + const router = useRouter(); + const queryClient = useQueryClient(); + const toast = useToast(); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + const { data: policies, isLoading, error, refetch } = useQuery({ queryKey: ["patch-policies"], queryFn: () => api.listPatchPolicies() }); + const { data: windows } = useQuery({ queryKey: ["maintenance-windows"], queryFn: () => api.listMaintenanceWindows() }); + const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() }); + const { data: runs } = useQuery({ queryKey: ["patch-runs", "all"], queryFn: () => api.listPatchRuns({ limit: 50 }) }); + + const runNow = useMutation({ + mutationFn: (p: PatchPolicy) => api.runPatchPolicyNow(p.policy_id), + onSuccess: (run) => router.push(`/patching/runs/${run.run_id}`), + onError: (e) => toast.error(friendlyMessage(e)), + }); + const remove = useMutation({ + mutationFn: (p: PatchPolicy) => api.deletePatchPolicy(p.policy_id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["patch-policies"] }); + toast.success("Policy deleted."); + setDeleting(null); + }, + onError: (e) => toast.error(friendlyMessage(e)), + }); + + const windowName = (id: string) => windows?.find((w) => w.window_id === id); + const lastRun = (id: string) => runs?.find((r) => r.policy_id === id); + + return ( + <> + {canEdit && ( +
+ +
+ )} + } + isEmpty={!policies || policies.length === 0} + empty={} + > + + + + + + + + + {canEdit && + + + {(policies ?? []).map((p) => { + const w = windowName(p.window_id); + const count = resolveTargets(servers ?? [], p.target_server_ids, p.target_tags ?? {}).length; + const last = lastRun(p.policy_id); + return ( + + + + + + + {canEdit && ( + + )} + + ); + })} + +
PolicyNext windowTargetsInstallsLast run} +
+
+ {p.name} + {!p.enabled && ( + + Disabled{p.disabled_reason ? `: ${p.disabled_reason}` : ""} + + )} + {p.last_skipped && ( + + Skipped {new Date(p.last_skipped.due).toLocaleString()}: {SKIP_REASON[p.last_skipped.reason] ?? p.last_skipped.reason} + + )} +
+
+ {p.enabled && p.next_run_at ? ( + + {new Date(p.next_run_at).toLocaleString(undefined, { timeZone: w?.tz, dateStyle: "medium", timeStyle: "short" })} + {w?.name} + + ) : ( + none + )} + + {count} + +
+ {p.scope === "security" ? "security only" : "all updates"} + {p.reboot === "if_required" && reboots} +
+
+ {last ? ( + + {RUN_STATUS[last.status].label} + + ) : ( + never + )} + +
+ + + +
+
+
+ {editing && setEditing(null)} />} + {deleting && ( + remove.mutate(deleting)} + onClose={() => setDeleting(null)} + /> + )} + + ); +} +``` + +- [ ] **Step 4: Create `app/(app)/patching/page.tsx`** + +```tsx +"use client"; + +import { Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useAuth } from "@/components/AuthProvider"; +import { Card, CenteredSpinner } from "@/components/ui"; +import { PolicyList } from "@/components/patching/PolicyList"; +import { WindowList } from "@/components/patching/WindowList"; +import { RunList } from "@/components/patching/RunList"; + +const TABS = [ + { id: "policies", label: "Policies" }, + { id: "windows", label: "Windows" }, + { id: "runs", label: "Runs" }, +] as const; + +type TabId = (typeof TABS)[number]["id"]; + +// useSearchParams is only allowed inside a Suspense boundary, as on the +// servers page. +export default function PatchingPage() { + return ( + }> + + + ); +} + +function PatchingInner() { + const router = useRouter(); + const params = useSearchParams(); + const { isAdmin } = useAuth(); + const tab: TabId = (TABS.find((t) => t.id === params.get("tab"))?.id ?? "policies") as TabId; + + return ( +
+
+

Patching

+

+ Patch servers inside maintenance windows. Every patch Vantage performs, scheduled or clicked, is recorded as a run with a result for each server. +

+
+ +
+ {TABS.map((t) => ( + + ))} +
+ + + {tab === "policies" && } + {tab === "windows" && } + {tab === "runs" && } + +
+ ); +} +``` + +- [ ] **Step 5: Lint and build** + +Run: `npm run lint && npm run build` +Expected: both succeed. + +- [ ] **Step 6: Commit** + +```bash +git add components/patching/ "app/(app)/patching/page.tsx" +git commit -m "feat(web): patching page with policies, windows and runs" +``` + +### Task 18: Patch run detail page + +**Files:** +- Create: `app/(app)/patching/runs/[runId]/page.tsx` + +**Interfaces:** +- Consumes: `api.getPatchRun`, `api.cancelPatchRun`, `RUN_STATUS`, `SERVER_STATUS`. +- Produces: route `/patching/runs/[runId]`. + +- [ ] **Step 1: Create the page** + +```tsx +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { useParams } from "next/navigation"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, PatchServerRun } from "@/lib/api"; +import { AsyncBoundary, Badge, Button, Card, CenteredSpinner, Table, Tbody, Td, Th, Thead, Tr, friendlyMessage, useToast } from "@/components/ui"; +import { RUN_STATUS, SERVER_STATUS } from "@/components/patching/status"; + +function installed(s: PatchServerRun): string { + if (s.pending_after === undefined || s.pending_after === null) return "n/a"; + return String(Math.max(0, s.pending_before - s.pending_after)); +} + +function ServerRow({ s }: { s: PatchServerRun }) { + const [open, setOpen] = useState(false); + const meta = SERVER_STATUS[s.status]; + return ( + <> + + + + {s.hostname} + + + + {meta.label} + + + {installed(s)} + + + + {s.rebooted_at ? `rebooted ${new Date(s.rebooted_at).toLocaleTimeString()}` : "none"} + {s.verified_at && `, back ${new Date(s.verified_at).toLocaleTimeString()}`} + + + +
+ {s.error && {s.error}} + {s.output && ( + + )} +
+ + + {open && s.output && ( + + +
{s.output}
+ + + )} + + ); +} + +export default function PatchRunPage() { + const { runId } = useParams<{ runId: string }>(); + const queryClient = useQueryClient(); + const toast = useToast(); + const { data: run, isLoading, error, refetch } = useQuery({ + queryKey: ["patch-run", runId], + queryFn: () => api.getPatchRun(runId), + refetchInterval: (q) => (q.state.data?.status === "running" ? 5_000 : false), + }); + const cancel = useMutation({ + mutationFn: () => api.cancelPatchRun(runId), + onSuccess: () => { + toast.success("Cancelled. Servers already patching will finish; nothing further starts."); + queryClient.invalidateQueries({ queryKey: ["patch-run", runId] }); + }, + onError: (e) => toast.error(friendlyMessage(e)), + }); + + if (isLoading) return ; + + const counts = new Map(); + run?.servers.forEach((s) => counts.set(s.status, (counts.get(s.status) ?? 0) + 1)); + + return ( +
+ + {run && ( + <> + + Back to patch runs + +
+
+

{run.policy_name ?? "Manual update"}

+

+ Started {new Date(run.started_at).toLocaleString()} by {run.triggered_by}.{" "} + {run.scope === "security" ? "Security updates only" : "All pending updates"}, {run.reboot === "if_required" ? "reboot if required" : "no reboot"}. + {run.window_end && ` Window ends ${new Date(run.window_end).toLocaleString()}.`} +

+
+
+ {RUN_STATUS[run.status].label} + {run.status === "running" && !run.cancelled_at && ( + + )} +
+
+ +
+ {[...counts.entries()].map(([status, n]) => ( + + {n} {SERVER_STATUS[status as PatchServerRun["status"]].label} + + ))} +
+ + + + + + + + + + + + + {run.servers.map((s) => ( + + ))} + +
ServerStatusInstalledReboot +
+
+ + )} +
+
+ ); +} +``` + +- [ ] **Step 2: Lint and build** + +Run: `npm run lint && npm run build` +Expected: both succeed. + +- [ ] **Step 3: Commit** + +```bash +git add "app/(app)/patching/runs" +git commit -m "feat(web): patch run detail page with per-server output" +``` + +### Task 19: Server page coverage and run links; vulnerabilities page + +**Files:** +- Modify: `components/servers/tabs/MaintenanceTab.tsx` +- Modify: `app/(app)/servers/[id]/page.tsx` (`applyUpdates` mutation ~line 131) +- Modify: `app/(app)/vulnerabilities/page.tsx` (`applyUpdates` mutation ~line 114) + +**Interfaces:** +- Consumes: `api.listPatchPolicies`, `api.listMaintenanceWindows`, `api.listPatchRuns`, `api.applyUpdates(serverId, source?)`, `matchesTags` from `lib/targets.ts`, `RUN_STATUS`. + +- [ ] **Step 1: Show policy coverage and the last run in `MaintenanceTab.tsx`** + +Add imports: + +```tsx +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { matchesTags } from "@/lib/targets"; +import { RUN_STATUS } from "@/components/patching/status"; +``` + +Inside the component, after `const updates = ...`: + +```tsx + const { data: policies } = useQuery({ queryKey: ["patch-policies"], queryFn: () => api.listPatchPolicies() }); + const { data: windows } = useQuery({ queryKey: ["maintenance-windows"], queryFn: () => api.listMaintenanceWindows() }); + const { data: lastRuns } = useQuery({ queryKey: ["patch-runs", "server", server.server_id], queryFn: () => api.listPatchRuns({ server_id: server.server_id, limit: 1 }) }); + // Same selector rule as the scheduler: named, or carrying every tag. + const covering = (policies ?? []).filter((p) => p.enabled && (p.target_server_ids.includes(server.server_id) || matchesTags(server, p.target_tags ?? {}))); + const next = covering + .filter((p) => p.next_run_at) + .sort((a, b) => (a.next_run_at! < b.next_run_at! ? -1 : 1))[0]; + const nextWindow = next && windows?.find((w) => w.window_id === next.window_id); + const lastRun = lastRuns?.[0]; +``` + +Directly under the OS updates card's header `div` (the one holding the pending and reboot badges), insert: + +```tsx +
+ {next && nextWindow ? ( + + Covered by {next.name}, next window{" "} + {new Date(next.next_run_at!).toLocaleString(undefined, { timeZone: nextWindow.tz, dateStyle: "medium", timeStyle: "short" })} ({nextWindow.tz}) + + ) : ( + + Not covered by any patch policy. Set one up + + )} + {lastRun && ( + + Last run {RUN_STATUS[lastRun.status].label} + + )} +
+``` + +Change the helper text beside the Apply updates button to: `Installs all pending updates now, without rebooting, and records a run.` + +- [ ] **Step 2: Navigate to the run from the server page** + +In `app/(app)/servers/[id]/page.tsx`, replace the `applyUpdates` mutation's `onSuccess`: + +```tsx + onSuccess: (res) => { + if (res.run_id) { + router.push(`/patching/runs/${res.run_id}`); + } else { + toast.success("Update command sent."); + } + }, +``` + +`useRouter` is already imported on this page; make sure `const router = useRouter();` exists in the component (add it beside the other hooks if not). + +- [ ] **Step 3: Same on the vulnerabilities page** + +In `app/(app)/vulnerabilities/page.tsx`, change the mutation to pass the source and navigate: + +```tsx + const applyUpdates = useMutation({ + mutationFn: (serverId: string) => api.applyUpdates(serverId, "vulnerabilities"), + onSuccess: (res) => { + if (res.run_id) router.push(`/patching/runs/${res.run_id}`); + }, + onError: toast.error, + }); +``` + +Keep whatever `onError` the existing mutation uses if it differs. Add `import { useRouter } from "next/navigation";` and `const router = useRouter();` if the page does not already have them. + +- [ ] **Step 4: Lint and build** + +Run: `npm run lint && npm run build` +Expected: both succeed. + +- [ ] **Step 5: Commit** + +```bash +git add components/servers/tabs/MaintenanceTab.tsx "app/(app)/servers/[id]/page.tsx" "app/(app)/vulnerabilities/page.tsx" +git commit -m "feat(web): show patch policy coverage on servers and open the run after Apply updates" +``` + +--- + +## Phase E: documentation (`vantage-docs`) + +### Task 20: User documentation + +**Files:** +- Create: `/go-projects/vantage/vantage-docs/docs/vantage/patching.md` +- Modify: `/go-projects/vantage/vantage-docs/sidebars.ts` (Vantage category) +- Modify: `/go-projects/vantage/vantage-docs/docs/vantage/servers.md` (section "OS updates") +- Modify: `/go-projects/vantage/vantage-docs/docs/vantage/vulnerabilities.md` (section "Fixing something") +- Modify: `/go-projects/vantage/vantage-docs/docs/hq/licensing-and-entitlements.md` (after the Features table) +- Modify: `/go-projects/vantage/vantage-docs/docs/reference/api-tokens.md` (scopes table) + +- [ ] **Step 1: Create `docs/vantage/patching.md`** + +````markdown +--- +id: patching +title: Patching +sidebar_label: Patching +--- + +Patching installs OS updates on your servers inside a **maintenance window** +you choose, and records what happened on every server. It is available on every +tier: security patching is never a paid feature. + +Three things work together: + +| Thing | What it answers | +| ---------------------- | ----------------------------------------------------------- | +| A maintenance window | *When.* "Sundays 02:00 to 04:00, Europe/London" | +| A patch policy | *What and where.* "Security updates on every `env:prod` server, reboot if needed" | +| A patch run | *What happened.* One record per window, with a result per server | + +Clicking **Apply updates** on a server or on the vulnerabilities page also +creates a run, so every patch Vantage performs has a record. + +## Maintenance windows + +**Patching → Windows → New window.** A window has a name, a start time written +as five-field cron, a timezone and a length from 15 minutes to 12 hours. The +editor shows the next three windows, computed by the same code that opens +them. + +The timezone is stored by name, so a 02:00 window stays at 02:00 across +daylight-saving changes. A window never starts while the previous one is still +open, including on the night the clocks go back and 01:30 happens twice. + +A window used by a policy cannot be deleted. Move the policy to another window +first. + +## Patch policies + +**Patching → Policies → New policy.** Owners and admins can create policies. + +| Setting | Meaning | +| -------------- | ------- | +| Window | The maintenance window the policy runs in | +| Targets | Named servers, tags, or both, exactly as for [workflows](./workflows.md#targeting). Tags are read when the window opens | +| What to install | **Security updates only** or **All pending updates** | +| Reboots | **Never reboot**, or **Reboot if required** | +| At most this many at once | How many servers patch at the same time. 0 means no limit | +| Alert channels | Told when a run finishes with anything other than every server succeeding | + +**Run now** opens a window of the policy's usual length starting immediately. +It is the way to try a policy before trusting it with a Sunday. + +### Security updates only + +| Package manager | How security-only works | +| --------------- | ----------------------- | +| apt (Debian, Ubuntu) | Only your `-security` sources are used | +| dnf, yum (RHEL, Rocky, Alma, Fedora) | `--security` | +| zypper (SUSE) | Security patches only | +| Windows | The Security Updates and Critical Updates classifications | +| apk (Alpine), pacman (Arch) | **Not supported.** These publish no security metadata, so the server reports *unsupported* and nothing is installed | + +Security-only never falls back to installing everything. + +### Reboots + +With **Reboot if required**, a server reboots only when its OS reports that a +reboot is owed, and only if at least 5 minutes of the window remain. The agent +reports first, then reboots after one minute. + +Vantage then waits for the server to come back. The reboot counts as done when +the agent reports a boot time later than the reboot, with no reboot still +owed. A server that does not come back within 20 minutes is marked failed. + +With **Never reboot**, the server shows **reboot required** instead. + +## What happens during a window + +- Servers start patching as the window opens, up to the concurrency limit. +- A server whose agent is offline is retried until the window closes. +- Nothing new starts after the window closes. Servers already patching are + allowed to finish. +- A policy whose previous run is still going skips the window, and says so on + the policy. + +## Patch runs + +**Patching → Runs** lists every run. Open one to see each server's result, how +many updates were installed, reboot times, and the last part of the package +manager's output. + +| Server status | Meaning | +| ------------- | ------- | +| queued | Waiting for a concurrency slot | +| waiting for agent | The agent is offline; retried while the window is open | +| patching | Installing now | +| rebooting | Rebooted; waiting for it to come back | +| succeeded | Patched, and rebooted and back if a reboot was owed and allowed | +| failed | The package manager failed, the agent did not answer, or the reboot did not complete | +| unsupported | Security-only on a server with no security metadata | +| agent too old | The agent must be updated before it can take part | +| missed, offline | Offline for the whole window | +| window closed | Still waiting when the window ended | +| cancelled | The run was cancelled before this server started | + +A run is **succeeded** when every server succeeded, **failed** when none did, +and **partial** otherwise. Anyone can cancel a running run: servers already +patching finish, and nothing further starts. + +Runs are kept for the same time as workflow logs (**Settings → Monitoring**). + +## Agent version + +Patch policies need agent **1.4.0** or later. An older agent would ignore +"security only" and install everything, so Vantage does not send it policy +work: it shows **agent too old** until you update it +(see [Agent updates](../operations/agent-updates.md)). **Apply updates** still +works on an older agent, but the run cannot report a result. +```` + +- [ ] **Step 2: Add it to the sidebar** + +In `sidebars.ts`, in the Vantage category, add `"vantage/patching",` after `"vantage/vulnerabilities",`. + +- [ ] **Step 3: Update `servers.md`** + +In "OS updates", replace the **Apply updates** bullet and the `:::warning Applying updates is not scheduled or staged` block (through its closing `:::`) with: + +```markdown +- **Apply updates** installs every pending update now, without rebooting, and + opens the [patch run](./patching.md#patch-runs) recording the result. If a + reboot is owed, a **reboot required** badge appears on the next inventory + snapshot. +- **Update agent** upgrades the Vantage agent on that machine. See + [Agent updates](../operations/agent-updates.md). + +The panel also shows which [patch policy](./patching.md) covers the server and +when its next window opens. To patch on a schedule, security-only, or with +reboots, use a patch policy. +``` + +- [ ] **Step 4: Update `vulnerabilities.md`** + +In "Fixing something", replace the two paragraphs with: + +```markdown +A finding with a known fixed version gets an **Apply updates** button. It +installs every pending update on that server now and opens the +[patch run](./patching.md#patch-runs) so you can see the result. + +To keep servers patched without clicking, create a +[patch policy](./patching.md) with **Security updates only**. +``` + +- [ ] **Step 5: Update licensing and API token docs** + +In `hq/licensing-and-entitlements.md`, after the Features table add: + +```markdown +[Patching](../vantage/patching.md) is available on every tier and is not a +feature you enable: security patching is never paid for. +``` + +In `reference/api-tokens.md`, add a `patching` row to the scopes table in the same format as the existing rows, reading: `patching` | maintenance windows, patch policies and patch runs. `patching:write` creates and edits them and starts or cancels runs. + +- [ ] **Step 6: Build** + +Run: `cd /go-projects/vantage/vantage-docs && npm run build` +Expected: succeeds with no broken-link errors. + +- [ ] **Step 7: Commit** + +```bash +git add docs/vantage/patching.md sidebars.ts docs/vantage/servers.md docs/vantage/vulnerabilities.md docs/hq/licensing-and-entitlements.md docs/reference/api-tokens.md +git commit -m "docs: patching - maintenance windows, patch policies and runs" +``` + +--- + +## Phase F: release and verification + +### Task 21: Release in order and verify on real hosts + +Every step here acts on real machines, including reboots. Use test VMs only. + +- [ ] **Step 1: Confirm the gate matches the release** + +Run: `grep -n 'MinAgentVersion = ' /go-projects/vantage/vantage-app/server/internal/patchrun/patchrun.go` and `grep -n 'MIN_AGENT_VERSION = ' /go-projects/vantage/vantage-app/web/components/patching/status.ts` +Expected: both say `1.4.0`, the tag in Step 3. + +- [ ] **Step 2: Ship the control plane** + +Merge `feat/scheduled-patching` in `vantage-app` to `main` and push; `server-deploy.yml` builds `server` and `web`. Deploy to the test instance. Until Step 3 ships, every policy target shows **agent too old**; that is expected. + +- [ ] **Step 3: Tag the agent** + +Merge the agent branch to `main` in `vantage-agent`, then: + +```bash +cd /go-projects/vantage/vantage-agent +git tag agent/v1.4.0 && git push origin agent/v1.4.0 +``` + +Wait for the release workflow to publish Linux and Windows builds and the MSI. + +- [ ] **Step 4: Publish the docs** + +Push `vantage-docs` to `main`. + +- [ ] **Step 5: Verify on test hosts** + +Hosts: Debian 12, Ubuntu 24.04, Rocky 9, Alpine 3.20, Windows Server 2022 (all on agent 1.4.0), and one host left on agent 1.3.5. Tag the 1.4.0 hosts `env:patchtest`. + +| Check | How | Expected | +| --- | --- | --- | +| Window preview | Create "Test" window: cron five minutes from now, your zone, 30 min | Next three windows shown, 30 minutes long | +| Security-only run | Policy: `env:patchtest`, security only, never reboot, max 2 | At most 2 `patching` at once; Debian, Ubuntu, Rocky, Windows `succeeded` with output; Alpine `unsupported` | +| apt lists intact | On Debian after the run: `apt list --upgradable` | Still lists non-security updates (lists were not cleaned up) | +| Reboot | Ubuntu with `/var/run/reboot-required` present; policy reboot if required; Run now | `rebooting`, then `succeeded` with a verified time; audit shows `patch.reboot` | +| Offline | Stop the agent on Rocky before a window opens and leave it off | `waiting for agent`, then `missed, offline` when the window closes | +| Old agent | Add the 1.3.5 host to the policy | `agent too old`, nothing dispatched; Apply updates on it works and the run says no result reported | +| Cancel | Run now with max 1 and 3 targets; cancel after the first starts | First finishes, others `cancelled`, run `cancelled` | +| Alert | Attach a channel; run with the Alpine host included | One `partial` summary with counts and run ID | +| Manual run | Apply updates on the server page and on the vulnerabilities page | Browser opens the run; source reads server page or vulnerabilities | +| Token scope | API token restricted to `env:other`; `GET /api/patch-runs/` | 404 | +| Coverage | Server page, Maintenance tab | "Covered by Test, next window ..." and last run badge | +| Audit | Audit log, category Patching | `patch.*` events for windows, policies, runs, reboots | diff --git a/docs/superpowers/specs/2026-09-14-scheduled-patching-design.md b/docs/superpowers/specs/2026-09-14-scheduled-patching-design.md index 36cbefd..615ce47 100644 --- a/docs/superpowers/specs/2026-09-14-scheduled-patching-design.md +++ b/docs/superpowers/specs/2026-09-14-scheduled-patching-design.md @@ -132,7 +132,9 @@ type PatchRun struct { RunID string `bson:"run_id" json:"run_id"` PolicyID string `bson:"policy_id,omitempty" json:"policy_id,omitempty"` // empty for manual PolicyName string `bson:"policy_name,omitempty" json:"policy_name,omitempty"` - TriggeredBy string `bson:"triggered_by" json:"triggered_by"` // "schedule" | actor | "vulnerability:" + TriggeredBy string `bson:"triggered_by" json:"triggered_by"` // "schedule" | actor + Source string `bson:"source" json:"source"` // schedule | server | vulnerabilities | mcp | run_now + CancelledAt *time.Time `bson:"cancelled_at,omitempty" json:"cancelled_at,omitempty"` Scope string `bson:"scope" json:"scope"` // snapshot Reboot string `bson:"reboot" json:"reboot"` // snapshot MaxConcurrent int `bson:"max_concurrent" json:"max_concurrent"` // snapshot @@ -375,7 +377,8 @@ qualifies. `POST /servers/:id/apply-updates` and the vulnerability page's Apply updates create a one-server `patch_run` (`scope: all`, `reboot: never`, no window, -`TriggeredBy` the actor or `vulnerability:`) and dispatch immediately in +`TriggeredBy` the actor, `Source` one of `server`, `vulnerabilities` or `mcp`) +and dispatch immediately in the handler rather than waiting for the next tick. A dispatch failure still answers 503, and the run is recorded as `failed` so the attempt is not lost. Timeouts and finalisation go through the same tick. @@ -391,10 +394,11 @@ flight, in which case immediately. ### Notifications When a run finalises as `partial` or `failed`, one summary goes to each channel -in `NotifyChannelIDs` through the existing `notify` dispatch: `[Vantage] Patch -policy "Sunday prod" partial: 38 succeeded, 2 failed, 1 missed offline`. The -webhook payload carries the counts as fields and the run ID. A clean run sends -nothing. +in `NotifyChannelIDs` through the existing `notify` dispatch, as a new event +type `patch`: `[Vantage] Patch policy "Sunday prod" partial: 38 succeeded, +2 failed, 1 missed offline (run 3f2a...)`. The webhook payload is the existing +event shape with that summary as its message; no new payload fields. A clean +run sends nothing. ## REST API @@ -430,7 +434,9 @@ or run, `409 window_in_use`, `503` agent offline on apply-updates. ## Audit -`maintenance_window.created|updated|deleted`, `patch_policy.created|updated|deleted|disabled`, +Every event uses the `patch.` prefix so the audit page groups them under one +category: `patch.window_created|window_updated|window_deleted`, +`patch.policy_created|policy_updated|policy_deleted|policy_disabled`, `patch.run_started`, `patch.run_finished`, `patch.skipped`, `patch.cancelled`, `patch.reboot` (one per server Vantage reboots, naming the policy), and the existing `updates.applied` for manual runs, now carrying the run ID.