docs: Design for Windows agent parity on updates and workloads
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
# Windows agent parity: OS updates and workloads
|
||||
|
||||
Date: 2026-08-13
|
||||
|
||||
## Goal
|
||||
|
||||
Bring the Windows agent up to the Linux agent on two subsystems: OS update
|
||||
check/apply, and the workload registry (collection, control, logs). Everything
|
||||
else about the Windows agent stays as it is.
|
||||
|
||||
Out of scope, deliberately:
|
||||
|
||||
- **Package inventory and CVE findings.** `trivy-db` carries no Windows feed, so
|
||||
a Windows finding needs a different source, a different matcher and a
|
||||
different version comparison. That is its own project, and until it exists a
|
||||
Windows host correctly reports `status: unsupported` rather than "0 findings".
|
||||
- **SSH key management on Windows.** `administrators_authorized_keys` is a real
|
||||
possibility but a separate decision.
|
||||
- **winget.** Third-party app upgrades are a different question from OS
|
||||
patching, and winget is absent on Server Core and older builds.
|
||||
|
||||
## Current state
|
||||
|
||||
The Windows agent registers, heartbeats, reports inventory, runs workflow steps
|
||||
through PowerShell, relays console connections and self-updates via MSI. Four
|
||||
gates stop it doing more:
|
||||
|
||||
| Gate | Location |
|
||||
| --- | --- |
|
||||
| `runtime.GOOS != "linux"` early return | `agent/internal/workloads/workloads.go`, `agent/internal/sync/workloads.go` (twice) |
|
||||
| package-manager detection finds nothing | `agent/internal/updates/updates.go` (`detectPM`) |
|
||||
| hard error | `agent/internal/packages/packages.go` (out of scope here) |
|
||||
| `authorized_keys` write skipped | `agent/internal/sync/sync.go` (out of scope here) |
|
||||
|
||||
## Approach
|
||||
|
||||
The platform split moves into the agent, expressed as build tags following the
|
||||
existing `inventory/collect_linux.go` / `collect_windows.go` /
|
||||
`collect_other.go` precedent. The control plane stays OS-blind: `ReportWorkloads`,
|
||||
`ControlWorkload`, `WorkloadLogs` and `ApplyUpdates` need no changes at all,
|
||||
because a Windows service is reported as the same `unit` kind a systemd service
|
||||
is.
|
||||
|
||||
Build tags rather than `runtime.GOOS` switches so PowerShell command strings do
|
||||
not ship in the Linux binary, and so a platform left unimplemented is a compile
|
||||
error rather than a silent no-op at runtime.
|
||||
|
||||
## Updates
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
agent/internal/updates/
|
||||
updates.go # PackageUpdate; CheckAvailable/ApplyAll declared once
|
||||
updates_linux.go # existing detectPM, checkApt/DnfYum/Pacman/Zypper/Apk, ApplyAll
|
||||
updates_windows.go # Windows Update COM, driven through PowerShell
|
||||
updates_other.go # //go:build !linux && !windows — no-ops
|
||||
```
|
||||
|
||||
`updates_other.go` carries the build constraint for the same reason
|
||||
`inventory/collect_other.go` does: `_other` is not a GOOS suffix, so without the
|
||||
constraint the file compiles everywhere and collides.
|
||||
|
||||
### Checking
|
||||
|
||||
One PowerShell invocation, `-NoProfile -NonInteractive`, emitting JSON:
|
||||
|
||||
```powershell
|
||||
$searcher = (New-Object -ComObject Microsoft.Update.Session).CreateUpdateSearcher()
|
||||
$result = $searcher.Search("IsInstalled=0 and Type='Software' and IsHidden=0")
|
||||
```
|
||||
|
||||
The Windows Update COM API is used rather than the `PSWindowsUpdate` module
|
||||
because it is present on every supported Windows, needs no PowerShell Gallery
|
||||
install, and works unchanged against a WSUS server on an air-gapped fleet. The
|
||||
agent runs as `LocalSystem`, which has the rights the API requires.
|
||||
|
||||
Each result maps to a `PackageUpdate`:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| `Name` | the update Title |
|
||||
| `CurrentVersion` | empty |
|
||||
| `NewVersion` | the KB article ID, e.g. `KB5034123` |
|
||||
|
||||
`CurrentVersion` is empty because a Windows update is not a version bump of a
|
||||
named package, and inventing a current version would put a wrong string in front
|
||||
of an operator. The KB ID goes in `NewVersion` because it is the identifier
|
||||
people actually search for.
|
||||
|
||||
Timeout: 10 minutes. The first search after a boot contacts Microsoft Update and
|
||||
is routinely slow.
|
||||
|
||||
### Applying
|
||||
|
||||
The same COM session: `CreateUpdateDownloader` then `CreateUpdateInstaller`,
|
||||
over the updates returned by the search above, skipping any that require user
|
||||
input. EULAs are accepted programmatically; an update whose EULA cannot be
|
||||
accepted is skipped rather than failing the batch.
|
||||
|
||||
The operation fails when the installer's `ResultCode` is not 2 (succeeded) or 3
|
||||
(succeeded with errors).
|
||||
|
||||
Timeout: 60 minutes. A patch-Tuesday cumulative genuinely takes that long, and
|
||||
the Linux path's existing 5-minute cap is already tight.
|
||||
|
||||
**The agent never reboots the host.** A control plane silently restarting a
|
||||
production server is unrecoverable from the UI, and the reboot is a decision a
|
||||
person or a workflow makes. Instead the need for one is reported.
|
||||
|
||||
### Reboot required
|
||||
|
||||
A new field `reboot_required` on `InventoryReport`, added to
|
||||
`proto/vantage/v1/vantage.proto` and to both hand-written `pb` copies
|
||||
(`agent/internal/grpc/pb`, `server/internal/grpc/pb`) in the same commit.
|
||||
|
||||
It travels on the inventory report rather than the update report because it is a
|
||||
host property like the kernel version, and inventory refreshes every 30 seconds
|
||||
with a full static snapshot every 15 minutes — so a host rebooted by hand clears
|
||||
the flag promptly instead of showing it for up to an hour.
|
||||
|
||||
Both platforms set it, since parity is free here:
|
||||
|
||||
- Linux: `/var/run/reboot-required` exists, or `dnf needs-restarting -r` exits
|
||||
non-zero.
|
||||
- Windows: the `Microsoft.Update.SystemInfo` COM object's `RebootRequired`
|
||||
property, falling back to the pending-reboot registry keys
|
||||
(`Component Based Servicing\RebootPending`,
|
||||
`WindowsUpdate\Auto Update\RebootRequired`,
|
||||
`Session Manager\PendingFileRenameOperations`).
|
||||
|
||||
`services.ReportInventory` stores it on `servers.inventory`.
|
||||
|
||||
## Workloads
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
agent/internal/workloads/
|
||||
workloads.go # Result, Collect, Hash — Collect calls collectUnits
|
||||
docker.go # unchanged, shared: shells to the docker binary
|
||||
systemd_linux.go # was systemd.go
|
||||
services_windows.go # new: Win32_Service collection
|
||||
control.go # shared validation; platform halves split out
|
||||
control_linux.go # docker/systemctl, /proc/self/cgroup own-container check
|
||||
control_windows.go # Start/Stop/Restart-Service, VantageAgent protection
|
||||
logs.go # shared: capLog, MaxLogLines, MaxLogBytes
|
||||
logs_linux.go # docker logs / journalctl
|
||||
logs_windows.go # docker logs / Get-WinEvent
|
||||
```
|
||||
|
||||
`Collect` loses its `runtime.GOOS != "linux"` return and calls
|
||||
`collectUnits(ctx)`, which is the systemd collector on Linux and the service
|
||||
collector on Windows. `runWorkloads` and `reportWorkloads` in
|
||||
`agent/internal/sync/workloads.go` lose all three of their platform returns.
|
||||
|
||||
`docker.go` stays shared and ungated. It shells to the `docker` binary, which
|
||||
behaves identically on Windows, so a Docker Desktop or Mirantis host reports its
|
||||
containers with no new code. `DockerOK` / `DockerError` keep their existing
|
||||
three-state meaning: not installed (the common case, not a fault), installed but
|
||||
not responding, and running nothing.
|
||||
|
||||
### Collecting Windows services
|
||||
|
||||
`Get-CimInstance Win32_Service` converted to JSON — not `Get-Service`, which
|
||||
exposes neither `PathName` nor `StartMode`, and the filter needs both.
|
||||
|
||||
A service is reported when its executable does **not** resolve under
|
||||
`%SystemRoot%\System32`, and it is running, failed, or has `StartMode=Auto`
|
||||
while stopped. This mirrors the systemd collector's intent: show what an
|
||||
operator installed, and show what is meant to be up but is not.
|
||||
|
||||
Path parsing strips surrounding quotes and trailing arguments before the
|
||||
`%SystemRoot%` comparison. `"C:\Program Files\X\x.exe" -service` is one path
|
||||
with one argument, and splitting naively on whitespace misfiles a substantial
|
||||
share of a real fleet.
|
||||
|
||||
Field mapping:
|
||||
|
||||
| Workload field | Source |
|
||||
| --- | --- |
|
||||
| `Kind` | `"unit"` |
|
||||
| `ID` | `Name` (the service name) |
|
||||
| `Name` | `DisplayName` |
|
||||
| `State` | `running` / `stopped` / `failed`, from `State` plus `ExitCode` |
|
||||
| `Health`, `Image`, `Stack`, `Ports`, `Restarts` | unset |
|
||||
|
||||
`Kind: "unit"` and the existing `systemd_ok` / `systemd_error` fields are reused
|
||||
rather than a `service` kind and `services_ok` fields being added. That would
|
||||
cost a proto change, both pb copies, the server model, the service layer and the
|
||||
web client, and would teach every existing consumer a second kind — to describe
|
||||
the same thing. The naming is corrected where it is read, in the UI, which knows
|
||||
the server's OS.
|
||||
|
||||
`State` values match the ones the UI already colours, so no web change is needed
|
||||
for the rows themselves.
|
||||
|
||||
### Protection
|
||||
|
||||
The protected set stays computed and enforced agent-side, as it is on Linux: the
|
||||
control plane may name a target, but the agent decides what it will do to
|
||||
itself.
|
||||
|
||||
On Windows the protected workload is the `VantageAgent` service — the NSSM
|
||||
service name written by `installer/setup.ps1` — matched case-insensitively,
|
||||
because Windows service names are. `detectOwnContainer` and its
|
||||
`/proc/self/cgroup` read move to `control_linux.go`; the Windows build returns
|
||||
no own-container ID.
|
||||
|
||||
`ErrProtected` still surfaces as HTTP 409 from the API, and the reported
|
||||
`Protected` flag remains a courtesy that greys the button rather than the
|
||||
boundary.
|
||||
|
||||
### Control
|
||||
|
||||
`Start-Service`, `Stop-Service -Force`, `Restart-Service -Force`, under the same
|
||||
90-second `controlTimeout`, with the error text taken from PowerShell's stderr.
|
||||
|
||||
`sc.exe` is avoided because it returns before the operation completes, which
|
||||
turns a timeout into a false success. `-Force` is required because
|
||||
`Stop-Service` without it refuses when other services depend on the target, and
|
||||
that refusal reads to an operator as a silent no-op.
|
||||
|
||||
### Logs
|
||||
|
||||
`Get-WinEvent` with a filter hashtable over the `System` and `Application` logs,
|
||||
provider names matching the service name, its display name, and
|
||||
`Service Control Manager`, newest first, capped by the requested tail.
|
||||
|
||||
Each event is formatted as `<ISO 8601 timestamp> <Level> <Message>`, which is
|
||||
the same shape `journalctl --output=short-iso` produces, so the log dialog needs
|
||||
no per-platform rendering.
|
||||
|
||||
Service Control Manager logs every service on the host under one provider, so
|
||||
its events are filtered client-side to those naming the target service.
|
||||
|
||||
`capLog` is shared and unchanged: 500 lines and 256KB, whichever binds first,
|
||||
trimmed from the front. There is still no follow mode.
|
||||
|
||||
An empty result returns an empty string and no error. A service that has logged
|
||||
nothing is normal, and an error there would read as a broken feature.
|
||||
|
||||
## Server and web
|
||||
|
||||
The server changes in one place: `services.ReportInventory` persists
|
||||
`reboot_required`.
|
||||
|
||||
The web changes in three, all keyed on `server.os_type`, which is already stored
|
||||
on the server document and already serialised, but currently unread by `web/`:
|
||||
|
||||
1. `web/components/workloads/WorkloadList.tsx` — the systemd status lines become
|
||||
platform-worded. On Windows the error line reads "Windows services could not
|
||||
be read" and the "systemd is not in use on this server" line is not rendered
|
||||
at all. The empty-state line drops "on Linux only". The Docker lines are
|
||||
unchanged.
|
||||
2. Server detail — a `Reboot required` pill beside the update count when the
|
||||
flag is set, placed with the update panel because that is what caused it.
|
||||
3. The Updates panel's Windows copy describes a list of KB articles rather than
|
||||
package upgrades, since `current_version` is empty on that platform.
|
||||
|
||||
## Testing
|
||||
|
||||
The Windows collectors are, in substance, parsers of PowerShell output. Parsing
|
||||
is separated from invocation and table-tested against captured real output,
|
||||
following `agent/internal/packages/parse.go`:
|
||||
|
||||
- `Win32_Service` JSON, including a quoted path with arguments, a
|
||||
`%SystemRoot%\System32` service that must be filtered out, a stopped
|
||||
`StartMode=Auto` service that must be kept, and a failed service with a
|
||||
non-zero `ExitCode`.
|
||||
- Update searcher JSON, including an update with no KB ID.
|
||||
- `Get-WinEvent` JSON, including a Service Control Manager event for another
|
||||
service that must be filtered out.
|
||||
- Pending-reboot detection from registry key presence.
|
||||
|
||||
`capLog` and `Hash` are unchanged and gain no tests.
|
||||
|
||||
The invocation halves are verified by hand on a Windows host: check, apply,
|
||||
service start/stop/restart, a protected refusal on `VantageAgent`, and logs on
|
||||
both a chatty service and a silent one.
|
||||
|
||||
`GOOS=windows go build ./...` and `GOOS=linux go build ./...` both belong in the
|
||||
implementation plan as explicit steps — a build-tag split is exactly the change
|
||||
that compiles on the machine you are sitting at and nowhere else. CI already
|
||||
cross-builds the agent on release, so no workflow change is needed.
|
||||
Reference in New Issue
Block a user