Compare commits
19 Commits
v1.1.0
...
8fdf4a1bdf
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fdf4a1bdf | |||
| 750a06384b | |||
| 2110c7dab4 | |||
| 8ddd3456e1 | |||
| 74cb7f660f | |||
| 1131f4330c | |||
| 337472a819 | |||
| b315932cc8 | |||
| f6fa84d7d8 | |||
| 904c522a23 | |||
| 31f53d65a7 | |||
| 528bdef433 | |||
| dfe082629f | |||
| 39aff83837 | |||
| 27be28ee9c | |||
| a8a6fdfab5 | |||
| e9df802478 | |||
| 6c6b962e24 | |||
| e64075d5d7 |
+24
-1
@@ -6,6 +6,23 @@ and the project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.1.1] - 2026-08-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Prevented agents from panicking when a WebSocket connection ends. The
|
||||||
|
WebSocket library transfers ownership of a successful upgrade stream to
|
||||||
|
the connection and leaves the HTTP response body nil; attempting to close
|
||||||
|
that body caused agents to restart and left forget jobs permanently stuck
|
||||||
|
in `running`. ([#37])
|
||||||
|
- Manual forget jobs now receive the same per-source-group retention policies
|
||||||
|
as scheduled forget jobs. The API also supports a validated `--dry-run`
|
||||||
|
option through the complete server-to-agent-to-restic path, and rejects
|
||||||
|
hosts without configured retention before creating a job. ([#36])
|
||||||
|
- Corrected the admin-credentials help text to reflect that forget uses normal
|
||||||
|
append-only credentials and blank admin credentials do not provide a
|
||||||
|
fallback for prune. ([#34])
|
||||||
|
|
||||||
## [1.1.0] - 2026-06-15
|
## [1.1.0] - 2026-06-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -123,5 +140,11 @@ with a web UI, JSON API, and self-updating agent fleet.
|
|||||||
go vet, golangci-lint).
|
go vet, golangci-lint).
|
||||||
- Threat model published (`docs/threat-model.md`).
|
- Threat model published (`docs/threat-model.md`).
|
||||||
|
|
||||||
[Unreleased]: https://gitea.dcglab.co.uk/steve/restic-manager/compare/v1.0.0...HEAD
|
[Unreleased]: https://gitea.dcglab.co.uk/steve/restic-manager/compare/v1.1.1...HEAD
|
||||||
|
[1.1.1]: https://gitea.dcglab.co.uk/steve/restic-manager/compare/v1.1.0...v1.1.1
|
||||||
|
[1.1.0]: https://gitea.dcglab.co.uk/steve/restic-manager/releases/tag/v1.1.0
|
||||||
[1.0.0]: https://gitea.dcglab.co.uk/steve/restic-manager/releases/tag/v1.0.0
|
[1.0.0]: https://gitea.dcglab.co.uk/steve/restic-manager/releases/tag/v1.0.0
|
||||||
|
|
||||||
|
[#37]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/37
|
||||||
|
[#36]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/36
|
||||||
|
[#34]: https://gitea.dcglab.co.uk/steve/restic-manager/issues/34
|
||||||
|
|||||||
+37
-11
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Thanks for your interest in restic-manager. This document covers how
|
Thanks for your interest in restic-manager. This document covers how
|
||||||
to set up a development environment, the conventions the project
|
to set up a development environment, the conventions the project
|
||||||
follows, and how patches make it from your machine into `main`.
|
follows, and how to contribute through issues as well as patches that
|
||||||
|
make it from your machine into `main`.
|
||||||
|
|
||||||
## Project status and scope
|
## Project status and scope
|
||||||
|
|
||||||
@@ -108,6 +109,32 @@ admin user.
|
|||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
|
### Opening an issue
|
||||||
|
|
||||||
|
Issues are contributions too. Use them to report a bug, suggest a
|
||||||
|
feature, improve the documentation, or start a design discussion even
|
||||||
|
if you do not plan to submit a patch.
|
||||||
|
|
||||||
|
Before opening one, search the existing issues and check `tasks.md` to
|
||||||
|
see whether the topic is already tracked. Then choose the closest issue
|
||||||
|
template:
|
||||||
|
|
||||||
|
- [Bug report](./.gitea/issue_template/bug_report.md) for behaviour that
|
||||||
|
does not match the documentation or expected operation.
|
||||||
|
- [Feature request](./.gitea/issue_template/feature_request.md) for a new
|
||||||
|
capability or a change to existing behaviour.
|
||||||
|
|
||||||
|
Give the issue a specific title, keep it to one problem or proposal,
|
||||||
|
and complete the relevant template fields. If no template is an exact
|
||||||
|
fit, open a regular issue and explain the context, desired outcome, and
|
||||||
|
any alternatives you have considered. Maintainers may ask follow-up
|
||||||
|
questions or close requests that duplicate existing work or fall
|
||||||
|
outside the project's scope.
|
||||||
|
|
||||||
|
Security-sensitive reports are the exception: follow the
|
||||||
|
[SECURITY.md](./SECURITY.md) disclosure process and do not open a public
|
||||||
|
issue.
|
||||||
|
|
||||||
### Before opening a PR
|
### Before opening a PR
|
||||||
|
|
||||||
1. **Open an issue first** for non-trivial changes. The design is
|
1. **Open an issue first** for non-trivial changes. The design is
|
||||||
@@ -136,25 +163,24 @@ The PR template asks for:
|
|||||||
|
|
||||||
### Reporting bugs
|
### Reporting bugs
|
||||||
|
|
||||||
Open an issue with:
|
Use the bug report issue template and include:
|
||||||
|
|
||||||
- restic-manager version (`server --version`) and agent version.
|
- restic-manager version (`server --version`) and agent version.
|
||||||
- restic version on the affected host.
|
- restic version on the affected host.
|
||||||
- Steps to reproduce.
|
- Steps to reproduce.
|
||||||
- Server and agent logs (sanitise any tokens before pasting).
|
- Server and agent logs (sanitise any tokens before pasting).
|
||||||
|
|
||||||
Security-sensitive bugs go through the [SECURITY.md](./SECURITY.md)
|
For security-sensitive bugs, use the private disclosure process noted
|
||||||
disclosure path instead — please don't open a public issue for
|
above.
|
||||||
them.
|
|
||||||
|
|
||||||
### Suggesting features
|
### Suggesting features
|
||||||
|
|
||||||
Open an issue describing the use case (not just the proposed
|
Use the feature request issue template and describe the use case (not
|
||||||
solution). The roadmap in `tasks.md` shows where the project is
|
just the proposed solution). The roadmap in `tasks.md` shows where the
|
||||||
heading; if the suggestion fits a future phase we'll wire it in
|
project is heading; if the suggestion fits a future phase we'll wire it
|
||||||
there. If it falls outside the project's scope (multi-tenancy, SaaS,
|
in there. If it falls outside the project's scope (multi-tenancy, SaaS,
|
||||||
non-restic backends — see `spec.md` §2 non-goals) we'll say so
|
non-restic backends — see `spec.md` §2 non-goals) we'll say so early to
|
||||||
early to save your time.
|
save your time.
|
||||||
|
|
||||||
## Code of conduct
|
## Code of conduct
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -601,6 +601,11 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc
|
|||||||
failJob(p, tx, "forget: command.run carried no forget_groups (server didn't populate them)")
|
failJob(p, tx, "forget: command.run carried no forget_groups (server didn't populate them)")
|
||||||
return fmt.Errorf("forget: command.run carried no forget_groups (server didn't populate them)")
|
return fmt.Errorf("forget: command.run carried no forget_groups (server didn't populate them)")
|
||||||
}
|
}
|
||||||
|
if len(p.Args) > 1 || (len(p.Args) == 1 && p.Args[0] != "--dry-run") {
|
||||||
|
failJob(p, tx, "forget: command.run carried unsupported arguments")
|
||||||
|
return fmt.Errorf("forget: command.run carried unsupported arguments")
|
||||||
|
}
|
||||||
|
dryRun := len(p.Args) == 1
|
||||||
groups := make([]restic.ForgetGroup, 0, len(p.ForgetGroups))
|
groups := make([]restic.ForgetGroup, 0, len(p.ForgetGroups))
|
||||||
for _, g := range p.ForgetGroups {
|
for _, g := range p.ForgetGroups {
|
||||||
groups = append(groups, restic.ForgetGroup{
|
groups = append(groups, restic.ForgetGroup{
|
||||||
@@ -615,9 +620,9 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
slog.Info("agent: accepting forget job", "job_id", p.JobID, "groups", len(groups))
|
slog.Info("agent: accepting forget job", "job_id", p.JobID, "groups", len(groups), "dry_run", dryRun)
|
||||||
spawn("forget", func(jobCtx context.Context) error {
|
spawn("forget", func(jobCtx context.Context) error {
|
||||||
return r.RunForget(jobCtx, p.JobID, groups)
|
return r.RunForget(jobCtx, p.JobID, groups, dryRun)
|
||||||
})
|
})
|
||||||
case api.JobPrune:
|
case api.JobPrune:
|
||||||
// Prune may require admin creds (delete authority on rest-server).
|
// Prune may require admin creds (delete authority on rest-server).
|
||||||
|
|||||||
@@ -274,13 +274,13 @@ func (r *Runner) RunInit(ctx context.Context, jobID string) error {
|
|||||||
// snapshot projection (forget rewrites the snapshot index — the
|
// snapshot projection (forget rewrites the snapshot index — the
|
||||||
// host's snapshot list shrinks). Snapshot refresh runs once after
|
// host's snapshot list shrinks). Snapshot refresh runs once after
|
||||||
// every group completes, not per-group.
|
// every group completes, not per-group.
|
||||||
func (r *Runner) RunForget(ctx context.Context, jobID string, groups []restic.ForgetGroup) error {
|
func (r *Runner) RunForget(ctx context.Context, jobID string, groups []restic.ForgetGroup, dryRun bool) error {
|
||||||
startedAt := time.Now().UTC()
|
startedAt := time.Now().UTC()
|
||||||
r.sendStarted(jobID, api.JobForget, startedAt)
|
r.sendStarted(jobID, api.JobForget, startedAt)
|
||||||
|
|
||||||
env := r.resticEnv()
|
env := r.resticEnv()
|
||||||
var seq atomic.Int64
|
var seq atomic.Int64
|
||||||
err := env.RunForget(ctx, groups, r.streamHandler(jobID, &seq))
|
err := env.RunForget(ctx, groups, dryRun, r.streamHandler(jobID, &seq))
|
||||||
finishedAt := time.Now().UTC()
|
finishedAt := time.Now().UTC()
|
||||||
r.sendFinished(ctx, jobID, finishedAt, err, nil)
|
r.sendFinished(ctx, jobID, finishedAt, err, nil)
|
||||||
|
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ esac
|
|||||||
Tag: "documents",
|
Tag: "documents",
|
||||||
Policy: restic.ForgetPolicy{KeepLast: &keepLast},
|
Policy: restic.ForgetPolicy{KeepLast: &keepLast},
|
||||||
}}
|
}}
|
||||||
if err := r.RunForget(context.Background(), "job-forget", groups); err != nil {
|
if err := r.RunForget(context.Background(), "job-forget", groups, false); err != nil {
|
||||||
t.Fatalf("RunForget: %v", err)
|
t.Fatalf("RunForget: %v", err)
|
||||||
}
|
}
|
||||||
_ = firstEnvOfType(t, tx.envs, api.MsgJobStarted)
|
_ = firstEnvOfType(t, tx.envs, api.MsgJobStarted)
|
||||||
|
|||||||
@@ -103,15 +103,15 @@ func connectOnce(ctx context.Context, cfg Config, handle Handler) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
conn, res, err := websocket.Dial(dialCtx, wsURL, dialOpts)
|
conn, _, err := websocket.Dial(dialCtx, wsURL, dialOpts) //nolint:bodyclose // successful upgrades have a nil response body owned by conn
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("dial: %w", err)
|
return fmt.Errorf("dial: %w", err)
|
||||||
}
|
}
|
||||||
// websocket.Dial returns the upgrade response separately from the
|
conn.SetReadLimit(api.MaxWebSocketMessageBytes)
|
||||||
// conn. Body is empty on a successful upgrade but Go's net/http
|
// On a successful upgrade coder/websocket transfers ownership of the
|
||||||
// still expects it closed to release the connection.
|
// response stream to conn and deliberately sets res.Body to nil. Closing
|
||||||
defer func() { _ = res.Body.Close() }()
|
// the connection below releases that stream.
|
||||||
defer conn.CloseNow() //nolint:errcheck
|
defer conn.CloseNow() //nolint:errcheck
|
||||||
|
|
||||||
// Send hello.
|
// Send hello.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ import (
|
|||||||
// are evaluated — always-on hosts' stale_schedule stays a no-op.
|
// are evaluated — always-on hosts' stale_schedule stays a no-op.
|
||||||
const staleBackupThreshold = 7 * 24 * time.Hour
|
const staleBackupThreshold = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultStuckJobThreshold = 6 * time.Hour
|
||||||
|
longStuckJobThreshold = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
// JobFinishedEvent carries everything the engine needs to evaluate
|
// JobFinishedEvent carries everything the engine needs to evaluate
|
||||||
// the failed-X rules. Pushed via Engine.NotifyJobFinished from the
|
// the failed-X rules. Pushed via Engine.NotifyJobFinished from the
|
||||||
// MarkJobFinished site.
|
// MarkJobFinished site.
|
||||||
@@ -53,6 +58,7 @@ type Engine struct {
|
|||||||
// we raise. Configurable for tests; default 15m.
|
// we raise. Configurable for tests; default 15m.
|
||||||
agentOfflineFloor time.Duration
|
agentOfflineFloor time.Duration
|
||||||
tickPeriod time.Duration
|
tickPeriod time.Duration
|
||||||
|
stuckThresholds map[string]time.Duration
|
||||||
|
|
||||||
closeOnce sync.Once
|
closeOnce sync.Once
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
@@ -69,7 +75,12 @@ func NewEngine(st *store.Store, hub *notification.Hub) *Engine {
|
|||||||
hostUp: make(chan string, 32),
|
hostUp: make(chan string, 32),
|
||||||
agentOfflineFloor: 15 * time.Minute,
|
agentOfflineFloor: 15 * time.Minute,
|
||||||
tickPeriod: 60 * time.Second,
|
tickPeriod: 60 * time.Second,
|
||||||
done: make(chan struct{}),
|
stuckThresholds: map[string]time.Duration{
|
||||||
|
"backup": longStuckJobThreshold,
|
||||||
|
"restore": longStuckJobThreshold,
|
||||||
|
"check": longStuckJobThreshold,
|
||||||
|
},
|
||||||
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +136,10 @@ func (e *Engine) NotifyHostOnline(hostID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) handleJobFinished(ctx context.Context, ev JobFinishedEvent) {
|
func (e *Engine) handleJobFinished(ctx context.Context, ev JobFinishedEvent) {
|
||||||
|
// A late terminal message is authoritative and clears any stuck alert for
|
||||||
|
// this exact job regardless of its outcome or kind.
|
||||||
|
e.resolveAndNotify(ctx, ev.HostID, KindJobStuck, ev.JobID, ev.When)
|
||||||
|
|
||||||
// Determine which kind/severity pair this job maps to. Jobs not
|
// Determine which kind/severity pair this job maps to. Jobs not
|
||||||
// listed here (init, unlock, restore, diff) produce no alerts in v1.
|
// listed here (init, unlock, restore, diff) produce no alerts in v1.
|
||||||
var kind, severity string
|
var kind, severity string
|
||||||
@@ -210,6 +225,7 @@ func (e *Engine) tick(ctx context.Context, now time.Time) {
|
|||||||
if _, err := e.store.CleanupExpiredOIDCState(ctx, now.Add(-5*time.Minute)); err != nil {
|
if _, err := e.store.CleanupExpiredOIDCState(ctx, now.Add(-5*time.Minute)); err != nil {
|
||||||
slog.Warn("alert: cleanup expired oidc state", "err", err)
|
slog.Warn("alert: cleanup expired oidc state", "err", err)
|
||||||
}
|
}
|
||||||
|
e.evaluateStuckJobs(ctx, now)
|
||||||
|
|
||||||
hosts, err := e.store.ListHosts(ctx)
|
hosts, err := e.store.ListHosts(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -257,6 +273,46 @@ func (e *Engine) tick(ctx context.Context, now time.Time) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Engine) evaluateStuckJobs(ctx context.Context, now time.Time) {
|
||||||
|
running, err := e.store.ListRunningJobActivity(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("alert: tick list running jobs", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
active := make(map[string]store.RunningJobActivity, len(running))
|
||||||
|
for _, job := range running {
|
||||||
|
active[job.JobID] = job
|
||||||
|
threshold := defaultStuckJobThreshold
|
||||||
|
if configured, ok := e.stuckThresholds[job.Kind]; ok {
|
||||||
|
threshold = configured
|
||||||
|
}
|
||||||
|
age := now.Sub(job.LastActivity)
|
||||||
|
if age < threshold {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e.raiseAndNotify(ctx, job.HostID, KindJobStuck, job.JobID, "warning",
|
||||||
|
fmt.Sprintf("%s job %s is stuck: started %s, last activity %s (%s ago; threshold %s)",
|
||||||
|
job.Kind, job.JobID, job.StartedAt.Format(time.RFC3339),
|
||||||
|
job.LastActivity.Format(time.RFC3339), roundDur(age), threshold), now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-heal alerts when another server path made a job terminal without
|
||||||
|
// emitting JobFinishedEvent, or after a restart missed the event.
|
||||||
|
alerts, err := e.store.ListAlerts(ctx, store.AlertFilter{Status: "open"})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acked, _ := e.store.ListAlerts(ctx, store.AlertFilter{Status: "acknowledged"})
|
||||||
|
for _, item := range append(alerts, acked...) {
|
||||||
|
if item.Kind != KindJobStuck || item.HostID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := active[item.DedupKey]; !ok {
|
||||||
|
e.resolveAndNotify(ctx, *item.HostID, KindJobStuck, item.DedupKey, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// roundDur returns a human-readable duration string, rounding to the
|
// roundDur returns a human-readable duration string, rounding to the
|
||||||
// nearest minute. Durations under a minute are reported as "less than
|
// nearest minute. Durations under a minute are reported as "less than
|
||||||
// a minute".
|
// a minute".
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ const (
|
|||||||
// KindAgentOffline is raised when a host's last_seen_at is older
|
// KindAgentOffline is raised when a host's last_seen_at is older
|
||||||
// than the 15-minute floor and resolved when the host reconnects.
|
// than the 15-minute floor and resolved when the host reconnects.
|
||||||
KindAgentOffline = "agent_offline"
|
KindAgentOffline = "agent_offline"
|
||||||
|
|
||||||
|
// KindJobStuck is raised per job when a running job has no persisted
|
||||||
|
// activity beyond its kind-specific threshold. The job ID is the dedup key.
|
||||||
|
KindJobStuck = "job_stuck"
|
||||||
)
|
)
|
||||||
|
|
||||||
// raiseAndNotify is the standard raise pattern: store.RaiseOrTouch
|
// raiseAndNotify is the standard raise pattern: store.RaiseOrTouch
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package alert
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -69,6 +70,65 @@ func TestEngineBackupFailedRaisesThenResolves(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEngineStuckJobRaisesDeduplicatesAndResolves(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
eng, st, hostID := setupEngine(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now().UTC().Truncate(time.Second)
|
||||||
|
started := now.Add(-7 * time.Hour)
|
||||||
|
if err := st.CreateJob(ctx, store.Job{ID: "stuck-job", HostID: hostID, Kind: "prune", ActorKind: "user", CreatedAt: started}); err != nil {
|
||||||
|
t.Fatalf("create job: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.MarkJobStarted(ctx, "stuck-job", started); err != nil {
|
||||||
|
t.Fatalf("start job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
eng.evaluateStuckJobs(ctx, now)
|
||||||
|
eng.evaluateStuckJobs(ctx, now.Add(time.Minute))
|
||||||
|
open, err := st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list alerts: %v", err)
|
||||||
|
}
|
||||||
|
if len(open) != 1 || open[0].Kind != KindJobStuck || open[0].DedupKey != "stuck-job" {
|
||||||
|
t.Fatalf("expected one deduplicated stuck alert, got %+v", open)
|
||||||
|
}
|
||||||
|
if !strings.Contains(open[0].Message, started.Format(time.RFC3339)) {
|
||||||
|
t.Errorf("alert does not identify start/activity time: %q", open[0].Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.MarkJobFinished(ctx, "stuck-job", "succeeded", 0, nil, "", now); err != nil {
|
||||||
|
t.Fatalf("finish job: %v", err)
|
||||||
|
}
|
||||||
|
eng.evaluateStuckJobs(ctx, now.Add(2*time.Minute))
|
||||||
|
open, _ = st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
|
||||||
|
if len(open) != 0 {
|
||||||
|
t.Fatalf("expected terminal job alert resolved, got %+v", open)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEngineStuckJobUsesLatestLogActivity(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
eng, st, hostID := setupEngine(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now().UTC().Truncate(time.Second)
|
||||||
|
started := now.Add(-7 * time.Hour)
|
||||||
|
if err := st.CreateJob(ctx, store.Job{ID: "active-job", HostID: hostID, Kind: "forget", ActorKind: "user", CreatedAt: started}); err != nil {
|
||||||
|
t.Fatalf("create job: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.MarkJobStarted(ctx, "active-job", started); err != nil {
|
||||||
|
t.Fatalf("start job: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.AppendJobLog(ctx, "active-job", 1, now.Add(-time.Hour), "stdout", "active"); err != nil {
|
||||||
|
t.Fatalf("append log: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
eng.evaluateStuckJobs(ctx, now)
|
||||||
|
open, _ := st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
|
||||||
|
if len(open) != 0 {
|
||||||
|
t.Fatalf("recent activity should suppress stuck alert, got %+v", open)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEngineCheckFailedSeverityCritical(t *testing.T) {
|
func TestEngineCheckFailedSeverityCritical(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
eng, st, hostID := setupEngine(t)
|
eng, st, hostID := setupEngine(t)
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ import (
|
|||||||
// (not iota ints) makes traffic readable in logs and packet captures.
|
// (not iota ints) makes traffic readable in logs and packet captures.
|
||||||
type MessageType string
|
type MessageType string
|
||||||
|
|
||||||
|
// MaxWebSocketMessageBytes is the protocol-wide upper bound for one agent ↔
|
||||||
|
// server envelope. Snapshot projections and restic JSON log events can exceed
|
||||||
|
// coder/websocket's 32 KiB default on ordinary repositories, so both peers set
|
||||||
|
// this limit explicitly. It remains bounded to protect either process from an
|
||||||
|
// untrusted or malfunctioning peer allocating without limit.
|
||||||
|
const MaxWebSocketMessageBytes int64 = 8 << 20
|
||||||
|
|
||||||
// Agent → server message types.
|
// Agent → server message types.
|
||||||
const (
|
const (
|
||||||
MsgHello MessageType = "hello"
|
MsgHello MessageType = "hello"
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ type ForgetGroup struct {
|
|||||||
// any keep-* would delete every snapshot in the tagged set).
|
// any keep-* would delete every snapshot in the tagged set).
|
||||||
// Returns the first error encountered, or nil when every group runs
|
// Returns the first error encountered, or nil when every group runs
|
||||||
// to a clean exit.
|
// to a clean exit.
|
||||||
func (e Env) RunForget(ctx context.Context, groups []ForgetGroup, handle LineHandler) error {
|
func (e Env) RunForget(ctx context.Context, groups []ForgetGroup, dryRun bool, handle LineHandler) error {
|
||||||
if len(groups) == 0 {
|
if len(groups) == 0 {
|
||||||
return fmt.Errorf("restic forget: refusing to run with no groups (would be a no-op)")
|
return fmt.Errorf("restic forget: refusing to run with no groups (would be a no-op)")
|
||||||
}
|
}
|
||||||
@@ -332,6 +332,9 @@ func (e Env) RunForget(ctx context.Context, groups []ForgetGroup, handle LineHan
|
|||||||
}
|
}
|
||||||
args := []string{"forget", "--json", "--tag", g.Tag}
|
args := []string{"forget", "--json", "--tag", g.Tag}
|
||||||
args = append(args, g.Policy.args()...)
|
args = append(args, g.Policy.args()...)
|
||||||
|
if dryRun {
|
||||||
|
args = append(args, "--dry-run")
|
||||||
|
}
|
||||||
cmd := e.resticCmd(ctx, args...)
|
cmd := e.resticCmd(ctx, args...)
|
||||||
if err := runWithPump(cmd, handle); err != nil {
|
if err := runWithPump(cmd, handle); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -60,6 +60,26 @@ func TestRunPruneInvokesPrune(t *testing.T) {
|
|||||||
t.Fatalf("expected 'prune' in captured output; got: %v", *lines)
|
t.Fatalf("expected 'prune' in captured output; got: %v", *lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunForgetDryRunArgument(t *testing.T) {
|
||||||
|
bin := setupScriptBin(t, `echo "$@"`)
|
||||||
|
env := Env{Bin: bin}
|
||||||
|
lines, h := captureLines()
|
||||||
|
keepLast := 1
|
||||||
|
groups := []ForgetGroup{{
|
||||||
|
Tag: "documents",
|
||||||
|
Policy: ForgetPolicy{KeepLast: &keepLast},
|
||||||
|
}}
|
||||||
|
if err := env.RunForget(context.Background(), groups, true, h); err != nil {
|
||||||
|
t.Fatalf("RunForget: %v", err)
|
||||||
|
}
|
||||||
|
for _, line := range *lines {
|
||||||
|
if strings.Contains(line, "forget --json --tag documents --keep-last 1 --dry-run") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("expected forget invocation with --dry-run; got: %v", *lines)
|
||||||
|
}
|
||||||
|
|
||||||
// --- B2: RunCheck ---
|
// --- B2: RunCheck ---
|
||||||
|
|
||||||
func TestRunCheckLockSniff(t *testing.T) {
|
func TestRunCheckLockSniff(t *testing.T) {
|
||||||
|
|||||||
@@ -65,10 +65,32 @@ func (s *Server) handleRunNow(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|||||||
func (s *Server) dispatchJob(ctx context.Context, user *store.User,
|
func (s *Server) dispatchJob(ctx context.Context, user *store.User,
|
||||||
hostID string, kind api.JobKind, args []string,
|
hostID string, kind api.JobKind, args []string,
|
||||||
) (res runNowResponse, status int, code, msg string) {
|
) (res runNowResponse, status int, code, msg string) {
|
||||||
return s.dispatchJobWithPayload(ctx, user, hostID, kind, nil, api.CommandRunPayload{
|
payload := api.CommandRunPayload{
|
||||||
Kind: kind,
|
Kind: kind,
|
||||||
Args: args,
|
Args: args,
|
||||||
})
|
}
|
||||||
|
if kind == api.JobForget {
|
||||||
|
if !validForgetArgs(args) {
|
||||||
|
return res, stdhttp.StatusBadRequest, "invalid_args",
|
||||||
|
"forget accepts no arguments other than --dry-run"
|
||||||
|
}
|
||||||
|
var ok bool
|
||||||
|
var err error
|
||||||
|
payload, ok, err = s.buildForgetPayloadForHost(ctx, hostID)
|
||||||
|
if err != nil {
|
||||||
|
return res, stdhttp.StatusInternalServerError, "internal", ""
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return res, stdhttp.StatusUnprocessableEntity, "no_retention_policy",
|
||||||
|
"host has no source groups with a retention policy"
|
||||||
|
}
|
||||||
|
payload.Args = args
|
||||||
|
}
|
||||||
|
return s.dispatchJobWithPayload(ctx, user, hostID, kind, nil, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validForgetArgs(args []string) bool {
|
||||||
|
return len(args) == 0 || (len(args) == 1 && args[0] == "--dry-run")
|
||||||
}
|
}
|
||||||
|
|
||||||
// dispatchJobWithPayload is dispatchJob's variant that lets callers
|
// dispatchJobWithPayload is dispatchJob's variant that lets callers
|
||||||
|
|||||||
@@ -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 {
|
switch d.Kind {
|
||||||
case "forget":
|
case "forget":
|
||||||
payload, ok := s.buildForgetPayloadForHost(ctx, d.HostID)
|
payload, ok, err := s.buildForgetPayloadForHost(ctx, d.HostID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("maintenance: list source groups failed",
|
||||||
|
"host_id", d.HostID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
slog.Info("maintenance: forget skipped — no source groups with retention",
|
slog.Info("maintenance: forget skipped — no source groups with retention",
|
||||||
"host_id", d.HostID)
|
"host_id", d.HostID)
|
||||||
@@ -88,11 +93,10 @@ func (s *Server) DispatchMaintenance(ctx context.Context, decisions []maintenanc
|
|||||||
// that has a non-empty retention policy and builds a CommandRunPayload
|
// that has a non-empty retention policy and builds a CommandRunPayload
|
||||||
// with ForgetGroups populated. Returns ok=false if the host has no
|
// with ForgetGroups populated. Returns ok=false if the host has no
|
||||||
// such groups (the dispatcher then skips this kind).
|
// such groups (the dispatcher then skips this kind).
|
||||||
func (s *Server) buildForgetPayloadForHost(ctx context.Context, hostID string) (api.CommandRunPayload, bool) {
|
func (s *Server) buildForgetPayloadForHost(ctx context.Context, hostID string) (api.CommandRunPayload, bool, error) {
|
||||||
groups, err := s.deps.Store.ListSourceGroupsByHost(ctx, hostID)
|
groups, err := s.deps.Store.ListSourceGroupsByHost(ctx, hostID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("maintenance: list source groups failed", "host_id", hostID, "err", err)
|
return api.CommandRunPayload{}, false, err
|
||||||
return api.CommandRunPayload{}, false
|
|
||||||
}
|
}
|
||||||
fg := make([]api.ForgetGroup, 0, len(groups))
|
fg := make([]api.ForgetGroup, 0, len(groups))
|
||||||
for _, g := range groups {
|
for _, g := range groups {
|
||||||
@@ -105,9 +109,9 @@ func (s *Server) buildForgetPayloadForHost(ctx context.Context, hostID string) (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if len(fg) == 0 {
|
if len(fg) == 0 {
|
||||||
return api.CommandRunPayload{}, false
|
return api.CommandRunPayload{}, false, nil
|
||||||
}
|
}
|
||||||
return api.CommandRunPayload{ForgetGroups: fg}, true
|
return api.CommandRunPayload{ForgetGroups: fg}, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func isEmptyRetention(p store.RetentionPolicy) bool {
|
func isEmptyRetention(p store.RetentionPolicy) bool {
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/alert"
|
||||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
|
||||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/metrics"
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/metrics"
|
||||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||||
@@ -173,13 +175,37 @@ func (s *Server) gatherMetricsSnapshot(ctx context.Context) (metrics.Snapshot, e
|
|||||||
return metrics.Snapshot{}, err
|
return metrics.Snapshot{}, err
|
||||||
}
|
}
|
||||||
bySeverity := map[string]int{"info": 0, "warning": 0, "critical": 0}
|
bySeverity := map[string]int{"info": 0, "warning": 0, "critical": 0}
|
||||||
|
stuckJobs := 0
|
||||||
|
var oldestStuckAge time.Duration
|
||||||
|
now := time.Now().UTC()
|
||||||
|
running, err := s.deps.Store.ListRunningJobActivity(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return metrics.Snapshot{}, err
|
||||||
|
}
|
||||||
|
activityByJob := make(map[string]time.Time, len(running))
|
||||||
|
for _, job := range running {
|
||||||
|
activityByJob[job.JobID] = job.LastActivity
|
||||||
|
}
|
||||||
for _, a := range open {
|
for _, a := range open {
|
||||||
bySeverity[a.Severity]++
|
bySeverity[a.Severity]++
|
||||||
|
if a.Kind == alert.KindJobStuck {
|
||||||
|
stuckJobs++
|
||||||
|
lastActivity, ok := activityByJob[a.DedupKey]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if age := now.Sub(lastActivity); age > oldestStuckAge {
|
||||||
|
oldestStuckAge = age
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reg := s.deps.Metrics
|
reg := s.deps.Metrics
|
||||||
if reg == nil {
|
if reg == nil {
|
||||||
reg = metrics.NewRegistry() // empty histogram block
|
reg = metrics.NewRegistry() // empty histogram block
|
||||||
}
|
}
|
||||||
return reg.SnapshotWith(hostRows, bySeverity, version.Version, version.Commit, runtime.Version()), nil
|
snap := reg.SnapshotWith(hostRows, bySeverity, version.Version, version.Commit, runtime.Version())
|
||||||
|
snap.StuckJobs = stuckJobs
|
||||||
|
snap.OldestStuckAge = oldestStuckAge
|
||||||
|
return snap, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -512,11 +512,27 @@ func TestDrainPendingSerializesPerHost(t *testing.T) {
|
|||||||
// Connect the agent so DrainPending can dispatch.
|
// Connect the agent so DrainPending can dispatch.
|
||||||
c := agentDial(t, srv, ts, hostID, token)
|
c := agentDial(t, srv, ts, hostID, token)
|
||||||
sendHello(t, c, "serialise-host")
|
sendHello(t, c, "serialise-host")
|
||||||
// Drain the on-hello goroutine's pass first (no pending rows yet),
|
// Wait for the on-hello push to settle.
|
||||||
// then wait for the schedule.set so the connection is fully settled.
|
|
||||||
_ = drainUntil(t, c, api.MsgScheduleSet)
|
_ = drainUntil(t, c, api.MsgScheduleSet)
|
||||||
|
|
||||||
// Insert 5 pending rows now that the on-hello drain has already run.
|
// A real agent is always in a read loop. Keep this test client
|
||||||
|
// reading in the background for the rest of the test: without an
|
||||||
|
// active reader the server-side conn can be dropped under parallel
|
||||||
|
// load, which unregisters it from the hub and makes DrainPending
|
||||||
|
// no-op (conn == nil) — the historical source of this test's
|
||||||
|
// flakiness (it would observe 0 or a partial drain). The reader also
|
||||||
|
// consumes the command.run envelopes our drains emit.
|
||||||
|
readerCtx, stopReader := context.WithCancel(context.Background())
|
||||||
|
defer stopReader()
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
if _, _, err := c.Read(readerCtx); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Insert 5 due pending rows.
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
for i := range 5 {
|
for i := range 5 {
|
||||||
pid := ulid.Make().String()
|
pid := ulid.Make().String()
|
||||||
@@ -533,7 +549,8 @@ func TestDrainPendingSerializesPerHost(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn 10 goroutines all calling DrainPending concurrently.
|
// Fire 10 concurrent DrainPending calls. The per-host mutex must
|
||||||
|
// ensure each row is dispatched at most once (no double-dispatch).
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for range 10 {
|
for range 10 {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
@@ -544,24 +561,26 @@ func TestDrainPendingSerializesPerHost(t *testing.T) {
|
|||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
// Drain any envelopes the agent received so we don't block below.
|
// Drain to completion. The fire-and-forget on-hello DrainPending
|
||||||
// We read with short timeouts and stop when the connection goes quiet.
|
// shares the same per-host mutex and can hold it during the burst,
|
||||||
drainDeadline := time.Now().Add(500 * time.Millisecond)
|
// leaving rows for a later pass — exactly how production drains
|
||||||
for time.Now().Before(drainDeadline) {
|
// (repeatedly, via the 30s tick / on reconnect). Re-drain until the
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
// queue is empty; because every drain is still serialised, each row
|
||||||
_, _, err := c.Read(ctx)
|
// is dispatched at most once, so the exactly-5 job count below proves
|
||||||
cancel()
|
// there was no double-dispatch.
|
||||||
if err != nil {
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
break
|
for countPendingForHost(t, st, hostID) > 0 && time.Now().Before(deadline) {
|
||||||
}
|
srv.DrainPending(context.Background(), hostID)
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
// All 5 pending rows must be gone.
|
// All 5 pending rows must be drained.
|
||||||
if n := countPendingForHost(t, st, hostID); n != 0 {
|
if n := countPendingForHost(t, st, hostID); n != 0 {
|
||||||
t.Errorf("pending rows after concurrent drain: got %d, want 0", n)
|
t.Errorf("pending rows after drain-to-completion: got %d, want 0", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exactly 5 backup job rows (one per pending row), not 10+ from a race.
|
// Exactly 5 backup job rows (one per pending row) — never more, which
|
||||||
|
// would mean the per-host mutex failed to prevent double-dispatch.
|
||||||
var n int
|
var n int
|
||||||
_ = st.DB().QueryRow(
|
_ = st.DB().QueryRow(
|
||||||
`SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = 'backup' AND actor_kind = 'schedule'`,
|
`SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = 'backup' AND actor_kind = 'schedule'`,
|
||||||
|
|||||||
@@ -155,6 +155,8 @@ type Snapshot struct {
|
|||||||
BuildCommit string
|
BuildCommit string
|
||||||
GoVersion string
|
GoVersion string
|
||||||
JobDurationRows []HistogramRow
|
JobDurationRows []HistogramRow
|
||||||
|
StuckJobs int
|
||||||
|
OldestStuckAge time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// SnapshotWith builds a Snapshot from raw inputs and the registry's
|
// SnapshotWith builds a Snapshot from raw inputs and the registry's
|
||||||
@@ -205,6 +207,13 @@ func Render(w io.Writer, s Snapshot) error {
|
|||||||
fmt.Fprintf(&b, "rm_build_info{version=%q,commit=%q,go_version=%q} 1\n",
|
fmt.Fprintf(&b, "rm_build_info{version=%q,commit=%q,go_version=%q} 1\n",
|
||||||
s.BuildVersion, s.BuildCommit, s.GoVersion)
|
s.BuildVersion, s.BuildCommit, s.GoVersion)
|
||||||
|
|
||||||
|
b.WriteString("# HELP rm_stuck_jobs Number of open job_stuck alerts.\n")
|
||||||
|
b.WriteString("# TYPE rm_stuck_jobs gauge\n")
|
||||||
|
fmt.Fprintf(&b, "rm_stuck_jobs %d\n", s.StuckJobs)
|
||||||
|
b.WriteString("# HELP rm_oldest_stuck_job_age_seconds Time since last activity for the oldest open stuck job.\n")
|
||||||
|
b.WriteString("# TYPE rm_oldest_stuck_job_age_seconds gauge\n")
|
||||||
|
fmt.Fprintf(&b, "rm_oldest_stuck_job_age_seconds %.0f\n", s.OldestStuckAge.Seconds())
|
||||||
|
|
||||||
// --- Per-host gauges -------------------------------------------------
|
// --- Per-host gauges -------------------------------------------------
|
||||||
// Stable order: by host id.
|
// Stable order: by host id.
|
||||||
hosts := append([]HostRow(nil), s.Hosts...)
|
hosts := append([]HostRow(nil), s.Hosts...)
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ func TestRenderGolden(t *testing.T) {
|
|||||||
snap := r.SnapshotWith(hosts,
|
snap := r.SnapshotWith(hosts,
|
||||||
map[string]int{"info": 0, "warning": 1, "critical": 0},
|
map[string]int{"info": 0, "warning": 1, "critical": 0},
|
||||||
"v1.2.3", "deadbeef", "go1.25.0")
|
"v1.2.3", "deadbeef", "go1.25.0")
|
||||||
|
snap.StuckJobs = 2
|
||||||
|
snap.OldestStuckAge = 90 * time.Minute
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := Render(&buf, snap); err != nil {
|
if err := Render(&buf, snap); err != nil {
|
||||||
@@ -129,6 +131,8 @@ func TestRenderGolden(t *testing.T) {
|
|||||||
`rm_active_alerts{severity="info"} 0`,
|
`rm_active_alerts{severity="info"} 0`,
|
||||||
`rm_active_alerts{severity="critical"} 0`,
|
`rm_active_alerts{severity="critical"} 0`,
|
||||||
`rm_build_info{version="v1.2.3",commit="deadbeef",go_version="go1.25.0"} 1`,
|
`rm_build_info{version="v1.2.3",commit="deadbeef",go_version="go1.25.0"} 1`,
|
||||||
|
`rm_stuck_jobs 2`,
|
||||||
|
`rm_oldest_stuck_job_age_seconds 5400`,
|
||||||
`rm_host_agent_online{host_id="01H0001",host="alpha"} 1`,
|
`rm_host_agent_online{host_id="01H0001",host="alpha"} 1`,
|
||||||
`rm_host_agent_online{host_id="01H0002",host="bravo"} 0`,
|
`rm_host_agent_online{host_id="01H0002",host="bravo"} 0`,
|
||||||
`rm_host_last_backup_timestamp_seconds{host_id="01H0001",host="alpha"} 1700000000`,
|
`rm_host_last_backup_timestamp_seconds{host_id="01H0001",host="alpha"} 1700000000`,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ func AgentHandler(deps HandlerDeps) stdhttp.Handler {
|
|||||||
slog.Warn("ws accept failed", "err", err, "host_id", host.ID)
|
slog.Warn("ws accept failed", "err", err, "host_id", host.ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
conn.SetReadLimit(api.MaxWebSocketMessageBytes)
|
||||||
|
|
||||||
c := NewConn(host.ID, conn)
|
c := NewConn(host.ID, conn)
|
||||||
// Keep agents alive across NAT boxes; coder/websocket
|
// Keep agents alive across NAT boxes; coder/websocket
|
||||||
|
|||||||
@@ -123,6 +123,71 @@ func TestWSHelloAndHeartbeat(t *testing.T) {
|
|||||||
t.Error("heartbeat did not update last_seen_at")
|
t.Error("heartbeat did not update last_seen_at")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWSAcceptsSnapshotReportLargerThanDefaultReadLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
url, token, hostID, st, hub := setupTestHub(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
c, resp, err := websocket.Dial(ctx, url, &websocket.DialOptions{
|
||||||
|
HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}},
|
||||||
|
})
|
||||||
|
if resp != nil && resp.Body != nil {
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
defer c.CloseNow() //nolint:errcheck
|
||||||
|
|
||||||
|
hello, _ := api.Marshal(api.MsgHello, "", api.HelloPayload{
|
||||||
|
ProtocolVersion: api.CurrentProtocolVersion,
|
||||||
|
AgentVersion: "0.1.0",
|
||||||
|
ResticVersion: "0.17.1",
|
||||||
|
Hostname: "h1",
|
||||||
|
OS: api.OSLinux,
|
||||||
|
Arch: api.ArchAmd64,
|
||||||
|
})
|
||||||
|
helloRaw, _ := json.Marshal(hello)
|
||||||
|
if err := c.Write(ctx, websocket.MessageText, helloRaw); err != nil {
|
||||||
|
t.Fatalf("write hello: %v", err)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for !hub.Connected(hostID) && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
report, _ := api.Marshal(api.MsgSnapshotsRpt, "", api.SnapshotsReportPayload{
|
||||||
|
Snapshots: []api.Snapshot{{
|
||||||
|
ID: strings.Repeat("a", 64),
|
||||||
|
ShortID: "aaaaaaaa",
|
||||||
|
Time: time.Now().UTC(),
|
||||||
|
Hostname: "h1",
|
||||||
|
Paths: []string{"/" + strings.Repeat("long-path/", 5000)},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
reportRaw, _ := json.Marshal(report)
|
||||||
|
if len(reportRaw) <= 32*1024 {
|
||||||
|
t.Fatalf("test payload is only %d bytes; must exceed old limit", len(reportRaw))
|
||||||
|
}
|
||||||
|
if int64(len(reportRaw)) >= api.MaxWebSocketMessageBytes {
|
||||||
|
t.Fatalf("test payload %d exceeds protocol limit", len(reportRaw))
|
||||||
|
}
|
||||||
|
if err := c.Write(ctx, websocket.MessageText, reportRaw); err != nil {
|
||||||
|
t.Fatalf("write snapshots.report: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline = time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
host, err := st.GetHost(context.Background(), hostID)
|
||||||
|
if err == nil && host.SnapshotCount == 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("oversized snapshots.report was not projected")
|
||||||
|
}
|
||||||
|
|
||||||
func TestWSRejectsOldProtocol(t *testing.T) {
|
func TestWSRejectsOldProtocol(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
url, token, _, _, _ := setupTestHub(t)
|
url, token, _, _, _ := setupTestHub(t)
|
||||||
|
|||||||
@@ -27,6 +27,52 @@ type Job struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunningJobActivity is the persisted activity summary used to detect jobs
|
||||||
|
// whose terminal message was lost. LastActivity is the newer of started_at and
|
||||||
|
// the most recent persisted log line; ephemeral progress events deliberately
|
||||||
|
// do not extend it.
|
||||||
|
type RunningJobActivity struct {
|
||||||
|
JobID string
|
||||||
|
HostID string
|
||||||
|
Kind string
|
||||||
|
StartedAt time.Time
|
||||||
|
LastActivity time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRunningJobActivity returns every running job with its latest persisted
|
||||||
|
// activity. Jobs without started_at are excluded because they have not actually
|
||||||
|
// entered the running state coherently and cannot be aged safely here.
|
||||||
|
func (s *Store) ListRunningJobActivity(ctx context.Context) ([]RunningJobActivity, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT j.id, j.host_id, j.kind, j.started_at,
|
||||||
|
COALESCE(MAX(l.ts), j.started_at) AS last_activity
|
||||||
|
FROM jobs j
|
||||||
|
LEFT JOIN job_logs l ON l.job_id = j.id
|
||||||
|
WHERE j.status = 'running' AND j.started_at IS NOT NULL
|
||||||
|
GROUP BY j.id, j.host_id, j.kind, j.started_at
|
||||||
|
ORDER BY j.started_at`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list running job activity: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var out []RunningJobActivity
|
||||||
|
for rows.Next() {
|
||||||
|
var item RunningJobActivity
|
||||||
|
var started, activity string
|
||||||
|
if err := rows.Scan(&item.JobID, &item.HostID, &item.Kind, &started, &activity); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan running job activity: %w", err)
|
||||||
|
}
|
||||||
|
item.StartedAt, _ = time.Parse(time.RFC3339Nano, started)
|
||||||
|
item.LastActivity, _ = time.Parse(time.RFC3339Nano, activity)
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: iterate running job activity: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreateJob inserts a queued job. The agent will mark it running
|
// CreateJob inserts a queued job. The agent will mark it running
|
||||||
// when it actually starts work. ScheduledID is set when the job
|
// when it actually starts work. ScheduledID is set when the job
|
||||||
// originates from a cron fire (actor_kind="schedule"); nil for
|
// originates from a cron fire (actor_kind="schedule"); nil for
|
||||||
|
|||||||
@@ -7,6 +7,48 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestListRunningJobActivity(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
hostID := makeSchedHost(t, s)
|
||||||
|
started := time.Now().UTC().Add(-8 * time.Hour).Truncate(time.Second)
|
||||||
|
|
||||||
|
for _, id := range []string{"running-idle", "running-active", "finished"} {
|
||||||
|
if err := s.CreateJob(ctx, Job{ID: id, HostID: hostID, Kind: "backup", ActorKind: "user", CreatedAt: started}); err != nil {
|
||||||
|
t.Fatalf("create %s: %v", id, err)
|
||||||
|
}
|
||||||
|
if err := s.MarkJobStarted(ctx, id, started); err != nil {
|
||||||
|
t.Fatalf("start %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activity := started.Add(7 * time.Hour)
|
||||||
|
if err := s.AppendJobLog(ctx, "running-active", 1, activity, "stdout", "still working"); err != nil {
|
||||||
|
t.Fatalf("append log: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.MarkJobFinished(ctx, "finished", "succeeded", 0, nil, "", activity); err != nil {
|
||||||
|
t.Fatalf("finish: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.ListRunningJobActivity(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list activity: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d rows, want 2: %+v", len(got), got)
|
||||||
|
}
|
||||||
|
byID := make(map[string]RunningJobActivity, len(got))
|
||||||
|
for _, row := range got {
|
||||||
|
byID[row.JobID] = row
|
||||||
|
}
|
||||||
|
if !byID["running-idle"].LastActivity.Equal(started) {
|
||||||
|
t.Errorf("idle last activity = %s, want %s", byID["running-idle"].LastActivity, started)
|
||||||
|
}
|
||||||
|
if !byID["running-active"].LastActivity.Equal(activity) {
|
||||||
|
t.Errorf("active last activity = %s, want %s", byID["running-active"].LastActivity, activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLatestJobByKind(t *testing.T) {
|
func TestLatestJobByKind(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
|
|||||||
@@ -82,10 +82,14 @@
|
|||||||
<div class="text-[12px] text-ok mb-3 mono">✓ saved</div>
|
<div class="text-[12px] text-ok mb-3 mono">✓ saved</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<p class="text-[12.5px] text-ink-mid leading-[1.6] mb-4 max-w-[640px]">
|
<p class="text-[12.5px] text-ink-mid leading-[1.6] mb-4 max-w-[640px]">
|
||||||
Only needed for rest-server repos that distinguish an append-only
|
Required for prune. On rest-server repos this is the
|
||||||
user (everyday backups) from a delete-capable user (prune /
|
delete-capable user, as distinct from the append-only user used
|
||||||
forget). For S3 / B2 / SFTP / local, leave this blank — the
|
for everyday backups. Note that <strong>forget</strong> always
|
||||||
everyday repo credentials handle prune too.
|
runs with the everyday repo credentials, so those must have
|
||||||
|
delete authority. For S3 / B2 / SFTP / local, enter the same
|
||||||
|
delete-capable repository credentials here if you want prune
|
||||||
|
enabled. <strong>Prune is skipped when admin credentials are
|
||||||
|
unset</strong>, on any backend.
|
||||||
</p>
|
</p>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user