Files
restic-manager/internal/store/host_repo_stats.go
T
steve f0dfa689fe P3 follow-up: editable target dir, conditional --no-ownership, UK lint
Three small follow-ups from review:

1. Restore target is now operator-editable. Default value is the
   literal '\$HOME/rm-restore/<job-id>/' (agent expands \$HOME at
   run time using os.UserHomeDir(); also handles \${HOME} and ~/
   prefixes). Operator can replace with any absolute path.
   - ui_restore.go validates the input is either absolute or starts
     with one of the recognised prefixes; other env-var refs (\$PATH
     etc.) are deliberately rejected so operator paths can't pick up
     arbitrary agent env values.
   - host_restore.html replaces the read-only mono-text display with
     a real <input>; help text spells out that \$HOME resolves
     agent-side and <job-id> is substituted on dispatch.
   - install.sh + the systemd unit prep /root/rm-restore so the
     default works under the sandbox: ReadWritePaths gains a soft
     '-/root/rm-restore' entry (the '-' makes the bind-mount soft-fail
     if missing, but install.sh pre-creates it root-owned 0700).

2. --no-ownership flag now gated on restic version. The flag was
   added in restic 0.17 and 0.16 rejects it. Previously dropped it
   wholesale — that meant new-dir restores silently preserved
   ownership against design intent on 0.17+. Now the agent threads
   its detected restic version (sysinfo already collects it) through
   runner.Config -> restic.Env, and RunRestore appends --no-ownership
   only when AtLeastVersion(0, 17) returns true. 0.16 hosts still
   restore with original uid/gid; help text in the wizard explicitly
   notes this. The previous 'Original ownership is preserved' copy
   was wrong for new-dir mode and is corrected.

3. golangci-lint misspell locale switched US -> UK and the codebase
   swept (73 corrections, mostly behaviour/serialise/recognise/honour).
   Wire-format ErrorCode 'unauthorized' -> 'unauthorised' is a tiny
   contract change but the agent doesn't parse those codes today and
   no external API consumers exist yet. Tests passed before + after.

Tests:
- internal/restic/version_test.go covers Env.AtLeastVersion across
  edge cases (empty, exact match, patch above, minor below, non-
  numeric) and expandHome on \$HOME / \${HOME} / ~/, plus
  pass-through for absolute paths and refusal of other env vars.
- ui_restore_test updated: TargetDir now starts '\$HOME/rm-restore/'
  with the job_id substituted into the placeholder.

Live verified on the smoke env: default target restored to
/root/rm-restore/<job-id>/ as the agent's expanded \$HOME (2 files,
14 bytes); custom override '/tmp/custom-restore/<job-id>/' restored
into the agent's PrivateTmp namespace (1 file, 6 bytes); both jobs
'succeeded', exit 0.
2026-05-04 17:27:52 +01:00

