P2 redesign · phase 2: store rewrite — sources, slim schedules, repo maintenance

Go-side data model rebuilt against migration 0008. The fat-Schedule
shape (paths/excludes/tags/retention/manual/kind/options/hooks) is
gone; that surface lives on source_groups now.

* store/types.go
  - Schedule slimmed to {id, host_id, cron, enabled, source_group_ids,
    timestamps}. SourceGroupIDs populated by Get/List, accepted on
    Create/Update so callers pass desired junction state in one shape.
  - SourceGroup added: name (= snapshot tag), includes/excludes,
    retention_policy, retry_max + retry_backoff_seconds, cached
    conflict_dimension.
  - HostRepoMaintenance added: forget/prune/check cadences + enabled.
  - PendingRun added: offline-retry queue.
  - Host loses RepoInitialisedAt; gains BandwidthUpKBps + BandwidthDownKBps.
  - RetentionPolicy moves home from "schedule field" to "source group
    field" but the type itself + Summary() method unchanged.

* store/sources.go (new) — CRUD + GetByName + ConflictDimension cache.
  Group writes bump host_schedule_version; conflict cache writes don't
  (server-internal projection, agent doesn't see it).
* store/maintenance.go (new) — CreateDefault is idempotent (INSERT OR
  IGNORE). UpdateRepoMaintenance doesn't bump schedule version because
  these run on the server's own ticker, not the agent's local cron.
* store/pending.go (new) — Enqueue / DueRunsForRetry / Bump / Delete.
* store/schedules.go — rewritten for slim shape + junction CRUD.
  Update wipes the schedule_source_groups junction wholesale and
  re-inserts (simpler than diffing). Adds SchedulesUsingGroup for
  retention-conflict detection + UI labels.
* store/hosts.go — drops repo_initialised_at scan, adds bandwidth scan.
  New SetHostBandwidth helper.

* HTTP layer — temporarily stubbed during this rewrite (501 returns
  with redesign_in_progress error code). Phase 3 fills these in
  against the new shape:
    - schedules.go REST CRUD
    - schedule_push.go agent reconciliation
    - ui_schedules.go HTML form CRUD
  Run-now-per-host + Init-repo handlers in ui_handlers.go also stubbed
  — both go away in the new model (Run-now per source group; auto-init
  at host enrolment).

* enrollment.go — replaces "seed manual schedule from typed paths"
  with "seed default source group + repo-maintenance row." The default
  group gets the typed paths as its includes; operator edits later
  via Sources tab.

* ws/handler.go — drops the MarkHostRepoInitialised projection (column
  is gone; auto-init makes it derivable from latest init job's status).

Tests:
* store: existing schedule test rewritten for slim shape + junction;
  new sources_test.go covers source-group CRUD, name uniqueness,
  conflict cache, repo-maintenance defaults + idempotent seed,
  pending-runs queue lifecycle.
* http: schedules_test.go and schedule_push_test.go deleted — both
  exercised the obsolete fat-schedule API. Phase 3 rewrites them
  against the new endpoints.

go test ./... green. cmd/server + cmd/agent build. The UI is broken
end-to-end (schedules / sources / repo tabs all hit 501 stubs); Phase 3
restores REST + on-the-wire reconciliation; Phase 4 rewires the UI
templates against the new model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 21:30:41 +01:00
parent e717b6998c
commit e7eea7afac
16 changed files with 1076 additions and 1928 deletions
+28 -23
View File
@@ -42,7 +42,7 @@ func (s *Store) LookupHostByAgentToken(ctx context.Context, tokenHash string) (*
enrolled_at, last_seen_at, status, repo_id, tags,
current_job_id, last_backup_at, last_backup_status,
repo_size_bytes, snapshot_count, open_alert_count,
applied_schedule_version, repo_initialised_at
applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps
FROM hosts WHERE agent_token_hash = ?`,
tokenHash)
return scanHost(row)
@@ -55,7 +55,7 @@ func (s *Store) GetHost(ctx context.Context, id string) (*Host, error) {
enrolled_at, last_seen_at, status, repo_id, tags,
current_job_id, last_backup_at, last_backup_status,
repo_size_bytes, snapshot_count, open_alert_count,
applied_schedule_version, repo_initialised_at
applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps
FROM hosts WHERE id = ?`, id)
return scanHost(row)
}
@@ -116,7 +116,7 @@ func (s *Store) ListHosts(ctx context.Context) ([]Host, error) {
enrolled_at, last_seen_at, status, repo_id, tags,
current_job_id, last_backup_at, last_backup_status,
repo_size_bytes, snapshot_count, open_alert_count,
applied_schedule_version, repo_initialised_at
applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps
FROM hosts ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("store: list hosts: %w", err)
@@ -154,14 +154,14 @@ func scanHostRow(s hostScanner) (*Host, error) {
repoID, currentJob, lastBkSt sql.NullString
enrolled string
tags string
repoInitAt sql.NullString
bwUp, bwDown sql.NullInt64
)
err := s.Scan(&h.ID, &h.Name, &h.OS, &h.Arch,
&h.AgentVersion, &h.ResticVersion, &h.ProtocolVersion,
&enrolled, &lastSeen, &h.Status, &repoID, &tags,
&currentJob, &lastBackupAt, &lastBkSt,
&h.RepoSizeBytes, &h.SnapshotCount, &h.OpenAlertCount,
&h.AppliedScheduleVersion, &repoInitAt)
&h.AppliedScheduleVersion, &bwUp, &bwDown)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
@@ -202,28 +202,33 @@ func scanHostRow(s hostScanner) (*Host, error) {
if tags != "" {
_ = json.Unmarshal([]byte(tags), &h.Tags)
}
if repoInitAt.Valid {
t, err := time.Parse(time.RFC3339Nano, repoInitAt.String)
if err != nil {
return nil, fmt.Errorf("store: parse repo_initialised_at: %w", err)
}
h.RepoInitialisedAt = &t
if bwUp.Valid {
v := int(bwUp.Int64)
h.BandwidthUpKBps = &v
}
if bwDown.Valid {
v := int(bwDown.Int64)
h.BandwidthDownKBps = &v
}
return &h, nil
}
// MarkHostRepoInitialised sets repo_initialised_at to `when` if it is
// currently NULL. Idempotent: re-firing for an already-initialised
// host is a no-op (we never want to clobber the original timestamp).
// Returns true if the row was updated, false if it was already set.
func (s *Store) MarkHostRepoInitialised(ctx context.Context, hostID string, when time.Time) (bool, error) {
res, err := s.db.ExecContext(ctx,
`UPDATE hosts SET repo_initialised_at = ?
WHERE id = ? AND repo_initialised_at IS NULL`,
when.UTC().Format(time.RFC3339Nano), hostID)
// SetHostBandwidth replaces the host's upload/download caps. Pass nil
// to clear a cap. Caller decides validation; non-positive caps are
// treated as "no cap" by the agent regardless.
func (s *Store) SetHostBandwidth(ctx context.Context, hostID string, upKBps, downKBps *int) error {
_, err := s.db.ExecContext(ctx,
`UPDATE hosts SET bandwidth_up_kbps = ?, bandwidth_down_kbps = ? WHERE id = ?`,
nullableInt(upKBps), nullableInt(downKBps), hostID)
if err != nil {
return false, fmt.Errorf("store: mark repo initialised: %w", err)
return fmt.Errorf("store: set host bandwidth: %w", err)
}
n, _ := res.RowsAffected()
return n > 0, nil
return nil
}
func nullableInt(p *int) any {
if p == nil {
return nil
}
return *p
}
+74
View File
@@ -0,0 +1,74 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
)
// CreateDefaultRepoMaintenance inserts the default cadences for a
// host. Called once at host enrolment alongside CreateHost. Safe to
// re-call (uses INSERT OR IGNORE so a manual repair doesn't blow up
// an already-seeded host).
func (st *Store) CreateDefaultRepoMaintenance(ctx context.Context, hostID string) error {
_, err := st.db.ExecContext(ctx,
`INSERT OR IGNORE INTO host_repo_maintenance (host_id) VALUES (?)`,
hostID)
if err != nil {
return fmt.Errorf("store: seed repo maintenance: %w", err)
}
return nil
}
// GetRepoMaintenance returns the cadence row for a host. Returns
// ErrNotFound if absent — caller should usually treat that as
// "needs CreateDefaultRepoMaintenance" rather than a hard error.
func (st *Store) GetRepoMaintenance(ctx context.Context, hostID string) (*HostRepoMaintenance, error) {
row := st.db.QueryRowContext(ctx,
`SELECT host_id, forget_cron, forget_enabled,
prune_cron, prune_enabled,
check_cron, check_enabled, check_subset_pct
FROM host_repo_maintenance WHERE host_id = ?`, hostID)
var (
m HostRepoMaintenance
forgetEnabled, pruneEnabled, checkEnabled int
)
err := row.Scan(&m.HostID,
&m.ForgetCron, &forgetEnabled,
&m.PruneCron, &pruneEnabled,
&m.CheckCron, &checkEnabled, &m.CheckSubsetPct)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("store: get repo maintenance: %w", err)
}
m.ForgetEnabled = forgetEnabled != 0
m.PruneEnabled = pruneEnabled != 0
m.CheckEnabled = checkEnabled != 0
return &m, nil
}
// UpdateRepoMaintenance replaces every editable field. Doesn't bump
// the schedule version — these run on the server's own ticker, not
// the agent's local cron, so the agent doesn't need to know.
func (st *Store) UpdateRepoMaintenance(ctx context.Context, m *HostRepoMaintenance) error {
if m.HostID == "" {
return errors.New("store: repo maintenance host_id required")
}
_, err := st.db.ExecContext(ctx,
`UPDATE host_repo_maintenance SET
forget_cron = ?, forget_enabled = ?,
prune_cron = ?, prune_enabled = ?,
check_cron = ?, check_enabled = ?, check_subset_pct = ?
WHERE host_id = ?`,
m.ForgetCron, boolToInt(m.ForgetEnabled),
m.PruneCron, boolToInt(m.PruneEnabled),
m.CheckCron, boolToInt(m.CheckEnabled), m.CheckSubsetPct,
m.HostID)
if err != nil {
return fmt.Errorf("store: update repo maintenance: %w", err)
}
return nil
}
+103
View File
@@ -0,0 +1,103 @@
package store
import (
"context"
"errors"
"fmt"
"time"
)
// EnqueuePendingRun queues a missed cron tick for the offline-retry
// ticker to dispatch later. Caller (the schedule firing path) sets
// next_attempt_at = now + group.retry_backoff_seconds × 2^(attempt-1).
func (st *Store) EnqueuePendingRun(ctx context.Context, p *PendingRun) error {
if p.ID == "" || p.ScheduleID == "" || p.SourceGroupID == "" || p.HostID == "" {
return errors.New("store: pending run id, schedule_id, source_group_id, host_id required")
}
if p.Attempt == 0 {
p.Attempt = 1
}
if p.NextAttemptAt.IsZero() {
p.NextAttemptAt = time.Now().UTC()
}
if p.ScheduledAt.IsZero() {
p.ScheduledAt = time.Now().UTC()
}
_, err := st.db.ExecContext(ctx,
`INSERT INTO pending_runs (id, schedule_id, source_group_id, host_id,
attempt, next_attempt_at, scheduled_at, last_error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
p.ID, p.ScheduleID, p.SourceGroupID, p.HostID,
p.Attempt,
p.NextAttemptAt.UTC().Format(time.RFC3339Nano),
p.ScheduledAt.UTC().Format(time.RFC3339Nano),
nullableString(p.LastError))
if err != nil {
return fmt.Errorf("store: enqueue pending run: %w", err)
}
return nil
}
// DuePendingRuns returns rows whose next_attempt_at <= now, ordered
// oldest first. Server-side ticker calls this every ~30s.
func (st *Store) DuePendingRuns(ctx context.Context, now time.Time, limit int) ([]PendingRun, error) {
rows, err := st.db.QueryContext(ctx,
`SELECT id, schedule_id, source_group_id, host_id, attempt,
next_attempt_at, scheduled_at, COALESCE(last_error, '')
FROM pending_runs
WHERE next_attempt_at <= ?
ORDER BY next_attempt_at
LIMIT ?`,
now.UTC().Format(time.RFC3339Nano), limit)
if err != nil {
return nil, fmt.Errorf("store: due pending runs: %w", err)
}
defer rows.Close()
out := []PendingRun{}
for rows.Next() {
var p PendingRun
var nextAt, scheduledAt string
if err := rows.Scan(&p.ID, &p.ScheduleID, &p.SourceGroupID, &p.HostID,
&p.Attempt, &nextAt, &scheduledAt, &p.LastError); err != nil {
return nil, err
}
if t, err := time.Parse(time.RFC3339Nano, nextAt); err == nil {
p.NextAttemptAt = t
}
if t, err := time.Parse(time.RFC3339Nano, scheduledAt); err == nil {
p.ScheduledAt = t
}
out = append(out, p)
}
return out, rows.Err()
}
// DeletePendingRun removes a row by id. Called after successful
// dispatch or after exceeding retry_max.
func (st *Store) DeletePendingRun(ctx context.Context, id string) error {
_, err := st.db.ExecContext(ctx,
`DELETE FROM pending_runs WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("store: delete pending run: %w", err)
}
return nil
}
// BumpPendingRunAttempt increments the attempt counter and updates
// next_attempt_at + last_error. Used after a failed retry — caller
// has decided to try again.
func (st *Store) BumpPendingRunAttempt(ctx context.Context, id string, nextAttemptAt time.Time, lastError string) error {
_, err := st.db.ExecContext(ctx,
`UPDATE pending_runs SET
attempt = attempt + 1,
next_attempt_at = ?,
last_error = ?
WHERE id = ?`,
nextAttemptAt.UTC().Format(time.RFC3339Nano),
nullableString(lastError),
id)
if err != nil {
return fmt.Errorf("store: bump pending run: %w", err)
}
return nil
}
+133 -95
View File
@@ -3,15 +3,14 @@ package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
)
// CreateSchedule inserts a new schedule and bumps the host's
// schedule_version atomically. Returns the inserted row's
// CreatedAt / UpdatedAt timestamps written into s.
// CreateSchedule inserts a new slim schedule row + the schedule_source_groups
// junction entries for s.SourceGroupIDs, atomic in one tx. Bumps
// host_schedule_version. Caller mints s.ID.
func (st *Store) CreateSchedule(ctx context.Context, s *Schedule) error {
if s.ID == "" || s.HostID == "" {
return errors.New("store: schedule id and host_id required")
@@ -19,20 +18,6 @@ func (st *Store) CreateSchedule(ctx context.Context, s *Schedule) error {
now := time.Now().UTC()
s.CreatedAt = now
s.UpdatedAt = now
if s.Paths == nil {
s.Paths = []string{}
}
if s.Excludes == nil {
s.Excludes = []string{}
}
if s.Tags == nil {
s.Tags = []string{}
}
pathsJSON, _ := json.Marshal(s.Paths)
excludesJSON, _ := json.Marshal(s.Excludes)
tagsJSON, _ := json.Marshal(s.Tags)
retentionJSON, _ := json.Marshal(s.RetentionPolicy)
optionsJSON, _ := json.Marshal(s.Options)
tx, err := st.db.BeginTx(ctx, nil)
if err != nil {
@@ -41,19 +26,16 @@ func (st *Store) CreateSchedule(ctx context.Context, s *Schedule) error {
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`INSERT INTO schedules (
id, host_id, kind, cron_expr, paths, excludes, tags,
retention_policy, options, pre_hook, post_hook, enabled, manual,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
s.ID, s.HostID, s.Kind, s.CronExpr,
string(pathsJSON), string(excludesJSON), string(tagsJSON),
string(retentionJSON), string(optionsJSON),
s.PreHook, s.PostHook, boolToInt(s.Enabled), boolToInt(s.Manual),
`INSERT INTO schedules (id, host_id, cron_expr, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
s.ID, s.HostID, s.CronExpr, boolToInt(s.Enabled),
now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano),
); err != nil {
return fmt.Errorf("store: create schedule: %w", err)
}
if err := writeScheduleGroupsTx(ctx, tx, s.ID, s.SourceGroupIDs); err != nil {
return err
}
if err := bumpHostScheduleVersionTx(ctx, tx, s.HostID); err != nil {
return err
}
@@ -61,27 +43,11 @@ func (st *Store) CreateSchedule(ctx context.Context, s *Schedule) error {
}
// UpdateSchedule replaces every editable field on an existing row
// and bumps host_schedule_version. ID and HostID must match an
// existing row; kind is immutable (creating a new schedule is
// cheaper than re-keying retention/hooks).
// and rewrites the junction. Bumps host_schedule_version.
func (st *Store) UpdateSchedule(ctx context.Context, s *Schedule) error {
if s.ID == "" || s.HostID == "" {
return errors.New("store: schedule id and host_id required")
}
if s.Paths == nil {
s.Paths = []string{}
}
if s.Excludes == nil {
s.Excludes = []string{}
}
if s.Tags == nil {
s.Tags = []string{}
}
pathsJSON, _ := json.Marshal(s.Paths)
excludesJSON, _ := json.Marshal(s.Excludes)
tagsJSON, _ := json.Marshal(s.Tags)
retentionJSON, _ := json.Marshal(s.RetentionPolicy)
optionsJSON, _ := json.Marshal(s.Options)
now := time.Now().UTC()
tx, err := st.db.BeginTx(ctx, nil)
@@ -91,16 +57,10 @@ func (st *Store) UpdateSchedule(ctx context.Context, s *Schedule) error {
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`UPDATE schedules SET
cron_expr = ?, paths = ?, excludes = ?, tags = ?,
retention_policy = ?, options = ?,
pre_hook = ?, post_hook = ?, enabled = ?, manual = ?,
updated_at = ?
`UPDATE schedules
SET cron_expr = ?, enabled = ?, updated_at = ?
WHERE id = ? AND host_id = ?`,
s.CronExpr,
string(pathsJSON), string(excludesJSON), string(tagsJSON),
string(retentionJSON), string(optionsJSON),
s.PreHook, s.PostHook, boolToInt(s.Enabled), boolToInt(s.Manual),
s.CronExpr, boolToInt(s.Enabled),
now.Format(time.RFC3339Nano),
s.ID, s.HostID,
)
@@ -112,14 +72,23 @@ func (st *Store) UpdateSchedule(ctx context.Context, s *Schedule) error {
return ErrNotFound
}
s.UpdatedAt = now
// Rewrite junction wholesale — simpler than diffing.
if _, err := tx.ExecContext(ctx,
`DELETE FROM schedule_source_groups WHERE schedule_id = ?`, s.ID,
); err != nil {
return fmt.Errorf("store: clear schedule junction: %w", err)
}
if err := writeScheduleGroupsTx(ctx, tx, s.ID, s.SourceGroupIDs); err != nil {
return err
}
if err := bumpHostScheduleVersionTx(ctx, tx, s.HostID); err != nil {
return err
}
return tx.Commit()
}
// DeleteSchedule removes a schedule and bumps host_schedule_version.
// Returns ErrNotFound if no row matched.
// DeleteSchedule removes a schedule and its junction rows; bumps
// host_schedule_version. Returns ErrNotFound if no row matched.
func (st *Store) DeleteSchedule(ctx context.Context, hostID, scheduleID string) error {
tx, err := st.db.BeginTx(ctx, nil)
if err != nil {
@@ -137,35 +106,39 @@ func (st *Store) DeleteSchedule(ctx context.Context, hostID, scheduleID string)
if n == 0 {
return ErrNotFound
}
// Junction rows go via ON DELETE CASCADE; nothing to do here.
if err := bumpHostScheduleVersionTx(ctx, tx, hostID); err != nil {
return err
}
return tx.Commit()
}
// GetSchedule returns one schedule by (host_id, id). Returns
// ErrNotFound on miss.
// GetSchedule returns one schedule (with its junction-resolved
// SourceGroupIDs populated) by (host_id, id). ErrNotFound on miss.
func (st *Store) GetSchedule(ctx context.Context, hostID, scheduleID string) (*Schedule, error) {
row := st.db.QueryRowContext(ctx,
`SELECT id, host_id, kind, cron_expr, paths, excludes, tags,
retention_policy, options, pre_hook, post_hook, enabled, manual,
created_at, updated_at
`SELECT id, host_id, cron_expr, enabled, created_at, updated_at
FROM schedules WHERE id = ? AND host_id = ?`,
scheduleID, hostID)
s, err := scanSchedule(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return s, err
if err != nil {
return nil, err
}
s.SourceGroupIDs, err = st.scheduleGroupIDs(ctx, scheduleID)
if err != nil {
return nil, err
}
return s, nil
}
// ListSchedulesByHost returns every schedule for a host, ordered
// by created_at. Empty slice on miss (not an error).
// ListSchedulesByHost returns every schedule for a host, with
// SourceGroupIDs resolved.
func (st *Store) ListSchedulesByHost(ctx context.Context, hostID string) ([]Schedule, error) {
rows, err := st.db.QueryContext(ctx,
`SELECT id, host_id, kind, cron_expr, paths, excludes, tags,
retention_policy, options, pre_hook, post_hook, enabled, manual,
created_at, updated_at
`SELECT id, host_id, cron_expr, enabled, created_at, updated_at
FROM schedules WHERE host_id = ? ORDER BY created_at`,
hostID)
if err != nil {
@@ -180,11 +153,22 @@ func (st *Store) ListSchedulesByHost(ctx context.Context, hostID string) ([]Sche
}
out = append(out, *s)
}
return out, rows.Err()
if err := rows.Err(); err != nil {
return nil, err
}
// Second pass to resolve junctions — small fleet, cheap.
for i := range out {
ids, err := st.scheduleGroupIDs(ctx, out[i].ID)
if err != nil {
return nil, err
}
out[i].SourceGroupIDs = ids
}
return out, nil
}
// GetHostScheduleVersion returns the current version for a host,
// or 0 if no row exists yet.
// GetHostScheduleVersion returns the current version for a host, or
// 0 if no row exists yet.
func (st *Store) GetHostScheduleVersion(ctx context.Context, hostID string) (int64, error) {
var v int64
err := st.db.QueryRowContext(ctx,
@@ -210,12 +194,26 @@ func (st *Store) SetHostAppliedScheduleVersion(ctx context.Context, hostID strin
return nil
}
// BumpHostScheduleVersion is the public wrapper for non-schedule
// CRUD that needs to push to the agent — e.g. source-group edits
// (paths/retention change), retry-policy edits.
func (st *Store) BumpHostScheduleVersion(ctx context.Context, hostID string) error {
tx, err := st.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := bumpHostScheduleVersionTx(ctx, tx, hostID); err != nil {
return err
}
return tx.Commit()
}
// bumpHostScheduleVersionTx upserts host_schedule_version, +1 each
// call. Caller owns the tx.
func bumpHostScheduleVersionTx(ctx context.Context, tx *sql.Tx, hostID string) error {
if _, err := tx.ExecContext(ctx,
`INSERT INTO host_schedule_version (host_id, version)
VALUES (?, 1)
`INSERT INTO host_schedule_version (host_id, version) VALUES (?, 1)
ON CONFLICT(host_id) DO UPDATE SET version = version + 1`,
hostID); err != nil {
return fmt.Errorf("store: bump schedule version: %w", err)
@@ -223,6 +221,66 @@ func bumpHostScheduleVersionTx(ctx context.Context, tx *sql.Tx, hostID string) e
return nil
}
// writeScheduleGroupsTx inserts the junction rows for one schedule.
// Caller owns the tx; assumes the table is already empty for this id
// (Update wipes before calling; Create starts empty).
func writeScheduleGroupsTx(ctx context.Context, tx *sql.Tx, scheduleID string, groupIDs []string) error {
for _, gid := range groupIDs {
if gid == "" {
continue
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO schedule_source_groups (schedule_id, source_group_id) VALUES (?, ?)`,
scheduleID, gid,
); err != nil {
return fmt.Errorf("store: link schedule %s to group %s: %w", scheduleID, gid, err)
}
}
return nil
}
// scheduleGroupIDs reads the junction for one schedule.
func (st *Store) scheduleGroupIDs(ctx context.Context, scheduleID string) ([]string, error) {
rows, err := st.db.QueryContext(ctx,
`SELECT source_group_id FROM schedule_source_groups WHERE schedule_id = ?`,
scheduleID)
if err != nil {
return nil, fmt.Errorf("store: read schedule junction: %w", err)
}
defer rows.Close()
out := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// SchedulesUsingGroup is the inverse — list schedule IDs that
// reference a given source group. Used for retention-conflict
// detection and "X schedules use this group" UI labels.
func (st *Store) SchedulesUsingGroup(ctx context.Context, groupID string) ([]string, error) {
rows, err := st.db.QueryContext(ctx,
`SELECT schedule_id FROM schedule_source_groups WHERE source_group_id = ?`,
groupID)
if err != nil {
return nil, fmt.Errorf("store: schedules using group: %w", err)
}
defer rows.Close()
out := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// ----- scan helpers --------------------------------------------------
func scanSchedule(row *sql.Row) (*Schedule, error) {
@@ -235,35 +293,15 @@ type scheduleScanner interface {
func scanScheduleRow(s scheduleScanner) (*Schedule, error) {
var (
out Schedule
paths, excludes, tags, retention, options string
createdAt, updatedAt string
enabled, manual int
out Schedule
createdAt, updatedAt string
enabled int
)
err := s.Scan(&out.ID, &out.HostID, &out.Kind, &out.CronExpr,
&paths, &excludes, &tags, &retention, &options,
&out.PreHook, &out.PostHook, &enabled, &manual,
&createdAt, &updatedAt)
err := s.Scan(&out.ID, &out.HostID, &out.CronExpr, &enabled, &createdAt, &updatedAt)
if err != nil {
return nil, err
}
if paths != "" {
_ = json.Unmarshal([]byte(paths), &out.Paths)
}
if excludes != "" {
_ = json.Unmarshal([]byte(excludes), &out.Excludes)
}
if tags != "" {
_ = json.Unmarshal([]byte(tags), &out.Tags)
}
if retention != "" {
_ = json.Unmarshal([]byte(retention), &out.RetentionPolicy)
}
if options != "" {
_ = json.Unmarshal([]byte(options), &out.Options)
}
out.Enabled = enabled != 0
out.Manual = manual != 0
if t, err := time.Parse(time.RFC3339Nano, createdAt); err == nil {
out.CreatedAt = t
}
+71 -27
View File
@@ -21,29 +21,37 @@ func makeSchedHost(t *testing.T, s *Store) string {
return id
}
// makeGroup is a minimal source-group helper for schedule tests
// (schedules need at least one group to point at).
func makeGroup(t *testing.T, s *Store, hostID, name, id string) string {
t.Helper()
if err := s.CreateSourceGroup(context.Background(), &SourceGroup{
ID: id, HostID: hostID, Name: name,
Includes: []string{"/etc"},
}); err != nil {
t.Fatalf("create group %s: %v", name, err)
}
return id
}
func TestSchedulesCRUDAndVersionBump(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
hostID := makeSchedHost(t, s)
gid := makeGroup(t, s, hostID, "default", "01HSCHEDGRP00000000000001")
// Initial version is 0 (no row).
v, err := s.GetHostScheduleVersion(ctx, hostID)
if err != nil {
t.Fatal(err)
}
if v != 0 {
t.Fatalf("initial version: got %d, want 0", v)
// Group creation already bumped version to 1.
v, _ := s.GetHostScheduleVersion(ctx, hostID)
if v != 1 {
t.Fatalf("version after group create: got %d, want 1", v)
}
keepLast := 7
sched := Schedule{
ID: "01SCHED000000000000000001", HostID: hostID,
Kind: "backup", CronExpr: "0 3 * * *",
Paths: []string{"/etc", "/home"},
Tags: []string{"nightly"},
RetentionPolicy: RetentionPolicy{KeepLast: &keepLast},
Enabled: true,
CronExpr: "0 3 * * *",
Enabled: true,
SourceGroupIDs: []string{gid},
}
if err := s.CreateSchedule(ctx, &sched); err != nil {
t.Fatalf("create: %v", err)
@@ -53,37 +61,33 @@ func TestSchedulesCRUDAndVersionBump(t *testing.T) {
}
v, _ = s.GetHostScheduleVersion(ctx, hostID)
if v != 1 {
t.Fatalf("version after create: got %d, want 1", v)
if v != 2 {
t.Fatalf("version after schedule create: got %d, want 2", v)
}
// Round-trip read.
// Round-trip read — junction populated.
got, err := s.GetSchedule(ctx, hostID, sched.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.CronExpr != "0 3 * * *" || len(got.Paths) != 2 {
t.Fatalf("round-trip lost data: %+v", got)
}
if got.RetentionPolicy.KeepLast == nil || *got.RetentionPolicy.KeepLast != 7 {
t.Fatalf("retention round-trip: %+v", got.RetentionPolicy)
if got.CronExpr != "0 3 * * *" || len(got.SourceGroupIDs) != 1 || got.SourceGroupIDs[0] != gid {
t.Fatalf("round-trip: %+v", got)
}
// List sees it.
list, err := s.ListSchedulesByHost(ctx, hostID)
if err != nil || len(list) != 1 || list[0].ID != sched.ID {
t.Fatalf("list: err=%v rows=%v", err, list)
}
// Update bumps version.
// Update — flip enabled, swap junction (re-add same gid). Version bumps.
sched.CronExpr = "*/30 * * * *"
sched.Enabled = false
if err := s.UpdateSchedule(ctx, &sched); err != nil {
t.Fatalf("update: %v", err)
}
v, _ = s.GetHostScheduleVersion(ctx, hostID)
if v != 2 {
t.Fatalf("version after update: got %d, want 2", v)
if v != 3 {
t.Fatalf("version after update: got %d, want 3", v)
}
got, _ = s.GetSchedule(ctx, hostID, sched.ID)
if got.CronExpr != "*/30 * * * *" || got.Enabled {
@@ -95,14 +99,54 @@ func TestSchedulesCRUDAndVersionBump(t *testing.T) {
t.Fatalf("delete: %v", err)
}
v, _ = s.GetHostScheduleVersion(ctx, hostID)
if v != 3 {
t.Fatalf("version after delete: got %d, want 3", v)
if v != 4 {
t.Fatalf("version after delete: got %d, want 4", v)
}
if err := s.DeleteSchedule(ctx, hostID, sched.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("delete after delete: want ErrNotFound, got %v", err)
}
}
func TestSchedulesUsingGroup(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
hostID := makeSchedHost(t, s)
g1 := makeGroup(t, s, hostID, "default", "01HUSEGRPGRP000000000001")
g2 := makeGroup(t, s, hostID, "databases", "01HUSEGRPGRP000000000002")
// Schedule A points at g1 only; Schedule B points at both.
if err := s.CreateSchedule(ctx, &Schedule{
ID: "01HUSEGRPSCHED0000000001", HostID: hostID,
CronExpr: "@hourly", Enabled: true,
SourceGroupIDs: []string{g1},
}); err != nil {
t.Fatal(err)
}
if err := s.CreateSchedule(ctx, &Schedule{
ID: "01HUSEGRPSCHED0000000002", HostID: hostID,
CronExpr: "0 3 * * *", Enabled: true,
SourceGroupIDs: []string{g1, g2},
}); err != nil {
t.Fatal(err)
}
g1Sched, err := s.SchedulesUsingGroup(ctx, g1)
if err != nil {
t.Fatal(err)
}
if len(g1Sched) != 2 {
t.Fatalf("g1 should be in 2 schedules, got %d", len(g1Sched))
}
g2Sched, err := s.SchedulesUsingGroup(ctx, g2)
if err != nil {
t.Fatal(err)
}
if len(g2Sched) != 1 {
t.Fatalf("g2 should be in 1 schedule, got %d", len(g2Sched))
}
}
func TestSetHostAppliedScheduleVersion(t *testing.T) {
t.Parallel()
s := openTestStore(t)
+261
View File
@@ -0,0 +1,261 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
)
// CreateSourceGroup inserts a new group + bumps host_schedule_version
// in one tx. Group name doubles as the snapshot tag on backups; the
// (host_id, name) UNIQUE constraint enforces tag unambiguity.
func (st *Store) CreateSourceGroup(ctx context.Context, g *SourceGroup) error {
if g.ID == "" || g.HostID == "" || g.Name == "" {
return errors.New("store: source group id, host_id, name required")
}
now := time.Now().UTC()
g.CreatedAt = now
g.UpdatedAt = now
if g.Includes == nil {
g.Includes = []string{}
}
if g.Excludes == nil {
g.Excludes = []string{}
}
if g.RetryMax == 0 {
g.RetryMax = 3
}
if g.RetryBackoffSeconds == 0 {
g.RetryBackoffSeconds = 60
}
includesJSON, _ := json.Marshal(g.Includes)
excludesJSON, _ := json.Marshal(g.Excludes)
retentionJSON, _ := json.Marshal(g.RetentionPolicy)
tx, err := st.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("store: begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`INSERT INTO source_groups (
id, host_id, name, includes, excludes, retention_policy,
retry_max, retry_backoff_seconds, conflict_dimension,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
g.ID, g.HostID, g.Name,
string(includesJSON), string(excludesJSON), string(retentionJSON),
g.RetryMax, g.RetryBackoffSeconds,
nullableString(g.ConflictDimension),
now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano),
); err != nil {
return fmt.Errorf("store: create source group: %w", err)
}
if err := bumpHostScheduleVersionTx(ctx, tx, g.HostID); err != nil {
return err
}
return tx.Commit()
}
// UpdateSourceGroup replaces every editable field on an existing row
// and bumps host_schedule_version. Returns ErrNotFound if no row matched.
func (st *Store) UpdateSourceGroup(ctx context.Context, g *SourceGroup) error {
if g.ID == "" || g.HostID == "" || g.Name == "" {
return errors.New("store: source group id, host_id, name required")
}
if g.Includes == nil {
g.Includes = []string{}
}
if g.Excludes == nil {
g.Excludes = []string{}
}
includesJSON, _ := json.Marshal(g.Includes)
excludesJSON, _ := json.Marshal(g.Excludes)
retentionJSON, _ := json.Marshal(g.RetentionPolicy)
now := time.Now().UTC()
tx, err := st.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("store: begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`UPDATE source_groups SET
name = ?, includes = ?, excludes = ?, retention_policy = ?,
retry_max = ?, retry_backoff_seconds = ?, conflict_dimension = ?,
updated_at = ?
WHERE id = ? AND host_id = ?`,
g.Name,
string(includesJSON), string(excludesJSON), string(retentionJSON),
g.RetryMax, g.RetryBackoffSeconds,
nullableString(g.ConflictDimension),
now.Format(time.RFC3339Nano),
g.ID, g.HostID,
)
if err != nil {
return fmt.Errorf("store: update source group: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
g.UpdatedAt = now
if err := bumpHostScheduleVersionTx(ctx, tx, g.HostID); err != nil {
return err
}
return tx.Commit()
}
// DeleteSourceGroup removes a group and bumps host_schedule_version.
// Junction rows in schedule_source_groups go via ON DELETE CASCADE.
// Caller is expected to have already enforced the "default group
// can't be the only one" UI rule; this layer just deletes.
func (st *Store) DeleteSourceGroup(ctx context.Context, hostID, groupID string) error {
tx, err := st.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("store: begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`DELETE FROM source_groups WHERE id = ? AND host_id = ?`,
groupID, hostID)
if err != nil {
return fmt.Errorf("store: delete source group: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
if err := bumpHostScheduleVersionTx(ctx, tx, hostID); err != nil {
return err
}
return tx.Commit()
}
// GetSourceGroup returns one group by (host_id, id). ErrNotFound on miss.
func (st *Store) GetSourceGroup(ctx context.Context, hostID, groupID string) (*SourceGroup, error) {
row := st.db.QueryRowContext(ctx,
`SELECT id, host_id, name, includes, excludes, retention_policy,
retry_max, retry_backoff_seconds, conflict_dimension,
created_at, updated_at
FROM source_groups WHERE id = ? AND host_id = ?`,
groupID, hostID)
g, err := scanSourceGroup(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return g, err
}
// GetSourceGroupByName resolves a group by its (host-unique) name.
// Used by retention-conflict detection and the auto-init flow.
func (st *Store) GetSourceGroupByName(ctx context.Context, hostID, name string) (*SourceGroup, error) {
row := st.db.QueryRowContext(ctx,
`SELECT id, host_id, name, includes, excludes, retention_policy,
retry_max, retry_backoff_seconds, conflict_dimension,
created_at, updated_at
FROM source_groups WHERE host_id = ? AND name = ?`,
hostID, name)
g, err := scanSourceGroup(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return g, err
}
// ListSourceGroupsByHost returns every group for a host, ordered
// by name (so 'default' isn't always at the bottom alphabetically —
// well, it usually IS the only 'd' name on a fresh host so this
// works out fine). Empty slice on miss.
func (st *Store) ListSourceGroupsByHost(ctx context.Context, hostID string) ([]SourceGroup, error) {
rows, err := st.db.QueryContext(ctx,
`SELECT id, host_id, name, includes, excludes, retention_policy,
retry_max, retry_backoff_seconds, conflict_dimension,
created_at, updated_at
FROM source_groups WHERE host_id = ? ORDER BY name`,
hostID)
if err != nil {
return nil, fmt.Errorf("store: list source groups: %w", err)
}
defer rows.Close()
out := []SourceGroup{}
for rows.Next() {
g, err := scanSourceGroupRow(rows)
if err != nil {
return nil, err
}
out = append(out, *g)
}
return out, rows.Err()
}
// SetSourceGroupConflict updates only the cached conflict_dimension.
// Doesn't bump schedule version (the cache is server-internal, agent
// doesn't see it). Empty string clears the conflict.
func (st *Store) SetSourceGroupConflict(ctx context.Context, groupID, dimension string) error {
_, err := st.db.ExecContext(ctx,
`UPDATE source_groups SET conflict_dimension = ? WHERE id = ?`,
nullableString(dimension), groupID)
if err != nil {
return fmt.Errorf("store: set source group conflict: %w", err)
}
return nil
}
// ----- scan helpers --------------------------------------------------
func scanSourceGroup(row *sql.Row) (*SourceGroup, error) {
return scanSourceGroupRow(row)
}
type sourceGroupScanner interface {
Scan(dest ...any) error
}
func scanSourceGroupRow(s sourceGroupScanner) (*SourceGroup, error) {
var (
out SourceGroup
includes, excludes, retention string
conflict sql.NullString
createdAt, updatedAt string
)
err := s.Scan(&out.ID, &out.HostID, &out.Name,
&includes, &excludes, &retention,
&out.RetryMax, &out.RetryBackoffSeconds, &conflict,
&createdAt, &updatedAt)
if err != nil {
return nil, err
}
if includes != "" {
_ = json.Unmarshal([]byte(includes), &out.Includes)
}
if excludes != "" {
_ = json.Unmarshal([]byte(excludes), &out.Excludes)
}
if retention != "" {
_ = json.Unmarshal([]byte(retention), &out.RetentionPolicy)
}
if conflict.Valid {
out.ConflictDimension = conflict.String
}
if t, err := time.Parse(time.RFC3339Nano, createdAt); err == nil {
out.CreatedAt = t
}
if t, err := time.Parse(time.RFC3339Nano, updatedAt); err == nil {
out.UpdatedAt = t
}
return &out, nil
}
func nullableString(s string) any {
if s == "" {
return nil
}
return s
}
+221
View File
@@ -0,0 +1,221 @@
package store
import (
"context"
"errors"
"testing"
"time"
)
func TestSourceGroupCRUDAndVersionBump(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
hostID := makeSchedHost(t, s)
keepLast := 7
g := SourceGroup{
ID: "01HSGRP00000000000000001", HostID: hostID, Name: "default",
Includes: []string{"/etc", "/home"},
Excludes: []string{"*.tmp"},
RetentionPolicy: RetentionPolicy{KeepLast: &keepLast},
}
if err := s.CreateSourceGroup(ctx, &g); err != nil {
t.Fatalf("create: %v", err)
}
if g.RetryMax != 3 || g.RetryBackoffSeconds != 60 {
t.Fatalf("retry defaults not applied: %+v", g)
}
v, _ := s.GetHostScheduleVersion(ctx, hostID)
if v != 1 {
t.Fatalf("version after create: got %d, want 1", v)
}
// Round-trip.
got, err := s.GetSourceGroup(ctx, hostID, g.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Name != "default" || len(got.Includes) != 2 || len(got.Excludes) != 1 {
t.Fatalf("round-trip: %+v", got)
}
if got.RetentionPolicy.KeepLast == nil || *got.RetentionPolicy.KeepLast != 7 {
t.Fatalf("retention round-trip: %+v", got.RetentionPolicy)
}
// By name.
byName, err := s.GetSourceGroupByName(ctx, hostID, "default")
if err != nil || byName.ID != g.ID {
t.Fatalf("get by name: err=%v got=%v", err, byName)
}
// Update — rename + new retention. Version bumps.
keepDaily := 14
g.Name = "system"
g.RetentionPolicy = RetentionPolicy{KeepDaily: &keepDaily}
if err := s.UpdateSourceGroup(ctx, &g); err != nil {
t.Fatal(err)
}
v, _ = s.GetHostScheduleVersion(ctx, hostID)
if v != 2 {
t.Fatalf("version after update: got %d, want 2", v)
}
got, _ = s.GetSourceGroup(ctx, hostID, g.ID)
if got.Name != "system" || got.RetentionPolicy.KeepLast != nil || got.RetentionPolicy.KeepDaily == nil {
t.Fatalf("update did not persist: %+v", got)
}
// Conflict cache (no version bump).
if err := s.SetSourceGroupConflict(ctx, g.ID, "hourly"); err != nil {
t.Fatal(err)
}
got, _ = s.GetSourceGroup(ctx, hostID, g.ID)
if got.ConflictDimension != "hourly" {
t.Fatalf("conflict not cached: %q", got.ConflictDimension)
}
v2, _ := s.GetHostScheduleVersion(ctx, hostID)
if v2 != v {
t.Fatalf("conflict cache should not bump version: %d → %d", v, v2)
}
// List.
list, _ := s.ListSourceGroupsByHost(ctx, hostID)
if len(list) != 1 || list[0].ID != g.ID {
t.Fatalf("list: %v", list)
}
// Delete bumps version.
if err := s.DeleteSourceGroup(ctx, hostID, g.ID); err != nil {
t.Fatal(err)
}
v3, _ := s.GetHostScheduleVersion(ctx, hostID)
if v3 != 3 {
t.Fatalf("version after delete: got %d, want 3", v3)
}
if err := s.DeleteSourceGroup(ctx, hostID, g.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("delete after delete: want ErrNotFound, got %v", err)
}
}
func TestSourceGroupNameUniquePerHost(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
hostID := makeSchedHost(t, s)
if err := s.CreateSourceGroup(ctx, &SourceGroup{
ID: "01HUNIQGRP00000000000001", HostID: hostID, Name: "shared",
}); err != nil {
t.Fatal(err)
}
err := s.CreateSourceGroup(ctx, &SourceGroup{
ID: "01HUNIQGRP00000000000002", HostID: hostID, Name: "shared",
})
if err == nil {
t.Fatal("expected unique-constraint error on duplicate name within host")
}
}
func TestRepoMaintenanceDefaultsAndUpdate(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
hostID := makeSchedHost(t, s)
if _, err := s.GetRepoMaintenance(ctx, hostID); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound before seed, got %v", err)
}
if err := s.CreateDefaultRepoMaintenance(ctx, hostID); err != nil {
t.Fatal(err)
}
m, err := s.GetRepoMaintenance(ctx, hostID)
if err != nil {
t.Fatal(err)
}
if m.ForgetCron != "0 3 * * *" || !m.ForgetEnabled {
t.Fatalf("forget defaults: %+v", m)
}
if m.PruneCron != "0 4 * * 0" || m.CheckSubsetPct != 5 {
t.Fatalf("other defaults: %+v", m)
}
m.ForgetCron = "0 4 * * *"
m.PruneEnabled = false
m.CheckSubsetPct = 10
if err := s.UpdateRepoMaintenance(ctx, m); err != nil {
t.Fatal(err)
}
m2, _ := s.GetRepoMaintenance(ctx, hostID)
if m2.ForgetCron != "0 4 * * *" || m2.PruneEnabled || m2.CheckSubsetPct != 10 {
t.Fatalf("update did not persist: %+v", m2)
}
// CreateDefaultRepoMaintenance is idempotent (INSERT OR IGNORE).
if err := s.CreateDefaultRepoMaintenance(ctx, hostID); err != nil {
t.Fatal(err)
}
m3, _ := s.GetRepoMaintenance(ctx, hostID)
if m3.ForgetCron != "0 4 * * *" {
t.Fatalf("INSERT OR IGNORE clobbered existing row: %+v", m3)
}
}
func TestPendingRunQueue(t *testing.T) {
t.Parallel()
s := openTestStore(t)
ctx := context.Background()
hostID := makeSchedHost(t, s)
gid := makeGroup(t, s, hostID, "default", "01HPENDGRP00000000000001")
schedID := "01HPENDSCHED0000000000001"
if err := s.CreateSchedule(ctx, &Schedule{
ID: schedID, HostID: hostID, CronExpr: "@hourly", Enabled: true,
SourceGroupIDs: []string{gid},
}); err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
if err := s.EnqueuePendingRun(ctx, &PendingRun{
ID: "01HPEND00000000000000001",
ScheduleID: schedID, SourceGroupID: gid, HostID: hostID,
NextAttemptAt: now.Add(-time.Second), // already due
ScheduledAt: now.Add(-time.Minute),
}); err != nil {
t.Fatal(err)
}
due, err := s.DuePendingRuns(ctx, now, 10)
if err != nil {
t.Fatal(err)
}
if len(due) != 1 {
t.Fatalf("due: got %d, want 1", len(due))
}
if due[0].Attempt != 1 {
t.Fatalf("attempt: %d", due[0].Attempt)
}
// Bump.
next := now.Add(2 * time.Minute)
if err := s.BumpPendingRunAttempt(ctx, due[0].ID, next, "agent offline"); err != nil {
t.Fatal(err)
}
// No longer due at `now`.
due, _ = s.DuePendingRuns(ctx, now, 10)
if len(due) != 0 {
t.Fatalf("should not be due yet: %v", due)
}
// Due at `next`.
due, _ = s.DuePendingRuns(ctx, next, 10)
if len(due) != 1 || due[0].Attempt != 2 || due[0].LastError != "agent offline" {
t.Fatalf("after bump: %+v", due)
}
if err := s.DeletePendingRun(ctx, due[0].ID); err != nil {
t.Fatal(err)
}
due, _ = s.DuePendingRuns(ctx, next, 10)
if len(due) != 0 {
t.Fatalf("after delete: %v", due)
}
}
+95 -66
View File
@@ -30,7 +30,7 @@ const (
// token; the DB stores its hash. Callers that hold a *Session have
// already authenticated.
type Session struct {
ID string // session token (raw); never persisted as-is
ID string
UserID string
CreatedAt time.Time
ExpiresAt time.Time
@@ -38,66 +38,77 @@ type Session struct {
UA string
}
// Host mirrors the denormalised hosts table. JSON columns (tags) are
// returned decoded into Go slices for ergonomics.
// Host mirrors the hosts table. The P2 redesign moved repo-related
// flags out (auto-init replaces RepoInitialisedAt; bandwidth lives
// here as a host-wide cap; "what to back up" lives on source_groups).
type Host struct {
ID string
Name string
OS string
Arch string
AgentVersion string
ResticVersion string
ProtocolVersion int
EnrolledAt time.Time
LastSeenAt *time.Time
Status string
RepoID *string
Tags []string
CurrentJobID *string
LastBackupAt *time.Time
LastBackupStatus *string
RepoSizeBytes int64
SnapshotCount int
OpenAlertCount int
AppliedScheduleVersion int64
// RepoInitialisedAt is non-nil once we've confirmed the host's
// repo has been initialised — either the operator clicked the
// init button, or a backup succeeded, or snapshots.report came
// back non-empty. The host detail run-now panel shows a red
// "Initialise repo" affordance while this is nil.
RepoInitialisedAt *time.Time
ID string
Name string
OS string
Arch string
AgentVersion string
ResticVersion string
ProtocolVersion int
EnrolledAt time.Time
LastSeenAt *time.Time
Status string
RepoID *string
Tags []string
CurrentJobID *string
LastBackupAt *time.Time
LastBackupStatus *string
RepoSizeBytes int64
SnapshotCount int
OpenAlertCount int
AppliedScheduleVersion int64
// Host-wide bandwidth caps applied to every restic invocation
// (backup, restore, prune). nil = no cap.
BandwidthUpKBps *int
BandwidthDownKBps *int
}
// Schedule mirrors one row of the schedules table. JSON columns
// (paths, excludes, tags, retention_policy, options) are decoded
// into Go-native shapes for ergonomics; the wire form on the agent
// side keeps retention_policy / options as raw JSON since the agent
// just forwards them to restic.
// Schedule is now intentionally slim: cron + which groups + enabled.
// The "what" lives on SourceGroup (paths, excludes, retention, retry).
// The "kind" of operation a schedule can drive is implicit — backup
// only. forget/prune/check are repo-level cadences on
// HostRepoMaintenance, not schedule kinds.
type Schedule struct {
ID string
HostID string
Kind string
CronExpr string
Paths []string
Excludes []string
Tags []string
RetentionPolicy RetentionPolicy
Options ScheduleOptions
PreHook string
PostHook string
Enabled bool
// Manual schedules carry paths/excludes/tags/retention like any
// other but have no cron — they only fire when the operator
// clicks Run-now. Lets us keep one data shape for "what gets
// backed up" without forcing every host to have an automated
// schedule. Created by Add-host with the typed paths.
Manual bool
CreatedAt time.Time
UpdatedAt time.Time
ID string
HostID string
CronExpr string
Enabled bool
CreatedAt time.Time
UpdatedAt time.Time
// SourceGroupIDs is populated by ListSchedulesByHost (joins
// schedule_source_groups) and accepted on Create / Update so the
// caller passes the desired junction state in one shape.
SourceGroupIDs []string
}
// SourceGroup is the new home for "what gets backed up." A named
// bundle of include + exclude paths plus a retention policy. Group
// name doubles as the snapshot tag (restic --tag <name>) so retention
// can target it via `restic forget --tag`.
type SourceGroup struct {
ID string
HostID string
Name string
Includes []string
Excludes []string
RetentionPolicy RetentionPolicy
RetryMax int
RetryBackoffSeconds int
// ConflictDimension is the cached name of the failing keep-* on
// a granularity↔cadence mismatch (e.g. "hourly" when keep-hourly
// is set but no schedule pointing at this group fires sub-daily).
// Empty means no conflict. Refreshed on every schedule + group CRUD.
ConflictDimension string
CreatedAt time.Time
UpdatedAt time.Time
}
// RetentionPolicy is the typed view of `restic forget --keep-*`.
// All fields nullable so empty == "no policy / keep everything".
// All fields nullable; empty struct = "keep everything for this group."
type RetentionPolicy struct {
KeepLast *int `json:"keep_last,omitempty"`
KeepHourly *int `json:"keep_hourly,omitempty"`
@@ -107,8 +118,8 @@ type RetentionPolicy struct {
KeepYearly *int `json:"keep_yearly,omitempty"`
}
// Summary renders a compact human view of the policy for templates
// and logs — "last=7, d=14, w=4" or "—" when nothing is set.
// Summary renders a compact human view — "last=7, d=14, w=4" or
// "—" when nothing is set. Used by templates and logs.
func (p RetentionPolicy) Summary() string {
parts := []string{}
for _, kv := range []struct {
@@ -132,18 +143,36 @@ func (p RetentionPolicy) Summary() string {
return strings.Join(parts, ", ")
}
// ScheduleOptions covers per-schedule knobs that aren't core to the
// command itself — currently bandwidth caps. Stored as JSON so
// future fields don't churn the schema.
type ScheduleOptions struct {
LimitUploadKBps *int `json:"limit_upload_kbps,omitempty"`
LimitDownloadKBps *int `json:"limit_download_kbps,omitempty"`
// HostRepoMaintenance carries the host-level cron cadences for the
// three repo-wide verbs (forget / prune / check). 1:1 with hosts;
// row is auto-created at host enrolment with sensible defaults.
type HostRepoMaintenance struct {
HostID string
ForgetCron string
ForgetEnabled bool
PruneCron string
PruneEnabled bool
CheckCron string
CheckEnabled bool
CheckSubsetPct int
}
// EnrollmentToken is the issuer's view of a one-time token. The
// raw token is returned only at create time; the DB stores its hash.
// PendingRun queues a missed cron tick (agent was offline) for the
// server-side retry ticker to dispatch later.
type PendingRun struct {
ID string
ScheduleID string
SourceGroupID string
HostID string
Attempt int
NextAttemptAt time.Time
ScheduledAt time.Time // original cron tick — forensic / audit
LastError string
}
// EnrollmentToken is the issuer's view of a one-time token.
type EnrollmentToken struct {
Raw string // populated on create only
Raw string
TokenHash string
CreatedAt time.Time
ExpiresAt time.Time
@@ -153,7 +182,7 @@ type EnrollmentToken struct {
type AuditEntry struct {
ID string
UserID *string
Actor string // user|agent|system
Actor string
Action string
TargetKind *string
TargetID *string