Files
cairnobs/enterprise/cmd/enterprise-api/main.go
T
jcoffey-dev 4f0da1ae5e 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.
2026-08-16 18:08:51 -07:00

358 lines
14 KiB
Go

// Command enterprise-api is the multi-tenant-aware alternative to
// api/cmd/api -- same POST /query and /dashboards surface (it reuses
// api/queryapi and api/dashboards's actual Handler types unchanged), but
// backed by a per-tenant ClickHouse connection registry
// (enterprise/internal/chrunner), a per-tenant Tantivy search client
// (enterprise/internal/searchclient), and a real audit logger
// (enterprise/internal/audit.QueryAPILogger) instead of the single
// shared connections and nil audit logger api/cmd/api's binary carries.
//
// Why a second binary, not a flag on api/cmd/api: api is AGPL core and
// must never import enterprise/ (hack/check-tenant-boundary.sh enforces
// this) -- there is no way for api's own binary to construct an
// enterprise-supplied chrunner.Registry or audit.Store without that
// import. enterprise/ importing api/ is the allowed direction, so this
// binary lives here instead, wiring core's handler types together with
// enterprise's tenant-aware implementations. A single-tenant deployment
// keeps running plain api/cmd/api, unchanged; a real multi-tenant
// deployment runs this one instead.
//
// Both Helm (deploy/helm/sentry/templates/api.yaml vs
// enterprise-api.yaml) and docker-compose.yml (COMPOSE_PROFILES) now
// make this the deployment-topology choice, not just a binary sitting
// unused alongside api's -- see this repo's CLAUDE.md. `search`'s write
// side (ingest, and by extension the Redpanda consumer search itself
// runs) is still not tenant-aware -- see enterprise/internal/searchclient
// and search/src/registry.rs's doc comments, and
// /docs/security/threat-model.md.
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
chdriver "github.com/ClickHouse/clickhouse-go/v2"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/sentry/sentry/api/agents"
"github.com/sentry/sentry/api/ai/aiapi"
"github.com/sentry/sentry/api/ai/provider/ollama"
"github.com/sentry/sentry/api/ai/router"
"github.com/sentry/sentry/api/authz"
"github.com/sentry/sentry/api/dashboards"
"github.com/sentry/sentry/api/httpserver"
"github.com/sentry/sentry/api/queryapi"
"github.com/sentry/sentry/enterprise/internal/apiconfig"
"github.com/sentry/sentry/enterprise/internal/audit"
"github.com/sentry/sentry/enterprise/internal/chrunner"
"github.com/sentry/sentry/enterprise/internal/groundingregistry"
"github.com/sentry/sentry/enterprise/internal/rbacstore"
"github.com/sentry/sentry/enterprise/internal/searchclient"
"github.com/sentry/sentry/enterprise/internal/tenantcrd"
"github.com/sentry/sentry/enterprise/internal/tenantprovision"
)
// groundingRefreshInterval matches api/cmd/api's own constant of the
// same name and reasoning -- see that file's doc comment.
const groundingRefreshInterval = time.Minute
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
cfg, err := apiconfig.Load()
if err != nil {
logger.Error("loading config", "error", err)
os.Exit(1)
}
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
os.Exit(runHealthcheck(cfg.HTTPListenAddr))
}
provisionTenant := flag.String("provision-tenant", "", "provision ClickHouse for the named tenant id (creating it in rbacstore if needed) and exit")
provisionDisplayName := flag.String("display-name", "", "display name for -provision-tenant, if the tenant doesn't already exist in rbacstore")
flag.Parse()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
pgDSN := fmt.Sprintf("postgres://%s:%s@%s/%s", cfg.Postgres.Username, cfg.Postgres.Password, cfg.Postgres.Addr, cfg.Postgres.Database)
pgPool, err := pgxpool.New(ctx, pgDSN)
if err != nil {
logger.Error("opening postgres pool", "error", err)
os.Exit(1)
}
defer pgPool.Close()
if err := pgPool.Ping(ctx); err != nil {
logger.Error("pinging postgres", "error", err)
os.Exit(1)
}
rbac := rbacstore.NewStore(pgPool)
if *provisionTenant != "" {
os.Exit(runProvisionTenant(ctx, logger, cfg, rbac, *provisionTenant, *provisionDisplayName))
}
adminConn, err := chdriver.Open(&chdriver.Options{
Addr: []string{cfg.ClickHouseAddr},
Auth: chdriver.Auth{Database: "default", Username: cfg.ClickHouseAdmin.Username, Password: cfg.ClickHouseAdmin.Password},
})
if err != nil {
logger.Error("opening clickhouse admin connection", "error", err)
os.Exit(1)
}
defer adminConn.Close()
sources, err := rbac.ListProvisionedDataSources(ctx)
if err != nil {
logger.Error("listing provisioned data sources", "error", err)
os.Exit(1)
}
chrunnerSources := make([]chrunner.DataSource, 0, len(sources))
for _, s := range sources {
if s.ClickHouseUsername == nil || s.ClickHousePassword == nil {
continue // ListProvisionedDataSources already filters these out; defensive only.
}
chrunnerSources = append(chrunnerSources, chrunner.DataSource{
TenantID: s.TenantID, Database: s.ClickHouseDatabaseName,
Username: *s.ClickHouseUsername, Password: *s.ClickHousePassword,
})
}
logger.Info("loaded tenant data sources", "count", len(chrunnerSources))
registry, err := chrunner.New(ctx, cfg.ClickHouseAddr, chrunnerSources)
if err != nil {
logger.Error("building tenant connection registry", "error", err)
os.Exit(1)
}
defer registry.Close()
search, err := searchclient.Dial(cfg.SearchGRPCAddr, rbac)
if err != nil {
logger.Error("dialing search service", "error", err)
os.Exit(1)
}
defer search.Close()
var authorizer authz.Authorizer
if cfg.EnterpriseAuthURL != "" {
authorizer = authz.NewHTTPAuthorizer(cfg.EnterpriseAuthURL)
} else {
logger.Warn("ENTERPRISE_AUTH_URL is not set -- RBAC enforcement is a no-op, but tenant query routing still requires a resolved identity, so every /query request will be refused (see chrunner.Registry.RunSQL)")
}
auditWriterDSN := fmt.Sprintf("postgres://%s:%s@%s/%s", cfg.AuditWriter.Username, cfg.AuditWriter.Password, cfg.Postgres.Addr, cfg.Postgres.Database)
auditPool, err := pgxpool.New(ctx, auditWriterDSN)
if err != nil {
logger.Error("opening audit_writer postgres pool", "error", err)
os.Exit(1)
}
defer auditPool.Close()
if err := auditPool.Ping(ctx); err != nil {
logger.Error("pinging audit_writer postgres pool", "error", err)
os.Exit(1)
}
auditLogger := audit.NewQueryAPILogger(audit.NewStore(auditPool), audit.SourceAPI)
queryHandler := queryapi.NewHandler(logger, registry, search, cfg.QueryTimeout, auditLogger, authorizer)
dashboardsHandler := dashboards.NewHandler(logger, dashboards.NewStore(pgPool), authorizer, rbacstore.NewDashboardPermissions(rbac))
agentsHandler := agents.NewHandler(logger, agents.NewStore(pgPool), authorizer)
mux := http.NewServeMux()
queryHandler.RegisterRoutes(mux) // also registers GET /healthz
dashboardsHandler.RegisterRoutes(mux)
agentsHandler.RegisterRoutes(mux)
// Same "off unless OLLAMA_BASE_URL is set" gate as api/cmd/api --
// see that file's doc comment.
if cfg.AI.OllamaBaseURL != "" {
groundingReg := groundingregistry.New(registry)
groundingReg.StartRefreshing(ctx, rbac.ListActiveTenantIDs, groundingRefreshInterval, logger)
defaultProvider := ollama.New(cfg.AI.OllamaBaseURL, cfg.AI.OllamaModel)
aiRouter := router.New(defaultProvider)
if cfg.AI.OllamaFastModel != "" && cfg.AI.OllamaFastModel != cfg.AI.OllamaModel {
aiRouter.SetOperation(router.OpComplete, ollama.New(cfg.AI.OllamaBaseURL, cfg.AI.OllamaFastModel))
}
// Reuses the same audit_writer-role pool/store auditLogger above
// writes through -- same append-only audit_log table, new
// event_type (see metadata/migrations/0036).
interactionLogger := audit.NewAIInteractionLogger(audit.NewStore(auditPool), audit.SourceAPI)
aiHandler := aiapi.NewHandler(logger, aiRouter, groundingReg, authorizer, interactionLogger)
aiHandler.RegisterRoutes(mux)
logger.Info("ai routes enabled", "ollama_base_url", cfg.AI.OllamaBaseURL, "model", cfg.AI.OllamaModel)
}
srv := &http.Server{
Addr: cfg.HTTPListenAddr,
Handler: httpserver.WithCORS(mux, cfg.CORSAllowedOrigin),
}
errCh := make(chan error, 1)
go func() {
logger.Info("enterprise-api listening", "addr", cfg.HTTPListenAddr)
errCh <- srv.ListenAndServe()
}()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("graceful shutdown failed", "error", err)
}
case err := <-errCh:
if err != nil && err != http.ErrServerClosed {
logger.Error("server exited with error", "error", err)
os.Exit(1)
}
}
}
// runProvisionTenant is the operator action that actually closes
// /docs/phase-4-isolation-design.md's ordered provisioning gate: ensure
// the tenant row exists, ensure a data_sources row exists, provision
// ClickHouse (CREATE USER -> GRANT), persist the returned credentials,
// and only then mark the tenant active. Same "offline operator action,
// not a network-reachable endpoint" shape as enterprise-auth's
// -mint-service-token.
//
// If cfg.TenantCRDNamespace is set, this also syncs the real result into
// the Tenant CRD (enterprise/internal/tenantcrd) -- the "lightweight
// unification" of this mechanism with deploy/operator's Tenant CRD (see
// that package's doc comment). An already-active tenant no longer
// refuses outright: ClickHouse (re-)provisioning is still refused (that
// part is unchanged -- rotating a live credential would break every
// open connection for no benefit), but CR sync alone is safe to retry
// using the credentials already on file in rbacstore, which matters if
// a previous run's CR sync failed (or TENANT_CRD_NAMESPACE is being
// turned on for a tenant provisioned before this feature existed) and
// needs to be retried without touching ClickHouse again.
func runProvisionTenant(ctx context.Context, logger *slog.Logger, cfg apiconfig.Config, rbac *rbacstore.Store, tenantID, displayName string) int {
adminConn, err := chdriver.Open(&chdriver.Options{
Addr: []string{cfg.ClickHouseAddr},
Auth: chdriver.Auth{Database: "default", Username: cfg.ClickHouseAdmin.Username, Password: cfg.ClickHouseAdmin.Password},
})
if err != nil {
logger.Error("opening clickhouse admin connection", "error", err)
return 1
}
defer adminConn.Close()
tenant, err := rbac.GetTenant(ctx, tenantID)
if err != nil {
if err != rbacstore.ErrNotFound {
logger.Error("getting tenant", "error", err)
return 1
}
name := displayName
if name == "" {
name = tenantID
}
tenant, err = rbac.CreateTenant(ctx, tenantID, name)
if err != nil {
logger.Error("creating tenant", "error", err)
return 1
}
logger.Info("created tenant row", "tenant_id", tenantID)
}
alreadyActive := tenant.Status == "active"
if alreadyActive {
logger.Info("tenant is already active -- skipping ClickHouse (re-)provisioning, will still sync the Tenant CRD if TENANT_CRD_NAMESPACE is set", "tenant_id", tenantID)
}
dataSource, err := rbac.GetDataSourceForTenant(ctx, tenantID)
if err != nil {
if err != rbacstore.ErrNotFound {
logger.Error("getting data source", "error", err)
return 1
}
if alreadyActive {
// An active tenant with no data_sources row at all is
// inconsistent state runProvisionTenant's own gate should
// never have allowed -- fail loudly rather than silently
// provisioning ClickHouse for a tenant already marked
// active, which SetDataSourceClickHouseCredentials's own
// doc comment says must never happen twice.
logger.Error("tenant is active but has no data source row -- inconsistent state, refusing", "tenant_id", tenantID)
return 1
}
dataSource, err = rbac.CreateDataSource(ctx, tenantID, "default", tenantID, "/var/lib/sentry-search/tenants/"+tenantID)
if err != nil {
logger.Error("creating data source", "error", err)
return 1
}
}
var creds tenantprovision.Credentials
if alreadyActive {
if dataSource.ClickHouseUsername == nil || dataSource.ClickHousePassword == nil {
logger.Error("tenant is active but its data source has no ClickHouse credentials -- inconsistent state, refusing", "tenant_id", tenantID)
return 1
}
creds = tenantprovision.Credentials{Username: *dataSource.ClickHouseUsername, Password: *dataSource.ClickHousePassword}
} else {
if dataSource.ClickHouseUsername != nil {
logger.Error("data source already has ClickHouse credentials -- refusing to re-provision", "tenant_id", tenantID)
return 1
}
creds, err = tenantprovision.New(adminConn).ProvisionClickHouse(ctx, tenantID)
if err != nil {
logger.Error("provisioning clickhouse", "error", err)
return 1
}
if err := rbac.SetDataSourceClickHouseCredentials(ctx, dataSource.ID, creds.Username, creds.Password); err != nil {
logger.Error("persisting clickhouse credentials", "error", err)
return 1
}
if err := rbac.SetTenantStatus(ctx, tenantID, "active"); err != nil {
logger.Error("activating tenant", "error", err)
return 1
}
logger.Info("tenant provisioned and active", "tenant_id", tenantID, "clickhouse_database", tenantID, "clickhouse_username", creds.Username)
}
if cfg.TenantCRDNamespace != "" {
syncer, err := tenantcrd.New(cfg.TenantCRDNamespace)
if err != nil {
logger.Error("building tenant CRD syncer", "error", err)
return 1
}
if err := syncer.Sync(ctx, tenantID, tenant.DisplayName, dataSource.TantivyIndexPath, tenantcrd.Credentials{Username: creds.Username, Password: creds.Password}); err != nil {
logger.Error("syncing tenant CRD", "error", err)
return 1
}
logger.Info("synced tenant CRD", "tenant_id", tenantID, "namespace", cfg.TenantCRDNamespace)
}
return 0
}
func runHealthcheck(listenAddr string) int {
addr := listenAddr
if strings.HasPrefix(addr, ":") {
addr = "localhost" + addr
}
client := http.Client{Timeout: 3 * time.Second}
resp, err := client.Get("http://" + addr + "/healthz")
if err != nil {
return 1
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 1
}
return 0
}