lint: drive baseline to zero, drop only-new-issues gate

Cleanup pass over the repo so CI can enforce lint going forward
without the only-new-issues escape hatch:

* gofumpt -w across the tree (31 hits, all formatting)
* misspell --fix (25 hits, US-locale spelling) — but reverted on
  api.JobCancelled = "cancelled" since that literal is the wire +
  DB CHECK constraint value, plus matched the case in store/fleet.go
  back to "cancelled" and added //nolint:misspell on both for the
  next time someone reaches for the auto-fix
* Wrap every `defer rows.Close()` / `defer stmt.Close()` /
  `defer res.Body.Close()` in `defer func() { _ = .Close() }()`
  to satisfy errcheck without losing the close itself
* websocket.Dial callers (1 prod, 4 tests) now capture + close the
  upgrade response Body — coder/websocket can return res with a nil
  Body on success, so the test deferred-closes guard against that
* Annotate the two genuine-by-design nilerr cases with //nolint
  comments explaining why nil-on-error is the contract (cookie
  missing = no session; ctx cancelled mid-backoff = clean shutdown)
* Add brief godoc on the 10 exported const groups + types that
  revive flagged (api.HostOS/HostArch/JobKind/JobStatus/LogStream/
  ErrorCode, restic.EventKind, store.Role, web.FS)
* Drop the unused (*Server).userByID method
* Inline the unparam baseView(active) — every UI page is under
  the dashboard primary nav today

