Files
cairnobs/api/logretention/store.go
T
jcoffey-dev 5ff2e5bb60 Scope log retention deletion and floors to (host, service), not host alone
logs rows carry a real per-record `service` (nginx, smtp, ufw, ...) --
already true of the schema (storage/migrations/0001) and wire protocol,
not something this feature invents. Both the deletion picker and the
retention floor now operate on (host, service) pairs instead of whole
hosts, so an operator can delete just one noisy log type from an agent
without touching everything else it ships, and can protect one service
(e.g. keep smtp a year) longer than the rest of that host's default.

api/agents.ConfigOverride gains ServiceLogRetentionDays (map[string]int),
owner-only to change like LogRetentionDays -- a service listed there
overrides the host's LogRetentionDays default for that service only.
Agent config page gets a matching "Per-service log retention overrides"
add/remove list next to the existing host-level field.

api/logretention: Store's count/delete now take []HostService and build
a ClickHouse tuple IN ((?,?),...) over (host, service); AgentRetentionStore.
FloorsByHost returns each host's default plus its per-service map, with
HostFloor.Effective(service) resolving which one applies. preview/delete
moved from GET/DELETE-with-query-params to POST-with-JSON-body (a list of
targets needs a real body, not a repeated compound query param), and
partitionTargets checks the floor per target so one protected service
never blocks deleting a different, unprotected one in the same request.

Settings' Log retention section is a two-level picker now: each host
row (with a "select all services" checkbox and its default floor badge)
expands to its services, each with its own count and effective
protected-days badge.

Verified live against real ClickHouse/Postgres and in-browser: a host
with a 7-day default plus a 365-day smtp override -- deleting nginx+
smtp+ufw together correctly removed nginx and ufw, left smtp's 10
records untouched, and confirmed via a follow-up owner delete that
bypassing the floor works. Also verified the full click-through (add a
service override on the agent page, see it reflected in Settings'
picker, select/preview/cancel) and confirmed no regression from the
prior host-only version's tests.
2026-08-21 15:54:58 -07:00

151 lines
6.3 KiB
Go

