40 Commits

Author SHA1 Message Date
steve 500602804d Merge pull request 'Release v1.2.2 — API and orphaned-job recovery' (#58) from release-v1.2.2 into main
Release / Build + push image (push) Successful in 1m53s
2026-08-22 13:47:38 +01:00
steve 377a522c53 docs(changelog): prepare v1.2.2
CI / Test (store) (pull_request) Successful in 4s
CI / Lint (pull_request) Successful in 11s
CI / Test (rest) (pull_request) Successful in 20s
CI / Build (windows/amd64) (pull_request) Successful in 8s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 8s
CI / Test (server-http) (pull_request) Successful in 1m43s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m18s
2026-08-22 13:45:37 +01:00
steve 5fb08ba489 Merge pull request 'Fix source-group API, backup validation, and orphaned jobs' (#57) from fix-issues-54-55-56 into main 2026-08-22 13:45:04 +01:00
steve 4f56b8f705 Reject backup jobs without paths
CI / Test (store) (pull_request) Successful in 5s
CI / Lint (pull_request) Successful in 12s
CI / Test (rest) (pull_request) Successful in 20s
CI / Build (windows/amd64) (pull_request) Successful in 8s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 7s
CI / Test (server-http) (pull_request) Successful in 1m36s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m26s
2026-08-22 13:42:42 +01:00
steve 25866ff102 Preserve source group hooks through the API 2026-08-22 13:42:42 +01:00
steve ec448157d9 Add recovery for orphaned jobs 2026-08-22 13:42:42 +01:00
steve 3ccefc254a Merge pull request 'Release v1.2.1 — stable Agent updates controls' (#53) from release-v1.2.1 into main
Release / Build + push image (push) Successful in 2m3s
2026-08-22 12:36:00 +01:00
steve 4ff6c7296d docs(changelog): prepare v1.2.1
CI / Test (rest) (pull_request) Successful in 8s
CI / Test (store) (pull_request) Successful in 4s
CI / Build (windows/amd64) (pull_request) Successful in 8s
CI / Lint (pull_request) Successful in 10s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 8s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m18s
CI / Test (server-http) (pull_request) Successful in 1m47s
2026-08-22 12:34:01 +01:00
steve bc5c99898c Merge pull request 'Stop polling idle Agent updates controls' (#52) from fix-agent-update-refresh into main 2026-08-22 12:33:45 +01:00
steve 80dac33f0a Stop polling idle agent update controls
CI / Test (rest) (pull_request) Successful in 22s
CI / Lint (pull_request) Successful in 11s
CI / Build (windows/amd64) (pull_request) Successful in 8s
CI / Test (store) (pull_request) Successful in 40s
CI / Build (linux/amd64) (pull_request) Successful in 7s
CI / Build (linux/arm64) (pull_request) Successful in 8s
CI / Test (server-http) (pull_request) Successful in 1m45s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m26s
2026-08-22 12:31:08 +01:00
steve facef5b254 Merge pull request 'Fix Agent updates filters and refresh state' (#51) from fix-issue-50-agent-update-filters into main 2026-08-22 12:06:44 +01:00
steve 6374201f6c Fix fleet update filters across refreshes
CI / Test (store) (pull_request) Successful in 5s
CI / Test (rest) (pull_request) Successful in 8s
CI / Build (windows/amd64) (pull_request) Successful in 7s
CI / Lint (pull_request) Successful in 11s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 7s
CI / Test (server-http) (pull_request) Successful in 1m35s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m26s
2026-08-22 12:04:26 +01:00
steve 9080826b06 Merge pull request 'Release v1.2.0 — fleet updates and reliability' (#49) from release-v1.2.0 into main
Release / Build + push image (push) Successful in 2m10s
2026-08-22 11:34:51 +01:00
steve 39a0875d58 test(alert): drain notifications before teardown
CI / Test (rest) (pull_request) Successful in 39s
CI / Lint (pull_request) Successful in 11s
CI / Test (store) (pull_request) Successful in 43s
CI / Build (windows/amd64) (pull_request) Successful in 7s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 8s
CI / Test (server-http) (pull_request) Successful in 1m34s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m27s
2026-08-22 11:31:56 +01:00
steve 320be34970 docs(changelog): prepare v1.2.0
CI / Test (store) (pull_request) Successful in 15s
CI / Lint (pull_request) Successful in 11s
CI / Test (rest) (pull_request) Failing after 33s
CI / Build (windows/amd64) (pull_request) Successful in 7s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 7s
CI / Test (server-http) (pull_request) Successful in 1m44s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m27s
2026-08-22 11:29:59 +01:00
steve 25aefe90d6 Use anonymously pullable CI images 2026-08-22 11:29:15 +01:00
steve 56d97f13af Merge pull request 'Add selectable canary-first agent updates' (#48) from fix-issue-43-agent-updates-workflow into main 2026-08-22 11:11:41 +01:00
steve 383bdb7d36 Add selectable canary-first fleet updates
CI / Test (rest) (pull_request) Successful in 39s
CI / Test (store) (pull_request) Successful in 42s
CI / Lint (pull_request) Successful in 11s
CI / Build (windows/amd64) (pull_request) Successful in 7s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 8s
CI / Test (server-http) (pull_request) Successful in 1m45s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m17s
2026-08-22 11:08:53 +01:00
steve b64f029892 Merge pull request 'Make snapshot projections fresh and self-diagnosing' (#47) from fix-issue-40-snapshot-reconciliation into main 2026-08-22 11:03:47 +01:00
steve f9718e6077 Make snapshot projections self-diagnosing
CI / Test (rest) (pull_request) Successful in 40s
CI / Test (store) (pull_request) Successful in 42s
CI / Lint (pull_request) Successful in 11s
CI / Build (windows/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 7s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Test (server-http) (pull_request) Successful in 1m32s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m26s
2026-08-22 11:00:46 +01:00
steve 8fdf4a1bdf Merge pull request 'Detect and alert on stuck jobs' (#46) from fix-issue-41-stuck-job-alerts into main 2026-08-22 10:56:31 +01:00
steve 750a06384b Close websocket handshake response in test
CI / Test (server-http) (pull_request) Successful in 5s
CI / Test (rest) (pull_request) Successful in 23s
CI / Lint (pull_request) Successful in 10s
CI / Build (windows/amd64) (pull_request) Successful in 7s
CI / Test (store) (pull_request) Successful in 41s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 6s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m24s
2026-08-22 10:53:51 +01:00
steve 2110c7dab4 Detect and alert on stuck jobs
CI / Test (rest) (pull_request) Successful in 39s
CI / Test (store) (pull_request) Successful in 40s
CI / Lint (pull_request) Failing after 11s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 7s
CI / Build (windows/amd64) (pull_request) Successful in 25s
CI / Test (server-http) (pull_request) Successful in 1m39s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m16s
2026-08-22 10:52:13 +01:00
steve 8ddd3456e1 Merge pull request 'Fix WebSocket limit for large agent messages' (#45) 2026-08-22 10:48:31 +01:00
steve 74cb7f660f fix(ws): raise bounded agent message limit
CI / Test (store) (pull_request) Successful in 6s
CI / Lint (pull_request) Failing after 20s
CI / Build (windows/amd64) (pull_request) Successful in 8s
CI / Test (rest) (pull_request) Successful in 38s
CI / Build (linux/amd64) (pull_request) Successful in 7s
CI / Build (linux/arm64) (pull_request) Successful in 8s
CI / Test (server-http) (pull_request) Successful in 1m28s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m24s
2026-08-22 10:46:00 +01:00
steve 1131f4330c Merge pull request 'Release v1.1.1 — forget reliability fixes' (#42)
Release / Build + push image (push) Successful in 2m12s
2026-08-22 10:12:53 +01:00
steve 337472a819 docs(changelog): prepare v1.1.1
CI / Test (store) (pull_request) Successful in 5s
CI / Test (rest) (pull_request) Successful in 8s
CI / Build (windows/amd64) (pull_request) Successful in 8s
CI / Lint (pull_request) Successful in 11s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 25s
CI / Test (server-http) (pull_request) Successful in 1m30s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m37s
2026-08-22 10:10:12 +01:00
steve b315932cc8 chore(web): normalize generated stylesheet 2026-08-22 10:09:42 +01:00
steve f6fa84d7d8 docs: expand issue contribution guidance 2026-08-22 10:09:42 +01:00
steve 904c522a23 Merge pull request 'Fix manual forget payload and dry-run execution' (#39) 2026-08-22 09:56:12 +01:00
steve 31f53d65a7 fix(forget): populate retention groups for manual runs
CI / Test (store) (pull_request) Successful in 5s
CI / Lint (pull_request) Successful in 10s
CI / Build (windows/amd64) (pull_request) Successful in 7s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 7s
CI / Test (rest) (pull_request) Successful in 39s
CI / Test (server-http) (pull_request) Successful in 1m37s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m16s
2026-08-22 09:53:31 +01:00
steve 528bdef433 Merge pull request 'Fix agent panic on WebSocket disconnect' (#38) 2026-08-22 09:49:37 +01:00
steve dfe082629f chore(lint): document websocket response ownership
CI / Test (rest) (pull_request) Successful in 38s
CI / Lint (pull_request) Successful in 7s
CI / Test (store) (pull_request) Successful in 39s
CI / Build (windows/amd64) (pull_request) Successful in 22s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 7s
CI / Test (server-http) (pull_request) Successful in 1m38s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m22s
2026-08-22 09:46:50 +01:00
steve 39aff83837 fix(agent): avoid panic on websocket disconnect
CI / Test (server-http) (pull_request) Successful in 5s
CI / Test (rest) (pull_request) Successful in 7s
CI / Test (store) (pull_request) Successful in 5s
CI / Build (windows/amd64) (pull_request) Successful in 7s
CI / Lint (pull_request) Failing after 10s
CI / Build (linux/arm64) (pull_request) Successful in 8s
CI / Build (linux/amd64) (pull_request) Successful in 24s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m36s
2026-08-22 09:45:20 +01:00
steve 27be28ee9c Merge pull request 'docs: correct admin-credentials help text (fixes #34)' (#35) from docs/admin-creds-help-text into main 2026-08-21 22:39:04 +01:00
Steve Cliff a8a6fdfab5 docs: address review — make S3/B2/SFTP/local guidance actionable
CI / Test (store) (pull_request) Successful in 37s
CI / Test (rest) (pull_request) Successful in 48s
CI / Build (windows/amd64) (pull_request) Successful in 8s
CI / Lint (pull_request) Successful in 20s
CI / Build (linux/arm64) (pull_request) Successful in 24s
CI / Build (linux/amd64) (pull_request) Successful in 26s
CI / Test (server-http) (pull_request) Successful in 1m37s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m16s
The previous wording told operators to leave the slot blank and then
warned that doing so disables prune, which is contradictory. Prune is
gated on the admin slot for every backend, on both paths:
scheduled (maintenance_dispatch.go:52) skips silently, manual
(repo_ops.go:39) returns 400 admin_creds_required.

Also reworded the opening line, which still said 'only needed for
rest-server repos' while the new text asks other backends to fill it in.
2026-08-21 22:36:14 +01:00
Steve Cliff e9df802478 docs: correct admin-credentials help text
CI / Test (rest) (pull_request) Successful in 1m42s
CI / Test (store) (pull_request) Successful in 1m45s
CI / Lint (pull_request) Successful in 28s
CI / Build (windows/amd64) (pull_request) Successful in 29s
CI / Test (server-http) (pull_request) Successful in 2m27s
CI / Build (linux/amd64) (pull_request) Successful in 30s
CI / Build (linux/arm64) (pull_request) Successful in 26s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m30s
Forget does not use admin credentials. Only JobPrune builds a runner
against the admin slot (cmd/agent/main.go:622-645); JobForget runs on
the everyday runner, as the comment at main.go:517-524 states.

Also corrects the claim that leaving this blank lets everyday creds
handle prune: maintenance_dispatch.go:52-56 skips prune entirely with
'prune skipped — no admin creds' on any backend, with no fallback.

Refs #34
2026-08-21 21:01:39 +01:00
steve 6c6b962e24 Merge pull request 'De-flake TestDrainPendingSerializesPerHost (CI stability)' (#33) from fix-flaky-server-http-tests into main
Reviewed-on: #33
2026-06-16 15:44:47 +01:00
steve e64075d5d7 test(pending-drain): de-flake TestDrainPendingSerializesPerHost
CI / Test (store) (pull_request) Successful in 8s
CI / Test (rest) (pull_request) Successful in 12s
CI / Build (windows/amd64) (pull_request) Successful in 15s
CI / Lint (pull_request) Successful in 19s
CI / Build (linux/amd64) (pull_request) Successful in 12s
CI / Build (linux/arm64) (pull_request) Successful in 44s
CI / Test (server-http) (pull_request) Successful in 2m55s
e2e / Playwright vs docker-compose (pull_request) Successful in 2m45s
Keep the test WS client actively reading (a real agent always is) so
the server-side conn stays registered under parallel load, and drain to
completion via condition polling instead of asserting one-shot
completeness. The conn could be dropped/unregistered under CI load,
making DrainPending correctly no-op (conn==nil) and the test observe a
partial/empty drain. -race confirms no production data race; the
exactly-5-jobs assertion (proving the per-host mutex blocks
double-dispatch) is unchanged. Verified: 0 failures over 25 loaded runs
+ 4 -race iterations.
2026-06-16 13:29:47 +01:00
steve 0f5110f3d9 Merge pull request 'Release v1.1.0 — CHANGELOG' (#32) from release-v1.1.0 into main
Release / Build + push image (push) Successful in 3m39s
2026-06-16 07:32:00 +01:00
56 changed files with 1910 additions and 176 deletions
+6 -15
View File
@@ -3,7 +3,7 @@
# Notes for anyone editing this file: # Notes for anyone editing this file:
# #
# Custom runner image # Custom runner image
# Every job runs inside `gitea.dcglab.co.uk/steve/ci-runner-go` # Every job runs inside `docker.dcglab.co.uk/public/ci-runner-go:latest`
# (recipe: https://gitea.dcglab.co.uk/steve/ci/src/branch/main/images/ci-runner-go). # (recipe: https://gitea.dcglab.co.uk/steve/ci/src/branch/main/images/ci-runner-go).
# That image already ships: # That image already ships:
# * Go on PATH at /usr/local/go/bin (so `actions/setup-go` is # * Go on PATH at /usr/local/go/bin (so `actions/setup-go` is
@@ -11,8 +11,8 @@
# otherwise re-download Go on every job) # otherwise re-download Go on every job)
# * Node.js + npm (used by docs / e2e workflows) # * Node.js + npm (used by docs / e2e workflows)
# * Docker CLI, Buildx, Compose v2 (used by docker-build steps) # * Docker CLI, Buildx, Compose v2 (used by docker-build steps)
# When bumping the Go floor, push a new ci-runner-go image with # The runner image is intentionally tracked via `latest`: it is shared CI
# the matching Go version and bump the date pin in IMAGE below. # infrastructure, and updates should propagate to consuming repositories.
# #
# Self-hosted runner expectations # Self-hosted runner expectations
# Each runner host bind-mounts persistent volumes for # Each runner host bind-mounts persistent volumes for
@@ -71,10 +71,7 @@ jobs:
name: Test (${{ matrix.name }}) name: Test (${{ matrix.name }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker.dcglab.co.uk/ci-runner-go:2026-05-15 image: docker.dcglab.co.uk/public/ci-runner-go:latest
credentials:
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -110,10 +107,7 @@ jobs:
name: Lint name: Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker.dcglab.co.uk/ci-runner-go:2026-05-15 image: docker.dcglab.co.uk/public/ci-runner-go:latest
credentials:
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: golangci/golangci-lint-action@v7 - uses: golangci/golangci-lint-action@v7
@@ -130,10 +124,7 @@ jobs:
name: Build (${{ matrix.goos }}/${{ matrix.goarch }}) name: Build (${{ matrix.goos }}/${{ matrix.goarch }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker.dcglab.co.uk/ci-runner-go:2026-05-15 image: docker.dcglab.co.uk/public/ci-runner-go:latest
credentials:
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
e2e: e2e:
name: Playwright vs docker-compose name: Playwright vs docker-compose
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: gitea.dcglab.co.uk/steve/ci-runner-go:2026-05-08 container: docker.dcglab.co.uk/public/ci-runner-go:latest
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+5 -6
View File
@@ -12,7 +12,9 @@
# plus install.sh / install.ps1 / the systemd unit baked in under # plus install.sh / install.ps1 / the systemd unit baked in under
# /opt/restic-manager/dist (the read-only fallback path the server # /opt/restic-manager/dist (the read-only fallback path the server
# handlers use when <DataDir>/... is empty). # handlers use when <DataDir>/... is empty).
# * Pushes to zot OCI registry (docker.dcglab.co.uk). # * Pushes to the public namespace in the zot OCI registry
# (docker.dcglab.co.uk/public/restic-manager). Pulls are anonymous;
# pushes remain authenticated.
# #
# Tag fan-out # Tag fan-out
# * tag push: :vX.Y.Z, :X.Y, :X # * tag push: :vX.Y.Z, :X.Y, :X
@@ -29,7 +31,7 @@ on:
env: env:
REGISTRY: docker.dcglab.co.uk REGISTRY: docker.dcglab.co.uk
IMAGE_NAME: restic-manager IMAGE_NAME: public/restic-manager
# Force bash as the default shell — see ci.yml header. # Force bash as the default shell — see ci.yml header.
defaults: defaults:
@@ -41,10 +43,7 @@ jobs:
name: Build + push image name: Build + push image
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker.dcglab.co.uk/ci-runner-go:2026-05-15 image: docker.dcglab.co.uk/public/ci-runner-go:latest
credentials:
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+95 -1
View File
@@ -6,6 +6,83 @@ and the project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
## [1.2.2] - 2026-08-22
### Added
- Added audited single-job and bulk recovery controls for jobs that are no
longer running on their agents but remain stuck server-side. Agents now
acknowledge unknown cancellation targets so orphaned jobs become terminal,
while host ownership checks prevent cross-host updates. ([#56])
### Fixed
- Source-group JSON create and update requests now accept encrypted pre- and
post-hooks, preserve hooks when their fields are omitted, allow explicit
clearing, and expose only boolean presence indicators. ([#54])
- Generic backup-job requests without paths are rejected before a broken job
is created. Configured run-now backups use the source-group endpoint. ([#55])
## [1.2.1] - 2026-08-22
### Fixed
- Made the Agent updates host-selection screen stable and responsive: removed
the redundant typed-count confirmation, corrected name/version/eligibility
filtering, and stopped replacing idle or terminal controls through polling.
Progress polling now runs only while a rollout is actively running. ([#50])
## [1.2.0] - 2026-08-22
### Added
- Fleet-wide agent updates are now discoverable from Settings and the
dashboard, with arbitrary host subsets, name/tag/version/eligibility
filtering, explicit exclusion reasons, server-side membership validation,
and a canary-first pause after the first verified reconnect. Halted rolls
expose retry and resume actions. ([#43])
- Running jobs with no recent persisted activity are detected server-side and
surfaced through deduplicated alerts. Kind-aware thresholds protect normal
long-running work, terminal jobs self-resolve, and Prometheus exports stuck
job count and oldest inactivity age. ([#41])
- Snapshot projections now retain host-level refresh timestamps—including
authoritative empty reports—expose a derived stale flag, refresh after
backup, forget, and prune, and support an explicit operator reconciliation
endpoint. ([#40])
### Fixed
- Raised the bounded WebSocket read limit on both peers so ordinary large
snapshot reports and restic events no longer disconnect otherwise healthy
agents. Regression coverage exercises payloads beyond the library's former
32 KiB default in both directions. ([#44])
- Fleet-update timeout verification now performs a final authoritative read of
the agent version delivered by the reconnect `hello`, avoiding a poll/deadline
race and reporting the last observed version when verification fails. ([#43])
### Changed
- CI runner images and release images use the anonymous-pull `public`
namespace at `docker.dcglab.co.uk`; registry authentication remains required
only for publishing.
## [1.1.1] - 2026-08-22
### Fixed
- Prevented agents from panicking when a WebSocket connection ends. The
WebSocket library transfers ownership of a successful upgrade stream to
the connection and leaves the HTTP response body nil; attempting to close
that body caused agents to restart and left forget jobs permanently stuck
in `running`. ([#37])
- Manual forget jobs now receive the same per-source-group retention policies
as scheduled forget jobs. The API also supports a validated `--dry-run`
option through the complete server-to-agent-to-restic path, and rejects
hosts without configured retention before creating a job. ([#36])
- Corrected the admin-credentials help text to reflect that forget uses normal
append-only credentials and blank admin credentials do not provide a
fallback for prune. ([#34])
## [1.1.0] - 2026-06-15 ## [1.1.0] - 2026-06-15
### Added ### Added
@@ -123,5 +200,22 @@ with a web UI, JSON API, and self-updating agent fleet.
go vet, golangci-lint). go vet, golangci-lint).
- Threat model published (`docs/threat-model.md`). - Threat model published (`docs/threat-model.md`).
[Unreleased]: https://gitea.dcglab.co.uk/steve/restic-manager/compare/v1.0.0...HEAD [Unreleased]: https://gitea.dcglab.co.uk/steve/restic-manager/compare/v1.2.2...HEAD
[1.2.2]: https://gitea.dcglab.co.uk/steve/restic-manager/compare/v1.2.1...v1.2.2
[1.2.1]: https://gitea.dcglab.co.uk/steve/restic-manager/compare/v1.2.0...v1.2.1
[1.2.0]: https://gitea.dcglab.co.uk/steve/restic-manager/compare/v1.1.1...v1.2.0
[1.1.1]: https://gitea.dcglab.co.uk/steve/restic-manager/compare/v1.1.0...v1.1.1
[1.1.0]: https://gitea.dcglab.co.uk/steve/restic-manager/releases/tag/v1.1.0
[1.0.0]: https://gitea.dcglab.co.uk/steve/restic-manager/releases/tag/v1.0.0 [1.0.0]: https://gitea.dcglab.co.uk/steve/restic-manager/releases/tag/v1.0.0
[#37]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/37
[#36]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/36
[#34]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/34
[#40]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/40
[#41]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/41
[#43]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/43
[#44]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/44
[#50]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/50
[#54]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/54
[#55]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/55
[#56]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/56
+37 -11
View File
@@ -2,7 +2,8 @@
Thanks for your interest in restic-manager. This document covers how Thanks for your interest in restic-manager. This document covers how
to set up a development environment, the conventions the project to set up a development environment, the conventions the project
follows, and how patches make it from your machine into `main`. follows, and how to contribute through issues as well as patches that
make it from your machine into `main`.
## Project status and scope ## Project status and scope
@@ -108,6 +109,32 @@ admin user.
## Workflow ## Workflow
### Opening an issue
Issues are contributions too. Use them to report a bug, suggest a
feature, improve the documentation, or start a design discussion even
if you do not plan to submit a patch.
Before opening one, search the existing issues and check `tasks.md` to
see whether the topic is already tracked. Then choose the closest issue
template:
- [Bug report](./.gitea/issue_template/bug_report.md) for behaviour that
does not match the documentation or expected operation.
- [Feature request](./.gitea/issue_template/feature_request.md) for a new
capability or a change to existing behaviour.
Give the issue a specific title, keep it to one problem or proposal,
and complete the relevant template fields. If no template is an exact
fit, open a regular issue and explain the context, desired outcome, and
any alternatives you have considered. Maintainers may ask follow-up
questions or close requests that duplicate existing work or fall
outside the project's scope.
Security-sensitive reports are the exception: follow the
[SECURITY.md](./SECURITY.md) disclosure process and do not open a public
issue.
### Before opening a PR ### Before opening a PR
1. **Open an issue first** for non-trivial changes. The design is 1. **Open an issue first** for non-trivial changes. The design is
@@ -136,25 +163,24 @@ The PR template asks for:
### Reporting bugs ### Reporting bugs
Open an issue with: Use the bug report issue template and include:
- restic-manager version (`server --version`) and agent version. - restic-manager version (`server --version`) and agent version.
- restic version on the affected host. - restic version on the affected host.
- Steps to reproduce. - Steps to reproduce.
- Server and agent logs (sanitise any tokens before pasting). - Server and agent logs (sanitise any tokens before pasting).
Security-sensitive bugs go through the [SECURITY.md](./SECURITY.md) For security-sensitive bugs, use the private disclosure process noted
disclosure path instead — please don't open a public issue for above.
them.
### Suggesting features ### Suggesting features
Open an issue describing the use case (not just the proposed Use the feature request issue template and describe the use case (not
solution). The roadmap in `tasks.md` shows where the project is just the proposed solution). The roadmap in `tasks.md` shows where the
heading; if the suggestion fits a future phase we'll wire it in project is heading; if the suggestion fits a future phase we'll wire it
there. If it falls outside the project's scope (multi-tenancy, SaaS, in there. If it falls outside the project's scope (multi-tenancy, SaaS,
non-restic backends — see `spec.md` §2 non-goals) we'll say so non-restic backends — see `spec.md` §2 non-goals) we'll say so early to
early to save your time. save your time.
## Code of conduct ## Code of conduct
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"context"
"testing"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
)
type recordingSender struct {
envelopes []api.Envelope
}
func (s *recordingSender) Send(env api.Envelope) error {
s.envelopes = append(s.envelopes, env)
return nil
}
func TestCancelCommandReportsUnknownJob(t *testing.T) {
d := &dispatcher{}
tx := &recordingSender{}
env, err := api.Marshal(api.MsgCommandCancel, "request-1", api.CommandCancelPayload{JobID: "missing-job"})
if err != nil {
t.Fatalf("marshal command: %v", err)
}
if err := d.handle(context.Background(), env, tx); err != nil {
t.Fatalf("handle cancel: %v", err)
}
if len(tx.envelopes) != 1 {
t.Fatalf("sent %d envelopes, want 1", len(tx.envelopes))
}
if tx.envelopes[0].Type != api.MsgCommandResult || tx.envelopes[0].ID != env.ID {
t.Fatalf("unexpected result envelope: %+v", tx.envelopes[0])
}
var result api.CommandResultPayload
if err := tx.envelopes[0].UnmarshalPayload(&result); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if result.JobID != "missing-job" || result.Accepted || result.Error != "job_not_found" {
t.Fatalf("unexpected result: %+v", result)
}
}
func TestCancelCommandCancelsTrackedJob(t *testing.T) {
d := &dispatcher{}
cancelled := false
d.trackJob("running-job", func() { cancelled = true })
tx := &recordingSender{}
env, err := api.Marshal(api.MsgCommandCancel, "request-2", api.CommandCancelPayload{JobID: "running-job"})
if err != nil {
t.Fatalf("marshal command: %v", err)
}
if err := d.handle(context.Background(), env, tx); err != nil {
t.Fatalf("handle cancel: %v", err)
}
if !cancelled {
t.Fatal("tracked job was not cancelled")
}
var result api.CommandResultPayload
if err := tx.envelopes[0].UnmarshalPayload(&result); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if !result.Accepted || result.Error != "" {
t.Fatalf("unexpected result: %+v", result)
}
}
+39 -3
View File
@@ -276,7 +276,8 @@ func (d *dispatcher) handle(ctx context.Context, env api.Envelope, tx wsclient.S
if err := env.UnmarshalPayload(&p); err != nil { if err := env.UnmarshalPayload(&p); err != nil {
return fmt.Errorf("command.cancel: %w", err) return fmt.Errorf("command.cancel: %w", err)
} }
if d.cancelJob(p.JobID) { known := d.cancelJob(p.JobID)
if known {
slog.Info("ws agent: command.cancel applied", "job_id", p.JobID) slog.Info("ws agent: command.cancel applied", "job_id", p.JobID)
} else { } else {
// Job already finished or was never seen on this agent. // Job already finished or was never seen on this agent.
@@ -284,6 +285,17 @@ func (d *dispatcher) handle(ctx context.Context, env api.Envelope, tx wsclient.S
// natural completion. Server-side state is authoritative. // natural completion. Server-side state is authoritative.
slog.Info("ws agent: command.cancel for unknown job (already finished?)", "job_id", p.JobID) slog.Info("ws agent: command.cancel for unknown job (already finished?)", "job_id", p.JobID)
} }
result := api.CommandResultPayload{JobID: p.JobID, Accepted: known}
if !known {
result.Error = "job_not_found"
}
ack, err := api.Marshal(api.MsgCommandResult, env.ID, result)
if err != nil {
return fmt.Errorf("command.cancel result: %w", err)
}
if err := tx.Send(ack); err != nil {
return fmt.Errorf("command.cancel result send: %w", err)
}
case api.MsgTreeList: case api.MsgTreeList:
// Synchronous RPC for the restore wizard's tree browser. The // Synchronous RPC for the restore wizard's tree browser. The
@@ -296,6 +308,9 @@ func (d *dispatcher) handle(ctx context.Context, env api.Envelope, tx wsclient.S
} }
go d.handleTreeList(ctx, env.ID, p, tx) go d.handleTreeList(ctx, env.ID, p, tx)
case api.MsgSnapshotsRefresh:
go d.refreshSnapshots(ctx, tx)
case api.MsgScheduleSet: case api.MsgScheduleSet:
var p api.ScheduleSetPayload var p api.ScheduleSetPayload
if err := env.UnmarshalPayload(&p); err != nil { if err := env.UnmarshalPayload(&p); err != nil {
@@ -405,6 +420,22 @@ func (d *dispatcher) handle(ctx context.Context, env api.Envelope, tx wsclient.S
return nil return nil
} }
func (d *dispatcher) refreshSnapshots(ctx context.Context, tx wsclient.Sender) {
creds, err := d.secrets.Load()
if err != nil || creds.Empty() {
slog.Warn("ws agent: snapshots.refresh unavailable", "err", err)
return
}
r := runner.New(runner.Config{
ResticBin: d.resticBin, ResticVersion: d.resticVer,
RepoURL: creds.URL, RepoUsername: creds.Username, RepoPassword: creds.Password,
SupportsRestoreNoOwnership: d.resticSupportsNoOwnership,
}, tx, time.Second)
if err := r.RefreshSnapshots(ctx); err != nil {
slog.Warn("ws agent: snapshots.refresh failed", "err", err)
}
}
// handleTreeList runs `restic ls --json <snapshot> <path>` and ships // handleTreeList runs `restic ls --json <snapshot> <path>` and ships
// the matching tree.list.result envelope back, correlated by the // the matching tree.list.result envelope back, correlated by the
// request envelope's ID. Errors (missing creds, restic failure) // request envelope's ID. Errors (missing creds, restic failure)
@@ -601,6 +632,11 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc
failJob(p, tx, "forget: command.run carried no forget_groups (server didn't populate them)") failJob(p, tx, "forget: command.run carried no forget_groups (server didn't populate them)")
return fmt.Errorf("forget: command.run carried no forget_groups (server didn't populate them)") return fmt.Errorf("forget: command.run carried no forget_groups (server didn't populate them)")
} }
if len(p.Args) > 1 || (len(p.Args) == 1 && p.Args[0] != "--dry-run") {
failJob(p, tx, "forget: command.run carried unsupported arguments")
return fmt.Errorf("forget: command.run carried unsupported arguments")
}
dryRun := len(p.Args) == 1
groups := make([]restic.ForgetGroup, 0, len(p.ForgetGroups)) groups := make([]restic.ForgetGroup, 0, len(p.ForgetGroups))
for _, g := range p.ForgetGroups { for _, g := range p.ForgetGroups {
groups = append(groups, restic.ForgetGroup{ groups = append(groups, restic.ForgetGroup{
@@ -615,9 +651,9 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc
}, },
}) })
} }
slog.Info("agent: accepting forget job", "job_id", p.JobID, "groups", len(groups)) slog.Info("agent: accepting forget job", "job_id", p.JobID, "groups", len(groups), "dry_run", dryRun)
spawn("forget", func(jobCtx context.Context) error { spawn("forget", func(jobCtx context.Context) error {
return r.RunForget(jobCtx, p.JobID, groups) return r.RunForget(jobCtx, p.JobID, groups, dryRun)
}) })
case api.JobPrune: case api.JobPrune:
// Prune may require admin creds (delete authority on rest-server). // Prune may require admin creds (delete authority on rest-server).
@@ -6,11 +6,12 @@ Three ways to trigger one:
1. **Scheduled** — the agent's local cron fires at the time set 1. **Scheduled** — the agent's local cron fires at the time set
on the schedule. on the schedule.
2. **Run-now** — operator clicks **Run now** on the host detail 2. **Run-now** — operator clicks **Run now** for a specific source
right rail. Posts to `/hosts/{id}/run-backup` (defaults to all group. This uses `POST /hosts/{id}/source-groups/{gid}/run`.
source groups) or to a per-group form for finer control. 3. **API** — use `POST /api/hosts/{id}/source-groups/{gid}/run` for a
3. **API** `POST /api/hosts/{id}/jobs` with the appropriate configured source group. The lower-level `POST /api/hosts/{id}/jobs`
payload. Same audit + dispatch path. backup form requires explicit paths in `args`; an empty backup is
rejected instead of dispatching a job that restic cannot run.
In every case the server creates a `jobs` row, broadcasts a In every case the server creates a `jobs` row, broadcasts a
`command.run` to the host, and lands the operator on the live `command.run` to the host, and lands the operator on the live
+15 -11
View File
@@ -77,6 +77,12 @@ func (r *Runner) resticEnv() restic.Env {
} }
} }
// RefreshSnapshots reconciles the server's cached projection without running
// a mutating repository job.
func (r *Runner) RefreshSnapshots(ctx context.Context) error {
return r.reportSnapshots(ctx, r.resticEnv())
}
// sendStarted ships a job.started envelope. // sendStarted ships a job.started envelope.
func (r *Runner) sendStarted(jobID string, kind api.JobKind, startedAt time.Time) { func (r *Runner) sendStarted(jobID string, kind api.JobKind, startedAt time.Time) {
env, _ := api.Marshal(api.MsgJobStarted, jobID, api.JobStartedPayload{ env, _ := api.Marshal(api.MsgJobStarted, jobID, api.JobStartedPayload{
@@ -226,14 +232,9 @@ func (r *Runner) RunBackup(ctx context.Context, jobID string, paths, excludes, t
} }
} }
r.sendFinished(ctx, jobID, finishedAt, err, statsBlob)
// On a successful backup, refresh the server's snapshot projection. // On a successful backup, refresh the server's snapshot projection.
// We do this *after* job.finished so the UI sees the job land first; // Do this before job.finished so a failure in terminal reporting cannot
// the snapshot list is a follow-up that the host detail page polls // prevent the independently useful projection refresh.
// or the dashboard sees on its next refresh. A failure here is
// logged but doesn't fail the job — the next successful backup will
// catch the projection up.
if err == nil { if err == nil {
if rerr := r.reportSnapshots(ctx, env); rerr != nil { if rerr := r.reportSnapshots(ctx, env); rerr != nil {
slog.Warn("runner: snapshots.report failed", "job_id", jobID, "err", rerr) slog.Warn("runner: snapshots.report failed", "job_id", jobID, "err", rerr)
@@ -242,6 +243,7 @@ func (r *Runner) RunBackup(ctx context.Context, jobID string, paths, excludes, t
slog.Warn("runner: stats.report after backup failed", "job_id", jobID, "err", rerr) slog.Warn("runner: stats.report after backup failed", "job_id", jobID, "err", rerr)
} }
} }
r.sendFinished(ctx, jobID, finishedAt, err, statsBlob)
if err != nil { if err != nil {
return fmt.Errorf("runner backup: %w", err) return fmt.Errorf("runner backup: %w", err)
@@ -274,16 +276,14 @@ func (r *Runner) RunInit(ctx context.Context, jobID string) error {
// snapshot projection (forget rewrites the snapshot index — the // snapshot projection (forget rewrites the snapshot index — the
// host's snapshot list shrinks). Snapshot refresh runs once after // host's snapshot list shrinks). Snapshot refresh runs once after
// every group completes, not per-group. // every group completes, not per-group.
func (r *Runner) RunForget(ctx context.Context, jobID string, groups []restic.ForgetGroup) error { func (r *Runner) RunForget(ctx context.Context, jobID string, groups []restic.ForgetGroup, dryRun bool) error {
startedAt := time.Now().UTC() startedAt := time.Now().UTC()
r.sendStarted(jobID, api.JobForget, startedAt) r.sendStarted(jobID, api.JobForget, startedAt)
env := r.resticEnv() env := r.resticEnv()
var seq atomic.Int64 var seq atomic.Int64
err := env.RunForget(ctx, groups, r.streamHandler(jobID, &seq)) err := env.RunForget(ctx, groups, dryRun, r.streamHandler(jobID, &seq))
finishedAt := time.Now().UTC() finishedAt := time.Now().UTC()
r.sendFinished(ctx, jobID, finishedAt, err, nil)
// Refresh the server's snapshot projection — forget rewrites the // Refresh the server's snapshot projection — forget rewrites the
// index so the host's snapshot list almost certainly shrunk. // index so the host's snapshot list almost certainly shrunk.
if err == nil { if err == nil {
@@ -292,6 +292,7 @@ func (r *Runner) RunForget(ctx context.Context, jobID string, groups []restic.Fo
"job_id", jobID, "err", rerr) "job_id", jobID, "err", rerr)
} }
} }
r.sendFinished(ctx, jobID, finishedAt, err, nil)
if err != nil { if err != nil {
return fmt.Errorf("runner forget: %w", err) return fmt.Errorf("runner forget: %w", err)
@@ -318,6 +319,9 @@ func (r *Runner) RunPrune(ctx context.Context, jobID string) error {
if rerr := r.reportStats(ctx, env, api.RepoStatsPayload{LastPruneAt: &pruneAt}); rerr != nil { if rerr := r.reportStats(ctx, env, api.RepoStatsPayload{LastPruneAt: &pruneAt}); rerr != nil {
slog.Warn("runner: stats.report after prune failed", "job_id", jobID, "err", rerr) slog.Warn("runner: stats.report after prune failed", "job_id", jobID, "err", rerr)
} }
if rerr := r.reportSnapshots(ctx, env); rerr != nil {
slog.Warn("runner: snapshots.report after prune failed", "job_id", jobID, "err", rerr)
}
} }
r.sendFinished(ctx, jobID, finishedAt, err, nil) r.sendFinished(ctx, jobID, finishedAt, err, nil)
+19 -4
View File
@@ -116,7 +116,8 @@ func envelopeOrder(envs []api.Envelope) []api.MessageType {
// TestRunPruneShipsExpectedEnvelopes drives RunPrune with a fake // TestRunPruneShipsExpectedEnvelopes drives RunPrune with a fake
// binary that prints "prune" on stdout (for the log.stream envelope) // binary that prints "prune" on stdout (for the log.stream envelope)
// and emits valid stats JSON so reportStats can populate size fields. // and emits valid stats JSON so reportStats can populate size fields.
// Expected sequence: job.started → log.stream → repo.stats → job.finished. // Expected sequence: job.started → log.stream → repo.stats → snapshots.report
// → job.finished.
func TestRunPruneShipsExpectedEnvelopes(t *testing.T) { func TestRunPruneShipsExpectedEnvelopes(t *testing.T) {
t.Parallel() t.Parallel()
@@ -126,6 +127,7 @@ func TestRunPruneShipsExpectedEnvelopes(t *testing.T) {
case "$1" in case "$1" in
prune) echo "prune" ;; prune) echo "prune" ;;
stats) echo '`+statsJSON+`' ;; stats) echo '`+statsJSON+`' ;;
snapshots) echo "[]" ;;
*) echo "unknown: $*" ;; *) echo "unknown: $*" ;;
esac esac
`) `)
@@ -138,7 +140,7 @@ esac
order := envelopeOrder(tx.envs) order := envelopeOrder(tx.envs)
// Confirm landmark envelope types appear in the required order. // Confirm landmark envelope types appear in the required order.
wantTypes := []api.MessageType{api.MsgJobStarted, api.MsgLogStream, api.MsgRepoStats, api.MsgJobFinished} wantTypes := []api.MessageType{api.MsgJobStarted, api.MsgLogStream, api.MsgRepoStats, api.MsgSnapshotsRpt, api.MsgJobFinished}
positions := map[api.MessageType]int{} positions := map[api.MessageType]int{}
for i, mt := range order { for i, mt := range order {
if _, seen := positions[mt]; !seen { if _, seen := positions[mt]; !seen {
@@ -379,6 +381,15 @@ func TestRunInitShipsStartedAndFinished(t *testing.T) {
_ = firstEnvOfType(t, tx.envs, api.MsgJobFinished) _ = firstEnvOfType(t, tx.envs, api.MsgJobFinished)
} }
func firstIndexOfType(envs []api.Envelope, typ api.MessageType) int {
for i, env := range envs {
if env.Type == typ {
return i
}
}
return -1
}
// TestRunForgetShipsStartedAndFinished confirms the refactored // TestRunForgetShipsStartedAndFinished confirms the refactored
// RunForget still produces job.started and job.finished envelopes. // RunForget still produces job.started and job.finished envelopes.
func TestRunForgetShipsStartedAndFinished(t *testing.T) { func TestRunForgetShipsStartedAndFinished(t *testing.T) {
@@ -398,9 +409,13 @@ esac
Tag: "documents", Tag: "documents",
Policy: restic.ForgetPolicy{KeepLast: &keepLast}, Policy: restic.ForgetPolicy{KeepLast: &keepLast},
}} }}
if err := r.RunForget(context.Background(), "job-forget", groups); err != nil { if err := r.RunForget(context.Background(), "job-forget", groups, false); err != nil {
t.Fatalf("RunForget: %v", err) t.Fatalf("RunForget: %v", err)
} }
_ = firstEnvOfType(t, tx.envs, api.MsgJobStarted) _ = firstEnvOfType(t, tx.envs, api.MsgJobStarted)
_ = firstEnvOfType(t, tx.envs, api.MsgJobFinished) finished := firstIndexOfType(tx.envs, api.MsgJobFinished)
refreshed := firstIndexOfType(tx.envs, api.MsgSnapshotsRpt)
if refreshed < 0 || finished < 0 || refreshed >= finished {
t.Fatalf("snapshot refresh must precede terminal report: %v", envelopeOrder(tx.envs))
}
} }
+5 -5
View File
@@ -103,15 +103,15 @@ func connectOnce(ctx context.Context, cfg Config, handle Handler) error {
} }
dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second) dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
conn, res, err := websocket.Dial(dialCtx, wsURL, dialOpts) conn, _, err := websocket.Dial(dialCtx, wsURL, dialOpts) //nolint:bodyclose // successful upgrades have a nil response body owned by conn
cancel() cancel()
if err != nil { if err != nil {
return fmt.Errorf("dial: %w", err) return fmt.Errorf("dial: %w", err)
} }
// websocket.Dial returns the upgrade response separately from the conn.SetReadLimit(api.MaxWebSocketMessageBytes)
// conn. Body is empty on a successful upgrade but Go's net/http // On a successful upgrade coder/websocket transfers ownership of the
// still expects it closed to release the connection. // response stream to conn and deliberately sets res.Body to nil. Closing
defer func() { _ = res.Body.Close() }() // the connection below releases that stream.
defer conn.CloseNow() //nolint:errcheck defer conn.CloseNow() //nolint:errcheck
// Send hello. // Send hello.
+103
View File
@@ -0,0 +1,103 @@
package wsclient
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
)
func TestConnectOnceCleanDisconnectDoesNotPanic(t *testing.T) {
serverErr := make(chan error, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, nil)
if err != nil {
serverErr <- err
return
}
defer conn.CloseNow() //nolint:errcheck
// Wait for the agent hello so Dial and the first client write have both
// completed before ending the connection normally.
if _, _, err := conn.Read(r.Context()); err != nil {
serverErr <- err
return
}
serverErr <- conn.Close(websocket.StatusNormalClosure, "test complete")
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := connectOnce(ctx, Config{
ServerURL: srv.URL,
AgentToken: "test-token",
HeartbeatPeriod: time.Hour,
}, nil)
if err == nil {
t.Fatal("connectOnce returned nil after server disconnected")
}
if err := <-serverErr; err != nil {
t.Fatalf("server websocket: %v", err)
}
}
func TestConnectOnceAcceptsMessageLargerThanDefaultReadLimit(t *testing.T) {
received := make(chan struct{}, 1)
serverErr := make(chan error, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, nil)
if err != nil {
serverErr <- err
return
}
defer conn.CloseNow() //nolint:errcheck
if _, _, err := conn.Read(r.Context()); err != nil {
serverErr <- err
return
}
env := api.Envelope{
Type: api.MsgConfigUpdate,
Payload: json.RawMessage(`{"padding":"` + strings.Repeat("x", 40*1024) + `"}`),
}
raw, _ := json.Marshal(env)
serverErr <- conn.Write(r.Context(), websocket.MessageText, raw)
<-r.Context().Done()
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- connectOnce(ctx, Config{
ServerURL: srv.URL,
AgentToken: "test-token",
HeartbeatPeriod: time.Hour,
}, func(_ context.Context, env api.Envelope, _ Sender) error {
if env.Type == api.MsgConfigUpdate {
received <- struct{}{}
cancel()
}
return nil
})
}()
select {
case <-received:
case <-time.After(5 * time.Second):
t.Fatal("agent did not receive oversized server message")
}
if err := <-serverErr; err != nil {
t.Fatalf("server websocket: %v", err)
}
if err := <-done; err == nil {
t.Fatal("connectOnce returned nil")
}
}
+68 -1
View File
@@ -28,6 +28,11 @@ import (
// are evaluated — always-on hosts' stale_schedule stays a no-op. // are evaluated — always-on hosts' stale_schedule stays a no-op.
const staleBackupThreshold = 7 * 24 * time.Hour const staleBackupThreshold = 7 * 24 * time.Hour
const (
defaultStuckJobThreshold = 6 * time.Hour
longStuckJobThreshold = 24 * time.Hour
)
// JobFinishedEvent carries everything the engine needs to evaluate // JobFinishedEvent carries everything the engine needs to evaluate
// the failed-X rules. Pushed via Engine.NotifyJobFinished from the // the failed-X rules. Pushed via Engine.NotifyJobFinished from the
// MarkJobFinished site. // MarkJobFinished site.
@@ -53,11 +58,23 @@ type Engine struct {
// we raise. Configurable for tests; default 15m. // we raise. Configurable for tests; default 15m.
agentOfflineFloor time.Duration agentOfflineFloor time.Duration
tickPeriod time.Duration tickPeriod time.Duration
stuckThresholds map[string]time.Duration
closeOnce sync.Once closeOnce sync.Once
notifyWG sync.WaitGroup
done chan struct{} done chan struct{}
} }
func (e *Engine) dispatchNotification(ctx context.Context, payload notification.Payload) {
e.notifyWG.Add(1)
go func() {
defer e.notifyWG.Done()
e.hub.Dispatch(ctx, payload)
}()
}
func (e *Engine) waitNotifications() { e.notifyWG.Wait() }
// NewEngine builds the engine. agentOfflineFloor + tickPeriod default // NewEngine builds the engine. agentOfflineFloor + tickPeriod default
// to 15min and 60s respectively when zero. // to 15min and 60s respectively when zero.
func NewEngine(st *store.Store, hub *notification.Hub) *Engine { func NewEngine(st *store.Store, hub *notification.Hub) *Engine {
@@ -69,7 +86,12 @@ func NewEngine(st *store.Store, hub *notification.Hub) *Engine {
hostUp: make(chan string, 32), hostUp: make(chan string, 32),
agentOfflineFloor: 15 * time.Minute, agentOfflineFloor: 15 * time.Minute,
tickPeriod: 60 * time.Second, tickPeriod: 60 * time.Second,
done: make(chan struct{}), stuckThresholds: map[string]time.Duration{
"backup": longStuckJobThreshold,
"restore": longStuckJobThreshold,
"check": longStuckJobThreshold,
},
done: make(chan struct{}),
} }
} }
@@ -125,6 +147,10 @@ func (e *Engine) NotifyHostOnline(hostID string) {
} }
func (e *Engine) handleJobFinished(ctx context.Context, ev JobFinishedEvent) { func (e *Engine) handleJobFinished(ctx context.Context, ev JobFinishedEvent) {
// A late terminal message is authoritative and clears any stuck alert for
// this exact job regardless of its outcome or kind.
e.resolveAndNotify(ctx, ev.HostID, KindJobStuck, ev.JobID, ev.When)
// Determine which kind/severity pair this job maps to. Jobs not // Determine which kind/severity pair this job maps to. Jobs not
// listed here (init, unlock, restore, diff) produce no alerts in v1. // listed here (init, unlock, restore, diff) produce no alerts in v1.
var kind, severity string var kind, severity string
@@ -210,6 +236,7 @@ func (e *Engine) tick(ctx context.Context, now time.Time) {
if _, err := e.store.CleanupExpiredOIDCState(ctx, now.Add(-5*time.Minute)); err != nil { if _, err := e.store.CleanupExpiredOIDCState(ctx, now.Add(-5*time.Minute)); err != nil {
slog.Warn("alert: cleanup expired oidc state", "err", err) slog.Warn("alert: cleanup expired oidc state", "err", err)
} }
e.evaluateStuckJobs(ctx, now)
hosts, err := e.store.ListHosts(ctx) hosts, err := e.store.ListHosts(ctx)
if err != nil { if err != nil {
@@ -257,6 +284,46 @@ func (e *Engine) tick(ctx context.Context, now time.Time) {
} }
} }
func (e *Engine) evaluateStuckJobs(ctx context.Context, now time.Time) {
running, err := e.store.ListRunningJobActivity(ctx)
if err != nil {
slog.Warn("alert: tick list running jobs", "err", err)
return
}
active := make(map[string]store.RunningJobActivity, len(running))
for _, job := range running {
active[job.JobID] = job
threshold := defaultStuckJobThreshold
if configured, ok := e.stuckThresholds[job.Kind]; ok {
threshold = configured
}
age := now.Sub(job.LastActivity)
if age < threshold {
continue
}
e.raiseAndNotify(ctx, job.HostID, KindJobStuck, job.JobID, "warning",
fmt.Sprintf("%s job %s is stuck: started %s, last activity %s (%s ago; threshold %s)",
job.Kind, job.JobID, job.StartedAt.Format(time.RFC3339),
job.LastActivity.Format(time.RFC3339), roundDur(age), threshold), now)
}
// Self-heal alerts when another server path made a job terminal without
// emitting JobFinishedEvent, or after a restart missed the event.
alerts, err := e.store.ListAlerts(ctx, store.AlertFilter{Status: "open"})
if err != nil {
return
}
acked, _ := e.store.ListAlerts(ctx, store.AlertFilter{Status: "acknowledged"})
for _, item := range append(alerts, acked...) {
if item.Kind != KindJobStuck || item.HostID == nil {
continue
}
if _, ok := active[item.DedupKey]; !ok {
e.resolveAndNotify(ctx, *item.HostID, KindJobStuck, item.DedupKey, now)
}
}
}
// roundDur returns a human-readable duration string, rounding to the // roundDur returns a human-readable duration string, rounding to the
// nearest minute. Durations under a minute are reported as "less than // nearest minute. Durations under a minute are reported as "less than
// a minute". // a minute".
+8 -4
View File
@@ -36,6 +36,10 @@ const (
// KindAgentOffline is raised when a host's last_seen_at is older // KindAgentOffline is raised when a host's last_seen_at is older
// than the 15-minute floor and resolved when the host reconnects. // than the 15-minute floor and resolved when the host reconnects.
KindAgentOffline = "agent_offline" KindAgentOffline = "agent_offline"
// KindJobStuck is raised per job when a running job has no persisted
// activity beyond its kind-specific threshold. The job ID is the dedup key.
KindJobStuck = "job_stuck"
) )
// raiseAndNotify is the standard raise pattern: store.RaiseOrTouch // raiseAndNotify is the standard raise pattern: store.RaiseOrTouch
@@ -56,7 +60,7 @@ func (e *Engine) raiseAndNotify(ctx context.Context, hostID, kind, dedupKey, sev
if err == nil { if err == nil {
hostName = host.Name hostName = host.Name
} }
go e.hub.Dispatch(ctx, notification.Payload{ e.dispatchNotification(ctx, notification.Payload{
Event: notification.EventRaised, Event: notification.EventRaised,
AlertID: id, AlertID: id,
Severity: severity, Severity: severity,
@@ -81,7 +85,7 @@ func (e *Engine) Acknowledge(ctx context.Context, alertID, userID string, when t
return nil //nolint:nilerr return nil //nolint:nilerr
} }
p := alertPayload(ctx, e.store, notification.EventAcknowledged, a) p := alertPayload(ctx, e.store, notification.EventAcknowledged, a)
go e.hub.Dispatch(context.WithoutCancel(ctx), p) e.dispatchNotification(context.WithoutCancel(ctx), p)
return nil return nil
} }
@@ -95,7 +99,7 @@ func (e *Engine) Resolve(ctx context.Context, alertID string, when time.Time) er
return nil return nil
} }
p := alertPayload(ctx, e.store, notification.EventResolved, a) p := alertPayload(ctx, e.store, notification.EventResolved, a)
go e.hub.Dispatch(context.WithoutCancel(ctx), p) e.dispatchNotification(context.WithoutCancel(ctx), p)
return nil return nil
} }
@@ -160,7 +164,7 @@ func (e *Engine) resolveAndNotify(ctx context.Context, hostID, kind, dedupKey st
if a.Kind != kind || a.DedupKey != dedupKey { if a.Kind != kind || a.DedupKey != dedupKey {
continue continue
} }
go e.hub.Dispatch(ctx, notification.Payload{ e.dispatchNotification(ctx, notification.Payload{
Event: notification.EventResolved, Event: notification.EventResolved,
AlertID: a.ID, AlertID: a.ID,
Severity: a.Severity, Severity: a.Severity,
+61
View File
@@ -3,6 +3,7 @@ package alert
import ( import (
"context" "context"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
@@ -24,6 +25,7 @@ func setupEngine(t *testing.T) (*Engine, *store.Store, string) {
aead, _ := crypto.NewAEAD(key) aead, _ := crypto.NewAEAD(key)
hub := notification.NewHub(st, aead, "https://rm.example") hub := notification.NewHub(st, aead, "https://rm.example")
eng := NewEngine(st, hub) eng := NewEngine(st, hub)
t.Cleanup(eng.waitNotifications)
hostID := ulid.Make().String() hostID := ulid.Make().String()
if err := st.CreateHost(context.Background(), store.Host{ if err := st.CreateHost(context.Background(), store.Host{
ID: hostID, Name: "alfa-01", OS: "linux", Arch: "amd64", ID: hostID, Name: "alfa-01", OS: "linux", Arch: "amd64",
@@ -69,6 +71,65 @@ func TestEngineBackupFailedRaisesThenResolves(t *testing.T) {
} }
} }
func TestEngineStuckJobRaisesDeduplicatesAndResolves(t *testing.T) {
t.Parallel()
eng, st, hostID := setupEngine(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
started := now.Add(-7 * time.Hour)
if err := st.CreateJob(ctx, store.Job{ID: "stuck-job", HostID: hostID, Kind: "prune", ActorKind: "user", CreatedAt: started}); err != nil {
t.Fatalf("create job: %v", err)
}
if err := st.MarkJobStarted(ctx, "stuck-job", started); err != nil {
t.Fatalf("start job: %v", err)
}
eng.evaluateStuckJobs(ctx, now)
eng.evaluateStuckJobs(ctx, now.Add(time.Minute))
open, err := st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
if err != nil {
t.Fatalf("list alerts: %v", err)
}
if len(open) != 1 || open[0].Kind != KindJobStuck || open[0].DedupKey != "stuck-job" {
t.Fatalf("expected one deduplicated stuck alert, got %+v", open)
}
if !strings.Contains(open[0].Message, started.Format(time.RFC3339)) {
t.Errorf("alert does not identify start/activity time: %q", open[0].Message)
}
if err := st.MarkJobFinished(ctx, "stuck-job", "succeeded", 0, nil, "", now); err != nil {
t.Fatalf("finish job: %v", err)
}
eng.evaluateStuckJobs(ctx, now.Add(2*time.Minute))
open, _ = st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
if len(open) != 0 {
t.Fatalf("expected terminal job alert resolved, got %+v", open)
}
}
func TestEngineStuckJobUsesLatestLogActivity(t *testing.T) {
t.Parallel()
eng, st, hostID := setupEngine(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
started := now.Add(-7 * time.Hour)
if err := st.CreateJob(ctx, store.Job{ID: "active-job", HostID: hostID, Kind: "forget", ActorKind: "user", CreatedAt: started}); err != nil {
t.Fatalf("create job: %v", err)
}
if err := st.MarkJobStarted(ctx, "active-job", started); err != nil {
t.Fatalf("start job: %v", err)
}
if err := st.AppendJobLog(ctx, "active-job", 1, now.Add(-time.Hour), "stdout", "active"); err != nil {
t.Fatalf("append log: %v", err)
}
eng.evaluateStuckJobs(ctx, now)
open, _ := st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
if len(open) != 0 {
t.Fatalf("recent activity should suppress stuck alert, got %+v", open)
}
}
func TestEngineCheckFailedSeverityCritical(t *testing.T) { func TestEngineCheckFailedSeverityCritical(t *testing.T) {
t.Parallel() t.Parallel()
eng, st, hostID := setupEngine(t) eng, st, hostID := setupEngine(t)
+14 -6
View File
@@ -10,6 +10,13 @@ import (
// (not iota ints) makes traffic readable in logs and packet captures. // (not iota ints) makes traffic readable in logs and packet captures.
type MessageType string type MessageType string
// MaxWebSocketMessageBytes is the protocol-wide upper bound for one agent ↔
// server envelope. Snapshot projections and restic JSON log events can exceed
// coder/websocket's 32 KiB default on ordinary repositories, so both peers set
// this limit explicitly. It remains bounded to protect either process from an
// untrusted or malfunctioning peer allocating without limit.
const MaxWebSocketMessageBytes int64 = 8 << 20
// Agent → server message types. // Agent → server message types.
const ( const (
MsgHello MessageType = "hello" MsgHello MessageType = "hello"
@@ -29,12 +36,13 @@ const (
// Server → agent message types. // Server → agent message types.
const ( const (
MsgCommandRun MessageType = "command.run" MsgCommandRun MessageType = "command.run"
MsgCommandCancel MessageType = "command.cancel" MsgCommandCancel MessageType = "command.cancel"
MsgScheduleSet MessageType = "schedule.set" MsgScheduleSet MessageType = "schedule.set"
MsgConfigUpdate MessageType = "config.update" MsgConfigUpdate MessageType = "config.update"
MsgCommandUpdate MessageType = "command.update" MsgCommandUpdate MessageType = "command.update"
MsgTreeList MessageType = "tree.list" // sync RPC: list a snapshot's children MsgTreeList MessageType = "tree.list" // sync RPC: list a snapshot's children
MsgSnapshotsRefresh MessageType = "snapshots.refresh"
) )
// Envelope is the framing for every WS message in either direction. // Envelope is the framing for every WS message in either direction.
+4 -1
View File
@@ -322,7 +322,7 @@ type ForgetGroup struct {
// any keep-* would delete every snapshot in the tagged set). // any keep-* would delete every snapshot in the tagged set).
// Returns the first error encountered, or nil when every group runs // Returns the first error encountered, or nil when every group runs
// to a clean exit. // to a clean exit.
func (e Env) RunForget(ctx context.Context, groups []ForgetGroup, handle LineHandler) error { func (e Env) RunForget(ctx context.Context, groups []ForgetGroup, dryRun bool, handle LineHandler) error {
if len(groups) == 0 { if len(groups) == 0 {
return fmt.Errorf("restic forget: refusing to run with no groups (would be a no-op)") return fmt.Errorf("restic forget: refusing to run with no groups (would be a no-op)")
} }
@@ -332,6 +332,9 @@ func (e Env) RunForget(ctx context.Context, groups []ForgetGroup, handle LineHan
} }
args := []string{"forget", "--json", "--tag", g.Tag} args := []string{"forget", "--json", "--tag", g.Tag}
args = append(args, g.Policy.args()...) args = append(args, g.Policy.args()...)
if dryRun {
args = append(args, "--dry-run")
}
cmd := e.resticCmd(ctx, args...) cmd := e.resticCmd(ctx, args...)
if err := runWithPump(cmd, handle); err != nil { if err := runWithPump(cmd, handle); err != nil {
return err return err
+20
View File
@@ -60,6 +60,26 @@ func TestRunPruneInvokesPrune(t *testing.T) {
t.Fatalf("expected 'prune' in captured output; got: %v", *lines) t.Fatalf("expected 'prune' in captured output; got: %v", *lines)
} }
func TestRunForgetDryRunArgument(t *testing.T) {
bin := setupScriptBin(t, `echo "$@"`)
env := Env{Bin: bin}
lines, h := captureLines()
keepLast := 1
groups := []ForgetGroup{{
Tag: "documents",
Policy: ForgetPolicy{KeepLast: &keepLast},
}}
if err := env.RunForget(context.Background(), groups, true, h); err != nil {
t.Fatalf("RunForget: %v", err)
}
for _, line := range *lines {
if strings.Contains(line, "forget --json --tag documents --keep-last 1 --dry-run") {
return
}
}
t.Fatalf("expected forget invocation with --dry-run; got: %v", *lines)
}
// --- B2: RunCheck --- // --- B2: RunCheck ---
func TestRunCheckLockSniff(t *testing.T) { func TestRunCheckLockSniff(t *testing.T) {
+31 -1
View File
@@ -71,6 +71,16 @@ func NewWorker(st *store.Store, hub Hub, disp Dispatcher, alerts AlertRaiser) *W
// worker goroutine. Returns the new fleet_update_id on success. // worker goroutine. Returns the new fleet_update_id on success.
// store.ErrFleetUpdateRunning bubbles up unchanged. // store.ErrFleetUpdateRunning bubbles up unchanged.
func (w *Worker) Start(ctx context.Context, userID, targetVersion string, hostIDs []string) (string, error) { func (w *Worker) Start(ctx context.Context, userID, targetVersion string, hostIDs []string) (string, error) {
return w.start(ctx, userID, targetVersion, hostIDs, false)
}
// StartCanary starts the same sequential rollout but pauses after the first
// verified host so an operator can inspect it before starting the remainder.
func (w *Worker) StartCanary(ctx context.Context, userID, targetVersion string, hostIDs []string) (string, error) {
return w.start(ctx, userID, targetVersion, hostIDs, true)
}
func (w *Worker) start(ctx context.Context, userID, targetVersion string, hostIDs []string, pauseAfterFirst bool) (string, error) {
if userID == "" || targetVersion == "" { if userID == "" || targetVersion == "" {
return "", errors.New("fleetupdate: userID and targetVersion required") return "", errors.New("fleetupdate: userID and targetVersion required")
} }
@@ -85,6 +95,7 @@ func (w *Worker) Start(ctx context.Context, userID, targetVersion string, hostID
StartedByUserID: userID, StartedByUserID: userID,
TargetVersion: targetVersion, TargetVersion: targetVersion,
Status: "running", Status: "running",
PauseAfterFirst: pauseAfterFirst,
}, hostIDs); err != nil { }, hostIDs); err != nil {
return "", err return "", err
} }
@@ -137,6 +148,13 @@ func (w *Worker) run(ctx context.Context, fuID, userID, targetVersion string) {
next := pending[0] next := pending[0]
w.processHost(ctx, fuID, userID, next) w.processHost(ctx, fuID, userID, next)
if fu.PauseAfterFirst && next.Position == 0 {
updated, _, gerr := w.store.GetFleetUpdate(ctx, fuID)
if gerr == nil && updated.Status == "running" {
_ = w.store.HaltFleetUpdate(ctx, fuID, "canary succeeded; review the host, then resume remaining agents", time.Now().UTC())
}
return
}
} }
} }
@@ -195,7 +213,19 @@ func (w *Worker) processHost(ctx context.Context, fuID, userID string, slot stor
return return
} }
} }
reason := fmt.Sprintf("timeout waiting for %s to reach %s", hostID, w.targetVersion) // One authoritative final read closes the poll/deadline race. AgentVersion
// is written directly from the reconnect hello handshake.
lastVersion := "unknown"
if h, err := w.store.GetHost(ctx, hostID); err == nil && h != nil {
if h.AgentVersion == w.targetVersion {
_ = w.store.SetFleetUpdateHostStatus(ctx, fuID, hostID, "succeeded", "", jobID)
return
}
if h.AgentVersion != "" {
lastVersion = h.AgentVersion
}
}
reason := fmt.Sprintf("timeout waiting for %s reconnect hello at %s (last reported %s)", hostID, w.targetVersion, lastVersion)
_ = w.store.SetFleetUpdateHostStatus(ctx, fuID, hostID, "failed", reason, jobID) _ = w.store.SetFleetUpdateHostStatus(ctx, fuID, hostID, "failed", reason, jobID)
w.halt(ctx, fuID, reason) w.halt(ctx, fuID, reason)
} }
@@ -160,6 +160,35 @@ func TestWorkerTwoHostsBothSucceed(t *testing.T) {
} }
} }
func TestWorkerCanaryPausesAfterFirstVerifiedHost(t *testing.T) {
st := openStore(t)
uid := mustCreateAdmin(t, st)
h1 := mustCreateHost(t, st, "canary", "v0")
h2 := mustCreateHost(t, st, "remainder", "v0")
hub := &fakeHub{online: map[string]bool{h1: true, h2: true}}
disp := &fakeDispatcher{st: st, target: "v2", delayMS: 20}
alerts := &recAlert{}
w := NewWorker(st, hub, disp, alerts)
w.pollPeriod = 10 * time.Millisecond
w.hostTimeout = time.Second
fuID, err := w.StartCanary(context.Background(), uid, "v2", []string{h1, h2})
if err != nil {
t.Fatalf("start canary: %v", err)
}
fu := waitForStatus(t, st, fuID, "halted", 2*time.Second)
if fu.HaltedReason != "canary succeeded; review the host, then resume remaining agents" {
t.Fatalf("halt reason: %q", fu.HaltedReason)
}
_, hosts, _ := st.GetFleetUpdate(context.Background(), fuID)
if hosts[0].Status != "succeeded" || hosts[1].Status != "pending" {
t.Fatalf("canary statuses: %+v", hosts)
}
if len(alerts.reasons) != 0 {
t.Fatalf("successful canary pause must not alert: %v", alerts.reasons)
}
}
func TestWorkerSecondHostTimesOutHalts(t *testing.T) { func TestWorkerSecondHostTimesOutHalts(t *testing.T) {
st := openStore(t) st := openStore(t)
uid := mustCreateAdmin(t, st) uid := mustCreateAdmin(t, st)
+109 -12
View File
@@ -32,6 +32,13 @@ import (
type fleetUpdateStartReq struct { type fleetUpdateStartReq struct {
TargetVersion string `json:"target_version,omitempty"` TargetVersion string `json:"target_version,omitempty"`
HostIDs []string `json:"host_ids,omitempty"` HostIDs []string `json:"host_ids,omitempty"`
CanaryFirst bool `json:"canary_first,omitempty"`
}
type fleetUpdateCandidate struct {
Host store.Host
Eligible bool
Reason string
} }
// fleetUpdateHostView is one row in the JSON response for GET // fleetUpdateHostView is one row in the JSON response for GET
@@ -56,6 +63,7 @@ type fleetUpdateView struct {
CurrentHostID string `json:"current_host_id,omitempty"` CurrentHostID string `json:"current_host_id,omitempty"`
HaltedReason string `json:"halted_reason,omitempty"` HaltedReason string `json:"halted_reason,omitempty"`
CompletedAt *string `json:"completed_at,omitempty"` CompletedAt *string `json:"completed_at,omitempty"`
CanaryFirst bool `json:"canary_first"`
Hosts []fleetUpdateHostView `json:"hosts"` Hosts []fleetUpdateHostView `json:"hosts"`
} }
@@ -65,6 +73,7 @@ type fleetUpdateView struct {
type fleetUpdatePage struct { type fleetUpdatePage struct {
// Idle-state fields. // Idle-state fields.
OutOfDateHosts []store.Host // online hosts whose version != target OutOfDateHosts []store.Host // online hosts whose version != target
Candidates []fleetUpdateCandidate
TargetVersion string TargetVersion string
// Active-state fields. Nil when no fleet update has ever run. // Active-state fields. Nil when no fleet update has ever run.
@@ -100,6 +109,11 @@ func (s *Server) handleAPIFleetUpdateStart(w stdhttp.ResponseWriter, r *stdhttp.
if target == "" { if target == "" {
target = version.Version target = version.Version
} }
if target != version.Version {
writeJSONError(w, stdhttp.StatusUnprocessableEntity, "unsupported_target_version",
"fleet updates can only target the running server version")
return
}
hostIDs := body.HostIDs hostIDs := body.HostIDs
if len(hostIDs) == 0 { if len(hostIDs) == 0 {
derived, err := s.deriveOutOfDateOnlineHostIDs(r.Context(), target) derived, err := s.deriveOutOfDateOnlineHostIDs(r.Context(), target)
@@ -108,6 +122,19 @@ func (s *Server) handleAPIFleetUpdateStart(w stdhttp.ResponseWriter, r *stdhttp.
return return
} }
hostIDs = derived hostIDs = derived
} else {
validated, reasons, err := s.validateFleetUpdateHostIDs(r.Context(), target, hostIDs)
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return
}
if len(reasons) > 0 {
writeJSON(w, stdhttp.StatusUnprocessableEntity, map[string]any{
"code": "ineligible_hosts", "message": "one or more selected hosts are ineligible", "reasons": reasons,
})
return
}
hostIDs = validated
} }
if len(hostIDs) == 0 { if len(hostIDs) == 0 {
writeJSONError(w, stdhttp.StatusConflict, "no_hosts_eligible", writeJSONError(w, stdhttp.StatusConflict, "no_hosts_eligible",
@@ -115,7 +142,20 @@ func (s *Server) handleAPIFleetUpdateStart(w stdhttp.ResponseWriter, r *stdhttp.
return return
} }
fuID, err := s.deps.FleetWorker.Start(r.Context(), user.ID, target, hostIDs) var fuID string
var err error
if body.CanaryFirst && len(hostIDs) > 1 {
worker, supported := s.deps.FleetWorker.(interface {
StartCanary(context.Context, string, string, []string) (string, error)
})
if !supported {
writeJSONError(w, stdhttp.StatusServiceUnavailable, "canary_unavailable", "")
return
}
fuID, err = worker.StartCanary(r.Context(), user.ID, target, hostIDs)
} else {
fuID, err = s.deps.FleetWorker.Start(r.Context(), user.ID, target, hostIDs)
}
if err != nil { if err != nil {
if errors.Is(err, store.ErrFleetUpdateRunning) { if errors.Is(err, store.ErrFleetUpdateRunning) {
writeJSONError(w, stdhttp.StatusConflict, "fleet_update_in_progress", err.Error()) writeJSONError(w, stdhttp.StatusConflict, "fleet_update_in_progress", err.Error())
@@ -129,6 +169,7 @@ func (s *Server) handleAPIFleetUpdateStart(w stdhttp.ResponseWriter, r *stdhttp.
"fleet_update_id": fuID, "fleet_update_id": fuID,
"target_version": target, "target_version": target,
"host_count": len(hostIDs), "host_count": len(hostIDs),
"canary_first": body.CanaryFirst,
}) })
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: &user.ID, Actor: "user", ID: ulid.Make().String(), UserID: &user.ID, Actor: "user",
@@ -209,6 +250,7 @@ func (s *Server) handleAPIFleetUpdateGet(w stdhttp.ResponseWriter, r *stdhttp.Re
Status: fu.Status, Status: fu.Status,
CurrentHostID: fu.CurrentHostID, CurrentHostID: fu.CurrentHostID,
HaltedReason: fu.HaltedReason, HaltedReason: fu.HaltedReason,
CanaryFirst: fu.PauseAfterFirst,
Hosts: make([]fleetUpdateHostView, 0, len(hosts)), Hosts: make([]fleetUpdateHostView, 0, len(hosts)),
} }
if fu.CompletedAt != nil { if fu.CompletedAt != nil {
@@ -286,6 +328,27 @@ func (s *Server) buildFleetUpdatePage(r *stdhttp.Request) (fleetUpdatePage, erro
} }
for _, h := range hosts { for _, h := range hosts {
page.HostNames[h.ID] = h.Name page.HostNames[h.ID] = h.Name
candidate := fleetUpdateCandidate{Host: h}
switch {
case h.AgentVersion == "":
candidate.Reason = "version unknown"
case h.AgentVersion == page.TargetVersion:
candidate.Reason = "already current"
case s.deps.Hub == nil || !s.deps.Hub.Connected(h.ID):
candidate.Reason = "offline"
default:
updating, uerr := s.deps.Store.RunningUpdateJobForHost(r.Context(), h.ID)
if uerr != nil {
return page, uerr
}
if updating != "" {
candidate.Reason = "update already running"
} else {
candidate.Eligible = true
page.OutOfDateHosts = append(page.OutOfDateHosts, h)
}
}
page.Candidates = append(page.Candidates, candidate)
} }
active, err := s.deps.Store.ActiveFleetUpdate(r.Context()) active, err := s.deps.Store.ActiveFleetUpdate(r.Context())
@@ -328,20 +391,54 @@ func (s *Server) buildFleetUpdatePage(r *stdhttp.Request) (fleetUpdatePage, erro
} }
} }
// Idle list (or "still out of date" reference even when an active
// roll is running — cheap to compute, harmless to attach).
for _, h := range hosts {
if h.Status != "online" {
continue
}
if h.AgentVersion == "" || h.AgentVersion == page.TargetVersion {
continue
}
page.OutOfDateHosts = append(page.OutOfDateHosts, h)
}
return page, nil return page, nil
} }
// validateFleetUpdateHostIDs deduplicates an explicit selection while
// preserving review order and rejects the entire request if membership has
// changed or any selected host is not dispatchable.
func (s *Server) validateFleetUpdateHostIDs(ctx context.Context, target string, requested []string) ([]string, map[string]string, error) {
hosts, err := s.deps.Store.ListHosts(ctx)
if err != nil {
return nil, nil, err
}
byID := make(map[string]store.Host, len(hosts))
for _, h := range hosts {
byID[h.ID] = h
}
seen := map[string]bool{}
validated := make([]string, 0, len(requested))
reasons := map[string]string{}
for _, id := range requested {
if seen[id] {
continue
}
seen[id] = true
h, ok := byID[id]
switch {
case !ok:
reasons[id] = "host not found"
case h.AgentVersion == "":
reasons[id] = "agent version unknown"
case h.AgentVersion == target:
reasons[id] = "already at target version"
case s.deps.Hub == nil || !s.deps.Hub.Connected(id):
reasons[id] = "host offline"
default:
jobID, jerr := s.deps.Store.RunningUpdateJobForHost(ctx, id)
if jerr != nil {
return nil, nil, jerr
}
if jobID != "" {
reasons[id] = "update already in progress"
} else {
validated = append(validated, id)
}
}
}
return validated, reasons, nil
}
// deriveOutOfDateOnlineHostIDs returns the list of host IDs that // deriveOutOfDateOnlineHostIDs returns the list of host IDs that
// (a) are online (Hub.Connected) and (b) have an agent_version that's // (a) are online (Hub.Connected) and (b) have an agent_version that's
// non-empty AND != target. Used by the start endpoint when the caller // non-empty AND != target. Used by the start endpoint when the caller
+128
View File
@@ -6,7 +6,9 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"io"
stdhttp "net/http" stdhttp "net/http"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -19,6 +21,67 @@ import (
"gitea.dcglab.co.uk/steve/restic-manager/internal/version" "gitea.dcglab.co.uk/steve/restic-manager/internal/version"
) )
func TestFleetUpdateIdlePageDoesNotPollOrRequireTypedConfirmation(t *testing.T) {
t.Parallel()
_, baseURL, st := newTestServerWithUI(t)
cookie := loginAsAdmin(t, st)
_ = makeHost(t, st, "fleet-filter-host")
req, _ := stdhttp.NewRequest("GET", baseURL+"/settings/fleet-update", nil)
req.AddCookie(cookie)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get fleet update page: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusOK {
t.Fatalf("status: got %d, want 200", res.StatusCode)
}
raw, _ := io.ReadAll(res.Body)
body := string(raw)
for _, want := range []string{
`id="fleet-filter-name"`,
`id="fleet-filter-version"`,
`id="fleet-filter-state"`,
"r.style.display=",
} {
if !strings.Contains(body, want) {
t.Errorf("page missing %q", want)
}
}
if strings.Contains(body, "Type selected count to confirm") || strings.Contains(body, `id="fleet-update-confirm"`) {
t.Error("page still renders the typed-count confirmation")
}
if strings.Contains(body, `hx-trigger="every 3s`) {
t.Error("idle selection page must not poll and replace operator input")
}
}
func TestFleetUpdateRunningPagePollsForProgress(t *testing.T) {
t.Parallel()
_, baseURL, st := newTestServerWithUI(t)
cookie, userID := loginAsAdminWithID(t, st)
hostID := makeHost(t, st, "fleet-running-host")
if err := st.CreateFleetUpdate(context.Background(), store.FleetUpdate{
ID: ulid.Make().String(), StartedByUserID: userID, TargetVersion: version.Version,
}, []string{hostID}); err != nil {
t.Fatalf("create fleet update: %v", err)
}
req, _ := stdhttp.NewRequest("GET", baseURL+"/settings/fleet-update", nil)
req.AddCookie(cookie)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get fleet update page: %v", err)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if body := string(raw); !strings.Contains(body, `hx-trigger="every 3s`) {
t.Error("running rollout page must poll for progress")
}
}
// fakeFleetWorker stands in for *fleetupdate.Worker in HTTP tests. // fakeFleetWorker stands in for *fleetupdate.Worker in HTTP tests.
// It records what was passed to Start/Cancel and lets tests inject // It records what was passed to Start/Cancel and lets tests inject
// canned errors. Satisfies the FleetWorker interface in // canned errors. Satisfies the FleetWorker interface in
@@ -50,6 +113,10 @@ func (f *fakeFleetWorker) Start(_ context.Context, userID, target string, hostID
return f.startID, nil return f.startID, nil
} }
func (f *fakeFleetWorker) StartCanary(ctx context.Context, userID, target string, hostIDs []string) (string, error) {
return f.Start(ctx, userID, target, hostIDs)
}
func (f *fakeFleetWorker) Cancel(_ context.Context, id string) error { func (f *fakeFleetWorker) Cancel(_ context.Context, id string) error {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -190,6 +257,67 @@ func TestFleetUpdateStartDerivesHostIDsWhenEmpty(t *testing.T) {
} }
} }
func TestFleetUpdateStartDeduplicatesExplicitSelection(t *testing.T) {
t.Parallel()
srv, ts, st := rawTestServer(t)
worker := &fakeFleetWorker{startID: ulid.Make().String()}
srv.deps.FleetWorker = worker
cookie := loginAsAdmin(t, st)
hostID := helloOnlineHost(t, srv, st, "duplicate-host", "v0")
raw, _ := json.Marshal(map[string]any{"host_ids": []string{hostID, hostID}})
req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/fleet/update", bytes.NewReader(raw))
req.AddCookie(cookie)
req.Header.Set("Content-Type", "application/json")
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusAccepted {
t.Fatalf("status: %d", res.StatusCode)
}
if got := worker.startCalls[0].HostIDs; len(got) != 1 || got[0] != hostID {
t.Fatalf("deduplicated ids: %v", got)
}
}
func TestFleetUpdateStartRejectsUnknownAndIneligibleHosts(t *testing.T) {
t.Parallel()
srv, ts, st := rawTestServer(t)
worker := &fakeFleetWorker{startID: ulid.Make().String()}
srv.deps.FleetWorker = worker
cookie := loginAsAdmin(t, st)
offline := makeHost(t, st, "offline-host")
if err := st.MarkHostHello(context.Background(), offline, "v0", "0.17", api.CurrentProtocolVersion, time.Now().UTC()); err != nil {
t.Fatalf("mark offline host: %v", err)
}
raw, _ := json.Marshal(map[string]any{"host_ids": []string{offline, "does-not-exist"}})
req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/fleet/update", bytes.NewReader(raw))
req.AddCookie(cookie)
req.Header.Set("Content-Type", "application/json")
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusUnprocessableEntity {
t.Fatalf("status: got %d, want 422", res.StatusCode)
}
var body struct {
Code string `json:"code"`
Reasons map[string]string `json:"reasons"`
}
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Code != "ineligible_hosts" || len(body.Reasons) != 2 {
t.Fatalf("structured reasons: %+v", body)
}
if len(worker.startCalls) != 0 {
t.Fatal("worker must not start with invalid membership")
}
}
func TestFleetUpdateCancelHappyPath(t *testing.T) { func TestFleetUpdateCancelHappyPath(t *testing.T) {
t.Parallel() t.Parallel()
srv, ts, st := rawTestServer(t) srv, ts, st := rawTestServer(t)
+143
View File
@@ -0,0 +1,143 @@
package http
import (
"context"
"errors"
stdhttp "net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/alert"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
func (s *Server) abandonJob(ctx context.Context, userID *string, jobID, reason string) error {
job, err := s.deps.Store.GetJob(ctx, jobID)
if err != nil {
return err
}
switch api.JobStatus(job.Status) {
case api.JobSucceeded, api.JobFailed, api.JobCancelled:
return errJobTerminal
}
when := time.Now().UTC()
if err := s.deps.Store.MarkJobFinished(ctx, jobID, string(api.JobCancelled), -1, nil, reason, when); err != nil {
return err
}
_ = s.deps.Store.AppendAudit(ctx, store.AuditEntry{
ID: ulid.Make().String(), UserID: userID, Actor: "user",
Action: "job.abandon", TargetKind: ptr("job"), TargetID: &jobID,
TS: when,
})
return nil
}
var errJobTerminal = errors.New("job already terminal")
func (s *Server) handleAbandonJob(w stdhttp.ResponseWriter, r *stdhttp.Request) {
user, ok := s.requireUser(r)
if !ok {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
return
}
jobID := chi.URLParam(r, "id")
err := s.abandonJob(r.Context(), &user.ID, jobID, "abandoned by operator")
switch {
case errors.Is(err, store.ErrNotFound):
writeJSONError(w, stdhttp.StatusNotFound, "job_not_found", "")
case errors.Is(err, errJobTerminal):
writeJSONError(w, stdhttp.StatusConflict, "job_terminal", "job is already terminal")
case err != nil:
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
default:
writeJSON(w, stdhttp.StatusOK, map[string]string{"job_id": jobID, "status": string(api.JobCancelled)})
}
}
func (s *Server) abandonStuckAlerts(ctx context.Context, userID *string) (int, error) {
open, err := s.deps.Store.ListAlerts(ctx, store.AlertFilter{Status: "open"})
if err != nil {
return 0, err
}
acked, err := s.deps.Store.ListAlerts(ctx, store.AlertFilter{Status: "acknowledged"})
if err != nil {
return 0, err
}
count := 0
seen := map[string]bool{}
for _, a := range append(open, acked...) {
if a.Kind != alert.KindJobStuck || seen[a.DedupKey] {
continue
}
seen[a.DedupKey] = true
err := s.abandonJob(ctx, userID, a.DedupKey, "abandoned by operator after stuck-job alert")
if err != nil {
if errors.Is(err, errJobTerminal) || errors.Is(err, store.ErrNotFound) {
s.resolveAlert(ctx, a.ID)
continue
}
return count, err
}
count++
s.resolveAlert(ctx, a.ID)
}
return count, nil
}
func (s *Server) resolveAlert(ctx context.Context, alertID string) {
when := time.Now().UTC()
if s.deps.AlertEngine != nil {
_ = s.deps.AlertEngine.Resolve(ctx, alertID, when)
return
}
_ = s.deps.Store.Resolve(ctx, alertID, when)
}
func (s *Server) handleAbandonStuckJobs(w stdhttp.ResponseWriter, r *stdhttp.Request) {
user, ok := s.requireUser(r)
if !ok {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
return
}
count, err := s.abandonStuckAlerts(r.Context(), &user.ID)
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
writeJSON(w, stdhttp.StatusOK, map[string]int{"abandoned": count})
}
func (s *Server) handleUIAbandonAlertJob(w stdhttp.ResponseWriter, r *stdhttp.Request) {
user := s.requireUIUser(w, r)
if user == nil {
return
}
alertID := chi.URLParam(r, "id")
a, err := s.deps.Store.GetAlert(r.Context(), alertID)
if err != nil || a == nil || a.Kind != alert.KindJobStuck {
stdhttp.Error(w, "stuck-job alert not found", stdhttp.StatusNotFound)
return
}
if err := s.abandonJob(r.Context(), &user.ID, a.DedupKey, "abandoned by operator after stuck-job alert"); err != nil && !errors.Is(err, errJobTerminal) {
stdhttp.Error(w, "unable to abandon job", stdhttp.StatusConflict)
return
}
s.resolveAlert(r.Context(), alertID)
w.Header().Set("HX-Redirect", "/alerts?"+r.URL.RawQuery)
w.WriteHeader(stdhttp.StatusNoContent)
}
func (s *Server) handleUIAbandonStuckJobs(w stdhttp.ResponseWriter, r *stdhttp.Request) {
user := s.requireUIUser(w, r)
if user == nil {
return
}
if _, err := s.abandonStuckAlerts(r.Context(), &user.ID); err != nil {
stdhttp.Error(w, "unable to abandon stuck jobs", stdhttp.StatusInternalServerError)
return
}
stdhttp.Redirect(w, r, "/alerts", stdhttp.StatusSeeOther)
}
+93
View File
@@ -0,0 +1,93 @@
package http
import (
"bytes"
"context"
"encoding/json"
stdhttp "net/http"
"testing"
"time"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/alert"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
func seedRunningJob(t *testing.T, st *store.Store, hostID string) string {
t.Helper()
id := ulid.Make().String()
when := time.Now().UTC().Add(-24 * time.Hour)
if err := st.CreateJob(context.Background(), store.Job{
ID: id, HostID: hostID, Kind: "backup", ActorKind: "user", CreatedAt: when,
}); err != nil {
t.Fatalf("create job: %v", err)
}
if err := st.MarkJobStarted(context.Background(), id, when); err != nil {
t.Fatalf("start job: %v", err)
}
return id
}
func TestAbandonJobMakesOrphanTerminalAndAudits(t *testing.T) {
t.Parallel()
_, ts, st := rawTestServer(t)
hostID := makeHost(t, st, "abandon-host")
jobID := seedRunningJob(t, st, hostID)
req, _ := stdhttp.NewRequest(stdhttp.MethodPost, ts.URL+"/api/jobs/"+jobID+"/abandon", nil)
req.AddCookie(loginAsAdmin(t, st))
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("abandon: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusOK {
t.Fatalf("status: got %d", res.StatusCode)
}
job, _ := st.GetJob(context.Background(), jobID)
if job.Status != "cancelled" || job.FinishedAt == nil || job.Error == nil {
t.Fatalf("job not terminalised: %+v", job)
}
var audits int
_ = st.DB().QueryRow(`SELECT COUNT(*) FROM audit_log WHERE action = 'job.abandon' AND target_id = ?`, jobID).Scan(&audits)
if audits != 1 {
t.Fatalf("audit rows: got %d", audits)
}
}
func TestBulkAbandonTargetsOnlyAlertedStuckJobs(t *testing.T) {
t.Parallel()
_, ts, st := rawTestServer(t)
hostID := makeHost(t, st, "bulk-abandon-host")
stuckID := seedRunningJob(t, st, hostID)
unalertedID := seedRunningJob(t, st, hostID)
_, _, err := st.RaiseOrTouch(context.Background(), hostID, alert.KindJobStuck, stuckID,
"warning", "stuck", time.Now().UTC())
if err != nil {
t.Fatalf("raise alert: %v", err)
}
req, _ := stdhttp.NewRequest(stdhttp.MethodPost, ts.URL+"/api/jobs/abandon-stuck", bytes.NewReader(nil))
req.AddCookie(loginAsAdmin(t, st))
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("bulk abandon: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusOK {
t.Fatalf("status: got %d", res.StatusCode)
}
var out map[string]int
_ = json.NewDecoder(res.Body).Decode(&out)
if out["abandoned"] != 1 {
t.Fatalf("abandoned: %+v", out)
}
stuck, _ := st.GetJob(context.Background(), stuckID)
unalerted, _ := st.GetJob(context.Background(), unalertedID)
if stuck.Status != "cancelled" || unalerted.Status != "running" {
t.Fatalf("statuses: stuck=%s unalerted=%s", stuck.Status, unalerted.Status)
}
open, _ := st.ListAlerts(context.Background(), store.AlertFilter{Status: "open"})
if len(open) != 0 {
t.Fatalf("stuck alerts remained open: %+v", open)
}
}
+28 -2
View File
@@ -65,10 +65,36 @@ func (s *Server) handleRunNow(w stdhttp.ResponseWriter, r *stdhttp.Request) {
func (s *Server) dispatchJob(ctx context.Context, user *store.User, func (s *Server) dispatchJob(ctx context.Context, user *store.User,
hostID string, kind api.JobKind, args []string, hostID string, kind api.JobKind, args []string,
) (res runNowResponse, status int, code, msg string) { ) (res runNowResponse, status int, code, msg string) {
return s.dispatchJobWithPayload(ctx, user, hostID, kind, nil, api.CommandRunPayload{ payload := api.CommandRunPayload{
Kind: kind, Kind: kind,
Args: args, Args: args,
}) }
if kind == api.JobBackup && len(args) == 0 {
return res, stdhttp.StatusUnprocessableEntity, "backup_paths_required",
"backup requires paths in args; configured backups must use POST /api/hosts/{id}/source-groups/{gid}/run"
}
if kind == api.JobForget {
if !validForgetArgs(args) {
return res, stdhttp.StatusBadRequest, "invalid_args",
"forget accepts no arguments other than --dry-run"
}
var ok bool
var err error
payload, ok, err = s.buildForgetPayloadForHost(ctx, hostID)
if err != nil {
return res, stdhttp.StatusInternalServerError, "internal", ""
}
if !ok {
return res, stdhttp.StatusUnprocessableEntity, "no_retention_policy",
"host has no source groups with a retention policy"
}
payload.Args = args
}
return s.dispatchJobWithPayload(ctx, user, hostID, kind, nil, payload)
}
func validForgetArgs(args []string) bool {
return len(args) == 0 || (len(args) == 1 && args[0] == "--dry-run")
} }
// dispatchJobWithPayload is dispatchJob's variant that lets callers // dispatchJobWithPayload is dispatchJob's variant that lets callers
+134
View File
@@ -0,0 +1,134 @@
package http
import (
"bytes"
"context"
"encoding/json"
stdhttp "net/http"
"testing"
"time"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
func TestRunNowBackupRejectsEmptyPaths(t *testing.T) {
t.Parallel()
_, ts, st := rawTestServer(t)
cookie := loginAsAdmin(t, st)
hostID := makeHost(t, st, "empty-backup-host")
req, _ := stdhttp.NewRequest(stdhttp.MethodPost, ts.URL+"/api/hosts/"+hostID+"/jobs",
bytes.NewReader([]byte(`{"kind":"backup"}`)))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post run-now: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusUnprocessableEntity {
t.Fatalf("status: got %d, want 422", res.StatusCode)
}
body := readJSONError(t, res.Body)
if body.Code != "backup_paths_required" {
t.Fatalf("code: got %q", body.Code)
}
var jobs int
_ = st.DB().QueryRow(`SELECT COUNT(*) FROM jobs WHERE host_id = ?`, hostID).Scan(&jobs)
if jobs != 0 {
t.Fatalf("invalid backup created %d jobs", jobs)
}
}
func TestRunNowForgetShipsRetentionGroupsAndDryRun(t *testing.T) {
t.Parallel()
srv, ts, st := rawTestServer(t)
hostID, token := enrolHostForWS(t, srv, st, "manual-forget-host")
seedInitJob(t, st, hostID)
keepDaily := 7
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
ID: ulid.Make().String(),
HostID: hostID,
Name: "documents",
Includes: []string{"/home/documents"},
RetentionPolicy: store.RetentionPolicy{KeepDaily: &keepDaily},
}); err != nil {
t.Fatalf("create source group: %v", err)
}
c := agentDial(t, srv, ts, hostID, token)
sendHello(t, c, "manual-forget-host")
_ = drainUntil(t, c, api.MsgScheduleSet)
body, err := json.Marshal(runNowRequest{Kind: api.JobForget, Args: []string{"--dry-run"}})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req, err := stdhttp.NewRequest(stdhttp.MethodPost, ts.URL+"/api/hosts/"+hostID+"/jobs", bytes.NewReader(body))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.AddCookie(loginAsAdmin(t, st))
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post run-now: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusAccepted {
t.Fatalf("status: got %d, want %d", res.StatusCode, stdhttp.StatusAccepted)
}
got := readNextCommandRun(t, c, time.Now().Add(2*time.Second))
if got == nil {
t.Fatal("no command.run received")
}
if len(got.Args) != 1 || got.Args[0] != "--dry-run" {
t.Fatalf("Args: got %q, want [--dry-run]", got.Args)
}
if len(got.ForgetGroups) != 1 {
t.Fatalf("ForgetGroups: got %d, want 1", len(got.ForgetGroups))
}
group := got.ForgetGroups[0]
if group.Tag != "documents" || group.Policy.KeepDaily == nil || *group.Policy.KeepDaily != 7 {
t.Fatalf("ForgetGroups[0]: got %+v", group)
}
}
func TestRunNowForgetRejectsHostWithoutRetention(t *testing.T) {
t.Parallel()
srv, ts, st := rawTestServer(t)
hostID, token := enrolHostForWS(t, srv, st, "no-manual-retention-host")
seedInitJob(t, st, hostID)
c := agentDial(t, srv, ts, hostID, token)
sendHello(t, c, "no-manual-retention-host")
_ = drainUntil(t, c, api.MsgScheduleSet)
body := []byte(`{"kind":"forget","args":["--dry-run"]}`)
req, err := stdhttp.NewRequest(stdhttp.MethodPost, ts.URL+"/api/hosts/"+hostID+"/jobs", bytes.NewReader(body))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.AddCookie(loginAsAdmin(t, st))
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post run-now: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusUnprocessableEntity {
t.Fatalf("status: got %d, want %d", res.StatusCode, stdhttp.StatusUnprocessableEntity)
}
var jobs int
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = 'forget'`, hostID).Scan(&jobs); err != nil {
t.Fatalf("count forget jobs: %v", err)
}
if jobs != 0 {
t.Fatalf("forget jobs: got %d, want 0", jobs)
}
}
+10 -6
View File
@@ -37,7 +37,12 @@ func (s *Server) DispatchMaintenance(ctx context.Context, decisions []maintenanc
} }
switch d.Kind { switch d.Kind {
case "forget": case "forget":
payload, ok := s.buildForgetPayloadForHost(ctx, d.HostID) payload, ok, err := s.buildForgetPayloadForHost(ctx, d.HostID)
if err != nil {
slog.Warn("maintenance: list source groups failed",
"host_id", d.HostID, "err", err)
continue
}
if !ok { if !ok {
slog.Info("maintenance: forget skipped — no source groups with retention", slog.Info("maintenance: forget skipped — no source groups with retention",
"host_id", d.HostID) "host_id", d.HostID)
@@ -88,11 +93,10 @@ func (s *Server) DispatchMaintenance(ctx context.Context, decisions []maintenanc
// that has a non-empty retention policy and builds a CommandRunPayload // that has a non-empty retention policy and builds a CommandRunPayload
// with ForgetGroups populated. Returns ok=false if the host has no // with ForgetGroups populated. Returns ok=false if the host has no
// such groups (the dispatcher then skips this kind). // such groups (the dispatcher then skips this kind).
func (s *Server) buildForgetPayloadForHost(ctx context.Context, hostID string) (api.CommandRunPayload, bool) { func (s *Server) buildForgetPayloadForHost(ctx context.Context, hostID string) (api.CommandRunPayload, bool, error) {
groups, err := s.deps.Store.ListSourceGroupsByHost(ctx, hostID) groups, err := s.deps.Store.ListSourceGroupsByHost(ctx, hostID)
if err != nil { if err != nil {
slog.Warn("maintenance: list source groups failed", "host_id", hostID, "err", err) return api.CommandRunPayload{}, false, err
return api.CommandRunPayload{}, false
} }
fg := make([]api.ForgetGroup, 0, len(groups)) fg := make([]api.ForgetGroup, 0, len(groups))
for _, g := range groups { for _, g := range groups {
@@ -105,9 +109,9 @@ func (s *Server) buildForgetPayloadForHost(ctx context.Context, hostID string) (
}) })
} }
if len(fg) == 0 { if len(fg) == 0 {
return api.CommandRunPayload{}, false return api.CommandRunPayload{}, false, nil
} }
return api.CommandRunPayload{ForgetGroups: fg}, true return api.CommandRunPayload{ForgetGroups: fg}, true, nil
} }
func isEmptyRetention(p store.RetentionPolicy) bool { func isEmptyRetention(p store.RetentionPolicy) bool {
+27 -1
View File
@@ -8,7 +8,9 @@ import (
"net/netip" "net/netip"
"runtime" "runtime"
"strings" "strings"
"time"
"gitea.dcglab.co.uk/steve/restic-manager/internal/alert"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/metrics" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/metrics"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store" "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
@@ -173,13 +175,37 @@ func (s *Server) gatherMetricsSnapshot(ctx context.Context) (metrics.Snapshot, e
return metrics.Snapshot{}, err return metrics.Snapshot{}, err
} }
bySeverity := map[string]int{"info": 0, "warning": 0, "critical": 0} bySeverity := map[string]int{"info": 0, "warning": 0, "critical": 0}
stuckJobs := 0
var oldestStuckAge time.Duration
now := time.Now().UTC()
running, err := s.deps.Store.ListRunningJobActivity(ctx)
if err != nil {
return metrics.Snapshot{}, err
}
activityByJob := make(map[string]time.Time, len(running))
for _, job := range running {
activityByJob[job.JobID] = job.LastActivity
}
for _, a := range open { for _, a := range open {
bySeverity[a.Severity]++ bySeverity[a.Severity]++
if a.Kind == alert.KindJobStuck {
stuckJobs++
lastActivity, ok := activityByJob[a.DedupKey]
if !ok {
continue
}
if age := now.Sub(lastActivity); age > oldestStuckAge {
oldestStuckAge = age
}
}
} }
reg := s.deps.Metrics reg := s.deps.Metrics
if reg == nil { if reg == nil {
reg = metrics.NewRegistry() // empty histogram block reg = metrics.NewRegistry() // empty histogram block
} }
return reg.SnapshotWith(hostRows, bySeverity, version.Version, version.Commit, runtime.Version()), nil snap := reg.SnapshotWith(hostRows, bySeverity, version.Version, version.Commit, runtime.Version())
snap.StuckJobs = stuckJobs
snap.OldestStuckAge = oldestStuckAge
return snap, nil
} }
+26
View File
@@ -153,6 +153,8 @@ func TestSourceGroupsCRUD(t *testing.T) {
}, },
"retry_max": 3, "retry_max": 3,
"retry_backoff_seconds": 60, "retry_backoff_seconds": 60,
"pre_hook": "prepare-db",
"post_hook": "resume-db",
}, cookie) }, cookie)
if status != 201 { if status != 201 {
t.Fatalf("create status: %d, body: %+v", status, body) t.Fatalf("create status: %d, body: %+v", status, body)
@@ -161,6 +163,16 @@ func TestSourceGroupsCRUD(t *testing.T) {
if gid == "" { if gid == "" {
t.Fatalf("create: no id returned: %+v", body) t.Fatalf("create: no id returned: %+v", body)
} }
if body["has_pre_hook"] != true || body["has_post_hook"] != true {
t.Fatalf("create hook indicators: %+v", body)
}
stored, err := st.GetSourceGroup(context.Background(), hostID, gid)
if err != nil {
t.Fatalf("get stored group: %v", err)
}
if stored.PreHook == "prepare-db" || stored.PostHook == "resume-db" || stored.PreHook == "" || stored.PostHook == "" {
t.Fatalf("hooks not encrypted at rest: pre=%q post=%q", stored.PreHook, stored.PostHook)
}
// Duplicate name → 409. // Duplicate name → 409.
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/source-groups", status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/source-groups",
@@ -185,6 +197,20 @@ func TestSourceGroupsCRUD(t *testing.T) {
if got := body["name"]; got != "system" { if got := body["name"]; got != "system" {
t.Errorf("rename: got %v want system", got) t.Errorf("rename: got %v want system", got)
} }
stored, _ = st.GetSourceGroup(context.Background(), hostID, gid)
if stored.PreHook == "" || stored.PostHook == "" {
t.Fatal("PUT without hook fields cleared existing hooks")
}
// Explicit empty hook clears only that hook; plaintext is never returned.
status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/source-groups/"+gid,
map[string]any{"name": "system", "includes": []string{"/etc"}, "pre_hook": ""}, cookie)
if status != 200 || body["has_pre_hook"] != false || body["has_post_hook"] != true {
t.Fatalf("clear hook status=%d body=%+v", status, body)
}
if _, exposed := body["post_hook"]; exposed {
t.Fatal("source-group response exposed hook plaintext field")
}
// Delete. // Delete.
status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie) status, _ = doJSON(t, url, "DELETE", "/api/hosts/"+hostID+"/source-groups/"+gid, nil, cookie)
+36 -17
View File
@@ -512,11 +512,27 @@ func TestDrainPendingSerializesPerHost(t *testing.T) {
// Connect the agent so DrainPending can dispatch. // Connect the agent so DrainPending can dispatch.
c := agentDial(t, srv, ts, hostID, token) c := agentDial(t, srv, ts, hostID, token)
sendHello(t, c, "serialise-host") sendHello(t, c, "serialise-host")
// Drain the on-hello goroutine's pass first (no pending rows yet), // Wait for the on-hello push to settle.
// then wait for the schedule.set so the connection is fully settled.
_ = drainUntil(t, c, api.MsgScheduleSet) _ = drainUntil(t, c, api.MsgScheduleSet)
// Insert 5 pending rows now that the on-hello drain has already run. // A real agent is always in a read loop. Keep this test client
// reading in the background for the rest of the test: without an
// active reader the server-side conn can be dropped under parallel
// load, which unregisters it from the hub and makes DrainPending
// no-op (conn == nil) — the historical source of this test's
// flakiness (it would observe 0 or a partial drain). The reader also
// consumes the command.run envelopes our drains emit.
readerCtx, stopReader := context.WithCancel(context.Background())
defer stopReader()
go func() {
for {
if _, _, err := c.Read(readerCtx); err != nil {
return
}
}
}()
// Insert 5 due pending rows.
now := time.Now().UTC() now := time.Now().UTC()
for i := range 5 { for i := range 5 {
pid := ulid.Make().String() pid := ulid.Make().String()
@@ -533,7 +549,8 @@ func TestDrainPendingSerializesPerHost(t *testing.T) {
} }
} }
// Spawn 10 goroutines all calling DrainPending concurrently. // Fire 10 concurrent DrainPending calls. The per-host mutex must
// ensure each row is dispatched at most once (no double-dispatch).
var wg sync.WaitGroup var wg sync.WaitGroup
for range 10 { for range 10 {
wg.Add(1) wg.Add(1)
@@ -544,24 +561,26 @@ func TestDrainPendingSerializesPerHost(t *testing.T) {
} }
wg.Wait() wg.Wait()
// Drain any envelopes the agent received so we don't block below. // Drain to completion. The fire-and-forget on-hello DrainPending
// We read with short timeouts and stop when the connection goes quiet. // shares the same per-host mutex and can hold it during the burst,
drainDeadline := time.Now().Add(500 * time.Millisecond) // leaving rows for a later pass — exactly how production drains
for time.Now().Before(drainDeadline) { // (repeatedly, via the 30s tick / on reconnect). Re-drain until the
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) // queue is empty; because every drain is still serialised, each row
_, _, err := c.Read(ctx) // is dispatched at most once, so the exactly-5 job count below proves
cancel() // there was no double-dispatch.
if err != nil { deadline := time.Now().Add(5 * time.Second)
break for countPendingForHost(t, st, hostID) > 0 && time.Now().Before(deadline) {
} srv.DrainPending(context.Background(), hostID)
time.Sleep(10 * time.Millisecond)
} }
// All 5 pending rows must be gone. // All 5 pending rows must be drained.
if n := countPendingForHost(t, st, hostID); n != 0 { if n := countPendingForHost(t, st, hostID); n != 0 {
t.Errorf("pending rows after concurrent drain: got %d, want 0", n) t.Errorf("pending rows after drain-to-completion: got %d, want 0", n)
} }
// Exactly 5 backup job rows (one per pending row), not 10+ from a race. // Exactly 5 backup job rows (one per pending row) — never more, which
// would mean the per-host mutex failed to prevent double-dispatch.
var n int var n int
_ = st.DB().QueryRow( _ = st.DB().QueryRow(
`SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = 'backup' AND actor_kind = 'schedule'`, `SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = 'backup' AND actor_kind = 'schedule'`,
+5
View File
@@ -261,7 +261,10 @@ func (s *Server) routes(r chi.Router) {
r.Post("/api/hosts/{id}/repo/check", s.handleRunRepoCheck) r.Post("/api/hosts/{id}/repo/check", s.handleRunRepoCheck)
r.Post("/api/hosts/{id}/repo/unlock", s.handleRunRepoUnlock) r.Post("/api/hosts/{id}/repo/unlock", s.handleRunRepoUnlock)
r.Post("/api/jobs/{id}/cancel", s.handleCancelJob) r.Post("/api/jobs/{id}/cancel", s.handleCancelJob)
r.Post("/api/jobs/{id}/abandon", s.handleAbandonJob)
r.Post("/api/jobs/abandon-stuck", s.handleAbandonStuckJobs)
r.Post("/api/hosts/{id}/snapshots/diff", s.handleSnapshotDiff) r.Post("/api/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
r.Post("/api/hosts/{id}/snapshots/refresh", s.handleRefreshHostSnapshots)
// HTMX form variants outside /api. // HTMX form variants outside /api.
r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff) r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
@@ -297,6 +300,8 @@ func (s *Server) routes(r chi.Router) {
r.Post("/hosts/{id}/restore", s.handleUIRestorePost) r.Post("/hosts/{id}/restore", s.handleUIRestorePost)
r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge) r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge)
r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve) r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve)
r.Post("/alerts/{id}/abandon-job", s.handleUIAbandonAlertJob)
r.Post("/alerts/abandon-stuck", s.handleUIAbandonStuckJobs)
} }
}) })
+41 -4
View File
@@ -5,6 +5,10 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
) )
// snapshotView is the public JSON shape for a snapshot. Matches the // snapshotView is the public JSON shape for a snapshot. Matches the
@@ -26,6 +30,7 @@ type listSnapshotsResponse struct {
HostID string `json:"host_id"` HostID string `json:"host_id"`
Count int `json:"count"` Count int `json:"count"`
RefreshedAt *time.Time `json:"refreshed_at,omitempty"` RefreshedAt *time.Time `json:"refreshed_at,omitempty"`
Stale bool `json:"stale"`
Snapshots []snapshotView `json:"snapshots"` Snapshots []snapshotView `json:"snapshots"`
} }
@@ -45,7 +50,8 @@ func (s *Server) handleListHostSnapshots(w stdhttp.ResponseWriter, r *stdhttp.Re
return return
} }
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil { host, err := s.deps.Store.GetHost(r.Context(), hostID)
if err != nil {
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "") writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
return return
} }
@@ -61,10 +67,13 @@ func (s *Server) handleListHostSnapshots(w stdhttp.ResponseWriter, r *stdhttp.Re
Count: len(snaps), Count: len(snaps),
Snapshots: make([]snapshotView, len(snaps)), Snapshots: make([]snapshotView, len(snaps)),
} }
if len(snaps) > 0 { out.RefreshedAt = host.SnapshotRefreshedAt
t := snaps[0].RefreshedAt mutationAt, err := s.deps.Store.LatestSuccessfulRepoMutation(r.Context(), hostID)
out.RefreshedAt = &t if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
} }
out.Stale = mutationAt != nil && (out.RefreshedAt == nil || out.RefreshedAt.Before(*mutationAt))
for i, sn := range snaps { for i, sn := range snaps {
out.Snapshots[i] = snapshotView{ out.Snapshots[i] = snapshotView{
ID: sn.ID, ID: sn.ID,
@@ -80,3 +89,31 @@ func (s *Server) handleListHostSnapshots(w stdhttp.ResponseWriter, r *stdhttp.Re
writeJSON(w, stdhttp.StatusOK, out) writeJSON(w, stdhttp.StatusOK, out)
} }
func (s *Server) handleRefreshHostSnapshots(w stdhttp.ResponseWriter, r *stdhttp.Request) {
user, ok := s.requireUser(r)
if !ok {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
return
}
hostID := chi.URLParam(r, "id")
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
writeJSONError(w, stdhttp.StatusNotFound, "host_not_found", "")
return
}
if s.deps.Hub == nil || !s.deps.Hub.Connected(hostID) {
writeJSONError(w, stdhttp.StatusConflict, "host_offline", "agent is not currently connected")
return
}
env, _ := api.Marshal(api.MsgSnapshotsRefresh, ulid.Make().String(), nil)
if err := s.deps.Hub.Send(r.Context(), hostID, env); err != nil {
writeJSONError(w, stdhttp.StatusConflict, "host_offline", err.Error())
return
}
now := time.Now().UTC()
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: &user.ID, Actor: "user",
Action: "host.snapshots_refresh", TargetKind: ptr("host"), TargetID: &hostID, TS: now,
})
writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "refresh_requested"})
}
+83
View File
@@ -0,0 +1,83 @@
package http
import (
"context"
"encoding/json"
stdhttp "net/http"
"testing"
"time"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
func TestSnapshotsFreshnessAndExplicitRefresh(t *testing.T) {
t.Parallel()
srv, ts, st := rawTestServerWithUI(t)
hostID, token := enrolHostForUI(t, srv, st, "snapshot-refresh-host")
c := agentDial(t, srv, ts, hostID, token)
sendHello(t, c, "snapshot-refresh-host")
_ = drainUntil(t, c, api.MsgScheduleSet)
cookie := loginAsAdmin(t, st)
mutationAt := time.Now().UTC().Add(-time.Minute).Truncate(time.Millisecond)
if err := st.CreateJob(context.Background(), store.Job{
ID: "mutation-job", HostID: hostID, Kind: "forget", ActorKind: "user", CreatedAt: mutationAt.Add(-time.Minute),
}); err != nil {
t.Fatalf("create mutation: %v", err)
}
if err := st.MarkJobFinished(context.Background(), "mutation-job", "succeeded", 0, nil, "", mutationAt); err != nil {
t.Fatalf("finish mutation: %v", err)
}
get := func() listSnapshotsResponse {
req, _ := stdhttp.NewRequest(stdhttp.MethodGet, ts.URL+"/api/hosts/"+hostID+"/snapshots", nil)
req.AddCookie(cookie)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get snapshots: %v", err)
}
defer res.Body.Close()
var body listSnapshotsResponse
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
t.Fatalf("decode snapshots: %v", err)
}
return body
}
if body := get(); !body.Stale || body.RefreshedAt != nil {
t.Fatalf("unrefreshed projection should be stale: %+v", body)
}
refreshedAt := mutationAt.Add(time.Second)
if err := st.ReplaceHostSnapshots(context.Background(), hostID, nil, refreshedAt); err != nil {
t.Fatalf("replace empty: %v", err)
}
if body := get(); body.Stale || body.RefreshedAt == nil || !body.RefreshedAt.Equal(refreshedAt) {
t.Fatalf("fresh empty projection reported incorrectly: %+v", body)
}
req, _ := stdhttp.NewRequest(stdhttp.MethodPost, ts.URL+"/api/hosts/"+hostID+"/snapshots/refresh", nil)
req.AddCookie(cookie)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request refresh: %v", err)
}
defer res.Body.Close()
if res.StatusCode != stdhttp.StatusAccepted {
t.Fatalf("refresh status = %d, want 202", res.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, raw, err := c.Read(ctx)
if err != nil {
t.Fatalf("read refresh command: %v", err)
}
var env api.Envelope
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("decode envelope: %v", err)
}
if env.Type != api.MsgSnapshotsRefresh {
t.Fatalf("message type = %q, want %q", env.Type, api.MsgSnapshotsRefresh)
}
}
+39 -1
View File
@@ -29,6 +29,8 @@ type sourceGroupView struct {
RetryMax int `json:"retry_max"` RetryMax int `json:"retry_max"`
RetryBackoffSeconds int `json:"retry_backoff_seconds"` RetryBackoffSeconds int `json:"retry_backoff_seconds"`
ConflictDimension string `json:"conflict_dimension,omitempty"` ConflictDimension string `json:"conflict_dimension,omitempty"`
HasPreHook bool `json:"has_pre_hook"`
HasPostHook bool `json:"has_post_hook"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -49,6 +51,8 @@ func toSourceGroupView(g store.SourceGroup) sourceGroupView {
RetryMax: g.RetryMax, RetryMax: g.RetryMax,
RetryBackoffSeconds: g.RetryBackoffSeconds, RetryBackoffSeconds: g.RetryBackoffSeconds,
ConflictDimension: g.ConflictDimension, ConflictDimension: g.ConflictDimension,
HasPreHook: g.PreHook != "",
HasPostHook: g.PostHook != "",
CreatedAt: g.CreatedAt, CreatedAt: g.CreatedAt,
UpdatedAt: g.UpdatedAt, UpdatedAt: g.UpdatedAt,
} }
@@ -62,6 +66,29 @@ type sourceGroupWriteRequest struct {
RetentionPolicy store.RetentionPolicy `json:"retention_policy"` RetentionPolicy store.RetentionPolicy `json:"retention_policy"`
RetryMax int `json:"retry_max"` RetryMax int `json:"retry_max"`
RetryBackoffSeconds int `json:"retry_backoff_seconds"` RetryBackoffSeconds int `json:"retry_backoff_seconds"`
// Pointer fields distinguish omission (preserve on PUT) from an explicit
// empty string (clear). Hook plaintext is accepted on write but never
// returned by the JSON API.
PreHook *string `json:"pre_hook,omitempty"`
PostHook *string `json:"post_hook,omitempty"`
}
func (s *Server) applySourceGroupHooks(hostID string, req sourceGroupWriteRequest, g *store.SourceGroup) error {
if req.PreHook != nil {
enc, err := s.EncryptHookForGroup(hostID, "pre", *req.PreHook)
if err != nil {
return err
}
g.PreHook = enc
}
if req.PostHook != nil {
enc, err := s.EncryptHookForGroup(hostID, "post", *req.PostHook)
if err != nil {
return err
}
g.PostHook = enc
}
return nil
} }
func (s *Server) handleListSourceGroups(w stdhttp.ResponseWriter, r *stdhttp.Request) { func (s *Server) handleListSourceGroups(w stdhttp.ResponseWriter, r *stdhttp.Request) {
@@ -142,6 +169,10 @@ func (s *Server) handleCreateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Re
RetryMax: req.RetryMax, RetryMax: req.RetryMax,
RetryBackoffSeconds: req.RetryBackoffSeconds, RetryBackoffSeconds: req.RetryBackoffSeconds,
} }
if err := s.applySourceGroupHooks(hostID, req, &g); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "hook_encryption_failed", "")
return
}
if err := s.deps.Store.CreateSourceGroup(r.Context(), &g); err != nil { if err := s.deps.Store.CreateSourceGroup(r.Context(), &g); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
return return
@@ -157,7 +188,8 @@ func (s *Server) handleUpdateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Re
} }
hostID := chi.URLParam(r, "id") hostID := chi.URLParam(r, "id")
groupID := chi.URLParam(r, "gid") groupID := chi.URLParam(r, "gid")
if _, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID); err != nil { existingGroup, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, groupID)
if err != nil {
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
writeJSONError(w, stdhttp.StatusNotFound, "group_not_found", "") writeJSONError(w, stdhttp.StatusNotFound, "group_not_found", "")
return return
@@ -188,6 +220,12 @@ func (s *Server) handleUpdateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Re
RetentionPolicy: req.RetentionPolicy, RetentionPolicy: req.RetentionPolicy,
RetryMax: req.RetryMax, RetryMax: req.RetryMax,
RetryBackoffSeconds: req.RetryBackoffSeconds, RetryBackoffSeconds: req.RetryBackoffSeconds,
PreHook: existingGroup.PreHook,
PostHook: existingGroup.PostHook,
}
if err := s.applySourceGroupHooks(hostID, req, &g); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "hook_encryption_failed", "")
return
} }
if err := s.deps.Store.UpdateSourceGroup(r.Context(), &g); err != nil { if err := s.deps.Store.UpdateSourceGroup(r.Context(), &g); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
+9
View File
@@ -155,6 +155,8 @@ type Snapshot struct {
BuildCommit string BuildCommit string
GoVersion string GoVersion string
JobDurationRows []HistogramRow JobDurationRows []HistogramRow
StuckJobs int
OldestStuckAge time.Duration
} }
// SnapshotWith builds a Snapshot from raw inputs and the registry's // SnapshotWith builds a Snapshot from raw inputs and the registry's
@@ -205,6 +207,13 @@ func Render(w io.Writer, s Snapshot) error {
fmt.Fprintf(&b, "rm_build_info{version=%q,commit=%q,go_version=%q} 1\n", fmt.Fprintf(&b, "rm_build_info{version=%q,commit=%q,go_version=%q} 1\n",
s.BuildVersion, s.BuildCommit, s.GoVersion) s.BuildVersion, s.BuildCommit, s.GoVersion)
b.WriteString("# HELP rm_stuck_jobs Number of open job_stuck alerts.\n")
b.WriteString("# TYPE rm_stuck_jobs gauge\n")
fmt.Fprintf(&b, "rm_stuck_jobs %d\n", s.StuckJobs)
b.WriteString("# HELP rm_oldest_stuck_job_age_seconds Time since last activity for the oldest open stuck job.\n")
b.WriteString("# TYPE rm_oldest_stuck_job_age_seconds gauge\n")
fmt.Fprintf(&b, "rm_oldest_stuck_job_age_seconds %.0f\n", s.OldestStuckAge.Seconds())
// --- Per-host gauges ------------------------------------------------- // --- Per-host gauges -------------------------------------------------
// Stable order: by host id. // Stable order: by host id.
hosts := append([]HostRow(nil), s.Hosts...) hosts := append([]HostRow(nil), s.Hosts...)
+4
View File
@@ -114,6 +114,8 @@ func TestRenderGolden(t *testing.T) {
snap := r.SnapshotWith(hosts, snap := r.SnapshotWith(hosts,
map[string]int{"info": 0, "warning": 1, "critical": 0}, map[string]int{"info": 0, "warning": 1, "critical": 0},
"v1.2.3", "deadbeef", "go1.25.0") "v1.2.3", "deadbeef", "go1.25.0")
snap.StuckJobs = 2
snap.OldestStuckAge = 90 * time.Minute
var buf bytes.Buffer var buf bytes.Buffer
if err := Render(&buf, snap); err != nil { if err := Render(&buf, snap); err != nil {
@@ -129,6 +131,8 @@ func TestRenderGolden(t *testing.T) {
`rm_active_alerts{severity="info"} 0`, `rm_active_alerts{severity="info"} 0`,
`rm_active_alerts{severity="critical"} 0`, `rm_active_alerts{severity="critical"} 0`,
`rm_build_info{version="v1.2.3",commit="deadbeef",go_version="go1.25.0"} 1`, `rm_build_info{version="v1.2.3",commit="deadbeef",go_version="go1.25.0"} 1`,
`rm_stuck_jobs 2`,
`rm_oldest_stuck_job_age_seconds 5400`,
`rm_host_agent_online{host_id="01H0001",host="alpha"} 1`, `rm_host_agent_online{host_id="01H0001",host="alpha"} 1`,
`rm_host_agent_online{host_id="01H0002",host="bravo"} 0`, `rm_host_agent_online{host_id="01H0002",host="bravo"} 0`,
`rm_host_last_backup_timestamp_seconds{host_id="01H0001",host="alpha"} 1700000000`, `rm_host_last_backup_timestamp_seconds{host_id="01H0001",host="alpha"} 1700000000`,
+21 -4
View File
@@ -77,6 +77,7 @@ func AgentHandler(deps HandlerDeps) stdhttp.Handler {
slog.Warn("ws accept failed", "err", err, "host_id", host.ID) slog.Warn("ws accept failed", "err", err, "host_id", host.ID)
return return
} }
conn.SetReadLimit(api.MaxWebSocketMessageBytes)
c := NewConn(host.ID, conn) c := NewConn(host.ID, conn)
// Keep agents alive across NAT boxes; coder/websocket // Keep agents alive across NAT boxes; coder/websocket
@@ -356,10 +357,26 @@ func dispatchAgentMessage(ctx context.Context, c *Conn, hostID string, env api.E
} }
case api.MsgCommandResult: case api.MsgCommandResult:
// TODO(P2): persist command.result acks for "did the agent var p api.CommandResultPayload
// accept the dispatch?" forensics. Currently the job lifecycle if err := env.UnmarshalPayload(&p); err != nil {
// (job.started → job.finished) is sufficient signal. slog.Warn("ws: decode command result", "host_id", hostID, "err", err)
slog.Debug("ws msg not yet handled", "type", env.Type, "host_id", hostID) break
}
// A cancel for a job unknown to the current agent process can never
// produce job.finished. Terminalise the orphan server-side.
if !p.Accepted && p.Error == "job_not_found" && p.JobID != "" {
job, err := deps.Store.GetJob(ctx, p.JobID)
if err != nil || job.HostID != hostID {
slog.Warn("ws: reject orphan result for foreign or missing job", "job_id", p.JobID, "host_id", hostID)
break
}
if job.Status == string(api.JobQueued) || job.Status == string(api.JobRunning) {
if err := deps.Store.MarkJobFinished(ctx, p.JobID, string(api.JobCancelled), -1, nil,
"agent reported job not found during cancellation", time.Now().UTC()); err != nil {
slog.Warn("ws: terminalise orphaned job", "job_id", p.JobID, "err", err)
}
}
}
case api.MsgTreeListResult: case api.MsgTreeListResult:
// Reply to a synchronous tree.list RPC. Route to the waiter // Reply to a synchronous tree.list RPC. Route to the waiter
+48
View File
@@ -36,6 +36,54 @@ func seedHostWS(t *testing.T, s *store.Store, hostID string) {
func int64ptrWS(v int64) *int64 { return &v } func int64ptrWS(v int64) *int64 { return &v }
func boolptrWS(v bool) *bool { return &v } func boolptrWS(v bool) *bool { return &v }
func TestUnknownCancelResultTerminalisesOrphanedJob(t *testing.T) {
t.Parallel()
s := openWSTestStore(t)
ctx := context.Background()
const hostID = "h-orphan-ws"
seedHostWS(t, s, hostID)
jobID := "j-orphan-ws"
started := time.Now().UTC().Add(-time.Hour)
if err := s.CreateJob(ctx, store.Job{ID: jobID, HostID: hostID, Kind: "backup", ActorKind: "user", CreatedAt: started}); err != nil {
t.Fatalf("create job: %v", err)
}
if err := s.MarkJobStarted(ctx, jobID, started); err != nil {
t.Fatalf("start job: %v", err)
}
env, _ := api.Marshal(api.MsgCommandResult, jobID, api.CommandResultPayload{
JobID: jobID, Accepted: false, Error: "job_not_found",
})
dispatchAgentMessage(ctx, nil, hostID, env, HandlerDeps{Store: s})
job, _ := s.GetJob(ctx, jobID)
if job.Status != "cancelled" || job.FinishedAt == nil {
t.Fatalf("orphan not terminalised: %+v", job)
}
}
func TestUnknownCancelResultCannotTerminaliseAnotherHostsJob(t *testing.T) {
t.Parallel()
s := openWSTestStore(t)
ctx := context.Background()
seedHostWS(t, s, "reporting-host")
seedHostWS(t, s, "job-host")
const jobID = "j-foreign-ws"
started := time.Now().UTC().Add(-time.Hour)
if err := s.CreateJob(ctx, store.Job{ID: jobID, HostID: "job-host", Kind: "backup", ActorKind: "user", CreatedAt: started}); err != nil {
t.Fatalf("create job: %v", err)
}
if err := s.MarkJobStarted(ctx, jobID, started); err != nil {
t.Fatalf("start job: %v", err)
}
env, _ := api.Marshal(api.MsgCommandResult, jobID, api.CommandResultPayload{
JobID: jobID, Accepted: false, Error: "job_not_found",
})
dispatchAgentMessage(ctx, nil, "reporting-host", env, HandlerDeps{Store: s})
job, _ := s.GetJob(ctx, jobID)
if job.Status != "running" || job.FinishedAt != nil {
t.Fatalf("foreign job was modified: %+v", job)
}
}
func TestRepoStatsReportPersisted(t *testing.T) { func TestRepoStatsReportPersisted(t *testing.T) {
t.Parallel() t.Parallel()
s := openWSTestStore(t) s := openWSTestStore(t)
+65
View File
@@ -123,6 +123,71 @@ func TestWSHelloAndHeartbeat(t *testing.T) {
t.Error("heartbeat did not update last_seen_at") t.Error("heartbeat did not update last_seen_at")
} }
func TestWSAcceptsSnapshotReportLargerThanDefaultReadLimit(t *testing.T) {
t.Parallel()
url, token, hostID, st, hub := setupTestHub(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
c, resp, err := websocket.Dial(ctx, url, &websocket.DialOptions{
HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}},
})
if resp != nil && resp.Body != nil {
defer resp.Body.Close() //nolint:errcheck
}
if err != nil {
t.Fatalf("dial: %v", err)
}
defer c.CloseNow() //nolint:errcheck
hello, _ := api.Marshal(api.MsgHello, "", api.HelloPayload{
ProtocolVersion: api.CurrentProtocolVersion,
AgentVersion: "0.1.0",
ResticVersion: "0.17.1",
Hostname: "h1",
OS: api.OSLinux,
Arch: api.ArchAmd64,
})
helloRaw, _ := json.Marshal(hello)
if err := c.Write(ctx, websocket.MessageText, helloRaw); err != nil {
t.Fatalf("write hello: %v", err)
}
deadline := time.Now().Add(time.Second)
for !hub.Connected(hostID) && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
report, _ := api.Marshal(api.MsgSnapshotsRpt, "", api.SnapshotsReportPayload{
Snapshots: []api.Snapshot{{
ID: strings.Repeat("a", 64),
ShortID: "aaaaaaaa",
Time: time.Now().UTC(),
Hostname: "h1",
Paths: []string{"/" + strings.Repeat("long-path/", 5000)},
}},
})
reportRaw, _ := json.Marshal(report)
if len(reportRaw) <= 32*1024 {
t.Fatalf("test payload is only %d bytes; must exceed old limit", len(reportRaw))
}
if int64(len(reportRaw)) >= api.MaxWebSocketMessageBytes {
t.Fatalf("test payload %d exceeds protocol limit", len(reportRaw))
}
if err := c.Write(ctx, websocket.MessageText, reportRaw); err != nil {
t.Fatalf("write snapshots.report: %v", err)
}
deadline = time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
host, err := st.GetHost(context.Background(), hostID)
if err == nil && host.SnapshotCount == 1 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("oversized snapshots.report was not projected")
}
func TestWSRejectsOldProtocol(t *testing.T) { func TestWSRejectsOldProtocol(t *testing.T) {
t.Parallel() t.Parallel()
url, token, _, _, _ := setupTestHub(t) url, token, _, _, _ := setupTestHub(t)
+11 -7
View File
@@ -42,9 +42,9 @@ func (st *Store) CreateFleetUpdate(ctx context.Context, fu FleetUpdate, hostIDs
} }
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`INSERT INTO fleet_updates (id, started_at, started_by_user_id, target_version, status) `INSERT INTO fleet_updates (id, started_at, started_by_user_id, target_version, status, pause_after_first)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
fu.ID, fu.StartedAt.UTC().Format(time.RFC3339Nano), fu.StartedByUserID, fu.TargetVersion, fu.Status, fu.ID, fu.StartedAt.UTC().Format(time.RFC3339Nano), fu.StartedByUserID, fu.TargetVersion, fu.Status, fu.PauseAfterFirst,
); err != nil { ); err != nil {
return fmt.Errorf("store: insert fleet_updates: %w", err) return fmt.Errorf("store: insert fleet_updates: %w", err)
} }
@@ -67,12 +67,13 @@ func (st *Store) ActiveFleetUpdate(ctx context.Context) (*FleetUpdate, error) {
var current sql.NullString var current sql.NullString
var halted sql.NullString var halted sql.NullString
var completedAt sql.NullString var completedAt sql.NullString
var pauseAfterFirst int
err := st.db.QueryRowContext(ctx, err := st.db.QueryRowContext(ctx,
`SELECT id, started_at, started_by_user_id, target_version, status, `SELECT id, started_at, started_by_user_id, target_version, status,
current_host_id, halted_reason, completed_at current_host_id, halted_reason, completed_at, pause_after_first
FROM fleet_updates WHERE status = 'running' LIMIT 1`). FROM fleet_updates WHERE status = 'running' LIMIT 1`).
Scan(&fu.ID, &startedAt, &fu.StartedByUserID, &fu.TargetVersion, &fu.Status, Scan(&fu.ID, &startedAt, &fu.StartedByUserID, &fu.TargetVersion, &fu.Status,
&current, &halted, &completedAt) &current, &halted, &completedAt, &pauseAfterFirst)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
} }
@@ -82,6 +83,7 @@ func (st *Store) ActiveFleetUpdate(ctx context.Context) (*FleetUpdate, error) {
fu.StartedAt, _ = time.Parse(time.RFC3339Nano, startedAt) fu.StartedAt, _ = time.Parse(time.RFC3339Nano, startedAt)
fu.CurrentHostID = current.String fu.CurrentHostID = current.String
fu.HaltedReason = halted.String fu.HaltedReason = halted.String
fu.PauseAfterFirst = pauseAfterFirst != 0
if completedAt.Valid { if completedAt.Valid {
t, _ := time.Parse(time.RFC3339Nano, completedAt.String) t, _ := time.Parse(time.RFC3339Nano, completedAt.String)
fu.CompletedAt = &t fu.CompletedAt = &t
@@ -97,12 +99,13 @@ func (st *Store) GetFleetUpdate(ctx context.Context, id string) (*FleetUpdate, [
var current sql.NullString var current sql.NullString
var halted sql.NullString var halted sql.NullString
var completedAt sql.NullString var completedAt sql.NullString
var pauseAfterFirst int
err := st.db.QueryRowContext(ctx, err := st.db.QueryRowContext(ctx,
`SELECT id, started_at, started_by_user_id, target_version, status, `SELECT id, started_at, started_by_user_id, target_version, status,
current_host_id, halted_reason, completed_at current_host_id, halted_reason, completed_at, pause_after_first
FROM fleet_updates WHERE id = ?`, id). FROM fleet_updates WHERE id = ?`, id).
Scan(&fu.ID, &startedAt, &fu.StartedByUserID, &fu.TargetVersion, &fu.Status, Scan(&fu.ID, &startedAt, &fu.StartedByUserID, &fu.TargetVersion, &fu.Status,
&current, &halted, &completedAt) &current, &halted, &completedAt, &pauseAfterFirst)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil, ErrNotFound return nil, nil, ErrNotFound
} }
@@ -112,6 +115,7 @@ func (st *Store) GetFleetUpdate(ctx context.Context, id string) (*FleetUpdate, [
fu.StartedAt, _ = time.Parse(time.RFC3339Nano, startedAt) fu.StartedAt, _ = time.Parse(time.RFC3339Nano, startedAt)
fu.CurrentHostID = current.String fu.CurrentHostID = current.String
fu.HaltedReason = halted.String fu.HaltedReason = halted.String
fu.PauseAfterFirst = pauseAfterFirst != 0
if completedAt.Valid { if completedAt.Valid {
t, _ := time.Parse(time.RFC3339Nano, completedAt.String) t, _ := time.Parse(time.RFC3339Nano, completedAt.String)
fu.CompletedAt = &t fu.CompletedAt = &t
+12 -4
View File
@@ -44,7 +44,7 @@ func (s *Store) LookupHostByAgentToken(ctx context.Context, tokenHash string) (*
repo_size_bytes, snapshot_count, open_alert_count, repo_size_bytes, snapshot_count, open_alert_count,
applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps, applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps,
pre_hook_default, post_hook_default, pre_hook_default, post_hook_default,
repo_status, repo_status_error, always_on repo_status, repo_status_error, always_on, snapshot_refreshed_at
FROM hosts WHERE agent_token_hash = ?`, FROM hosts WHERE agent_token_hash = ?`,
tokenHash) tokenHash)
return scanHost(row) return scanHost(row)
@@ -59,7 +59,7 @@ func (s *Store) GetHost(ctx context.Context, id string) (*Host, error) {
repo_size_bytes, snapshot_count, open_alert_count, repo_size_bytes, snapshot_count, open_alert_count,
applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps, applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps,
pre_hook_default, post_hook_default, pre_hook_default, post_hook_default,
repo_status, repo_status_error, always_on repo_status, repo_status_error, always_on, snapshot_refreshed_at
FROM hosts WHERE id = ?`, id) FROM hosts WHERE id = ?`, id)
return scanHost(row) return scanHost(row)
} }
@@ -227,7 +227,7 @@ func (s *Store) ListHosts(ctx context.Context) ([]Host, error) {
repo_size_bytes, snapshot_count, open_alert_count, repo_size_bytes, snapshot_count, open_alert_count,
applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps, applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps,
pre_hook_default, post_hook_default, pre_hook_default, post_hook_default,
repo_status, repo_status_error, always_on repo_status, repo_status_error, always_on, snapshot_refreshed_at
FROM hosts ORDER BY name`) FROM hosts ORDER BY name`)
if err != nil { if err != nil {
return nil, fmt.Errorf("store: list hosts: %w", err) return nil, fmt.Errorf("store: list hosts: %w", err)
@@ -268,6 +268,7 @@ func scanHostRow(s hostScanner) (*Host, error) {
bwUp, bwDown sql.NullInt64 bwUp, bwDown sql.NullInt64
preHook, postHook sql.NullString preHook, postHook sql.NullString
alwaysOn int alwaysOn int
snapshotRefreshedAt sql.NullString
) )
err := s.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, err := s.Scan(&h.ID, &h.Name, &h.OS, &h.Arch,
&h.AgentVersion, &h.ResticVersion, &h.ProtocolVersion, &h.AgentVersion, &h.ResticVersion, &h.ProtocolVersion,
@@ -276,7 +277,7 @@ func scanHostRow(s hostScanner) (*Host, error) {
&h.RepoSizeBytes, &h.SnapshotCount, &h.OpenAlertCount, &h.RepoSizeBytes, &h.SnapshotCount, &h.OpenAlertCount,
&h.AppliedScheduleVersion, &bwUp, &bwDown, &h.AppliedScheduleVersion, &bwUp, &bwDown,
&preHook, &postHook, &preHook, &postHook,
&h.RepoStatus, &h.RepoStatusError, &alwaysOn) &h.RepoStatus, &h.RepoStatusError, &alwaysOn, &snapshotRefreshedAt)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
@@ -332,6 +333,13 @@ func scanHostRow(s hostScanner) (*Host, error) {
h.PostHookDefault = postHook.String h.PostHookDefault = postHook.String
} }
h.AlwaysOn = alwaysOn != 0 h.AlwaysOn = alwaysOn != 0
if snapshotRefreshedAt.Valid {
t, err := time.Parse(time.RFC3339Nano, snapshotRefreshedAt.String)
if err != nil {
return nil, fmt.Errorf("store: parse snapshot_refreshed_at: %w", err)
}
h.SnapshotRefreshedAt = &t
}
return &h, nil return &h, nil
} }
+67
View File
@@ -27,6 +27,73 @@ type Job struct {
CreatedAt time.Time CreatedAt time.Time
} }
// RunningJobActivity is the persisted activity summary used to detect jobs
// whose terminal message was lost. LastActivity is the newer of started_at and
// the most recent persisted log line; ephemeral progress events deliberately
// do not extend it.
type RunningJobActivity struct {
JobID string
HostID string
Kind string
StartedAt time.Time
LastActivity time.Time
}
// LatestSuccessfulRepoMutation returns the newest completion time for a job
// that can change the repository's snapshot projection.
func (s *Store) LatestSuccessfulRepoMutation(ctx context.Context, hostID string) (*time.Time, error) {
var raw sql.NullString
err := s.db.QueryRowContext(ctx, `
SELECT MAX(finished_at) FROM jobs
WHERE host_id = ? AND status = 'succeeded'
AND kind IN ('backup', 'forget', 'prune')`, hostID).Scan(&raw)
if err != nil {
return nil, fmt.Errorf("store: latest successful repo mutation: %w", err)
}
if !raw.Valid {
return nil, nil
}
t, err := time.Parse(time.RFC3339Nano, raw.String)
if err != nil {
return nil, fmt.Errorf("store: parse latest repo mutation: %w", err)
}
return &t, nil
}
// ListRunningJobActivity returns every running job with its latest persisted
// activity. Jobs without started_at are excluded because they have not actually
// entered the running state coherently and cannot be aged safely here.
func (s *Store) ListRunningJobActivity(ctx context.Context) ([]RunningJobActivity, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT j.id, j.host_id, j.kind, j.started_at,
COALESCE(MAX(l.ts), j.started_at) AS last_activity
FROM jobs j
LEFT JOIN job_logs l ON l.job_id = j.id
WHERE j.status = 'running' AND j.started_at IS NOT NULL
GROUP BY j.id, j.host_id, j.kind, j.started_at
ORDER BY j.started_at`)
if err != nil {
return nil, fmt.Errorf("store: list running job activity: %w", err)
}
defer func() { _ = rows.Close() }()
var out []RunningJobActivity
for rows.Next() {
var item RunningJobActivity
var started, activity string
if err := rows.Scan(&item.JobID, &item.HostID, &item.Kind, &started, &activity); err != nil {
return nil, fmt.Errorf("store: scan running job activity: %w", err)
}
item.StartedAt, _ = time.Parse(time.RFC3339Nano, started)
item.LastActivity, _ = time.Parse(time.RFC3339Nano, activity)
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate running job activity: %w", err)
}
return out, nil
}
// CreateJob inserts a queued job. The agent will mark it running // CreateJob inserts a queued job. The agent will mark it running
// when it actually starts work. ScheduledID is set when the job // when it actually starts work. ScheduledID is set when the job
// originates from a cron fire (actor_kind="schedule"); nil for // originates from a cron fire (actor_kind="schedule"); nil for
+42
View File
@@ -7,6 +7,48 @@ import (
"time" "time"
) )
func TestListRunningJobActivity(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
hostID := makeSchedHost(t, s)
started := time.Now().UTC().Add(-8 * time.Hour).Truncate(time.Second)
for _, id := range []string{"running-idle", "running-active", "finished"} {
if err := s.CreateJob(ctx, Job{ID: id, HostID: hostID, Kind: "backup", ActorKind: "user", CreatedAt: started}); err != nil {
t.Fatalf("create %s: %v", id, err)
}
if err := s.MarkJobStarted(ctx, id, started); err != nil {
t.Fatalf("start %s: %v", id, err)
}
}
activity := started.Add(7 * time.Hour)
if err := s.AppendJobLog(ctx, "running-active", 1, activity, "stdout", "still working"); err != nil {
t.Fatalf("append log: %v", err)
}
if err := s.MarkJobFinished(ctx, "finished", "succeeded", 0, nil, "", activity); err != nil {
t.Fatalf("finish: %v", err)
}
got, err := s.ListRunningJobActivity(ctx)
if err != nil {
t.Fatalf("list activity: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d rows, want 2: %+v", len(got), got)
}
byID := make(map[string]RunningJobActivity, len(got))
for _, row := range got {
byID[row.JobID] = row
}
if !byID["running-idle"].LastActivity.Equal(started) {
t.Errorf("idle last activity = %s, want %s", byID["running-idle"].LastActivity, started)
}
if !byID["running-active"].LastActivity.Equal(activity) {
t.Errorf("active last activity = %s, want %s", byID["running-active"].LastActivity, activity)
}
}
func TestLatestJobByKind(t *testing.T) { func TestLatestJobByKind(t *testing.T) {
t.Parallel() t.Parallel()
s := openTestStore(t) s := openTestStore(t)
@@ -0,0 +1 @@
ALTER TABLE hosts ADD COLUMN snapshot_refreshed_at TEXT;
@@ -0,0 +1 @@
ALTER TABLE fleet_updates ADD COLUMN pause_after_first INTEGER NOT NULL DEFAULT 0;
+2 -2
View File
@@ -69,8 +69,8 @@ func (s *Store) ReplaceHostSnapshots(ctx context.Context, hostID string, snaps [
} }
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`UPDATE hosts SET snapshot_count = ? WHERE id = ?`, `UPDATE hosts SET snapshot_count = ?, snapshot_refreshed_at = ? WHERE id = ?`,
len(snaps), hostID); err != nil { len(snaps), when.UTC().Format(time.RFC3339Nano), hostID); err != nil {
return fmt.Errorf("store: update host snapshot_count: %w", err) return fmt.Errorf("store: update host snapshot_count: %w", err)
} }
+5 -1
View File
@@ -138,7 +138,8 @@ func TestReplaceHostSnapshotsEmpty(t *testing.T) {
t.Fatalf("replace 1: %v", err) t.Fatalf("replace 1: %v", err)
} }
// Then empty — host has been wiped. // Then empty — host has been wiped.
if err := s.ReplaceHostSnapshots(ctx, hostID, nil, time.Now().UTC()); err != nil { refreshedAt := time.Now().UTC().Truncate(time.Millisecond)
if err := s.ReplaceHostSnapshots(ctx, hostID, nil, refreshedAt); err != nil {
t.Fatalf("replace empty: %v", err) t.Fatalf("replace empty: %v", err)
} }
out, err := s.ListSnapshotsByHost(ctx, hostID) out, err := s.ListSnapshotsByHost(ctx, hostID)
@@ -152,4 +153,7 @@ func TestReplaceHostSnapshotsEmpty(t *testing.T) {
if h.SnapshotCount != 0 { if h.SnapshotCount != 0 {
t.Errorf("snapshot_count should reset to 0, got %d", h.SnapshotCount) t.Errorf("snapshot_count should reset to 0, got %d", h.SnapshotCount)
} }
if h.SnapshotRefreshedAt == nil || !h.SnapshotRefreshedAt.Equal(refreshedAt) {
t.Errorf("empty projection refresh time = %v, want %s", h.SnapshotRefreshedAt, refreshedAt)
}
} }
+2
View File
@@ -77,6 +77,7 @@ type Host struct {
LastBackupStatus *string LastBackupStatus *string
RepoSizeBytes int64 RepoSizeBytes int64
SnapshotCount int SnapshotCount int
SnapshotRefreshedAt *time.Time
OpenAlertCount int OpenAlertCount int
AppliedScheduleVersion int64 AppliedScheduleVersion int64
// Host-wide bandwidth caps applied to every restic invocation // Host-wide bandwidth caps applied to every restic invocation
@@ -230,6 +231,7 @@ type FleetUpdate struct {
CurrentHostID string CurrentHostID string
HaltedReason string HaltedReason string
CompletedAt *time.Time CompletedAt *time.Time
PauseAfterFirst bool
} }
// FleetUpdateHost is one host's slot in a fleet update. Position is // FleetUpdateHost is one host's slot in a fleet update. Position is
+4
View File
@@ -24,6 +24,10 @@
</h1> </h1>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<form method="post" action="/alerts/abandon-stuck"
onsubmit="return confirm('Mark every currently alerted stuck job as cancelled? Use this only for jobs no longer running on their agents.');">
<button type="submit" class="btn btn-danger">Abandon all stuck jobs</button>
</form>
<a href="/settings/notifications" class="btn">Channel settings →</a> <a href="/settings/notifications" class="btn">Channel settings →</a>
</div> </div>
</div> </div>
+1 -1
View File
@@ -69,7 +69,7 @@
{{/* ---------- Hosts-behind hero tile (P6-18) ---------- */}} {{/* ---------- Hosts-behind hero tile (P6-18) ---------- */}}
{{if gt $page.UpdatesBehind 0}} {{if gt $page.UpdatesBehind 0}}
<div class="pt-4"> <div class="pt-4">
<a href="?updates=behind" class="hero-tile hero-tile--amber" style="display:inline-flex;"> <a href="/settings/fleet-update" class="hero-tile hero-tile--amber" style="display:inline-flex;">
<span class="hero-num">{{$page.UpdatesBehind}}</span> <span class="hero-num">{{$page.UpdatesBehind}}</span>
<span class="hero-label">{{if eq $page.UpdatesBehind 1}}host behind{{else}}hosts behind{{end}} · review →</span> <span class="hero-label">{{if eq $page.UpdatesBehind 1}}host behind{{else}}hosts behind{{end}} · review →</span>
</a> </a>
+13 -3
View File
@@ -8,18 +8,18 @@
<div class="crumbs pt-6"> <div class="crumbs pt-6">
<a href="/">Dashboard</a><span class="sep">/</span> <a href="/">Dashboard</a><span class="sep">/</span>
<a href="/settings">Settings</a><span class="sep">/</span> <a href="/settings">Settings</a><span class="sep">/</span>
<span class="text-ink-mid">fleet update</span> <span class="text-ink-mid">agent updates</span>
</div> </div>
{{/* page header */}} {{/* page header */}}
<div class="flex items-baseline justify-between mt-3.5"> <div class="flex items-baseline justify-between mt-3.5">
<div> <div>
<h1 class="text-[22px] font-medium tracking-[-0.005em]"> <h1 class="text-[22px] font-medium tracking-[-0.005em]">
Fleet update Agent updates
<span class="text-ink-fade font-normal text-[14px] ml-2 mono">target {{$page.TargetVersion}}</span> <span class="text-ink-fade font-normal text-[14px] ml-2 mono">target {{$page.TargetVersion}}</span>
</h1> </h1>
<p class="text-ink-mute text-[12px] mt-1 max-w-[760px] leading-[1.55]"> <p class="text-ink-mute text-[12px] mt-1 max-w-[760px] leading-[1.55]">
Rolling, sequential agent self-update. One host at a time, halts on first failure, Rolling, sequential agent self-update. One host at a time, halts on first failure,
cancellable mid-roll. Only online hosts whose <span class="mono">agent_version</span> cancellable mid-roll. Only online hosts whose <span class="mono">agent_version</span>
differs from the server are eligible. differs from the server are eligible.
</p> </p>
@@ -28,5 +28,15 @@
{{template "fleet_update_inner" .}} {{template "fleet_update_inner" .}}
<script>
function fleetBoxes(){return Array.from(document.querySelectorAll('.fleet-host:not(:disabled)'))}
function fleetReview(){const n=fleetBoxes().filter(x=>x.checked).length;document.getElementById('fleet-selected-count').textContent=n;document.getElementById('fleet-timeout').textContent=n+' × 95s';document.getElementById('fleet-update-start-btn').disabled=n===0}
function fleetFilter(){const q=document.getElementById('fleet-filter-name').value.toLowerCase(),v=document.getElementById('fleet-filter-version').value.toLowerCase(),s=document.getElementById('fleet-filter-state').value;document.querySelectorAll('.fleet-candidate').forEach(r=>r.style.display=r.dataset.name.toLowerCase().includes(q)&&r.dataset.version.toLowerCase().includes(v)&&(s==='all'||r.dataset.state===s)?'':'none')}
function fleetSelectVisible(on){document.querySelectorAll('.fleet-candidate').forEach(r=>{const x=r.querySelector('.fleet-host:not(:disabled)');if(x&&r.style.display!=='none')x.checked=on});fleetReview()}
function fleetSelectOnly(id){fleetBoxes().forEach(x=>x.checked=x.value===id);fleetReview()}
async function fleetStart(e){e.preventDefault();const ids=fleetBoxes().filter(x=>x.checked).map(x=>x.value),out=document.getElementById('fleet-start-error');out.textContent='';const res=await fetch('/api/fleet/update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({host_ids:ids,canary_first:document.getElementById('fleet-canary').checked})});if(res.ok){location.reload();return false}const body=await res.json();out.textContent=body.message||body.code||'Unable to start';return false}
if(document.getElementById('fleet-selected-count'))fleetReview()
</script>
</div> </div>
{{end}} {{end}}
+8 -4
View File
@@ -82,10 +82,14 @@
<div class="text-[12px] text-ok mb-3 mono">✓ saved</div> <div class="text-[12px] text-ok mb-3 mono">✓ saved</div>
{{end}} {{end}}
<p class="text-[12.5px] text-ink-mid leading-[1.6] mb-4 max-w-[640px]"> <p class="text-[12.5px] text-ink-mid leading-[1.6] mb-4 max-w-[640px]">
Only needed for rest-server repos that distinguish an append-only Required for prune. On rest-server repos this is the
user (everyday backups) from a delete-capable user (prune / delete-capable user, as distinct from the append-only user used
forget). For S3 / B2 / SFTP / local, leave this blank — the for everyday backups. Note that <strong>forget</strong> always
everyday repo credentials handle prune too. runs with the everyday repo credentials, so those must have
delete authority. For S3 / B2 / SFTP / local, enter the same
delete-capable repository credentials here if you want prune
enabled. <strong>Prune is skipped when admin credentials are
unset</strong>, on any backend.
</p> </p>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
+1
View File
@@ -40,6 +40,7 @@
{{if not $page.Form}}<span class="mono text-ink-fade text-[11px] ml-1">{{len $page.Channels}}</span>{{end}} {{if not $page.Form}}<span class="mono text-ink-fade text-[11px] ml-1">{{len $page.Channels}}</span>{{end}}
</a> </a>
<a href="/settings/users" class="sub-tab {{if eq $page.ActiveTab "users"}}active{{end}}">Users</a> <a href="/settings/users" class="sub-tab {{if eq $page.ActiveTab "users"}}active{{end}}">Users</a>
<a href="/settings/fleet-update" class="sub-tab">Agent updates</a>
<span class="sub-tab text-ink-fade cursor-default" title="lands later">Authentication</span> <span class="sub-tab text-ink-fade cursor-default" title="lands later">Authentication</span>
</div> </div>
+16
View File
@@ -66,6 +66,14 @@
{{/* actions */}} {{/* actions */}}
<div style="display: flex; gap: 6px; justify-content: flex-end; align-items: center;"> <div style="display: flex; gap: 6px; justify-content: flex-end; align-items: center;">
{{if eq $status "open"}} {{if eq $status "open"}}
{{if eq $a.Kind "job_stuck"}}
<form method="post" action="/alerts/{{$a.ID}}/abandon-job">
<button type="submit" class="btn btn-danger"
hx-post="/alerts/{{$a.ID}}/abandon-job{{if $qs}}?{{$qs}}{{end}}"
hx-swap="none"
hx-confirm="Mark this job cancelled on the server? Use this only when it is no longer running on the agent.">Abandon job</button>
</form>
{{end}}
<form method="post" action="/alerts/{{$a.ID}}/acknowledge"> <form method="post" action="/alerts/{{$a.ID}}/acknowledge">
{{if $qs}}<input type="hidden" name="qs" value="{{$qs}}">{{end}} {{if $qs}}<input type="hidden" name="qs" value="{{$qs}}">{{end}}
<button type="submit" class="btn" <button type="submit" class="btn"
@@ -89,6 +97,14 @@
{{end}} {{end}}
ack'd{{if $ackedBy}} by {{$ackedBy}}{{end}} · {{relTime $a.AcknowledgedAt}} ack'd{{if $ackedBy}} by {{$ackedBy}}{{end}} · {{relTime $a.AcknowledgedAt}}
</span> </span>
{{if eq $a.Kind "job_stuck"}}
<form method="post" action="/alerts/{{$a.ID}}/abandon-job">
<button type="submit" class="btn btn-danger"
hx-post="/alerts/{{$a.ID}}/abandon-job{{if $qs}}?{{$qs}}{{end}}"
hx-swap="none"
hx-confirm="Mark this job cancelled on the server? Use this only when it is no longer running on the agent.">Abandon job</button>
</form>
{{end}}
<form method="post" action="/alerts/{{$a.ID}}/resolve"> <form method="post" action="/alerts/{{$a.ID}}/resolve">
{{if $qs}}<input type="hidden" name="qs" value="{{$qs}}">{{end}} {{if $qs}}<input type="hidden" name="qs" value="{{$qs}}">{{end}}
<button type="submit" class="btn" <button type="submit" class="btn"
+40 -31
View File
@@ -7,11 +7,11 @@
*/}} */}}
{{define "fleet_update_inner"}} {{define "fleet_update_inner"}}
{{$page := .Page}} {{$page := .Page}}
<div id="fleet-update-panel" class="mt-5" <div id="fleet-update-panel" class="mt-5"{{if and $page.Active (eq $page.Active.Status "running")}}
hx-get="{{$page.PollURL}}" hx-get="{{$page.PollURL}}"
hx-trigger="every 3s [document.visibilityState==='visible']" hx-trigger="every 3s [document.visibilityState==='visible']"
hx-select="#fleet-update-panel" hx-select="#fleet-update-panel"
hx-swap="outerHTML"> hx-swap="outerHTML"{{end}}>
{{if and $page.Active (eq $page.Active.Status "running")}} {{if and $page.Active (eq $page.Active.Status "running")}}
@@ -32,6 +32,7 @@
</div> </div>
<div class="text-[11.5px] text-ink-mute mt-1"> <div class="text-[11.5px] text-ink-mute mt-1">
target <span class="mono text-ink-mid">{{$page.Active.TargetVersion}}</span> target <span class="mono text-ink-mid">{{$page.Active.TargetVersion}}</span>
{{if $page.Active.PauseAfterFirst}} · canary-first{{end}}
· started <span class="mono text-ink-mid">{{relTime $page.Active.StartedAt}}</span> · started <span class="mono text-ink-mid">{{relTime $page.Active.StartedAt}}</span>
{{if $page.Active.CurrentHostID}} {{if $page.Active.CurrentHostID}}
· waiting on <span class="mono text-ink-mid">{{index $page.HostNames $page.Active.CurrentHostID}}</span> · waiting on <span class="mono text-ink-mid">{{index $page.HostNames $page.Active.CurrentHostID}}</span>
@@ -68,6 +69,12 @@
{{if $page.Active.HaltedReason}} {{if $page.Active.HaltedReason}}
<div class="text-[12px] text-bad mt-2">{{$page.Active.HaltedReason}}</div> <div class="text-[12px] text-bad mt-2">{{$page.Active.HaltedReason}}</div>
{{end}} {{end}}
{{if eq $page.Active.Status "halted"}}
<div class="mt-3 flex gap-2 text-[12px]">
<a class="btn" href="#fleet-update-start-form">Resume remaining as new rollout</a>
{{range $page.ActiveRows}}{{if eq .Status "failed"}}<button class="btn" type="button" onclick="fleetSelectOnly('{{.HostID}}');document.getElementById('fleet-update-start-form').scrollIntoView()">Retry {{.HostName}}</button>{{end}}{{end}}
</div>
{{end}}
</div> </div>
{{template "fleet_update_rows" $page}} {{template "fleet_update_rows" $page}}
@@ -122,11 +129,11 @@
{{define "fleet_update_idle_panel"}} {{define "fleet_update_idle_panel"}}
{{$page := .}} {{$page := .}}
<div class="panel rounded-[7px] px-5 py-4"> <div class="panel rounded-[7px] px-5 py-4">
{{if eq (len $page.OutOfDateHosts) 0}} {{if eq (len $page.Candidates) 0}}
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="dot dot-online"></span> <span class="dot dot-online"></span>
<div> <div>
<div class="text-ink text-[14px] font-medium">All hosts are up to date.</div> <div class="text-ink text-[14px] font-medium">No hosts enrolled.</div>
<div class="text-ink-mute text-[12px] mt-0.5"> <div class="text-ink-mute text-[12px] mt-0.5">
Every online agent matches server version <span class="mono">{{$page.TargetVersion}}</span>. Every online agent matches server version <span class="mono">{{$page.TargetVersion}}</span>.
</div> </div>
@@ -137,34 +144,36 @@
<h2 class="text-[14px] font-medium">{{len $page.OutOfDateHosts}} host{{if ne (len $page.OutOfDateHosts) 1}}s{{end}} out of date</h2> <h2 class="text-[14px] font-medium">{{len $page.OutOfDateHosts}} host{{if ne (len $page.OutOfDateHosts) 1}}s{{end}} out of date</h2>
<span class="mono text-[11px] text-ink-fade">target {{$page.TargetVersion}}</span> <span class="mono text-[11px] text-ink-fade">target {{$page.TargetVersion}}</span>
</div> </div>
<ul class="mt-3 space-y-1 text-[12px]"> <div class="mt-3 flex flex-wrap gap-2">
{{range $page.OutOfDateHosts}} <input id="fleet-filter-name" class="field text-[12px]" placeholder="Filter name or tag" oninput="fleetFilter()">
<li class="flex items-center gap-3"> <input id="fleet-filter-version" class="field text-[12px] mono" placeholder="Version" oninput="fleetFilter()">
<span class="dot dot-online"></span> <select id="fleet-filter-state" class="field text-[12px]" onchange="fleetFilter()">
<span class="mono text-ink">{{.Name}}</span> <option value="all">All states</option><option value="eligible">Eligible</option><option value="excluded">Excluded</option>
<span class="mono text-ink-mute">{{if .AgentVersion}}{{.AgentVersion}}{{else}}—{{end}} → {{$page.TargetVersion}}</span> </select>
</li> <button type="button" class="btn" onclick="fleetSelectVisible(true)">Select visible eligible</button>
{{end}} <button type="button" class="btn" onclick="fleetSelectVisible(false)">Clear selection</button>
</ul> </div>
<div class="panel mt-3 rounded-[7px] overflow-hidden">
<form id="fleet-update-start-form" class="mt-4 flex items-center gap-3" {{range $page.Candidates}}
hx-post="/api/fleet/update" <label class="fleet-candidate grid items-center px-3 py-2 hairline text-[12px]"
hx-headers='{"Content-Type":"application/json"}' data-name="{{.Host.Name}} {{range .Host.Tags}}{{.}} {{end}}" data-version="{{.Host.AgentVersion}}" data-state="{{if .Eligible}}eligible{{else}}excluded{{end}}"
hx-vals='{}' style="grid-template-columns: 28px 1.4fr .8fr .8fr 1.2fr;gap:12px">
hx-swap="none" <input class="fleet-host" type="checkbox" value="{{.Host.ID}}" {{if .Eligible}}checked onchange="fleetReview()"{{else}}disabled{{end}}>
hx-on::after-request="if(event.detail.successful) location.reload()"> <span class="mono">{{.Host.Name}}</span>
<label class="text-[11.5px] text-ink-mute"> <span class="mono text-ink-mute">{{if .Host.AgentVersion}}{{.Host.AgentVersion}}{{else}}unknown{{end}}</span>
Type the count <span class="mono text-ink-mute">{{$page.TargetVersion}}</span>
<span class="mono text-ink-mid">({{len $page.OutOfDateHosts}})</span> <span class="text-ink-mute">{{if .Eligible}}eligible{{else}}{{.Reason}}{{end}}</span>
to enable Start:
</label> </label>
<input type="text" id="fleet-update-confirm" class="field mono text-[12.5px]" {{end}}
style="width: 80px; padding: 5px 8px;" </div>
oninput="document.getElementById('fleet-update-start-btn').disabled = (this.value !== '{{len $page.OutOfDateHosts}}');" <div class="mt-4 text-[12px] text-ink-mute">
autocomplete="off" /> <span id="fleet-selected-count">{{len $page.OutOfDateHosts}}</span> selected · sequential, halts on first failure · worst-case
<button type="submit" id="fleet-update-start-btn" class="btn btn-amber" disabled> <span id="fleet-timeout" class="mono">{{len $page.OutOfDateHosts}} × 95s</span>
Start fleet update </div>
</button> <form class="mt-3 flex items-center gap-3" onsubmit="return fleetStart(event)">
<label class="text-[11.5px] text-ink-mute"><input id="fleet-canary" type="checkbox" checked> Pause after first host for canary review</label>
<button id="fleet-update-start-btn" class="btn btn-amber">Start agent update</button>
<span id="fleet-start-error" class="text-bad text-[12px]"></span>
</form> </form>
{{end}} {{end}}
</div> </div>