# Vantage agent (`vantage-agent`) The lightweight Go agent installed on every managed server, and the Windows installer that packages it. Extracted from the `vantage` monorepo with its history; the agent is the repository root, so the module is `gitea.hostxtra.co.uk/vantage/vantage-agent`. ``` vantage-agent/ ├── cmd/main.go # flags: -generate-key ├── internal/ │ ├── checker/ # monitor check execution │ ├── config/ # config.yaml load/save │ ├── exec/ # workflow step execution │ ├── grpc/ # client + the codec registration │ ├── inventory/ # CPU/mem/disk collection (linux/other/windows) │ ├── keys/ # authorized_keys read/diff/write │ ├── monitors/ # agent-run monitor loop │ ├── proxy/ # console relay, always from 127.0.0.1 │ ├── sync/ # poll loop + command stream + self-update │ ├── updates/ # OS package update check/apply │ ├── winexec/ # PowerShell invocation on Windows │ └── workloads/ # containers and units/services ├── installer/ # Windows: setup.ps1, nssm.exe, WiX .wxs └── .gitea/workflows/agent-release.yml ``` ## Relationship to the other repositories | Repository | Relationship | | ---------------- | ------------------------------------------------------------------------------------------------------ | | `vantage-shared` | a private Go module. `grpc/pb` and `grpc/codec` are the wire contract; `proto/` there documents them | | `vantage` | the control plane. **No import in either direction** - the coupling is the gRPC wire, and it is entirely mediated by `vantage-shared` | **`vantage-shared` is private**, so every Go build needs `GOPRIVATE=gitea.hostxtra.co.uk/*` plus a credential. CI writes a netrc from `REGISTRY_USER` + `RELEASE_TOKEN` (**that token needs read access to the `vantage` org**) - twice, because the `msi` job is Windows and Go looks for `_netrc` in the profile directory there, not `.netrc`. Locally, either a netrc or `git config --global url."git@gitea.hostxtra.co.uk:".insteadOf https://gitea.hostxtra.co.uk/`. ### A wire change is three steps, in order `shared/grpc/pb` is hand-written and shared by both sides, so a new message is a compile error rather than a silent disagreement - but only once each side moves: 1. release `vantage-shared` (and change `proto/vantage/v1/vantage.proto` in the same commit as the Go types) 2. bump the pin in `vantage`'s `server/go.mod` - live at the next push to main 3. bump the pin here - live only at the next `agent/v*` tag The control plane runs ahead of the fleet in between. That was true before the split too; it is now explicit in two `go.mod` files rather than implicit in a shared directory. ## Releases Tags are `agent/v*`, **not** bare `v*`, even though this repository is only the agent. The tag is not a private detail: it is what the fleet downloads by, what `UpdateAgentCmd` carries, and what the control plane greps release tag names for when answering `GET /api/agent/latest-version`. Renaming it would be a second breaking change stacked on the repository move. ```bash git tag agent/v1.2.0 && git push origin agent/v1.2.0 ``` Builds `linux/amd64`, `linux/arm64` and `windows/amd64`, writes `checksums.txt`, creates the Gitea release. A second `msi` job on `windows-2022` builds the exe again, packages it with WiX and appends the MSI to the same release **through the API** - `gitea-release-action` cannot find a tag with a slash in it. ### The self-update path, and what the move broke `internal/sync` downloads its own replacement from `/vantage/vantage-agent/releases/download//…`, verifying the SHA-256 from `checksums.txt` before swapping itself. That path is **compiled into the binary**. **Every agent built before this move has the old path - `mrhid6/vantage` - baked in, and releases are no longer published there.** For those agents the push-button update in the UI will fail: the download 404s. They are not stranded, because `/update` and `/update.ps1` are generated by the control plane at request time and point wherever the current server says, so re-running the update one-liner on a host moves it onto a build that knows the new address. After that, self-update works again permanently. This was a deliberate choice - the alternative was publishing releases to a repository that no longer holds the source - but it means **the fleet needs one pass of the update one-liner**, and the control plane must be redeployed with the new release paths *first*, or the one-liner points at the old repository too. ## What the agent will not do - **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. - **It decides what it will not touch.** The protected workload set is computed and enforced agent-side - `vantage-agent.service`, `VantageAgent` on Windows, and its own container ID from `/proc/self/cgroup`. The control plane may name a target; the agent decides what it will do to itself. A server-side denylist alone would be bypassed by the next dispatch path someone adds, and the failure is unrecoverable from the UI. - **The console relay dials `127.0.0.1` only.** The host is hardcoded here, so the control plane can name a port and nothing else. - **No `authorized_keys` management on Windows**, and no package inventory: a Windows agent never calls `ReportPackages`, so no `server_packages` document exists for it at all - a different, earlier state than the `unsupported` a Linux distribution reaches when its family has no security feed. ## Platform split Windows support is build tags, not runtime branches - `systemd_linux.go` / `services_windows.go` and the matching `control_` and `logs_` pairs. Windows collection runs PowerShell through `internal/winexec`, and **every script that reports data emits JSON that a build-tag-free parser reads**, so those parsers are tested on Linux. This module has no Windows CI: the control verbs and `serviceDisplayName` emit no JSON, have no parser, and are exercised only by running the agent on Windows. Windows updates go through the Windows Update COM API (`Microsoft.Update.Session`) rather than the PSWindowsUpdate module, which would need a PowerShell Gallery install on every host and fails on an air-gapped fleet. `CurrentVersion` is empty on Windows and `NewVersion` carries the KB article ID: a Windows update is not a version bump of a named package. ## 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`. The deadline (`deadline_unix`, the window end) only gates the **start** of each phase: the apt index refresh, the upgrade command, the Windows install script. A phase that has not started by then is refused with "the maintenance window ended before could start" (`canStart` in `internal/updates/phase.go`). A started upgrade is never killed by the window: it runs under a 2 hour backstop from its own start (`defaultApplyCap`), which on Linux sends SIGTERM and waits 5 minutes before a kill. Interrupting a package manager mid-transaction is worse than letting it finish late. A `PatchResult` whose send fails (the command stream reconnected while the patch ran) is kept in a bounded queue (32, oldest dropped) and flushed on the next stream right after `AgentReady`. A queued result that announced a reboot has the reboot decision taken again at flush time, and the host still reboots only once the result is delivered. The startup static inventory report is retried every 30 seconds, up to 10 attempts: it carries the boot time the control plane uses to prove a patch reboot. ## Two constants that mirror the control plane Neither can be shared - this is a separate module and the control plane's are under `internal/` - so both must change in step, by hand: - the workload log cap, **500 lines and 256KB whichever binds first**, mirrored in the control plane's `services.MaxWorkloadLogLines` - `streamHealthyAfter` and the 70s command-stream watchdog, which pair with the control plane's 20s `PingCmd`. The watchdog arms only **after** a first ping has been seen, so an older server that sends none is treated as working rather than put into a reconnect loop. ## Writing style Never use em dashes (the long dash character) anywhere: code, comments, UI copy, docs, commit messages. Use a plain hyphen ` - `, a comma, a colon, or split the sentence instead.