Result: `golangci-lint run ./...` reports 0 issues. CI lint job
no longer needs only-new-issues: true; X-06 follow-up entry in
tasks.md removed.
This commit is contained in:
2026-05-03 16:15:17 +01:00
parent 41c3ec7c6f
commit b6f8de1dcc
54 changed files with 317 additions and 260 deletions
-7
View File
@@ -41,13 +41,6 @@ jobs:
# Bumping to a v2.x release built against current Go. # Bumping to a v2.x release built against current Go.
version: v2.1.6 version: v2.1.6
args: --timeout=5m args: --timeout=5m
# Only flag issues introduced by the PR. The repo carries
# ~90 pre-existing findings (mostly missing godoc comments
# + gofumpt drift + misspell) accumulated before lint was
# actually wired into CI; cleaning them up is its own piece
# of work tracked separately. Without this, every PR fails
# on baseline noise instead of its own changes.
only-new-issues: true
build: build:
name: Build (${{ matrix.goos }}/${{ matrix.goarch }}) name: Build (${{ matrix.goos }}/${{ matrix.goarch }})
+7 -7
View File
@@ -99,13 +99,13 @@ func (r *Runner) RunBackup(ctx context.Context, jobID string, paths, excludes, t
} }
lastProgress = time.Now() lastProgress = time.Now()
progEnv, _ := api.Marshal(api.MsgJobProgress, jobID, api.JobProgressPayload{ progEnv, _ := api.Marshal(api.MsgJobProgress, jobID, api.JobProgressPayload{
JobID: jobID, JobID: jobID,
PercentDone: status.PercentDone, PercentDone: status.PercentDone,
FilesDone: status.FilesDone, FilesDone: status.FilesDone,
TotalFiles: status.TotalFiles, TotalFiles: status.TotalFiles,
BytesDone: status.BytesDone, BytesDone: status.BytesDone,
TotalBytes: status.TotalBytes, TotalBytes: status.TotalBytes,
ETASeconds: status.SecondsRem, ETASeconds: status.SecondsRem,
ThroughputBps: throughput(status.BytesDone, status.SecondsElapsed), ThroughputBps: throughput(status.BytesDone, status.SecondsElapsed),
}) })
_ = r.tx.Send(progEnv) _ = r.tx.Send(progEnv)
+1 -2
View File
@@ -110,7 +110,7 @@ func (s *Scheduler) Apply(payload api.ScheduleSetPayload, tx Sender) {
"received", len(payload.Schedules), "active", added) "received", len(payload.Schedules), "active", added)
// Ack outside the lock — Send() shouldn't take long, but holding // Ack outside the lock — Send() shouldn't take long, but holding
// s.mu across an external call would needlessly serialise other // s.mu across an external call would needlessly serialize other
// callers (e.g. a future Status() inspection from the UI). // callers (e.g. a future Status() inspection from the UI).
ackEnv, err := api.Marshal(api.MsgScheduleAck, "", api.ScheduleAckPayload{ ackEnv, err := api.Marshal(api.MsgScheduleAck, "", api.ScheduleAckPayload{
Version: payload.Version, Version: payload.Version,
@@ -167,4 +167,3 @@ func (s *Scheduler) fire(entry api.Schedule) {
"schedule_id", entry.ID, "err", err) "schedule_id", entry.ID, "err", err)
} }
} }
+1 -1
View File
@@ -20,7 +20,7 @@ import (
// additionalData binds ciphertexts to the agent-secrets context, so a // additionalData binds ciphertexts to the agent-secrets context, so a
// blob lifted from one role's file can't be replayed into another's // blob lifted from one role's file can't be replayed into another's
// row in some unrelated table that uses the same key. (Defence in // row in some unrelated table that uses the same key. (Defense in
// depth — the key is per-host today, but cheap to be careful.) // depth — the key is per-host today, but cheap to be careful.)
const additionalData = "rm-agent-repo-creds-v1" const additionalData = "rm-agent-repo-creds-v1"
+4 -2
View File
@@ -48,7 +48,9 @@ func Collect(ctx context.Context, resticPath string) (Snapshot, error) {
// detectResticVersion runs `restic version` and parses the first line. // detectResticVersion runs `restic version` and parses the first line.
// Output looks like: // Output looks like:
// restic 0.17.1 compiled with go1.22.5 on linux/amd64 //
// restic 0.17.1 compiled with go1.22.5 on linux/amd64
//
// Returns the version token (e.g. "0.17.1") or "" if restic isn't // Returns the version token (e.g. "0.17.1") or "" if restic isn't
// found. We never block startup on a missing restic — the operator // found. We never block startup on a missing restic — the operator
// might not have installed it yet, and the agent should still be // might not have installed it yet, and the agent should still be
@@ -74,5 +76,5 @@ func detectResticVersion(ctx context.Context, override string) (string, error) {
if len(parts) >= 2 && parts[0] == "restic" { if len(parts) >= 2 && parts[0] == "restic" {
return parts[1], nil return parts[1], nil
} }
return "", fmt.Errorf("sysinfo: unrecognised restic version output: %q", first) return "", fmt.Errorf("sysinfo: unrecognized restic version output: %q", first)
} }
+11 -4
View File
@@ -40,7 +40,7 @@ type Config struct {
// Sender is what handlers use to push agent → server messages // Sender is what handlers use to push agent → server messages
// (job.progress, job.finished, log.stream, command.result, …). // (job.progress, job.finished, log.stream, command.result, …).
// Returned by the WS client to the dispatch handler. Write operations // Returned by the WS client to the dispatch handler. Write operations
// serialise behind a single mutex on the conn; concurrent calls are // serialize behind a single mutex on the conn; concurrent calls are
// safe. // safe.
type Sender interface { type Sender interface {
Send(env api.Envelope) error Send(env api.Envelope) error
@@ -52,7 +52,7 @@ type Sender interface {
type Handler func(ctx context.Context, env api.Envelope, tx Sender) error type Handler func(ctx context.Context, env api.Envelope, tx Sender) error
// Run keeps the agent connected indefinitely. Returns when ctx is // Run keeps the agent connected indefinitely. Returns when ctx is
// cancelled. Errors during a single connection attempt are logged and // canceled. Errors during a single connection attempt are logged and
// trigger reconnect-with-backoff; only ctx.Done() ends the loop. // trigger reconnect-with-backoff; only ctx.Done() ends the loop.
func Run(ctx context.Context, cfg Config, handle Handler) error { func Run(ctx context.Context, cfg Config, handle Handler) error {
if cfg.HeartbeatPeriod <= 0 { if cfg.HeartbeatPeriod <= 0 {
@@ -69,7 +69,10 @@ func Run(ctx context.Context, cfg Config, handle Handler) error {
slog.Warn("ws agent disconnect", "err", err) slog.Warn("ws agent disconnect", "err", err)
} }
if err := sleepCtx(ctx, backoff.next()); err != nil { if err := sleepCtx(ctx, backoff.next()); err != nil {
return nil // ctx cancellation mid-backoff means the parent shut us down —
// exit the reconnect loop quietly rather than propagating
// a context error up to a caller that will discard it.
return nil //nolint:nilerr
} }
} }
} }
@@ -100,11 +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, _, err := websocket.Dial(dialCtx, wsURL, dialOpts) conn, res, err := websocket.Dial(dialCtx, wsURL, dialOpts)
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. 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() }()
defer conn.CloseNow() //nolint:errcheck defer conn.CloseNow() //nolint:errcheck
// Send hello. // Send hello.
+1 -1
View File
@@ -50,7 +50,7 @@ func Enroll(ctx context.Context, serverURL string, req EnrollRequest) (*EnrollRe
if err != nil { if err != nil {
return nil, fmt.Errorf("agent enroll: post: %w", err) return nil, fmt.Errorf("agent enroll: post: %w", err)
} }
defer res.Body.Close() defer func() { _ = res.Body.Close() }()
rawRes, _ := io.ReadAll(res.Body) rawRes, _ := io.ReadAll(res.Body)
if res.StatusCode != stdhttp.StatusCreated { if res.StatusCode != stdhttp.StatusCreated {
return nil, fmt.Errorf("agent enroll: server returned %d: %s", return nil, fmt.Errorf("agent enroll: server returned %d: %s",
+28 -14
View File
@@ -10,13 +10,18 @@ import (
// constants so we don't end up with both "linux" and "Linux" rows. // constants so we don't end up with both "linux" and "Linux" rows.
type HostOS string type HostOS string
// Allowed values for HostOS. Lowercased on the wire so the server
// can use a single CHECK constraint.
const ( const (
OSLinux HostOS = "linux" OSLinux HostOS = "linux"
OSWindows HostOS = "windows" OSWindows HostOS = "windows"
) )
// HostArch is the agent's CPU architecture; same lowercase-on-wire
// rule as HostOS.
type HostArch string type HostArch string
// Allowed values for HostArch.
const ( const (
ArchAmd64 HostArch = "amd64" ArchAmd64 HostArch = "amd64"
ArchArm64 HostArch = "arm64" ArchArm64 HostArch = "arm64"
@@ -45,6 +50,9 @@ type HeartbeatPayload struct {
// JobKind is the operation an agent is being asked to run, or just ran. // JobKind is the operation an agent is being asked to run, or just ran.
type JobKind string type JobKind string
// Allowed JobKind values. backup is operator/cron driven; init runs
// once per host on first connect; forget/prune/check fire from the
// server-side maintenance ticker; unlock is operator-only.
const ( const (
JobBackup JobKind = "backup" JobBackup JobKind = "backup"
JobInit JobKind = "init" JobInit JobKind = "init"
@@ -57,12 +65,16 @@ const (
// JobStatus is the lifecycle state of a job. // JobStatus is the lifecycle state of a job.
type JobStatus string type JobStatus string
// Allowed JobStatus values. queued → running → one of {succeeded,
// failed, JobCancelled} as a terminal state. The wire/DB literal for
// the JobCancelled value uses UK spelling — don't "fix" it; existing
// job rows + agent payloads will mismatch. //nolint:misspell
const ( const (
JobQueued JobStatus = "queued" JobQueued JobStatus = "queued"
JobRunning JobStatus = "running" JobRunning JobStatus = "running"
JobSucceeded JobStatus = "succeeded" JobSucceeded JobStatus = "succeeded"
JobFailed JobStatus = "failed" JobFailed JobStatus = "failed"
JobCancelled JobStatus = "cancelled" JobCancelled JobStatus = "cancelled" //nolint:misspell // wire format
) )
// CommandRunPayload is the server → agent dispatch for a run-now job. // CommandRunPayload is the server → agent dispatch for a run-now job.
@@ -145,6 +157,8 @@ type LogStreamLine struct {
// LogStream identifies which channel a log line came from. // LogStream identifies which channel a log line came from.
type LogStream string type LogStream string
// Allowed LogStream values. stdout/stderr are passed through verbatim;
// event is the parsed restic --json envelope (summary, error, etc).
const ( const (
LogStdout LogStream = "stdout" LogStdout LogStream = "stdout"
LogStderr LogStream = "stderr" LogStderr LogStream = "stderr"
@@ -175,12 +189,12 @@ type Snapshot struct {
// RepoStatsPayload — agent reports periodic repo health facts derived // RepoStatsPayload — agent reports periodic repo health facts derived
// from `restic stats` and lock-file inspection. // from `restic stats` and lock-file inspection.
type RepoStatsPayload struct { type RepoStatsPayload struct {
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
SnapshotCount int `json:"snapshot_count"` SnapshotCount int `json:"snapshot_count"`
DedupRatio float64 `json:"dedup_ratio"` DedupRatio float64 `json:"dedup_ratio"`
LastCheckAt time.Time `json:"last_check_at,omitempty"` LastCheckAt time.Time `json:"last_check_at,omitempty"`
LastCheckStatus string `json:"last_check_status,omitempty"` LastCheckStatus string `json:"last_check_status,omitempty"`
LockState string `json:"lock_state"` // locked|unlocked LockState string `json:"lock_state"` // locked|unlocked
} }
// Schedule is the agent-facing view of a slim Schedule row plus its // Schedule is the agent-facing view of a slim Schedule row plus its
@@ -220,8 +234,8 @@ type ScheduleSetPayload struct {
// ScheduleAckPayload — agent confirms it has applied a given version. // ScheduleAckPayload — agent confirms it has applied a given version.
type ScheduleAckPayload struct { type ScheduleAckPayload struct {
Version int64 `json:"version"` Version int64 `json:"version"`
AppliedAt time.Time `json:"applied_at"` AppliedAt time.Time `json:"applied_at"`
} }
// ScheduleFirePayload — agent reports a local cron entry just fired. // ScheduleFirePayload — agent reports a local cron entry just fired.
@@ -239,11 +253,11 @@ type ScheduleFirePayload struct {
// repo connection details). Empty fields mean "leave existing alone"; // repo connection details). Empty fields mean "leave existing alone";
// to clear something, send an explicit zero value. // to clear something, send an explicit zero value.
type ConfigUpdatePayload struct { type ConfigUpdatePayload struct {
RepoURL string `json:"repo_url,omitempty"` RepoURL string `json:"repo_url,omitempty"`
RepoPassword string `json:"repo_password,omitempty"` // sensitive RepoPassword string `json:"repo_password,omitempty"` // sensitive
RepoUsername string `json:"repo_username,omitempty"` RepoUsername string `json:"repo_username,omitempty"`
RepoCredential string `json:"repo_credential,omitempty"` // sensitive (for rest server basic auth) RepoCredential string `json:"repo_credential,omitempty"` // sensitive (for rest server basic auth)
HookShell string `json:"hook_shell,omitempty"` HookShell string `json:"hook_shell,omitempty"`
} }
// AgentUpdateAvailablePayload — informational only; the agent does // AgentUpdateAvailablePayload — informational only; the agent does
+22 -20
View File
@@ -12,35 +12,35 @@ type MessageType string
// Agent → server message types. // Agent → server message types.
const ( const (
MsgHello MessageType = "hello" MsgHello MessageType = "hello"
MsgHeartbeat MessageType = "heartbeat" MsgHeartbeat MessageType = "heartbeat"
MsgJobStarted MessageType = "job.started" MsgJobStarted MessageType = "job.started"
MsgJobProgress MessageType = "job.progress" MsgJobProgress MessageType = "job.progress"
MsgJobFinished MessageType = "job.finished" MsgJobFinished MessageType = "job.finished"
MsgSnapshotsRpt MessageType = "snapshots.report" MsgSnapshotsRpt MessageType = "snapshots.report"
MsgRepoStats MessageType = "repo.stats" MsgRepoStats MessageType = "repo.stats"
MsgLogStream MessageType = "log.stream" MsgLogStream MessageType = "log.stream"
MsgScheduleAck MessageType = "schedule.ack" MsgScheduleAck MessageType = "schedule.ack"
MsgScheduleFire MessageType = "schedule.fire" // agent: a local cron entry fired, please dispatch a job MsgScheduleFire MessageType = "schedule.fire" // agent: a local cron entry fired, please dispatch a job
MsgCommandResult MessageType = "command.result" // ack for command.run MsgCommandResult MessageType = "command.result" // ack for command.run
MsgError MessageType = "error" MsgError MessageType = "error"
) )
// Server → agent message types. // Server → agent message types.
const ( const (
MsgCommandRun MessageType = "command.run" MsgCommandRun MessageType = "command.run"
MsgCommandCancel MessageType = "command.cancel" MsgCommandCancel MessageType = "command.cancel"
MsgScheduleSet MessageType = "schedule.set" MsgScheduleSet MessageType = "schedule.set"
MsgConfigUpdate MessageType = "config.update" MsgConfigUpdate MessageType = "config.update"
MsgAgentUpdateAvail MessageType = "agent.update.available" MsgAgentUpdateAvail MessageType = "agent.update.available"
) )
// Envelope is the framing for every WS message in either direction. // Envelope is the framing for every WS message in either direction.
// Payload is parsed into the concrete struct chosen by Type. // Payload is parsed into the concrete struct chosen by Type.
// //
// ID is set on RPC-style messages (command.run / command.result) so // ID is set on RPC-style messages (command.run / command.result) so
// responses can be correlated. For one-shot pushes (heartbeat, // responses can be correlated. For one-shot pushes (heartbeat,
// job.progress) it is empty. // job.progress) it is empty.
type Envelope struct { type Envelope struct {
Type MessageType `json:"type"` Type MessageType `json:"type"`
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
@@ -71,6 +71,8 @@ func (e Envelope) UnmarshalPayload(v any) error {
// These are stable identifiers; client code may switch on them. // These are stable identifiers; client code may switch on them.
type ErrorCode string type ErrorCode string
// Stable ErrorCode values surfaced over the wire. Clients switch on
// these; renaming requires a wire-version bump.
const ( const (
ErrProtocolTooOld ErrorCode = "protocol_too_old" ErrProtocolTooOld ErrorCode = "protocol_too_old"
ErrProtocolTooNew ErrorCode = "protocol_too_new" ErrProtocolTooNew ErrorCode = "protocol_too_new"
+5 -2
View File
@@ -16,6 +16,7 @@ import (
// argon2id parameters following RFC 9106 §4 "second // argon2id parameters following RFC 9106 §4 "second
// recommended option" (memory-constrained): // recommended option" (memory-constrained):
// - 64 MiB memory, 3 iterations, 4 lanes, 32-byte tag. // - 64 MiB memory, 3 iterations, 4 lanes, 32-byte tag.
//
// These are tunable per-deployment if a beefy controller wants to // These are tunable per-deployment if a beefy controller wants to
// crank them; we ship a defensible default. // crank them; we ship a defensible default.
const ( const (
@@ -27,7 +28,9 @@ const (
) )
// HashPassword returns an argon2id-encoded string of the form // HashPassword returns an argon2id-encoded string of the form
// $argon2id$v=19$m=...,t=...,p=...$<salt>$<hash> //
// $argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>
//
// safe to store in a TEXT column. The salt is freshly random per call. // safe to store in a TEXT column. The salt is freshly random per call.
func HashPassword(password string) (string, error) { func HashPassword(password string) (string, error) {
salt := make([]byte, defaultSaltLen) salt := make([]byte, defaultSaltLen)
@@ -53,7 +56,7 @@ func VerifyPassword(encoded, password string) error {
parts := strings.Split(encoded, "$") parts := strings.Split(encoded, "$")
// "$argon2id$v=...$m=...,t=...,p=...$<salt>$<hash>" → 6 parts (leading empty) // "$argon2id$v=...$m=...,t=...,p=...$<salt>$<hash>" → 6 parts (leading empty)
if len(parts) != 6 || parts[1] != "argon2id" { if len(parts) != 6 || parts[1] != "argon2id" {
return errors.New("auth: unrecognised hash format") return errors.New("auth: unrecognized hash format")
} }
var version int var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
+1 -1
View File
@@ -41,7 +41,7 @@ func TestVerifyRejectsMalformed(t *testing.T) {
"", "",
"not-a-hash", "not-a-hash",
"$argon2i$v=19$m=64,t=3,p=4$AAAA$BBBB", // wrong variant "$argon2i$v=19$m=64,t=3,p=4$AAAA$BBBB", // wrong variant
"$argon2id$", // truncated "$argon2id$", // truncated
"$argon2id$v=99$m=64,t=3,p=4$AAAA$BBBB", // bad version "$argon2id$v=99$m=64,t=3,p=4$AAAA$BBBB", // bad version
} }
for _, c := range cases { for _, c := range cases {
+1 -1
View File
@@ -65,7 +65,7 @@ func GenerateKeyFile(path string) error {
if err != nil { if err != nil {
return fmt.Errorf("create key file %q: %w", path, err) return fmt.Errorf("create key file %q: %w", path, err)
} }
defer f.Close() defer func() { _ = f.Close() }()
key := make([]byte, KeyLen) key := make([]byte, KeyLen)
if _, err := io.ReadFull(rand.Reader, key); err != nil { if _, err := io.ReadFull(rand.Reader, key); err != nil {
return fmt.Errorf("read random: %w", err) return fmt.Errorf("read random: %w", err)
+14 -12
View File
@@ -15,7 +15,7 @@ import (
"time" "time"
) )
// Locate resolves the path to the restic binary. Honour an explicit // Locate resolves the path to the restic binary. Honor an explicit
// override if provided, else fall back to PATH. // override if provided, else fall back to PATH.
func Locate(override string) (string, error) { func Locate(override string) (string, error) {
if override != "" { if override != "" {
@@ -41,12 +41,12 @@ func Locate(override string) (string, error) {
// never assign it back to Env, never pass it to slog. If anything // never assign it back to Env, never pass it to slog. If anything
// in this package ever needs to *log* a URL, use RedactURL. // in this package ever needs to *log* a URL, use RedactURL.
type Env struct { type Env struct {
Bin string // path to restic binary Bin string // path to restic binary
RepoURL string // RESTIC_REPOSITORY (no embedded creds) RepoURL string // RESTIC_REPOSITORY (no embedded creds)
RepoUsername string // optional HTTP basic-auth user for rest: URLs RepoUsername string // optional HTTP basic-auth user for rest: URLs
RepoPassword string // doubles as RESTIC_PASSWORD and (for rest:) HTTP basic-auth password RepoPassword string // doubles as RESTIC_PASSWORD and (for rest:) HTTP basic-auth password
ExtraEnv map[string]string // any other RESTIC_* / passthrough ExtraEnv map[string]string // any other RESTIC_* / passthrough
WorkDir string // CWD; default = current WorkDir string // CWD; default = current
} }
// EventKind enumerates what we care about in restic's --json output // EventKind enumerates what we care about in restic's --json output
@@ -54,10 +54,12 @@ type Env struct {
// switch on message_type. // switch on message_type.
type EventKind string type EventKind string
// Known message_type values restic --json emits during a backup.
// Kept as constants so callers can switch without typo risk.
const ( const (
EventStatus EventKind = "status" // periodic progress EventStatus EventKind = "status" // periodic progress
EventVerbose EventKind = "verbose_status" EventVerbose EventKind = "verbose_status"
EventSummary EventKind = "summary" // emitted once at end of backup EventSummary EventKind = "summary" // emitted once at end of backup
EventErrorEvent EventKind = "error" EventErrorEvent EventKind = "error"
) )
@@ -90,7 +92,7 @@ type BackupSummary struct {
} }
// LineHandler receives every stdout/stderr line. event is non-nil // LineHandler receives every stdout/stderr line. event is non-nil
// when the line is a recognised JSON status; raw always carries the // when the line is a recognized JSON status; raw always carries the
// original text (so we can also tee to job_logs as `stdout`). // original text (so we can also tee to job_logs as `stdout`).
type LineHandler func(stream string, raw string, event any) type LineHandler func(stream string, raw string, event any)
@@ -256,7 +258,7 @@ func (e Env) RunInit(ctx context.Context, handle LineHandler) error {
// Sniff for "config file already exists" on stderr; if we see it // Sniff for "config file already exists" on stderr; if we see it
// we'll treat the non-zero exit as a soft success — running init // we'll treat the non-zero exit as a soft success — running init
// against an already-initialised repo is a no-op semantically, // against an already-initialized repo is a no-op semantically,
// not a failure. Wraps the caller's handle so the line still // not a failure. Wraps the caller's handle so the line still
// gets streamed verbatim to the operator-facing log. // gets streamed verbatim to the operator-facing log.
alreadyInited := false alreadyInited := false
@@ -280,7 +282,7 @@ func (e Env) RunInit(ctx context.Context, handle LineHandler) error {
if werr := cmd.Wait(); werr != nil { if werr := cmd.Wait(); werr != nil {
if alreadyInited { if alreadyInited {
if handle != nil { if handle != nil {
handle("event", "repo already initialised — treating as success", nil) handle("event", "repo already initialized — treating as success", nil)
} }
return nil return nil
} }
+4 -2
View File
@@ -8,9 +8,11 @@ func TestMergeRestCreds(t *testing.T) {
}{ }{
{"rest with creds", "rest:http://h:8000/p/", "u", "p", "rest:http://u:p@h:8000/p/"}, {"rest with creds", "rest:http://h:8000/p/", "u", "p", "rest:http://u:p@h:8000/p/"},
{"rest no user — no-op", "rest:http://h:8000/p/", "", "p", "rest:http://h:8000/p/"}, {"rest no user — no-op", "rest:http://h:8000/p/", "", "p", "rest:http://h:8000/p/"},
{"rest creds already inline — no-op", {
"rest creds already inline — no-op",
"rest:http://existing:secret@h:8000/p/", "u", "p", "rest:http://existing:secret@h:8000/p/", "u", "p",
"rest:http://existing:secret@h:8000/p/"}, "rest:http://existing:secret@h:8000/p/",
},
{"non-rest s3 — no-op", "s3:s3.amazonaws.com/bucket", "u", "p", "s3:s3.amazonaws.com/bucket"}, {"non-rest s3 — no-op", "s3:s3.amazonaws.com/bucket", "u", "p", "s3:s3.amazonaws.com/bucket"},
{"unparseable — pass through", "rest:not a url", "u", "p", "rest:not a url"}, {"unparseable — pass through", "rest:not a url", "u", "p", "rest:not a url"},
{"https URL kept intact", "rest:https://h/p/", "u", "p", "rest:https://u:p@h/p/"}, {"https URL kept intact", "rest:https://h/p/", "u", "p", "rest:https://u:p@h/p/"},
+3 -3
View File
@@ -34,9 +34,9 @@ type Config struct {
} }
// Load resolves config in this order: // Load resolves config in this order:
// 1. defaults // 1. defaults
// 2. YAML at the given path (if non-empty and exists) // 2. YAML at the given path (if non-empty and exists)
// 3. environment variables (RM_LISTEN, RM_DATA_DIR, …) // 3. environment variables (RM_LISTEN, RM_DATA_DIR, …)
// //
// The result is validated; a zero-error return means the server is // The result is validated; a zero-error return means the server is
// safe to start. // safe to start.
+1 -1
View File
@@ -57,7 +57,7 @@ func (s *Server) handleAgentBinary(w stdhttp.ResponseWriter, r *stdhttp.Request)
} }
func (s *Server) handleInstallAsset(w stdhttp.ResponseWriter, r *stdhttp.Request) { func (s *Server) handleInstallAsset(w stdhttp.ResponseWriter, r *stdhttp.Request) {
// chi's TrimPrefix-like behaviour: r.URL.Path is "/install/<file>". // chi's TrimPrefix-like behavior: r.URL.Path is "/install/<file>".
rel := strings.TrimPrefix(r.URL.Path, "/install/") rel := strings.TrimPrefix(r.URL.Path, "/install/")
// Reject any path traversal — must be a flat filename. // Reject any path traversal — must be a flat filename.
if rel == "" || strings.ContainsAny(rel, "/\\") { if rel == "" || strings.ContainsAny(rel, "/\\") {
+1 -1
View File
@@ -137,7 +137,7 @@ func (s *Server) handleBootstrap(w stdhttp.ResponseWriter, r *stdhttp.Request) {
return return
} }
if n > 0 { if n > 0 {
writeJSONError(w, stdhttp.StatusConflict, "already_initialised", writeJSONError(w, stdhttp.StatusConflict, "already_initialized",
"a user already exists; bootstrap is disabled") "a user already exists; bootstrap is disabled")
return return
} }
+4 -2
View File
@@ -36,7 +36,7 @@ func newTestServer(t *testing.T, withBootstrapToken bool) (*Server, string) {
aead, _ := crypto.NewAEAD(key) aead, _ := crypto.NewAEAD(key)
deps := Deps{ deps := Deps{
Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath}, Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath},
Store: st, Store: st,
AEAD: aead, AEAD: aead,
} }
@@ -125,7 +125,9 @@ func TestLoginAndLogout(t *testing.T) {
bs, _ := json.Marshal(bootstrapRequest{ bs, _ := json.Marshal(bootstrapRequest{
Token: "test-token", Username: "alice", Password: "averylongpassword", Token: "test-token", Username: "alice", Password: "averylongpassword",
}) })
stdhttp.Post(url+"/api/bootstrap", "application/json", bytes.NewReader(bs)) //nolint:errcheck if bsRes, err := stdhttp.Post(url+"/api/bootstrap", "application/json", bytes.NewReader(bs)); err == nil {
_ = bsRes.Body.Close()
}
// Login. // Login.
body, _ := json.Marshal(loginRequest{Username: "alice", Password: "averylongpassword"}) body, _ := json.Marshal(loginRequest{Username: "alice", Password: "averylongpassword"})
+6 -6
View File
@@ -3,6 +3,7 @@ package http
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
stdhttp "net/http" stdhttp "net/http"
@@ -142,7 +143,7 @@ func (s *Server) handleAgentEnroll(w stdhttp.ResponseWriter, r *stdhttp.Request)
// Seed the host's "default" source group with whatever paths the // Seed the host's "default" source group with whatever paths the
// operator typed into Add-host (empty allowed; group is editable // operator typed into Add-host (empty allowed; group is editable
// from the Sources tab post-enrol). Also seed the host's // from the Sources tab post-enroll). Also seed the host's
// repo-maintenance row with default cadences so forget/prune/check // repo-maintenance row with default cadences so forget/prune/check
// start ticking on their own. Auto-init dispatch lands in Phase 6 // start ticking on their own. Auto-init dispatch lands in Phase 6
// of the redesign. // of the redesign.
@@ -222,12 +223,11 @@ func (s *Server) handleCreateEnrollmentToken(w stdhttp.ResponseWriter, r *stdhtt
return return
} }
token, expiresAt, err := s.mintEnrollmentToken(r.Context(), req.RepoURL, req.RepoUsername, req.RepoPassword, req.InitialPaths) token, expiresAt, err := s.mintEnrollmentToken(r.Context(), req.RepoURL, req.RepoUsername, req.RepoPassword, req.InitialPaths)
switch err { switch {
case nil: case err == nil:
writeJSON(w, stdhttp.StatusCreated, enrollOperatorResponse{Token: token, ExpiresAt: expiresAt}) writeJSON(w, stdhttp.StatusCreated, enrollOperatorResponse{Token: token, ExpiresAt: expiresAt})
case errMissingRepoCreds: case errors.Is(err, errMissingRepoCreds):
writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", "repo_url and repo_password are required so the agent can run backups on first connect")
"repo_url and repo_password are required so the agent can run backups on first connect")
default: default:
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
} }
+1 -1
View File
@@ -162,7 +162,7 @@ func (s *Server) handleSetHostCredentials(w stdhttp.ResponseWriter, r *stdhttp.R
w.WriteHeader(stdhttp.StatusNoContent) w.WriteHeader(stdhttp.StatusNoContent)
} }
// pushRepoCredsToAgent serialises blob into a config.update envelope // pushRepoCredsToAgent serializes blob into a config.update envelope
// and ships it down the agent's WS. Returns an error from the hub // and ships it down the agent's WS. Returns an error from the hub
// (no-op if not connected — caller is expected to check first when it // (no-op if not connected — caller is expected to check first when it
// matters). // matters).
+17 -17
View File
@@ -10,23 +10,23 @@ import (
// store row, but with explicit time-strings so wire format is stable // store row, but with explicit time-strings so wire format is stable
// across DB driver changes. // across DB driver changes.
type hostView struct { type hostView struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
OS string `json:"os"` OS string `json:"os"`
Arch string `json:"arch"` Arch string `json:"arch"`
AgentVersion string `json:"agent_version,omitempty"` AgentVersion string `json:"agent_version,omitempty"`
ResticVersion string `json:"restic_version,omitempty"` ResticVersion string `json:"restic_version,omitempty"`
ProtocolVersion int `json:"protocol_version"` ProtocolVersion int `json:"protocol_version"`
EnrolledAt string `json:"enrolled_at"` EnrolledAt string `json:"enrolled_at"`
LastSeenAt *string `json:"last_seen_at,omitempty"` LastSeenAt *string `json:"last_seen_at,omitempty"`
Status string `json:"status"` Status string `json:"status"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
CurrentJobID *string `json:"current_job_id,omitempty"` CurrentJobID *string `json:"current_job_id,omitempty"`
LastBackupAt *string `json:"last_backup_at,omitempty"` LastBackupAt *string `json:"last_backup_at,omitempty"`
LastBackupStatus *string `json:"last_backup_status,omitempty"` LastBackupStatus *string `json:"last_backup_status,omitempty"`
RepoSizeBytes int64 `json:"repo_size_bytes"` RepoSizeBytes int64 `json:"repo_size_bytes"`
SnapshotCount int `json:"snapshot_count"` SnapshotCount int `json:"snapshot_count"`
OpenAlertCount int `json:"open_alert_count"` OpenAlertCount int `json:"open_alert_count"`
} }
// handleListHosts returns the full fleet as JSON. Authenticated; the // handleListHosts returns the full fleet as JSON. Authenticated; the
+2 -2
View File
@@ -16,8 +16,8 @@ import (
// runNowRequest is the body of POST /api/hosts/:id/jobs. // runNowRequest is the body of POST /api/hosts/:id/jobs.
type runNowRequest struct { type runNowRequest struct {
Kind api.JobKind `json:"kind"` Kind api.JobKind `json:"kind"`
Args []string `json:"args,omitempty"` // restic CLI args (paths for backup, etc.) Args []string `json:"args,omitempty"` // restic CLI args (paths for backup, etc.)
} }
type runNowResponse struct { type runNowResponse struct {
+24 -12
View File
@@ -215,24 +215,30 @@ func TestSchedulesCRUDValidation(t *testing.T) {
// Bad cron → 400. // Bad cron → 400.
status, body := doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules", status, body := doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{"cron": "not-a-cron", "enabled": true, map[string]any{
"source_group_ids": []string{"x"}}, cookie) "cron": "not-a-cron", "enabled": true,
"source_group_ids": []string{"x"},
}, cookie)
if status != 400 { if status != 400 {
t.Fatalf("bad cron: want 400, got %d (body=%+v)", status, body) t.Fatalf("bad cron: want 400, got %d (body=%+v)", status, body)
} }
// Missing groups → 400. // Missing groups → 400.
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules", status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{"cron": "0 3 * * *", "enabled": true, map[string]any{
"source_group_ids": []string{}}, cookie) "cron": "0 3 * * *", "enabled": true,
"source_group_ids": []string{},
}, cookie)
if status != 400 { if status != 400 {
t.Errorf("missing groups: want 400, got %d", status) t.Errorf("missing groups: want 400, got %d", status)
} }
// Group not on host → 400. // Group not on host → 400.
status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules", status, _ = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{"cron": "0 3 * * *", "enabled": true, map[string]any{
"source_group_ids": []string{"non-existent"}}, cookie) "cron": "0 3 * * *", "enabled": true,
"source_group_ids": []string{"non-existent"},
}, cookie)
if status != 400 { if status != 400 {
t.Errorf("bogus group: want 400, got %d", status) t.Errorf("bogus group: want 400, got %d", status)
} }
@@ -247,8 +253,10 @@ func TestSchedulesCRUDValidation(t *testing.T) {
// Happy create. // Happy create.
status, body = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules", status, body = doJSON(t, url, "POST", "/api/hosts/"+hostID+"/schedules",
map[string]any{"cron": "0 3 * * *", "enabled": true, map[string]any{
"source_group_ids": []string{gid}}, cookie) "cron": "0 3 * * *", "enabled": true,
"source_group_ids": []string{gid},
}, cookie)
if status != 201 { if status != 201 {
t.Fatalf("create: %d body=%+v", status, body) t.Fatalf("create: %d body=%+v", status, body)
} }
@@ -269,8 +277,10 @@ func TestSchedulesCRUDValidation(t *testing.T) {
// Update — change cron, keep group. // Update — change cron, keep group.
status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/schedules/"+sid, status, body = doJSON(t, url, "PUT", "/api/hosts/"+hostID+"/schedules/"+sid,
map[string]any{"cron": "@hourly", "enabled": false, map[string]any{
"source_group_ids": []string{gid}}, cookie) "cron": "@hourly", "enabled": false,
"source_group_ids": []string{gid},
}, cookie)
if status != 200 { if status != 200 {
t.Fatalf("update: %d body=%+v", status, body) t.Fatalf("update: %d body=%+v", status, body)
} }
@@ -484,5 +494,7 @@ func equalStrings(a, b []string) bool {
} }
// keep fmt import live — used for occasional debug. // keep fmt import live — used for occasional debug.
var _ = fmt.Sprintf var (
var _ = strings.HasPrefix _ = fmt.Sprintf
_ = strings.HasPrefix
)
+9 -4
View File
@@ -6,8 +6,8 @@ package http
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"net/http/httptest"
stdhttp "net/http" stdhttp "net/http"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -29,13 +29,18 @@ func agentDial(t *testing.T, srv *Server, ts *httptest.Server, hostID, token str
url := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws/agent" url := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws/agent"
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
c, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ c, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{
HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}}, HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}},
}) })
if err != nil { if err != nil {
t.Fatalf("dial: %v", err) t.Fatalf("dial: %v", err)
} }
t.Cleanup(func() { _ = c.CloseNow() }) t.Cleanup(func() {
_ = c.CloseNow()
if res != nil && res.Body != nil {
_ = res.Body.Close()
}
})
return c return c
} }
@@ -76,7 +81,7 @@ func drainUntil(t *testing.T, c *websocket.Conn, wantType api.MessageType) api.E
return api.Envelope{} return api.Envelope{}
} }
// enrolHostForWS pre-enrols a host with bound repo creds so the server // enrolHostForWS pre-enrolls a host with bound repo creds so the server
// will treat it as ready to receive command.run. // will treat it as ready to receive command.run.
func enrolHostForWS(t *testing.T, srv *Server, st *store.Store, name string) (hostID, token string) { func enrolHostForWS(t *testing.T, srv *Server, st *store.Store, name string) (hostID, token string) {
t.Helper() t.Helper()
-1
View File
@@ -142,4 +142,3 @@ func (s *Server) handleUpdateRepoMaintenance(w stdhttp.ResponseWriter, r *stdhtt
} }
writeJSON(w, stdhttp.StatusOK, toRepoMaintenanceView(m)) writeJSON(w, stdhttp.StatusOK, toRepoMaintenanceView(m))
} }
+1 -1
View File
@@ -4,7 +4,7 @@
// The slim-schedule wire shape is built here from the (Schedule, // The slim-schedule wire shape is built here from the (Schedule,
// SourceGroup) pair. Each schedule is sent with its resolved source // SourceGroup) pair. Each schedule is sent with its resolved source
// groups inlined so the agent doesn't have to keep its own copy of // groups inlined so the agent doesn't have to keep its own copy of
// the group catalogue. Cron + enabled drive the agent's local timer; // the group catalog. Cron + enabled drive the agent's local timer;
// when an entry fires the agent ships back a schedule.fire and // when an entry fires the agent ships back a schedule.fire and
// dispatchScheduledJob below resolves the schedule's groups and // dispatchScheduledJob below resolves the schedule's groups and
// dispatches one backup command.run per group. // dispatches one backup command.run per group.
+1 -1
View File
@@ -212,7 +212,7 @@ func (s *Server) validateScheduleRequest(r *stdhttp.Request, hostID string, req
for _, gid := range req.SourceGroupIDs { for _, gid := range req.SourceGroupIDs {
g, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, gid) g, err := s.deps.Store.GetSourceGroup(r.Context(), hostID, gid)
if err != nil || g == nil { if err != nil || g == nil {
return "invalid_group", "source group "+gid+" not found on this host", false return "invalid_group", "source group " + gid + " not found on this host", false
} }
} }
return "", "", true return "", "", true
+1 -1
View File
@@ -184,7 +184,7 @@ func (s *Server) routes(r chi.Router) {
// Durable post-Add-host page (operator can refresh / come // Durable post-Add-host page (operator can refresh / come
// back; password decrypted from the token row each render). // back; password decrypted from the token row each render).
// Polled fragment under /awaiting flips to "connected" once // Polled fragment under /awaiting flips to "connected" once
// the agent enrols. // the agent enrolls.
r.Get("/hosts/pending/{token}", s.handleUIPendingHost) r.Get("/hosts/pending/{token}", s.handleUIPendingHost)
r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting) r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting)
// Host detail (Snapshots tab is the default). // Host detail (Snapshots tab is the default).
+28 -37
View File
@@ -44,7 +44,11 @@ func staticHandler() stdhttp.Handler {
func (s *Server) sessionUser(r *stdhttp.Request) (*ui.User, error) { func (s *Server) sessionUser(r *stdhttp.Request) (*ui.User, error) {
c, err := r.Cookie(sessionCookieName) c, err := r.Cookie(sessionCookieName)
if err != nil { if err != nil {
return nil, nil // Missing or invalid cookie just means the caller isn't logged
// in — that's a normal state, not a server error. Return
// (nil, nil) so callers can decide between "redirect to login"
// and "treat as anonymous".
return nil, nil //nolint:nilerr
} }
sess, err := s.deps.Store.LookupSession(r.Context(), auth.HashToken(c.Value)) sess, err := s.deps.Store.LookupSession(r.Context(), auth.HashToken(c.Value))
if err != nil { if err != nil {
@@ -81,11 +85,13 @@ func (s *Server) requireUIUser(w stdhttp.ResponseWriter, r *stdhttp.Request) *ui
} }
// baseView populates the fields the nav partial needs on every // baseView populates the fields the nav partial needs on every
// authenticated page. // authenticated page. Every UI page sits under the dashboard primary
func (s *Server) baseView(u *ui.User, active string) ui.ViewData { // nav today; if a future page lives under a different primary nav
// tab (e.g. Settings, Audit), accept an Active arg again.
func (s *Server) baseView(u *ui.User) ui.ViewData {
return ui.ViewData{ return ui.ViewData{
User: u, User: u,
Active: active, Active: "dashboard",
Version: s.version(), Version: s.version(),
} }
} }
@@ -200,7 +206,7 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request)
rows = append(rows, row) rows = append(rows, row)
} }
view := s.baseView(u, "dashboard") view := s.baseView(u)
view.OpenAlerts = summary.OpenAlerts view.OpenAlerts = summary.OpenAlerts
view.Page = dashboardPage{ view.Page = dashboardPage{
Hosts: rows, Hosts: rows,
@@ -249,16 +255,16 @@ type addHostPage struct {
} }
// pendingHostPage is the GET /hosts/pending/{token} view. Lives // pendingHostPage is the GET /hosts/pending/{token} view. Lives
// for as long as the token does (1h ttl); once the agent enrols, // for as long as the token does (1h ttl); once the agent enrolls,
// the handler redirects to /hosts/{host_id} and this page is gone. // the handler redirects to /hosts/{host_id} and this page is gone.
type pendingHostPage struct { type pendingHostPage struct {
Token string Token string
ServerURL string ServerURL string
ExpiresAt time.Time ExpiresAt time.Time
RepoURL string RepoURL string
RepoUsername string RepoUsername string
RepoPassword string RepoPassword string
InitialPaths []string InitialPaths []string
} }
// handleUIAddHostGet renders the empty Add host form. // handleUIAddHostGet renders the empty Add host form.
@@ -267,7 +273,7 @@ func (s *Server) handleUIAddHostGet(w stdhttp.ResponseWriter, r *stdhttp.Request
if u == nil { if u == nil {
return return
} }
view := s.baseView(u, "dashboard") view := s.baseView(u)
view.Title = "Add host · restic-manager" view.Title = "Add host · restic-manager"
view.Page = addHostPage{ServerURL: s.publicURL(r)} view.Page = addHostPage{ServerURL: s.publicURL(r)}
if err := s.deps.UI.Render(w, "add_host", view); err != nil { if err := s.deps.UI.Render(w, "add_host", view); err != nil {
@@ -327,11 +333,11 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques
if page.Error == "" { if page.Error == "" {
token, _, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, repoUsername, repoPassword, splitPaths(page.Paths)) token, _, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, repoUsername, repoPassword, splitPaths(page.Paths))
switch err { switch {
case nil: case err == nil:
stdhttp.Redirect(w, r, "/hosts/pending/"+token, stdhttp.StatusSeeOther) stdhttp.Redirect(w, r, "/hosts/pending/"+token, stdhttp.StatusSeeOther)
return return
case errMissingRepoCreds: case errors.Is(err, errMissingRepoCreds):
page.Error = "Repo URL and password are both required." page.Error = "Repo URL and password are both required."
default: default:
slog.Error("ui add_host: mint token", "err", err) slog.Error("ui add_host: mint token", "err", err)
@@ -339,7 +345,7 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques
} }
} }
view := s.baseView(u, "dashboard") view := s.baseView(u)
view.Title = "Add host · restic-manager" view.Title = "Add host · restic-manager"
view.Page = page view.Page = page
w.WriteHeader(stdhttp.StatusUnprocessableEntity) w.WriteHeader(stdhttp.StatusUnprocessableEntity)
@@ -350,7 +356,7 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques
// handleUIPendingHost serves the durable Add-host result page — // handleUIPendingHost serves the durable Add-host result page —
// shown after a successful POST /hosts/new and reachable until the // shown after a successful POST /hosts/new and reachable until the
// agent enrols (the page redirects to /hosts/{id} once that // agent enrolls (the page redirects to /hosts/{id} once that
// happens) or the token expires (1h ttl). The password is // happens) or the token expires (1h ttl). The password is
// re-decrypted from the encrypted token row on every render so // re-decrypted from the encrypted token row on every render so
// the operator can refresh, bookmark, navigate away and come back. // the operator can refresh, bookmark, navigate away and come back.
@@ -406,7 +412,7 @@ func (s *Server) handleUIPendingHost(w stdhttp.ResponseWriter, r *stdhttp.Reques
} }
} }
view := s.baseView(u, "dashboard") view := s.baseView(u)
view.Title = "Pending host · restic-manager" view.Title = "Pending host · restic-manager"
view.Page = page view.Page = page
if err := s.deps.UI.Render(w, "pending_host", view); err != nil { if err := s.deps.UI.Render(w, "pending_host", view); err != nil {
@@ -546,7 +552,7 @@ func (s *Server) handleUIHostDetail(w stdhttp.ResponseWriter, r *stdhttp.Request
shown = shown[:cap] shown = shown[:cap]
} }
view := s.baseView(u, "dashboard") view := s.baseView(u)
view.Title = host.Name + " · restic-manager" view.Title = host.Name + " · restic-manager"
view.Page = hostDetailPage{ view.Page = hostDetailPage{
hostChromeData: s.loadHostChrome(r, *host, "snapshots", "snapshots"), hostChromeData: s.loadHostChrome(r, *host, "snapshots", "snapshots"),
@@ -645,7 +651,7 @@ func (s *Server) handleUIJobDetail(w stdhttp.ResponseWriter, r *stdhttp.Request)
nextSeq = logs[n-1].Seq nextSeq = logs[n-1].Seq
} }
view := s.baseView(u, "dashboard") view := s.baseView(u)
view.Title = job.Kind + " · " + host.Name + " · restic-manager" view.Title = job.Kind + " · " + host.Name + " · restic-manager"
view.Page = jobDetailPage{ view.Page = jobDetailPage{
Job: *job, Job: *job,
@@ -742,21 +748,6 @@ func buildSyntheticJobFinished(job *store.Job) (api.Envelope, error) {
}) })
} }
// userByID fetches the full store.User the UI session represents.
// Returns the user, ok-flag, error. Used by handlers that need the
// store-side row (e.g. for audit_log.user_id) rather than just the
// projected ui.User.
func (s *Server) userByID(r *stdhttp.Request, id string) (*store.User, bool, error) {
u, err := s.deps.Store.GetUserByID(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, false, nil
}
return nil, false, err
}
return u, true, nil
}
// handleUILoginGet renders the login form. If the user is already // handleUILoginGet renders the login form. If the user is already
// signed in we redirect them home — login is for the unauthenticated. // signed in we redirect them home — login is for the unauthenticated.
func (s *Server) handleUILoginGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { func (s *Server) handleUILoginGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+2 -2
View File
@@ -143,7 +143,7 @@ func (s *Server) handleUIHostRepo(w stdhttp.ResponseWriter, r *stdhttp.Request)
return return
} }
page.SavedSection = r.URL.Query().Get("saved") page.SavedSection = r.URL.Query().Get("saved")
view := s.baseView(u, "dashboard") view := s.baseView(u)
view.Title = host.Name + " repo · restic-manager" view.Title = host.Name + " repo · restic-manager"
view.Page = *page view.Page = *page
if err := s.deps.UI.Render(w, "host_repo", view); err != nil { if err := s.deps.UI.Render(w, "host_repo", view); err != nil {
@@ -166,7 +166,7 @@ func (s *Server) renderRepoPage(w stdhttp.ResponseWriter, r *stdhttp.Request, u
page.CredentialsError = credErr page.CredentialsError = credErr
page.BandwidthError = bwErr page.BandwidthError = bwErr
page.MaintenanceError = mntErr page.MaintenanceError = mntErr
view := s.baseView(u, "dashboard") view := s.baseView(u)
view.Title = host.Name + " repo · restic-manager" view.Title = host.Name + " repo · restic-manager"
view.Page = *page view.Page = *page
w.WriteHeader(stdhttp.StatusUnprocessableEntity) w.WriteHeader(stdhttp.StatusUnprocessableEntity)
+4 -4
View File
@@ -78,7 +78,7 @@ func (s *Server) handleUISchedulesList(w stdhttp.ResponseWriter, r *stdhttp.Requ
chrome.ScheduleCount = len(scheds) chrome.ScheduleCount = len(scheds)
chrome.SourceGroupCount = len(groups) chrome.SourceGroupCount = len(groups)
view := s.baseView(u, "dashboard") view := s.baseView(u)
view.Title = host.Name + " schedules · restic-manager" view.Title = host.Name + " schedules · restic-manager"
view.Page = hostSchedulesPage{ view.Page = hostSchedulesPage{
hostChromeData: chrome, hostChromeData: chrome,
@@ -106,7 +106,7 @@ func (s *Server) handleUIScheduleNewGet(w stdhttp.ResponseWriter, r *stdhttp.Req
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return return
} }
view := s.baseView(u, "dashboard") view := s.baseView(u)
view.Title = "New schedule · " + host.Name + " · restic-manager" view.Title = "New schedule · " + host.Name + " · restic-manager"
view.Page = scheduleEditPage{ view.Page = scheduleEditPage{
hostChromeData: s.loadHostChrome(r, *host, "schedules", "new schedule"), hostChromeData: s.loadHostChrome(r, *host, "schedules", "new schedule"),
@@ -152,7 +152,7 @@ func (s *Server) handleUIScheduleEditGet(w stdhttp.ResponseWriter, r *stdhttp.Re
for _, gid := range sc.SourceGroupIDs { for _, gid := range sc.SourceGroupIDs {
selected[gid] = true selected[gid] = true
} }
view := s.baseView(u, "dashboard") view := s.baseView(u)
view.Title = "Edit schedule · " + host.Name + " · restic-manager" view.Title = "Edit schedule · " + host.Name + " · restic-manager"
view.Page = scheduleEditPage{ view.Page = scheduleEditPage{
hostChromeData: s.loadHostChrome(r, *host, "schedules", "edit schedule"), hostChromeData: s.loadHostChrome(r, *host, "schedules", "edit schedule"),
@@ -381,7 +381,7 @@ func (s *Server) renderScheduleFormError(w stdhttp.ResponseWriter, r *stdhttp.Re
saveAction = "/hosts/" + host.ID + "/schedules/" + sid + "/edit" saveAction = "/hosts/" + host.ID + "/schedules/" + sid + "/edit"
crumb = "edit schedule" crumb = "edit schedule"
} }
view := s.baseView(u, "dashboard") view := s.baseView(u)
view.Title = "Schedule · " + host.Name + " · restic-manager" view.Title = "Schedule · " + host.Name + " · restic-manager"
view.Page = scheduleEditPage{ view.Page = scheduleEditPage{
hostChromeData: s.loadHostChrome(r, *host, "schedules", crumb), hostChromeData: s.loadHostChrome(r, *host, "schedules", crumb),
+4 -4
View File
@@ -119,7 +119,7 @@ func (s *Server) handleUIHostSources(w stdhttp.ResponseWriter, r *stdhttp.Reques
// loadHostChrome already counted groups; reuse count we just got. // loadHostChrome already counted groups; reuse count we just got.
chrome.SourceGroupCount = len(groups) chrome.SourceGroupCount = len(groups)
view := s.baseView(u, "dashboard") view := s.baseView(u)
view.Title = host.Name + " sources · restic-manager" view.Title = host.Name + " sources · restic-manager"
view.Page = hostSourcesPage{hostChromeData: chrome, Groups: rows} view.Page = hostSourcesPage{hostChromeData: chrome, Groups: rows}
if err := s.deps.UI.Render(w, "host_sources", view); err != nil { if err := s.deps.UI.Render(w, "host_sources", view); err != nil {
@@ -137,7 +137,7 @@ func (s *Server) handleUISourceGroupNewGet(w stdhttp.ResponseWriter, r *stdhttp.
if !ok { if !ok {
return return
} }
view := s.baseView(u, "dashboard") view := s.baseView(u)
view.Title = "New source group · " + host.Name + " · restic-manager" view.Title = "New source group · " + host.Name + " · restic-manager"
view.Page = sourceGroupEditPage{ view.Page = sourceGroupEditPage{
hostChromeData: s.loadHostChrome(r, *host, "sources", "new source group"), hostChromeData: s.loadHostChrome(r, *host, "sources", "new source group"),
@@ -171,7 +171,7 @@ func (s *Server) handleUISourceGroupEditGet(w stdhttp.ResponseWriter, r *stdhttp
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return return
} }
view := s.baseView(u, "dashboard") view := s.baseView(u)
view.Title = g.Name + " · " + host.Name + " · restic-manager" view.Title = g.Name + " · " + host.Name + " · restic-manager"
view.Page = sourceGroupEditPage{ view.Page = sourceGroupEditPage{
hostChromeData: s.loadHostChrome(r, *host, "sources", g.Name), hostChromeData: s.loadHostChrome(r, *host, "sources", g.Name),
@@ -341,7 +341,7 @@ func (s *Server) handleUISourceGroupDelete(w stdhttp.ResponseWriter, r *stdhttp.
// typed input intact + an error banner. Returns 422 to signal "form // typed input intact + an error banner. Returns 422 to signal "form
// rejected" while still returning HTML (mirrors handleUIAddHostPost). // rejected" while still returning HTML (mirrors handleUIAddHostPost).
func (s *Server) renderSourceFormError(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, gid string, isNew bool, form sourceFormData, msg string) { func (s *Server) renderSourceFormError(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, gid string, isNew bool, form sourceFormData, msg string) {
view := s.baseView(u, "dashboard") view := s.baseView(u)
view.Title = "Source group · " + host.Name + " · restic-manager" view.Title = "Source group · " + host.Name + " · restic-manager"
saveAction := "/hosts/" + host.ID + "/sources/new" saveAction := "/hosts/" + host.ID + "/sources/new"
crumb := "new source group" crumb := "new source group"
+4 -4
View File
@@ -13,10 +13,10 @@ import (
// which can pre-compute and pass primitives into the view. // which can pre-compute and pass primitives into the view.
func funcMap() template.FuncMap { func funcMap() template.FuncMap {
return template.FuncMap{ return template.FuncMap{
"bytes": formatBytes, "bytes": formatBytes,
"relTime": formatRelTime, "relTime": formatRelTime,
"comma": formatComma, "comma": formatComma,
"deref": derefStr, "deref": derefStr,
"timeNotZero": func(t *time.Time) bool { return t != nil && !t.IsZero() }, "timeNotZero": func(t *time.Time) bool { return t != nil && !t.IsZero() },
"joinDot": func(parts []string) string { return strings.Join(parts, " · ") }, "joinDot": func(parts []string) string { return strings.Join(parts, " · ") },
"absTime": func(t time.Time) string { "absTime": func(t time.Time) string {
+9 -9
View File
@@ -42,14 +42,14 @@ type HandlerDeps struct {
// enrollment) before the WS upgrade. // enrollment) before the WS upgrade.
// //
// Lifecycle: // Lifecycle:
// 1. Bearer token resolves to a Host row. // 1. Bearer token resolves to a Host row.
// 2. Upgrade. // 2. Upgrade.
// 3. First message must be `hello`; protocol_version checked here. // 3. First message must be `hello`; protocol_version checked here.
// 4. Loop: read messages, dispatch by type. Heartbeats touch the // 4. Loop: read messages, dispatch by type. Heartbeats touch the
// host row; job/log/repo messages forward to the relevant // host row; job/log/repo messages forward to the relevant
// handlers (TODO: lands with P1-18 onward). // handlers (TODO: lands with P1-18 onward).
// 5. On Read error or context cancel, mark host offline, unregister // 5. On Read error or context cancel, mark host offline, unregister
// from the hub. // from the hub.
func AgentHandler(deps HandlerDeps) stdhttp.Handler { func AgentHandler(deps HandlerDeps) stdhttp.Handler {
return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
host, ok := authenticateAgent(r, deps.Store) host, ok := authenticateAgent(r, deps.Store)
@@ -204,7 +204,7 @@ func dispatchAgentMessage(ctx context.Context, c *Conn, hostID string, env api.E
string(p.Status), p.ExitCode, p.Stats, errMsg, p.FinishedAt); err != nil { string(p.Status), p.ExitCode, p.Stats, errMsg, p.FinishedAt); err != nil {
slog.Warn("ws: mark job finished", "job_id", p.JobID, "err", err) slog.Warn("ws: mark job finished", "job_id", p.JobID, "err", err)
} }
// repo_initialised_at projection has been removed — auto-init // repo_initialized_at projection has been removed — auto-init
// at host enrolment makes "is the repo init'd" derivable from // at host enrolment makes "is the repo init'd" derivable from
// the latest init job's status, no separate column needed. // the latest init job's status, no separate column needed.
if deps.JobHub != nil { if deps.JobHub != nil {
+1 -1
View File
@@ -100,7 +100,7 @@ func NewConn(hostID string, c *websocket.Conn) *Conn {
} }
// Send writes an envelope as a JSON text message. Concurrent calls // Send writes an envelope as a JSON text message. Concurrent calls
// are serialised; the underlying socket is not safe for parallel // are serialized; the underlying socket is not safe for parallel
// writers. // writers.
func (c *Conn) Send(ctx context.Context, env api.Envelope) error { func (c *Conn) Send(ctx context.Context, env api.Envelope) error {
c.writeMu.Lock() c.writeMu.Lock()
+19 -2
View File
@@ -57,13 +57,18 @@ func TestWSHelloAndHeartbeat(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
c, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ c, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{
HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}}, HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}},
}) })
if err != nil { if err != nil {
t.Fatalf("dial: %v", err) t.Fatalf("dial: %v", err)
} }
defer c.CloseNow() defer c.CloseNow()
defer func() {
if res != nil && res.Body != nil {
_ = res.Body.Close()
}
}()
// Send hello. // Send hello.
hello := api.HelloPayload{ hello := api.HelloPayload{
@@ -125,13 +130,18 @@ func TestWSRejectsOldProtocol(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
c, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ c, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{
HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}}, HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer " + token}},
}) })
if err != nil { if err != nil {
t.Fatalf("dial: %v", err) t.Fatalf("dial: %v", err)
} }
defer c.CloseNow() defer c.CloseNow()
defer func() {
if res != nil && res.Body != nil {
_ = res.Body.Close()
}
}()
hello := api.HelloPayload{ProtocolVersion: 0} // below minimum hello := api.HelloPayload{ProtocolVersion: 0} // below minimum
env, _ := api.Marshal(api.MsgHello, "", hello) env, _ := api.Marshal(api.MsgHello, "", hello)
@@ -170,6 +180,13 @@ func TestWSRejectsBadToken(t *testing.T) {
_, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{ _, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{
HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer wrong"}}, HTTPHeader: stdhttp.Header{"Authorization": []string{"Bearer wrong"}},
}) })
if res != nil {
defer func() {
if res != nil && res.Body != nil {
_ = res.Body.Close()
}
}()
}
if err == nil { if err == nil {
t.Fatal("dial should fail") t.Fatal("dial should fail")
} }
+2 -2
View File
@@ -33,7 +33,7 @@ func NewJobHub() *JobHub {
// the hub's set (so concurrent Broadcasts will reach it), but no // the hub's set (so concurrent Broadcasts will reach it), but no
// pump goroutine runs yet. The caller can prime the channel via Send // pump goroutine runs yet. The caller can prime the channel via Send
// — useful for late-subscriber catch-up — and then call Run to start // — useful for late-subscriber catch-up — and then call Run to start
// the pump. Run blocks until ctx is cancelled or conn dies, and // the pump. Run blocks until ctx is canceled or conn dies, and
// unregisters on return. // unregisters on return.
type Subscriber struct { type Subscriber struct {
hub *JobHub hub *JobHub
@@ -73,7 +73,7 @@ func (s *Subscriber) Send(env api.Envelope) {
} }
// Run pumps messages from the subscriber's channel onto conn until // Run pumps messages from the subscriber's channel onto conn until
// ctx is cancelled or conn dies. Unregisters on return. Caller is // ctx is canceled or conn dies. Unregisters on return. Caller is
// expected to invoke this from the goroutine that owns conn. // expected to invoke this from the goroutine that owns conn.
func (s *Subscriber) Run(ctx context.Context, conn *Conn) { func (s *Subscriber) Run(ctx context.Context, conn *Conn) {
defer s.unregister() defer s.unregister()
+1 -1
View File
@@ -26,7 +26,7 @@ func (s *Store) AppendAudit(ctx context.Context, e AuditEntry) error {
} }
// nullable returns nil for nil/empty *string so SQLite stores NULL. // nullable returns nil for nil/empty *string so SQLite stores NULL.
// SQLite's driver treats Go nil as NULL but treats *string("") as ''. // SQLite's driver treats Go nil as NULL but treats *string("") as .
// We want NULL semantics for "absent." // We want NULL semantics for "absent."
func nullable(p *string) any { func nullable(p *string) any {
if p == nil || *p == "" { if p == nil || *p == "" {
-1
View File
@@ -172,4 +172,3 @@ func (s *Store) PurgeExpiredEnrollmentTokens(ctx context.Context) (int64, error)
n, _ := res.RowsAffected() n, _ := res.RowsAffected()
return n, nil return n, nil
} }
+2 -2
View File
@@ -57,7 +57,7 @@ func (s *Store) FleetSummary(ctx context.Context) (FleetSummary, error) {
if err != nil { if err != nil {
return FleetSummary{}, fmt.Errorf("store: fleet summary jobs: %w", err) return FleetSummary{}, fmt.Errorf("store: fleet summary jobs: %w", err)
} }
defer rows.Close() defer func() { _ = rows.Close() }()
for rows.Next() { for rows.Next() {
var status string var status string
var n int var n int
@@ -70,7 +70,7 @@ func (s *Store) FleetSummary(ctx context.Context) (FleetSummary, error) {
fs.JobsLast24hSucceeded = n fs.JobsLast24hSucceeded = n
case "failed": case "failed":
fs.JobsLast24hFailed = n fs.JobsLast24hFailed = n
case "cancelled": case "cancelled": //nolint:misspell // matches the DB CHECK constraint and api.JobCancelled wire value
fs.JobsLast24hCancelled = n fs.JobsLast24hCancelled = n
} }
} }
+6 -6
View File
@@ -121,7 +121,7 @@ func (s *Store) ListHosts(ctx context.Context) ([]Host, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("store: list hosts: %w", err) return nil, fmt.Errorf("store: list hosts: %w", err)
} }
defer rows.Close() defer func() { _ = rows.Close() }()
var out []Host var out []Host
for rows.Next() { for rows.Next() {
h, err := scanHostRow(rows) h, err := scanHostRow(rows)
@@ -150,11 +150,11 @@ func scanHost(row *sql.Row) (*Host, error) {
func scanHostRow(s hostScanner) (*Host, error) { func scanHostRow(s hostScanner) (*Host, error) {
var h Host var h Host
var ( var (
lastSeen, lastBackupAt sql.NullString lastSeen, lastBackupAt sql.NullString
repoID, currentJob, lastBkSt sql.NullString repoID, currentJob, lastBkSt sql.NullString
enrolled string enrolled string
tags string tags string
bwUp, bwDown sql.NullInt64 bwUp, bwDown sql.NullInt64
) )
err := s.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, err := s.Scan(&h.ID, &h.Name, &h.OS, &h.Arch,
&h.AgentVersion, &h.ResticVersion, &h.ProtocolVersion, &h.AgentVersion, &h.ResticVersion, &h.ProtocolVersion,
+10 -10
View File
@@ -118,7 +118,7 @@ func (s *Store) ListJobLogs(ctx context.Context, jobID string, afterSeq int64, l
if err != nil { if err != nil {
return nil, fmt.Errorf("store: list job logs: %w", err) return nil, fmt.Errorf("store: list job logs: %w", err)
} }
defer rows.Close() defer func() { _ = rows.Close() }()
var out []JobLogLine var out []JobLogLine
for rows.Next() { for rows.Next() {
var l JobLogLine var l JobLogLine
@@ -143,15 +143,15 @@ func (s *Store) GetJob(ctx context.Context, id string) (*Job, error) {
started_at, finished_at, exit_code, stats, error, created_at started_at, finished_at, exit_code, stats, error, created_at
FROM jobs WHERE id = ?`, id) FROM jobs WHERE id = ?`, id)
var ( var (
j Job j Job
schedID sql.NullString schedID sql.NullString
actorID sql.NullString actorID sql.NullString
startedAt sql.NullString startedAt sql.NullString
finishedAt sql.NullString finishedAt sql.NullString
exitCode sql.NullInt64 exitCode sql.NullInt64
stats sql.NullString stats sql.NullString
errMsg sql.NullString errMsg sql.NullString
createdAt string createdAt string
) )
if err := row.Scan(&j.ID, &j.HostID, &j.Kind, &j.Status, &schedID, if err := row.Scan(&j.ID, &j.HostID, &j.Kind, &j.Status, &schedID,
&j.ActorKind, &actorID, &startedAt, &finishedAt, &j.ActorKind, &actorID, &startedAt, &finishedAt,
+1 -1
View File
@@ -31,7 +31,7 @@ func (st *Store) GetRepoMaintenance(ctx context.Context, hostID string) (*HostRe
check_cron, check_enabled, check_subset_pct check_cron, check_enabled, check_subset_pct
FROM host_repo_maintenance WHERE host_id = ?`, hostID) FROM host_repo_maintenance WHERE host_id = ?`, hostID)
var ( var (
m HostRepoMaintenance m HostRepoMaintenance
forgetEnabled, pruneEnabled, checkEnabled int forgetEnabled, pruneEnabled, checkEnabled int
) )
err := row.Scan(&m.HostID, err := row.Scan(&m.HostID,
+3 -3
View File
@@ -15,9 +15,9 @@ var migrationsFS embed.FS
// migration is one ordered SQL file from migrations/. // migration is one ordered SQL file from migrations/.
type migration struct { type migration struct {
version int // parsed from filename prefix (0001, 0002, …) version int // parsed from filename prefix (0001, 0002, …)
name string // full filename, for error messages name string // full filename, for error messages
sql string sql string
} }
// loadMigrations reads every migrations/*.sql file in lexical order // loadMigrations reads every migrations/*.sql file in lexical order
+1 -1
View File
@@ -52,7 +52,7 @@ func (st *Store) DuePendingRuns(ctx context.Context, now time.Time, limit int) (
if err != nil { if err != nil {
return nil, fmt.Errorf("store: due pending runs: %w", err) return nil, fmt.Errorf("store: due pending runs: %w", err)
} }
defer rows.Close() defer func() { _ = rows.Close() }()
out := []PendingRun{} out := []PendingRun{}
for rows.Next() { for rows.Next() {
var p PendingRun var p PendingRun
+3 -3
View File
@@ -144,7 +144,7 @@ func (st *Store) ListSchedulesByHost(ctx context.Context, hostID string) ([]Sche
if err != nil { if err != nil {
return nil, fmt.Errorf("store: list schedules: %w", err) return nil, fmt.Errorf("store: list schedules: %w", err)
} }
defer rows.Close() defer func() { _ = rows.Close() }()
out := []Schedule{} out := []Schedule{}
for rows.Next() { for rows.Next() {
s, err := scanScheduleRow(rows) s, err := scanScheduleRow(rows)
@@ -247,7 +247,7 @@ func (st *Store) scheduleGroupIDs(ctx context.Context, scheduleID string) ([]str
if err != nil { if err != nil {
return nil, fmt.Errorf("store: read schedule junction: %w", err) return nil, fmt.Errorf("store: read schedule junction: %w", err)
} }
defer rows.Close() defer func() { _ = rows.Close() }()
out := []string{} out := []string{}
for rows.Next() { for rows.Next() {
var id string var id string
@@ -269,7 +269,7 @@ func (st *Store) SchedulesUsingGroup(ctx context.Context, groupID string) ([]str
if err != nil { if err != nil {
return nil, fmt.Errorf("store: schedules using group: %w", err) return nil, fmt.Errorf("store: schedules using group: %w", err)
} }
defer rows.Close() defer func() { _ = rows.Close() }()
out := []string{} out := []string{}
for rows.Next() { for rows.Next() {
var id string var id string
+2 -2
View File
@@ -51,7 +51,7 @@ func (s *Store) ReplaceHostSnapshots(ctx context.Context, hostID string, snaps [
if err != nil { if err != nil {
return fmt.Errorf("store: prepare snapshot insert: %w", err) return fmt.Errorf("store: prepare snapshot insert: %w", err)
} }
defer stmt.Close() defer func() { _ = stmt.Close() }()
refreshed := when.UTC().Format(time.RFC3339Nano) refreshed := when.UTC().Format(time.RFC3339Nano)
for _, snap := range snaps { for _, snap := range snaps {
@@ -92,7 +92,7 @@ func (s *Store) ListSnapshotsByHost(ctx context.Context, hostID string) ([]Snaps
if err != nil { if err != nil {
return nil, fmt.Errorf("store: list snapshots: %w", err) return nil, fmt.Errorf("store: list snapshots: %w", err)
} }
defer rows.Close() defer func() { _ = rows.Close() }()
var out []Snapshot var out []Snapshot
for rows.Next() { for rows.Next() {
+15 -13
View File
@@ -30,20 +30,20 @@ func TestReplaceHostSnapshotsRoundTrip(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second) now := time.Now().UTC().Truncate(time.Second)
in := []Snapshot{ in := []Snapshot{
{ {
ID: "deadbeef" + "00000000000000000000000000000000000000000000000000000000", ID: "deadbeef" + "00000000000000000000000000000000000000000000000000000000",
ShortID: "deadbeef", ShortID: "deadbeef",
Time: now.Add(-2 * time.Hour), Time: now.Add(-2 * time.Hour),
Hostname: "snap-host", Hostname: "snap-host",
Paths: []string{"/etc", "/home"}, Paths: []string{"/etc", "/home"},
Tags: []string{"daily"}, Tags: []string{"daily"},
SizeBytes: 4096, FileCount: 12, SizeBytes: 4096, FileCount: 12,
}, },
{ {
ID: "cafef00d" + "00000000000000000000000000000000000000000000000000000000", ID: "cafef00d" + "00000000000000000000000000000000000000000000000000000000",
ShortID: "cafef00d", ShortID: "cafef00d",
Time: now.Add(-1 * time.Hour), Time: now.Add(-1 * time.Hour),
Hostname: "snap-host", Hostname: "snap-host",
Paths: []string{"/etc"}, Paths: []string{"/etc"},
SizeBytes: 8192, FileCount: 24, SizeBytes: 8192, FileCount: 24,
}, },
} }
@@ -129,9 +129,11 @@ func TestReplaceHostSnapshotsEmpty(t *testing.T) {
// First a non-empty replace. // First a non-empty replace.
if err := s.ReplaceHostSnapshots(ctx, hostID, []Snapshot{ if err := s.ReplaceHostSnapshots(ctx, hostID, []Snapshot{
{ID: "1111111111111111111111111111111111111111111111111111111111111111", {
ID: "1111111111111111111111111111111111111111111111111111111111111111",
ShortID: "11111111", Time: time.Now().UTC(), Hostname: "snap-host", ShortID: "11111111", Time: time.Now().UTC(), Hostname: "snap-host",
Paths: []string{"/x"}}, Paths: []string{"/x"},
},
}, time.Now().UTC()); err != nil { }, time.Now().UTC()); err != nil {
t.Fatalf("replace 1: %v", err) t.Fatalf("replace 1: %v", err)
} }
+5 -5
View File
@@ -183,7 +183,7 @@ func (st *Store) ListSourceGroupsByHost(ctx context.Context, hostID string) ([]S
if err != nil { if err != nil {
return nil, fmt.Errorf("store: list source groups: %w", err) return nil, fmt.Errorf("store: list source groups: %w", err)
} }
defer rows.Close() defer func() { _ = rows.Close() }()
out := []SourceGroup{} out := []SourceGroup{}
for rows.Next() { for rows.Next() {
g, err := scanSourceGroupRow(rows) g, err := scanSourceGroupRow(rows)
@@ -220,10 +220,10 @@ type sourceGroupScanner interface {
func scanSourceGroupRow(s sourceGroupScanner) (*SourceGroup, error) { func scanSourceGroupRow(s sourceGroupScanner) (*SourceGroup, error) {
var ( var (
out SourceGroup out SourceGroup
includes, excludes, retention string includes, excludes, retention string
conflict sql.NullString conflict sql.NullString
createdAt, updatedAt string createdAt, updatedAt string
) )
err := s.Scan(&out.ID, &out.HostID, &out.Name, err := s.Scan(&out.ID, &out.HostID, &out.Name,
&includes, &excludes, &retention, &includes, &excludes, &retention,
+1 -1
View File
@@ -177,7 +177,7 @@ func TestPendingRunQueue(t *testing.T) {
now := time.Now().UTC() now := time.Now().UTC()
if err := s.EnqueuePendingRun(ctx, &PendingRun{ if err := s.EnqueuePendingRun(ctx, &PendingRun{
ID: "01HPEND00000000000000001", ID: "01HPEND00000000000000001",
ScheduleID: schedID, SourceGroupID: gid, HostID: hostID, ScheduleID: schedID, SourceGroupID: gid, HostID: hostID,
NextAttemptAt: now.Add(-time.Second), // already due NextAttemptAt: now.Add(-time.Second), // already due
ScheduledAt: now.Add(-time.Minute), ScheduledAt: now.Add(-time.Minute),
+4 -2
View File
@@ -34,10 +34,12 @@ func TestOpenAppliesMigrations(t *testing.T) {
} }
// Spot-check a few tables exist with expected columns. // Spot-check a few tables exist with expected columns.
tables := []string{"users", "sessions", "hosts", "repos", tables := []string{
"users", "sessions", "hosts", "repos",
"credentials", "schedules", "jobs", "job_logs", "credentials", "schedules", "jobs", "job_logs",
"snapshots", "alerts", "audit_log", "snapshots", "alerts", "audit_log",
"enrollment_tokens", "host_schedule_version"} "enrollment_tokens", "host_schedule_version",
}
for _, tbl := range tables { for _, tbl := range tables {
row := s.DB().QueryRow( row := s.DB().QueryRow(
`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, tbl) `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, tbl)
+15 -14
View File
@@ -20,6 +20,7 @@ type User struct {
// Role enumerates the access tiers from spec.md §7.2. // Role enumerates the access tiers from spec.md §7.2.
type Role string type Role string
// Defined Role values, in descending order of privilege.
const ( const (
RoleAdmin Role = "admin" RoleAdmin Role = "admin"
RoleOperator Role = "operator" RoleOperator Role = "operator"
@@ -73,12 +74,12 @@ type Host struct {
// only. forget/prune/check are repo-level cadences on // only. forget/prune/check are repo-level cadences on
// HostRepoMaintenance, not schedule kinds. // HostRepoMaintenance, not schedule kinds.
type Schedule struct { type Schedule struct {
ID string ID string
HostID string HostID string
CronExpr string CronExpr string
Enabled bool Enabled bool
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
// SourceGroupIDs is populated by ListSchedulesByHost (joins // SourceGroupIDs is populated by ListSchedulesByHost (joins
// schedule_source_groups) and accepted on Create / Update so the // schedule_source_groups) and accepted on Create / Update so the
// caller passes the desired junction state in one shape. // caller passes the desired junction state in one shape.
@@ -160,14 +161,14 @@ type HostRepoMaintenance struct {
// PendingRun queues a missed cron tick (agent was offline) for the // PendingRun queues a missed cron tick (agent was offline) for the
// server-side retry ticker to dispatch later. // server-side retry ticker to dispatch later.
type PendingRun struct { type PendingRun struct {
ID string ID string
ScheduleID string ScheduleID string
SourceGroupID string SourceGroupID string
HostID string HostID string
Attempt int Attempt int
NextAttemptAt time.Time NextAttemptAt time.Time
ScheduledAt time.Time // original cron tick — forensic / audit ScheduledAt time.Time // original cron tick — forensic / audit
LastError string LastError string
} }
// EnrollmentToken is the issuer's view of a one-time token. // EnrollmentToken is the issuer's view of a one-time token.
-1
View File
@@ -273,4 +273,3 @@ Sizes: **S** = under a day, **M** = 13 days, **L** = 37 days.
- [ ] **X-03** Periodic dependency updates (`dependabot` or `renovate`) - [ ] **X-03** Periodic dependency updates (`dependabot` or `renovate`)
- [ ] **X-04** Threat-model review at end of each phase - [ ] **X-04** Threat-model review at end of each phase
- [ ] **X-05** Proper first-run onboarding UI: admin shouldn't need to `curl` `/api/bootstrap` by hand. Render the bootstrap form on the same login page (extra "setup token" field shown only while no admin user exists, hidden after); on submit POST to `/api/bootstrap`, then drop straight into a session. Surface the one-time token from the server log somewhere copy-able (or print a clickable URL with the token in the query string at first-run). Also: relax the 12-char password floor for the first-run path or document it in the form so `admin` doesn't silently fail validation. - [ ] **X-05** Proper first-run onboarding UI: admin shouldn't need to `curl` `/api/bootstrap` by hand. Render the bootstrap form on the same login page (extra "setup token" field shown only while no admin user exists, hidden after); on submit POST to `/api/bootstrap`, then drop straight into a session. Surface the one-time token from the server log somewhere copy-able (or print a clickable URL with the token in the query string at first-run). Also: relax the 12-char password floor for the first-run path or document it in the form so `admin` doesn't silently fail validation.
- [ ] **X-06** Lint-baseline cleanup pass. `.golangci.yml` is now on the v2 schema; CI is gated with `only-new-issues: true` because the repo carries ~90 pre-existing findings (gofumpt drift × 31, misspell × 25, missing godoc on exported consts × 10, bodyclose × 6, errcheck × 12, errorlint/nilerr/unused × handful) accumulated before lint was actually wired into CI. Drive the count to zero in a dedicated PR (mostly mechanical: `gofumpt -w .`, fix typos, add comments, audit nilerr cases since those *might* be real bugs), then drop `only-new-issues: true` so future regressions are caught at the source.
+4
View File
@@ -7,5 +7,9 @@ package web
import "embed" import "embed"
// FS is the embedded view of every template + static asset under
// this package. Consumed by internal/server/ui (templates) and
// internal/server/http (static handler).
//
//go:embed templates/* static/* //go:embed templates/* static/*
var FS embed.FS var FS embed.FS