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.
This commit is contained in:
+70
-45
@@ -1,12 +1,21 @@
|
||||
// Package logretention lets an owner or admin permanently delete log
|
||||
// records older than a chosen age, scoped to specific hosts -- deleting
|
||||
// by age alone (with no way to target which agents' logs) turned out
|
||||
// to be a real footgun for an operator who only wants to clean up one
|
||||
// noisy host, not everything; storage/README.md has flagged "no
|
||||
// TTL/retention clause yet" since Phase 0, this is the on-demand,
|
||||
// operator-triggered, host-scoped half of that gap (not an automatic
|
||||
// TTL, which is a different, engine-driven design nobody asked for
|
||||
// here).
|
||||
// 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
|
||||
@@ -40,7 +49,7 @@ import (
|
||||
// `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 hosts, count, delete), so keeping them
|
||||
// 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 {
|
||||
@@ -51,59 +60,75 @@ func NewStore(conn driver.Conn) *Store {
|
||||
return &Store{conn: conn}
|
||||
}
|
||||
|
||||
type HostCount struct {
|
||||
Host string `json:"host"`
|
||||
Count uint64 `json:"count"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// HostsOlderThan lists every host with at least one log record older
|
||||
// than cutoff, along with how many -- backs the host picker a caller
|
||||
// selects from before previewing/deleting, so the list only ever shows
|
||||
// hosts that actually have something to act on for the chosen age.
|
||||
func (s *Store) HostsOlderThan(ctx context.Context, cutoff time.Time) ([]HostCount, error) {
|
||||
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, count() AS n FROM logs WHERE timestamp < ? GROUP BY host ORDER BY n DESC`, cutoff)
|
||||
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 []HostCount
|
||||
var out []TargetCount
|
||||
for rows.Next() {
|
||||
var hc HostCount
|
||||
if err := rows.Scan(&hc.Host, &hc.Count); err != nil {
|
||||
var tc TargetCount
|
||||
if err := rows.Scan(&tc.Host, &tc.Service, &tc.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, hc)
|
||||
out = append(out, tc)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// hostPlaceholders builds "?, ?, ..." for n hosts and the matching
|
||||
// []any argument slice (cutoff first, then each host) -- shared by
|
||||
// CountOlderThan and DeleteOlderThan since both statements have the
|
||||
// same "timestamp < ? AND host IN (...)" shape. Callers must never
|
||||
// pass an empty hosts slice (an empty IN () is invalid SQL, and more
|
||||
// importantly "no hosts specified" must never silently mean "every
|
||||
// host" -- see Handler.parseHosts, which rejects that before this is
|
||||
// ever called).
|
||||
func hostPlaceholders(cutoff time.Time, hosts []string) (string, []any) {
|
||||
placeholders := make([]string, len(hosts))
|
||||
args := make([]any, 0, len(hosts)+1)
|
||||
// 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, h := range hosts {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, h)
|
||||
for i, t := range targets {
|
||||
pairs[i] = "(?, ?)"
|
||||
args = append(args, t.Host, t.Service)
|
||||
}
|
||||
return strings.Join(placeholders, ", "), args
|
||||
return strings.Join(pairs, ", "), args
|
||||
}
|
||||
|
||||
// CountOlderThan reports how many log records from any of hosts are
|
||||
// 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, hosts []string) (uint64, error) {
|
||||
ph, args := hostPlaceholders(cutoff, hosts)
|
||||
row := s.conn.QueryRow(ctx, fmt.Sprintf("SELECT count() FROM logs WHERE timestamp < ? AND host IN (%s)", ph), args...)
|
||||
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
|
||||
@@ -113,13 +138,13 @@ func (s *Store) CountOlderThan(ctx context.Context, cutoff time.Time, hosts []st
|
||||
|
||||
// DeleteOlderThan issues a synchronous ClickHouse mutation
|
||||
// (SETTINGS mutations_sync = 1) deleting every log record from any of
|
||||
// hosts older than cutoff. Synchronous rather than fire-and-forget so
|
||||
// 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, hosts []string) error {
|
||||
ph, args := hostPlaceholders(cutoff, hosts)
|
||||
return s.conn.Exec(ctx, fmt.Sprintf("ALTER TABLE logs DELETE WHERE timestamp < ? AND host IN (%s) SETTINGS mutations_sync = 1", ph), args...)
|
||||
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...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user