b6f8de1dcc
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.
129 lines
3.6 KiB
Go
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
|
|
}
|