Add agent inventory, management, and remote config

Extends the heartbeat mechanism with a second gRPC service on the same
mTLS channel (AgentControl.CheckIn, agent-initiated on the existing
heartbeat ticker -- still push-only, no inbound port on any agent) so
an agent reports its running config and can pick up an operator-set
override. A new web UI section (/agents) lists every agent that's
checked in, shows its reported config, and lets an operator edit a
narrow, deliberately-scoped subset remotely: batch/heartbeat tuning,
and (journald sources only) the unit filter.

TLS material and the ingest endpoint are never reportable or remotely
editable, by proto shape rather than a validation rule -- a bad or
malicious edit there could permanently strand an agent or redirect
where its logs go, unlike every other editable field, which only
degrades behavior.

An override lives only in the agent's memory (agent.toml is never
rewritten) and re-syncs on the agent's own schedule; changing the
journald filter aborts and respawns the source task since there's no
other way to change what's being tailed. Building the hot-reload path
surfaced a real, independent, pre-existing bug: shutdown was using
poll_timeout(), which only drains once flush_interval has elapsed,
silently dropping anything buffered more recently on every graceful
shutdown that landed between flushes -- fixed with a new unconditional
Batcher::flush_all(), now used at both shutdown and hot-reload.

Verified live end-to-end against a real stack: an edited heartbeat
interval changed a running agent's actual send cadence within one
check-in cycle (confirmed by the real timestamps landing in
ClickHouse), and an edited journald filter triggered a real source
restart, both reflected back in the next reported-config snapshot.

