Files
cairnobs/enterprise/internal/groundingregistry/registry.go
T
jcoffey-dev 13cf9a30cb Rebrand: Sentry -> Cairn OBS
Full rebrand across cosmetic branding, code identifiers, and
infrastructure/data-plane naming, using the supplied Cairn OBS logo
package. Cosmetic: favicon/logo swap (also closes a stale license-audit
finding -- the old favicon was SvelteKit's unreplaced scaffold logo),
new centered welcome landing page, larger/legible sidebar logo, page
titles, CLAUDE.md/README/docs prose.

Code identifiers: Go module path github.com/sentry/sentry ->
github.com/cairnobs/cairnobs across all 13 modules and ~91 files (protoc
regenerated); Rust crates sentry-agent/sentry-parser/sentry-search ->
cairnobs-*; CLI sentryctl -> cairnobsctl; Terraform provider fully
renamed (sentry_dashboard etc. -> cairnobs_dashboard, provider type,
env vars); every session/auth cookie name; agent config paths and
Windows service identity.

Deliberately preserved: the gRPC wire protocol's protobuf packages
(sentry.logs.v1, sentry.agent.v1) and their Go import directory
(proto/sentry/...) -- renaming the wire-level package would break every
currently-deployed agent binary (confirmed two real hosts, including
mail.inbuxa.com, are actively streaming through this exact contract)
until rebuilt and redeployed in lockstep with an ingest cutover. Only
the Go module path wrapping the generated code changes.

Infrastructure: every docker-compose container name (root and three
component-level compose files); the Helm chart (directory, Chart.yaml,
named-template helpers, all templates, values.yaml image repos);
Kubernetes Operator (CRD group sentry.io -> cairnobs.io, both CRD YAML
files, Go identifiers, RBAC markers); the coupled enterprise/tenantcrd
package. Caught and fixed real path-coupling bugs along the way: the
Helm chart's search/ingest volume mounts and the dev-only-credential
detection constant vs. docker-compose.yml's literal values had to move
together or a security warning would have silently stopped firing.

Data plane: Postgres database sentry_metadata -> cairnobs_metadata and
role sentry -> cairnobs; ClickHouse database sentry -> cairnobs; Kafka
topic sentry.logs.raw -> cairnobs.logs.raw and its consumer groups.
Source-level defaults, docker-compose.yml, and every migrate.sh/
provision script default updated together; already-applied migration
files left untouched per this repo's immutable-migration convention.

Verified at every layer: all 13 Go modules build/vet/test clean, both
Rust workspaces (agent, search) build/clippy/test clean, npm run check/
build clean, docker compose config validates on all four compose files.
Live-verified against a real docker stack multiple times through this
work, including a final fresh-volume run confirming the actual renamed
Postgres database/role, ClickHouse database, and Kafka topic all work
end to end with a real login and query, zero console errors.
2026-08-21 20:53:32 -07:00

145 lines
5.4 KiB
Go

// Package groundingregistry gives each active tenant its own schema-
// grounding snapshot (Phase 7 task 3) in a multi-tenant deployment,
// mirroring enterprise/internal/chwriter.Registry's per-tenant-instance
// shape. It exists because api/ai/grounding.Service is deliberately
// tenant-agnostic (it just wraps whatever executor.SQLRunner it's given
// and caches one snapshot) -- a multi-tenant deployment needs many
// snapshots, one per tenant, refreshed independently.
//
// The underlying SQLRunner every tenant's Service samples through is the
// *same* chrunner.Registry instance shared across all of them: chrunner
// resolves which tenant's actual ClickHouse connection to use from the
// context.Context passed to RunSQL, not from anything this package
// stores per tenant -- see chrunner.Registry.RunSQL's doc comment. So
// "one grounding.Service per tenant" doesn't mean one ClickHouse
// connection per tenant here (chrunner already owns that); it means one
// cached snapshot per tenant, refreshed by calling that tenant's
// Service.Refresh with a context stamped with that tenant's identity via
// api/authz.WithIdentity -- the same "construct our own request context
// outside an HTTP handler" pattern that function's doc comment names
// this exact kind of caller as being for.
package groundingregistry
import (
"context"
"log/slog"
"sync"
"time"
"github.com/cairnobs/cairnobs/api/ai/grounding"
"github.com/cairnobs/cairnobs/api/ai/provider"
"github.com/cairnobs/cairnobs/api/authz"
"github.com/cairnobs/cairnobs/api/querylang/executor"
)
// TenantLister returns the currently-active tenant IDs to sample --
// a narrow function type rather than an rbacstore dependency, same
// reasoning chwriter.Registry's SourceLister gives: this package
// shouldn't need to import rbacstore just to know its return type.
// enterprise-api's main.go supplies one backed by
// rbacstore.ListProvisionedDataSources, the same source chrunner/
// chwriter's own registries already refresh from.
type TenantLister func(ctx context.Context) ([]string, error)
// Registry holds one grounding.Service per active tenant, all sharing
// the same underlying SQLRunner (chrunner.Registry).
type Registry struct {
runner executor.SQLRunner
mu sync.RWMutex
services map[string]*grounding.Service
}
func New(runner executor.SQLRunner) *Registry {
return &Registry{runner: runner, services: make(map[string]*grounding.Service)}
}
// SchemaContextFor returns tenant's cached grounding snapshot, or a
// zero-valued SchemaContext if that tenant hasn't been sampled yet (new
// tenant, not yet seen by a refresh cycle) -- same "absence is normal,
// not an error" posture grounding.Service.Current documents.
func (r *Registry) SchemaContextFor(tenantID string) provider.SchemaContext {
r.mu.RLock()
svc, ok := r.services[tenantID]
r.mu.RUnlock()
if !ok {
return provider.SchemaContext{}
}
return svc.Current()
}
// SchemaContext implements aiapi.SchemaContextSource, resolving the
// tenant from ctx the same way chrunner.RunSQL does -- the multi-tenant
// counterpart to grounding.Service's own same-named method, which has
// no tenant to resolve in a single-tenant deployment. An unauthenticated
// or tenant-less context (shouldn't happen behind aiapi's RoleViewer
// auth wrapper, but handled rather than assumed) returns a zero-valued
// SchemaContext, same as an unseen tenant -- absence is normal here, not
// worth a panic or a swallowed error over.
func (r *Registry) SchemaContext(ctx context.Context) provider.SchemaContext {
id, ok := authz.IdentityFromContext(ctx)
if !ok || id.TenantID == "" {
return provider.SchemaContext{}
}
return r.SchemaContextFor(id.TenantID)
}
// StartRefreshing lists active tenants and refreshes each one's
// grounding snapshot, immediately and then on interval, until ctx is
// cancelled -- same shape as chwriter.Registry.StartRefreshing. A
// newly-active tenant gets a Service the first time it appears in
// lister's output; a tenant that's no longer listed keeps its last
// snapshot rather than being torn down (grounding data going briefly
// stale for a deprovisioned tenant is harmless -- unlike a ClickHouse
// writer connection, there's no credential to leak or clean up here).
func (r *Registry) StartRefreshing(ctx context.Context, lister TenantLister, interval time.Duration, logger *slog.Logger) {
r.refreshAll(ctx, lister, logger)
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.refreshAll(ctx, lister, logger)
}
}
}()
}
func (r *Registry) refreshAll(ctx context.Context, lister TenantLister, logger *slog.Logger) {
tenantIDs, err := lister(ctx)
if err != nil {
if logger != nil {
logger.Error("groundingregistry: listing active tenants", "error", err)
}
return
}
for _, tenantID := range tenantIDs {
svc := r.serviceFor(tenantID)
tenantCtx := authz.WithIdentity(ctx, authz.Identity{TenantID: tenantID, Role: authz.RoleService})
if err := svc.Refresh(tenantCtx); err != nil && logger != nil {
logger.Error("groundingregistry: refreshing tenant", "tenant", tenantID, "error", err)
}
}
}
func (r *Registry) serviceFor(tenantID string) *grounding.Service {
r.mu.RLock()
svc, ok := r.services[tenantID]
r.mu.RUnlock()
if ok {
return svc
}
r.mu.Lock()
defer r.mu.Unlock()
if svc, ok := r.services[tenantID]; ok { // re-check under write lock
return svc
}
svc = grounding.New(r.runner)
r.services[tenantID] = svc
return svc
}