232 lines
6.8 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// HostRepoStats is the per-host projection of repo-level metrics.
// All pointer fields are nullable; nil means "not yet known." The row
// is created (or replaced) by UpsertHostRepoStats which merges in only
// the non-nil fields from a patch.
type HostRepoStats struct {
HostID string
TotalSizeBytes *int64
RawSizeBytes *int64
UniqueFiles *int64
SnapshotCount *int64
LastCheckAt *time.Time
LastCheckStatus string // "" | "ok" | "errors_found" | "failed"
LockPresent *bool
LastPruneAt *time.Time
LastPruneFreedBytes *int64
UpdatedAt time.Time
}
// GetHostRepoStats returns the row, or (nil, ErrNotFound) if absent.
func (s *Store) GetHostRepoStats(ctx context.Context, hostID string) (*HostRepoStats, error) {
row := s.db.QueryRowContext(ctx,
`SELECT host_id, total_size_bytes, raw_size_bytes, unique_files,
snapshot_count, last_check_at, last_check_status,
lock_present, last_prune_at, last_prune_freed_bytes, updated_at
FROM host_repo_stats WHERE host_id = ?`, hostID)
return scanHostRepoStats(row)
}
// getHostRepoStatsTx is identical to GetHostRepoStats but runs on an
// existing transaction so the fetch-merge-upsert in UpsertHostRepoStats
// is fully serialised.
func getHostRepoStatsTx(ctx context.Context, tx *sql.Tx, hostID string) (*HostRepoStats, error) {
row := tx.QueryRowContext(ctx,
`SELECT host_id, total_size_bytes, raw_size_bytes, unique_files,
snapshot_count, last_check_at, last_check_status,
lock_present, last_prune_at, last_prune_freed_bytes, updated_at
FROM host_repo_stats WHERE host_id = ?`, hostID)
return scanHostRepoStats(row)
}
// scanHostRepoStats scans one row from host_repo_stats.
func scanHostRepoStats(row *sql.Row) (*HostRepoStats, error) {
var (
st HostRepoStats
totalSize sql.NullInt64
rawSize sql.NullInt64
uniqueFiles sql.NullInt64
snapshotCount sql.NullInt64
lastCheckAt sql.NullString
lastCheckStatus sql.NullString
lockPresent int64
lastPruneAt sql.NullString
lastPruneFreed sql.NullInt64
updatedAt string
)
if err := row.Scan(
&st.HostID,
&totalSize, &rawSize, &uniqueFiles, &snapshotCount,
&lastCheckAt, &lastCheckStatus,
&lockPresent,
&lastPruneAt, &lastPruneFreed,
&updatedAt,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("store: scan host_repo_stats: %w", err)
}
if totalSize.Valid {
v := totalSize.Int64
st.TotalSizeBytes = &v
}
if rawSize.Valid {
v := rawSize.Int64
st.RawSizeBytes = &v
}
if uniqueFiles.Valid {
v := uniqueFiles.Int64
st.UniqueFiles = &v
}
if snapshotCount.Valid {
v := snapshotCount.Int64
st.SnapshotCount = &v
}
if lastCheckAt.Valid {
t, err := time.Parse(time.RFC3339Nano, lastCheckAt.String)
if err != nil {
return nil, fmt.Errorf("store: parse last_check_at: %w", err)
}
st.LastCheckAt = &t
}
if lastCheckStatus.Valid {
st.LastCheckStatus = lastCheckStatus.String
}
lp := lockPresent != 0
st.LockPresent = &lp
if lastPruneAt.Valid {
t, err := time.Parse(time.RFC3339Nano, lastPruneAt.String)
if err != nil {
return nil, fmt.Errorf("store: parse last_prune_at: %w", err)
}
st.LastPruneAt = &t
}
if lastPruneFreed.Valid {
v := lastPruneFreed.Int64
st.LastPruneFreedBytes = &v
}
t, err := time.Parse(time.RFC3339Nano, updatedAt)
if err != nil {
return nil, fmt.Errorf("store: parse host_repo_stats.updated_at: %w", err)
}
st.UpdatedAt = t
return &st, nil
}
// UpsertHostRepoStats writes a partial update — only non-nil pointer
// fields (and LastCheckStatus when non-empty) overwrite existing
// columns. Wrapped in a transaction so concurrent upserts on the same
// host don't lose updates.
func (s *Store) UpsertHostRepoStats(ctx context.Context, hostID string, patch HostRepoStats) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("store: begin host_repo_stats tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
// Fetch existing row; start from zero if absent.
cur, err := getHostRepoStatsTx(ctx, tx, hostID)
if err != nil && !errors.Is(err, ErrNotFound) {
return err
}
if cur == nil {
cur = &HostRepoStats{HostID: hostID}
}
// Merge: non-nil patch fields overwrite current.
if patch.TotalSizeBytes != nil {
cur.TotalSizeBytes = patch.TotalSizeBytes
}
if patch.RawSizeBytes != nil {
cur.RawSizeBytes = patch.RawSizeBytes
}
if patch.UniqueFiles != nil {
cur.UniqueFiles = patch.UniqueFiles
}
if patch.SnapshotCount != nil {
cur.SnapshotCount = patch.SnapshotCount
}
if patch.LastCheckAt != nil {
cur.LastCheckAt = patch.LastCheckAt
}
if patch.LastCheckStatus != "" {
cur.LastCheckStatus = patch.LastCheckStatus
}
if patch.LockPresent != nil {
cur.LockPresent = patch.LockPresent
}
if patch.LastPruneAt != nil {
cur.LastPruneAt = patch.LastPruneAt
}
if patch.LastPruneFreedBytes != nil {
cur.LastPruneFreedBytes = patch.LastPruneFreedBytes
}
now := time.Now().UTC().Format(time.RFC3339Nano)
// Convert *bool → int for lock_present.
var lockPresentInt int64
if cur.LockPresent != nil && *cur.LockPresent {
lockPresentInt = 1
}
if _, err = tx.ExecContext(ctx,
`INSERT INTO host_repo_stats
(host_id, total_size_bytes, raw_size_bytes, unique_files,
snapshot_count, last_check_at, last_check_status,
lock_present, last_prune_at, last_prune_freed_bytes, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(host_id) DO UPDATE SET
total_size_bytes = excluded.total_size_bytes,
raw_size_bytes = excluded.raw_size_bytes,
unique_files = excluded.unique_files,
snapshot_count = excluded.snapshot_count,
last_check_at = excluded.last_check_at,
last_check_status = excluded.last_check_status,
lock_present = excluded.lock_present,
last_prune_at = excluded.last_prune_at,
last_prune_freed_bytes = excluded.last_prune_freed_bytes,
updated_at = excluded.updated_at`,
hostID,
nullableInt64(cur.TotalSizeBytes),
nullableInt64(cur.RawSizeBytes),
nullableInt64(cur.UniqueFiles),
nullableInt64(cur.SnapshotCount),
nullableTime(cur.LastCheckAt),
nullableStr(cur.LastCheckStatus),
lockPresentInt,
nullableTime(cur.LastPruneAt),
nullableInt64(cur.LastPruneFreedBytes),
now,
); err != nil {
return fmt.Errorf("store: upsert host_repo_stats: %w", err)
}
return tx.Commit()
}
// nullableInt64 converts *int64 to a database/sql-compatible nullable value.
func nullableInt64(p *int64) any {
if p == nil {
return nil
}
return *p
}
// nullableTime converts *time.Time to an RFC3339Nano string or nil.
func nullableTime(p *time.Time) any {
if p == nil {
return nil
}
return p.UTC().Format(time.RFC3339Nano)
}