Files
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

286 lines
11 KiB
Go

// Package agents is the web-facing half of agent inventory/remote
// config (see /docs/agent-management-design.md) -- reads/writes the
// same `agents` table ingest's internal/agentregistry writes on every
// CheckIn RPC, the same shared-schema-different-services shape
// alerting and api already use for dashboards/alert_rules.
package agents
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("not found")
// CommandRestart is the one supported lifecycle command -- see
// ingest/internal/grpcserver.AgentCommandRestart and
// agent_control.proto's AgentCommand enum comment for why STOP/
// UNINSTALL aren't here yet.
const CommandRestart = "restart"
func validCommand(c string) bool {
return c == CommandRestart
}
// ConfigOverride is the remotely-editable subset of an agent's config --
// a plain-Go mirror of ingest/internal/agentregistry's overrideFields
// and agent_control.proto's DesiredOverride. Deliberately duplicated
// rather than imported across the module boundary, same convention as
// every other cross-module shared shape in this codebase (see
// grpcserver.TenantIDHeaderKey, enterprise/internal/apiconfig.AIConfig).
// Keep the three in sync by hand.
type ConfigOverride struct {
BatchMaxSize *int64 `json:"batch_max_size,omitempty"`
BatchFlushIntervalMS *int64 `json:"batch_flush_interval_ms,omitempty"`
HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"`
HeartbeatIntervalMS *int64 `json:"heartbeat_interval_ms,omitempty"`
JournaldUnit *string `json:"journald_unit,omitempty"`
ExtraFilePaths []string `json:"extra_file_paths,omitempty"`
// LogRetentionDays is unlike every other field above: it configures
// nothing about the agent's own runtime behavior (agent_control.proto
// has no equivalent field, and the Rust agent never reads this) --
// it's a central policy tag read only by api/logretention, which
// treats the largest LogRetentionDays configured across any agent as
// a protective floor a non-owner's age-based log deletion request
// must not reach into (see logretention.AgentRetentionStore). Stored
// here anyway, in the same desired_override JSONB column and edited
// on the same per-agent config page, because "a setting attached to
// an agent" is exactly what it conceptually is, even though nothing
// ever ships it to the agent process itself.
LogRetentionDays *int `json:"log_retention_days,omitempty"`
// ServiceLogRetentionDays is LogRetentionDays' per-service refinement:
// this host's `logs` rows are tagged with a `service` value (e.g.
// "nginx", "smtp", "ufw" -- see storage/migrations/0001_create_logs_table.sql
// and /docs/agent-management-design.md's note that distinct services
// on one host come from running separate agent processes, each with
// its own agent.toml `service`), and an operator may want to keep one
// service's logs longer than the rest of the host's default. A
// service present here overrides LogRetentionDays for that service
// only; every other service on the host still falls back to
// LogRetentionDays (or no floor at all if that's unset too). Same
// "never shipped to the agent process, central policy metadata only"
// posture as LogRetentionDays -- see logretention.AgentRetentionStore.
ServiceLogRetentionDays map[string]int `json:"service_log_retention_days,omitempty"`
}
type Agent struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Host string `json:"host"`
Service string `json:"service"`
AgentVersion string `json:"agent_version"`
SourceKind string `json:"source_kind"`
SourceDetail string `json:"source_detail"`
BatchMaxSize int64 `json:"batch_max_size"`
BatchFlushIntervalMS int64 `json:"batch_flush_interval_ms"`
HeartbeatEnabled bool `json:"heartbeat_enabled"`
HeartbeatIntervalMS int64 `json:"heartbeat_interval_ms"`
FirstSeenAt time.Time `json:"first_seen_at"`
LastSeenAt time.Time `json:"last_seen_at"`
DesiredOverride *ConfigOverride `json:"desired_override,omitempty"`
DesiredOverrideVersion string `json:"desired_override_version,omitempty"`
AppliedOverrideVersion string `json:"applied_override_version"`
// Pending is computed, not stored: an override exists
// (DesiredOverrideVersion != "") that the agent hasn't reported
// applying yet (AppliedOverrideVersion doesn't match). This is what
// the web UI's "pending"/"applied" indicator (task selected:
// "+ Remote config editing") reads directly, rather than
// recomputing the same string comparison itself.
Pending bool `json:"pending"`
UpdatedBy string `json:"updated_by,omitempty"`
// PendingCommand is "" when nothing is queued, or CommandRestart
// while a restart hasn't yet been delivered to the agent. Unlike
// Pending (config), there's no way to observe "delivered" from this
// table alone -- ingest clears pending_command the instant it hands
// the command out (see ingest/internal/agentregistry.Registry.
// CheckIn), so PendingCommand flipping back to "" just as plausibly
// means "delivered a moment ago" as "never issued." CommandIssuedAt
// is what the web UI shows instead, as a last-issued record.
PendingCommand string `json:"pending_command,omitempty"`
CommandIssuedAt *time.Time `json:"command_issued_at,omitempty"`
CommandIssuedBy string `json:"command_issued_by,omitempty"`
}
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
const selectColumns = `
id, tenant_id, host, service,
reported_agent_version, reported_source_kind, reported_source_detail,
reported_batch_max_size, reported_batch_flush_ms,
reported_heartbeat_on, reported_heartbeat_ms,
first_seen_at, last_seen_at,
desired_override, desired_override_version, applied_override_version, updated_by,
pending_command, command_issued_at, command_issued_by`
func (s *Store) List(ctx context.Context, tenantID string) ([]Agent, error) {
rows, err := s.pool.Query(ctx, `
SELECT `+selectColumns+`
FROM agents WHERE tenant_id = $1 ORDER BY host`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Agent
for rows.Next() {
a, err := scanAgent(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *Store) Get(ctx context.Context, tenantID, host string) (*Agent, error) {
rows, err := s.pool.Query(ctx, `
SELECT `+selectColumns+`
FROM agents WHERE tenant_id = $1 AND host = $2`, tenantID, host)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
if err := rows.Err(); err != nil {
return nil, err
}
return nil, ErrNotFound
}
a, err := scanAgent(rows)
if err != nil {
return nil, err
}
return &a, nil
}
// SetOverride writes a new desired override for host, generating a
// fresh version stamp -- overwrites any previous override wholesale
// (this is "set the desired config," not "patch a few fields into
// whatever was there," so a caller building a partial edit must have
// already merged it against the current value, same as any other PUT
// endpoint in this codebase). Returns ErrNotFound if the agent has
// never checked in (nothing to target an override at yet -- an
// override for a host ingest has never seen would be silently
// unreachable).
func (s *Store) SetOverride(ctx context.Context, tenantID, host string, override ConfigOverride, updatedBy string) (*Agent, error) {
version := newVersion()
data, err := json.Marshal(override)
if err != nil {
return nil, err
}
tag, err := s.pool.Exec(ctx, `
UPDATE agents SET desired_override = $1, desired_override_version = $2, updated_by = $3
WHERE tenant_id = $4 AND host = $5`,
data, version, updatedBy, tenantID, host)
if err != nil {
return nil, err
}
if tag.RowsAffected() == 0 {
return nil, ErrNotFound
}
return s.Get(ctx, tenantID, host)
}
// IssueCommand queues a one-shot lifecycle command for host, delivered
// on its next CheckIn and cleared atomically by ingest the instant
// that happens (see ingest/internal/agentregistry.Registry.CheckIn) --
// unlike SetOverride, there's no "applied" confirmation to wait for,
// since a restarting agent's process is gone before it could send one.
// command_issued_at/by are overwritten on every call, forming a
// last-issued record even after pending_command itself clears.
func (s *Store) IssueCommand(ctx context.Context, tenantID, host, command, issuedBy string) (*Agent, error) {
tag, err := s.pool.Exec(ctx, `
UPDATE agents SET pending_command = $1, command_issued_at = now(), command_issued_by = $2
WHERE tenant_id = $3 AND host = $4`,
command, issuedBy, tenantID, host)
if err != nil {
return nil, err
}
if tag.RowsAffected() == 0 {
return nil, ErrNotFound
}
return s.Get(ctx, tenantID, host)
}
// ClearOverride reverts an agent to running its local agent.toml
// untouched -- the next CheckIn gets has_override=false.
func (s *Store) ClearOverride(ctx context.Context, tenantID, host string) error {
tag, err := s.pool.Exec(ctx, `
UPDATE agents SET desired_override = NULL, desired_override_version = NULL, updated_by = NULL
WHERE tenant_id = $1 AND host = $2`, tenantID, host)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanAgent(row rowScanner) (Agent, error) {
var a Agent
var desiredOverride []byte
var desiredVersion, updatedBy, pendingCommand, commandIssuedBy *string
if err := row.Scan(
&a.ID, &a.TenantID, &a.Host, &a.Service,
&a.AgentVersion, &a.SourceKind, &a.SourceDetail,
&a.BatchMaxSize, &a.BatchFlushIntervalMS,
&a.HeartbeatEnabled, &a.HeartbeatIntervalMS,
&a.FirstSeenAt, &a.LastSeenAt,
&desiredOverride, &desiredVersion, &a.AppliedOverrideVersion, &updatedBy,
&pendingCommand, &a.CommandIssuedAt, &commandIssuedBy,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Agent{}, ErrNotFound
}
return Agent{}, err
}
if updatedBy != nil {
a.UpdatedBy = *updatedBy
}
if pendingCommand != nil {
a.PendingCommand = *pendingCommand
}
if commandIssuedBy != nil {
a.CommandIssuedBy = *commandIssuedBy
}
if desiredVersion != nil {
a.DesiredOverrideVersion = *desiredVersion
a.Pending = *desiredVersion != a.AppliedOverrideVersion
if len(desiredOverride) > 0 {
var override ConfigOverride
if err := json.Unmarshal(desiredOverride, &override); err != nil {
return Agent{}, err
}
a.DesiredOverride = &override
}
}
return a, nil
}
// newVersion is an opaque, monotonically-informative-enough stamp for
// DesiredOverride.version -- a timestamp, not a counter, since Store
// has no prior version to increment from without an extra read. Never
// interpreted as a real time value by the agent (see
// agent_control.proto's DesiredOverride.version comment) -- just needs
// to change on every edit.
func newVersion() string {
return time.Now().UTC().Format(time.RFC3339Nano)
}