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:
2026-08-21 15:54:58 -07:00
parent 087c52a64f
commit 5ff2e5bb60
10 changed files with 878 additions and 410 deletions
+66
View File
@@ -376,6 +376,72 @@ func TestHandleSetConfigRejectsInvalidLogRetentionDays(t *testing.T) {
}
}
// TestHandleSetConfigServiceLogRetentionDaysRequiresOwner mirrors
// TestHandleSetConfigLogRetentionDaysRequiresOwner exactly -- the
// per-service map has the same owner-only, no-safe-direction gate as
// the single host-level value.
func TestHandleSetConfigServiceLogRetentionDaysRequiresOwner(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
admin := NewHandler(discardLogger(), s, fakeAuthorizer{role: authz.RoleAdmin}, nil)
owner := NewHandler(discardLogger(), s, fakeAuthorizer{role: authz.RoleOwner}, nil)
rec := doRequest(t, admin, "PUT", "/agents/web-01/config", ConfigOverride{
ServiceLogRetentionDays: map[string]int{"smtp": 365},
})
if rec.Code != http.StatusForbidden {
t.Fatalf("admin setting service_log_retention_days: status = %d, want 403", rec.Code)
}
rec = doRequest(t, owner, "PUT", "/agents/web-01/config", ConfigOverride{
ServiceLogRetentionDays: map[string]int{"smtp": 365},
})
if rec.Code != http.StatusOK {
t.Fatalf("owner setting service_log_retention_days: status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var got Agent
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decoding response: %v", err)
}
if got.DesiredOverride == nil || got.DesiredOverride.ServiceLogRetentionDays["smtp"] != 365 {
t.Fatalf("stored override = %+v, want service_log_retention_days[smtp]=365", got.DesiredOverride)
}
// Changing the value of an existing entry is gated the same as
// adding a new one.
rec = doRequest(t, admin, "PUT", "/agents/web-01/config", ConfigOverride{
ServiceLogRetentionDays: map[string]int{"smtp": 30},
})
if rec.Code != http.StatusForbidden {
t.Fatalf("admin changing service_log_retention_days: status = %d, want 403", rec.Code)
}
// Clearing it (omitting the field) is gated too.
rec = doRequest(t, admin, "PUT", "/agents/web-01/config", ConfigOverride{})
if rec.Code != http.StatusForbidden {
t.Fatalf("admin clearing service_log_retention_days: status = %d, want 403", rec.Code)
}
}
func TestHandleSetConfigRejectsInvalidServiceLogRetentionDays(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
owner := NewHandler(discardLogger(), s, fakeAuthorizer{role: authz.RoleOwner}, nil)
cases := []map[string]int{
{"smtp": 0},
{"smtp": -1},
{"smtp": 3651},
{"": 30},
}
for _, days := range cases {
rec := doRequest(t, owner, "PUT", "/agents/web-01/config", ConfigOverride{ServiceLogRetentionDays: days})
if rec.Code != http.StatusBadRequest {
t.Errorf("service_log_retention_days=%v: status = %d, want 400", days, rec.Code)
}
}
}
func TestHandleSetConfigUnknownHostIsNotFound(t *testing.T) {
h := newTestHandler(newFakeStore())
interval := int64(30000)