See /docs/agent-management-design.md.
This commit is contained in:
2026-08-16 18:08:51 -07:00
parent 4df6931869
commit 4f0da1ae5e
29 changed files with 2618 additions and 53 deletions
+175
View File
@@ -0,0 +1,175 @@
package agents
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/sentry/sentry/api/authz"
)
// store is the narrow interface Handler depends on -- *Store (store.go)
// is the production implementation; tests use a fake, same pattern as
// dashboards.store/queryapi's SQLRunner.
type store interface {
List(ctx context.Context, tenantID string) ([]Agent, error)
Get(ctx context.Context, tenantID, host string) (*Agent, error)
SetOverride(ctx context.Context, tenantID, host string, override ConfigOverride, updatedBy string) (*Agent, error)
ClearOverride(ctx context.Context, tenantID, host string) error
}
type Handler struct {
logger *slog.Logger
store store
authorizer authz.Authorizer
}
func NewHandler(logger *slog.Logger, store store, authorizer authz.Authorizer) *Handler {
return &Handler{logger: logger, store: store, authorizer: authorizer}
}
// RegisterRoutes: viewing inventory is RoleViewer (same bar as viewing
// a dashboard); editing an agent's remote config is RoleEditor -- an
// operational-tuning action, not an admin-only one, matching the RBAC
// matrix's treatment of alert rules/notification targets rather than
// user/role management.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /agents", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleList))
mux.HandleFunc("GET /agents/{host}", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleGet))
mux.HandleFunc("PUT /agents/{host}/config", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleSetConfig))
mux.HandleFunc("DELETE /agents/{host}/config", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleClearConfig))
}
// tenantID mirrors dashboards.Handler.tenantID exactly -- resolved from
// the authenticated identity, never from a client-supplied field
// (there isn't one here to begin with; host alone identifies an agent
// within a tenant).
func (h *Handler) tenantID(r *http.Request) string {
if id, ok := authz.IdentityFromContext(r.Context()); ok && id.TenantID != "" {
return id.TenantID
}
return "default"
}
func (h *Handler) updatedBy(r *http.Request) string {
if id, ok := authz.IdentityFromContext(r.Context()); ok {
return id.UserID
}
return ""
}
func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) {
list, err := h.store.List(r.Context(), h.tenantID(r))
if err != nil {
h.logger.Error("listing agents", "error", err)
writeError(w, http.StatusInternalServerError, "listing agents failed")
return
}
writeJSON(w, http.StatusOK, list)
}
func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
a, err := h.store.Get(r.Context(), h.tenantID(r), r.PathValue("host"))
if err != nil {
h.writeStoreErr(w, err, "getting agent")
return
}
writeJSON(w, http.StatusOK, a)
}
// setConfigRequest is deliberately the same shape as ConfigOverride
// (Handler just decodes straight into it) -- every field optional,
// unset means "no override for this field." A caller changing just one
// field (e.g. only heartbeat_interval_ms) must still send the fields
// they want to KEEP as an override alongside it, since SetOverride
// replaces the whole stored override -- the web UI's edit form always
// reads the agent's current DesiredOverride first and PUTs back the
// full merged set, same pattern any other "edit form that PUTs a whole
// resource" in this codebase already uses (e.g. dashboards' PUT).
func (h *Handler) handleSetConfig(w http.ResponseWriter, r *http.Request) {
var override ConfigOverride
if !decodeJSON(w, r, &override) {
return
}
if err := validateOverride(override); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
a, err := h.store.SetOverride(r.Context(), h.tenantID(r), r.PathValue("host"), override, h.updatedBy(r))
if err != nil {
h.writeStoreErr(w, err, "setting agent config")
return
}
writeJSON(w, http.StatusOK, a)
}
func (h *Handler) handleClearConfig(w http.ResponseWriter, r *http.Request) {
if err := h.store.ClearOverride(r.Context(), h.tenantID(r), r.PathValue("host")); err != nil {
h.writeStoreErr(w, err, "clearing agent config")
return
}
w.WriteHeader(http.StatusNoContent)
}
// validateOverride rejects the two footguns a naive remote-config-edit
// feature could otherwise ship: a batch/heartbeat interval of 0 would
// mean "flush constantly"/"heartbeat constantly," hammering ingest and
// the agent's own CPU for no operator-intended reason -- floors match
// this codebase's other real floors (alerting's own
// eval_interval_seconds >= 30, found live during the heartbeat feature
// this builds on). There is deliberately no validation here for
// tls/ingest fields, because ConfigOverride has no such fields at all
// -- ingest connection details are not a remotely-editable dimension of
// an agent's config, full stop (see /docs/agent-management-design.md's
// security boundary section).
func validateOverride(o ConfigOverride) error {
if o.BatchMaxSize != nil && *o.BatchMaxSize < 1 {
return errors.New("batch_max_size must be at least 1")
}
if o.BatchFlushIntervalMS != nil && *o.BatchFlushIntervalMS < 100 {
return errors.New("batch_flush_interval_ms must be at least 100")
}
if o.HeartbeatIntervalMS != nil && *o.HeartbeatIntervalMS < 5000 {
return errors.New("heartbeat_interval_ms must be at least 5000 (5s)")
}
return nil
}
func (h *Handler) writeStoreErr(w http.ResponseWriter, err error, action string) {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "agent not found")
return
}
h.logger.Error(action, "error", err)
writeError(w, http.StatusInternalServerError, action+" failed")
}
const maxBodyBytes = 1 << 20 // 1 MiB, same cap as queryapi/dashboards
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
type errorResponse struct {
Error string `json:"error"`
}
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
}
+218
View File
@@ -0,0 +1,218 @@
package agents
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/sentry/sentry/api/authz"
)
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// fakeStore enforces tenant scoping the same way store.go's real
// pgx-backed Store does (WHERE tenant_id = ...) -- a lookup for the
// right host under the wrong tenant behaves exactly like a missing
// host, never a distinguishable "found but wrong tenant" error, so
// handler_test.go's tenant-scoping tests exercise real behavior.
type fakeStore struct {
agents map[string]*Agent // keyed by tenantID+"/"+host
}
func newFakeStore() *fakeStore {
return &fakeStore{agents: map[string]*Agent{}}
}
func (f *fakeStore) put(a Agent) {
f.agents[a.TenantID+"/"+a.Host] = &a
}
func (f *fakeStore) List(_ context.Context, tenantID string) ([]Agent, error) {
var out []Agent
for _, a := range f.agents {
if a.TenantID == tenantID {
out = append(out, *a)
}
}
return out, nil
}
func (f *fakeStore) Get(_ context.Context, tenantID, host string) (*Agent, error) {
a, ok := f.agents[tenantID+"/"+host]
if !ok {
return nil, ErrNotFound
}
cp := *a
return &cp, nil
}
func (f *fakeStore) SetOverride(_ context.Context, tenantID, host string, override ConfigOverride, updatedBy string) (*Agent, error) {
a, ok := f.agents[tenantID+"/"+host]
if !ok {
return nil, ErrNotFound
}
a.DesiredOverride = &override
a.DesiredOverrideVersion = "v-test"
a.Pending = true
a.UpdatedBy = updatedBy
cp := *a
return &cp, nil
}
func (f *fakeStore) ClearOverride(_ context.Context, tenantID, host string) error {
a, ok := f.agents[tenantID+"/"+host]
if !ok {
return ErrNotFound
}
a.DesiredOverride = nil
a.DesiredOverrideVersion = ""
a.Pending = false
a.UpdatedBy = ""
return nil
}
func newTestHandler(s *fakeStore) *Handler {
return NewHandler(discardLogger(), s, nil)
}
func doRequest(t *testing.T, h *Handler, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var req *http.Request
if body != nil {
b, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshaling request body: %v", err)
}
req = httptest.NewRequest(method, path, bytes.NewReader(b))
} else {
req = httptest.NewRequest(method, path, nil)
}
rec := httptest.NewRecorder()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
mux.ServeHTTP(rec, req)
return rec
}
func TestHandleListScopesToTenant(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
s.put(Agent{TenantID: "acme", Host: "web-02"})
h := newTestHandler(s)
rec := doRequest(t, h, "GET", "/agents", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var got []Agent
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decoding response: %v", err)
}
if len(got) != 1 || got[0].Host != "web-01" {
t.Fatalf("unexpected list: %+v", got)
}
}
func TestHandleGetNotFound(t *testing.T) {
h := newTestHandler(newFakeStore())
rec := doRequest(t, h, "GET", "/agents/nope", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestHandleGetCrossTenantIsNotFound(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "acme", Host: "web-01"})
h := newTestHandler(s) // default tenant (no authorizer/identity)
rec := doRequest(t, h, "GET", "/agents/web-01", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 (agent belongs to a different tenant)", rec.Code)
}
}
func TestHandleSetConfigRoundTrips(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
h := newTestHandler(s)
interval := int64(30000)
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{HeartbeatIntervalMS: &interval})
if rec.Code != http.StatusOK {
t.Fatalf("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.HeartbeatIntervalMS == nil || *got.DesiredOverride.HeartbeatIntervalMS != 30000 {
t.Fatalf("unexpected override: %+v", got.DesiredOverride)
}
if !got.Pending {
t.Fatal("expected pending=true right after setting a new override")
}
}
func TestHandleSetConfigRejectsTooSmallHeartbeatInterval(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
h := newTestHandler(s)
tooSmall := int64(100)
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{HeartbeatIntervalMS: &tooSmall})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestHandleSetConfigUnknownHostIsNotFound(t *testing.T) {
h := newTestHandler(newFakeStore())
interval := int64(30000)
rec := doRequest(t, h, "PUT", "/agents/nope/config", ConfigOverride{HeartbeatIntervalMS: &interval})
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestHandleClearConfig(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01", Pending: true})
h := newTestHandler(s)
rec := doRequest(t, h, "DELETE", "/agents/web-01/config", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
if s.agents["default/web-01"].Pending {
t.Fatal("expected override to be cleared")
}
}
func TestRequireEditorRoleForConfigWrites(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
authorizer := fakeAuthorizer{role: authz.RoleViewer}
h := NewHandler(discardLogger(), s, authorizer)
interval := int64(30000)
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{HeartbeatIntervalMS: &interval})
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (Viewer must not be able to edit agent config)", rec.Code)
}
}
type fakeAuthorizer struct {
role authz.Role
}
func (f fakeAuthorizer) Authorize(*http.Request) (authz.Identity, error) {
return authz.Identity{TenantID: "default", UserID: "u1", Role: f.role}, nil
}
+209
View File
@@ -0,0 +1,209 @@
// 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")
// 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"`
}
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"`
}
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`
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)
}
// 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 *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,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Agent{}, ErrNotFound
}
return Agent{}, err
}
if updatedBy != nil {
a.UpdatedBy = *updatedBy
}
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)
}