diff --git a/internal/store/hosts.go b/internal/store/hosts.go index 18d5c7d..8dad905 100644 --- a/internal/store/hosts.go +++ b/internal/store/hosts.go @@ -44,7 +44,7 @@ func (s *Store) LookupHostByAgentToken(ctx context.Context, tokenHash string) (* repo_size_bytes, snapshot_count, open_alert_count, applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps, pre_hook_default, post_hook_default, - repo_status, repo_status_error + repo_status, repo_status_error, always_on FROM hosts WHERE agent_token_hash = ?`, tokenHash) return scanHost(row) @@ -59,7 +59,7 @@ func (s *Store) GetHost(ctx context.Context, id string) (*Host, error) { repo_size_bytes, snapshot_count, open_alert_count, applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps, pre_hook_default, post_hook_default, - repo_status, repo_status_error + repo_status, repo_status_error, always_on FROM hosts WHERE id = ?`, id) return scanHost(row) } @@ -227,7 +227,7 @@ func (s *Store) ListHosts(ctx context.Context) ([]Host, error) { repo_size_bytes, snapshot_count, open_alert_count, applied_schedule_version, bandwidth_up_kbps, bandwidth_down_kbps, pre_hook_default, post_hook_default, - repo_status, repo_status_error + repo_status, repo_status_error, always_on FROM hosts ORDER BY name`) if err != nil { return nil, fmt.Errorf("store: list hosts: %w", err) @@ -267,6 +267,7 @@ func scanHostRow(s hostScanner) (*Host, error) { tags string bwUp, bwDown sql.NullInt64 preHook, postHook sql.NullString + alwaysOn int ) err := s.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.AgentVersion, &h.ResticVersion, &h.ProtocolVersion, @@ -275,7 +276,7 @@ func scanHostRow(s hostScanner) (*Host, error) { &h.RepoSizeBytes, &h.SnapshotCount, &h.OpenAlertCount, &h.AppliedScheduleVersion, &bwUp, &bwDown, &preHook, &postHook, - &h.RepoStatus, &h.RepoStatusError) + &h.RepoStatus, &h.RepoStatusError, &alwaysOn) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound @@ -330,6 +331,7 @@ func scanHostRow(s hostScanner) (*Host, error) { if postHook.Valid { h.PostHookDefault = postHook.String } + h.AlwaysOn = alwaysOn != 0 return &h, nil } @@ -378,6 +380,22 @@ func (s *Store) SetHostTags(ctx context.Context, hostID string, tags []string) e return nil } +// SetHostAlwaysOn flips the host's always-on flag. true = 24x7 server +// (default); false = intermittent host (laptop). See the +// always-on-host-mode spec. +func (s *Store) SetHostAlwaysOn(ctx context.Context, hostID string, alwaysOn bool) error { + v := 0 + if alwaysOn { + v = 1 + } + _, err := s.db.ExecContext(ctx, + `UPDATE hosts SET always_on = ? WHERE id = ?`, v, hostID) + if err != nil { + return fmt.Errorf("store: set host always_on: %w", err) + } + return nil +} + // DistinctHostTags returns the union of every tag in use across the // fleet, sorted. Powers the autocomplete on the host-tags editor and // the chip-row filter on the dashboard. Cheap at fleet sizes this diff --git a/internal/store/hosts_always_on_test.go b/internal/store/hosts_always_on_test.go new file mode 100644 index 0000000..8f9cf04 --- /dev/null +++ b/internal/store/hosts_always_on_test.go @@ -0,0 +1,46 @@ +package store + +import ( + "context" + "testing" + "time" +) + +func TestHostAlwaysOnDefaultAndToggle(t *testing.T) { + ctx := context.Background() + st := openTestStore(t) + + h := Host{ + ID: "h-always-on", Name: "lap", OS: "linux", Arch: "amd64", + ProtocolVersion: 1, EnrolledAt: time.Now().UTC(), + } + if err := st.CreateHost(ctx, h, "tok-hash", "pin"); err != nil { + t.Fatalf("create host: %v", err) + } + got, err := st.GetHost(ctx, h.ID) + if err != nil { + t.Fatalf("get host: %v", err) + } + if !got.AlwaysOn { + t.Fatalf("new host should default to always_on=true, got false") + } + + if err := st.SetHostAlwaysOn(ctx, h.ID, false); err != nil { + t.Fatalf("set always_on: %v", err) + } + got, err = st.GetHost(ctx, h.ID) + if err != nil { + t.Fatalf("get host 2: %v", err) + } + if got.AlwaysOn { + t.Fatalf("expected always_on=false after toggle, got true") + } + + hosts, err := st.ListHosts(ctx) + if err != nil { + t.Fatalf("list hosts: %v", err) + } + if len(hosts) != 1 || hosts[0].AlwaysOn { + t.Fatalf("ListHosts should report always_on=false, got %+v", hosts) + } +} diff --git a/internal/store/migrations/0024_hosts_always_on.sql b/internal/store/migrations/0024_hosts_always_on.sql new file mode 100644 index 0000000..7165fa8 --- /dev/null +++ b/internal/store/migrations/0024_hosts_always_on.sql @@ -0,0 +1,6 @@ +-- 0024: distinguish always-on (24x7 server) hosts from intermittent +-- hosts (laptops/workstations that legitimately sleep). Default 1 so +-- every existing and future host keeps today's offline/alert +-- semantics unless explicitly opted out. Column-level ALTER per the +-- repo's migration rules (no table rebuild — hosts has inbound FKs). +ALTER TABLE hosts ADD COLUMN always_on INTEGER NOT NULL DEFAULT 1; diff --git a/internal/store/types.go b/internal/store/types.go index cc60e48..9ad6326 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -99,6 +99,12 @@ type Host struct { // agent-side message when RepoStatus == "init_failed". RepoStatus string RepoStatusError string + + // AlwaysOn is true for 24x7 server hosts (the default). When false + // the host is intermittent (laptop/workstation): offline alerts are + // suppressed, the UI shows an "asleep" state, and a missed backup is + // caught up ~1 min after reconnect. See the always-on-host-mode spec. + AlwaysOn bool } // Schedule is now intentionally slim: cron + which groups + enabled.