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:
+28
-23
@@ -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,
|
||||
¤tJob, &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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user