Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5b9775d2b | ||
|
|
ecd703d502 | ||
|
|
db75d7dcbd | ||
|
|
a4745834de | ||
|
|
51b58fef21 | ||
|
|
5f5e19e23e | ||
|
|
3aa24bb938 | ||
|
|
b777ffcf58 | ||
|
|
1ad46dfda2 | ||
|
|
6b2c5f6f62 | ||
|
|
20fd5790a1 | ||
|
|
866584de81 | ||
|
|
1c8e6da013 | ||
|
|
72e27d9455 | ||
|
|
f2053cf703 | ||
|
|
a5930e89bd | ||
|
|
2a3ab12396 | ||
|
|
c38e2ab9ba | ||
|
|
d764eb2c6f |
@@ -0,0 +1,142 @@
|
||||
name: Agent Release
|
||||
|
||||
# The tag stays "agent/v*" rather than becoming a bare "v*", even though this
|
||||
# repository is only the agent now. The tag is not a private detail: it is the
|
||||
# release the fleet downloads by, it is what UpdateAgentCmd carries, and the
|
||||
# control plane finds the newest one by grepping tag names for that exact
|
||||
# prefix. Renaming it would be a second breaking change stacked on the
|
||||
# repository move.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "agent/v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-docker
|
||||
container: node:26
|
||||
env:
|
||||
GOPRIVATE: gitea.hostxtra.co.uk/*
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# vantage-shared is a private module, so the Go builds below cannot
|
||||
# resolve it without a credential.
|
||||
- name: Write the module fetch credential
|
||||
run: |
|
||||
umask 077
|
||||
printf 'machine gitea.hostxtra.co.uk\nlogin %s\npassword %s\n' \
|
||||
"${{ secrets.REGISTRY_USER }}" "${{ secrets.RELEASE_TOKEN }}" \
|
||||
> "$HOME/.netrc"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF_NAME#agent/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
mkdir -p dist
|
||||
GOOS=linux GOARCH=amd64 go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o dist/vantage-agent-linux-amd64 ./cmd
|
||||
GOOS=linux GOARCH=arm64 go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o dist/vantage-agent-linux-arm64 ./cmd
|
||||
GOOS=windows GOARCH=amd64 go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o dist/vantage-agent-windows-amd64.exe ./cmd
|
||||
|
||||
- name: Checksums
|
||||
working-directory: dist
|
||||
run: sha256sum vantage-agent-linux-amd64 vantage-agent-linux-arm64 vantage-agent-windows-amd64.exe > checksums.txt
|
||||
|
||||
- name: Create release
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
files: |
|
||||
dist/vantage-agent-linux-amd64
|
||||
dist/vantage-agent-linux-arm64
|
||||
dist/vantage-agent-windows-amd64.exe
|
||||
dist/checksums.txt
|
||||
|
||||
msi:
|
||||
needs: build
|
||||
runs-on: windows-2022
|
||||
env:
|
||||
GOPRIVATE: gitea.hostxtra.co.uk/*
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Same private-module credential as the build job, in the file
|
||||
# Windows Go looks for: _netrc in the profile directory, not .netrc.
|
||||
- name: Write the module fetch credential
|
||||
shell: pwsh
|
||||
run: |
|
||||
"machine gitea.hostxtra.co.uk`nlogin ${{ secrets.REGISTRY_USER }}`npassword ${{ secrets.RELEASE_TOKEN }}" |
|
||||
Out-File -Encoding ascii "$env:USERPROFILE\_netrc"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$v = "${{ github.ref_name }}" -replace '^agent/v', ''
|
||||
"VERSION=$v" | Out-File -Append $env:GITHUB_OUTPUT
|
||||
# MSI ProductVersion must be numeric x.x.x.x
|
||||
"MSIVERSION=$v.0" | Out-File -Append $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Build agent exe
|
||||
shell: pwsh
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
$env:GOOS = "windows"; $env:GOARCH = "amd64"
|
||||
go build -ldflags="-s -w -X main.Version=$env:VERSION" -o installer/vantage-agent-windows-amd64.exe ./cmd
|
||||
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.*
|
||||
|
||||
- name: Build MSI
|
||||
working-directory: installer
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:PATH = "$env:PATH;$env:USERPROFILE\.dotnet\tools"
|
||||
wix build vantage-agent.wxs -d Version=${{ steps.version.outputs.MSIVERSION }} -o vantage-agent.msi
|
||||
(Get-FileHash vantage-agent.msi -Algorithm SHA256).Hash.ToLower() + " vantage-agent.msi" | Out-File -Encoding ascii checksums-msi.txt
|
||||
|
||||
- name: Attach MSI to release
|
||||
working-directory: installer
|
||||
shell: pwsh
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
$api = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||
$tag = [uri]::EscapeDataString("${{ github.ref_name }}")
|
||||
$headers = @{ Authorization = "token $env:TOKEN" }
|
||||
# gitea-release-action can't find a slashed tag, so append via the API directly
|
||||
$rel = Invoke-RestMethod -Headers $headers -Uri "$api/releases/tags/$tag"
|
||||
foreach ($f in "vantage-agent.msi", "checksums-msi.txt") {
|
||||
$name = [uri]::EscapeDataString($f)
|
||||
Invoke-RestMethod -Headers $headers -Method Post -InFile $f `
|
||||
-ContentType "application/octet-stream" `
|
||||
-Uri "$api/releases/$($rel.id)/assets?name=$name"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
dist
|
||||
installer/vantage-agent-windows-amd64.exe
|
||||
installer/vantage-agent.msi
|
||||
installer/checksums-msi.txt
|
||||
.env
|
||||
@@ -0,0 +1,174 @@
|
||||
# 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
|
||||
`<gitea>/vantage/vantage-agent/releases/download/<tag>/…`, 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 <phase> 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.
|
||||
+2
-2
@@ -7,8 +7,8 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
|
||||
agentsync "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/sync"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/config"
|
||||
agentsync "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/sync"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
module gitea.hostxtra.co.uk/mrhid6/vantage/agent
|
||||
module gitea.hostxtra.co.uk/vantage/vantage-agent
|
||||
|
||||
go 1.26
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
golang.org/x/sys v0.20.0
|
||||
google.golang.org/grpc v1.64.0
|
||||
golang.org/x/sys v0.48.0
|
||||
google.golang.org/grpc v1.83.2
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.5.0
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
)
|
||||
|
||||
@@ -1,17 +1,43 @@
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e h1:Elxv5MwEkCI9f5SkoL6afed6NTdxaGoAo39eANBwHL8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
|
||||
google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
|
||||
google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.5.0 h1:xwSIEkQKTd4Qk+BYHvoGN+h84Isr2h5qqnitUWF1m2w=
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.5.0/go.mod h1:Zo66XhqF8No3dveIowLCepvMxVg8KnhsNMz0k0Xpuck=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 h1:ieEbjQ6lzbvntOXUB9nMx9uH+yIU/HbgkNDjnk/mJuk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
|
||||
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -18,6 +18,10 @@ const (
|
||||
TypeTCP = "tcp"
|
||||
TypeICMP = "icmp"
|
||||
TypeTLS = "tls"
|
||||
|
||||
// UserAgent identifies Vantage monitor traffic so a WAF rule can single it
|
||||
// out. Match on a prefix, not equality: the version moves.
|
||||
UserAgent = "Vantage-Monitor/1.0 (+https://vantage.hostxtra.co.uk)"
|
||||
)
|
||||
|
||||
|
||||
@@ -84,6 +88,7 @@ func runHTTP(ctx context.Context, s Spec) Result {
|
||||
if err != nil {
|
||||
return Result{Message: err.Error()}
|
||||
}
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: err.Error()}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
type streamWriter struct {
|
||||
|
||||
@@ -6,7 +6,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/codec"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
@@ -15,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
func init() {
|
||||
encoding.RegisterCodec(JSONCodec{})
|
||||
encoding.RegisterCodec(codec.JSONCodec{})
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package grpcclient
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type JSONCodec struct{}
|
||||
|
||||
func (JSONCodec) Marshal(v interface{}) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func (JSONCodec) Unmarshal(data []byte, v interface{}) error {
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
|
||||
func (JSONCodec) Name() string {
|
||||
return "proto"
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
package pb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type RegisterRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
PreRegToken string `json:"pre_reg_token"`
|
||||
Hostname string `json:"hostname"`
|
||||
IpAddress string `json:"ip_address"`
|
||||
OsInfo string `json:"os_info"`
|
||||
}
|
||||
|
||||
type RegisterResponse struct {
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
|
||||
type SyncRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
AgentVersion string `json:"agent_version,omitempty"`
|
||||
}
|
||||
|
||||
type SyncResponse struct {
|
||||
PublicKeys []string `json:"public_keys"`
|
||||
// CollectPackages tells the agent whether this instance's licence grants
|
||||
// vulnerability scanning. Absent decodes as false, which is the safe
|
||||
// direction: an older server leaves agents collecting nothing.
|
||||
CollectPackages bool `json:"collect_packages,omitempty"`
|
||||
}
|
||||
|
||||
type OSRelease struct {
|
||||
Family string `json:"family"`
|
||||
// VersionId is not optional: Ubuntu 22.04 and 24.04 publish different fixed
|
||||
// versions for the same CVE, so a scan without it is guesswork.
|
||||
VersionId string `json:"version_id"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
}
|
||||
|
||||
type InstalledPackage struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Epoch int32 `json:"epoch,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
// SourceName is what the Debian and Ubuntu feeds are keyed on: one advisory
|
||||
// against "openssl" covers libssl3, openssl and libssl-dev.
|
||||
SourceName string `json:"source_name,omitempty"`
|
||||
}
|
||||
|
||||
// ReportPackagesRequest carries a server's installed package set.
|
||||
//
|
||||
// The agent calls twice at most: first with Packages empty, offering only the
|
||||
// hash. If the server already holds it, NeedFull is false and the ~150KB body
|
||||
// is never sent.
|
||||
type ReportPackagesRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Hash string `json:"hash"`
|
||||
Os OSRelease `json:"os"`
|
||||
Packages []InstalledPackage `json:"packages,omitempty"`
|
||||
}
|
||||
|
||||
type ReportPackagesResponse struct {
|
||||
NeedFull bool `json:"need_full"`
|
||||
}
|
||||
|
||||
type UploadKeyRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Label string `json:"label"`
|
||||
PrivateKey string `json:"private_key,omitempty"`
|
||||
}
|
||||
|
||||
type UploadKeyResponse struct {
|
||||
KeyId string `json:"key_id"`
|
||||
}
|
||||
|
||||
type PackageUpdate struct {
|
||||
Name string `json:"name"`
|
||||
CurrentVersion string `json:"current_version,omitempty"`
|
||||
NewVersion string `json:"new_version"`
|
||||
}
|
||||
|
||||
type ReportUpdatesRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Updates []PackageUpdate `json:"updates"`
|
||||
}
|
||||
|
||||
type ReportUpdatesResponse struct{}
|
||||
|
||||
type CPUReport struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
UsagePct float64 `json:"usage_pct"`
|
||||
Load1 float64 `json:"load1,omitempty"`
|
||||
}
|
||||
type MemReport struct {
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
}
|
||||
type PartitionReport struct {
|
||||
Device string `json:"device"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
Fstype string `json:"fstype,omitempty"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
}
|
||||
type InventoryReport struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
IncludeStatic bool `json:"include_static"`
|
||||
CPU *CPUReport `json:"cpu,omitempty"`
|
||||
Memory *MemReport `json:"memory,omitempty"`
|
||||
SwapTotal uint64 `json:"swap_total"`
|
||||
SwapUsed uint64 `json:"swap_used"`
|
||||
Partitions []PartitionReport `json:"partitions,omitempty"`
|
||||
Kernel string `json:"kernel,omitempty"`
|
||||
RebootRequired bool `json:"reboot_required,omitempty"`
|
||||
}
|
||||
type InventoryReportResponse struct{}
|
||||
|
||||
type MonitorSpec struct {
|
||||
MonitorId string `json:"monitor_id"`
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
ExpectedStatus int `json:"expected_status,omitempty"`
|
||||
Keyword string `json:"keyword,omitempty"`
|
||||
TLSWarnDays int `json:"tls_warn_days,omitempty"`
|
||||
Insecure bool `json:"insecure,omitempty"`
|
||||
IntervalSec int `json:"interval_sec"`
|
||||
Retries int `json:"retries"`
|
||||
}
|
||||
type SyncMonitorsRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
type SyncMonitorsResponse struct {
|
||||
Monitors []MonitorSpec `json:"monitors,omitempty"`
|
||||
}
|
||||
type CheckResult struct {
|
||||
MonitorId string `json:"monitor_id"`
|
||||
Up bool `json:"up"`
|
||||
LatencyMs int `json:"latency_ms"`
|
||||
Message string `json:"message,omitempty"`
|
||||
CertExpiryUnix int64 `json:"cert_expiry_unix,omitempty"`
|
||||
}
|
||||
type ReportChecksRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Results []CheckResult `json:"results,omitempty"`
|
||||
}
|
||||
type ReportChecksResponse struct{}
|
||||
|
||||
type ApplyUpdatesCmd struct{}
|
||||
|
||||
type OpenProxyCmd struct {
|
||||
ProxyId string `json:"proxy_id"`
|
||||
Port uint32 `json:"port"`
|
||||
}
|
||||
|
||||
type ProxyOpen struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
ProxyId string `json:"proxy_id"`
|
||||
}
|
||||
|
||||
type ProxyClose struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type ProxyClientMsg struct {
|
||||
Open *ProxyOpen `json:"open,omitempty"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Close *ProxyClose `json:"close,omitempty"`
|
||||
}
|
||||
|
||||
type ProxyServerMsg struct {
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Close *ProxyClose `json:"close,omitempty"`
|
||||
}
|
||||
|
||||
type ServerCommand struct {
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
|
||||
RunStep *RunStepCmd `json:"run_step,omitempty"`
|
||||
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
|
||||
OpenProxy *OpenProxyCmd `json:"open_proxy,omitempty"`
|
||||
Ping *PingCmd `json:"ping,omitempty"`
|
||||
|
||||
RefreshWorkloads *RefreshWorkloadsCmd `json:"refresh_workloads,omitempty"`
|
||||
ControlWorkload *ControlWorkloadCmd `json:"control_workload,omitempty"`
|
||||
WorkloadLogs *WorkloadLogsCmd `json:"workload_logs,omitempty"`
|
||||
}
|
||||
|
||||
// PingCmd is a server-originated liveness beat. It carries nothing and expects
|
||||
// no reply: its arrival is the entire message. See the .proto for why gRPC
|
||||
// keepalive is not sufficient on its own.
|
||||
type PingCmd struct{}
|
||||
|
||||
type CleanupWorkspaceCmd struct {
|
||||
WorkspaceId string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type DeleteKeyCmd struct {
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type UpdateAgentCmd struct {
|
||||
Version string `json:"version"`
|
||||
GiteaBaseURL string `json:"gitea_base_url"`
|
||||
}
|
||||
|
||||
type GenerateKeyCmd struct {
|
||||
Label string `json:"label"`
|
||||
KeyType string `json:"key_type,omitempty"`
|
||||
KeySize int `json:"key_size,omitempty"`
|
||||
Passphrase string `json:"passphrase,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
type AgentMessage struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Ready *AgentReady `json:"ready,omitempty"`
|
||||
Result *CommandResult `json:"result,omitempty"`
|
||||
StepResult *StepResult `json:"step_result,omitempty"`
|
||||
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
|
||||
|
||||
WorkloadLogsResult *WorkloadLogsResult `json:"workload_logs_result,omitempty"`
|
||||
}
|
||||
|
||||
type AgentReady struct{}
|
||||
|
||||
type CommandResult struct {
|
||||
CommandId string `json:"command_id"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type RunStepCmd struct {
|
||||
Interpreter string `json:"interpreter"`
|
||||
Script string `json:"script"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
|
||||
WorkspaceId string `json:"workspace_id,omitempty"`
|
||||
}
|
||||
|
||||
type StepResult struct {
|
||||
CommandId string `json:"command_id"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
OutputEnv map[string]string `json:"output_env,omitempty"`
|
||||
}
|
||||
|
||||
type StepOutputChunk struct {
|
||||
CommandId string `json:"command_id"`
|
||||
Seq uint64 `json:"seq"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Eof bool `json:"eof,omitempty"`
|
||||
}
|
||||
|
||||
type Vantage_CommandStreamClient interface {
|
||||
Send(*AgentMessage) error
|
||||
Recv() (*ServerCommand, error)
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
type vantageCommandStreamClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (c *vantageCommandStreamClient) Send(m *AgentMessage) error {
|
||||
return c.ClientStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
|
||||
m := new(ServerCommand)
|
||||
if err := c.ClientStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type Vantage_CommandStreamServer interface {
|
||||
Send(*ServerCommand) error
|
||||
Recv() (*AgentMessage, error)
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
type keyManagerCommandStreamServer struct {
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
func (s *keyManagerCommandStreamServer) Send(m *ServerCommand) error {
|
||||
return s.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
|
||||
m := new(AgentMessage)
|
||||
if err := s.ServerStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type Vantage_ProxyStreamServer interface {
|
||||
Send(*ProxyServerMsg) error
|
||||
Recv() (*ProxyClientMsg, error)
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
type vantageProxyStreamServer struct {
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
func (s *vantageProxyStreamServer) Send(m *ProxyServerMsg) error {
|
||||
return s.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (s *vantageProxyStreamServer) Recv() (*ProxyClientMsg, error) {
|
||||
m := new(ProxyClientMsg)
|
||||
if err := s.ServerStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type Vantage_ProxyStreamClient interface {
|
||||
Send(*ProxyClientMsg) error
|
||||
Recv() (*ProxyServerMsg, error)
|
||||
CloseSend() error
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
type vantageProxyStreamClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (c *vantageProxyStreamClient) Send(m *ProxyClientMsg) error {
|
||||
return c.ClientStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (c *vantageProxyStreamClient) Recv() (*ProxyServerMsg, error) {
|
||||
m := new(ProxyServerMsg)
|
||||
if err := c.ClientStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type VantageClient interface {
|
||||
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
|
||||
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
|
||||
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
|
||||
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
|
||||
ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error)
|
||||
ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, error)
|
||||
ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error)
|
||||
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
|
||||
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
|
||||
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
|
||||
ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error)
|
||||
}
|
||||
|
||||
type UnimplementedVantageServer struct{}
|
||||
|
||||
func (UnimplementedVantageServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "not implemented")
|
||||
}
|
||||
func (UnimplementedVantageServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "not implemented")
|
||||
}
|
||||
func (UnimplementedVantageServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "not implemented")
|
||||
}
|
||||
|
||||
type keyManagerClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewVantageClient(cc grpc.ClientConnInterface) VantageClient {
|
||||
return &keyManagerClient{cc}
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
|
||||
out := new(RegisterResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/Register", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) {
|
||||
out := new(SyncResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncKeys", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) {
|
||||
out := new(UploadKeyResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/UploadGeneratedKey", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error) {
|
||||
out := new(ReportUpdatesResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportUpdates", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error) {
|
||||
out := new(ReportPackagesResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportPackages", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error) {
|
||||
out := new(InventoryReportResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportInventory", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error) {
|
||||
out := new(SyncMonitorsResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncMonitors", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error) {
|
||||
out := new(ReportChecksResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportChecks", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error) {
|
||||
desc := &grpc.StreamDesc{StreamName: "CommandStream", ServerStreams: true, ClientStreams: true}
|
||||
stream, err := c.cc.NewStream(ctx, desc, "/vantage.v1.Vantage/CommandStream", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &vantageCommandStreamClient{stream}, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error) {
|
||||
desc := &grpc.StreamDesc{StreamName: "ProxyStream", ServerStreams: true, ClientStreams: true}
|
||||
stream, err := c.cc.NewStream(ctx, desc, "/vantage.v1.Vantage/ProxyStream", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &vantageProxyStreamClient{stream}, nil
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package pb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// Workload registry messages. Hand-written like the rest of this package: the
|
||||
// .proto is the contract, this file is the Go side of it, and the two must be
|
||||
// changed together.
|
||||
|
||||
// Workload is one container or one systemd unit.
|
||||
type Workload struct {
|
||||
Kind string `json:"kind"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Health string `json:"health,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Stack string `json:"stack,omitempty"`
|
||||
Ports []string `json:"ports,omitempty"`
|
||||
Restarts int32 `json:"restarts,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"` // RFC3339, empty when not running
|
||||
Protected bool `json:"protected,omitempty"`
|
||||
}
|
||||
|
||||
// ReportWorkloadsRequest carries what a server is running.
|
||||
//
|
||||
// Offer-then-send, the same handshake as ReportPackages: the agent calls once
|
||||
// with Workloads empty, and resends with the body only if NeedFull is set.
|
||||
type ReportWorkloadsRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Hash string `json:"hash"`
|
||||
DockerOk bool `json:"docker_ok"`
|
||||
DockerError string `json:"docker_error,omitempty"`
|
||||
SystemdOk bool `json:"systemd_ok"`
|
||||
SystemdError string `json:"systemd_error,omitempty"`
|
||||
Workloads []Workload `json:"workloads,omitempty"` // empty on the offer call
|
||||
// Full marks the second call. It is not inferred from an empty Workloads
|
||||
// slice: a host running nothing sends an empty list as its full report.
|
||||
Full bool `json:"full,omitempty"`
|
||||
}
|
||||
|
||||
type ReportWorkloadsResponse struct {
|
||||
NeedFull bool `json:"need_full"`
|
||||
}
|
||||
|
||||
// RefreshWorkloadsCmd carries no payload back. It makes the agent report
|
||||
// immediately through ReportWorkloads, so there is exactly one writer for the
|
||||
// server_workloads collection rather than two arriving by different routes.
|
||||
type RefreshWorkloadsCmd struct{}
|
||||
|
||||
type ControlWorkloadCmd struct {
|
||||
Kind string `json:"kind"`
|
||||
Id string `json:"id"`
|
||||
Action string `json:"action"` // start | stop | restart
|
||||
}
|
||||
|
||||
type WorkloadLogsCmd struct {
|
||||
Kind string `json:"kind"`
|
||||
Id string `json:"id"`
|
||||
Tail int32 `json:"tail,omitempty"`
|
||||
}
|
||||
|
||||
type WorkloadLogsResult struct {
|
||||
CommandId string `json:"command_id"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, error) {
|
||||
out := new(ReportWorkloadsResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportWorkloads", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,11 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {
|
||||
r.BootTimeUnix = parseBtime(readProc("/proc/stat"))
|
||||
r.CPU.UsagePct = cpuUsage()
|
||||
r.CPU.Load1 = load1()
|
||||
memTotal, memAvail, swapTotal, swapFree := meminfo()
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
// without it this file compiles on Linux too and collides with collect_linux.go.
|
||||
package inventory
|
||||
|
||||
import "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
|
||||
import "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -20,6 +20,8 @@ var (
|
||||
)
|
||||
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {
|
||||
// DurationSinceBoot returns milliseconds since boot and does not wrap.
|
||||
r.BootTimeUnix = time.Now().Add(-windows.DurationSinceBoot()).Unix()
|
||||
r.CPU.UsagePct = cpuUsage()
|
||||
// Windows has no load average. Left at zero; the UI already treats it as
|
||||
// optional because it is omitempty on the wire.
|
||||
@@ -28,7 +30,7 @@ func collect(r *pb.InventoryReport, includeStatic bool) {
|
||||
if m.TotalPhys > m.AvailPhys {
|
||||
r.Memory.UsedBytes = m.TotalPhys - m.AvailPhys
|
||||
}
|
||||
// TotalPageFile is the commit limit — physical memory plus the pagefile —
|
||||
// TotalPageFile is the commit limit - physical memory plus the pagefile -
|
||||
// so the pagefile alone is the difference.
|
||||
swapTotal := sub(m.TotalPageFile, m.TotalPhys)
|
||||
swapUsed := sub(sub(m.TotalPageFile, m.AvailPageFile), sub(m.TotalPhys, m.AvailPhys))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package inventory
|
||||
|
||||
import "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
import "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
|
||||
func Collect(includeStatic bool) *pb.InventoryReport {
|
||||
r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}}
|
||||
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/checker"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
|
||||
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/checker"
|
||||
"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-shared/grpc/pb"
|
||||
)
|
||||
|
||||
const syncInterval = 30 * time.Second
|
||||
|
||||
@@ -16,7 +16,7 @@ const collectTimeout = 2 * time.Minute
|
||||
//
|
||||
// The format strings below are raw string literals on purpose. The "\t" and
|
||||
// "\n" reach dpkg-query and rpm as two characters each, and those tools do the
|
||||
// interpreting themselves — Go must not consume the escapes first.
|
||||
// interpreting themselves - Go must not consume the escapes first.
|
||||
func Collect() (OSRelease, []Package, error) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return OSRelease{}, nil, fmt.Errorf("package collection is linux-only, got %s", runtime.GOOS)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// Package is one installed package as the distribution reports it. Version is
|
||||
// the distribution's own version string, verbatim — never normalised, because
|
||||
// the distribution's own version string, verbatim - never normalised, because
|
||||
// the advisory feeds are keyed on exactly this form.
|
||||
type Package struct {
|
||||
Name string
|
||||
@@ -29,7 +29,7 @@ type Package struct {
|
||||
//
|
||||
// The fifth column is why "rc" packages do not appear. dpkg-query -W lists
|
||||
// every package dpkg knows about, including ones removed with their config
|
||||
// files left behind — a host that has upgraded its kernel a dozen times reports
|
||||
// files left behind - a host that has upgraded its kernel a dozen times reports
|
||||
// a dozen old linux-modules versions that are not on disk, and the oldest of
|
||||
// them sorts first and reads as the installed version. Only "installed" is
|
||||
// installed. An empty status means dpkg did not understand the field, in which
|
||||
@@ -143,7 +143,7 @@ func splitAPK(s string) (name, version string) {
|
||||
//
|
||||
// It sorts first: the ordering of dpkg or rpm output is not guaranteed stable,
|
||||
// and an ordering-sensitive hash would resend the full ~150KB list every hour
|
||||
// for no reason — a cost visible only as traffic.
|
||||
// for no reason - a cost visible only as traffic.
|
||||
func Hash(pkgs []Package) string {
|
||||
lines := make([]string, 0, len(pkgs))
|
||||
for _, p := range pkgs {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -8,14 +8,14 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
|
||||
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/packages"
|
||||
"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/packages"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
// collectPackagesFlag is written by the 30s key poll and read by the hourly
|
||||
// package loop — two different goroutines, hence the atomic.
|
||||
// package loop - two different goroutines, hence the atomic.
|
||||
//
|
||||
// It defaults to false, so an agent that has not yet completed a poll, or is
|
||||
// talking to a server too old to send the field, collects nothing. Off is the
|
||||
@@ -27,7 +27,7 @@ var collectPackagesFlag atomic.Bool
|
||||
//
|
||||
// Without it the boot-time package report loses a race it can only lose: the
|
||||
// hourly loop starts before the first poll, reads a flag that is still false by
|
||||
// construction, and skips — so a freshly installed agent reports no packages for
|
||||
// construction, and skips - so a freshly installed agent reports no packages for
|
||||
// an hour and the server shows nothing to scan.
|
||||
// How long the boot package report waits for that first poll. Two poll
|
||||
// intervals plus slack: long enough to cover one failed attempt, short enough
|
||||
@@ -43,7 +43,7 @@ func markFirstPoll() { firstPollOnce.Do(func() { close(firstPoll) }) }
|
||||
|
||||
// waitFirstPoll blocks until the flag is known, or gives up. The wait is
|
||||
// bounded because this loop also reports OS updates, which do not depend on the
|
||||
// flag at all — a control plane that cannot be polled must not silence those too.
|
||||
// flag at all - a control plane that cannot be polled must not silence those too.
|
||||
func waitFirstPoll(ctx context.Context, limit time.Duration) {
|
||||
t := time.NewTimer(limit)
|
||||
defer t.Stop()
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package agentsync
|
||||
|
||||
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)
|
||||
|
||||
msg := &pb.AgentMessage{ServerId: cfg.ServerID, AgentToken: cfg.AgentToken, PatchResult: pr}
|
||||
if err := send(msg); err != nil {
|
||||
log.Printf("send patch result (cmd=%s): %v; keeping it for the next command stream", cmd.CommandId, err)
|
||||
// A reboot nobody was told about looks like a crash. Without a
|
||||
// delivered result, do not reboot: the queued result retakes the
|
||||
// decision when it is finally sent.
|
||||
if pendingResults.push(pendingResult{msg: msg, requested: c.RebootIfRequired, deadline: deadline}) {
|
||||
log.Printf("patch result queue full: dropped the oldest undelivered result")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pendingResults holds PatchResults whose send failed, typically because the
|
||||
// command stream reconnected while a patch ran. The server guards result
|
||||
// writes on status and command id, so a late result is safe to deliver.
|
||||
var pendingResults = &resultQueue{max: maxPendingResults}
|
||||
|
||||
// flushPendingResults delivers queued results on a newly established stream.
|
||||
// A queued result that announced a reboot has that decision taken again now:
|
||||
// time has passed, so the window may be too close to its end, or the reboot
|
||||
// may no longer be owed. Rebooting is cleared before sending when it no
|
||||
// longer holds, and the host reboots only once the result is delivered.
|
||||
func flushPendingResults(send func(*pb.AgentMessage) error) {
|
||||
pendingResults.flush(func(p pendingResult) error {
|
||||
pr := p.msg.PatchResult
|
||||
if pr.Rebooting {
|
||||
owed := updates.RebootRequired()
|
||||
pr.RebootRequired = owed
|
||||
if !shouldReboot(p.requested, owed, time.Now(), p.deadline) {
|
||||
pr.Rebooting = false
|
||||
}
|
||||
}
|
||||
if err := send(p.msg); err != nil {
|
||||
log.Printf("send queued patch result (cmd=%s): %v", pr.CommandId, err)
|
||||
return err
|
||||
}
|
||||
log.Printf("queued patch result sent (cmd=%s status=%s rebooting=%v)", pr.CommandId, pr.Status, pr.Rebooting)
|
||||
if pr.Rebooting {
|
||||
if err := updates.ScheduleReboot(); err != nil {
|
||||
log.Printf("schedule reboot (cmd=%s): %v", pr.CommandId, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package agentsync
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package agentsync
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
// maxPendingResults bounds the results kept for a later stream. A host that
|
||||
// cannot reach the control plane for a very long time drops its oldest
|
||||
// results rather than growing without limit.
|
||||
const maxPendingResults = 32
|
||||
|
||||
// pendingResult is a PatchResult that could not be sent, with what the reboot
|
||||
// decision needs to be taken again when it finally can be.
|
||||
type pendingResult struct {
|
||||
msg *pb.AgentMessage
|
||||
requested bool // the command's RebootIfRequired
|
||||
deadline time.Time // the command's deadline, zero for a manual run
|
||||
}
|
||||
|
||||
// resultQueue holds undelivered PatchResults until the next command stream.
|
||||
type resultQueue struct {
|
||||
mu sync.Mutex
|
||||
items []pendingResult
|
||||
max int
|
||||
}
|
||||
|
||||
// push appends p, dropping the oldest entry when the queue is full. It
|
||||
// reports whether an entry was dropped.
|
||||
func (q *resultQueue) push(p pendingResult) bool {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
q.items = append(q.items, p)
|
||||
if len(q.items) > q.max {
|
||||
q.items = q.items[len(q.items)-q.max:]
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// flush sends the queued results oldest first, removing each once sent. It
|
||||
// stops at the first failure and keeps that entry and everything after it
|
||||
// for the next stream. The lock is held throughout so two streams never
|
||||
// deliver the same result.
|
||||
func (q *resultQueue) flush(send func(pendingResult) error) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
for len(q.items) > 0 {
|
||||
if err := send(q.items[0]); err != nil {
|
||||
return
|
||||
}
|
||||
q.items = q.items[1:]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package agentsync
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
func pending(id string) pendingResult {
|
||||
return pendingResult{msg: &pb.AgentMessage{PatchResult: &pb.PatchResult{CommandId: id}}}
|
||||
}
|
||||
|
||||
func ids(q *resultQueue) []string {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
var out []string
|
||||
for _, p := range q.items {
|
||||
out = append(out, p.msg.PatchResult.CommandId)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestResultQueueEnqueue(t *testing.T) {
|
||||
q := &resultQueue{max: 3}
|
||||
q.push(pending("a"))
|
||||
q.push(pending("b"))
|
||||
if got := ids(q); len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultQueueBoundDropsOldest(t *testing.T) {
|
||||
q := &resultQueue{max: 2}
|
||||
q.push(pending("a"))
|
||||
q.push(pending("b"))
|
||||
if dropped := q.push(pending("c")); !dropped {
|
||||
t.Fatal("push over the bound must report a drop")
|
||||
}
|
||||
if got := ids(q); len(got) != 2 || got[0] != "b" || got[1] != "c" {
|
||||
t.Fatalf("got %v, want [b c]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultQueueDefaultBound(t *testing.T) {
|
||||
if maxPendingResults != 32 {
|
||||
t.Fatalf("maxPendingResults = %d, want 32", maxPendingResults)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultQueueFlushOrderAndRemoval(t *testing.T) {
|
||||
q := &resultQueue{max: 8}
|
||||
for _, id := range []string{"a", "b", "c"} {
|
||||
q.push(pending(id))
|
||||
}
|
||||
var sent []string
|
||||
q.flush(func(p pendingResult) error {
|
||||
sent = append(sent, p.msg.PatchResult.CommandId)
|
||||
return nil
|
||||
})
|
||||
if len(sent) != 3 || sent[0] != "a" || sent[1] != "b" || sent[2] != "c" {
|
||||
t.Fatalf("flush order %v, want [a b c]", sent)
|
||||
}
|
||||
if got := ids(q); len(got) != 0 {
|
||||
t.Fatalf("sent entries must be removed, left %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultQueueFlushRetainsOnFailure(t *testing.T) {
|
||||
q := &resultQueue{max: 8}
|
||||
for _, id := range []string{"a", "b", "c"} {
|
||||
q.push(pending(id))
|
||||
}
|
||||
var sent []string
|
||||
q.flush(func(p pendingResult) error {
|
||||
if p.msg.PatchResult.CommandId == "b" {
|
||||
return errors.New("stream gone")
|
||||
}
|
||||
sent = append(sent, p.msg.PatchResult.CommandId)
|
||||
return nil
|
||||
})
|
||||
if len(sent) != 1 || sent[0] != "a" {
|
||||
t.Fatalf("sent %v, want [a]", sent)
|
||||
}
|
||||
if got := ids(q); len(got) != 2 || got[0] != "b" || got[1] != "c" {
|
||||
t.Fatalf("retained %v, want [b c]", got)
|
||||
}
|
||||
}
|
||||
+41
-38
@@ -17,15 +17,15 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
|
||||
agentexec "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/exec"
|
||||
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/inventory"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/keys"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/monitors"
|
||||
agentproxy "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/proxy"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/updates"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/config"
|
||||
agentexec "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/exec"
|
||||
grpcclient "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/inventory"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/keys"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/monitors"
|
||||
agentproxy "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/proxy"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/updates"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
func Run(ctx context.Context, cfg *config.Config, version string) error {
|
||||
@@ -134,7 +134,7 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
|
||||
const streamHealthyAfter = time.Minute
|
||||
|
||||
// Stream staleness. The server beats every 20s, so 70s tolerates three missed
|
||||
// beats before the stream is written off — high enough that a slow network or a
|
||||
// beats before the stream is written off - high enough that a slow network or a
|
||||
// briefly busy server does not cost a reconnect, low enough that an agent is
|
||||
// not uncommandable for minutes after a control-plane restart.
|
||||
const (
|
||||
@@ -142,7 +142,7 @@ const (
|
||||
streamStaleCheck = 10 * time.Second
|
||||
|
||||
// How often a healthy stream reports itself. Also the interval at which an
|
||||
// agent talking to a control plane too old to send heartbeats says so —
|
||||
// agent talking to a control plane too old to send heartbeats says so -
|
||||
// that agent is running without a watchdog, and the journal should not be
|
||||
// silent about it.
|
||||
pingSummaryInterval = 5 * time.Minute
|
||||
@@ -184,7 +184,7 @@ func runCommandStream(ctx context.Context, cfg *config.Config) {
|
||||
|
||||
// The uptime is in the line because it is what distinguishes a stream
|
||||
// that never worked from one that ran for hours and was dropped by a
|
||||
// deploy — and it is the same measure that decides whether the backoff
|
||||
// deploy - and it is the same measure that decides whether the backoff
|
||||
// resets, so a reader can see why the delay is what it is.
|
||||
up := time.Since(started).Truncate(time.Second)
|
||||
if err != nil {
|
||||
@@ -243,6 +243,11 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
return stream.Send(msg)
|
||||
}
|
||||
|
||||
// Results that could not be sent on an earlier stream go out first. In
|
||||
// its own goroutine: a queued reboot re-check can take a while on
|
||||
// Windows, and the receive loop below must start promptly.
|
||||
go flushPendingResults(send)
|
||||
|
||||
// Stream liveness, tracked here rather than left to gRPC keepalive.
|
||||
//
|
||||
// Keepalive operates on the transport, and behind an L7 proxy the transport
|
||||
@@ -253,7 +258,7 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
// the operator watches nothing happen.
|
||||
//
|
||||
// The watchdog only arms once a ping has actually been seen. A server too
|
||||
// old to send them must not be treated as dead — that would put the agent
|
||||
// old to send them must not be treated as dead - that would put the agent
|
||||
// in a reconnect loop against a control plane that is working perfectly.
|
||||
var (
|
||||
lastMu sync.Mutex
|
||||
@@ -283,7 +288,7 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
|
||||
// Reported periodically rather than per beat: at one every 20s the
|
||||
// journal would be nothing else. The count is what makes a partial
|
||||
// failure visible — beats arriving but fewer than expected is a
|
||||
// failure visible - beats arriving but fewer than expected is a
|
||||
// different problem from beats stopping altogether.
|
||||
summary := time.NewTicker(pingSummaryInterval)
|
||||
defer summary.Stop()
|
||||
@@ -341,7 +346,7 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
go handleUpdateAgent(cmd)
|
||||
}
|
||||
if cmd.ApplyUpdates != nil {
|
||||
go handleApplyUpdates(cfg, cmd)
|
||||
go handleApplyUpdates(send, cfg, cmd)
|
||||
}
|
||||
if cmd.CleanupWorkspace != nil {
|
||||
go handleCleanupWorkspace(cmd)
|
||||
@@ -446,11 +451,11 @@ func runInventory(ctx context.Context, cfg *config.Config) {
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
report := func(static bool) {
|
||||
report := func(static bool) error {
|
||||
r := inventory.Collect(static)
|
||||
r.ServerId = cfg.ServerID
|
||||
r.AgentToken = cfg.AgentToken
|
||||
// Static snapshots only — every 15 minutes, not every 30 seconds. On
|
||||
// Static snapshots only - every 15 minutes, not every 30 seconds. On
|
||||
// Windows this spawns a PowerShell process, which is not something to
|
||||
// do twice a minute forever, and a host rebooted by hand clearing the
|
||||
// flag within a quarter of an hour is soon enough.
|
||||
@@ -462,10 +467,18 @@ func runInventory(ctx context.Context, cfg *config.Config) {
|
||||
}
|
||||
if err := client.ReportInventory(r); err != nil {
|
||||
log.Printf("report inventory: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
report(true)
|
||||
// The startup static report carries the boot time the server uses to
|
||||
// prove a patch reboot happened, so a failure is retried on the next
|
||||
// ticks (every 30 seconds, up to startupStaticAttempts in total) instead
|
||||
// of waiting a quarter of an hour for the next static snapshot.
|
||||
const startupStaticAttempts = 10
|
||||
attempts := 1
|
||||
startupPending := report(true) != nil
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
tick := 0
|
||||
@@ -475,27 +488,17 @@ func runInventory(ctx context.Context, cfg *config.Config) {
|
||||
return
|
||||
case <-ticker.C:
|
||||
tick++
|
||||
if startupPending && attempts < startupStaticAttempts {
|
||||
attempts++
|
||||
startupPending = report(true) != nil
|
||||
continue
|
||||
}
|
||||
startupPending = false
|
||||
report(tick%30 == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleApplyUpdates(cfg *config.Config, cmd *pb.ServerCommand) {
|
||||
log.Printf("applying OS updates (cmd=%s)…", cmd.CommandId)
|
||||
if err := updates.ApplyAll(); err != nil {
|
||||
log.Printf("OS upgrade failed (cmd=%s): %v", cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
log.Printf("OS updates applied successfully (cmd=%s)", cmd.CommandId)
|
||||
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
_ = client.ReportUpdates(cfg.ServerID, cfg.AgentToken, nil)
|
||||
}
|
||||
|
||||
func handleCleanupWorkspace(cmd *pb.ServerCommand) {
|
||||
id := cmd.CleanupWorkspace.WorkspaceId
|
||||
dir := agentexec.WorkspacePath(id)
|
||||
@@ -554,8 +557,8 @@ func handleUpdateAgent(cmd *pb.ServerCommand) {
|
||||
u := cmd.UpdateAgent
|
||||
arch := runtime.GOARCH
|
||||
tag := "agent%2Fv" + u.Version
|
||||
binaryURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent-linux-%s", u.GiteaBaseURL, tag, arch)
|
||||
checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag)
|
||||
binaryURL := fmt.Sprintf("%s/vantage/vantage-agent/releases/download/%s/vantage-agent-linux-%s", u.GiteaBaseURL, tag, arch)
|
||||
checksumURL := fmt.Sprintf("%s/vantage/vantage-agent/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag)
|
||||
|
||||
log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId)
|
||||
|
||||
@@ -592,8 +595,8 @@ func handleUpdateAgent(cmd *pb.ServerCommand) {
|
||||
func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
|
||||
u := cmd.UpdateAgent
|
||||
tag := "agent%2Fv" + u.Version
|
||||
msiURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent.msi", u.GiteaBaseURL, tag)
|
||||
checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums-msi.txt", u.GiteaBaseURL, tag)
|
||||
msiURL := fmt.Sprintf("%s/vantage/vantage-agent/releases/download/%s/vantage-agent.msi", u.GiteaBaseURL, tag)
|
||||
checksumURL := fmt.Sprintf("%s/vantage/vantage-agent/releases/download/%s/checksums-msi.txt", u.GiteaBaseURL, tag)
|
||||
|
||||
log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId)
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
|
||||
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/workloads"
|
||||
"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/workloads"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
// workloadInterval is the report cadence. Sixty seconds is affordable because
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// isSecuritySuite reports whether an apt suite carries security fixes. Debian
|
||||
// 11+ and every supported Ubuntu name them "<codename>-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()
|
||||
}
|
||||
|
||||
// deb822Fields groups a paragraph's lines into fields. A line that starts
|
||||
// with a space or tab continues the field above it (a folded or multi-line
|
||||
// value, such as a long Suites list or an inline Signed-By key block).
|
||||
func deb822Fields(para string) [][]string {
|
||||
var fields [][]string
|
||||
for _, l := range strings.Split(strings.Trim(para, "\n"), "\n") {
|
||||
if (strings.HasPrefix(l, " ") || strings.HasPrefix(l, "\t")) && len(fields) > 0 {
|
||||
fields[len(fields)-1] = append(fields[len(fields)-1], l)
|
||||
continue
|
||||
}
|
||||
fields = append(fields, []string{l})
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func filterDeb822(content string) string {
|
||||
var b strings.Builder
|
||||
for _, para := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n\n") {
|
||||
var out []string
|
||||
isDeb, enabled, kept := false, true, false
|
||||
for _, field := range deb822Fields(para) {
|
||||
key, val, found := strings.Cut(field[0], ":")
|
||||
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":
|
||||
// The value runs across every continuation line. The filtered
|
||||
// result is written back as one line and the continuation
|
||||
// lines are dropped with the rest of the original field.
|
||||
all := strings.Fields(strings.Join(append([]string{v}, field[1:]...), " "))
|
||||
var sec []string
|
||||
for _, s := range all {
|
||||
if isSecuritySuite(s) {
|
||||
sec = append(sec, s)
|
||||
}
|
||||
}
|
||||
if len(sec) == 0 {
|
||||
continue // drop the field; the paragraph is dropped below
|
||||
}
|
||||
kept = true
|
||||
out = append(out, "Suites: "+strings.Join(sec, " "))
|
||||
continue
|
||||
}
|
||||
// Every other field, continuation lines included, stays verbatim.
|
||||
out = append(out, field...)
|
||||
}
|
||||
if isDeb && enabled && kept {
|
||||
b.WriteString(strings.Join(out, "\n"))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// A folded Suites field continues on lines that start with whitespace. Those
|
||||
// lines belong to Suites and must be filtered with it, not copied verbatim.
|
||||
func TestSecuritySourcesDeb822FoldedSuites(t *testing.T) {
|
||||
files := map[string]string{"/x.sources": "Types: deb\nURIs: http://a/\nSuites: noble\n noble-security\nComponents: main\n"}
|
||||
_, d822, ok := securitySources(files)
|
||||
if !ok || !strings.Contains(d822, "Suites: noble-security\n") {
|
||||
t.Fatalf("got ok=%v\n%s", ok, d822)
|
||||
}
|
||||
if strings.Contains(d822, "\n noble") {
|
||||
t.Fatalf("the folded continuation line must be dropped:\n%s", d822)
|
||||
}
|
||||
}
|
||||
|
||||
// A folded Suites field with no security suite on any line drops the paragraph.
|
||||
func TestSecuritySourcesDeb822FoldedSuitesNoSecurity(t *testing.T) {
|
||||
files := map[string]string{"/x.sources": "Types: deb\nURIs: http://a/\nSuites: noble\n\tnoble-updates\nComponents: main\n"}
|
||||
if _, _, ok := securitySources(files); ok {
|
||||
t.Fatal("no security suite across the folded lines, want ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
// An inline Signed-By key block is a multi-line field of its own. Its
|
||||
// continuation lines stay verbatim, including the "." blank-line marker.
|
||||
func TestSecuritySourcesDeb822InlineSignedBy(t *testing.T) {
|
||||
key := "Signed-By: -----BEGIN PGP PUBLIC KEY BLOCK-----\n .\n mQINBGRkZXYBEAC\n -----END PGP PUBLIC KEY BLOCK-----\n"
|
||||
files := map[string]string{"/x.sources": "Types: deb\nURIs: http://a/\nSuites: noble noble-security\nComponents: main\n" + key}
|
||||
_, d822, ok := securitySources(files)
|
||||
if !ok || !strings.Contains(d822, "Suites: noble-security\n") {
|
||||
t.Fatalf("got ok=%v\n%s", ok, d822)
|
||||
}
|
||||
if !strings.Contains(d822, key) {
|
||||
t.Fatalf("inline key block must be kept verbatim:\n%s", d822)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// canStart is the whole "may this phase begin" decision. The maintenance
|
||||
// window deadline only gates the start of a phase: a package manager that is
|
||||
// already running is never interrupted by it, because killing apt, dnf or
|
||||
// Windows Update partway through a transaction is worse than letting it
|
||||
// finish late. A zero deadline is a manual run, which always may start.
|
||||
func canStart(now, deadline time.Time) bool {
|
||||
return deadline.IsZero() || now.Before(deadline)
|
||||
}
|
||||
|
||||
// startGate returns the error reported when a phase is refused.
|
||||
func startGate(deadline time.Time, phase string) error {
|
||||
if canStart(time.Now(), deadline) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("the maintenance window ended before %s could start", phase)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCanStart(t *testing.T) {
|
||||
now := time.Date(2026, 9, 20, 2, 30, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
deadline time.Time
|
||||
want bool
|
||||
}{
|
||||
{"no deadline (manual run)", time.Time{}, true},
|
||||
{"deadline ahead", now.Add(time.Second), true},
|
||||
{"deadline exactly now", now, false},
|
||||
{"deadline passed", now.Add(-time.Minute), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := canStart(now, c.deadline); got != c.want {
|
||||
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartGate(t *testing.T) {
|
||||
if err := startGate(time.Time{}, "the upgrade"); err != nil {
|
||||
t.Fatalf("zero deadline: %v", err)
|
||||
}
|
||||
err := startGate(time.Now().Add(-time.Minute), "the upgrade")
|
||||
if err == nil || !strings.Contains(err.Error(), "the maintenance window ended before the upgrade could start") {
|
||||
t.Fatalf("passed deadline: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PackageUpdate is one pending update. On Linux it is a package with a version
|
||||
// on each side. On Windows CurrentVersion is empty and NewVersion carries the
|
||||
// KB article ID: a Windows update is not a version bump of a named package,
|
||||
@@ -14,11 +20,51 @@ type PackageUpdate struct {
|
||||
// CheckAvailable lists pending OS updates.
|
||||
func CheckAvailable() ([]PackageUpdate, error) { return checkAvailable() }
|
||||
|
||||
// ApplyAll installs every pending update. It never reboots: a control plane
|
||||
// silently restarting a production server is unrecoverable from the UI, so the
|
||||
// reboot stays a decision a person or a workflow makes. RebootRequired reports
|
||||
// when one is owed.
|
||||
func ApplyAll() error { return applyAll() }
|
||||
// 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 the end of the maintenance window. It only gates the start
|
||||
// of each phase (index refresh, upgrade, Windows install): a phase that
|
||||
// has not started by then is not started, and one already running is
|
||||
// allowed to finish, bounded by defaultApplyCap from its own start. Zero
|
||||
// means a manual run with no window.
|
||||
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")
|
||||
|
||||
// defaultApplyCap is the backstop for one started upgrade command, counted
|
||||
// from that command's own start. It exists for a package manager that hangs,
|
||||
// not to enforce the window.
|
||||
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()
|
||||
return apply(opts.SecurityOnly, opts.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() }
|
||||
|
||||
// RebootRequired reports whether this host is waiting on a restart.
|
||||
func RebootRequired() bool { return rebootRequired() }
|
||||
|
||||
@@ -4,9 +4,14 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -22,8 +27,6 @@ func detectPM() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
|
||||
func checkAvailable() ([]PackageUpdate, error) {
|
||||
switch detectPM() {
|
||||
case "apt":
|
||||
@@ -43,30 +46,160 @@ func checkAvailable() ([]PackageUpdate, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// aptRefreshTimeout bounds the index refresh alone. The upgrade itself runs
|
||||
// under defaultApplyCap: one shared five-minute limit used to kill large
|
||||
// upgrades partway through.
|
||||
const aptRefreshTimeout = 5 * time.Minute
|
||||
|
||||
func applyAll() error {
|
||||
switch detectPM() {
|
||||
case "apt":
|
||||
// termGrace is how long a command has to exit after SIGTERM before it is
|
||||
// killed. Package managers finish or roll back their current step on TERM.
|
||||
const termGrace = 5 * time.Minute
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return exec.CommandContext(ctx, "apt-get", "upgrade", "-y").Run()
|
||||
case "dnf":
|
||||
return exec.Command("dnf", "upgrade", "-y").Run()
|
||||
case "yum":
|
||||
return exec.Command("yum", "upgrade", "-y").Run()
|
||||
case "pacman":
|
||||
return exec.Command("pacman", "-Syu", "--noconfirm").Run()
|
||||
case "zypper":
|
||||
return exec.Command("zypper", "update", "-y").Run()
|
||||
case "apk":
|
||||
return exec.Command("apk", "upgrade").Run()
|
||||
default:
|
||||
return nil
|
||||
// phase is one package manager command, started only if the window deadline
|
||||
// has not passed and then bounded by its own limit from its own start.
|
||||
type phase struct {
|
||||
deadline time.Time
|
||||
out io.Writer
|
||||
env []string
|
||||
}
|
||||
|
||||
func (p phase) run(name string, limit time.Duration, args ...string) error {
|
||||
return p.runNamed("the upgrade", name, limit, args...)
|
||||
}
|
||||
|
||||
func (p phase) runNamed(label, name string, limit time.Duration, args ...string) error {
|
||||
if err := startGate(p.deadline, label); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), limit)
|
||||
defer cancel()
|
||||
fmt.Fprintf(p.out, "$ %s %s\n", name, strings.Join(args, " "))
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Stdout, cmd.Stderr = p.out, p.out
|
||||
cmd.Env = append(os.Environ(), p.env...)
|
||||
// On the backstop, ask the package manager to stop rather than killing it
|
||||
// outright, and give it time to leave its database consistent.
|
||||
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
|
||||
cmd.WaitDelay = termGrace
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func apply(securityOnly bool, deadline time.Time) (Result, error) {
|
||||
out := newTailBuffer(outputTailMax)
|
||||
ph := phase{deadline: deadline, out: out}
|
||||
|
||||
var err error
|
||||
switch pm := detectPM(); pm {
|
||||
case "apt":
|
||||
var res Result
|
||||
res, err = applyApt(deadline, 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 = ph.run(pm, defaultApplyCap, args...)
|
||||
case "zypper":
|
||||
if securityOnly {
|
||||
err = ph.run("zypper", defaultApplyCap, "--non-interactive", "patch", "--category", "security")
|
||||
} else {
|
||||
err = ph.run("zypper", defaultApplyCap, "--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 = ph.run("pacman", defaultApplyCap, "-Syu", "--noconfirm")
|
||||
case "apk":
|
||||
if securityOnly {
|
||||
return Result{Unsupported: true, Reason: "apk publishes no security metadata"}, nil
|
||||
}
|
||||
if err = ph.runNamed("the apk index refresh", "apk", aptRefreshTimeout, "update"); err == nil {
|
||||
err = ph.run("apk", defaultApplyCap, "upgrade")
|
||||
}
|
||||
default:
|
||||
return Result{Unsupported: true, Reason: "no supported package manager found"}, nil
|
||||
}
|
||||
return Result{Output: out.String()}, err
|
||||
}
|
||||
|
||||
func applyApt(deadline time.Time, out io.Writer, securityOnly bool) (Result, error) {
|
||||
ph := phase{deadline: deadline, out: out, 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",
|
||||
}
|
||||
}
|
||||
if err := ph.runNamed("the apt index refresh", "apt-get", aptRefreshTimeout, 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{}, ph.runNamed("the upgrade", "apt-get", defaultApplyCap, 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()
|
||||
}
|
||||
|
||||
// rebootRequired reads what the distributions themselves record. Debian and
|
||||
|
||||
@@ -4,6 +4,17 @@
|
||||
// without it this file compiles on Linux too and collides with updates_linux.go.
|
||||
package updates
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
func checkAvailable() ([]PackageUpdate, error) { return nil, nil }
|
||||
func applyAll() error { return nil }
|
||||
func rebootRequired() bool { return false }
|
||||
|
||||
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") }
|
||||
|
||||
func rebootRequired() bool { return false }
|
||||
|
||||
@@ -3,10 +3,11 @@ package updates
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/winexec"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -14,10 +15,6 @@ const (
|
||||
// routinely slow. Ten minutes is not generous, it is realistic.
|
||||
searchTimeout = 10 * time.Minute
|
||||
|
||||
// A patch-Tuesday cumulative genuinely takes this long to download and
|
||||
// install on a modest server.
|
||||
applyTimeout = 60 * time.Minute
|
||||
|
||||
rebootTimeout = 2 * time.Minute
|
||||
)
|
||||
|
||||
@@ -39,37 +36,6 @@ foreach ($u in $result.Updates) {
|
||||
ConvertTo-Json -InputObject @($rows) -Depth 3 -Compress
|
||||
`
|
||||
|
||||
const applyScript = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$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 (-not $u.EulaAccepted) {
|
||||
try { $u.AcceptEula() } catch { continue }
|
||||
}
|
||||
$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 logs a failure rather than an ack.
|
||||
if ($r.ResultCode -ne 2 -and $r.ResultCode -ne 3) { exit 1 }
|
||||
exit 0
|
||||
`
|
||||
|
||||
const rebootScript = `
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
$si = New-Object -ComObject Microsoft.Update.SystemInfo
|
||||
@@ -95,14 +61,29 @@ func checkAvailable() ([]PackageUpdate, error) {
|
||||
return parseUpdateSearch(out)
|
||||
}
|
||||
|
||||
func applyAll() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), applyTimeout)
|
||||
func apply(securityOnly bool, deadline time.Time) (Result, error) {
|
||||
if err := startGate(deadline, "the Windows Update install"); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
// The window deadline only gates the start. Once running, the install is
|
||||
// bounded by defaultApplyCap from its own start, with the default kill:
|
||||
// Windows has no SIGTERM to offer PowerShell.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultApplyCap)
|
||||
defer cancel()
|
||||
|
||||
if _, err := winexec.Run(ctx, applyScript); err != nil {
|
||||
return fmt.Errorf("windows update install: %w", err)
|
||||
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 nil
|
||||
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()
|
||||
}
|
||||
|
||||
func rebootRequired() bool {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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
|
||||
`
|
||||
@@ -0,0 +1,29 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package winexec runs PowerShell on Windows hosts.
|
||||
//
|
||||
// It exists because three subsystems — updates, workload collection and
|
||||
// workload logs — all need the same invocation, and because getting a
|
||||
// It exists because three subsystems - updates, workload collection and
|
||||
// workload logs - all need the same invocation, and because getting a
|
||||
// multi-line script past Go quoting, cmd.exe quoting and PowerShell's own
|
||||
// parser is a problem worth solving once.
|
||||
package winexec
|
||||
|
||||
@@ -19,12 +19,16 @@ func Run(ctx context.Context, script string) (string, error) {
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
|
||||
return "", fmt.Errorf("powershell: %s", strings.TrimSpace(string(ee.Stderr)))
|
||||
}
|
||||
// Checked before the ExitError/stderr branch: CommandContext kills the
|
||||
// process on timeout, and that kill can itself produce an ExitError
|
||||
// carrying stderr text, so a genuine timeout would otherwise surface
|
||||
// as that stderr instead of the "timed out" message callers match on.
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return "", fmt.Errorf("powershell: timed out")
|
||||
}
|
||||
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
|
||||
return "", fmt.Errorf("powershell: %s", strings.TrimSpace(string(ee.Stderr)))
|
||||
}
|
||||
return "", fmt.Errorf("powershell: %w", err)
|
||||
}
|
||||
return string(out), nil
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/winexec"
|
||||
)
|
||||
|
||||
// AgentUnit is the service this agent runs as - the NSSM service name written
|
||||
// by installer/setup.ps1. Change one, change the other.
|
||||
const AgentUnit = "VantageAgent"
|
||||
|
||||
// A Windows agent is never itself in a container; the Linux build reads
|
||||
// /proc/self/cgroup, and there is no equivalent question to ask here.
|
||||
var ownContainerID = ""
|
||||
|
||||
// Windows service names are case-insensitive, so the comparison must be too.
|
||||
func isProtectedUnit(id, name string) bool {
|
||||
return strings.EqualFold(id, AgentUnit) || strings.EqualFold(name, AgentUnit)
|
||||
}
|
||||
|
||||
func controlPlatform(ctx context.Context, kind, id, action string) error {
|
||||
switch kind {
|
||||
case "container":
|
||||
// Docker behaves identically on Windows, so this path is shared in
|
||||
// spirit with the Linux one rather than routed through PowerShell.
|
||||
cmd := exec.CommandContext(ctx, "docker", action, id)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout)
|
||||
}
|
||||
return fmt.Errorf("%s %s: %s", action, id, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
|
||||
case "unit":
|
||||
// -Force is required: Stop-Service without it refuses outright when
|
||||
// another service depends on the target, and that refusal reads to an
|
||||
// operator as a silent no-op.
|
||||
//
|
||||
// sc.exe is avoided because it returns before the operation completes,
|
||||
// which turns a timeout into a false success.
|
||||
var verb string
|
||||
switch action {
|
||||
case "start":
|
||||
verb = "Start-Service"
|
||||
case "stop":
|
||||
verb = "Stop-Service"
|
||||
case "restart":
|
||||
verb = "Restart-Service"
|
||||
default:
|
||||
return fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
|
||||
script := "$ErrorActionPreference='Stop'\n" + verb + " -Name " + psQuote(id)
|
||||
if action != "start" {
|
||||
script += " -Force"
|
||||
}
|
||||
|
||||
if _, err := winexec.Run(ctx, script); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout)
|
||||
}
|
||||
return fmt.Errorf("%s %s: %w", action, id, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown workload kind %q", kind)
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,8 @@ const dockerTimeout = 30 * time.Second
|
||||
// dockerInspect is the subset of `docker inspect` output we read.
|
||||
//
|
||||
// We use inspect rather than `docker ps --format '{{json .}}'` because ps
|
||||
// reports health and uptime inside a human Status string — "Up 2 hours
|
||||
// (healthy)" — and anything built on that is parsing English that is
|
||||
// reports health and uptime inside a human Status string - "Up 2 hours
|
||||
// (healthy)" - and anything built on that is parsing English that is
|
||||
// localised, reworded between releases, and silently different for a paused or
|
||||
// restarting container. inspect gives typed fields instead.
|
||||
type dockerInspect struct {
|
||||
@@ -59,7 +59,7 @@ type dockerInspect struct {
|
||||
}
|
||||
|
||||
// collectDocker enumerates containers. It returns ok=false with an empty error
|
||||
// string when Docker is simply not installed — the common case on this fleet,
|
||||
// string when Docker is simply not installed - the common case on this fleet,
|
||||
// and not a fault.
|
||||
func collectDocker(ctx context.Context) ([]Workload, bool, string) {
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/winexec"
|
||||
)
|
||||
|
||||
func logsPlatform(ctx context.Context, kind, id string, tail int) (string, error) {
|
||||
switch kind {
|
||||
case "container":
|
||||
cmd := exec.CommandContext(ctx, "docker", "logs",
|
||||
"--tail", strconv.Itoa(tail), "--timestamps", id)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil && len(out) == 0 {
|
||||
return "", fmt.Errorf("read logs for %s: %s", id, errText(err))
|
||||
}
|
||||
return string(out), nil
|
||||
|
||||
case "unit":
|
||||
display := serviceDisplayName(ctx, id)
|
||||
|
||||
// Timestamps are formatted PowerShell-side rather than left to
|
||||
// ConvertTo-Json, whose DateTime rendering differs between PowerShell
|
||||
// versions - one of them emits /Date(1699...)/.
|
||||
//
|
||||
// $ErrorActionPreference = 'SilentlyContinue' because Get-WinEvent
|
||||
// treats "no events matched" as a terminating error, and a quiet
|
||||
// service is normal.
|
||||
names := psQuote(id)
|
||||
if display != "" && display != id {
|
||||
names += "," + psQuote(display)
|
||||
}
|
||||
names += "," + psQuote(scmProvider)
|
||||
|
||||
// ProviderName includes the host-wide Service Control Manager, so a
|
||||
// -MaxEvents cap of exactly tail would apply to the combined stream
|
||||
// before parseEvents narrows SCM rows down to this service - on a
|
||||
// host with busy service churn the target's own events could be
|
||||
// squeezed out of the window entirely. Over-fetch instead, hard-capped
|
||||
// so a pathological host cannot pull an unbounded batch across the
|
||||
// wire, and let parseEvents trim to the last tail lines after
|
||||
// filtering.
|
||||
fetch := tail * 5
|
||||
if fetch > 2500 {
|
||||
fetch = 2500
|
||||
}
|
||||
|
||||
script := `
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
$rows = Get-WinEvent -FilterHashtable @{LogName='System','Application'; ProviderName=@(` + names + `)} ` +
|
||||
`-MaxEvents ` + strconv.Itoa(fetch) + ` |
|
||||
ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
t = $_.TimeCreated.ToUniversalTime().ToString('o')
|
||||
l = [string]$_.LevelDisplayName
|
||||
p = [string]$_.ProviderName
|
||||
m = [string]$_.Message
|
||||
}
|
||||
}
|
||||
ConvertTo-Json -InputObject @($rows) -Depth 3 -Compress
|
||||
`
|
||||
|
||||
out, err := winexec.Run(ctx, script)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read events for %s: %w", id, err)
|
||||
}
|
||||
return parseEvents(out, id, display, tail)
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unknown workload kind %q", kind)
|
||||
}
|
||||
}
|
||||
|
||||
// serviceDisplayName resolves a service's display name, which is what Service
|
||||
// Control Manager events name it by. An empty answer is fine - the filter then
|
||||
// matches on the service name alone.
|
||||
func serviceDisplayName(ctx context.Context, id string) string {
|
||||
out, err := winexec.Run(ctx,
|
||||
"$ErrorActionPreference='SilentlyContinue'\n"+
|
||||
"(Get-Service -Name "+psQuote(id)+").DisplayName")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return trimLine(out)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/winexec"
|
||||
)
|
||||
|
||||
const servicesTimeout = 60 * time.Second
|
||||
|
||||
const servicesScript = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$svcs = Get-CimInstance Win32_Service | ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
Name = $_.Name
|
||||
DisplayName = $_.DisplayName
|
||||
State = $_.State
|
||||
StartMode = $_.StartMode
|
||||
PathName = $_.PathName
|
||||
ExitCode = $_.ExitCode
|
||||
}
|
||||
}
|
||||
ConvertTo-Json -InputObject @($svcs) -Depth 3 -Compress
|
||||
`
|
||||
|
||||
// collectUnits enumerates Windows services. The bool and string it returns are
|
||||
// the same SystemdOK / SystemdError pair the Linux collector fills: the wire
|
||||
// shape is shared, and the UI words it per platform.
|
||||
func collectUnits(ctx context.Context) ([]Workload, bool, string) {
|
||||
ctx, cancel := context.WithTimeout(ctx, servicesTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := winexec.Run(ctx, servicesScript)
|
||||
if err != nil {
|
||||
return nil, false, "Win32_Service query failed: " + err.Error()
|
||||
}
|
||||
|
||||
systemRoot := os.Getenv("SystemRoot")
|
||||
if systemRoot == "" {
|
||||
systemRoot = `C:\Windows`
|
||||
}
|
||||
|
||||
wls, err := parseServices(out, systemRoot)
|
||||
if err != nil {
|
||||
return nil, false, "Win32_Service output could not be read: " + err.Error()
|
||||
}
|
||||
return wls, true, ""
|
||||
}
|
||||
@@ -15,7 +15,7 @@ const systemdTimeout = 30 * time.Second
|
||||
var excludedPrefixes = []string{"systemd-", "user@", "user-", "session-", "init.scope"}
|
||||
|
||||
// collectUnits enumerates services in two passes, because "running or
|
||||
// failed" and "enabled but stopped" are different questions — and an enabled
|
||||
// failed" and "enabled but stopped" are different questions - and an enabled
|
||||
// unit that is not running is exactly the one worth seeing.
|
||||
func collectUnits(ctx context.Context) ([]Workload, bool, string) {
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build !linux && !windows
|
||||
|
||||
// The build constraint is load-bearing — see updates_other.go.
|
||||
// The build constraint is load-bearing - see updates_other.go.
|
||||
package workloads
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// winService is one row of Get-CimInstance Win32_Service.
|
||||
//
|
||||
// Win32_Service rather than Get-Service: Get-Service exposes neither PathName
|
||||
// nor StartMode, and the filter below needs both.
|
||||
type winService struct {
|
||||
Name string `json:"Name"`
|
||||
DisplayName string `json:"DisplayName"`
|
||||
State string `json:"State"`
|
||||
StartMode string `json:"StartMode"`
|
||||
PathName string `json:"PathName"`
|
||||
ExitCode int `json:"ExitCode"`
|
||||
}
|
||||
|
||||
// exitCodeNeverStarted is ERROR_SERVICE_NEVER_STARTED. A stopped service
|
||||
// carrying it has not failed - it has not run since boot - and painting that
|
||||
// red would cry wolf on every host.
|
||||
const exitCodeNeverStarted = 1077
|
||||
|
||||
// servicePath extracts the executable from a Win32_Service PathName.
|
||||
//
|
||||
// A naive split on whitespace misfiles a substantial share of a real fleet:
|
||||
// `"C:\Program Files\X\x.exe" -service` is one path and one argument.
|
||||
func servicePath(pathName string) string {
|
||||
s := strings.TrimSpace(pathName)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if s[0] == '"' {
|
||||
if end := strings.IndexByte(s[1:], '"'); end >= 0 {
|
||||
return s[1 : 1+end]
|
||||
}
|
||||
// No closing quote: a malformed or truncated PathName. Fall back to
|
||||
// the unquoted handling below on the text after the opening quote,
|
||||
// so this yields a bare path rather than a path plus trailing
|
||||
// argument text.
|
||||
s = s[1:]
|
||||
}
|
||||
if i := exeBoundaryIndex(s); i >= 0 {
|
||||
return s[:i+len(".exe")]
|
||||
}
|
||||
if i := strings.IndexAny(s, " \t"); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// exeBoundaryIndex finds the first ".exe" (case-insensitive) in s that
|
||||
// actually ends the executable name - followed by end-of-string, whitespace,
|
||||
// or a double quote - rather than continuing into a longer segment such as
|
||||
// ".exec". It returns -1 when no such occurrence exists, so a path like
|
||||
// `C:\Program Files\Ad.exec\tool.com -flag` is not misparsed by matching the
|
||||
// ".exe" inside "Ad.exec" and silently dropping the real filename.
|
||||
func exeBoundaryIndex(s string) int {
|
||||
lower := strings.ToLower(s)
|
||||
from := 0
|
||||
for {
|
||||
rel := strings.Index(lower[from:], ".exe")
|
||||
if rel < 0 {
|
||||
return -1
|
||||
}
|
||||
idx := from + rel
|
||||
end := idx + len(".exe")
|
||||
if end == len(s) || s[end] == ' ' || s[end] == '\t' || s[end] == '"' {
|
||||
return idx
|
||||
}
|
||||
from = idx + 1
|
||||
}
|
||||
}
|
||||
|
||||
// parseServices turns the collector's JSON into workloads.
|
||||
//
|
||||
// systemRoot is a parameter rather than an environment read so this is testable
|
||||
// off Windows. The caller passes %SystemRoot%.
|
||||
//
|
||||
// The filter mirrors the systemd collector's intent: show what an operator
|
||||
// installed, and show what is meant to be up but is not. Services under
|
||||
// %SystemRoot%\System32 are the platform's own, and a typical host has well
|
||||
// over a hundred of them.
|
||||
func parseServices(jsonText, systemRoot string) ([]Workload, error) {
|
||||
s := strings.TrimSpace(jsonText)
|
||||
if s == "" || s == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var rows []winService
|
||||
if err := json.Unmarshal([]byte(s), &rows); err != nil {
|
||||
var one winService
|
||||
if err2 := json.Unmarshal([]byte(s), &one); err2 != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = []winService{one}
|
||||
}
|
||||
|
||||
sys32 := strings.ToLower(strings.TrimRight(systemRoot, `\`) + `\system32\`)
|
||||
|
||||
var wls []Workload
|
||||
for _, r := range rows {
|
||||
if p := strings.ToLower(servicePath(r.PathName)); p != "" && strings.HasPrefix(p, sys32) {
|
||||
continue
|
||||
}
|
||||
|
||||
running := strings.EqualFold(r.State, "Running")
|
||||
failed := !running && r.ExitCode != 0 && r.ExitCode != exitCodeNeverStarted
|
||||
auto := strings.HasPrefix(strings.ToLower(r.StartMode), "auto")
|
||||
if !running && !failed && !auto {
|
||||
continue
|
||||
}
|
||||
|
||||
// The wire shape is shared with the systemd collector - both report
|
||||
// under kind "unit" - so the state word has to be too, or the UI
|
||||
// (which colours and filters on it, and does so before it knows
|
||||
// which platform sent the row) needs two vocabularies for one kind.
|
||||
// running/stopped/failed become active/inactive/failed to match.
|
||||
state := "inactive"
|
||||
switch {
|
||||
case running:
|
||||
state = "active"
|
||||
case failed:
|
||||
state = "failed"
|
||||
}
|
||||
|
||||
name := r.DisplayName
|
||||
if name == "" {
|
||||
name = r.Name
|
||||
}
|
||||
|
||||
wls = append(wls, Workload{
|
||||
Kind: "unit",
|
||||
ID: r.Name,
|
||||
Name: name,
|
||||
State: state,
|
||||
})
|
||||
}
|
||||
return wls, nil
|
||||
}
|
||||
|
||||
// psQuote renders a Go string as a PowerShell single-quoted literal. Single
|
||||
// quotes suppress every form of expansion, so the only character needing an
|
||||
// escape is the quote itself, which is doubled.
|
||||
func psQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||
}
|
||||
|
||||
// scmProvider is the provider every service's start and stop is logged under,
|
||||
// host-wide.
|
||||
const scmProvider = "Service Control Manager"
|
||||
|
||||
type winEvent struct {
|
||||
T string `json:"t"`
|
||||
L string `json:"l"`
|
||||
P string `json:"p"`
|
||||
M string `json:"m"`
|
||||
}
|
||||
|
||||
// parseEvents renders Get-WinEvent output as text in the shape journalctl
|
||||
// --output=short-iso produces, so the log dialog needs no per-platform
|
||||
// rendering: "<timestamp> <level> <message>", oldest first.
|
||||
//
|
||||
// The caller over-fetches from Get-WinEvent because the ProviderName filter
|
||||
// includes the host-wide Service Control Manager, and a -MaxEvents cap
|
||||
// applied before SCM rows are narrowed down to this service would squeeze the
|
||||
// target's own events out of the window on a host with busy service churn.
|
||||
// tail is therefore applied here, AFTER filtering and AFTER the oldest-first
|
||||
// reversal, keeping the last tail lines - the most recent lines are the ones
|
||||
// worth keeping, matching capLog's front-trim reasoning in the shared
|
||||
// logs.go.
|
||||
func parseEvents(jsonText, serviceName, displayName string, tail int) (string, error) {
|
||||
s := strings.TrimSpace(jsonText)
|
||||
if s == "" || s == "null" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var rows []winEvent
|
||||
if err := json.Unmarshal([]byte(s), &rows); err != nil {
|
||||
var one winEvent
|
||||
if err2 := json.Unmarshal([]byte(s), &one); err2 != nil {
|
||||
return "", err
|
||||
}
|
||||
rows = []winEvent{one}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, e := range rows {
|
||||
if strings.EqualFold(e.P, scmProvider) {
|
||||
if !strings.Contains(e.M, serviceName) &&
|
||||
(displayName == "" || !strings.Contains(e.M, displayName)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Collapse every newline form, not just "\r\n": a message containing a
|
||||
// bare "\n" would otherwise still break the one-line-per-event shape
|
||||
// this renders for the log dialog, and undercount the tail trim above.
|
||||
msg := strings.TrimSpace(strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(e.M))
|
||||
lines = append(lines, e.T+" "+e.L+" "+msg)
|
||||
}
|
||||
|
||||
// Get-WinEvent is newest-first. Reverse it.
|
||||
for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
|
||||
lines[i], lines[j] = lines[j], lines[i]
|
||||
}
|
||||
|
||||
if tail > 0 && len(lines) > tail {
|
||||
lines = lines[len(lines)-tail:]
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
// trimLine reduces single-value PowerShell output to its first non-empty line.
|
||||
func trimLine(s string) string {
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if t := strings.TrimSpace(l); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServicePath(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{`"C:\Program Files\Contoso\svc.exe" -service`, `C:\Program Files\Contoso\svc.exe`},
|
||||
{`C:\WINDOWS\system32\svchost.exe -k netsvcs`, `C:\WINDOWS\system32\svchost.exe`},
|
||||
{`C:\Vantage\vantage-agent.exe`, `C:\Vantage\vantage-agent.exe`},
|
||||
{`"C:\no\args.exe"`, `C:\no\args.exe`},
|
||||
{``, ``},
|
||||
// ".exe" appearing inside an earlier segment ("Ad.exec") must not be
|
||||
// treated as the end of the executable - that would drop the real
|
||||
// filename and arguments.
|
||||
{`C:\Program Files\Ad.exec\tool.com -flag`, `C:\Program`},
|
||||
// An unterminated quote falls back to the unquoted handling on the
|
||||
// text after the opening quote, yielding a bare path rather than a
|
||||
// path plus trailing argument text.
|
||||
{`"C:\Program Files\Contoso\svc.exe -service`, `C:\Program Files\Contoso\svc.exe`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := servicePath(c.in); got != c.want {
|
||||
t.Errorf("servicePath(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServicesFilters(t *testing.T) {
|
||||
in := `[
|
||||
{"Name":"Contoso","DisplayName":"Contoso Broker","State":"Running","StartMode":"Auto","PathName":"\"C:\\Program Files\\Contoso\\svc.exe\" -service","ExitCode":0},
|
||||
{"Name":"Themes","DisplayName":"Themes","State":"Running","StartMode":"Auto","PathName":"C:\\WINDOWS\\system32\\svchost.exe -k netsvcs","ExitCode":0},
|
||||
{"Name":"Fabrikam","DisplayName":"Fabrikam Sync","State":"Stopped","StartMode":"Auto","PathName":"C:\\Fabrikam\\sync.exe","ExitCode":0},
|
||||
{"Name":"Northwind","DisplayName":"Northwind Poller","State":"Stopped","StartMode":"Manual","PathName":"C:\\Northwind\\poll.exe","ExitCode":0},
|
||||
{"Name":"Crashed","DisplayName":"Crashed Thing","State":"Stopped","StartMode":"Auto","PathName":"C:\\Crashed\\c.exe","ExitCode":1067}
|
||||
]`
|
||||
|
||||
got, err := parseServices(in, `C:\WINDOWS`)
|
||||
if err != nil {
|
||||
t.Fatalf("parseServices: %v", err)
|
||||
}
|
||||
|
||||
byID := map[string]Workload{}
|
||||
for _, w := range got {
|
||||
byID[w.ID] = w
|
||||
}
|
||||
|
||||
// The OS's own svchost service is dropped; a manual, stopped, never-failed
|
||||
// service is nobody's business either.
|
||||
if _, ok := byID["Themes"]; ok {
|
||||
t.Error("Themes (under %SystemRoot%) should be filtered out")
|
||||
}
|
||||
if _, ok := byID["Northwind"]; ok {
|
||||
t.Error("stopped Manual service should be filtered out")
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d workloads, want 3: %+v", len(got), got)
|
||||
}
|
||||
|
||||
if w := byID["Contoso"]; w.Kind != "unit" || w.Name != "Contoso Broker" || w.State != "active" {
|
||||
t.Errorf("Contoso = %+v", w)
|
||||
}
|
||||
// Enabled but not running is exactly the row worth seeing.
|
||||
if byID["Fabrikam"].State != "inactive" {
|
||||
t.Errorf("Fabrikam state = %q, want inactive", byID["Fabrikam"].State)
|
||||
}
|
||||
// A non-zero exit code on a stopped service is a crash, not a clean stop.
|
||||
if byID["Crashed"].State != "failed" {
|
||||
t.Errorf("Crashed state = %q, want failed", byID["Crashed"].State)
|
||||
}
|
||||
}
|
||||
|
||||
// 1077 means "no attempt to start since boot" - a clean stopped service, not a
|
||||
// failure, and reporting it red would cry wolf on every host.
|
||||
func TestParseServicesExitCode1077(t *testing.T) {
|
||||
in := `[{"Name":"Idle","DisplayName":"Idle","State":"Stopped","StartMode":"Auto","PathName":"C:\\Idle\\i.exe","ExitCode":1077}]`
|
||||
got, err := parseServices(in, `C:\WINDOWS`)
|
||||
if err != nil {
|
||||
t.Fatalf("parseServices: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].State != "inactive" {
|
||||
t.Fatalf("got %+v, want one inactive workload", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServicesSingleObjectAndEmpty(t *testing.T) {
|
||||
one := `{"Name":"Solo","DisplayName":"Solo","State":"Running","StartMode":"Auto","PathName":"C:\\Solo\\s.exe","ExitCode":0}`
|
||||
got, err := parseServices(one, `C:\WINDOWS`)
|
||||
if err != nil || len(got) != 1 || got[0].State != "active" {
|
||||
t.Fatalf("single object: got %+v, err %v", got, err)
|
||||
}
|
||||
|
||||
for _, in := range []string{"", "[]", "null"} {
|
||||
got, err := parseServices(in, `C:\WINDOWS`)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("parseServices(%q) = %+v, err %v", in, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPSQuote(t *testing.T) {
|
||||
if got := psQuote(`it's`); got != `'it''s'` {
|
||||
t.Fatalf("psQuote = %s", got)
|
||||
}
|
||||
if got := psQuote(`plain`); got != `'plain'` {
|
||||
t.Fatalf("psQuote = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEventsFormatsAndOrders(t *testing.T) {
|
||||
// Get-WinEvent returns newest first; journalctl --output=short-iso returns
|
||||
// oldest first, and the log dialog and capLog's front-trim both assume the
|
||||
// most recent line is at the bottom.
|
||||
in := `[
|
||||
{"t":"2026-08-13T10:22:31.0000000Z","l":"Error","p":"Contoso","m":"broker died"},
|
||||
{"t":"2026-08-13T10:22:03.0000000Z","l":"Information","p":"Contoso","m":"broker starting"}
|
||||
]`
|
||||
|
||||
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEvents: %v", err)
|
||||
}
|
||||
|
||||
want := "2026-08-13T10:22:03.0000000Z Information broker starting\n" +
|
||||
"2026-08-13T10:22:31.0000000Z Error broker died"
|
||||
if got != want {
|
||||
t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A message containing a bare "\n" (no carriage return) must still collapse to
|
||||
// one line, or it silently multiplies into several output lines and throws
|
||||
// off the tail trim's count.
|
||||
func TestParseEventsCollapsesBareLF(t *testing.T) {
|
||||
in := `[{"t":"2026-08-13T10:00:00Z","l":"Error","p":"Contoso","m":"broker died\nstack trace here"}]`
|
||||
|
||||
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEvents: %v", err)
|
||||
}
|
||||
if strings.Count(got, "\n") != 0 {
|
||||
t.Fatalf("parseEvents did not collapse bare LF into one line: %q", got)
|
||||
}
|
||||
want := "2026-08-13T10:00:00Z Error broker died stack trace here"
|
||||
if got != want {
|
||||
t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Service Control Manager logs every service on the host under one provider, so
|
||||
// its rows must be filtered down to the target or the log is somebody else's.
|
||||
func TestParseEventsFiltersOtherServicesSCM(t *testing.T) {
|
||||
in := `[
|
||||
{"t":"2026-08-13T10:00:00Z","l":"Information","p":"Service Control Manager","m":"The Print Spooler service entered the running state."},
|
||||
{"t":"2026-08-13T10:00:01Z","l":"Information","p":"Service Control Manager","m":"The Contoso Broker service entered the running state."}
|
||||
]`
|
||||
|
||||
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEvents: %v", err)
|
||||
}
|
||||
if strings.Contains(got, "Print Spooler") {
|
||||
t.Errorf("another service's SCM event leaked in:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "Contoso Broker") {
|
||||
t.Errorf("the target's SCM event was dropped:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A service that has logged nothing is normal. An error there would read as a
|
||||
// broken feature.
|
||||
func TestParseEventsEmpty(t *testing.T) {
|
||||
for _, in := range []string{"", "[]", "null"} {
|
||||
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
|
||||
if err != nil || got != "" {
|
||||
t.Fatalf("parseEvents(%q) = %q, err %v", in, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The over-fetch in logs_windows.go can return more events than the caller
|
||||
// asked for once SCM rows are filtered down to the target; parseEvents must
|
||||
// keep the most RECENT tail lines, not the oldest, matching capLog's
|
||||
// front-trim reasoning in the shared logs.go.
|
||||
func TestParseEventsTrimsToTailKeepingMostRecent(t *testing.T) {
|
||||
in := `[
|
||||
{"t":"2026-08-13T10:00:06Z","l":"Information","p":"Contoso","m":"event 6"},
|
||||
{"t":"2026-08-13T10:00:05Z","l":"Information","p":"Contoso","m":"event 5"},
|
||||
{"t":"2026-08-13T10:00:04Z","l":"Information","p":"Contoso","m":"event 4"},
|
||||
{"t":"2026-08-13T10:00:03Z","l":"Information","p":"Contoso","m":"event 3"},
|
||||
{"t":"2026-08-13T10:00:02Z","l":"Information","p":"Contoso","m":"event 2"},
|
||||
{"t":"2026-08-13T10:00:01Z","l":"Information","p":"Contoso","m":"event 1"}
|
||||
]`
|
||||
|
||||
got, err := parseEvents(in, "Contoso", "Contoso Broker", 2)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEvents: %v", err)
|
||||
}
|
||||
|
||||
want := "2026-08-13T10:00:05Z Information event 5\n" +
|
||||
"2026-08-13T10:00:06Z Information event 6"
|
||||
if got != want {
|
||||
t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func Collect(ctx context.Context) Result {
|
||||
//
|
||||
// It sorts first: `docker ps` output ordering is not stable, and an
|
||||
// ordering-sensitive hash would resend the full list every 60 seconds forever
|
||||
// — a cost visible only as traffic.
|
||||
// - a cost visible only as traffic.
|
||||
//
|
||||
// StartedAt is deliberately excluded: it does not change while a container
|
||||
// runs, and including it would add nothing. Restarts IS included, because a
|
||||
|
||||
Reference in New Issue
Block a user