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.
95 lines
3.1 KiB
Go
95 lines
3.1 KiB
Go
package logretention
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// HostFloor is one host's configured retention protection --
|
|
// DefaultDays (from api/agents.ConfigOverride.LogRetentionDays) applies
|
|
// to any service on this host with no more specific entry;
|
|
// ServiceDays (from ConfigOverride.ServiceLogRetentionDays) overrides
|
|
// that default for the services named in it. Either may be absent
|
|
// independently (a host can have a service override with no host
|
|
// default, or vice versa).
|
|
type HostFloor struct {
|
|
DefaultDays *int
|
|
ServiceDays map[string]int
|
|
}
|
|
|
|
// AgentRetentionStore reads the protective retention floors set on
|
|
// agents.ConfigOverride -- a separate, Postgres-backed concern from
|
|
// Store's ClickHouse access above, so it lives in its own file.
|
|
// Deliberately its own narrow query against the same `agents` table
|
|
// api/agents.Store manages, rather than importing api/agents for a
|
|
// shared type, matching this codebase's "each package owns direct SQL
|
|
// access to what it needs" convention (e.g. alerting and api both read
|
|
// dashboards-adjacent tables independently rather than sharing a store
|
|
// type across a package boundary).
|
|
type AgentRetentionStore struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewAgentRetentionStore(pool *pgxpool.Pool) *AgentRetentionStore {
|
|
return &AgentRetentionStore{pool: pool}
|
|
}
|
|
|
|
// FloorsByHost reports every host with a configured floor of either
|
|
// kind (host-level default, per-service, or both), keyed by host -- a
|
|
// host absent from this map has no configured floor at all.
|
|
func (s *AgentRetentionStore) FloorsByHost(ctx context.Context) (map[string]HostFloor, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT host,
|
|
desired_override->>'log_retention_days',
|
|
(desired_override->'service_log_retention_days')::text
|
|
FROM agents
|
|
WHERE desired_override->>'log_retention_days' IS NOT NULL
|
|
OR desired_override->'service_log_retention_days' IS NOT NULL`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := map[string]HostFloor{}
|
|
for rows.Next() {
|
|
var host string
|
|
var defaultDaysText, serviceDaysJSON *string
|
|
if err := rows.Scan(&host, &defaultDaysText, &serviceDaysJSON); err != nil {
|
|
return nil, err
|
|
}
|
|
var hf HostFloor
|
|
if defaultDaysText != nil {
|
|
d, err := strconv.Atoi(*defaultDaysText)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing log_retention_days for host %q: %w", host, err)
|
|
}
|
|
hf.DefaultDays = &d
|
|
}
|
|
if serviceDaysJSON != nil {
|
|
hf.ServiceDays = map[string]int{}
|
|
if err := json.Unmarshal([]byte(*serviceDaysJSON), &hf.ServiceDays); err != nil {
|
|
return nil, fmt.Errorf("parsing service_log_retention_days for host %q: %w", host, err)
|
|
}
|
|
}
|
|
out[host] = hf
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// Effective reports the retention floor that applies to service on
|
|
// this host: a service-specific override if one exists, otherwise the
|
|
// host-level default, otherwise no floor at all.
|
|
func (hf HostFloor) Effective(service string) (int, bool) {
|
|
if days, ok := hf.ServiceDays[service]; ok {
|
|
return days, true
|
|
}
|
|
if hf.DefaultDays != nil {
|
|
return *hf.DefaultDays, true
|
|
}
|
|
return 0, false
|
|
}
|