Compare commits
64 Commits
v1.0.0
..
bc5c99898c
| Author | SHA1 | Date | |
|---|---|---|---|
| bc5c99898c | |||
| 80dac33f0a | |||
| facef5b254 | |||
| 6374201f6c | |||
| 9080826b06 | |||
| 39a0875d58 | |||
| 320be34970 | |||
| 25aefe90d6 | |||
| 56d97f13af | |||
| 383bdb7d36 | |||
| b64f029892 | |||
| f9718e6077 | |||
| 8fdf4a1bdf | |||
| 750a06384b | |||
| 2110c7dab4 | |||
| 8ddd3456e1 | |||
| 74cb7f660f | |||
| 1131f4330c | |||
| 337472a819 | |||
| b315932cc8 | |||
| f6fa84d7d8 | |||
| 904c522a23 | |||
| 31f53d65a7 | |||
| 528bdef433 | |||
| dfe082629f | |||
| 39aff83837 | |||
| 27be28ee9c | |||
| a8a6fdfab5 | |||
| e9df802478 | |||
| 6c6b962e24 | |||
| e64075d5d7 | |||
| 0f5110f3d9 | |||
| 0fbacf9f98 | |||
| d8fd4110b0 | |||
| e17932d797 | |||
| 39030a3bbe | |||
| a30f824a3c | |||
| 239d55b65b | |||
| 74e5b75380 | |||
| 9371b7b777 | |||
| 10b2518323 | |||
| 6694dfdc3a | |||
| f88f2cc1f2 | |||
| 1a07fbb217 | |||
| 9e6524788f | |||
| 25c55e5e4d | |||
| e408de9610 | |||
| 5c4e0275d9 | |||
| 7aaafceab5 | |||
| 4c9641b6ed | |||
| ff65d39f25 | |||
| 9d16e3f7e3 | |||
| 261b83ec26 | |||
| 0c3a0844e4 | |||
| 2dae61f678 | |||
| 55cb8909c7 | |||
| 06748f5582 | |||
| a4d705db6b | |||
| c6f73f790d | |||
| 068f08d96d | |||
| 28ef9750d3 | |||
| f4db0b17e8 | |||
| 8afda7cd8c | |||
| 123e4f4915 |
@@ -3,7 +3,7 @@
|
||||
# Notes for anyone editing this file:
|
||||
#
|
||||
# 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).
|
||||
# That image already ships:
|
||||
# * Go on PATH at /usr/local/go/bin (so `actions/setup-go` is
|
||||
@@ -11,8 +11,8 @@
|
||||
# otherwise re-download Go on every job)
|
||||
# * Node.js + npm (used by docs / e2e workflows)
|
||||
# * Docker CLI, Buildx, Compose v2 (used by docker-build steps)
|
||||
# When bumping the Go floor, push a new ci-runner-go image with
|
||||
# the matching Go version and bump the date pin in IMAGE below.
|
||||
# The runner image is intentionally tracked via `latest`: it is shared CI
|
||||
# infrastructure, and updates should propagate to consuming repositories.
|
||||
#
|
||||
# Self-hosted runner expectations
|
||||
# Each runner host bind-mounts persistent volumes for
|
||||
@@ -70,7 +70,8 @@ jobs:
|
||||
# one runner. The third shard ("rest") covers everything else.
|
||||
name: Test (${{ matrix.name }})
|
||||
runs-on: ubuntu-latest
|
||||
container: gitea.dcglab.co.uk/steve/ci-runner-go:2026-05-08
|
||||
container:
|
||||
image: docker.dcglab.co.uk/public/ci-runner-go:latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -105,7 +106,8 @@ jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
container: gitea.dcglab.co.uk/steve/ci-runner-go:2026-05-08
|
||||
container:
|
||||
image: docker.dcglab.co.uk/public/ci-runner-go:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: golangci/golangci-lint-action@v7
|
||||
@@ -121,7 +123,8 @@ jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.goos }}/${{ matrix.goarch }})
|
||||
runs-on: ubuntu-latest
|
||||
container: gitea.dcglab.co.uk/steve/ci-runner-go:2026-05-08
|
||||
container:
|
||||
image: docker.dcglab.co.uk/public/ci-runner-go:latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
e2e:
|
||||
name: Playwright vs docker-compose
|
||||
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
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -12,18 +12,14 @@
|
||||
# plus install.sh / install.ps1 / the systemd unit baked in under
|
||||
# /opt/restic-manager/dist (the read-only fallback path the server
|
||||
# handlers use when <DataDir>/... is empty).
|
||||
# * Pushes to this Gitea instance's container registry under
|
||||
# <gitea-host>/<owner>/restic-manager.
|
||||
# * 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 push: :vX.Y.Z, :X.Y, :X
|
||||
# * tag push and X >= 1: also :latest
|
||||
# * workflow_dispatch: only :snapshot-<shortsha>; nothing else moves.
|
||||
#
|
||||
# Why no goreleaser
|
||||
# The architecture already routes agent distribution through the
|
||||
# server's /agent/binary endpoint. The image is the only deliverable;
|
||||
# binary archives would just be a second source of truth.
|
||||
|
||||
name: Release
|
||||
|
||||
@@ -34,8 +30,8 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.dcglab.co.uk
|
||||
IMAGE_NAME: ${{ gitea.repository }}
|
||||
REGISTRY: docker.dcglab.co.uk
|
||||
IMAGE_NAME: public/restic-manager
|
||||
|
||||
# Force bash as the default shell — see ci.yml header.
|
||||
defaults:
|
||||
@@ -46,19 +42,20 @@ jobs:
|
||||
image:
|
||||
name: Build + push image
|
||||
runs-on: ubuntu-latest
|
||||
container: gitea.dcglab.co.uk/steve/ci-runner-go:2026-05-08
|
||||
container:
|
||||
image: docker.dcglab.co.uk/public/ci-runner-go:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
- name: Log in to zot registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.DEV_TOKEN }}
|
||||
username: ${{ secrets.ZOT_USERNAME }}
|
||||
password: ${{ secrets.ZOT_PASSWORD }}
|
||||
|
||||
- name: Compute tags + version
|
||||
id: meta
|
||||
|
||||
@@ -45,3 +45,10 @@ coverage.html
|
||||
# tooling already skips paths starting with _, but ignore explicitly
|
||||
# so an accidental `git add cmd/.` can't sneak them into a release.
|
||||
/cmd/_*/
|
||||
|
||||
# Local-only planning / scratch — never committed.
|
||||
/ask.md
|
||||
/docs/superpowers/
|
||||
|
||||
# Claude Code agent worktrees (transient, harness-created).
|
||||
/.claude/worktrees/
|
||||
|
||||
+101
-1
@@ -6,6 +6,95 @@ and the project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
- **Always-On vs intermittent host mode.** A host can now be marked as
|
||||
not always-on — for laptops/workstations that legitimately sleep,
|
||||
travel, or shut down outside hours. An intermittent host no longer
|
||||
raises "agent offline" alerts when it disappears; instead it shows a
|
||||
calm "asleep" state in the UI ("asleep · last seen … · will catch up
|
||||
on return") and is covered by a longer-horizon staleness alert (raised
|
||||
only when it has an enabled schedule and no successful backup in 7
|
||||
days). When such a host reconnects, the server waits a short settle
|
||||
window and then automatically dispatches any scheduled backup whose
|
||||
window elapsed while it was asleep. Toggle per host from the host
|
||||
detail page (operator-band, audited as `host.mode_updated`). New and
|
||||
existing hosts default to always-on, so current fleets are unaffected.
|
||||
|
||||
### Changed
|
||||
|
||||
- Host-detail header redesign: tags and presence are grouped into
|
||||
labelled, boxed pills with click-to-edit; presence shows a `24x7` /
|
||||
`Free` chip; the agent "out of date" indicator is simplified (the full
|
||||
version detail remains in the Agent-update panel and on hover).
|
||||
- Relative timestamps ("2h ago") now tick client-side, so a tab left
|
||||
open no longer shows a stale value as wall-clock time moves on.
|
||||
- Release and CI container images are now published to and pulled from
|
||||
the zot OCI registry (`docker.dcglab.co.uk`).
|
||||
|
||||
## [1.0.1] - 2026-05-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Build version is now single-sourced from `internal/version`, and the
|
||||
server Dockerfile's ldflags were corrected so docker-built binaries
|
||||
report their real version. Previously `internal/version.Version` stayed
|
||||
at its "dev" default in docker images, which made every host look
|
||||
permanently out-of-date to the update logic.
|
||||
|
||||
## [1.0.0] - 2026-05-09
|
||||
|
||||
First tagged release. Six development phases brought the project from
|
||||
@@ -85,5 +174,16 @@ with a web UI, JSON API, and self-updating agent fleet.
|
||||
go vet, golangci-lint).
|
||||
- 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.0...HEAD
|
||||
[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
|
||||
|
||||
[#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
|
||||
|
||||
+37
-11
@@ -2,7 +2,8 @@
|
||||
|
||||
Thanks for your interest in restic-manager. This document covers how
|
||||
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
|
||||
|
||||
@@ -108,6 +109,32 @@ admin user.
|
||||
|
||||
## 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
|
||||
|
||||
1. **Open an issue first** for non-trivial changes. The design is
|
||||
@@ -136,25 +163,24 @@ The PR template asks for:
|
||||
|
||||
### Reporting bugs
|
||||
|
||||
Open an issue with:
|
||||
Use the bug report issue template and include:
|
||||
|
||||
- restic-manager version (`server --version`) and agent version.
|
||||
- restic version on the affected host.
|
||||
- Steps to reproduce.
|
||||
- Server and agent logs (sanitise any tokens before pasting).
|
||||
|
||||
Security-sensitive bugs go through the [SECURITY.md](./SECURITY.md)
|
||||
disclosure path instead — please don't open a public issue for
|
||||
them.
|
||||
For security-sensitive bugs, use the private disclosure process noted
|
||||
above.
|
||||
|
||||
### Suggesting features
|
||||
|
||||
Open an issue describing the use case (not just the proposed
|
||||
solution). The roadmap in `tasks.md` shows where the project is
|
||||
heading; if the suggestion fits a future phase we'll wire it 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
|
||||
early to save your time.
|
||||
Use the feature request issue template and describe the use case (not
|
||||
just the proposed solution). The roadmap in `tasks.md` shows where the
|
||||
project is heading; if the suggestion fits a future phase we'll wire it
|
||||
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 early to
|
||||
save your time.
|
||||
|
||||
## Code of conduct
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || ec
|
||||
COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo none)
|
||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
VERSION_PKG := gitea.dcglab.co.uk/steve/restic-manager/internal/version
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE) \
|
||||
-X $(VERSION_PKG).Version=$(VERSION) -X $(VERSION_PKG).Commit=$(COMMIT)
|
||||
LDFLAGS := -s -w \
|
||||
-X $(VERSION_PKG).Version=$(VERSION) \
|
||||
-X $(VERSION_PKG).Commit=$(COMMIT) \
|
||||
-X $(VERSION_PKG).Date=$(DATE)
|
||||
GOFLAGS := -trimpath
|
||||
DOCKER_IMAGE ?= gitea.dcglab.co.uk/steve/restic-manager
|
||||
DOCKER_TAG ?= dev
|
||||
|
||||
+32
-13
@@ -22,12 +22,7 @@ import (
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/agent/wsclient"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/restic"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "none"
|
||||
date = "unknown"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -66,7 +61,7 @@ func run() error {
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Printf("restic-manager-agent %s (commit %s, built %s)\n", version, commit, date)
|
||||
fmt.Printf("restic-manager-agent %s (commit %s, built %s)\n", version.Version, version.Commit, version.Date)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -82,14 +77,14 @@ func run() error {
|
||||
if *enrollServer == "" {
|
||||
return errors.New("enrollment: -enroll-server is required with -enroll-token")
|
||||
}
|
||||
return doEnroll(*enrollServer, *enrollToken, cfg, version)
|
||||
return doEnroll(*enrollServer, *enrollToken, cfg, version.Version)
|
||||
}
|
||||
|
||||
// Announce-and-approve: -enroll-server set, no token, agent not
|
||||
// yet enrolled. Run the announce flow inline; on success the cfg
|
||||
// has the bearer + host_id and we drop into the normal run loop.
|
||||
if !cfg.Enrolled() && *enrollServer != "" {
|
||||
if err := doAnnounce(*enrollServer, cfg, version); err != nil {
|
||||
if err := doAnnounce(*enrollServer, cfg, version.Version); err != nil {
|
||||
return fmt.Errorf("announce: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -106,7 +101,7 @@ func run() error {
|
||||
return fmt.Errorf("sysinfo: %w", err)
|
||||
}
|
||||
slog.Info("agent starting",
|
||||
"version", version,
|
||||
"version", version.Version,
|
||||
"host_id", cfg.HostID,
|
||||
"server", cfg.ServerURL,
|
||||
"restic_version", snap.ResticVersion,
|
||||
@@ -136,7 +131,7 @@ func run() error {
|
||||
CertPinSHA256: cfg.CertPinSHA256,
|
||||
HelloPayload: api.HelloPayload{
|
||||
ProtocolVersion: snap.ProtocolVersion,
|
||||
AgentVersion: version,
|
||||
AgentVersion: version.Version,
|
||||
ResticVersion: snap.ResticVersion,
|
||||
Hostname: snap.Hostname,
|
||||
OS: snap.OS,
|
||||
@@ -301,6 +296,9 @@ func (d *dispatcher) handle(ctx context.Context, env api.Envelope, tx wsclient.S
|
||||
}
|
||||
go d.handleTreeList(ctx, env.ID, p, tx)
|
||||
|
||||
case api.MsgSnapshotsRefresh:
|
||||
go d.refreshSnapshots(ctx, tx)
|
||||
|
||||
case api.MsgScheduleSet:
|
||||
var p api.ScheduleSetPayload
|
||||
if err := env.UnmarshalPayload(&p); err != nil {
|
||||
@@ -410,6 +408,22 @@ func (d *dispatcher) handle(ctx context.Context, env api.Envelope, tx wsclient.S
|
||||
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
|
||||
// the matching tree.list.result envelope back, correlated by the
|
||||
// request envelope's ID. Errors (missing creds, restic failure)
|
||||
@@ -606,6 +620,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)")
|
||||
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))
|
||||
for _, g := range p.ForgetGroups {
|
||||
groups = append(groups, restic.ForgetGroup{
|
||||
@@ -620,9 +639,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 {
|
||||
return r.RunForget(jobCtx, p.JobID, groups)
|
||||
return r.RunForget(jobCtx, p.JobID, groups, dryRun)
|
||||
})
|
||||
case api.JobPrune:
|
||||
// Prune may require admin creds (delete authority on rest-server).
|
||||
|
||||
+5
-9
@@ -26,12 +26,7 @@ import (
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "none"
|
||||
date = "unknown"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -47,7 +42,7 @@ func run() error {
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Printf("restic-manager-server %s (commit %s, built %s)\n", version, commit, date)
|
||||
fmt.Printf("restic-manager-server %s (commit %s, built %s)\n", version.Version, version.Commit, version.Date)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -123,7 +118,7 @@ func run() error {
|
||||
NotificationHub: notifHub,
|
||||
UpdateWatcher: updateWatcher,
|
||||
UI: renderer,
|
||||
Version: version,
|
||||
Version: version.Version,
|
||||
OIDC: oidcClient,
|
||||
Metrics: metricsRegistry,
|
||||
}
|
||||
@@ -177,7 +172,7 @@ func run() error {
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
slog.Info("server listening", "addr", cfg.Listen, "version", version)
|
||||
slog.Info("server listening", "addr", cfg.Listen, "version", version.Version)
|
||||
errCh <- srv.Start()
|
||||
}()
|
||||
|
||||
@@ -232,6 +227,7 @@ func run() error {
|
||||
}
|
||||
case <-pendingDrainTick.C:
|
||||
srv.DrainAllDue(ctx)
|
||||
srv.RunCatchupsDue(ctx)
|
||||
case <-pendingExpiryTick.C:
|
||||
if n, err := st.DeleteExpiredPendingHosts(ctx, time.Now().UTC()); err == nil && n > 0 {
|
||||
slog.Info("expired pending hosts swept", "n", n)
|
||||
|
||||
@@ -26,7 +26,11 @@ ARG DATE=unknown
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
ENV LDFLAGS="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}"
|
||||
ENV VERSION_PKG="gitea.dcglab.co.uk/steve/restic-manager/internal/version"
|
||||
ENV LDFLAGS="-s -w \
|
||||
-X ${VERSION_PKG}.Version=${VERSION} \
|
||||
-X ${VERSION_PKG}.Commit=${COMMIT} \
|
||||
-X ${VERSION_PKG}.Date=${DATE}"
|
||||
|
||||
# Server: built for the image's runtime arch.
|
||||
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,223 @@
|
||||
# Always-On vs Intermittent host mode
|
||||
|
||||
**Date:** 2026-06-15
|
||||
**Branch:** `feat-laptop-host-mode`
|
||||
**Status:** Design — awaiting review
|
||||
|
||||
## Problem
|
||||
|
||||
The server currently assumes every host should be present 24×7. When an
|
||||
agent stops heartbeating for 90s it is flipped to `offline`, and after 15
|
||||
minutes that raises a `warning` alert. This is correct for a server, but
|
||||
wrong for a host that legitimately comes and goes — a workstation or
|
||||
laptop that sleeps overnight, travels, or is shut down on weekends. Such
|
||||
a host generates noise alerts every time it is closed, and — more
|
||||
importantly — there is **no mechanism to catch up a backup it missed
|
||||
while it was away.**
|
||||
|
||||
Two distinct facts make the catch-up gap real:
|
||||
|
||||
- **Backup cron runs on the agent, locally.** The agent fires
|
||||
`MsgScheduleFire`; the server only dispatches in response. If the host
|
||||
is asleep, the agent process is suspended, so the cron tick never
|
||||
fires and no `MsgScheduleFire` is ever sent.
|
||||
- Therefore the existing `pending_runs` retry queue **does not** cover
|
||||
this case. `pending_runs` only gets a row when a schedule *fired* but
|
||||
the agent was momentarily disconnected at dispatch time. A window
|
||||
missed entirely during sleep never enqueues anything.
|
||||
|
||||
## Goal
|
||||
|
||||
Let an operator mark a host as **not** always-on. Such a host:
|
||||
|
||||
1. Does **not** raise offline/agent-down alerts when it is not visible.
|
||||
2. Renders a distinct, calm "asleep" state in the UI instead of the
|
||||
alarming red "offline".
|
||||
3. When it reconnects, after a short settle delay, the server checks
|
||||
whether it missed a scheduled backup and — if so — triggers a
|
||||
catch-up backup automatically.
|
||||
4. Still raises a *staleness* alert if it has genuinely gone too long
|
||||
without any backup (a host left in a drawer). This is the only
|
||||
alert covering an asleep host: while the agent is offline no job
|
||||
runs, so there is no failure to detect — staleness is the safety
|
||||
net for "no backups are happening at all."
|
||||
5. Leaves normal job-failure alerting untouched: a backup that
|
||||
actually runs (scheduled or catch-up) and fails alerts as it does
|
||||
today. Failures can only occur while the agent is online and
|
||||
executing restic.
|
||||
|
||||
Default behaviour is unchanged for the entire existing fleet.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
- **Setting shape:** a single boolean `Always On` checkbox per host,
|
||||
**default ON**. Checked = today's 24×7 server semantics. Unchecked =
|
||||
intermittent host. Opt-in only; zero behaviour change for current and
|
||||
future hosts unless explicitly toggled.
|
||||
- **Overdue trigger:** evaluated on **reconnect + behind schedule**
|
||||
(not a continuous always-evaluating sweep).
|
||||
- **Alert policy for intermittent hosts:** suppress offline alerts;
|
||||
keep a long-threshold **staleness** alert; keep job-failure alerts.
|
||||
- **Staleness threshold:** **7 days**, a global constant for v1. May
|
||||
become per-host configurable later — out of scope now.
|
||||
- **Catch-up granularity:** **per enabled schedule.** A host with a
|
||||
daily and a weekly schedule catches up only whichever is actually
|
||||
behind.
|
||||
- **UI vocabulary:** not-visible intermittent host shows a grey
|
||||
`asleep` state; detail line reads
|
||||
`asleep · last seen <relTime> · will catch up on return`.
|
||||
- **Chip:** chip and checkbox highlight the **same** truth (24×7). Show
|
||||
a chip for **Always-On** hosts; **no** chip for intermittent.
|
||||
|
||||
## Architecture
|
||||
|
||||
The change is deliberately a thin policy + presentation layer over the
|
||||
existing online/offline state machine. We do **not** add a new `status`
|
||||
enum value or alter heartbeat / `last_seen_at` tracking. "Asleep" is a
|
||||
reinterpretation of `status='offline' AND NOT always_on`.
|
||||
|
||||
### 1. Data model
|
||||
|
||||
- **Migration `0024_hosts_always_on.sql`:**
|
||||
```sql
|
||||
ALTER TABLE hosts ADD COLUMN always_on INTEGER NOT NULL DEFAULT 1;
|
||||
```
|
||||
Column-level ALTER per the repo's migration rules. Default `1` means
|
||||
every existing row is Always-On — no behaviour change on upgrade.
|
||||
- `store/types.go`: add `AlwaysOn bool` to the `Host` struct; thread it
|
||||
through every host SELECT scan and the host insert/update paths.
|
||||
- New store helper `SetHostAlwaysOn(ctx, hostID, bool) error`.
|
||||
|
||||
### 2. Online/offline mechanics — UNCHANGED
|
||||
|
||||
The 30s offline sweeper (`cmd/server/main.go:220`) still flips an unseen
|
||||
host to `status='offline'` and still calls
|
||||
`alertEngine.NotifyHostOffline(id)`. `TouchHost` / `MarkHostHello`
|
||||
behaviour is untouched. The intermittent distinction is applied
|
||||
*downstream* of this state, in the alert engine and the templates.
|
||||
|
||||
### 3. Alert behaviour
|
||||
|
||||
All changes key off `host.AlwaysOn`, which the engine already has access
|
||||
to via the host row it loads.
|
||||
|
||||
- **Suppress offline alert** (`alert/engine.go` `handleHostOffline()`
|
||||
and the 60s `tick()`): when `!host.AlwaysOn`, do not raise
|
||||
`agent_offline`.
|
||||
- **Resolve-on-toggle:** when a host is switched server→intermittent and
|
||||
has an open `agent_offline` alert, auto-resolve it. (Handled in the
|
||||
mode-change handler, fanning through the normal resolve path so
|
||||
channels/audit fire as usual.)
|
||||
- **Staleness alert** — wire up the currently-dead `KindStaleSchedule`
|
||||
constant, **for intermittent hosts only.** On the 60s tick, for each
|
||||
host where `!AlwaysOn` AND the host has ≥1 enabled schedule AND
|
||||
`LastBackupAt != nil` AND `now - LastBackupAt > 7*24h`: raise a
|
||||
`warning` `stale_schedule` alert (dedup key `""`, one per host).
|
||||
Auto-resolves when `LastBackupAt` advances past the threshold (i.e.
|
||||
any successful backup, including the catch-up). Always-On hosts'
|
||||
`stale_schedule` remains a no-op (unchanged, out of scope).
|
||||
- If `LastBackupAt == nil` (intermittent host enrolled but never
|
||||
backed up): no staleness alert in v1 — there is no baseline to
|
||||
measure against, and onboarding probe state (`repo_status`) already
|
||||
covers "never successfully set up."
|
||||
- **Job-failure alerts:** untouched. A catch-up backup that runs and
|
||||
fails alerts exactly like any other backup.
|
||||
|
||||
### 4. Catch-up on reconnect
|
||||
|
||||
A new small component — the **catch-up scheduler** — lives server-side
|
||||
alongside the existing ticks.
|
||||
|
||||
- **Arm:** on agent hello (`server/ws/handler.go` hello path /
|
||||
`onAgentHello`), if the host is `!AlwaysOn`, record
|
||||
`catchupDueAt[hostID] = now + 60s` in an in-memory map. Re-arming on a
|
||||
subsequent hello just overwrites the timestamp (debounce — rapid
|
||||
flapping does not stack catch-ups). In-memory is acceptable: catch-up
|
||||
is best-effort and a server restart simply re-arms on the next hello.
|
||||
- **Fire:** reuse the existing 30s server tick. For each due entry
|
||||
(`catchupDueAt <= now`):
|
||||
1. Re-verify the agent is still connected (`Hub.Connected(hostID)`).
|
||||
If it bounced back offline within the settle window, drop the entry
|
||||
(it will re-arm on the next hello).
|
||||
2. Skip if a backup is already running or queued for the host
|
||||
(`current_job_id` set, or a relevant `pending_runs` row exists) —
|
||||
avoid double-firing alongside a normal dispatch or pending drain.
|
||||
3. For each **enabled** schedule on the host, compute overdue:
|
||||
```
|
||||
overdue := sched.Next(host.LastBackupAt) <= now
|
||||
```
|
||||
using `robfig/cron/v3` (already a dependency) to parse
|
||||
`Schedule.CronExpr`. `Next(lastBackup)` is the first fire strictly
|
||||
after the last successful backup; if that moment has already
|
||||
passed, the window was missed → overdue. (If `LastBackupAt` is nil,
|
||||
treat as overdue so a never-backed-up intermittent host with a
|
||||
schedule gets its first run on connect.)
|
||||
4. For each overdue schedule, dispatch its source-groups via the
|
||||
existing `dispatchBackupForGroupCore()`.
|
||||
5. Clear the entry.
|
||||
|
||||
Net latency is ~60–90s after wake (60s settle + up to one 30s tick).
|
||||
This path is independent of and complementary to the `pending_runs`
|
||||
drain, which continues to handle the fired-but-not-sent case.
|
||||
|
||||
### 5. UI
|
||||
|
||||
- **CSS:** new grey `dot-asleep` token in `web/styles/input.css`,
|
||||
visually distinct from red `dot-offline`.
|
||||
- **`partials/host_row.html` and `partials/host_chrome.html`:** when
|
||||
`!AlwaysOn && status=='offline'`, render the grey dot + label
|
||||
`asleep`; the detail/last-seen line reads
|
||||
`asleep · last seen <relTime> · will catch up on return`. All other
|
||||
states unchanged.
|
||||
- **24×7 chip:** on the host detail header, render a small
|
||||
`Always On` / `24×7` chip **only when `AlwaysOn` is true**. No chip
|
||||
for intermittent hosts. (Chip and checkbox highlight the same fact.)
|
||||
- **Toggle:** an `Always On` checkbox (default checked) on the host edit
|
||||
surface. Operator-band `POST` (mirrors existing host-edit handlers),
|
||||
audited as `host.mode_updated`. On save, if switching to intermittent,
|
||||
trigger the resolve-on-toggle path for any open `agent_offline` alert.
|
||||
|
||||
## Error handling & edge cases
|
||||
|
||||
- **Toggle server→intermittent while offline+alerting:** open
|
||||
`agent_offline` alert auto-resolved on save.
|
||||
- **Toggle intermittent→server while asleep:** host resumes normal
|
||||
offline/alert semantics; it will alert per the 15-minute floor once
|
||||
the sweeper/tick next evaluates it.
|
||||
- **No enabled schedules:** no catch-up and no staleness alert — there
|
||||
is no backup expectation to measure against.
|
||||
- **Catch-up vs in-flight work:** guarded by the running/queued check in
|
||||
step 4.2 so catch-up never races a normal dispatch or pending drain.
|
||||
- **Agent flaps during settle window:** entry dropped if not connected
|
||||
at fire time; re-armed on the next hello.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Alert engine (unit):**
|
||||
- offline alert suppressed when `!AlwaysOn`.
|
||||
- staleness alert raised when intermittent + schedule + last backup >
|
||||
7d; not raised for Always-On hosts; not raised when last backup is
|
||||
recent; not raised when no enabled schedule.
|
||||
- staleness alert auto-resolves after a backup advances `LastBackupAt`.
|
||||
- server→intermittent toggle resolves an open `agent_offline` alert.
|
||||
- **Overdue computation (unit, table-driven):** `(cronExpr,
|
||||
lastBackupAt, now) → overdue?` including nil-last-backup and
|
||||
daily/weekly cases.
|
||||
- **Catch-up scheduler (unit):** fires only when still connected; skips
|
||||
when a backup is running/queued; dispatches only overdue schedules.
|
||||
- **UI (render test):** asleep state + 24×7 chip render under the right
|
||||
conditions; offline state for Always-On hosts unchanged.
|
||||
- `go vet ./...` and full `go test ./...` green before merge.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-host staleness thresholds (global 7d constant for v1).
|
||||
- Continuous (non-reconnect) overdue evaluation.
|
||||
- Agent-side catch-up cron — the server is the reliable arbiter.
|
||||
- Wiring `stale_schedule` for Always-On hosts (separate concern).
|
||||
|
||||
## Task tracking
|
||||
|
||||
Add an entry to `tasks.md` under "Next steps from testing" (or a new
|
||||
small section) once the plan is approved, per the repo's tasks.md
|
||||
source-of-truth rule.
|
||||
@@ -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.
|
||||
func (r *Runner) sendStarted(jobID string, kind api.JobKind, startedAt time.Time) {
|
||||
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.
|
||||
// We do this *after* job.finished so the UI sees the job land first;
|
||||
// the snapshot list is a follow-up that the host detail page polls
|
||||
// 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.
|
||||
// Do this before job.finished so a failure in terminal reporting cannot
|
||||
// prevent the independently useful projection refresh.
|
||||
if err == nil {
|
||||
if rerr := r.reportSnapshots(ctx, env); rerr != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
r.sendFinished(ctx, jobID, finishedAt, err, statsBlob)
|
||||
|
||||
if err != nil {
|
||||
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
|
||||
// host's snapshot list shrinks). Snapshot refresh runs once after
|
||||
// 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()
|
||||
r.sendStarted(jobID, api.JobForget, startedAt)
|
||||
|
||||
env := r.resticEnv()
|
||||
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()
|
||||
r.sendFinished(ctx, jobID, finishedAt, err, nil)
|
||||
|
||||
// Refresh the server's snapshot projection — forget rewrites the
|
||||
// index so the host's snapshot list almost certainly shrunk.
|
||||
if err == nil {
|
||||
@@ -292,6 +292,7 @@ func (r *Runner) RunForget(ctx context.Context, jobID string, groups []restic.Fo
|
||||
"job_id", jobID, "err", rerr)
|
||||
}
|
||||
}
|
||||
r.sendFinished(ctx, jobID, finishedAt, err, nil)
|
||||
|
||||
if err != nil {
|
||||
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 {
|
||||
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)
|
||||
|
||||
@@ -116,7 +116,8 @@ func envelopeOrder(envs []api.Envelope) []api.MessageType {
|
||||
// TestRunPruneShipsExpectedEnvelopes drives RunPrune with a fake
|
||||
// binary that prints "prune" on stdout (for the log.stream envelope)
|
||||
// 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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -126,6 +127,7 @@ func TestRunPruneShipsExpectedEnvelopes(t *testing.T) {
|
||||
case "$1" in
|
||||
prune) echo "prune" ;;
|
||||
stats) echo '`+statsJSON+`' ;;
|
||||
snapshots) echo "[]" ;;
|
||||
*) echo "unknown: $*" ;;
|
||||
esac
|
||||
`)
|
||||
@@ -138,7 +140,7 @@ esac
|
||||
|
||||
order := envelopeOrder(tx.envs)
|
||||
// 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{}
|
||||
for i, mt := range order {
|
||||
if _, seen := positions[mt]; !seen {
|
||||
@@ -379,6 +381,15 @@ func TestRunInitShipsStartedAndFinished(t *testing.T) {
|
||||
_ = 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
|
||||
// RunForget still produces job.started and job.finished envelopes.
|
||||
func TestRunForgetShipsStartedAndFinished(t *testing.T) {
|
||||
@@ -398,9 +409,13 @@ esac
|
||||
Tag: "documents",
|
||||
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)
|
||||
}
|
||||
_ = 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,15 +103,15 @@ func connectOnce(ctx context.Context, cfg Config, handle Handler) error {
|
||||
}
|
||||
|
||||
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()
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial: %w", err)
|
||||
}
|
||||
// websocket.Dial returns the upgrade response separately from the
|
||||
// conn. Body is empty on a successful upgrade but Go's net/http
|
||||
// still expects it closed to release the connection.
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
conn.SetReadLimit(api.MaxWebSocketMessageBytes)
|
||||
// On a successful upgrade coder/websocket transfers ownership of the
|
||||
// response stream to conn and deliberately sets res.Body to nil. Closing
|
||||
// the connection below releases that stream.
|
||||
defer conn.CloseNow() //nolint:errcheck
|
||||
|
||||
// Send hello.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+132
-7
@@ -22,6 +22,17 @@ import (
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
// staleBackupThreshold is how long an intermittent host may go without
|
||||
// a successful backup before we raise a stale_schedule alert. Global
|
||||
// constant for v1 (may become per-host later). Only intermittent hosts
|
||||
// are evaluated — always-on hosts' stale_schedule stays a no-op.
|
||||
const staleBackupThreshold = 7 * 24 * time.Hour
|
||||
|
||||
const (
|
||||
defaultStuckJobThreshold = 6 * time.Hour
|
||||
longStuckJobThreshold = 24 * time.Hour
|
||||
)
|
||||
|
||||
// JobFinishedEvent carries everything the engine needs to evaluate
|
||||
// the failed-X rules. Pushed via Engine.NotifyJobFinished from the
|
||||
// MarkJobFinished site.
|
||||
@@ -47,11 +58,23 @@ type Engine struct {
|
||||
// we raise. Configurable for tests; default 15m.
|
||||
agentOfflineFloor time.Duration
|
||||
tickPeriod time.Duration
|
||||
stuckThresholds map[string]time.Duration
|
||||
|
||||
closeOnce sync.Once
|
||||
notifyWG sync.WaitGroup
|
||||
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
|
||||
// to 15min and 60s respectively when zero.
|
||||
func NewEngine(st *store.Store, hub *notification.Hub) *Engine {
|
||||
@@ -63,7 +86,12 @@ func NewEngine(st *store.Store, hub *notification.Hub) *Engine {
|
||||
hostUp: make(chan string, 32),
|
||||
agentOfflineFloor: 15 * time.Minute,
|
||||
tickPeriod: 60 * time.Second,
|
||||
done: make(chan struct{}),
|
||||
stuckThresholds: map[string]time.Duration{
|
||||
"backup": longStuckJobThreshold,
|
||||
"restore": longStuckJobThreshold,
|
||||
"check": longStuckJobThreshold,
|
||||
},
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +147,10 @@ func (e *Engine) NotifyHostOnline(hostID string) {
|
||||
}
|
||||
|
||||
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
|
||||
// listed here (init, unlock, restore, diff) produce no alerts in v1.
|
||||
var kind, severity string
|
||||
@@ -149,6 +181,10 @@ func (e *Engine) handleJobFinished(ctx context.Context, ev JobFinishedEvent) {
|
||||
fmt.Sprintf("%s job %s failed", ev.Kind, ev.JobID), ev.When)
|
||||
case "succeeded":
|
||||
e.resolveAndNotify(ctx, ev.HostID, kind, dedupKey, ev.When)
|
||||
if ev.Kind == "backup" {
|
||||
// A fresh backup clears staleness for intermittent hosts.
|
||||
e.resolveAndNotify(ctx, ev.HostID, KindStaleSchedule, "", ev.When)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +193,12 @@ func (e *Engine) handleHostOffline(ctx context.Context, hostID string) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Intermittent hosts (laptops) legitimately disappear — never raise
|
||||
// agent_offline for them. The stale_schedule sweep in tick() is the
|
||||
// only staleness signal for these hosts.
|
||||
if !host.AlwaysOn {
|
||||
return
|
||||
}
|
||||
// Apply the 15-min floor — raise only when last_seen_at is older
|
||||
// than agentOfflineFloor. A nil last_seen_at (host enrolled but
|
||||
// never connected) is treated as "now" so we don't raise
|
||||
@@ -180,11 +222,9 @@ func (e *Engine) handleHostOnline(ctx context.Context, hostID string) {
|
||||
// tick is the 60-second sweep. Responsibilities:
|
||||
// 1. Re-evaluate agent_offline for every offline host that may have
|
||||
// crossed the floor between explicit events.
|
||||
// 2. Stale-schedule detection — declared in the spec but intentionally
|
||||
// left as a no-op in v1. The precise "expected to have fired but
|
||||
// didn't" trigger requires a store helper that lands in a later
|
||||
// task. The KindStaleSchedule constant is exported so UI code can
|
||||
// reference the tag string today.
|
||||
// 2. Stale-schedule detection for intermittent hosts — raises
|
||||
// stale_schedule when LastBackupAt is older than 7 days and the
|
||||
// host has an enabled schedule. Always-on hosts are excluded.
|
||||
func (e *Engine) tick(ctx context.Context, now time.Time) {
|
||||
// User-management cleanup piggy-backed here for now. Setup tokens
|
||||
// have a 1h expiry; the alert engine tick is the cheapest existing
|
||||
@@ -196,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 {
|
||||
slog.Warn("alert: cleanup expired oidc state", "err", err)
|
||||
}
|
||||
e.evaluateStuckJobs(ctx, now)
|
||||
|
||||
hosts, err := e.store.ListHosts(ctx)
|
||||
if err != nil {
|
||||
@@ -203,6 +244,35 @@ func (e *Engine) tick(ctx context.Context, now time.Time) {
|
||||
return
|
||||
}
|
||||
for _, h := range hosts {
|
||||
// Intermittent hosts: suppress agent_offline entirely; instead
|
||||
// raise stale_schedule when they have gone too long with no
|
||||
// successful backup AND they have at least one enabled schedule
|
||||
// to be measured against. A nil LastBackupAt (never backed up)
|
||||
// has no baseline — onboarding/repo_status covers that case.
|
||||
if !h.AlwaysOn {
|
||||
if h.LastBackupAt == nil {
|
||||
continue
|
||||
}
|
||||
if now.Sub(*h.LastBackupAt) < staleBackupThreshold {
|
||||
continue
|
||||
}
|
||||
hasEnabled, err := e.hostHasEnabledSchedule(ctx, h.ID)
|
||||
if err != nil {
|
||||
slog.Warn("alert: tick list schedules", "host_id", h.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
if !hasEnabled {
|
||||
continue
|
||||
}
|
||||
e.raiseAndNotify(ctx, h.ID, KindStaleSchedule, "", "warning",
|
||||
fmt.Sprintf("No backup in %s (threshold %s)",
|
||||
roundDur(now.Sub(*h.LastBackupAt)), staleBackupThreshold), now)
|
||||
// Resolution is handled in handleJobFinished on a successful
|
||||
// backup (and ResolveOnModeChange on toggle) — the tick only
|
||||
// raises, it does not auto-resolve.
|
||||
continue
|
||||
}
|
||||
// Always-on hosts: existing agent_offline re-evaluation.
|
||||
if h.Status != "offline" || h.LastSeenAt == nil {
|
||||
continue
|
||||
}
|
||||
@@ -212,7 +282,46 @@ func (e *Engine) tick(ctx context.Context, now time.Time) {
|
||||
roundDur(now.Sub(*h.LastSeenAt)), e.agentOfflineFloor), now)
|
||||
}
|
||||
}
|
||||
// Stale-schedule sweep — no-op in v1. See KindStaleSchedule doc comment.
|
||||
}
|
||||
|
||||
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
|
||||
@@ -224,3 +333,19 @@ func roundDur(d time.Duration) string {
|
||||
}
|
||||
return d.Round(time.Minute).String()
|
||||
}
|
||||
|
||||
// hostHasEnabledSchedule reports whether the host has at least one
|
||||
// enabled backup schedule — the precondition for a stale_schedule
|
||||
// alert (no schedule = no backup expectation to measure against).
|
||||
func (e *Engine) hostHasEnabledSchedule(ctx context.Context, hostID string) (bool, error) {
|
||||
schedules, err := e.store.ListSchedulesByHost(ctx, hostID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, sc := range schedules {
|
||||
if sc.Enabled {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
// TestIntermittentHostSuppressesOfflineAlert checks that handleHostOffline
|
||||
// does NOT raise agent_offline for a host with AlwaysOn=false.
|
||||
func TestIntermittentHostSuppressesOfflineAlert(t *testing.T) {
|
||||
t.Parallel()
|
||||
eng, st, hostID := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Make the host intermittent.
|
||||
if err := st.SetHostAlwaysOn(ctx, hostID, false); err != nil {
|
||||
t.Fatalf("SetHostAlwaysOn: %v", err)
|
||||
}
|
||||
|
||||
// Give it a stale last_seen_at well past the floor.
|
||||
if _, err := st.DB().Exec(
|
||||
`UPDATE hosts SET last_seen_at = ?, status = ? WHERE id = ?`,
|
||||
time.Now().UTC().Add(-2*time.Hour).Format(time.RFC3339Nano),
|
||||
"offline",
|
||||
hostID,
|
||||
); err != nil {
|
||||
t.Fatalf("update last_seen_at: %v", err)
|
||||
}
|
||||
|
||||
eng.handleHostOffline(ctx, hostID)
|
||||
|
||||
open, _ := st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
|
||||
if len(open) != 0 {
|
||||
t.Fatalf("expected 0 open alerts for intermittent host; got %d: %+v", len(open), open)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAlwaysOnHostStillRaisesOfflineAlert checks that always-on hosts still
|
||||
// get an agent_offline alert when offline past the floor.
|
||||
func TestAlwaysOnHostStillRaisesOfflineAlert(t *testing.T) {
|
||||
t.Parallel()
|
||||
eng, st, hostID := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// always_on=true is the default, but be explicit.
|
||||
if err := st.SetHostAlwaysOn(ctx, hostID, true); err != nil {
|
||||
t.Fatalf("SetHostAlwaysOn: %v", err)
|
||||
}
|
||||
|
||||
// Give it a stale last_seen_at well past the 15m floor.
|
||||
if _, err := st.DB().Exec(
|
||||
`UPDATE hosts SET last_seen_at = ?, status = ? WHERE id = ?`,
|
||||
time.Now().UTC().Add(-2*time.Hour).Format(time.RFC3339Nano),
|
||||
"offline",
|
||||
hostID,
|
||||
); err != nil {
|
||||
t.Fatalf("update last_seen_at: %v", err)
|
||||
}
|
||||
|
||||
eng.handleHostOffline(ctx, hostID)
|
||||
|
||||
open, _ := st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
|
||||
if len(open) != 1 || open[0].Kind != KindAgentOffline {
|
||||
t.Fatalf("expected 1 agent_offline alert; got %d: %+v", len(open), open)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStalenessAlertForIntermittentHost checks that tick raises stale_schedule
|
||||
// for an intermittent host whose last backup is older than 7 days AND has an
|
||||
// enabled schedule. Also verifies that a succeeded backup clears the alert.
|
||||
func TestStalenessAlertForIntermittentHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
eng, st, hostID := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Make intermittent.
|
||||
if err := st.SetHostAlwaysOn(ctx, hostID, false); err != nil {
|
||||
t.Fatalf("SetHostAlwaysOn: %v", err)
|
||||
}
|
||||
|
||||
// Create a source group to attach the schedule to.
|
||||
sgID := ulid.Make().String()
|
||||
if err := st.CreateSourceGroup(ctx, &store.SourceGroup{
|
||||
ID: sgID,
|
||||
HostID: hostID,
|
||||
Name: "default",
|
||||
Includes: []string{"/home"},
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateSourceGroup: %v", err)
|
||||
}
|
||||
|
||||
// Create an enabled schedule pointing at the source group.
|
||||
schedID := ulid.Make().String()
|
||||
if err := st.CreateSchedule(ctx, &store.Schedule{
|
||||
ID: schedID,
|
||||
HostID: hostID,
|
||||
CronExpr: "0 2 * * *",
|
||||
Enabled: true,
|
||||
SourceGroupIDs: []string{sgID},
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateSchedule: %v", err)
|
||||
}
|
||||
|
||||
// Set last_backup_at to 8 days ago.
|
||||
eightDaysAgo := time.Now().UTC().Add(-8 * 24 * time.Hour)
|
||||
if err := st.SetHostLastBackup(ctx, hostID, "succeeded", eightDaysAgo); err != nil {
|
||||
t.Fatalf("SetHostLastBackup: %v", err)
|
||||
}
|
||||
|
||||
eng.tick(ctx, time.Now().UTC())
|
||||
|
||||
open, _ := st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
|
||||
var staleCount int
|
||||
for _, a := range open {
|
||||
if a.Kind == KindStaleSchedule {
|
||||
staleCount++
|
||||
}
|
||||
}
|
||||
if staleCount != 1 {
|
||||
t.Fatalf("expected 1 stale_schedule alert after tick; got %d (all open: %+v)", staleCount, open)
|
||||
}
|
||||
|
||||
// A succeeded backup should clear the stale_schedule alert.
|
||||
eng.handleJobFinished(ctx, JobFinishedEvent{
|
||||
HostID: hostID,
|
||||
JobID: ulid.Make().String(),
|
||||
Kind: "backup",
|
||||
Status: "succeeded",
|
||||
SourceGroupID: sgID,
|
||||
When: time.Now().UTC(),
|
||||
})
|
||||
|
||||
open, _ = st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
|
||||
for _, a := range open {
|
||||
if a.Kind == KindStaleSchedule {
|
||||
t.Fatalf("expected stale_schedule to be resolved after backup succeeded; still open: %+v", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoStalenessWithoutEnabledSchedule checks that no stale_schedule is
|
||||
// raised for an intermittent host with a stale backup but no enabled schedule.
|
||||
func TestNoStalenessWithoutEnabledSchedule(t *testing.T) {
|
||||
t.Parallel()
|
||||
eng, st, hostID := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Make intermittent.
|
||||
if err := st.SetHostAlwaysOn(ctx, hostID, false); err != nil {
|
||||
t.Fatalf("SetHostAlwaysOn: %v", err)
|
||||
}
|
||||
|
||||
// Set last_backup_at to 8 days ago — stale — but no schedule.
|
||||
eightDaysAgo := time.Now().UTC().Add(-8 * 24 * time.Hour)
|
||||
if err := st.SetHostLastBackup(ctx, hostID, "succeeded", eightDaysAgo); err != nil {
|
||||
t.Fatalf("SetHostLastBackup: %v", err)
|
||||
}
|
||||
|
||||
eng.tick(ctx, time.Now().UTC())
|
||||
|
||||
open, _ := st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
|
||||
for _, a := range open {
|
||||
if a.Kind == KindStaleSchedule {
|
||||
t.Fatalf("expected no stale_schedule without an enabled schedule; got: %+v", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveOnModeChangeClearsOfflineAlert checks that ResolveOnModeChange
|
||||
// clears an open agent_offline alert when a host's mode is toggled.
|
||||
func TestResolveOnModeChangeClearsOfflineAlert(t *testing.T) {
|
||||
t.Parallel()
|
||||
eng, st, hostID := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Make always-on and set it offline with a stale last_seen_at.
|
||||
if err := st.SetHostAlwaysOn(ctx, hostID, true); err != nil {
|
||||
t.Fatalf("SetHostAlwaysOn: %v", err)
|
||||
}
|
||||
if _, err := st.DB().Exec(
|
||||
`UPDATE hosts SET last_seen_at = ?, status = ? WHERE id = ?`,
|
||||
time.Now().UTC().Add(-2*time.Hour).Format(time.RFC3339Nano),
|
||||
"offline",
|
||||
hostID,
|
||||
); err != nil {
|
||||
t.Fatalf("update last_seen_at: %v", err)
|
||||
}
|
||||
|
||||
// Raise the offline alert.
|
||||
eng.handleHostOffline(ctx, hostID)
|
||||
|
||||
open, _ := st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
|
||||
if len(open) != 1 || open[0].Kind != KindAgentOffline {
|
||||
t.Fatalf("expected 1 agent_offline alert before mode change; got %d: %+v", len(open), open)
|
||||
}
|
||||
|
||||
// Toggle mode — should clear the alert.
|
||||
eng.ResolveOnModeChange(ctx, hostID, time.Now().UTC())
|
||||
|
||||
open, _ = st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
|
||||
for _, a := range open {
|
||||
if a.Kind == KindAgentOffline {
|
||||
t.Fatalf("expected agent_offline to be resolved after mode change; still open: %+v", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoStalenessWhenNeverBackedUp checks that no stale_schedule alert is
|
||||
// raised for an intermittent host that has never backed up (nil LastBackupAt).
|
||||
func TestNoStalenessWhenNeverBackedUp(t *testing.T) {
|
||||
t.Parallel()
|
||||
eng, st, hostID := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Make intermittent.
|
||||
if err := st.SetHostAlwaysOn(ctx, hostID, false); err != nil {
|
||||
t.Fatalf("SetHostAlwaysOn: %v", err)
|
||||
}
|
||||
|
||||
// Create a source group and an enabled schedule — but do NOT set LastBackupAt.
|
||||
sgID := ulid.Make().String()
|
||||
if err := st.CreateSourceGroup(ctx, &store.SourceGroup{
|
||||
ID: sgID,
|
||||
HostID: hostID,
|
||||
Name: "default",
|
||||
Includes: []string{"/home"},
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateSourceGroup: %v", err)
|
||||
}
|
||||
|
||||
schedID := ulid.Make().String()
|
||||
if err := st.CreateSchedule(ctx, &store.Schedule{
|
||||
ID: schedID,
|
||||
HostID: hostID,
|
||||
CronExpr: "0 2 * * *",
|
||||
Enabled: true,
|
||||
SourceGroupIDs: []string{sgID},
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateSchedule: %v", err)
|
||||
}
|
||||
|
||||
eng.tick(ctx, time.Now().UTC())
|
||||
|
||||
open, _ := st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
|
||||
for _, a := range open {
|
||||
if a.Kind == KindStaleSchedule {
|
||||
t.Fatalf("expected no stale_schedule when never backed up; got: %+v", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-8
@@ -27,15 +27,19 @@ const (
|
||||
// integrity is at risk) when a check job fails.
|
||||
KindCheckFailed = "check_failed"
|
||||
|
||||
// KindStaleSchedule is declared for completeness but intentionally
|
||||
// left as a no-op in v1. The precise "expected to have fired but
|
||||
// didn't" logic requires a store helper that lands in a follow-up
|
||||
// task. Ask the team before implementing.
|
||||
// KindStaleSchedule is raised for intermittent (non-always-on) hosts
|
||||
// when their last successful backup is older than staleBackupThreshold
|
||||
// (7 days) and they have at least one enabled schedule. Resolved on
|
||||
// backup success or when the host is switched to always-on mode.
|
||||
KindStaleSchedule = "stale_schedule"
|
||||
|
||||
// KindAgentOffline is raised when a host's last_seen_at is older
|
||||
// than the 15-minute floor and resolved when the host reconnects.
|
||||
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
|
||||
@@ -56,7 +60,7 @@ func (e *Engine) raiseAndNotify(ctx context.Context, hostID, kind, dedupKey, sev
|
||||
if err == nil {
|
||||
hostName = host.Name
|
||||
}
|
||||
go e.hub.Dispatch(ctx, notification.Payload{
|
||||
e.dispatchNotification(ctx, notification.Payload{
|
||||
Event: notification.EventRaised,
|
||||
AlertID: id,
|
||||
Severity: severity,
|
||||
@@ -81,7 +85,7 @@ func (e *Engine) Acknowledge(ctx context.Context, alertID, userID string, when t
|
||||
return nil //nolint:nilerr
|
||||
}
|
||||
p := alertPayload(ctx, e.store, notification.EventAcknowledged, a)
|
||||
go e.hub.Dispatch(context.WithoutCancel(ctx), p)
|
||||
e.dispatchNotification(context.WithoutCancel(ctx), p)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -95,7 +99,7 @@ func (e *Engine) Resolve(ctx context.Context, alertID string, when time.Time) er
|
||||
return nil
|
||||
}
|
||||
p := alertPayload(ctx, e.store, notification.EventResolved, a)
|
||||
go e.hub.Dispatch(context.WithoutCancel(ctx), p)
|
||||
e.dispatchNotification(context.WithoutCancel(ctx), p)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -122,6 +126,16 @@ func alertPayload(ctx context.Context, st *store.Store, ev notification.Event, a
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveOnModeChange clears any open agent_offline and stale_schedule
|
||||
// alerts for a host whose always-on flag was just toggled. The next
|
||||
// 60s tick re-raises whichever still applies under the new mode, so
|
||||
// this is a self-correcting "wipe and let the sweep settle" call.
|
||||
// Safe to invoke from the HTTP layer (it only touches the store + hub).
|
||||
func (e *Engine) ResolveOnModeChange(ctx context.Context, hostID string, when time.Time) {
|
||||
e.resolveAndNotify(ctx, hostID, KindAgentOffline, "", when)
|
||||
e.resolveAndNotify(ctx, hostID, KindStaleSchedule, "", when)
|
||||
}
|
||||
|
||||
// resolveAndNotify clears the open (or acknowledged) alert matching
|
||||
// (host_id, kind, dedup_key) via store.AutoResolve, then fires
|
||||
// alert.resolved for the row(s) actually closed. Best-effort —
|
||||
@@ -150,7 +164,7 @@ func (e *Engine) resolveAndNotify(ctx context.Context, hostID, kind, dedupKey st
|
||||
if a.Kind != kind || a.DedupKey != dedupKey {
|
||||
continue
|
||||
}
|
||||
go e.hub.Dispatch(ctx, notification.Payload{
|
||||
e.dispatchNotification(ctx, notification.Payload{
|
||||
Event: notification.EventResolved,
|
||||
AlertID: a.ID,
|
||||
Severity: a.Severity,
|
||||
|
||||
@@ -3,6 +3,7 @@ package alert
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -24,6 +25,7 @@ func setupEngine(t *testing.T) (*Engine, *store.Store, string) {
|
||||
aead, _ := crypto.NewAEAD(key)
|
||||
hub := notification.NewHub(st, aead, "https://rm.example")
|
||||
eng := NewEngine(st, hub)
|
||||
t.Cleanup(eng.waitNotifications)
|
||||
hostID := ulid.Make().String()
|
||||
if err := st.CreateHost(context.Background(), store.Host{
|
||||
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) {
|
||||
t.Parallel()
|
||||
eng, st, hostID := setupEngine(t)
|
||||
|
||||
+14
-6
@@ -10,6 +10,13 @@ import (
|
||||
// (not iota ints) makes traffic readable in logs and packet captures.
|
||||
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.
|
||||
const (
|
||||
MsgHello MessageType = "hello"
|
||||
@@ -29,12 +36,13 @@ const (
|
||||
|
||||
// Server → agent message types.
|
||||
const (
|
||||
MsgCommandRun MessageType = "command.run"
|
||||
MsgCommandCancel MessageType = "command.cancel"
|
||||
MsgScheduleSet MessageType = "schedule.set"
|
||||
MsgConfigUpdate MessageType = "config.update"
|
||||
MsgCommandUpdate MessageType = "command.update"
|
||||
MsgTreeList MessageType = "tree.list" // sync RPC: list a snapshot's children
|
||||
MsgCommandRun MessageType = "command.run"
|
||||
MsgCommandCancel MessageType = "command.cancel"
|
||||
MsgScheduleSet MessageType = "schedule.set"
|
||||
MsgConfigUpdate MessageType = "config.update"
|
||||
MsgCommandUpdate MessageType = "command.update"
|
||||
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.
|
||||
|
||||
@@ -322,7 +322,7 @@ type ForgetGroup struct {
|
||||
// any keep-* would delete every snapshot in the tagged set).
|
||||
// Returns the first error encountered, or nil when every group runs
|
||||
// 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 {
|
||||
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 = append(args, g.Policy.args()...)
|
||||
if dryRun {
|
||||
args = append(args, "--dry-run")
|
||||
}
|
||||
cmd := e.resticCmd(ctx, args...)
|
||||
if err := runWithPump(cmd, handle); err != nil {
|
||||
return err
|
||||
|
||||
@@ -60,6 +60,26 @@ func TestRunPruneInvokesPrune(t *testing.T) {
|
||||
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 ---
|
||||
|
||||
func TestRunCheckLockSniff(t *testing.T) {
|
||||
|
||||
@@ -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.
|
||||
// store.ErrFleetUpdateRunning bubbles up unchanged.
|
||||
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 == "" {
|
||||
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,
|
||||
TargetVersion: targetVersion,
|
||||
Status: "running",
|
||||
PauseAfterFirst: pauseAfterFirst,
|
||||
}, hostIDs); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -137,6 +148,13 @@ func (w *Worker) run(ctx context.Context, fuID, userID, targetVersion string) {
|
||||
|
||||
next := pending[0]
|
||||
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
|
||||
}
|
||||
}
|
||||
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.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) {
|
||||
st := openStore(t)
|
||||
uid := mustCreateAdmin(t, st)
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// catchup.go — server-side catch-up for intermittent (non-always-on)
|
||||
// hosts. When such a host reconnects we wait a short settle window,
|
||||
// then dispatch a backup for any schedule whose window elapsed while
|
||||
// the host was asleep. This is separate from pending_runs: a host that
|
||||
// was asleep never fired its local cron, so no pending row exists.
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// scheduleOverdue reports whether a schedule's most recent expected
|
||||
// fire is newer than the host's last successful backup — i.e. a window
|
||||
// passed with no backup. A nil lastBackup means "never backed up" and
|
||||
// is always overdue (provided the cron parses). An unparseable cron is
|
||||
// treated as not-overdue so a bad expression can never trigger a
|
||||
// surprise dispatch. Uses the same cronParser the agent's scheduler
|
||||
// and schedule validation use, so interpretation is identical.
|
||||
func scheduleOverdue(cronExpr string, lastBackup *time.Time, now time.Time) bool {
|
||||
sched, err := cronParser.Parse(cronExpr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if lastBackup == nil {
|
||||
return true
|
||||
}
|
||||
next := sched.Next(*lastBackup)
|
||||
return !next.After(now)
|
||||
}
|
||||
|
||||
// catchupSettle is how long after a reconnect we wait before evaluating
|
||||
// catch-up, so a laptop that wakes briefly and sleeps again doesn't
|
||||
// trigger a backup it can't finish. ~1 minute per the spec.
|
||||
const catchupSettle = 60 * time.Second
|
||||
|
||||
// ArmCatchup records that an intermittent host just reconnected and
|
||||
// should be evaluated for a missed backup after the settle window.
|
||||
// No-op for always-on hosts (caller passes only intermittent hosts).
|
||||
// Re-arming overwrites the timer (debounce — flapping doesn't stack).
|
||||
func (s *Server) ArmCatchup(hostID string, now time.Time) {
|
||||
s.catchupMu.Lock()
|
||||
defer s.catchupMu.Unlock()
|
||||
s.catchupDueAt[hostID] = now.Add(catchupSettle)
|
||||
}
|
||||
|
||||
// dueCatchups returns the hostIDs whose settle window has elapsed and
|
||||
// removes them from the map. Caller evaluates each.
|
||||
func (s *Server) dueCatchups(now time.Time) []string {
|
||||
s.catchupMu.Lock()
|
||||
defer s.catchupMu.Unlock()
|
||||
var due []string
|
||||
for id, at := range s.catchupDueAt {
|
||||
if !now.Before(at) {
|
||||
due = append(due, id)
|
||||
delete(s.catchupDueAt, id)
|
||||
}
|
||||
}
|
||||
return due
|
||||
}
|
||||
|
||||
// RunCatchupsDue is the tick entrypoint. For each host past its settle
|
||||
// window it dispatches a backup for every enabled schedule that is
|
||||
// overdue. Skips hosts that bounced back offline, that are already
|
||||
// running/queued a job, or that turned out to be always-on.
|
||||
func (s *Server) RunCatchupsDue(ctx context.Context) {
|
||||
if s.deps.Hub == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, hostID := range s.dueCatchups(now) {
|
||||
s.runCatchup(ctx, hostID, now)
|
||||
}
|
||||
}
|
||||
|
||||
// runCatchup evaluates and dispatches catch-up backups for a single
|
||||
// host. Kept separate so RunCatchupsDue reads cleanly.
|
||||
func (s *Server) runCatchup(ctx context.Context, hostID string, now time.Time) {
|
||||
conn := s.deps.Hub.Conn(hostID)
|
||||
if conn == nil {
|
||||
return // bounced offline during the settle window; re-arms on next hello
|
||||
}
|
||||
host, err := s.deps.Store.GetHost(ctx, hostID)
|
||||
if err != nil {
|
||||
slog.Warn("catchup: load host", "host_id", hostID, "err", err)
|
||||
return
|
||||
}
|
||||
if host.AlwaysOn {
|
||||
return // mode flipped during settle window
|
||||
}
|
||||
// Skip if a backup is already queued or running for this host —
|
||||
// don't pile a catch-up on top of in-flight work. (hosts.current_job_id
|
||||
// is not maintained, so we check the jobs table directly.)
|
||||
active, err := s.deps.Store.HasActiveBackupJob(ctx, hostID)
|
||||
if err != nil {
|
||||
slog.Warn("catchup: check active backup", "host_id", hostID, "err", err)
|
||||
return
|
||||
}
|
||||
if active {
|
||||
return
|
||||
}
|
||||
schedules, err := s.deps.Store.ListSchedulesByHost(ctx, hostID)
|
||||
if err != nil {
|
||||
slog.Warn("catchup: list schedules", "host_id", hostID, "err", err)
|
||||
return
|
||||
}
|
||||
// NOTE: overdue is measured against host.LastBackupAt, which is the
|
||||
// most recent *successful backup of any schedule* on this host — not
|
||||
// a per-schedule timestamp. For the common intermittent host (a
|
||||
// single backup schedule) this is exact. With multiple schedules of
|
||||
// different cadences, a recent backup from one schedule can mask
|
||||
// another schedule's missed window. Acceptable for v1; revisit with
|
||||
// per-schedule last-success tracking if multi-cadence laptops appear.
|
||||
for _, sc := range schedules {
|
||||
if !sc.Enabled || len(sc.SourceGroupIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
if !scheduleOverdue(sc.CronExpr, host.LastBackupAt, now) {
|
||||
continue
|
||||
}
|
||||
for _, gid := range sc.SourceGroupIDs {
|
||||
g, err := s.deps.Store.GetSourceGroup(ctx, hostID, gid)
|
||||
if err != nil {
|
||||
slog.Warn("catchup: load source group",
|
||||
"host_id", hostID, "schedule_id", sc.ID, "group_id", gid, "err", err)
|
||||
continue
|
||||
}
|
||||
if _, derr := s.dispatchBackupForGroupCore(ctx, conn, hostID, sc.ID, g, now); derr != nil {
|
||||
// Send failed for this group — host may have dropped
|
||||
// again. Earlier groups in this batch were already
|
||||
// dispatched; re-arm so a later reconnect re-evaluates
|
||||
// any still-overdue schedules.
|
||||
s.ArmCatchup(hostID, now)
|
||||
return
|
||||
}
|
||||
slog.Info("catchup: dispatched missed backup",
|
||||
"host_id", hostID, "schedule_id", sc.ID, "group", g.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// catchup_scheduler_test.go — integration tests for the catch-up scheduler.
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
)
|
||||
|
||||
// TestRunCatchupDispatchesOverdue verifies four properties of the
|
||||
// catch-up scheduler in separate sub-tests sharing no state.
|
||||
func TestRunCatchupDispatchesOverdue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// --- 1. Overdue host with connected agent → backup dispatched -------
|
||||
t.Run("overdue_dispatch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, ts, st := rawTestServer(t)
|
||||
hostID, token := enrolHostForWS(t, srv, st, "catchup-overdue")
|
||||
|
||||
if err := st.SetHostAlwaysOn(context.Background(), hostID, false); err != nil {
|
||||
t.Fatalf("set always_on: %v", err)
|
||||
}
|
||||
// Last backup ~8 days ago → schedule overdue.
|
||||
eightDaysAgo := time.Now().UTC().Add(-8 * 24 * time.Hour)
|
||||
if err := st.SetHostLastBackup(context.Background(), hostID, "succeeded", eightDaysAgo); err != nil {
|
||||
t.Fatalf("set last backup: %v", err)
|
||||
}
|
||||
|
||||
if err := st.CreateJob(context.Background(), store.Job{
|
||||
ID: ulid.Make().String(), HostID: hostID, Kind: "init",
|
||||
ActorKind: "system", CreatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed init: %v", err)
|
||||
}
|
||||
|
||||
gid := ulid.Make().String()
|
||||
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
|
||||
ID: gid, HostID: hostID, Name: "home", Includes: []string{"/home"},
|
||||
}); err != nil {
|
||||
t.Fatalf("source group: %v", err)
|
||||
}
|
||||
sid := ulid.Make().String()
|
||||
if err := st.CreateSchedule(context.Background(), &store.Schedule{
|
||||
ID: sid, HostID: hostID, CronExpr: "0 2 * * *", Enabled: true,
|
||||
SourceGroupIDs: []string{gid},
|
||||
}); err != nil {
|
||||
t.Fatalf("schedule: %v", err)
|
||||
}
|
||||
|
||||
c := agentDial(t, srv, ts, hostID, token)
|
||||
sendHello(t, c, "catchup-overdue")
|
||||
_ = drainUntil(t, c, api.MsgScheduleSet)
|
||||
|
||||
// Arm with a past time so the settle window is already elapsed.
|
||||
srv.ArmCatchup(hostID, time.Now().UTC().Add(-2*time.Minute))
|
||||
srv.RunCatchupsDue(context.Background())
|
||||
|
||||
// Give the dispatch goroutine a moment to write the job row.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
var n int
|
||||
if err := st.DB().QueryRow(
|
||||
`SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = 'backup'`, hostID).Scan(&n); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if n < 1 {
|
||||
t.Errorf("overdue host: want ≥1 backup job, got %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
// --- 2. Not overdue → no dispatch -----------------------------------
|
||||
t.Run("not_overdue_no_dispatch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, ts, st := rawTestServer(t)
|
||||
hostID, token := enrolHostForWS(t, srv, st, "catchup-notoverdue")
|
||||
|
||||
if err := st.SetHostAlwaysOn(context.Background(), hostID, false); err != nil {
|
||||
t.Fatalf("set always_on: %v", err)
|
||||
}
|
||||
// Last backup just now → not overdue.
|
||||
now := time.Now().UTC()
|
||||
if err := st.SetHostLastBackup(context.Background(), hostID, "succeeded", now); err != nil {
|
||||
t.Fatalf("set last backup: %v", err)
|
||||
}
|
||||
|
||||
if err := st.CreateJob(context.Background(), store.Job{
|
||||
ID: ulid.Make().String(), HostID: hostID, Kind: "init",
|
||||
ActorKind: "system", CreatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed init: %v", err)
|
||||
}
|
||||
|
||||
gid := ulid.Make().String()
|
||||
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
|
||||
ID: gid, HostID: hostID, Name: "home", Includes: []string{"/home"},
|
||||
}); err != nil {
|
||||
t.Fatalf("source group: %v", err)
|
||||
}
|
||||
sid := ulid.Make().String()
|
||||
if err := st.CreateSchedule(context.Background(), &store.Schedule{
|
||||
ID: sid, HostID: hostID, CronExpr: "0 2 * * *", Enabled: true,
|
||||
SourceGroupIDs: []string{gid},
|
||||
}); err != nil {
|
||||
t.Fatalf("schedule: %v", err)
|
||||
}
|
||||
|
||||
c := agentDial(t, srv, ts, hostID, token)
|
||||
sendHello(t, c, "catchup-notoverdue")
|
||||
_ = drainUntil(t, c, api.MsgScheduleSet)
|
||||
|
||||
srv.ArmCatchup(hostID, time.Now().UTC().Add(-2*time.Minute))
|
||||
srv.RunCatchupsDue(context.Background())
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
var n int
|
||||
if err := st.DB().QueryRow(
|
||||
`SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = 'backup'`, hostID).Scan(&n); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("not-overdue host: want 0 backup jobs, got %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
// --- 3. Active backup in flight → no new dispatch -------------------
|
||||
t.Run("active_backup_blocks_dispatch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, ts, st := rawTestServer(t)
|
||||
hostID, token := enrolHostForWS(t, srv, st, "catchup-active")
|
||||
|
||||
if err := st.SetHostAlwaysOn(context.Background(), hostID, false); err != nil {
|
||||
t.Fatalf("set always_on: %v", err)
|
||||
}
|
||||
eightDaysAgo := time.Now().UTC().Add(-8 * 24 * time.Hour)
|
||||
if err := st.SetHostLastBackup(context.Background(), hostID, "succeeded", eightDaysAgo); err != nil {
|
||||
t.Fatalf("set last backup: %v", err)
|
||||
}
|
||||
|
||||
if err := st.CreateJob(context.Background(), store.Job{
|
||||
ID: ulid.Make().String(), HostID: hostID, Kind: "init",
|
||||
ActorKind: "system", CreatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed init: %v", err)
|
||||
}
|
||||
|
||||
gid := ulid.Make().String()
|
||||
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
|
||||
ID: gid, HostID: hostID, Name: "home", Includes: []string{"/home"},
|
||||
}); err != nil {
|
||||
t.Fatalf("source group: %v", err)
|
||||
}
|
||||
sid := ulid.Make().String()
|
||||
if err := st.CreateSchedule(context.Background(), &store.Schedule{
|
||||
ID: sid, HostID: hostID, CronExpr: "0 2 * * *", Enabled: true,
|
||||
SourceGroupIDs: []string{gid},
|
||||
}); err != nil {
|
||||
t.Fatalf("schedule: %v", err)
|
||||
}
|
||||
|
||||
// Seed a queued backup job — this is "already in flight".
|
||||
if err := st.CreateJob(context.Background(), store.Job{
|
||||
ID: ulid.Make().String(), HostID: hostID, Kind: "backup",
|
||||
ActorKind: "schedule", CreatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed queued backup: %v", err)
|
||||
}
|
||||
|
||||
c := agentDial(t, srv, ts, hostID, token)
|
||||
sendHello(t, c, "catchup-active")
|
||||
_ = drainUntil(t, c, api.MsgScheduleSet)
|
||||
|
||||
srv.ArmCatchup(hostID, time.Now().UTC().Add(-2*time.Minute))
|
||||
srv.RunCatchupsDue(context.Background())
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
var n int
|
||||
if err := st.DB().QueryRow(
|
||||
`SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = 'backup'`, hostID).Scan(&n); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
// Count must still be exactly 1 — no second job added.
|
||||
if n != 1 {
|
||||
t.Errorf("active backup guard: want 1 job (the seeded one), got %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
// --- 4. Disconnected host → no dispatch -----------------------------
|
||||
t.Run("disconnected_no_dispatch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, _, st := rawTestServer(t)
|
||||
hostID, _ := enrolHostForWS(t, srv, st, "catchup-disconnected")
|
||||
|
||||
if err := st.SetHostAlwaysOn(context.Background(), hostID, false); err != nil {
|
||||
t.Fatalf("set always_on: %v", err)
|
||||
}
|
||||
eightDaysAgo := time.Now().UTC().Add(-8 * 24 * time.Hour)
|
||||
if err := st.SetHostLastBackup(context.Background(), hostID, "succeeded", eightDaysAgo); err != nil {
|
||||
t.Fatalf("set last backup: %v", err)
|
||||
}
|
||||
|
||||
if err := st.CreateJob(context.Background(), store.Job{
|
||||
ID: ulid.Make().String(), HostID: hostID, Kind: "init",
|
||||
ActorKind: "system", CreatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed init: %v", err)
|
||||
}
|
||||
|
||||
gid := ulid.Make().String()
|
||||
if err := st.CreateSourceGroup(context.Background(), &store.SourceGroup{
|
||||
ID: gid, HostID: hostID, Name: "home", Includes: []string{"/home"},
|
||||
}); err != nil {
|
||||
t.Fatalf("source group: %v", err)
|
||||
}
|
||||
sid := ulid.Make().String()
|
||||
if err := st.CreateSchedule(context.Background(), &store.Schedule{
|
||||
ID: sid, HostID: hostID, CronExpr: "0 2 * * *", Enabled: true,
|
||||
SourceGroupIDs: []string{gid},
|
||||
}); err != nil {
|
||||
t.Fatalf("schedule: %v", err)
|
||||
}
|
||||
|
||||
// Host is NOT connected — no agentDial.
|
||||
|
||||
srv.ArmCatchup(hostID, time.Now().UTC().Add(-2*time.Minute))
|
||||
srv.RunCatchupsDue(context.Background())
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
var n int
|
||||
if err := st.DB().QueryRow(
|
||||
`SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = 'backup'`, hostID).Scan(&n); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("disconnected host: want 0 backup jobs, got %d", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestScheduleOverdue(t *testing.T) {
|
||||
mustParse := func(s string) time.Time {
|
||||
t.Helper()
|
||||
v, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", s, err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
daily := "0 2 * * *" // 02:00 every day
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cron string
|
||||
lastBackup *time.Time
|
||||
now time.Time
|
||||
want bool
|
||||
}{
|
||||
{name: "never backed up is overdue", cron: daily, lastBackup: nil, now: mustParse("2026-06-15T09:00:00Z"), want: true},
|
||||
{name: "missed last nights window", cron: daily, lastBackup: ptrTime(mustParse("2026-06-13T02:05:00Z")), now: mustParse("2026-06-15T09:00:00Z"), want: true},
|
||||
{name: "backed up after the most recent window", cron: daily, lastBackup: ptrTime(mustParse("2026-06-15T02:05:00Z")), now: mustParse("2026-06-15T09:00:00Z"), want: false},
|
||||
{name: "unparseable cron is never overdue", cron: "not a cron", lastBackup: nil, now: mustParse("2026-06-15T09:00:00Z"), want: false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := scheduleOverdue(c.cron, c.lastBackup, c.now)
|
||||
if got != c.want {
|
||||
t.Fatalf("scheduleOverdue(%q, %v, %v) = %v, want %v", c.cron, c.lastBackup, c.now, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ptrTime(t time.Time) *time.Time { return &t }
|
||||
@@ -32,6 +32,13 @@ import (
|
||||
type fleetUpdateStartReq struct {
|
||||
TargetVersion string `json:"target_version,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
|
||||
@@ -56,6 +63,7 @@ type fleetUpdateView struct {
|
||||
CurrentHostID string `json:"current_host_id,omitempty"`
|
||||
HaltedReason string `json:"halted_reason,omitempty"`
|
||||
CompletedAt *string `json:"completed_at,omitempty"`
|
||||
CanaryFirst bool `json:"canary_first"`
|
||||
Hosts []fleetUpdateHostView `json:"hosts"`
|
||||
}
|
||||
|
||||
@@ -65,6 +73,7 @@ type fleetUpdateView struct {
|
||||
type fleetUpdatePage struct {
|
||||
// Idle-state fields.
|
||||
OutOfDateHosts []store.Host // online hosts whose version != target
|
||||
Candidates []fleetUpdateCandidate
|
||||
TargetVersion string
|
||||
|
||||
// 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 == "" {
|
||||
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
|
||||
if len(hostIDs) == 0 {
|
||||
derived, err := s.deriveOutOfDateOnlineHostIDs(r.Context(), target)
|
||||
@@ -108,6 +122,19 @@ func (s *Server) handleAPIFleetUpdateStart(w stdhttp.ResponseWriter, r *stdhttp.
|
||||
return
|
||||
}
|
||||
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 {
|
||||
writeJSONError(w, stdhttp.StatusConflict, "no_hosts_eligible",
|
||||
@@ -115,7 +142,20 @@ func (s *Server) handleAPIFleetUpdateStart(w stdhttp.ResponseWriter, r *stdhttp.
|
||||
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 errors.Is(err, store.ErrFleetUpdateRunning) {
|
||||
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,
|
||||
"target_version": target,
|
||||
"host_count": len(hostIDs),
|
||||
"canary_first": body.CanaryFirst,
|
||||
})
|
||||
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
||||
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,
|
||||
CurrentHostID: fu.CurrentHostID,
|
||||
HaltedReason: fu.HaltedReason,
|
||||
CanaryFirst: fu.PauseAfterFirst,
|
||||
Hosts: make([]fleetUpdateHostView, 0, len(hosts)),
|
||||
}
|
||||
if fu.CompletedAt != nil {
|
||||
@@ -286,6 +328,27 @@ func (s *Server) buildFleetUpdatePage(r *stdhttp.Request) (fleetUpdatePage, erro
|
||||
}
|
||||
for _, h := range hosts {
|
||||
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())
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 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
|
||||
// (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
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
stdhttp "net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -19,6 +21,67 @@ import (
|
||||
"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.
|
||||
// It records what was passed to Start/Cancel and lets tests inject
|
||||
// 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
|
||||
}
|
||||
|
||||
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 {
|
||||
f.mu.Lock()
|
||||
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) {
|
||||
t.Parallel()
|
||||
srv, ts, st := rawTestServer(t)
|
||||
|
||||
@@ -483,6 +483,12 @@ func (s *Server) onAgentHello(ctx context.Context, hostID string, conn *ws.Conn)
|
||||
// and the drain may take seconds across many rows. A non-blocking
|
||||
// goroutine keeps the hello path snappy.
|
||||
go s.DrainPending(context.Background(), hostID)
|
||||
// Intermittent hosts that just reconnected may have slept through a
|
||||
// backup window. Arm a catch-up evaluation after a settle delay; the
|
||||
// pending-drain tick fires it. Always-on hosts never need this.
|
||||
if host, err := s.deps.Store.GetHost(ctx, hostID); err == nil && !host.AlwaysOn {
|
||||
s.ArmCatchup(hostID, time.Now().UTC())
|
||||
}
|
||||
}
|
||||
|
||||
// maybeAutoInit dispatches a `restic init` job iff the host has no
|
||||
|
||||
@@ -65,10 +65,32 @@ func (s *Server) handleRunNow(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
func (s *Server) dispatchJob(ctx context.Context, user *store.User,
|
||||
hostID string, kind api.JobKind, args []string,
|
||||
) (res runNowResponse, status int, code, msg string) {
|
||||
return s.dispatchJobWithPayload(ctx, user, hostID, kind, nil, api.CommandRunPayload{
|
||||
payload := api.CommandRunPayload{
|
||||
Kind: kind,
|
||||
Args: args,
|
||||
})
|
||||
}
|
||||
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
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,12 @@ func (s *Server) DispatchMaintenance(ctx context.Context, decisions []maintenanc
|
||||
}
|
||||
switch d.Kind {
|
||||
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 {
|
||||
slog.Info("maintenance: forget skipped — no source groups with retention",
|
||||
"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
|
||||
// with ForgetGroups populated. Returns ok=false if the host has no
|
||||
// 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)
|
||||
if err != nil {
|
||||
slog.Warn("maintenance: list source groups failed", "host_id", hostID, "err", err)
|
||||
return api.CommandRunPayload{}, false
|
||||
return api.CommandRunPayload{}, false, err
|
||||
}
|
||||
fg := make([]api.ForgetGroup, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
@@ -105,9 +109,9 @@ func (s *Server) buildForgetPayloadForHost(ctx context.Context, hostID string) (
|
||||
})
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"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/metrics"
|
||||
"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
|
||||
}
|
||||
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 {
|
||||
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
|
||||
if reg == nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -512,11 +512,27 @@ func TestDrainPendingSerializesPerHost(t *testing.T) {
|
||||
// Connect the agent so DrainPending can dispatch.
|
||||
c := agentDial(t, srv, ts, hostID, token)
|
||||
sendHello(t, c, "serialise-host")
|
||||
// Drain the on-hello goroutine's pass first (no pending rows yet),
|
||||
// then wait for the schedule.set so the connection is fully settled.
|
||||
// Wait for the on-hello push to settle.
|
||||
_ = 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()
|
||||
for i := range 5 {
|
||||
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
|
||||
for range 10 {
|
||||
wg.Add(1)
|
||||
@@ -544,24 +561,26 @@ func TestDrainPendingSerializesPerHost(t *testing.T) {
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Drain any envelopes the agent received so we don't block below.
|
||||
// We read with short timeouts and stop when the connection goes quiet.
|
||||
drainDeadline := time.Now().Add(500 * time.Millisecond)
|
||||
for time.Now().Before(drainDeadline) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
_, _, err := c.Read(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
// Drain to completion. The fire-and-forget on-hello DrainPending
|
||||
// shares the same per-host mutex and can hold it during the burst,
|
||||
// leaving rows for a later pass — exactly how production drains
|
||||
// (repeatedly, via the 30s tick / on reconnect). Re-drain until the
|
||||
// queue is empty; because every drain is still serialised, each row
|
||||
// is dispatched at most once, so the exactly-5 job count below proves
|
||||
// there was no double-dispatch.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
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 {
|
||||
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
|
||||
_ = st.DB().QueryRow(
|
||||
`SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = 'backup' AND actor_kind = 'schedule'`,
|
||||
|
||||
@@ -90,6 +90,13 @@ type Server struct {
|
||||
// directories (P3-X2). Pre-allocated in New so the lazy-init
|
||||
// race is impossible.
|
||||
treeCache *treeCache
|
||||
|
||||
// catchupDueAt tracks intermittent hosts that reconnected and are
|
||||
// in their settle window. Keyed hostID → earliest time to evaluate
|
||||
// catch-up. Best-effort + in-memory: a server restart simply re-arms
|
||||
// on the next hello. Guarded by catchupMu.
|
||||
catchupMu sync.Mutex
|
||||
catchupDueAt map[string]time.Time
|
||||
}
|
||||
|
||||
// New builds a configured but not-yet-started server.
|
||||
@@ -104,11 +111,12 @@ func New(deps Deps) *Server {
|
||||
r.Use(requestLogger)
|
||||
|
||||
s := &Server{
|
||||
deps: deps,
|
||||
drainLocks: make(map[string]*sync.Mutex),
|
||||
announceRL: newAnnounceLimiter(),
|
||||
pendingHub: newPendingHub(),
|
||||
treeCache: newTreeCache(),
|
||||
deps: deps,
|
||||
drainLocks: make(map[string]*sync.Mutex),
|
||||
announceRL: newAnnounceLimiter(),
|
||||
pendingHub: newPendingHub(),
|
||||
treeCache: newTreeCache(),
|
||||
catchupDueAt: make(map[string]time.Time),
|
||||
}
|
||||
s.routes(r)
|
||||
|
||||
@@ -254,6 +262,7 @@ func (s *Server) routes(r chi.Router) {
|
||||
r.Post("/api/hosts/{id}/repo/unlock", s.handleRunRepoUnlock)
|
||||
r.Post("/api/jobs/{id}/cancel", s.handleCancelJob)
|
||||
r.Post("/api/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
|
||||
r.Post("/api/hosts/{id}/snapshots/refresh", s.handleRefreshHostSnapshots)
|
||||
|
||||
// HTMX form variants outside /api.
|
||||
r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
|
||||
@@ -279,6 +288,7 @@ func (s *Server) routes(r chi.Router) {
|
||||
r.Post("/hosts/{id}/repo/probe", s.handleUIRepoProbe)
|
||||
r.Post("/hosts/{id}/repo/hooks", s.handleUIRepoHooksSave)
|
||||
r.Post("/hosts/{id}/tags", s.handleUIHostTagsSave)
|
||||
r.Post("/hosts/{id}/mode", s.handleUIHostModeSave)
|
||||
r.Post("/hosts/{id}/admin-credentials", s.handleUIAdminCredentialsSave)
|
||||
r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete)
|
||||
r.Post("/hosts/{id}/schedules/new", s.handleUIScheduleSave)
|
||||
|
||||
@@ -5,6 +5,10 @@ import (
|
||||
"time"
|
||||
|
||||
"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
|
||||
@@ -26,6 +30,7 @@ type listSnapshotsResponse struct {
|
||||
HostID string `json:"host_id"`
|
||||
Count int `json:"count"`
|
||||
RefreshedAt *time.Time `json:"refreshed_at,omitempty"`
|
||||
Stale bool `json:"stale"`
|
||||
Snapshots []snapshotView `json:"snapshots"`
|
||||
}
|
||||
|
||||
@@ -45,7 +50,8 @@ func (s *Server) handleListHostSnapshots(w stdhttp.ResponseWriter, r *stdhttp.Re
|
||||
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", "")
|
||||
return
|
||||
}
|
||||
@@ -61,10 +67,13 @@ func (s *Server) handleListHostSnapshots(w stdhttp.ResponseWriter, r *stdhttp.Re
|
||||
Count: len(snaps),
|
||||
Snapshots: make([]snapshotView, len(snaps)),
|
||||
}
|
||||
if len(snaps) > 0 {
|
||||
t := snaps[0].RefreshedAt
|
||||
out.RefreshedAt = &t
|
||||
out.RefreshedAt = host.SnapshotRefreshedAt
|
||||
mutationAt, err := s.deps.Store.LatestSuccessfulRepoMutation(r.Context(), hostID)
|
||||
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 {
|
||||
out.Snapshots[i] = snapshotView{
|
||||
ID: sn.ID,
|
||||
@@ -80,3 +89,31 @@ func (s *Server) handleListHostSnapshots(w stdhttp.ResponseWriter, r *stdhttp.Re
|
||||
|
||||
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"})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -49,8 +49,14 @@ func TestDashboard_HostRowSparklineRendersWithHistory(t *testing.T) {
|
||||
hostID := makeHost(t, st, "h-spark")
|
||||
ctx := context.Background()
|
||||
|
||||
// Two history points → polyline must render.
|
||||
for i, day := range []string{"2026-05-05", "2026-05-06"} {
|
||||
// Two history points → polyline must render. Use dates relative to
|
||||
// now so the points always fall inside the dashboard's rolling
|
||||
// 30-day window (ui_handlers.go: since = now-30d); hard-coded dates
|
||||
// silently age out of the window and break this test over time.
|
||||
for i, day := range []string{
|
||||
time.Now().UTC().AddDate(0, 0, -2).Format("2006-01-02"),
|
||||
time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02"),
|
||||
} {
|
||||
v := int64(100 + i*50)
|
||||
if err := st.UpsertHostRepoStatsHistory(ctx, hostID, day,
|
||||
store.HostRepoStats{TotalSizeBytes: &v}, time.Now().UTC()); err != nil {
|
||||
|
||||
@@ -983,6 +983,43 @@ func (s *Server) handleUIHostTagsSave(w stdhttp.ResponseWriter, r *stdhttp.Reque
|
||||
stdhttp.Redirect(w, r, "/hosts/"+hostID, stdhttp.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleUIHostModeSave flips a host's always-on flag. Checkbox present
|
||||
// in the form (value any) => always-on; absent => intermittent.
|
||||
// Operator-band; mounted in server.go. On change we clear open
|
||||
// offline/staleness alerts via the engine so the next sweep re-raises
|
||||
// only what still applies under the new mode.
|
||||
func (s *Server) handleUIHostModeSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
alwaysOn := r.PostForm.Get("always_on") != ""
|
||||
if err := s.deps.Store.SetHostAlwaysOn(r.Context(), hostID, alwaysOn); err != nil {
|
||||
slog.Error("ui host mode: save", "host_id", hostID, "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if s.deps.AlertEngine != nil {
|
||||
s.deps.AlertEngine.ResolveOnModeChange(r.Context(), hostID, time.Now().UTC())
|
||||
}
|
||||
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
||||
ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
|
||||
Action: "host.mode_updated",
|
||||
TargetKind: ptr("host"), TargetID: &hostID,
|
||||
TS: time.Now().UTC(),
|
||||
})
|
||||
stdhttp.Redirect(w, r, "/hosts/"+hostID, stdhttp.StatusSeeOther)
|
||||
}
|
||||
|
||||
// normaliseTags splits a comma-separated string, lowercases each token,
|
||||
// trims whitespace, drops empties, and dedupes. Order is preserved
|
||||
// from first occurrence (so the user's typing order shows on screen).
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// ui_host_mode_test.go — covers handleUIHostModeSave: toggling a
|
||||
// host's always-on flag via POST /hosts/{id}/mode.
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdhttp "net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestHostModeSaveToggle verifies the checkbox-absent ⇒ intermittent
|
||||
// and checkbox-present ⇒ always-on semantics, and that the audit row
|
||||
// lands for each request.
|
||||
func TestHostModeSaveToggle(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, st := rawTestServerWithUI(t)
|
||||
hostID, _ := enrolHostForUI(t, nil, st, "mode-toggle-host")
|
||||
|
||||
cookie := loginAsAdmin(t, st)
|
||||
|
||||
cli := &stdhttp.Client{
|
||||
CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error {
|
||||
return stdhttp.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
// --- POST with no always_on field => intermittent ---
|
||||
form := url.Values{}
|
||||
req, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/"+hostID+"/mode",
|
||||
strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
res, err := cli.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
_ = res.Body.Close()
|
||||
if res.StatusCode != stdhttp.StatusSeeOther {
|
||||
t.Fatalf("status: got %d, want 303", res.StatusCode)
|
||||
}
|
||||
if loc := res.Header.Get("Location"); loc != "/hosts/"+hostID {
|
||||
t.Errorf("Location: got %q, want /hosts/%s", loc, hostID)
|
||||
}
|
||||
|
||||
got, err := st.GetHost(context.Background(), hostID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetHost: %v", err)
|
||||
}
|
||||
if got.AlwaysOn {
|
||||
t.Errorf("AlwaysOn after empty form: got true, want false")
|
||||
}
|
||||
|
||||
// --- POST with always_on=on => always-on ---
|
||||
form2 := url.Values{"always_on": {"on"}}
|
||||
req2, _ := stdhttp.NewRequest("POST", ts.URL+"/hosts/"+hostID+"/mode",
|
||||
strings.NewReader(form2.Encode()))
|
||||
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req2.AddCookie(cookie)
|
||||
res2, err := cli.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
_ = res2.Body.Close()
|
||||
if res2.StatusCode != stdhttp.StatusSeeOther {
|
||||
t.Fatalf("status: got %d, want 303", res2.StatusCode)
|
||||
}
|
||||
|
||||
got2, err := st.GetHost(context.Background(), hostID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetHost: %v", err)
|
||||
}
|
||||
if !got2.AlwaysOn {
|
||||
t.Errorf("AlwaysOn after always_on=on: got false, want true")
|
||||
}
|
||||
|
||||
// Audit rows must exist (one per request).
|
||||
var n int
|
||||
if err := st.DB().QueryRow(
|
||||
`SELECT COUNT(*) FROM audit_log WHERE action = 'host.mode_updated' AND target_id = ?`,
|
||||
hostID).Scan(&n); err != nil {
|
||||
t.Fatalf("count audit: %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Errorf("audit rows: got %d, want 2", n)
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,8 @@ type Snapshot struct {
|
||||
BuildCommit string
|
||||
GoVersion string
|
||||
JobDurationRows []HistogramRow
|
||||
StuckJobs int
|
||||
OldestStuckAge time.Duration
|
||||
}
|
||||
|
||||
// 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",
|
||||
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 -------------------------------------------------
|
||||
// Stable order: by host id.
|
||||
hosts := append([]HostRow(nil), s.Hosts...)
|
||||
|
||||
@@ -114,6 +114,8 @@ func TestRenderGolden(t *testing.T) {
|
||||
snap := r.SnapshotWith(hosts,
|
||||
map[string]int{"info": 0, "warning": 1, "critical": 0},
|
||||
"v1.2.3", "deadbeef", "go1.25.0")
|
||||
snap.StuckJobs = 2
|
||||
snap.OldestStuckAge = 90 * time.Minute
|
||||
|
||||
var buf bytes.Buffer
|
||||
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="critical"} 0`,
|
||||
`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="01H0002",host="bravo"} 0`,
|
||||
`rm_host_last_backup_timestamp_seconds{host_id="01H0001",host="alpha"} 1700000000`,
|
||||
|
||||
@@ -221,23 +221,40 @@ func formatBytes(n int64) template.HTML {
|
||||
// "in 5m"-style. Accepts *time.Time or time.Time so templates can
|
||||
// pass either without fighting Go's lack of an address-of operator.
|
||||
// Anything else returns "—".
|
||||
func formatRelTime(v any) string {
|
||||
//
|
||||
// The output is wrapped in a <time data-rel-ts="..."> element so a
|
||||
// small client-side ticker (see base.html) can refresh the label
|
||||
// without a full page reload — otherwise a long-open tab shows
|
||||
// timestamps frozen at render time.
|
||||
func formatRelTime(v any) template.HTML {
|
||||
var t time.Time
|
||||
switch x := v.(type) {
|
||||
case time.Time:
|
||||
t = x
|
||||
case *time.Time:
|
||||
if x == nil {
|
||||
return "—"
|
||||
return template.HTML("—")
|
||||
}
|
||||
t = *x
|
||||
default:
|
||||
return "—"
|
||||
return template.HTML("—")
|
||||
}
|
||||
if t.IsZero() {
|
||||
return "—"
|
||||
return template.HTML("—")
|
||||
}
|
||||
d := time.Since(t)
|
||||
label := relTimeLabel(time.Since(t))
|
||||
return template.HTML(fmt.Sprintf(
|
||||
`<time data-rel-ts="%s" title="%s">%s</time>`,
|
||||
t.UTC().Format(time.RFC3339Nano),
|
||||
t.UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||
label,
|
||||
))
|
||||
}
|
||||
|
||||
// relTimeLabel turns a duration-since-now into the short human label
|
||||
// used by formatRelTime (and mirrored verbatim by the JS ticker, so
|
||||
// keep the two in sync if you change the buckets).
|
||||
func relTimeLabel(d time.Duration) string {
|
||||
suffix := "ago"
|
||||
if d < 0 {
|
||||
d = -d
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFormatRelTimeWrapsInTickableTimeElement(t *testing.T) {
|
||||
// A long-open tab needs a stable anchor so the JS ticker can
|
||||
// refresh the label — see base.html.
|
||||
when := time.Now().Add(-3 * time.Hour)
|
||||
got := string(formatRelTime(when))
|
||||
if !strings.Contains(got, `<time data-rel-ts="`) {
|
||||
t.Errorf("missing data-rel-ts anchor in %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "3h ago</time>") {
|
||||
t.Errorf("expected '3h ago' label, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatRelTimeNilReturnsDash(t *testing.T) {
|
||||
var p *time.Time
|
||||
if string(formatRelTime(p)) != "—" {
|
||||
t.Errorf("nil should render as em-dash, got %q", formatRelTime(p))
|
||||
}
|
||||
if string(formatRelTime(time.Time{})) != "—" {
|
||||
t.Errorf("zero should render as em-dash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelTimeLabelBuckets(t *testing.T) {
|
||||
cases := []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{30 * time.Second, "30s ago"},
|
||||
{5 * time.Minute, "5m ago"},
|
||||
{2 * time.Hour, "2h ago"},
|
||||
{3 * 24 * time.Hour, "3d ago"},
|
||||
{2 * 7 * 24 * time.Hour, "2w ago"},
|
||||
{-5 * time.Minute, "5m from now"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := relTimeLabel(c.d); got != c.want {
|
||||
t.Errorf("relTimeLabel(%v) = %q, want %q", c.d, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ func AgentHandler(deps HandlerDeps) stdhttp.Handler {
|
||||
slog.Warn("ws accept failed", "err", err, "host_id", host.ID)
|
||||
return
|
||||
}
|
||||
conn.SetReadLimit(api.MaxWebSocketMessageBytes)
|
||||
|
||||
c := NewConn(host.ID, conn)
|
||||
// Keep agents alive across NAT boxes; coder/websocket
|
||||
|
||||
@@ -123,6 +123,71 @@ func TestWSHelloAndHeartbeat(t *testing.T) {
|
||||
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) {
|
||||
t.Parallel()
|
||||
url, token, _, _, _ := setupTestHub(t)
|
||||
|
||||
@@ -42,9 +42,9 @@ func (st *Store) CreateFleetUpdate(ctx context.Context, fu FleetUpdate, hostIDs
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO fleet_updates (id, started_at, started_by_user_id, target_version, status)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
fu.ID, fu.StartedAt.UTC().Format(time.RFC3339Nano), fu.StartedByUserID, fu.TargetVersion, fu.Status,
|
||||
`INSERT INTO fleet_updates (id, started_at, started_by_user_id, target_version, status, pause_after_first)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
fu.ID, fu.StartedAt.UTC().Format(time.RFC3339Nano), fu.StartedByUserID, fu.TargetVersion, fu.Status, fu.PauseAfterFirst,
|
||||
); err != nil {
|
||||
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 halted sql.NullString
|
||||
var completedAt sql.NullString
|
||||
var pauseAfterFirst int
|
||||
err := st.db.QueryRowContext(ctx,
|
||||
`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`).
|
||||
Scan(&fu.ID, &startedAt, &fu.StartedByUserID, &fu.TargetVersion, &fu.Status,
|
||||
¤t, &halted, &completedAt)
|
||||
¤t, &halted, &completedAt, &pauseAfterFirst)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -82,6 +83,7 @@ func (st *Store) ActiveFleetUpdate(ctx context.Context) (*FleetUpdate, error) {
|
||||
fu.StartedAt, _ = time.Parse(time.RFC3339Nano, startedAt)
|
||||
fu.CurrentHostID = current.String
|
||||
fu.HaltedReason = halted.String
|
||||
fu.PauseAfterFirst = pauseAfterFirst != 0
|
||||
if completedAt.Valid {
|
||||
t, _ := time.Parse(time.RFC3339Nano, completedAt.String)
|
||||
fu.CompletedAt = &t
|
||||
@@ -97,12 +99,13 @@ func (st *Store) GetFleetUpdate(ctx context.Context, id string) (*FleetUpdate, [
|
||||
var current sql.NullString
|
||||
var halted sql.NullString
|
||||
var completedAt sql.NullString
|
||||
var pauseAfterFirst int
|
||||
err := st.db.QueryRowContext(ctx,
|
||||
`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).
|
||||
Scan(&fu.ID, &startedAt, &fu.StartedByUserID, &fu.TargetVersion, &fu.Status,
|
||||
¤t, &halted, &completedAt)
|
||||
¤t, &halted, &completedAt, &pauseAfterFirst)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
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.CurrentHostID = current.String
|
||||
fu.HaltedReason = halted.String
|
||||
fu.PauseAfterFirst = pauseAfterFirst != 0
|
||||
if completedAt.Valid {
|
||||
t, _ := time.Parse(time.RFC3339Nano, completedAt.String)
|
||||
fu.CompletedAt = &t
|
||||
|
||||
+33
-4
@@ -44,7 +44,7 @@ func (s *Store) LookupHostByAgentToken(ctx context.Context, tokenHash string) (*
|
||||
repo_size_bytes, snapshot_count, open_alert_count,
|
||||
applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps,
|
||||
pre_hook_default, post_hook_default,
|
||||
repo_status, repo_status_error
|
||||
repo_status, repo_status_error, always_on, snapshot_refreshed_at
|
||||
FROM hosts WHERE agent_token_hash = ?`,
|
||||
tokenHash)
|
||||
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,
|
||||
applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps,
|
||||
pre_hook_default, post_hook_default,
|
||||
repo_status, repo_status_error
|
||||
repo_status, repo_status_error, always_on, snapshot_refreshed_at
|
||||
FROM hosts WHERE id = ?`, id)
|
||||
return scanHost(row)
|
||||
}
|
||||
@@ -227,7 +227,7 @@ func (s *Store) ListHosts(ctx context.Context) ([]Host, error) {
|
||||
repo_size_bytes, snapshot_count, open_alert_count,
|
||||
applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps,
|
||||
pre_hook_default, post_hook_default,
|
||||
repo_status, repo_status_error
|
||||
repo_status, repo_status_error, always_on, snapshot_refreshed_at
|
||||
FROM hosts ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list hosts: %w", err)
|
||||
@@ -267,6 +267,8 @@ func scanHostRow(s hostScanner) (*Host, error) {
|
||||
tags string
|
||||
bwUp, bwDown sql.NullInt64
|
||||
preHook, postHook sql.NullString
|
||||
alwaysOn int
|
||||
snapshotRefreshedAt sql.NullString
|
||||
)
|
||||
err := s.Scan(&h.ID, &h.Name, &h.OS, &h.Arch,
|
||||
&h.AgentVersion, &h.ResticVersion, &h.ProtocolVersion,
|
||||
@@ -275,7 +277,7 @@ func scanHostRow(s hostScanner) (*Host, error) {
|
||||
&h.RepoSizeBytes, &h.SnapshotCount, &h.OpenAlertCount,
|
||||
&h.AppliedScheduleVersion, &bwUp, &bwDown,
|
||||
&preHook, &postHook,
|
||||
&h.RepoStatus, &h.RepoStatusError)
|
||||
&h.RepoStatus, &h.RepoStatusError, &alwaysOn, &snapshotRefreshedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -330,6 +332,14 @@ func scanHostRow(s hostScanner) (*Host, error) {
|
||||
if postHook.Valid {
|
||||
h.PostHookDefault = postHook.String
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -378,6 +388,25 @@ func (s *Store) SetHostTags(ctx context.Context, hostID string, tags []string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetHostAlwaysOn flips the host's always-on flag. true = 24x7 server
|
||||
// (default); false = intermittent host (laptop). See the
|
||||
// always-on-host-mode spec.
|
||||
func (s *Store) SetHostAlwaysOn(ctx context.Context, hostID string, alwaysOn bool) error {
|
||||
v := 0
|
||||
if alwaysOn {
|
||||
v = 1
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE hosts SET always_on = ? WHERE id = ?`, v, hostID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set host always_on: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DistinctHostTags returns the union of every tag in use across the
|
||||
// fleet, sorted. Powers the autocomplete on the host-tags editor and
|
||||
// the chip-row filter on the dashboard. Cheap at fleet sizes this
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHostAlwaysOnDefaultAndToggle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := openTestStore(t)
|
||||
|
||||
h := Host{
|
||||
ID: "h-always-on", Name: "lap", OS: "linux", Arch: "amd64",
|
||||
ProtocolVersion: 1, EnrolledAt: time.Now().UTC(),
|
||||
}
|
||||
if err := st.CreateHost(ctx, h, "tok-hash", "pin"); err != nil {
|
||||
t.Fatalf("create host: %v", err)
|
||||
}
|
||||
got, err := st.GetHost(ctx, h.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get host: %v", err)
|
||||
}
|
||||
if !got.AlwaysOn {
|
||||
t.Fatalf("new host should default to always_on=true, got false")
|
||||
}
|
||||
|
||||
if err := st.SetHostAlwaysOn(ctx, h.ID, false); err != nil {
|
||||
t.Fatalf("set always_on: %v", err)
|
||||
}
|
||||
got, err = st.GetHost(ctx, h.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get host 2: %v", err)
|
||||
}
|
||||
if got.AlwaysOn {
|
||||
t.Fatalf("expected always_on=false after toggle, got true")
|
||||
}
|
||||
|
||||
hosts, err := st.ListHosts(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list hosts: %v", err)
|
||||
}
|
||||
if len(hosts) != 1 || hosts[0].AlwaysOn {
|
||||
t.Fatalf("ListHosts should report always_on=false, got %+v", hosts)
|
||||
}
|
||||
|
||||
// Verify the agent hot-path (LookupHostByAgentToken) also reflects the toggle.
|
||||
byToken, err := st.LookupHostByAgentToken(ctx, "tok-hash")
|
||||
if err != nil {
|
||||
t.Fatalf("lookup by agent token: %v", err)
|
||||
}
|
||||
if byToken.AlwaysOn {
|
||||
t.Fatalf("LookupHostByAgentToken: expected always_on=false after toggle, got true")
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,73 @@ type Job struct {
|
||||
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
|
||||
// when it actually starts work. ScheduledID is set when the job
|
||||
// originates from a cron fire (actor_kind="schedule"); nil for
|
||||
@@ -270,6 +337,22 @@ func (s *Store) LatestJobByKind(ctx context.Context, hostID, kind string) (*Job,
|
||||
return &j, nil
|
||||
}
|
||||
|
||||
// HasActiveBackupJob reports whether the host has a backup job that is
|
||||
// still queued or running. The catch-up scheduler uses this to avoid
|
||||
// dispatching a duplicate backup alongside one already in flight
|
||||
// (hosts.current_job_id is not maintained, so this is the authoritative
|
||||
// in-flight check).
|
||||
func (s *Store) HasActiveBackupJob(ctx context.Context, hostID string) (bool, error) {
|
||||
var exists bool
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM jobs WHERE host_id = ? AND kind = 'backup' AND status IN ('queued','running'))`,
|
||||
hostID).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("store: has active backup job: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// HasJobOfKind reports whether any job of the given kind exists for
|
||||
// this host, regardless of status. Used by the auto-init path on
|
||||
// agent hello to decide whether to dispatch a fresh `restic init` —
|
||||
|
||||
@@ -7,6 +7,48 @@ import (
|
||||
"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) {
|
||||
t.Parallel()
|
||||
s := openTestStore(t)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 0024: distinguish always-on (24x7 server) hosts from intermittent
|
||||
-- hosts (laptops/workstations that legitimately sleep). Default 1 so
|
||||
-- every existing and future host keeps today's offline/alert
|
||||
-- semantics unless explicitly opted out. Column-level ALTER per the
|
||||
-- repo's migration rules (no table rebuild — hosts has inbound FKs).
|
||||
ALTER TABLE hosts ADD COLUMN always_on INTEGER NOT NULL DEFAULT 1;
|
||||
@@ -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;
|
||||
@@ -69,8 +69,8 @@ func (s *Store) ReplaceHostSnapshots(ctx context.Context, hostID string, snaps [
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE hosts SET snapshot_count = ? WHERE id = ?`,
|
||||
len(snaps), hostID); err != nil {
|
||||
`UPDATE hosts SET snapshot_count = ?, snapshot_refreshed_at = ? WHERE id = ?`,
|
||||
len(snaps), when.UTC().Format(time.RFC3339Nano), hostID); err != nil {
|
||||
return fmt.Errorf("store: update host snapshot_count: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -138,7 +138,8 @@ func TestReplaceHostSnapshotsEmpty(t *testing.T) {
|
||||
t.Fatalf("replace 1: %v", err)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
out, err := s.ListSnapshotsByHost(ctx, hostID)
|
||||
@@ -152,4 +153,7 @@ func TestReplaceHostSnapshotsEmpty(t *testing.T) {
|
||||
if h.SnapshotCount != 0 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ type Host struct {
|
||||
LastBackupStatus *string
|
||||
RepoSizeBytes int64
|
||||
SnapshotCount int
|
||||
SnapshotRefreshedAt *time.Time
|
||||
OpenAlertCount int
|
||||
AppliedScheduleVersion int64
|
||||
// Host-wide bandwidth caps applied to every restic invocation
|
||||
@@ -99,6 +100,12 @@ type Host struct {
|
||||
// agent-side message when RepoStatus == "init_failed".
|
||||
RepoStatus string
|
||||
RepoStatusError string
|
||||
|
||||
// AlwaysOn is true for 24x7 server hosts (the default). When false
|
||||
// the host is intermittent (laptop/workstation): offline alerts are
|
||||
// suppressed, the UI shows an "asleep" state, and a missed backup is
|
||||
// caught up ~1 min after reconnect. See the always-on-host-mode spec.
|
||||
AlwaysOn bool
|
||||
}
|
||||
|
||||
// Schedule is now intentionally slim: cron + which groups + enabled.
|
||||
@@ -224,6 +231,7 @@ type FleetUpdate struct {
|
||||
CurrentHostID string
|
||||
HaltedReason string
|
||||
CompletedAt *time.Time
|
||||
PauseAfterFirst bool
|
||||
}
|
||||
|
||||
// FleetUpdateHost is one host's slot in a fleet update. Position is
|
||||
|
||||
@@ -13,4 +13,8 @@ var (
|
||||
// Commit is the short git SHA. Informational only; surfaced via
|
||||
// /api/version but not used for any comparison.
|
||||
Commit = ""
|
||||
|
||||
// Date is the RFC3339 build timestamp. Informational only; printed
|
||||
// by `--version` but not used for any comparison.
|
||||
Date = "unknown"
|
||||
)
|
||||
|
||||
@@ -310,7 +310,7 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days.
|
||||
> **Sweep verified (smoke env):** admin adds operator → setup link generated → curl-as-new-user fetches /setup (200, page shows username) → POSTs password → 303 to / + Set-Cookie → operator authenticated → 200 on /, 200 on /settings/account, **403 on /settings/users** (admin-only) → admin disables user → operator's next request is **401** + session row count drops to 0 → audit log shows `user.created` + `user.setup_completed` for the cycle. All 26 implementation tasks landed; full `go test ./...` green.
|
||||
- [x] **P4-05** (L) OIDC login (generic provider config, group → role mapping)
|
||||
|
||||
> **As shipped (2026-05-05):** Authorization Code + PKCE (S256) against any OIDC IdP advertising standard discovery. Config is YAML+env (`oidc.issuer`, `oidc.client_id`, `oidc.client_secret`/`_file`, `oidc.role_claim` default `groups`, `oidc.role_mapping`, `oidc.display_name`, `oidc.redirect_url`); empty issuer → OIDC disabled, no routes mounted. Migration 0019 adds `users.auth_source`/`oidc_subject` (partial unique index on `oidc_subject`), `sessions.id_token`, and a small `oidc_state` table for state+verifier round-trip (cleaned up every alert tick, 5 min TTL). Login page renders **Sign in with `<display_name>`** above the local form when OIDC is enabled; the SSO button kicks off a 303 to the IdP with state + S256 code_challenge persisted server-side. Callback verifies ID token, fetches `/userinfo` to merge claims (Authelia / many IdPs only put `sub` in the ID token and surface `preferred_username`/`email`/`groups` from userinfo), maps the first matching group to a role; **no match → deny banner**, no row created, audit `user.oidc_login_blocked`. Username-collision with an existing local user → same deny path with `username_taken`. New user → JIT-provisioned with `auth_source='oidc'`, `oidc_subject=<sub>`, `password_hash=''`. Returning user → looked up by `oidc_subject` (stable when usernames change at the IdP), role + email refreshed on every login. Local password login is rejected for `auth_source='oidc'` users. Logout posts to `/logout` and, when the IdP advertised `end_session_endpoint`, follows up with RP-initiated logout (carries `id_token_hint` + `post_logout_redirect_uri=BaseURL`); when not advertised (Authelia in our smoke env), the local session is cleared and the browser lands on `/login`. Users list shows a small **oidc** chip beside enabled/disabled; the edit page disables username/email/role for OIDC users (server-side guard mirrors UI, returns 403). Force-logout, disable, and the last-admin guard from P4-04 all still apply. **Live Authelia sweep verified all four paths against `https://auth.example.invalid`:** rm-admin → admin role + JIT row + chip + readonly edit; rm-operator → operator JIT, 403 on `/settings/users`; rm-viewer → viewer JIT, 403 on `/hosts/new`; rm-other (group not in role_mapping) → no_role_match banner, no row created, audit logged. Returning rm-admin login resolved to the same row by sub. Screenshots in `_diag/p4-05-sweep/`. Out-of-scope and on Phase 6 candidate list: refresh tokens, back-channel logout, multiple providers, post-login PKCE for the cookie itself.
|
||||
> **As shipped (2026-05-05):** Authorization Code + PKCE (S256) against any OIDC IdP advertising standard discovery. Config is YAML+env (`oidc.issuer`, `oidc.client_id`, `oidc.client_secret`/`_file`, `oidc.role_claim` default `groups`, `oidc.role_mapping`, `oidc.display_name`, `oidc.redirect_url`); empty issuer → OIDC disabled, no routes mounted. Migration 0019 adds `users.auth_source`/`oidc_subject` (partial unique index on `oidc_subject`), `sessions.id_token`, and a small `oidc_state` table for state+verifier round-trip (cleaned up every alert tick, 5 min TTL). Login page renders **Sign in with `<display_name>`** above the local form when OIDC is enabled; the SSO button kicks off a 303 to the IdP with state + S256 code_challenge persisted server-side. Callback verifies ID token, fetches `/userinfo` to merge claims (Authelia / many IdPs only put `sub` in the ID token and surface `preferred_username`/`email`/`groups` from userinfo), maps the first matching group to a role; **no match → deny banner**, no row created, audit `user.oidc_login_blocked`. Username-collision with an existing local user → same deny path with `username_taken`. New user → JIT-provisioned with `auth_source='oidc'`, `oidc_subject=<sub>`, `password_hash=''`. Returning user → looked up by `oidc_subject` (stable when usernames change at the IdP), role + email refreshed on every login. Local password login is rejected for `auth_source='oidc'` users. Logout posts to `/logout` and, when the IdP advertised `end_session_endpoint`, follows up with RP-initiated logout (carries `id_token_hint` + `post_logout_redirect_uri=BaseURL`); when not advertised (Authelia in our smoke env), the local session is cleared and the browser lands on `/login`. Users list shows a small **oidc** chip beside enabled/disabled; the edit page disables username/email/role for OIDC users (server-side guard mirrors UI, returns 403). Force-logout, disable, and the last-admin guard from P4-04 all still apply. **Live Authelia sweep verified all four paths against local auth:** rm-admin → admin role + JIT row + chip + readonly edit; rm-operator → operator JIT, 403 on `/settings/users`; rm-viewer → viewer JIT, 403 on `/hosts/new`; rm-other (group not in role_mapping) → no_role_match banner, no row created, audit logged. Returning rm-admin login resolved to the same row by sub. Screenshots in `_diag/p4-05-sweep/`. Out-of-scope and on Phase 6 candidate list: refresh tokens, back-channel logout, multiple providers, post-login PKCE for the cookie itself.
|
||||
|
||||
- [x] **P4-07** (S) Per-host tags + dashboard filtering by tag
|
||||
|
||||
@@ -498,6 +498,8 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days.
|
||||
- [x] **NS-03** Auto-init repo on first onboard, surface credential failures eagerly. ✅ Landed: migration 0020 adds `hosts.repo_status` (`unknown`/`ready`/`init_failed`) + `repo_status_error`; WS handler projects every init job's terminal state onto the host row (with idempotent "config file already exists" → ready); creds-save handlers (UI + JSON API) reset status to `unknown` and dispatch a fresh init when the agent is online; new `/hosts/{id}/repo/probe` retry endpoint and a status banner on the repo page. Remainder of original scope below. surface credential failures eagerly. Today the operator types repo URL + creds during Add-host and the credentials are pushed to the agent on connect, but no `restic init`/probe runs until the first scheduled job — so a typo in the password or a wrong URL goes undetected for hours/days, manifesting as a silent missed-backup. Wanted behaviour: when the host completes enrolment (or when an admin saves new repo creds), the server dispatches a one-shot probe job that runs `restic cat config` (cheap, repo-existence + creds-validity in one call). On `Is there already a config file? unable to open config file` → run `restic init`. On success → mark the host's repo as ready. On any other error (network, auth, fingerprint) → surface a panel-level error on the host detail page and audit the failure, leaving the host in an "init pending" state with a "Retry" button. Needs: a new `JobKind` (or piggyback on an existing one) for the probe, server-side state on the host row (`repo_status` enum: `unknown`/`ready`/`init_pending`/`init_failed`), UI panel that shows the state, and clear copy on the Add-host page so the operator knows the save isn't fire-and-forget.
|
||||
- [x] **NS-05** Drop redundant `actions/setup-go` from `.gitea/workflows/ci.yml`. ✅ Already gone — verified `.gitea/workflows/ci.yml` has zero `actions/setup-go@v5` invocations and no `GO_VERSION` env; the file's header comment now documents that the runner image (`gitea.dcglab.co.uk/steve/ci-runner-go`) is the single source of truth for the Go version. Closing as done; no further code change needed.
|
||||
- [x] **NS-06** Remove the permanently-disabled "Run backup now" button from `web/templates/partials/host_chrome.html`. ✅ Landed: dropped the disabled tombstone button from the host header action row; only "Edit credentials" + the ⋯ menu remain. Per-source-group Run-now on `/hosts/{id}/sources` is the only path now. No e2e change needed — `smoke.spec.ts` does not assert on host_chrome's button row.
|
||||
- [x] **NS-07** Relative timestamps go stale on long-open tabs. ✅ Landed: `formatRelTime` now wraps its label in `<time data-rel-ts=…>` and both layouts (`base.html`, `chromeless.html`) carry a small ticker that re-renders every 30s, so a page rendered an hour ago no longer keeps showing "2h ago" when the wall-clock truth is "3h ago". Covered by `funcs_test.go`. The bug: every relative label was computed once at server render and never updated client-side, so a job-detail page left open drifted further from reality the longer it sat.
|
||||
- [x] **NS-08** Always-On vs intermittent host mode. ✅ Landed: a host can now be marked not-always-on (laptop/workstation) so it stops generating offline-alert noise when it legitimately sleeps. Migration 0024 adds `hosts.always_on` (default 1 = today's 24×7 behaviour; intermittent is strictly opt-in). The alert engine suppresses `agent_offline` for intermittent hosts and instead wires up the previously-dead `stale_schedule` alert for them — raised at a 7-day global threshold when the host has an enabled schedule and a stale last backup, resolved on the next successful backup. A new server-side catch-up scheduler (`internal/server/http/catchup.go`) arms on agent hello and fires from the existing 30s pending-drain tick: ~60s after an intermittent host reconnects it dispatches a backup for any enabled schedule whose window elapsed while asleep (overdue = `cron.Next(lastBackup) <= now`, reusing the shared `cronParser`), guarded against firing when the host bounced offline, flipped to always-on, or already has a job running. Overdue is measured against the per-host `LastBackupAt` (exact for the common single-schedule laptop; a known coarseness for multi-cadence hosts, documented in code). Operator toggle via `POST /hosts/{id}/mode` (audited `host.mode_updated`), which also clears open offline/staleness alerts so the next sweep re-settles. UI: intermittent offline hosts render a calm grey `asleep · <relTime> · will catch up on return` state (new `.dot-asleep`) instead of red "offline"; a `24×7` chip shows only for always-on hosts; a "presence" inline toggle on the host header. Design + plan in `docs/specs/2026-06-15-always-on-host-mode-design.md` and `docs/plans/2026-06-15-always-on-host-mode.md`. Spec §2 (online/offline mechanics) deliberately left untouched. Out of scope for v1: per-host staleness thresholds, continuous (non-reconnect) overdue evaluation, per-schedule last-success tracking.
|
||||
- [x] **NS-04** Dashboard parity with the alerts screen: live refresh, column sorting, filters. ✅ Landed: `/` now parses `q`/`status`/`repo_status`/`tag`/`sort`/`dir` query params (round-trip durable for bookmarks); table is wrapped in an `id="hosts-table"` htmx live-poll matching the alerts cadence (5s, gated on `document.visibilityState` and `localStorage.rm-dashboard-live`); filter row above the table with hostname free-text + status + repo_status selects + tag chips + clear; column headers (Host / OS · arch / Last backup / Repo size / Snapshots) are clickable links that toggle direction on the active column; pure-Go sort+filter pipeline covered by `dashboard_filter_test.go`. Original scope below. live refresh, column sorting, filters. The host list is currently a static render — operators have to reload to see new heartbeats / job state changes. Mirror the alerts pattern (`web/templates/pages/alerts.html` uses `hx-trigger="every 5s [document.visibilityState==='visible' && localStorage.getItem('rm-alerts-live')!=='off']"` plus a Live/Off toggle so background tabs and explicit-off don't burn server cycles). Add: server-side sort on every meaningful column (name, OS, last-backup time, last-backup status, agent online/offline, restic version, tags), and a small filter row above the table — at minimum free-text on hostname, status (online/offline/never-seen), and tag chips. Columns + filter state should round-trip through query string so a bookmarked / shared URL is durable. Re-use the `host_row` partial that already exists so the live-refresh swap is a clean OOB swap, not a full table re-render.
|
||||
|
||||
---
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -70,6 +70,7 @@
|
||||
.dot-online { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in oklch, var(--ok), transparent 80%); }
|
||||
.dot-degraded { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in oklch, var(--warn), transparent 80%); }
|
||||
.dot-offline { background: var(--off); }
|
||||
.dot-asleep { background: var(--ink-fade); opacity: 0.6; }
|
||||
.dot-failed { background: var(--bad); box-shadow: 0 0 0 3px color-mix(in oklch, var(--bad), transparent 80%); }
|
||||
.pulse { animation: rm-pulse 2.4s ease-in-out infinite; }
|
||||
@keyframes rm-pulse {
|
||||
@@ -195,6 +196,17 @@
|
||||
}
|
||||
.tag-removable .x { color: var(--ink-fade); cursor: pointer; padding-left: 2px; }
|
||||
|
||||
/* ---------- header meta groups (boxed tags / presence pills) ---------- */
|
||||
.meta-group {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 11px; line-height: 1; padding: 3px 9px;
|
||||
border: 1px solid var(--line); border-radius: 5px;
|
||||
background: color-mix(in oklch, var(--ink), transparent 95%);
|
||||
}
|
||||
.meta-group .meta-label { color: var(--ink-mute); }
|
||||
.meta-group .meta-val { color: var(--ink-mid); text-decoration: none; }
|
||||
.meta-group a.meta-val:hover { color: var(--ink); text-decoration: underline; }
|
||||
|
||||
/* ---------- form fields ---------- */
|
||||
.field-label { font-size: 12px; color: var(--ink-mid); margin-bottom: 6px; display: block; }
|
||||
.field-help { font-size: 12px; color: var(--ink-mute); margin-top: 6px; line-height: 1.55; }
|
||||
|
||||
@@ -20,6 +20,37 @@
|
||||
|
||||
{{template "toast" .}}
|
||||
|
||||
<script>
|
||||
// Tick <time data-rel-ts> labels so long-open tabs don't freeze
|
||||
// (e.g. a job page rendered an hour ago kept showing "2h ago" when
|
||||
// the truth was "3h ago"). Buckets must match relTimeLabel in
|
||||
// internal/server/ui/funcs.go.
|
||||
(function () {
|
||||
function label(ms) {
|
||||
var suffix = 'ago';
|
||||
if (ms < 0) { ms = -ms; suffix = 'from now'; }
|
||||
var s = Math.floor(ms / 1000);
|
||||
if (s < 60) return s + 's ' + suffix;
|
||||
var m = Math.floor(s / 60);
|
||||
if (m < 60) return m + 'm ' + suffix;
|
||||
var h = Math.floor(m / 60);
|
||||
if (h < 24) return h + 'h ' + suffix;
|
||||
var d = Math.floor(h / 24);
|
||||
if (d < 7) return d + 'd ' + suffix;
|
||||
return Math.floor(d / 7) + 'w ' + suffix;
|
||||
}
|
||||
function tick() {
|
||||
var now = Date.now();
|
||||
document.querySelectorAll('time[data-rel-ts]').forEach(function (el) {
|
||||
var t = Date.parse(el.getAttribute('data-rel-ts'));
|
||||
if (!isNaN(t)) el.textContent = label(now - t);
|
||||
});
|
||||
}
|
||||
tick();
|
||||
setInterval(tick, 30000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -11,6 +11,34 @@
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col">
|
||||
{{block "content" .}}{{end}}
|
||||
<script>
|
||||
// See base.html for rationale; chromeless pages (e.g. pending host)
|
||||
// also use the relTime helper, so they need the same ticker.
|
||||
(function () {
|
||||
function label(ms) {
|
||||
var suffix = 'ago';
|
||||
if (ms < 0) { ms = -ms; suffix = 'from now'; }
|
||||
var s = Math.floor(ms / 1000);
|
||||
if (s < 60) return s + 's ' + suffix;
|
||||
var m = Math.floor(s / 60);
|
||||
if (m < 60) return m + 'm ' + suffix;
|
||||
var h = Math.floor(m / 60);
|
||||
if (h < 24) return h + 'h ' + suffix;
|
||||
var d = Math.floor(h / 24);
|
||||
if (d < 7) return d + 'd ' + suffix;
|
||||
return Math.floor(d / 7) + 'w ' + suffix;
|
||||
}
|
||||
function tick() {
|
||||
var now = Date.now();
|
||||
document.querySelectorAll('time[data-rel-ts]').forEach(function (el) {
|
||||
var t = Date.parse(el.getAttribute('data-rel-ts'));
|
||||
if (!isNaN(t)) el.textContent = label(now - t);
|
||||
});
|
||||
}
|
||||
tick();
|
||||
setInterval(tick, 30000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
{{/* ---------- Hosts-behind hero tile (P6-18) ---------- */}}
|
||||
{{if gt $page.UpdatesBehind 0}}
|
||||
<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-label">{{if eq $page.UpdatesBehind 1}}host behind{{else}}hosts behind{{end}} · review →</span>
|
||||
</a>
|
||||
|
||||
@@ -8,18 +8,18 @@
|
||||
<div class="crumbs pt-6">
|
||||
<a href="/">Dashboard</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>
|
||||
|
||||
{{/* page header */}}
|
||||
<div class="flex items-baseline justify-between mt-3.5">
|
||||
<div>
|
||||
<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>
|
||||
</h1>
|
||||
<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>
|
||||
differs from the server are eligible.
|
||||
</p>
|
||||
@@ -28,5 +28,15 @@
|
||||
|
||||
{{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>
|
||||
{{end}}
|
||||
|
||||
@@ -82,10 +82,14 @@
|
||||
<div class="text-[12px] text-ok mb-3 mono">✓ saved</div>
|
||||
{{end}}
|
||||
<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
|
||||
user (everyday backups) from a delete-capable user (prune /
|
||||
forget). For S3 / B2 / SFTP / local, leave this blank — the
|
||||
everyday repo credentials handle prune too.
|
||||
Required for prune. On rest-server repos this is the
|
||||
delete-capable user, as distinct from the append-only user used
|
||||
for everyday backups. Note that <strong>forget</strong> always
|
||||
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>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
{{if not $page.Form}}<span class="mono text-ink-fade text-[11px] ml-1">{{len $page.Channels}}</span>{{end}}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
*/}}
|
||||
{{define "fleet_update_inner"}}
|
||||
{{$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-trigger="every 3s [document.visibilityState==='visible']"
|
||||
hx-select="#fleet-update-panel"
|
||||
hx-swap="outerHTML">
|
||||
hx-swap="outerHTML"{{end}}>
|
||||
|
||||
{{if and $page.Active (eq $page.Active.Status "running")}}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
</div>
|
||||
<div class="text-[11.5px] text-ink-mute mt-1">
|
||||
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>
|
||||
{{if $page.Active.CurrentHostID}}
|
||||
· waiting on <span class="mono text-ink-mid">{{index $page.HostNames $page.Active.CurrentHostID}}</span>
|
||||
@@ -68,6 +69,12 @@
|
||||
{{if $page.Active.HaltedReason}}
|
||||
<div class="text-[12px] text-bad mt-2">{{$page.Active.HaltedReason}}</div>
|
||||
{{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>
|
||||
|
||||
{{template "fleet_update_rows" $page}}
|
||||
@@ -122,11 +129,11 @@
|
||||
{{define "fleet_update_idle_panel"}}
|
||||
{{$page := .}}
|
||||
<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">
|
||||
<span class="dot dot-online"></span>
|
||||
<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">
|
||||
Every online agent matches server version <span class="mono">{{$page.TargetVersion}}</span>.
|
||||
</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>
|
||||
<span class="mono text-[11px] text-ink-fade">target {{$page.TargetVersion}}</span>
|
||||
</div>
|
||||
<ul class="mt-3 space-y-1 text-[12px]">
|
||||
{{range $page.OutOfDateHosts}}
|
||||
<li class="flex items-center gap-3">
|
||||
<span class="dot dot-online"></span>
|
||||
<span class="mono text-ink">{{.Name}}</span>
|
||||
<span class="mono text-ink-mute">{{if .AgentVersion}}{{.AgentVersion}}{{else}}—{{end}} → {{$page.TargetVersion}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
|
||||
<form id="fleet-update-start-form" class="mt-4 flex items-center gap-3"
|
||||
hx-post="/api/fleet/update"
|
||||
hx-headers='{"Content-Type":"application/json"}'
|
||||
hx-vals='{}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful) location.reload()">
|
||||
<label class="text-[11.5px] text-ink-mute">
|
||||
Type the count
|
||||
<span class="mono text-ink-mid">({{len $page.OutOfDateHosts}})</span>
|
||||
to enable Start:
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<input id="fleet-filter-name" class="field text-[12px]" placeholder="Filter name or tag" oninput="fleetFilter()">
|
||||
<input id="fleet-filter-version" class="field text-[12px] mono" placeholder="Version" oninput="fleetFilter()">
|
||||
<select id="fleet-filter-state" class="field text-[12px]" onchange="fleetFilter()">
|
||||
<option value="all">All states</option><option value="eligible">Eligible</option><option value="excluded">Excluded</option>
|
||||
</select>
|
||||
<button type="button" class="btn" onclick="fleetSelectVisible(true)">Select visible eligible</button>
|
||||
<button type="button" class="btn" onclick="fleetSelectVisible(false)">Clear selection</button>
|
||||
</div>
|
||||
<div class="panel mt-3 rounded-[7px] overflow-hidden">
|
||||
{{range $page.Candidates}}
|
||||
<label class="fleet-candidate grid items-center px-3 py-2 hairline text-[12px]"
|
||||
data-name="{{.Host.Name}} {{range .Host.Tags}}{{.}} {{end}}" data-version="{{.Host.AgentVersion}}" data-state="{{if .Eligible}}eligible{{else}}excluded{{end}}"
|
||||
style="grid-template-columns: 28px 1.4fr .8fr .8fr 1.2fr;gap:12px">
|
||||
<input class="fleet-host" type="checkbox" value="{{.Host.ID}}" {{if .Eligible}}checked onchange="fleetReview()"{{else}}disabled{{end}}>
|
||||
<span class="mono">{{.Host.Name}}</span>
|
||||
<span class="mono text-ink-mute">{{if .Host.AgentVersion}}{{.Host.AgentVersion}}{{else}}unknown{{end}}</span>
|
||||
<span class="mono text-ink-mute">{{$page.TargetVersion}}</span>
|
||||
<span class="text-ink-mute">{{if .Eligible}}eligible{{else}}{{.Reason}}{{end}}</span>
|
||||
</label>
|
||||
<input type="text" id="fleet-update-confirm" class="field mono text-[12.5px]"
|
||||
style="width: 80px; padding: 5px 8px;"
|
||||
oninput="document.getElementById('fleet-update-start-btn').disabled = (this.value !== '{{len $page.OutOfDateHosts}}');"
|
||||
autocomplete="off" />
|
||||
<button type="submit" id="fleet-update-start-btn" class="btn btn-amber" disabled>
|
||||
Start fleet update
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="mt-4 text-[12px] text-ink-mute">
|
||||
<span id="fleet-selected-count">{{len $page.OutOfDateHosts}}</span> selected · sequential, halts on first failure · worst-case
|
||||
<span id="fleet-timeout" class="mono">{{len $page.OutOfDateHosts}} × 95s</span>
|
||||
</div>
|
||||
<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>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -34,17 +34,32 @@
|
||||
{{else if eq $host.Status "degraded"}}
|
||||
<span class="dot dot-degraded"></span>
|
||||
{{else if eq $host.Status "offline"}}
|
||||
<span class="dot dot-offline"></span>
|
||||
{{if $host.AlwaysOn}}
|
||||
<span class="dot dot-offline"></span>
|
||||
{{else}}
|
||||
<span class="dot dot-asleep"></span>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<span class="dot dot-failed"></span>
|
||||
{{end}}
|
||||
<h1 class="mono text-[26px] font-medium tracking-[0.005em] text-ink">{{$host.Name}}</h1>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
{{range $host.Tags}}<a href="/?tag={{.}}" class="tag" title="filter dashboard by this tag">{{.}}</a>{{end}}
|
||||
<button type="button" class="text-ink-fade text-[11px] hover:text-ink-mid whitespace-nowrap"
|
||||
style="padding: 2px 8px; border: 1px dashed var(--line); border-radius: 3px; cursor: pointer;"
|
||||
<div class="flex items-center gap-2.5">
|
||||
{{/* tags group pill — click the "tags" label to edit; the tag
|
||||
values still filter the dashboard by that tag. */}}
|
||||
<span class="meta-group">
|
||||
<span class="meta-label cursor-pointer hover:text-ink"
|
||||
onclick="document.getElementById('tags-edit-{{$host.ID}}').classList.toggle('hidden')"
|
||||
title="Edit tags">{{if $host.Tags}}edit tags{{else}}add tags{{end}}</button>
|
||||
title="Edit tags">tags</span>
|
||||
{{range $host.Tags}}<a href="/?tag={{.}}" class="meta-val" title="filter dashboard by this tag">{{.}}</a>{{end}}
|
||||
{{if not $host.Tags}}<span class="meta-val">—</span>{{end}}
|
||||
</span>
|
||||
{{/* presence group pill — click anywhere to edit. */}}
|
||||
<span class="meta-group cursor-pointer"
|
||||
onclick="document.getElementById('mode-edit-{{$host.ID}}').classList.toggle('hidden')"
|
||||
title="Change presence mode">
|
||||
<span class="meta-label">presence</span>
|
||||
<span class="meta-val">{{if $host.AlwaysOn}}24x7{{else}}Free{{end}}</span>
|
||||
</span>
|
||||
</div>
|
||||
{{if gt $page.ScheduleVersion 0}}
|
||||
<span class="mono text-[11px] text-ink-mute ml-2">
|
||||
@@ -80,6 +95,24 @@
|
||||
</div>
|
||||
<div class="field-help">Comma-separated. Lowercased automatically.</div>
|
||||
</form>
|
||||
{{/* Presence-mode editor — hidden by default; toggled by the
|
||||
"presence" button. Checkbox present => always-on (24×7);
|
||||
unchecked => intermittent (laptop): no offline alerts, shows
|
||||
"asleep", auto-catches-up a missed backup on reconnect. */}}
|
||||
<form id="mode-edit-{{$host.ID}}" method="post"
|
||||
action="/hosts/{{$host.ID}}/mode"
|
||||
class="hidden mt-3" style="max-width: 640px;">
|
||||
<label class="flex items-center gap-2 text-[12px] text-ink-mid">
|
||||
<input type="checkbox" name="always_on" value="on" {{if $host.AlwaysOn}}checked{{end}} />
|
||||
Always On — expected online 24×7
|
||||
</label>
|
||||
<div class="field-help">
|
||||
Uncheck for an intermittent host (laptop/workstation): it won't
|
||||
raise offline alerts when asleep, shows an "asleep" state, and
|
||||
catches up a missed backup ~1 minute after it reconnects.
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary mt-2 whitespace-nowrap">Save presence</button>
|
||||
</form>
|
||||
<div class="flex items-center gap-3 mt-3 text-[13px] text-ink-mute">
|
||||
<span class="mono text-ink-mid">{{$host.OS}}/{{$host.Arch}}</span>
|
||||
<span class="text-ink-fade">·</span>
|
||||
@@ -88,7 +121,11 @@
|
||||
<span>restic <span class="mono text-ink-mid">{{if $host.ResticVersion}}{{$host.ResticVersion}}{{else}}—{{end}}</span></span>
|
||||
<span class="text-ink-fade">·</span>
|
||||
{{if eq $host.Status "offline"}}
|
||||
<span>last seen <span class="mono text-ink-mid">{{relTime $host.LastSeenAt}}</span></span>
|
||||
{{if $host.AlwaysOn}}
|
||||
<span>last seen <span class="mono text-ink-mid">{{relTime $host.LastSeenAt}}</span></span>
|
||||
{{else}}
|
||||
<span>asleep · last seen <span class="mono text-ink-mid">{{relTime $host.LastSeenAt}}</span> · will catch up on return</span>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<span>online · last heartbeat <span class="mono text-ink-mid">{{relTime $host.LastSeenAt}}</span></span>
|
||||
{{end}}
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
{{- else if eq $h.Status "degraded" -}}
|
||||
<span class="dot dot-degraded"></span>
|
||||
{{- else if eq $h.Status "offline" -}}
|
||||
<span class="dot dot-offline"></span>
|
||||
{{- if $h.AlwaysOn -}}
|
||||
<span class="dot dot-offline"></span>
|
||||
{{- else -}}
|
||||
<span class="dot dot-asleep"></span>
|
||||
{{- end -}}
|
||||
{{- else -}}
|
||||
<span class="dot dot-failed"></span>
|
||||
{{- end -}}
|
||||
@@ -26,7 +30,11 @@
|
||||
{{- else if eq (deref $h.LastBackupStatus) "cancelled" -}}
|
||||
<span class="text-warn">cancelled</span> · <span class="mono">{{relTime $h.LastBackupAt}}</span>
|
||||
{{- else if eq $h.Status "offline" -}}
|
||||
<span class="text-ink-mute">last seen <span class="mono">{{relTime $h.LastSeenAt}}</span></span>
|
||||
{{- if $h.AlwaysOn -}}
|
||||
<span class="text-ink-mute">last seen <span class="mono">{{relTime $h.LastSeenAt}}</span></span>
|
||||
{{- else -}}
|
||||
<span class="text-ink-mute">asleep · <span class="mono">{{relTime $h.LastSeenAt}}</span> · will catch up on return</span>
|
||||
{{- end -}}
|
||||
{{- else -}}
|
||||
<span class="text-ink-fade italic">never run</span>
|
||||
{{- end -}}
|
||||
@@ -53,7 +61,7 @@
|
||||
</div>
|
||||
<div class="text-right row-action">
|
||||
{{- if eq $h.Status "offline" -}}
|
||||
<span class="mono text-xs text-ink-fade">offline</span>
|
||||
<span class="mono text-xs text-ink-fade">{{if $h.AlwaysOn}}offline{{else}}asleep{{end}}</span>
|
||||
{{- else if $h.CurrentJobID -}}
|
||||
<a href="/jobs/{{deref $h.CurrentJobID}}" class="btn btn-ghost">View job →</a>
|
||||
{{- else if .RunAllScheduleID -}}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
Hidden entirely when UpdateAvailable is false.
|
||||
*/}}
|
||||
{{define "host_update_chip"}}
|
||||
{{if .UpdateAvailable}}<span class="update-chip" title="Agent at {{.Host.AgentVersion}}; server at {{.TargetVersion}}">out of date · {{.Host.AgentVersion}} → {{.TargetVersion}}</span>{{end}}
|
||||
{{if .UpdateAvailable}}<span class="update-chip" title="Agent at {{.Host.AgentVersion}}; server at {{.TargetVersion}}">out of date</span>{{end}}
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user