// Package logretention lets an owner or admin permanently delete log
// records older than a chosen age, scoped to specific (host, service)
// targets -- deleting by age alone, with no way to target which
// agents' or which services' logs, turned out to be a real footgun for
// an operator who only wants to clean up one noisy source (e.g. a
// chatty nginx access log) without touching everything else that host
// ships (smtp, ufw, ...); storage/README.md has flagged "no TTL/
// retention clause yet" since Phase 0, this is the on-demand,
// operator-triggered, host-and-service-scoped half of that gap (not an
// automatic TTL, which is a different, engine-driven design nobody
// asked for here).
//
// service is a genuine per-log-record dimension already, not something
// this package invents: storage/migrations/0001_create_logs_table.sql
// has always had a `service` column, and distinct services on one host
// are a real, already-supported shape (separate sentry-agent processes
// on the same machine, each with its own agent.toml `service` -- see
// /docs/agent-management-design.md), not merely a per-agent label.
//
// Deliberately scoped to core's single ClickHouse `logs` table, not
// enterprise/'s per-tenant ClickHouse routing
// (enterprise/internal/chrunner.Registry) -- core has no tenant_id
// column on `logs` at all (tenant isolation there lives at the
// connection layer per /docs/phase-4-isolation-design.md), so there is
// nothing to scope a single-tenant deletion by. A tenant-aware
// equivalent for enterprise/ is real, disclosed future work, not
// silently assumed to already work there.
//
// Also disclosed, not silently ignored: deleting from ClickHouse does
// not prune the Tantivy full-text index (search/) -- that index has no
// timestamp field and no bulk/range-delete primitive today (only a
// per-record upsert), so a deleted record's record_id can keep
// resolving to nothing via free-text search until search/ grows a real
// deletion path. Closing that gap is a separate, larger piece of work
// spanning proto/search.proto, search/src/grpc.rs, and
// api/searchclient -- out of scope for this feature.
package logretention
import (
"context"
"fmt"
"strings"
"time"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
)
// Store issues purpose-built, parameterized statements against the
// `logs` table -- deliberately not querylang/executor.ChRunner, whose
// one method (RunSQL) is scoped to arbitrary SELECT statements for the
// query language compiler. This package only ever needs a handful of
// fixed statement shapes (list targets, count, delete), so keeping them
// separate avoids stretching ChRunner's SELECT-shaped contract to also
// cover a DML mutation.
type Store struct {
conn driver.Conn
}
func NewStore(conn driver.Conn) *Store {
return &Store{conn: conn}
}
// HostService identifies one (host, service) pair -- the atomic unit a
// caller selects for preview/deletion. Never a wildcard: a request
// naming a host with no service (or vice versa) is invalid at the
// handler layer (see parseTargets), so this package never has to
// reason about "every service on this host."
type HostService struct {
Host string `json:"host"`
Service string `json:"service"`
}
type TargetCount struct {
Host string
Service string
Count uint64
}
// TargetsOlderThan lists every (host, service) pair with at least one
// log record older than cutoff, along with how many -- backs the
// picker a caller selects from before previewing/deleting. Ordered by
// host so the handler can group contiguous rows into a per-host list
// without a second pass.
func (s *Store) TargetsOlderThan(ctx context.Context, cutoff time.Time) ([]TargetCount, error) {
rows, err := s.conn.Query(ctx, `
SELECT host, service, count() AS n FROM logs
WHERE timestamp < ?
GROUP BY host, service
ORDER BY host, n DESC`, cutoff)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TargetCount
for rows.Next() {
var tc TargetCount
if err := rows.Scan(&tc.Host, &tc.Service, &tc.Count); err != nil {
return nil, err
}
out = append(out, tc)
}
return out, rows.Err()
}
// targetPlaceholders builds "(?, ?), (?, ?), ..." for a tuple IN clause
// over (host, service) and the matching []any argument slice (cutoff
// first, then each pair) -- shared by CountOlderThan and
// DeleteOlderThan since both statements have the same
// "timestamp < ? AND (host, service) IN (...)" shape. Callers must
// never pass an empty targets slice (an empty IN () is invalid SQL, and
// more importantly "no targets specified" must never silently mean
// "everything" -- see Handler.parseTargets, which rejects that before
// this is ever called).
func targetPlaceholders(cutoff time.Time, targets []HostService) (string, []any) {
pairs := make([]string, len(targets))
args := make([]any, 0, len(targets)*2+1)
args = append(args, cutoff)
for i, t := range targets {
pairs[i] = "(?, ?)"
args = append(args, t.Host, t.Service)
}
return strings.Join(pairs, ", "), args
}
// CountOlderThan reports how many log records from any of targets are
// older than cutoff -- backs the "this will delete N records" preview
// a caller shows before asking for confirmation.
func (s *Store) CountOlderThan(ctx context.Context, cutoff time.Time, targets []HostService) (uint64, error) {
ph, args := targetPlaceholders(cutoff, targets)
row := s.conn.QueryRow(ctx, fmt.Sprintf("SELECT count() FROM logs WHERE timestamp < ? AND (host, service) IN (%s)", ph), args...)
var n uint64
if err := row.Scan(&n); err != nil {
return 0, err
}
return n, nil
}
// DeleteOlderThan issues a synchronous ClickHouse mutation
// (SETTINGS mutations_sync = 1) deleting every log record from any of
// targets older than cutoff. Synchronous rather than fire-and-forget so
// a 200 response means the data is actually gone, not just queued -- an
// owner/admin confirming a permanent delete should be able to trust the
// response. This does block for as long as the mutation takes, which
// could be a while against a very large table; a disclosed tradeoff for
// this deployment's homelab/small-scale target, not a hidden one.
func (s *Store) DeleteOlderThan(ctx context.Context, cutoff time.Time, targets []HostService) error {
ph, args := targetPlaceholders(cutoff, targets)
return s.conn.Exec(ctx, fmt.Sprintf("ALTER TABLE logs DELETE WHERE timestamp < ? AND (host, service) IN (%s) SETTINGS mutations_sync = 1", ph), args...)
}