4 Commits

Author SHA1 Message Date
steve 9080826b06 Merge pull request 'Release v1.2.0 — fleet updates and reliability' (#49) from release-v1.2.0 into main
Release / Build + push image (push) Successful in 2m10s
2026-08-22 11:34:51 +01:00
steve 39a0875d58 test(alert): drain notifications before teardown
CI / Test (rest) (pull_request) Successful in 39s
CI / Lint (pull_request) Successful in 11s
CI / Test (store) (pull_request) Successful in 43s
CI / Build (windows/amd64) (pull_request) Successful in 7s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 8s
CI / Test (server-http) (pull_request) Successful in 1m34s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m27s
2026-08-22 11:31:56 +01:00
steve 320be34970 docs(changelog): prepare v1.2.0
CI / Test (store) (pull_request) Successful in 15s
CI / Lint (pull_request) Successful in 11s
CI / Test (rest) (pull_request) Failing after 33s
CI / Build (windows/amd64) (pull_request) Successful in 7s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 7s
CI / Test (server-http) (pull_request) Successful in 1m44s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m27s
2026-08-22 11:29:59 +01:00
steve 25aefe90d6 Use anonymously pullable CI images 2026-08-22 11:29:15 +01:00
7 changed files with 68 additions and 27 deletions
+6 -15
View File
@@ -3,7 +3,7 @@
# Notes for anyone editing this file: # Notes for anyone editing this file:
# #
# Custom runner image # Custom runner image
# Every job runs inside `gitea.dcglab.co.uk/steve/ci-runner-go` # Every job runs inside `docker.dcglab.co.uk/public/ci-runner-go:latest`
# (recipe: https://gitea.dcglab.co.uk/steve/ci/src/branch/main/images/ci-runner-go). # (recipe: https://gitea.dcglab.co.uk/steve/ci/src/branch/main/images/ci-runner-go).
# That image already ships: # That image already ships:
# * Go on PATH at /usr/local/go/bin (so `actions/setup-go` is # * Go on PATH at /usr/local/go/bin (so `actions/setup-go` is
@@ -11,8 +11,8 @@
# otherwise re-download Go on every job) # otherwise re-download Go on every job)
# * Node.js + npm (used by docs / e2e workflows) # * Node.js + npm (used by docs / e2e workflows)
# * Docker CLI, Buildx, Compose v2 (used by docker-build steps) # * Docker CLI, Buildx, Compose v2 (used by docker-build steps)
# When bumping the Go floor, push a new ci-runner-go image with # The runner image is intentionally tracked via `latest`: it is shared CI
# the matching Go version and bump the date pin in IMAGE below. # infrastructure, and updates should propagate to consuming repositories.
# #
# Self-hosted runner expectations # Self-hosted runner expectations
# Each runner host bind-mounts persistent volumes for # Each runner host bind-mounts persistent volumes for
@@ -71,10 +71,7 @@ jobs:
name: Test (${{ matrix.name }}) name: Test (${{ matrix.name }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker.dcglab.co.uk/ci-runner-go:2026-05-15 image: docker.dcglab.co.uk/public/ci-runner-go:latest
credentials:
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -110,10 +107,7 @@ jobs:
name: Lint name: Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker.dcglab.co.uk/ci-runner-go:2026-05-15 image: docker.dcglab.co.uk/public/ci-runner-go:latest
credentials:
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: golangci/golangci-lint-action@v7 - uses: golangci/golangci-lint-action@v7
@@ -130,10 +124,7 @@ jobs:
name: Build (${{ matrix.goos }}/${{ matrix.goarch }}) name: Build (${{ matrix.goos }}/${{ matrix.goarch }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker.dcglab.co.uk/ci-runner-go:2026-05-15 image: docker.dcglab.co.uk/public/ci-runner-go:latest
credentials:
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
e2e: e2e:
name: Playwright vs docker-compose name: Playwright vs docker-compose
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: gitea.dcglab.co.uk/steve/ci-runner-go:2026-05-08 container: docker.dcglab.co.uk/public/ci-runner-go:latest
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+5 -6
View File
@@ -12,7 +12,9 @@
# plus install.sh / install.ps1 / the systemd unit baked in under # plus install.sh / install.ps1 / the systemd unit baked in under
# /opt/restic-manager/dist (the read-only fallback path the server # /opt/restic-manager/dist (the read-only fallback path the server
# handlers use when <DataDir>/... is empty). # handlers use when <DataDir>/... is empty).
# * Pushes to zot OCI registry (docker.dcglab.co.uk). # * Pushes to the public namespace in the zot OCI registry
# (docker.dcglab.co.uk/public/restic-manager). Pulls are anonymous;
# pushes remain authenticated.
# #
# Tag fan-out # Tag fan-out
# * tag push: :vX.Y.Z, :X.Y, :X # * tag push: :vX.Y.Z, :X.Y, :X
@@ -29,7 +31,7 @@ on:
env: env:
REGISTRY: docker.dcglab.co.uk REGISTRY: docker.dcglab.co.uk
IMAGE_NAME: restic-manager IMAGE_NAME: public/restic-manager
# Force bash as the default shell — see ci.yml header. # Force bash as the default shell — see ci.yml header.
defaults: defaults:
@@ -41,10 +43,7 @@ jobs:
name: Build + push image name: Build + push image
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker.dcglab.co.uk/ci-runner-go:2026-05-15 image: docker.dcglab.co.uk/public/ci-runner-go:latest
credentials:
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+40 -1
View File
@@ -6,6 +6,40 @@ and the project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [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 ## [1.1.1] - 2026-08-22
### Fixed ### Fixed
@@ -140,7 +174,8 @@ with a web UI, JSON API, and self-updating agent fleet.
go vet, golangci-lint). go vet, golangci-lint).
- Threat model published (`docs/threat-model.md`). - Threat model published (`docs/threat-model.md`).
[Unreleased]: https://gitea.dcglab.co.uk/steve/restic-manager/compare/v1.1.1...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.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.1.0]: https://gitea.dcglab.co.uk/steve/restic-manager/releases/tag/v1.1.0
[1.0.0]: https://gitea.dcglab.co.uk/steve/restic-manager/releases/tag/v1.0.0 [1.0.0]: https://gitea.dcglab.co.uk/steve/restic-manager/releases/tag/v1.0.0
@@ -148,3 +183,7 @@ with a web UI, JSON API, and self-updating agent fleet.
[#37]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/37 [#37]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/37
[#36]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/36 [#36]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/36
[#34]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/34 [#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
+11
View File
@@ -61,9 +61,20 @@ type Engine struct {
stuckThresholds map[string]time.Duration stuckThresholds map[string]time.Duration
closeOnce sync.Once closeOnce sync.Once
notifyWG sync.WaitGroup
done chan struct{} done chan struct{}
} }
func (e *Engine) dispatchNotification(ctx context.Context, payload notification.Payload) {
e.notifyWG.Add(1)
go func() {
defer e.notifyWG.Done()
e.hub.Dispatch(ctx, payload)
}()
}
func (e *Engine) waitNotifications() { e.notifyWG.Wait() }
// NewEngine builds the engine. agentOfflineFloor + tickPeriod default // NewEngine builds the engine. agentOfflineFloor + tickPeriod default
// to 15min and 60s respectively when zero. // to 15min and 60s respectively when zero.
func NewEngine(st *store.Store, hub *notification.Hub) *Engine { func NewEngine(st *store.Store, hub *notification.Hub) *Engine {
+4 -4
View File
@@ -60,7 +60,7 @@ func (e *Engine) raiseAndNotify(ctx context.Context, hostID, kind, dedupKey, sev
if err == nil { if err == nil {
hostName = host.Name hostName = host.Name
} }
go e.hub.Dispatch(ctx, notification.Payload{ e.dispatchNotification(ctx, notification.Payload{
Event: notification.EventRaised, Event: notification.EventRaised,
AlertID: id, AlertID: id,
Severity: severity, Severity: severity,
@@ -85,7 +85,7 @@ func (e *Engine) Acknowledge(ctx context.Context, alertID, userID string, when t
return nil //nolint:nilerr return nil //nolint:nilerr
} }
p := alertPayload(ctx, e.store, notification.EventAcknowledged, a) p := alertPayload(ctx, e.store, notification.EventAcknowledged, a)
go e.hub.Dispatch(context.WithoutCancel(ctx), p) e.dispatchNotification(context.WithoutCancel(ctx), p)
return nil return nil
} }
@@ -99,7 +99,7 @@ func (e *Engine) Resolve(ctx context.Context, alertID string, when time.Time) er
return nil return nil
} }
p := alertPayload(ctx, e.store, notification.EventResolved, a) p := alertPayload(ctx, e.store, notification.EventResolved, a)
go e.hub.Dispatch(context.WithoutCancel(ctx), p) e.dispatchNotification(context.WithoutCancel(ctx), p)
return nil return nil
} }
@@ -164,7 +164,7 @@ func (e *Engine) resolveAndNotify(ctx context.Context, hostID, kind, dedupKey st
if a.Kind != kind || a.DedupKey != dedupKey { if a.Kind != kind || a.DedupKey != dedupKey {
continue continue
} }
go e.hub.Dispatch(ctx, notification.Payload{ e.dispatchNotification(ctx, notification.Payload{
Event: notification.EventResolved, Event: notification.EventResolved,
AlertID: a.ID, AlertID: a.ID,
Severity: a.Severity, Severity: a.Severity,
+1
View File
@@ -25,6 +25,7 @@ func setupEngine(t *testing.T) (*Engine, *store.Store, string) {
aead, _ := crypto.NewAEAD(key) aead, _ := crypto.NewAEAD(key)
hub := notification.NewHub(st, aead, "https://rm.example") hub := notification.NewHub(st, aead, "https://rm.example")
eng := NewEngine(st, hub) eng := NewEngine(st, hub)
t.Cleanup(eng.waitNotifications)
hostID := ulid.Make().String() hostID := ulid.Make().String()
if err := st.CreateHost(context.Background(), store.Host{ if err := st.CreateHost(context.Background(), store.Host{
ID: hostID, Name: "alfa-01", OS: "linux", Arch: "amd64", ID: hostID, Name: "alfa-01", OS: "linux", Arch: "amd64",