Files
steve b6f8de1dcc 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.
2026-05-03 16:15:17 +01:00

129 lines
3.6 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
)
// Snapshot mirrors the snapshots projection table.
type Snapshot struct {
ID string
HostID string
ShortID string
Time time.Time
Hostname string
Paths []string
Tags []string
SizeBytes int64
FileCount int64
RefreshedAt time.Time
}
// ReplaceHostSnapshots atomically replaces the snapshot projection for
// one host. Snapshots are reported by the agent in full after each
// successful backup, so we treat the message as the new source of
// truth and delete-then-insert under one transaction.
//
// snapshot_count on the host row is updated in the same tx so the
// dashboard's per-host count is always consistent with the snapshot
// list the host detail page renders.
func (s *Store) ReplaceHostSnapshots(ctx context.Context, hostID string, snaps []Snapshot, when time.Time) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("store: begin snapshots tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`DELETE FROM snapshots WHERE host_id = ?`, hostID); err != nil {
return fmt.Errorf("store: clear snapshots for host: %w", err)
}
if len(snaps) > 0 {
stmt, err := tx.PrepareContext(ctx,
`INSERT INTO snapshots (
id, host_id, short_id, time, hostname, paths, tags,
size_bytes, file_count, refreshed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return fmt.Errorf("store: prepare snapshot insert: %w", err)
}
defer func() { _ = stmt.Close() }()
refreshed := when.UTC().Format(time.RFC3339Nano)
for _, snap := range snaps {
paths, _ := json.Marshal(snap.Paths)
tags, _ := json.Marshal(snap.Tags)
if _, err := stmt.ExecContext(ctx,
snap.ID, hostID, snap.ShortID,
snap.Time.UTC().Format(time.RFC3339Nano),
snap.Hostname, string(paths), string(tags),
snap.SizeBytes, snap.FileCount, refreshed,
); err != nil {
return fmt.Errorf("store: insert snapshot %s: %w", snap.ID, err)
}
}
}
if _, err := tx.ExecContext(ctx,
`UPDATE hosts SET snapshot_count = ? WHERE id = ?`,
len(snaps), hostID); err != nil {
return fmt.Errorf("store: update host snapshot_count: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("store: commit snapshots: %w", err)
}
return nil
}
// ListSnapshotsByHost returns the cached snapshot list for a host,
// most-recent first. Empty slice is a normal "no snapshots yet" case.
func (s *Store) ListSnapshotsByHost(ctx context.Context, hostID string) ([]Snapshot, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, host_id, short_id, time, hostname, paths, tags,
size_bytes, file_count, refreshed_at
FROM snapshots
WHERE host_id = ?
ORDER BY time DESC`, hostID)
if err != nil {
return nil, fmt.Errorf("store: list snapshots: %w", err)
}
defer func() { _ = rows.Close() }()
var out []Snapshot
for rows.Next() {
snap, err := scanSnapshotRow(rows)
if err != nil {
return nil, err
}
out = append(out, *snap)
}
return out, rows.Err()
}
func scanSnapshotRow(r *sql.Rows) (*Snapshot, error) {
var (
snap Snapshot
t, refresh string
paths, tags string
)
if err := r.Scan(&snap.ID, &snap.HostID, &snap.ShortID,
&t, &snap.Hostname, &paths, &tags,
&snap.SizeBytes, &snap.FileCount, &refresh); err != nil {
return nil, fmt.Errorf("store: scan snapshot: %w", err)
}
snap.Time, _ = time.Parse(time.RFC3339Nano, t)
snap.RefreshedAt, _ = time.Parse(time.RFC3339Nano, refresh)
if paths != "" {
_ = json.Unmarshal([]byte(paths), &snap.Paths)
}
if tags != "" {
_ = json.Unmarshal([]byte(tags), &snap.Tags)
}
return &snap, nil
}