Finish the Cairn OBS rename through services, docs, and assets

The rename commit before this one covered module paths and the obvious
user-facing strings; this is the rest of it -- the places where "sentry"
was a default value, a filename, or a picture rather than a word in a
sentence.

Defaults that changed: CLICKHOUSE_DATABASE (sentry -> cairnobs),
POSTGRES_DATABASE (sentry_metadata -> cairnobs_metadata), and
POSTGRES_USERNAME (sentry -> cairnobs), across api/alerting/ingest and
the enterprise binaries, plus the compose files and migrate scripts that
create those objects. These are *defaults*, so a deployment that sets
them explicitly is unaffected -- but any deployment relying on the old
defaults must have its environment updated before it picks this up, or
it will come up pointing at a database that doesn't exist.

Also: the light-mode logo variants (the dark ones existed alone, so the
landing page and sidebar rendered a dark mark on a light background),
regenerated favicons, and the docs/README/threat-model prose that still
said Sentry.
This commit is contained in:
2026-08-22 16:12:08 -07:00
parent 13cf9a30cb
commit c920e0f2c4
68 changed files with 242 additions and 186 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
# alerting never needs /proto (it talks to /api over plain HTTP, no gRPC), # alerting never needs /proto (it talks to /api over plain HTTP, no gRPC),
# so unlike api/ingest/search this build context is just alerting/ itself, # so unlike api/ingest/search this build context is just alerting/ itself,
# same shape as cli/Dockerfile: # same shape as cli/Dockerfile:
# docker build -f alerting/Dockerfile -t sentry-alerting alerting/ # docker build -f alerting/Dockerfile -t cairnobs-alerting alerting/
FROM golang:1.25-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /src WORKDIR /src
COPY . . COPY . .
+4 -4
View File
@@ -11,7 +11,7 @@ correctness properties this implementation follows exactly).
POSTGRES_PASSWORD=cairnobs-dev-only API_QUERY_URL=http://localhost:8080 go run ./cmd/alerting POSTGRES_PASSWORD=cairnobs-dev-only API_QUERY_URL=http://localhost:8080 go run ./cmd/alerting
``` ```
Talks to the same `sentry_metadata` Postgres database as `/api` Talks to the same `cairnobs_metadata` Postgres database as `/api`
(different tables — see `/metadata/README.md`), and to `/api`'s (different tables — see `/metadata/README.md`), and to `/api`'s
`POST /query` over plain HTTP for rule evaluation. Never connects to `POST /query` over plain HTTP for rule evaluation. Never connects to
ClickHouse or Tantivy directly. ClickHouse or Tantivy directly.
@@ -48,8 +48,8 @@ only, not separate delivery paths.
|---|---| |---|---|
| `HTTP_LISTEN_ADDR` | `:8081` | | `HTTP_LISTEN_ADDR` | `:8081` |
| `POSTGRES_ADDR` | `localhost:5432` | | `POSTGRES_ADDR` | `localhost:5432` |
| `POSTGRES_DATABASE` | `sentry_metadata` | | `POSTGRES_DATABASE` | `cairnobs_metadata` |
| `POSTGRES_USERNAME` | `sentry` | | `POSTGRES_USERNAME` | `cairnobs` |
| `POSTGRES_PASSWORD` | (empty — must be set) | | `POSTGRES_PASSWORD` | (empty — must be set) |
| `API_QUERY_URL` | `http://localhost:8080` | | `API_QUERY_URL` | `http://localhost:8080` |
| `CORS_ALLOWED_ORIGIN` | `*` | | `CORS_ALLOWED_ORIGIN` | `*` |
@@ -94,5 +94,5 @@ go test ./...
``` ```
```sh ```sh
docker build -f Dockerfile -t sentry-alerting . # context is alerting/, not the repo root -- no /proto needed docker build -f Dockerfile -t cairnobs-alerting . # context is alerting/, not the repo root -- no /proto needed
``` ```
+2 -2
View File
@@ -72,8 +72,8 @@ func Load() (Config, error) {
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8081"), HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8081"),
Postgres: PostgresConfig{ Postgres: PostgresConfig{
Addr: getenv("POSTGRES_ADDR", "localhost:5432"), Addr: getenv("POSTGRES_ADDR", "localhost:5432"),
Database: getenv("POSTGRES_DATABASE", "sentry_metadata"), Database: getenv("POSTGRES_DATABASE", "cairnobs_metadata"),
Username: getenv("POSTGRES_USERNAME", "sentry"), Username: getenv("POSTGRES_USERNAME", "cairnobs"),
Password: getenv("POSTGRES_PASSWORD", ""), Password: getenv("POSTGRES_PASSWORD", ""),
}, },
APIQueryURL: getenv("API_QUERY_URL", "http://localhost:8080"), APIQueryURL: getenv("API_QUERY_URL", "http://localhost:8080"),
@@ -37,7 +37,7 @@ func integrationPool(t *testing.T) *pgxpool.Pool {
t.Skip("SESSIONCHECK_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test") t.Skip("SESSIONCHECK_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test")
} }
password := os.Getenv("SESSIONCHECK_TEST_POSTGRES_PASSWORD") password := os.Getenv("SESSIONCHECK_TEST_POSTGRES_PASSWORD")
dsn := fmt.Sprintf("postgres://sentry:%s@%s/sentry_metadata", password, addr) dsn := fmt.Sprintf("postgres://cairnobs:%s@%s/cairnobs_metadata", password, addr)
pool, err := pgxpool.New(context.Background(), dsn) pool, err := pgxpool.New(context.Background(), dsn)
if err != nil { if err != nil {
t.Fatalf("opening pool: %v", err) t.Fatalf("opening pool: %v", err)
+1 -1
View File
@@ -1,7 +1,7 @@
# Build context must be the repo root (sentry/), since this needs both # Build context must be the repo root (sentry/), since this needs both
# api/ and proto/ (api now speaks gRPC to /search, using proto's checked-in # api/ and proto/ (api now speaks gRPC to /search, using proto's checked-in
# Go bindings via the `replace` directive in api/go.mod): # Go bindings via the `replace` directive in api/go.mod):
# docker build -f api/Dockerfile -t sentry-api . # docker build -f api/Dockerfile -t cairnobs-api .
FROM golang:1.25-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /src WORKDIR /src
+2 -2
View File
@@ -46,7 +46,7 @@ Environment variables (see `internal/config/config.go`):
|---|---|---| |---|---|---|
| `HTTP_LISTEN_ADDR` | `:8080` | | | `HTTP_LISTEN_ADDR` | `:8080` | |
| `CLICKHOUSE_ADDR` | `localhost:9000` | Native protocol port | | `CLICKHOUSE_ADDR` | `localhost:9000` | Native protocol port |
| `CLICKHOUSE_DATABASE` / `_USERNAME` / `_PASSWORD` | `sentry` / `default` / `` | | | `CLICKHOUSE_DATABASE` / `_USERNAME` / `_PASSWORD` | `cairnobs` / `default` / `` | |
| `SEARCH_GRPC_ADDR` | `localhost:50052` | Must match `/search`'s `GRPC_LISTEN_ADDR` | | `SEARCH_GRPC_ADDR` | `localhost:50052` | Must match `/search`'s `GRPC_LISTEN_ADDR` |
| `QUERY_TIMEOUT_SECONDS` | `30` | Per-request timeout | | `QUERY_TIMEOUT_SECONDS` | `30` | Per-request timeout |
| `CORS_ALLOWED_ORIGIN` | `*` | Wide open by default since there's no auth yet; tighten together | | `CORS_ALLOWED_ORIGIN` | `*` | Wide open by default since there's no auth yet; tighten together |
@@ -66,7 +66,7 @@ go test ./...
```sh ```sh
# from the repo root, not api/ # from the repo root, not api/
docker build -f api/Dockerfile -t sentry-api . docker build -f api/Dockerfile -t cairnobs-api .
``` ```
## Testing notes ## Testing notes
+1 -1
View File
@@ -32,7 +32,7 @@ func integrationStore(t *testing.T) (*Store, *pgxpool.Pool) {
t.Skip("DASHBOARDS_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test") t.Skip("DASHBOARDS_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test")
} }
password := os.Getenv("DASHBOARDS_TEST_POSTGRES_PASSWORD") password := os.Getenv("DASHBOARDS_TEST_POSTGRES_PASSWORD")
dsn := fmt.Sprintf("postgres://sentry:%s@%s/sentry_metadata", password, addr) dsn := fmt.Sprintf("postgres://cairnobs:%s@%s/cairnobs_metadata", password, addr)
pool, err := pgxpool.New(context.Background(), dsn) pool, err := pgxpool.New(context.Background(), dsn)
if err != nil { if err != nil {
t.Fatalf("opening pool: %v", err) t.Fatalf("opening pool: %v", err)
+5 -5
View File
@@ -37,8 +37,8 @@ type LocalAuthConfig struct {
SessionTTL time.Duration SessionTTL time.Duration
// CookieDomain empty means a host-only cookie (fine for local dev, // CookieDomain empty means a host-only cookie (fine for local dev,
// where web/api are both localhost:<port>). Set to e.g. // where web/api are both localhost:<port>). Set to e.g.
// ".sentry.example.com" in production so the cookie is also sent to // ".cairnobs.example.com" in production so the cookie is also sent to
// api.sentry.example.com/alerting.sentry.example.com. // api.cairnobs.example.com/alerting.cairnobs.example.com.
CookieDomain string CookieDomain string
// CookieSecure defaults true (never sent over plain HTTP) -- // CookieSecure defaults true (never sent over plain HTTP) --
// deliberately opt-out via LOCAL_AUTH_COOKIE_SECURE=false, only // deliberately opt-out via LOCAL_AUTH_COOKIE_SECURE=false, only
@@ -108,14 +108,14 @@ func Load() (Config, error) {
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8080"), HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8080"),
ClickHouse: ClickHouseConfig{ ClickHouse: ClickHouseConfig{
Addr: getenv("CLICKHOUSE_ADDR", "localhost:9000"), Addr: getenv("CLICKHOUSE_ADDR", "localhost:9000"),
Database: getenv("CLICKHOUSE_DATABASE", "sentry"), Database: getenv("CLICKHOUSE_DATABASE", "cairnobs"),
Username: getenv("CLICKHOUSE_USERNAME", "default"), Username: getenv("CLICKHOUSE_USERNAME", "default"),
Password: getenv("CLICKHOUSE_PASSWORD", ""), Password: getenv("CLICKHOUSE_PASSWORD", ""),
}, },
Postgres: PostgresConfig{ Postgres: PostgresConfig{
Addr: getenv("POSTGRES_ADDR", "localhost:5432"), Addr: getenv("POSTGRES_ADDR", "localhost:5432"),
Database: getenv("POSTGRES_DATABASE", "sentry_metadata"), Database: getenv("POSTGRES_DATABASE", "cairnobs_metadata"),
Username: getenv("POSTGRES_USERNAME", "sentry"), Username: getenv("POSTGRES_USERNAME", "cairnobs"),
Password: getenv("POSTGRES_PASSWORD", ""), Password: getenv("POSTGRES_PASSWORD", ""),
}, },
// Search service's gRPC address (see /search) -- default matches // Search service's gRPC address (see /search) -- default matches
+2 -1
View File
@@ -35,7 +35,7 @@ func integrationStore(t *testing.T) *Store {
t.Skip("LOCALAUTH_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test") t.Skip("LOCALAUTH_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test")
} }
password := os.Getenv("LOCALAUTH_TEST_POSTGRES_PASSWORD") password := os.Getenv("LOCALAUTH_TEST_POSTGRES_PASSWORD")
dsn := fmt.Sprintf("postgres://sentry:%s@%s/sentry_metadata", password, addr) dsn := fmt.Sprintf("postgres://cairnobs:%s@%s/cairnobs_metadata", password, addr)
pool, err := pgxpool.New(context.Background(), dsn) pool, err := pgxpool.New(context.Background(), dsn)
if err != nil { if err != nil {
t.Fatalf("opening pool: %v", err) t.Fatalf("opening pool: %v", err)
@@ -285,3 +285,4 @@ func TestIntegrationGetPasswordHashByID(t *testing.T) {
t.Errorf("GetPasswordHashByID for an unknown ID: err = %v, want ErrNotFound", err) t.Errorf("GetPasswordHashByID for an unknown ID: err = %v, want ErrNotFound", err)
} }
} }
+20 -20
View File
@@ -38,7 +38,7 @@ services:
timeout: 5s timeout: 5s
retries: 30 retries: 30
# One-shot: creates the sentry.logs.raw topic, then exits 0. ingest # One-shot: creates the cairnobs.logs.raw topic, then exits 0. ingest
# waits on this completing successfully before it starts. # waits on this completing successfully before it starts.
redpanda-provision: redpanda-provision:
build: build:
@@ -120,13 +120,13 @@ services:
image: postgres:16-alpine image: postgres:16-alpine
container_name: cairnobs-metadata-postgres container_name: cairnobs-metadata-postgres
environment: environment:
POSTGRES_DB: sentry_metadata POSTGRES_DB: cairnobs_metadata
POSTGRES_USER: sentry POSTGRES_USER: cairnobs
POSTGRES_PASSWORD: "cairnobs-dev-only" # not a real secret, same framing as CLICKHOUSE_PASSWORD above POSTGRES_PASSWORD: "cairnobs-dev-only" # not a real secret, same framing as CLICKHOUSE_PASSWORD above
volumes: volumes:
- metadata-postgres-data:/var/lib/postgresql/data - metadata-postgres-data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U sentry -d sentry_metadata"] test: ["CMD-SHELL", "pg_isready -U cairnobs -d cairnobs_metadata"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 30 retries: 30
@@ -143,12 +143,12 @@ services:
environment: environment:
POSTGRES_HOST: "metadata-postgres" POSTGRES_HOST: "metadata-postgres"
POSTGRES_PORT: "5432" POSTGRES_PORT: "5432"
POSTGRES_USER: "sentry" POSTGRES_USER: "cairnobs"
POSTGRES_PASSWORD: "cairnobs-dev-only" POSTGRES_PASSWORD: "cairnobs-dev-only"
POSTGRES_DATABASE: "sentry_metadata" POSTGRES_DATABASE: "cairnobs_metadata"
# Password for the restricted audit_writer Postgres role (Phase 4 # Password for the restricted audit_writer Postgres role (Phase 4
# task 4) -- INSERT+SELECT only on audit_log, never UPDATE/DELETE, # task 4) -- INSERT+SELECT only on audit_log, never UPDATE/DELETE,
# via its own connection pool distinct from the shared "sentry" # via its own connection pool distinct from the shared "cairnobs"
# role every other store uses. See /docs/phase-4-isolation-design.md. # role every other store uses. See /docs/phase-4-isolation-design.md.
AUDIT_WRITER_PASSWORD: "audit-writer-dev-only" AUDIT_WRITER_PASSWORD: "audit-writer-dev-only"
@@ -185,19 +185,19 @@ services:
# compose file provisions one. # compose file provisions one.
# #
# AGENT_REGISTRY_POSTGRES_ADDR enables agent inventory/remote # AGENT_REGISTRY_POSTGRES_ADDR enables agent inventory/remote
# config (see /docs/agent-management-design.md) -- same "sentry" # config (see /docs/agent-management-design.md) -- same "cairnobs"
# shared Postgres role api/dashboards already uses (agent # shared Postgres role api/dashboards already uses (agent
# inventory carries no tamper-evidence requirement, unlike # inventory carries no tamper-evidence requirement, unlike
# audit_log's dedicated restricted role). Set here (unlike # audit_log's dedicated restricted role). Set here (unlike
# ENTERPRISE_AUTH_URL above) since this feature has no multi- # ENTERPRISE_AUTH_URL above) since this feature has no multi-
# tenancy prerequisite -- it works the same in single-tenant core. # tenancy prerequisite -- it works the same in single-tenant core.
AGENT_REGISTRY_POSTGRES_ADDR: "metadata-postgres:5432" AGENT_REGISTRY_POSTGRES_ADDR: "metadata-postgres:5432"
AGENT_REGISTRY_POSTGRES_USERNAME: "sentry" AGENT_REGISTRY_POSTGRES_USERNAME: "cairnobs"
AGENT_REGISTRY_POSTGRES_PASSWORD: "cairnobs-dev-only" AGENT_REGISTRY_POSTGRES_PASSWORD: "cairnobs-dev-only"
volumes: volumes:
- ./hack/dev-certs/out:/etc/cairnobs-ingest:ro - ./hack/dev-certs/out:/etc/cairnobs-ingest:ro
# Reads the same sentry.logs.raw topic ingest's consumer does (own # Reads the same cairnobs.logs.raw topic ingest's consumer does (own
# offset tracking, own failure domain — see /search/README.md) and # offset tracking, own failure domain — see /search/README.md) and
# builds a Tantivy full-text index over the message field. # builds a Tantivy full-text index over the message field.
search: search:
@@ -255,8 +255,8 @@ services:
CLICKHOUSE_PASSWORD: "cairnobs-dev-only" CLICKHOUSE_PASSWORD: "cairnobs-dev-only"
SEARCH_GRPC_ADDR: "search:50052" SEARCH_GRPC_ADDR: "search:50052"
POSTGRES_ADDR: "metadata-postgres:5432" POSTGRES_ADDR: "metadata-postgres:5432"
POSTGRES_DATABASE: "sentry_metadata" POSTGRES_DATABASE: "cairnobs_metadata"
POSTGRES_USERNAME: "sentry" POSTGRES_USERNAME: "cairnobs"
POSTGRES_PASSWORD: "cairnobs-dev-only" POSTGRES_PASSWORD: "cairnobs-dev-only"
healthcheck: healthcheck:
# alerting (Phase 3 task 5) depends_on api -- without this, that # alerting (Phase 3 task 5) depends_on api -- without this, that
@@ -293,8 +293,8 @@ services:
- "8081:8081" - "8081:8081"
environment: environment:
POSTGRES_ADDR: "metadata-postgres:5432" POSTGRES_ADDR: "metadata-postgres:5432"
POSTGRES_DATABASE: "sentry_metadata" POSTGRES_DATABASE: "cairnobs_metadata"
POSTGRES_USERNAME: "sentry" POSTGRES_USERNAME: "cairnobs"
POSTGRES_PASSWORD: "cairnobs-dev-only" POSTGRES_PASSWORD: "cairnobs-dev-only"
# Resolves to whichever of api/enterprise-api is actually active -- # Resolves to whichever of api/enterprise-api is actually active --
# enterprise-api declares a `default.aliases: [api]` network alias # enterprise-api declares a `default.aliases: [api]` network alias
@@ -337,8 +337,8 @@ services:
# secret. Must be at least 32 bytes (see internal/config.Load). # secret. Must be at least 32 bytes (see internal/config.Load).
ENTERPRISE_SESSION_SIGNING_KEY: "cairnobs-dev-only-session-signing-key-32bytes+" ENTERPRISE_SESSION_SIGNING_KEY: "cairnobs-dev-only-session-signing-key-32bytes+"
POSTGRES_ADDR: "metadata-postgres:5432" POSTGRES_ADDR: "metadata-postgres:5432"
POSTGRES_DATABASE: "sentry_metadata" POSTGRES_DATABASE: "cairnobs_metadata"
POSTGRES_USERNAME: "sentry" POSTGRES_USERNAME: "cairnobs"
POSTGRES_PASSWORD: "cairnobs-dev-only" POSTGRES_PASSWORD: "cairnobs-dev-only"
# Where the browser lands after internal/loginhandler sets a # Where the browser lands after internal/loginhandler sets a
# session cookie -- web's mapped host port (see web's build args # session cookie -- web's mapped host port (see web's build args
@@ -391,8 +391,8 @@ services:
CLICKHOUSE_ADMIN_PASSWORD: "cairnobs-dev-only" CLICKHOUSE_ADMIN_PASSWORD: "cairnobs-dev-only"
SEARCH_GRPC_ADDR: "search:50052" SEARCH_GRPC_ADDR: "search:50052"
POSTGRES_ADDR: "metadata-postgres:5432" POSTGRES_ADDR: "metadata-postgres:5432"
POSTGRES_DATABASE: "sentry_metadata" POSTGRES_DATABASE: "cairnobs_metadata"
POSTGRES_USERNAME: "sentry" POSTGRES_USERNAME: "cairnobs"
POSTGRES_PASSWORD: "cairnobs-dev-only" POSTGRES_PASSWORD: "cairnobs-dev-only"
AUDIT_WRITER_USERNAME: "audit_writer" AUDIT_WRITER_USERNAME: "audit_writer"
AUDIT_WRITER_PASSWORD: "audit-writer-dev-only" AUDIT_WRITER_PASSWORD: "audit-writer-dev-only"
@@ -434,8 +434,8 @@ services:
REDPANDA_BROKERS: "redpanda:9092" REDPANDA_BROKERS: "redpanda:9092"
CLICKHOUSE_ADDR: "clickhouse:9000" CLICKHOUSE_ADDR: "clickhouse:9000"
POSTGRES_ADDR: "metadata-postgres:5432" POSTGRES_ADDR: "metadata-postgres:5432"
POSTGRES_DATABASE: "sentry_metadata" POSTGRES_DATABASE: "cairnobs_metadata"
POSTGRES_USERNAME: "sentry" POSTGRES_USERNAME: "cairnobs"
POSTGRES_PASSWORD: "cairnobs-dev-only" POSTGRES_PASSWORD: "cairnobs-dev-only"
healthcheck: healthcheck:
test: ["CMD", "/enterprise-ingest", "-healthcheck"] test: ["CMD", "/enterprise-ingest", "-healthcheck"]
+6 -6
View File
@@ -135,7 +135,7 @@ already configured for.
## Data model ## Data model
`metadata/migrations/0037_create_agents.sql`: one `agents` table, one `metadata/migrations/0037_create_agents.sql`: one `agents` table, one
row per `(tenant_id, host)`, in the same `sentry_metadata` Postgres row per `(tenant_id, host)`, in the same `cairnobs_metadata` Postgres
dashboards/alert_rules already live in — not a new database, matching dashboards/alert_rules already live in — not a new database, matching
this project's established "shared schema, different services own this project's established "shared schema, different services own
different tables" shape. `tenant_id` defaults to `'default'` for different tables" shape. `tenant_id` defaults to `'default'` for
@@ -257,18 +257,18 @@ the process exits cleanly -- `pending_command` confirmed cleared and
## CLI surface (punch-list item 3) ## CLI surface (punch-list item 3)
`sentryctl agents` (`cli/cmd/sentryctl/cmd_agents.go`), same list/get `cairnobsctl agents` (`cli/cmd/cairnobsctl/cmd_agents.go`), same list/get
shape as `dashboards`/`alerts`, plus a `config` sub-subcommand shape as `dashboards`/`alerts`, plus a `config` sub-subcommand
(mirroring `dashboards permissions`) since an override has its own (mirroring `dashboards permissions`) since an override has its own
get/set/clear lifecycle distinct from the agent resource itself: get/set/clear lifecycle distinct from the agent resource itself:
``` ```
sentryctl agents list|get <host> cairnobsctl agents list|get <host>
sentryctl agents config get <host>|clear <host> cairnobsctl agents config get <host>|clear <host>
sentryctl agents config set <host> [--batch-max-size N] [--batch-flush-interval-ms N] cairnobsctl agents config set <host> [--batch-max-size N] [--batch-flush-interval-ms N]
[--heartbeat-enabled true|false] [--heartbeat-interval-ms N] [--heartbeat-enabled true|false] [--heartbeat-interval-ms N]
[--journald-unit UNIT] [--journald-unit UNIT]
sentryctl agents restart <host> [--yes] cairnobsctl agents restart <host> [--yes]
``` ```
`config set` is the one command with real logic beyond a thin HTTP `config set` is the one command with real logic beyond a thin HTTP
+2 -2
View File
@@ -81,7 +81,7 @@ This split is not to be changed without discussion — see CLAUDE.md.
| `alerting` (Go, Phase 3) | Evaluates alert rules on an interval, calls `api`'s `POST /query` (via a `RoleService` credential once Phase 4 auth is configured — see `/docs/phase-4-isolation-design.md`'s alerting↔api gap), delivers firing/resolved notifications (webhook/Slack/PagerDuty). | | `alerting` (Go, Phase 3) | Evaluates alert rules on an interval, calls `api`'s `POST /query` (via a `RoleService` credential once Phase 4 auth is configured — see `/docs/phase-4-isolation-design.md`'s alerting↔api gap), delivers firing/resolved notifications (webhook/Slack/PagerDuty). |
| `enterprise` (Go, AGPLv3 — see "Licensing boundary" below, Phase 4) | OIDC login (`internal/loginhandler`'s `/auth/oidc/login`+`/auth/oidc/callback`) and SAML login (`/auth/saml/login`+`/auth/saml/acs`, via `internal/saml`'s `crewjam/saml` wiring) — both a real IdP round trip, each verified with a real fake IdP (`coreos/go-oidc`'s `oidctest`, `crewjam/saml`'s `samlidp`) but not a real external one, RBAC storage (`internal/rbacstore`), session/service-token issuance (`internal/session`), the append-only audit log (`internal/audit`), `enterprise-auth`'s HTTP surface (`/internal/authorize`, `/auth/features`), per-tenant ClickHouse provisioning (`internal/tenantprovision`) and query routing (`internal/chrunner`), and `cmd/enterprise-api` — a second binary combining core's `api/queryapi`/`api/dashboards` handlers with these tenant-aware implementations. Never imported by core — see "Licensing boundary" below. Also `internal/searchclient` (per-tenant Tantivy routing, wired the same way into `search`). | | `enterprise` (Go, AGPLv3 — see "Licensing boundary" below, Phase 4) | OIDC login (`internal/loginhandler`'s `/auth/oidc/login`+`/auth/oidc/callback`) and SAML login (`/auth/saml/login`+`/auth/saml/acs`, via `internal/saml`'s `crewjam/saml` wiring) — both a real IdP round trip, each verified with a real fake IdP (`coreos/go-oidc`'s `oidctest`, `crewjam/saml`'s `samlidp`) but not a real external one, RBAC storage (`internal/rbacstore`), session/service-token issuance (`internal/session`), the append-only audit log (`internal/audit`), `enterprise-auth`'s HTTP surface (`/internal/authorize`, `/auth/features`), per-tenant ClickHouse provisioning (`internal/tenantprovision`) and query routing (`internal/chrunner`), and `cmd/enterprise-api` — a second binary combining core's `api/queryapi`/`api/dashboards` handlers with these tenant-aware implementations. Never imported by core — see "Licensing boundary" below. Also `internal/searchclient` (per-tenant Tantivy routing, wired the same way into `search`). |
| `web` (SvelteKit, static build) | Query bar, dashboards, alerts, and (Phase 4) a settings page that renders SSO status via a runtime capability check (`GET /auth/features`) rather than bundling `enterprise/`'s components directly — an architectural choice (core builds and runs standalone) that predates and doesn't depend on Phase 6's relicensing. | | `web` (SvelteKit, static build) | Query bar, dashboards, alerts, and (Phase 4) a settings page that renders SSO status via a runtime capability check (`GET /auth/features`) rather than bundling `enterprise/`'s components directly — an architectural choice (core builds and runs standalone) that predates and doesn't depend on Phase 6's relicensing. |
| `cli` (`sentryctl`) | `ping`, `query`, `dashboards` (list/get/apply), `alerts` (list/get/apply). `$SENTRYCTL_TOKEN`, if set, is forwarded as a Bearer credential (Phase 4). | | `cli` (`cairnobsctl`) | `ping`, `query`, `dashboards` (list/get/apply), `alerts` (list/get/apply). `$CAIRNOBSCTL_TOKEN`, if set, is forwarded as a Bearer credential (Phase 4). |
| `deploy` | A Helm chart covering every `docker-compose.yml` service, plus (Phase 4) a small Go Operator managing one CRD (`Tenant`) that provisions a per-tenant ClickHouse credential Secret. Never applied to a live cluster in the environment this was built in — see `/deploy/README.md`'s verification section before trusting it. | | `deploy` | A Helm chart covering every `docker-compose.yml` service, plus (Phase 4) a small Go Operator managing one CRD (`Tenant`) that provisions a per-tenant ClickHouse credential Secret. Never applied to a live cluster in the environment this was built in — see `/deploy/README.md`'s verification section before trusting it. |
## Tenant isolation model (Phase 4) ## Tenant isolation model (Phase 4)
@@ -199,7 +199,7 @@ escape hatch is opaque to any compiler-injected filter.
new credentials and still never touches ClickHouse/Postgres. new credentials and still never touches ClickHouse/Postgres.
**The deployment-topology gap is closed for both Helm and **The deployment-topology gap is closed for both Helm and
docker-compose**: `deploy/helm/sentry/templates/api.yaml`/ docker-compose**: `deploy/helm/cairnobs/templates/api.yaml`/
`enterprise-api.yaml` are mutually exclusive on `enterprise.enabled`, `enterprise-api.yaml` are mutually exclusive on `enterprise.enabled`,
rendering to the same Service name and port either way, so a rendering to the same Service name and port either way, so a
Helm-deployed cluster can't accidentally run the wrong binary — the same Helm-deployed cluster can't accidentally run the wrong binary — the same
+1 -1
View File
@@ -8510,7 +8510,7 @@
"direct_or_transitive": "direct", "direct_or_transitive": "direct",
"ecosystem": "docker-image", "ecosystem": "docker-image",
"flagged": "True", "flagged": "True",
"flag_reason": "Category (c): BSL is explicitly source-available, not OSI-approved open source, per the audit's own classification framework. Verified against the actual licenses/bsl.md at the v24.2.7 tag, not assumed. No AGPL linking-compatibility issue (Redpanda is consumed only over the Kafka wire protocol, never linked into Sentry's own code -- same relationship as ClickHouse/Postgres). Real open question: whether a third party self-hosting Sentry (now fully AGPLv3 per task 6) 'as a service' would trip BSL's Streaming-or-Queuing-Service restriction on the bundled Redpanda image -- Sentry's ingest pipeline creates fixed internal topics, not per-end-user topics, so this is very likely NOT a Streaming-or-Queuing-Service under BSL's own definition, but this is a business/redistribution judgment call, not a pure technical one -- flagged for your decision, not resolved unilaterally. See license-audit-report.md's Redpanda section for the three remediation options (accept as-is / swap to Apache Kafka / flag only). RESOLVED 2026-08-16 (business decision): accept as-is. Sentry's own use (internal Kafka-protocol transport, no resale of broker access) is within BSL's Additional Use Grant; the third-party self-hosting-as-a-service question was judged unlikely to trip BSL's Streaming-or-Queuing-Service restriction, and was accepted as a known, disclosed risk rather than swapping to a heavier broker or dropping the bundled image. See license-audit-report.md's Redpanda section.", "flag_reason": "Category (c): BSL is explicitly source-available, not OSI-approved open source, per the audit's own classification framework. Verified against the actual licenses/bsl.md at the v24.2.7 tag, not assumed. No AGPL linking-compatibility issue (Redpanda is consumed only over the Kafka wire protocol, never linked into Cairn OBS's own code -- same relationship as ClickHouse/Postgres). Real open question: whether a third party self-hosting Cairn OBS (now fully AGPLv3 per task 6) 'as a service' would trip BSL's Streaming-or-Queuing-Service restriction on the bundled Redpanda image -- Cairn OBS's ingest pipeline creates fixed internal topics, not per-end-user topics, so this is very likely NOT a Streaming-or-Queuing-Service under BSL's own definition, but this is a business/redistribution judgment call, not a pure technical one -- flagged for your decision, not resolved unilaterally. See license-audit-report.md's Redpanda section for the three remediation options (accept as-is / swap to Apache Kafka / flag only). RESOLVED 2026-08-16 (business decision): accept as-is. Cairn OBS's own use (internal Kafka-protocol transport, no resale of broker access) is within BSL's Additional Use Grant; the third-party self-hosting-as-a-service question was judged unlikely to trip BSL's Streaming-or-Queuing-Service restriction, and was accepted as a known, disclosed risk rather than swapping to a heavier broker or dropping the bundled image. See license-audit-report.md's Redpanda section.",
"classification": "c" "classification": "c"
}, },
{ {
+6 -6
View File
@@ -49,7 +49,7 @@ docker compose up -d --build
``` ```
This builds and starts, in dependency order: `redpanda``redpanda-provision` This builds and starts, in dependency order: `redpanda``redpanda-provision`
(creates the `sentry.logs.raw` topic, then exits) → `clickhouse` (creates the `cairnobs.logs.raw` topic, then exits) → `clickhouse`
`clickhouse-migrate` (applies `/storage/migrations`, then exits) → `clickhouse-migrate` (applies `/storage/migrations`, then exits) →
`ingest` and `api``web`. `ingest` and `api``web`.
@@ -133,7 +133,7 @@ In another terminal, **after** the agent is running and connected
started won't be picked up): started won't be picked up):
```sh ```sh
logger "hello from sentry phase 0" logger "hello from cairnobs phase 0"
``` ```
`logger` (part of util-linux, present on virtually every Linux distro) `logger` (part of util-linux, present on virtually every Linux distro)
@@ -150,7 +150,7 @@ interval by default, so the line won't hit ingest instantly.
starts every service in the file). Open `http://localhost:3000`, run the starts every service in the file). Open `http://localhost:3000`, run the
default query (`SELECT * FROM logs default query (`SELECT * FROM logs
ORDER BY timestamp DESC LIMIT 100`), and look for a row with ORDER BY timestamp DESC LIMIT 100`), and look for a row with
`message = "hello from sentry phase 0"`. `message = "hello from cairnobs phase 0"`.
**Or via curl, if you want to skip the browser:** **Or via curl, if you want to skip the browser:**
@@ -160,11 +160,11 @@ curl -X POST http://localhost:8080/query \
-d '{"sql": "SELECT * FROM logs ORDER BY timestamp DESC LIMIT 10"}' -d '{"sql": "SELECT * FROM logs ORDER BY timestamp DESC LIMIT 10"}'
``` ```
**Or via sentryctl, just to confirm api is up (doesn't check the data **Or via cairnobsctl, just to confirm api is up (doesn't check the data
itself):** itself):**
```sh ```sh
cd cli && go run ./cmd/sentryctl ping cd cli && go run ./cmd/cairnobsctl ping
``` ```
If you see the row: that's Phase 0 done, end to end. If you don't, see If you see the row: that's Phase 0 done, end to end. If you don't, see
@@ -199,7 +199,7 @@ Check each hop in order rather than guessing:
at-least-once design (see `/ingest/README.md`), so this may just need at-least-once design (see `/ingest/README.md`), so this may just need
more time rather than intervention. more time rather than intervention.
3. `docker compose exec redpanda rpk topic list` — confirm 3. `docker compose exec redpanda rpk topic list` — confirm
`sentry.logs.raw` exists (if `redpanda-provision` failed, it won't). `cairnobs.logs.raw` exists (if `redpanda-provision` failed, it won't).
**`docker compose up` fails on `service_completed_successfully`.** **`docker compose up` fails on `service_completed_successfully`.**
You're likely on Compose v1 (`docker-compose`, hyphenated) rather than v2 You're likely on Compose v1 (`docker-compose`, hyphenated) rather than v2
+1 -1
View File
@@ -54,7 +54,7 @@ docker compose logs search
``` ```
You should see "search gRPC server listening" and rskafka connecting to You should see "search gRPC server listening" and rskafka connecting to
all of `sentry.logs.raw`'s partitions. If you see nothing at all, check all of `cairnobs.logs.raw`'s partitions. If you see nothing at all, check
`RUST_LOG=info` is set on the `search` service in `docker-compose.yml`. `RUST_LOG=info` is set on the `search` service in `docker-compose.yml`.
### A2. Generate a log line and confirm both query paths agree ### A2. Generate a log line and confirm both query paths agree
+10 -10
View File
@@ -43,10 +43,10 @@ go run . --count 500000
Create a dashboard and a couple of panels, either through the web UI Create a dashboard and a couple of panels, either through the web UI
(`http://localhost:3000/dashboards` → "+ Create" → "+ Add panel") or via (`http://localhost:3000/dashboards` → "+ Create" → "+ Add panel") or via
`sentryctl`: `cairnobsctl`:
```sh ```sh
sentryctl dashboards apply my-dashboard.json # shape = GET /dashboards/{id}/export cairnobsctl dashboards apply my-dashboard.json # shape = GET /dashboards/{id}/export
``` ```
**Verified live**: a table panel (`severity=INFO | head 10`) and a bar **Verified live**: a table panel (`severity=INFO | head 10`) and a bar
@@ -95,7 +95,7 @@ Bring up a local webhook receiver for testing (no real Slack/PagerDuty
needed): needed):
```sh ```sh
docker run -d --name sentry-webhook-sink --network sentry_default \ docker run -d --name cairnobs-webhook-sink --network sentry_default \
-p 9099:9099 -v $(pwd)/hack/webhook-sink:/src -w /src golang:1.25-alpine go run . -p 9099:9099 -v $(pwd)/hack/webhook-sink:/src -w /src golang:1.25-alpine go run .
``` ```
@@ -104,7 +104,7 @@ Create a notification target and a rule, either via the web UI
```sh ```sh
curl -X POST http://localhost:8081/targets -H 'Content-Type: application/json' -d '{ curl -X POST http://localhost:8081/targets -H 'Content-Type: application/json' -d '{
"name": "local sink", "kind": "webhook", "webhook_url": "http://sentry-webhook-sink:9099/" "name": "local sink", "kind": "webhook", "webhook_url": "http://cairnobs-webhook-sink:9099/"
}' }'
curl -X POST http://localhost:8081/rules -H 'Content-Type: application/json' -d '{ curl -X POST http://localhost:8081/rules -H 'Content-Type: application/json' -d '{
@@ -119,7 +119,7 @@ curl -X POST http://localhost:8081/rules -H 'Content-Type: application/json' -d
rule via the `/alerts/new` form, watched it transition in the browser): rule via the `/alerts/new` form, watched it transition in the browser):
the rule transitions `ok``firing` on its first evaluation the rule transitions `ok``firing` on its first evaluation
(`for_minutes: 0`), the delivery log shows `firing / sent / 200`, and (`for_minutes: 0`), the delivery log shows `firing / sent / 200`, and
`docker logs sentry-webhook-sink` shows the real received payload. `docker logs cairnobs-webhook-sink` shows the real received payload.
Also verified live: a threshold rule whose query returns **zero rows** Also verified live: a threshold rule whose query returns **zero rows**
records `last_eval_status: "error"` with the exact expected message records `last_eval_status: "error"` with the exact expected message
@@ -205,13 +205,13 @@ already claimed. Moving off a single-process ticker to a distributed
scheduler, and materially larger rule counts (10,000+), are both scheduler, and materially larger rule counts (10,000+), are both
explicitly out of scope for this phase. explicitly out of scope for this phase.
## 5. Confirm `sentryctl` ## 5. Confirm `cairnobsctl`
```sh ```sh
sentryctl dashboards list cairnobsctl dashboards list
sentryctl dashboards apply exported-dashboard.json cairnobsctl dashboards apply exported-dashboard.json
sentryctl alerts list cairnobsctl alerts list
sentryctl alerts apply rule.json cairnobsctl alerts apply rule.json
``` ```
Both `dashboards` and `alerts` hit the exact same REST endpoints the web Both `dashboards` and `alerts` hit the exact same REST endpoints the web
+1 -1
View File
@@ -108,7 +108,7 @@ yet. Stated plainly so this doesn't read as more built than it is.
## Schema ## Schema
Lives in `/metadata` (`sentry_metadata`), alongside everything else from Lives in `/metadata` (`cairnobs_metadata`), alongside everything else from
Phase 3, per `/docs/phase-4-isolation-design.md`'s existing schema Phase 3, per `/docs/phase-4-isolation-design.md`'s existing schema
additions (`tenants`, the `tenant_id` backfill on `alert_state`/ additions (`tenants`, the `tenant_id` backfill on `alert_state`/
`delivery_log`). New tables, continuing that migration sequence: `delivery_log`). New tables, continuing that migration sequence:
+4 -4
View File
@@ -265,7 +265,7 @@ with three levels (`ok`/`warn`/`reject`) and human-readable reasons:
query"), decided here rather than left ambiguous: hand-written queries query"), decided here rather than left ambiguous: hand-written queries
get the identical assessment an AI-generated one would, so there's get the identical assessment an AI-generated one would, so there's
real parity, but retroactively hard-blocking existing dashboard/ real parity, but retroactively hard-blocking existing dashboard/
`sentryctl` query patterns that happen to have no time bound is a `cairnobsctl` query patterns that happen to have no time bound is a
behavioral change this phase didn't set out to make and could break behavioral change this phase didn't set out to make and could break
real existing usage. `warnings` is `omitempty` -- a client that real existing usage. `warnings` is `omitempty` -- a client that
doesn't look for it sees no shape change at all. All existing doesn't look for it sees no shape change at all. All existing
@@ -472,7 +472,7 @@ renders them is structurally the same conditional-message pattern
already live-verified repeatedly for Explain/Fix/Optimize's own already live-verified repeatedly for Explain/Fix/Optimize's own
"unavailable" states, not new untested UI shape. "unavailable" states, not new untested UI shape.
CLI (task 11): `sentryctl query --nl "..."` in `cli/cmd/sentryctl/cmd_query.go`. CLI (task 11): `cairnobsctl query --nl "..."` in `cli/cmd/cairnobsctl/cmd_query.go`.
Same posture as the UI, enforced identically regardless of how the Same posture as the UI, enforced identically regardless of how the
result was produced: a low-confidence, non-compiling, or cost-guard-blocked result was produced: a low-confidence, non-compiling, or cost-guard-blocked
translation is never run, even with `--execute` -- confirmed by translation is never run, even with `--execute` -- confirmed by
@@ -559,7 +559,7 @@ query at the moment of acceptance, not tracked keystroke-by-keystroke.
**Genuinely verified against a live Postgres**, not just unit-tested **Genuinely verified against a live Postgres**, not just unit-tested
against a fake `InteractionLogger`: `metadata/migrations/0036` was against a fake `InteractionLogger`: `metadata/migrations/0036` was
applied to the running dev stack's `sentry-metadata-postgres` applied to the running dev stack's `cairnobs-metadata-postgres`
(`docker compose up -d --build metadata-migrate`, confirmed via `\d+ (`docker compose up -d --build metadata-migrate`, confirmed via `\d+
audit_log` before/after showing `ai_interaction` added to the audit_log` before/after showing `ai_interaction` added to the
`event_type` CHECK constraint), and two new tests in `event_type` CHECK constraint), and two new tests in
@@ -622,7 +622,7 @@ ends up with:
handler/routing logic via `fakeProvider` -- fast, deterministic, no handler/routing logic via `fakeProvider` -- fast, deterministic, no
network, all run in CI today. network, all run in CI today.
2. **Integration tests** (this task, new): the mock-Ollama-server suite 2. **Integration tests** (this task, new): the mock-Ollama-server suite
above, plus `cli/cmd/sentryctl/cmd_query_test.go`'s existing above, plus `cli/cmd/cairnobsctl/cmd_query_test.go`'s existing
`httptest.Server`-backed coverage of `--nl`/`--execute` (already `httptest.Server`-backed coverage of `--nl`/`--execute` (already
written during Track B, task 11) -- proves the plumbing (HTTP routing, written during Track B, task 11) -- proves the plumbing (HTTP routing,
JSON contracts, `planner.Compile`/`costguard.Assess` integration, JSON contracts, `planner.Compile`/`costguard.Assess` integration,
+6 -6
View File
@@ -12,7 +12,7 @@ document is verification only.
Every AI operation (`complete`, `explain`, `fix`, `optimize`, Every AI operation (`complete`, `explain`, `fix`, `optimize`,
`translate`, and the audit-logging endpoint behind it) has been run `translate`, and the audit-logging endpoint behind it) has been run
end-to-end against a real `docker compose` stack — real HTTP requests end-to-end against a real `docker compose` stack — real HTTP requests
into the real `sentry-api` container, through the real into the real `cairnobs-api` container, through the real
`api/ai/provider/ollama.Client`, over a real network call, into a real `api/ai/provider/ollama.Client`, over a real network call, into a real
process answering Ollama's actual `/api/chat` wire contract. **No real process answering Ollama's actual `/api/chat` wire contract. **No real
model weights are used anywhere in this verification** — see model weights are used anywhere in this verification** — see
@@ -74,7 +74,7 @@ Run it as a container on the compose network with a network alias of
`OLLAMA_BASE_URL` at it via a throwaway compose override: `OLLAMA_BASE_URL` at it via a throwaway compose override:
```sh ```sh
docker run -d --rm --name sentry-mock-ollama --network sentry_default --network-alias ollama \ docker run -d --rm --name cairnobs-mock-ollama --network sentry_default --network-alias ollama \
-v "$(pwd)/hack/mock-ollama:/src" -w /src golang:1.25-alpine \ -v "$(pwd)/hack/mock-ollama:/src" -w /src golang:1.25-alpine \
sh -c "go build -o /tmp/mock-ollama . && /tmp/mock-ollama" sh -c "go build -o /tmp/mock-ollama . && /tmp/mock-ollama"
@@ -106,7 +106,7 @@ wired into a stack anyone else might reach:
```sh ```sh
docker compose up -d api # drops back to the plain env, no -f override docker compose up -d api # drops back to the plain env, no -f override
docker rm -f sentry-mock-ollama docker rm -f cairnobs-mock-ollama
rm /tmp/docker-compose.ai-verify.yml rm /tmp/docker-compose.ai-verify.yml
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/ai/translate -d '{}' curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/ai/translate -d '{}'
# 404 -- confirms AI routes are unregistered again # 404 -- confirms AI routes are unregistered again
@@ -118,7 +118,7 @@ With AI routes enabled (step 2) and the web dev server running against
`localhost:8080`, open the Search page's query bar: `localhost:8080`, open the Search page's query bar:
- Type a partial query and pause — ghost text should appear inline - Type a partial query and pause — ghost text should appear inline
after ~300ms; Tab accepts it. Stop `sentry-mock-ollama` and confirm after ~300ms; Tab accepts it. Stop `cairnobs-mock-ollama` and confirm
ghost text just silently stops appearing (no error toast, no ghost text just silently stops appearing (no error toast, no
console noise) — this is the "graceful degradation" requirement, console noise) — this is the "graceful degradation" requirement,
not incidental behavior. not incidental behavior.
@@ -149,7 +149,7 @@ separately click "Run query".
CLI: CLI:
```sh ```sh
cd cli && go run ./cmd/sentryctl query --nl "errors in the last hour" --api http://localhost:8080 cd cli && go run ./cmd/cairnobsctl query --nl "errors in the last hour" --api http://localhost:8080
# prints the translated query and, in an interactive terminal, prompts y/N before running # prints the translated query and, in an interactive terminal, prompts y/N before running
``` ```
@@ -164,7 +164,7 @@ pattern applied to that service instead:
1. Accept or dismiss a Fix/Optimize/Translate suggestion in the web UI. 1. Accept or dismiss a Fix/Optimize/Translate suggestion in the web UI.
2. Confirm a row landed in `audit_log`: 2. Confirm a row landed in `audit_log`:
```sh ```sh
docker exec sentry-metadata-postgres psql -U sentry -d sentry_metadata \ docker exec cairnobs-metadata-postgres psql -U cairnobs -d cairnobs_metadata \
-c "SELECT event_type, query_text, detail FROM audit_log WHERE event_type='ai_interaction' ORDER BY id DESC LIMIT 5;" -c "SELECT event_type, query_text, detail FROM audit_log WHERE event_type='ai_interaction' ORDER BY id DESC LIMIT 5;"
``` ```
`detail` should show `operation`/`accepted`/`edited` matching what you `detail` should show `operation`/`accepted`/`edited` matching what you
+8 -1
View File
@@ -23,8 +23,15 @@ new cross-backend join strategy from scratch.
Pipe syntax, SPL-inspired, EBNF-ish: Pipe syntax, SPL-inspired, EBNF-ish:
``` ```
query := base_search ("|" pipe_stage)* query := (base_search | pipe_stage) ("|" pipe_stage)*
base_search := bool_expr // implicit filter/search, SPL convention base_search := bool_expr // implicit filter/search, SPL convention
// omitted entirely when the query starts
// directly with a pipe-stage keyword (e.g.
// `stats count by host`, no leading filter,
// no leading "|") -- means match-everything.
// A field genuinely named "where"/"stats"/etc
// still parses as a filter (`where=foo`),
// disambiguated by comparator lookahead.
pipe_stage := "where" bool_expr pipe_stage := "where" bool_expr
| "stats" agg_call ("," agg_call)* ["by" field ("," field)*] | "stats" agg_call ("," agg_call)* ["by" field ("," field)*]
| "sort" sort_field ("," sort_field)* | "sort" sort_field ("," sort_field)*
+8 -8
View File
@@ -97,13 +97,13 @@ wire-level `SearchRequest` carries the right `tenant_id`. All pass, for
real, no disclaimer needed for this specific claim. real, no disclaimer needed for this specific claim.
**Both Helm and docker-compose now close this.** **Both Helm and docker-compose now close this.**
`deploy/helm/sentry/templates/api.yaml` and `enterprise-api.yaml` are `deploy/helm/cairnobs/templates/api.yaml` and `enterprise-api.yaml` are
mutually exclusive, gated on opposite sides of the same mutually exclusive, gated on opposite sides of the same
`enterprise.enabled` flag, rendering to the same Service name/port — so `enterprise.enabled` flag, rendering to the same Service name/port — so
a Helm-deployed cluster runs exactly one of the two binaries, chosen by a Helm-deployed cluster runs exactly one of the two binaries, chosen by
the same flag that turns on RBAC/audit/SSO, not a second the same flag that turns on RBAC/audit/SSO, not a second
independently-forgettable decision. Verified by parsing (not independently-forgettable decision. Verified by parsing (not
eyeballing) the rendered YAML under both values: exactly one `sentry-api` eyeballing) the rendered YAML under both values: exactly one `cairnobs-api`
Deployment either way, with the right image. `docker-compose.yml`'s Deployment either way, with the right image. `docker-compose.yml`'s
`api`/`enterprise-api` services are now the analogous mutually-exclusive `api`/`enterprise-api` services are now the analogous mutually-exclusive
choice, gated behind `COMPOSE_PROFILES` (`.env` checks in choice, gated behind `COMPOSE_PROFILES` (`.env` checks in
@@ -233,7 +233,7 @@ Browser ──▶ enterprise-auth (GET /auth/oidc/login, /auth/oidc/callback)
└─▶ external IdP (OIDC authorization code flow) └─▶ external IdP (OIDC authorization code flow)
└─▶ Postgres (rbacstore: users, tenant_memberships) └─▶ Postgres (rbacstore: users, tenant_memberships)
sentryctl ──▶ api, alerting (Bearer token when SENTRYCTL_TOKEN is set) cairnobsctl ──▶ api, alerting (Bearer token when CAIRNOBSCTL_TOKEN is set)
``` ```
Ingest path (agent → Redpanda → ingest → ClickHouse, and Redpanda → Ingest path (agent → Redpanda → ingest → ClickHouse, and Redpanda →
@@ -442,12 +442,12 @@ layer":
1. A dedicated `audit_writer` Postgres role with only `INSERT`+`SELECT` 1. A dedicated `audit_writer` Postgres role with only `INSERT`+`SELECT`
grants (`metadata/migrations/0012-0014`), via its **own** grants (`metadata/migrations/0012-0014`), via its **own**
`pgxpool.Pool` — never the shared `sentry` role/pool every other `pgxpool.Pool` — never the shared `cairnobs` role/pool every other
store uses. store uses.
2. A `BEFORE UPDATE OR DELETE ... RAISE EXCEPTION` trigger 2. A `BEFORE UPDATE OR DELETE ... RAISE EXCEPTION` trigger
(`metadata/migrations/0015-0016`) that rejects the operation for (`metadata/migrations/0015-0016`) that rejects the operation for
*any* role, including the table owner — confirmed live: even the *any* role, including the table owner — confirmed live: even the
`sentry` role cannot `UPDATE` a row without first disabling the `cairnobs` role cannot `UPDATE` a row without first disabling the
trigger, a privileged operation distinct from ordinary application trigger, a privileged operation distinct from ordinary application
access. access.
@@ -547,10 +547,10 @@ terms:
cluster/trusted network (`api`/`alerting`/`web`), never exposed cluster/trusted network (`api`/`alerting`/`web`), never exposed
publicly. Nothing in this codebase enforces that at the network layer; publicly. Nothing in this codebase enforces that at the network layer;
it's a deployment responsibility (NetworkPolicy, or equivalent) not it's a deployment responsibility (NetworkPolicy, or equivalent) not
yet codified in `/deploy/helm/sentry`. yet codified in `/deploy/helm/cairnobs`.
- `ENTERPRISE_SESSION_SIGNING_KEY`, ClickHouse/Postgres passwords, and - `ENTERPRISE_SESSION_SIGNING_KEY`, ClickHouse/Postgres passwords, and
(once minted) the `alerting` service token are all K8s `Secret` (once minted) the `alerting` service token are all K8s `Secret`
objects in the Helm chart (`/deploy/helm/sentry/templates/ objects in the Helm chart (`/deploy/helm/cairnobs/templates/
secrets.yaml`) — standard K8s `Secret` semantics apply (base64, not secrets.yaml`) — standard K8s `Secret` semantics apply (base64, not
encrypted at rest without a cluster-level `EncryptionConfiguration`). encrypted at rest without a cluster-level `EncryptionConfiguration`).
No secrets-manager integration (Vault, cloud KMS) exists; the chart No secrets-manager integration (Vault, cloud KMS) exists; the chart
@@ -571,7 +571,7 @@ terms:
| Ingest tenant *identity* (credential validation, tagging) | **Built and tested** — fail-closed `TenantResolver`, `tenant_id` Kafka header attached per record | | Ingest tenant *identity* (credential validation, tagging) | **Built and tested** — fail-closed `TenantResolver`, `tenant_id` Kafka header attached per record |
| Ingest tenant *write-routing*, ClickHouse | **Enforced, verified live**`enterprise-ingest`/`chwriter.Registry` route each tagged batch to its tenant's own database, fail-closed on an untagged/unprovisioned tenant; both Docker-free and live-ClickHouse tests pass. Active-tenant snapshot refreshes every minute (`Registry.StartRefreshing`) — a deprovisioned tenant loses write access within a minute, not "until the next restart" | | Ingest tenant *write-routing*, ClickHouse | **Enforced, verified live**`enterprise-ingest`/`chwriter.Registry` route each tagged batch to its tenant's own database, fail-closed on an untagged/unprovisioned tenant; both Docker-free and live-ClickHouse tests pass. Active-tenant snapshot refreshes every minute (`Registry.StartRefreshing`) — a deprovisioned tenant loses write access within a minute, not "until the next restart" |
| Ingest tenant *write-routing*, Tantivy | **Built and genuinely verified**`search/src/consumer.rs` routes each record into its own tenant's index via `IndexRegistry`, same registry the (already-verified) read side uses; no Docker needed, real tests pass. Active-tenant-gated too: `tenants::ActiveTenantTracker` polls `enterprise-auth` every 60s (off unless configured), refusing any tenant not in the polled allowlist — same one-minute staleness bound as ClickHouse's now-refreshing snapshot, no more asymmetry between the two | | Ingest tenant *write-routing*, Tantivy | **Built and genuinely verified**`search/src/consumer.rs` routes each record into its own tenant's index via `IndexRegistry`, same registry the (already-verified) read side uses; no Docker needed, real tests pass. Active-tenant-gated too: `tenants::ActiveTenantTracker` polls `enterprise-auth` every 60s (off unless configured), refusing any tenant not in the polled allowlist — same one-minute staleness bound as ClickHouse's now-refreshing snapshot, no more asymmetry between the two |
| Deployment actually routing traffic to `enterprise-api` (Helm) | **Enforced, verified live**`api`/`enterprise-api` are mutually exclusive, same flag as RBAC/audit/SSO; a real `helm install` against a real `kind` cluster confirmed the `sentry-api` Deployment runs `sentry-enterprise-api:latest` with `enterprise.enabled=true`, real endpoints behind the `sentry-api` Service, not just `helm template`'s rendered YAML | | Deployment actually routing traffic to `enterprise-api` (Helm) | **Enforced, verified live**`api`/`enterprise-api` are mutually exclusive, same flag as RBAC/audit/SSO; a real `helm install` against a real `kind` cluster confirmed the `cairnobs-api` Deployment runs `cairnobs-enterprise-api:latest` with `enterprise.enabled=true`, real endpoints behind the `cairnobs-api` Service, not just `helm template`'s rendered YAML |
| Deployment actually routing traffic to `enterprise-api` (docker-compose) | **Enforced, verified live**`api`/`enterprise-api` are mutually exclusive via `COMPOSE_PROFILES`, same flag choice as Helm's `enterprise.enabled`; a real `docker compose up` of `enterprise-api` was run in this environment (and caught/fixed a startup-crashing duplicate `GET /healthz` route registration bug in the process), not just `docker compose config` | | Deployment actually routing traffic to `enterprise-api` (docker-compose) | **Enforced, verified live**`api`/`enterprise-api` are mutually exclusive via `COMPOSE_PROFILES`, same flag choice as Helm's `enterprise.enabled`; a real `docker compose up` of `enterprise-api` was run in this environment (and caught/fixed a startup-crashing duplicate `GET /healthz` route registration bug in the process), not just `docker compose config` |
| Human SSO login — OIDC | **Enforced, verified live** — real login against a real Auth0 developer tenant, full browser round trip; correctly failed closed on an identity with no `tenant_memberships` row, then succeeded and issued a real session after `-grant-membership-*`, with `POST /internal/authorize` returning exactly the granted tenant/role | | Human SSO login — OIDC | **Enforced, verified live** — real login against a real Auth0 developer tenant, full browser round trip; correctly failed closed on an identity with no `tenant_memberships` row, then succeeded and issued a real session after `-grant-membership-*`, with `POST /internal/authorize` returning exactly the granted tenant/role |
| Human SSO login — SAML | **Enforced, verified live** — real login against Auth0's SAML2 Web App addon acting as a real SAML IdP, over real (self-signed, dev-only) TLS; a real signed assertion validated (audience, destination, signature), landed on `/select-tenant` with real memberships, and `POST /internal/authorize` confirmed the selected tenant/role. Found and fixed a real bug in the process: `loginhandler.go`'s cookies decided `Secure` from `r.TLS != nil` alone, which is wrong behind any TLS-terminating reverse proxy (the deployment shape this handler actually runs in) — `enterprise-auth` never terminates TLS itself, so `r.TLS` was nil even over a genuinely HTTPS connection, silently dropping `Secure` and breaking SAML's `SameSite=None` cookie | | Human SSO login — SAML | **Enforced, verified live** — real login against Auth0's SAML2 Web App addon acting as a real SAML IdP, over real (self-signed, dev-only) TLS; a real signed assertion validated (audience, destination, signature), landed on `/select-tenant` with real memberships, and `POST /internal/authorize` confirmed the selected tenant/role. Found and fixed a real bug in the process: `loginhandler.go`'s cookies decided `Secure` from `r.TLS != nil` alone, which is wrong behind any TLS-terminating reverse proxy (the deployment shape this handler actually runs in) — `enterprise-auth` never terminates TLS itself, so `r.TLS` was nil even over a genuinely HTTPS connection, silently dropping `Secure` and breaking SAML's `SameSite=None` cookie |
+1 -1
View File
@@ -13,7 +13,7 @@
# imports existed; the repo-root context below is the same shape # imports existed; the repo-root context below is the same shape
# enterprise-api's and enterprise-ingest's Dockerfiles already use for # enterprise-api's and enterprise-ingest's Dockerfiles already use for
# the identical reason. # the identical reason.
# docker build -f enterprise/Dockerfile -t sentry-enterprise-auth . # docker build -f enterprise/Dockerfile -t cairnobs-enterprise-auth .
FROM golang:1.25-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /src WORKDIR /src
COPY . . COPY . .
+13 -13
View File
@@ -40,7 +40,7 @@ section for exactly what "not yet run" means here and why. Don't read
- `/alerting`'s `queryclient` presents a `RoleService` Bearer token - `/alerting`'s `queryclient` presents a `RoleService` Bearer token
(`API_SERVICE_TOKEN`) when configured -- see (`API_SERVICE_TOKEN`) when configured -- see
`/docs/phase-4-isolation-design.md`'s `alerting``api` gap. `/docs/phase-4-isolation-design.md`'s `alerting``api` gap.
- `sentryctl` presents `$SENTRYCTL_TOKEN` as a Bearer credential on every - `cairnobsctl` presents `$CAIRNOBSCTL_TOKEN` as a Bearer credential on every
request when set. request when set.
- `internal/rbacstore`: full CRUD over `users`/`tenants`/ - `internal/rbacstore`: full CRUD over `users`/`tenants`/
`tenant_memberships`/`data_sources` (`metadata/migrations/0017-0032`). `tenant_memberships`/`data_sources` (`metadata/migrations/0017-0032`).
@@ -155,7 +155,7 @@ issues a `session.Manager.IssuePendingLogin` token (a distinct Go/JWT
type from a real session -- see that type's doc comment for a real bug type from a real session -- see that type's doc comment for a real bug
this design caught in its own tests: a shared JSON key would have let a this design caught in its own tests: a shared JSON key would have let a
full session token double as a pending login) as a full session token double as a pending login) as a
`sentry_pending_login` cookie (`Path=/auth`) and redirects to `cairnobs_pending_login` cookie (`Path=/auth`) and redirects to
`SELECT_TENANT_REDIRECT_URL` (defaults to `SELECT_TENANT_REDIRECT_URL` (defaults to
`{POST_LOGIN_REDIRECT_URL}/select-tenant`) instead of completing the `{POST_LOGIN_REDIRECT_URL}/select-tenant`) instead of completing the
login. From there: login. From there:
@@ -204,7 +204,7 @@ below) and Tantivy (`/search/README.md`'s "Per-tenant indices" section
-- needed no code in this module at all, since `search`'s -- needed no code in this module at all, since `search`'s
`IndexRegistry` already lived in AGPL core); and deployment-topology `IndexRegistry` already lived in AGPL core); and deployment-topology
routing (does traffic actually reach `enterprise-api` instead of routing (does traffic actually reach `enterprise-api` instead of
`api`), now a single-flag choice in both `deploy/helm/sentry` and `api`), now a single-flag choice in both `deploy/helm/cairnobs` and
`docker-compose.yml` (`enterprise.enabled` / `COMPOSE_PROFILES`), see `docker-compose.yml` (`enterprise.enabled` / `COMPOSE_PROFILES`), see
CLAUDE.md. CLAUDE.md.
@@ -285,7 +285,7 @@ for both directions is the simpler, still-correctly-scoped choice).
A real multi-tenant deployment runs `ingest -mode=server` (agent-facing, A real multi-tenant deployment runs `ingest -mode=server` (agent-facing,
tags records, unchanged) alongside `enterprise-ingest` (consumer, tags records, unchanged) alongside `enterprise-ingest` (consumer,
per-tenant writes) *instead of* `ingest -mode=consumer` -- see `deploy/ per-tenant writes) *instead of* `ingest -mode=consumer` -- see `deploy/
helm/sentry`'s `ingest.requireTenantCredential` value (gates both the helm/cairnobs`'s `ingest.requireTenantCredential` value (gates both the
credential-validation requirement and this mode split together, since credential-validation requirement and this mode split together, since
write-routing is only meaningful once records actually carry a write-routing is only meaningful once records actually carry a
tenant_id to route on) and `docker-compose.yml`'s `enterprise-ingest` tenant_id to route on) and `docker-compose.yml`'s `enterprise-ingest`
@@ -318,7 +318,7 @@ internal/saml/ crewjam/saml wiring: SP setup, login redirect, respons
internal/session/ issues/validates signed session + RoleService tokens internal/session/ issues/validates signed session + RoleService tokens
internal/authhandler/ POST /internal/authorize, GET /auth/features internal/authhandler/ POST /internal/authorize, GET /auth/features
internal/loginhandler/ GET /auth/oidc/{login,callback} + GET /auth/saml/login + POST /auth/saml/acs -- the human login flow internal/loginhandler/ GET /auth/oidc/{login,callback} + GET /auth/saml/login + POST /auth/saml/acs -- the human login flow
internal/rbacstore/ users/tenants/tenant_memberships/data_sources/dashboard_permissions CRUD (pgx against sentry_metadata) internal/rbacstore/ users/tenants/tenant_memberships/data_sources/dashboard_permissions CRUD (pgx against cairnobs_metadata)
internal/tenantprovision/ real ClickHouse CREATE DATABASE/USER/GRANT internal/tenantprovision/ real ClickHouse CREATE DATABASE/USER/GRANT
internal/tenantcrd/ syncs -provision-tenant's real result into deploy/operator's Tenant CRD (K8s dynamic client, no cluster needed to test) internal/tenantcrd/ syncs -provision-tenant's real result into deploy/operator's Tenant CRD (K8s dynamic client, no cluster needed to test)
internal/chrunner/ tenant-scoped api/querylang/executor.SQLRunner internal/chrunner/ tenant-scoped api/querylang/executor.SQLRunner
@@ -431,7 +431,7 @@ SEARCH_TOKEN=$(docker compose run --rm enterprise-auth -mint-service-token=searc
``` ```
```sh ```sh
docker build -f Dockerfile -t sentry-enterprise-auth . # context is enterprise/, not the repo root docker build -f Dockerfile -t cairnobs-enterprise-auth . # context is enterprise/, not the repo root
``` ```
## Bootstrapping a tenant and its first human user ## Bootstrapping a tenant and its first human user
@@ -484,7 +484,7 @@ comment already worries about). Changing a non-Owner role is just
re-running `-grant-membership-*` with a different re-running `-grant-membership-*` with a different
`-grant-membership-role` (`SetMembership`'s upsert already supports `-grant-membership-role` (`SetMembership`'s upsert already supports
it). `dashboard_permissions` grants have no `enterprise-auth` flag and it). `dashboard_permissions` grants have no `enterprise-auth` flag and
don't need one -- `sentryctl dashboards permissions list|grant|revoke` don't need one -- `cairnobsctl dashboards permissions list|grant|revoke`
covers them over the HTTP endpoints `api/dashboards`' handler already covers them over the HTTP endpoints `api/dashboards`' handler already
exposes (`PUT`/`DELETE /dashboards/{id}/permissions/{userId}`, exposes (`PUT`/`DELETE /dashboards/{id}/permissions/{userId}`,
`GET .../permissions`), see `/cli/README.md`. `GET .../permissions`), see `/cli/README.md`.
@@ -514,8 +514,8 @@ curl -s http://localhost:8080/healthz
`internal/tenantcrd` sync step is a documented no-op in this deployment `internal/tenantcrd` sync step is a documented no-op in this deployment
shape, same as everywhere else this codebase has an "off unless shape, same as everywhere else this codebase has an "off unless
configured" optional dependency. It only does anything in a real configured" optional dependency. It only does anything in a real
cluster with `deploy/helm/sentry`'s `tenantOperator.enabled=true` -- see cluster with `deploy/helm/cairnobs`'s `tenantOperator.enabled=true` -- see
`/deploy/helm/sentry/README.md`'s "Trying the two-tenant example." `/deploy/helm/cairnobs/README.md`'s "Trying the two-tenant example."
`-provision-tenant` creates the tenant/data_source rows in rbacstore if `-provision-tenant` creates the tenant/data_source rows in rbacstore if
they don't exist, provisions ClickHouse, persists the credentials, and they don't exist, provisions ClickHouse, persists the credentials, and
@@ -529,8 +529,8 @@ to, see `tenantprovision.ProvisionClickHouse`'s doc comment).
|---|---| |---|---|
| `HTTP_LISTEN_ADDR` | `:8082` | | `HTTP_LISTEN_ADDR` | `:8082` |
| `POSTGRES_ADDR` | `localhost:5432` | | `POSTGRES_ADDR` | `localhost:5432` |
| `POSTGRES_DATABASE` | `sentry_metadata` | | `POSTGRES_DATABASE` | `cairnobs_metadata` |
| `POSTGRES_USERNAME` | `sentry` | | `POSTGRES_USERNAME` | `cairnobs` |
| `POSTGRES_PASSWORD` | (empty) | | `POSTGRES_PASSWORD` | (empty) |
| `OIDC_ISSUER_URL` | (empty — OIDC discovery skipped if unset) | | `OIDC_ISSUER_URL` | (empty — OIDC discovery skipped if unset) |
| `OIDC_CLIENT_ID` | (empty) | | `OIDC_CLIENT_ID` | (empty) |
@@ -554,8 +554,8 @@ to, see `tenantprovision.ProvisionClickHouse`'s doc comment).
| `CLICKHOUSE_ADMIN_PASSWORD` | (empty) | | `CLICKHOUSE_ADMIN_PASSWORD` | (empty) |
| `SEARCH_GRPC_ADDR` | `localhost:50052` | | `SEARCH_GRPC_ADDR` | `localhost:50052` |
| `POSTGRES_ADDR` | `localhost:5432` | | `POSTGRES_ADDR` | `localhost:5432` |
| `POSTGRES_DATABASE` | `sentry_metadata` | | `POSTGRES_DATABASE` | `cairnobs_metadata` |
| `POSTGRES_USERNAME` | `sentry` | | `POSTGRES_USERNAME` | `cairnobs` |
| `POSTGRES_PASSWORD` | (empty) | | `POSTGRES_PASSWORD` | (empty) |
| `AUDIT_WRITER_USERNAME` | `audit_writer` | | `AUDIT_WRITER_USERNAME` | `audit_writer` |
| `AUDIT_WRITER_PASSWORD` | (empty) | | `AUDIT_WRITER_PASSWORD` | (empty) |
+1 -1
View File
@@ -1,7 +1,7 @@
# Same shape as every other Go service's Dockerfile in this repo -- # Same shape as every other Go service's Dockerfile in this repo --
# context must be the repo root (needs both enterprise/ and proto/, like # context must be the repo root (needs both enterprise/ and proto/, like
# api/Dockerfile does for api/ + proto/), not enterprise/ alone. # api/Dockerfile does for api/ + proto/), not enterprise/ alone.
# docker build -f enterprise/cmd/enterprise-api/Dockerfile -t sentry-enterprise-api . # docker build -f enterprise/cmd/enterprise-api/Dockerfile -t cairnobs-enterprise-api .
FROM golang:1.25-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /src WORKDIR /src
COPY . . COPY . .
+1 -1
View File
@@ -17,7 +17,7 @@
// keeps running plain api/cmd/api, unchanged; a real multi-tenant // keeps running plain api/cmd/api, unchanged; a real multi-tenant
// deployment runs this one instead. // deployment runs this one instead.
// //
// Both Helm (deploy/helm/sentry/templates/api.yaml vs // Both Helm (deploy/helm/cairnobs/templates/api.yaml vs
// enterprise-api.yaml) and docker-compose.yml (COMPOSE_PROFILES) now // enterprise-api.yaml) and docker-compose.yml (COMPOSE_PROFILES) now
// make this the deployment-topology choice, not just a binary sitting // make this the deployment-topology choice, not just a binary sitting
// unused alongside api's -- see this repo's CLAUDE.md. `search`'s write // unused alongside api's -- see this repo's CLAUDE.md. `search`'s write
+1 -1
View File
@@ -2,7 +2,7 @@
# context must be the repo root (needs ingest/, proto/, and enterprise/, # context must be the repo root (needs ingest/, proto/, and enterprise/,
# like enterprise-api/Dockerfile does for api/ + proto/ + enterprise/), # like enterprise-api/Dockerfile does for api/ + proto/ + enterprise/),
# not enterprise/ alone. # not enterprise/ alone.
# docker build -f enterprise/cmd/enterprise-ingest/Dockerfile -t sentry-enterprise-ingest . # docker build -f enterprise/cmd/enterprise-ingest/Dockerfile -t cairnobs-enterprise-ingest .
FROM golang:1.25-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /src WORKDIR /src
COPY . . COPY . .
+2 -2
View File
@@ -1,6 +1,6 @@
// Command enterprise-ingest is the multi-tenant-aware alternative to // Command enterprise-ingest is the multi-tenant-aware alternative to
// running `ingest -mode=consumer` -- reads the same shared // running `ingest -mode=consumer` -- reads the same shared
// sentry.logs.raw Redpanda topic ingest/cmd/ingest's agent-facing // cairnobs.logs.raw Redpanda topic ingest/cmd/ingest's agent-facing
// server half (PushBatch) produces onto (see that binary's doc // server half (PushBatch) produces onto (see that binary's doc
// comment), but writes each record into its own tenant's dedicated // comment), but writes each record into its own tenant's dedicated
// ClickHouse database (enterprise/internal/chwriter) instead of the one // ClickHouse database (enterprise/internal/chwriter) instead of the one
@@ -21,7 +21,7 @@
// alongside) `ingest -mode=consumer` -- `ingest -mode=server` (the // alongside) `ingest -mode=consumer` -- `ingest -mode=server` (the
// agent-facing half, which tags records with a tenant_id via // agent-facing half, which tags records with a tenant_id via
// TenantResolver) keeps running unchanged and unconditionally either // TenantResolver) keeps running unchanged and unconditionally either
// way; only which process consumes sentry.logs.raw and where it writes // way; only which process consumes cairnobs.logs.raw and where it writes
// changes. // changes.
package main package main
+2 -2
View File
@@ -125,8 +125,8 @@ func Load() (Config, error) {
}, },
Postgres: PostgresConfig{ Postgres: PostgresConfig{
Addr: getenv("POSTGRES_ADDR", "localhost:5432"), Addr: getenv("POSTGRES_ADDR", "localhost:5432"),
Database: getenv("POSTGRES_DATABASE", "sentry_metadata"), Database: getenv("POSTGRES_DATABASE", "cairnobs_metadata"),
Username: getenv("POSTGRES_USERNAME", "sentry"), Username: getenv("POSTGRES_USERNAME", "cairnobs"),
Password: getenv("POSTGRES_PASSWORD", ""), Password: getenv("POSTGRES_PASSWORD", ""),
}, },
AI: AIConfig{ AI: AIConfig{
+2 -2
View File
@@ -3,11 +3,11 @@
// section. Two independent defenses back the "no update/delete path // section. Two independent defenses back the "no update/delete path
// from the application layer" requirement -- both verified against a // from the application layer" requirement -- both verified against a
// live Postgres, not just written: audit_writer (this package's own // live Postgres, not just written: audit_writer (this package's own
// Postgres role, via its own connection pool, never the shared `sentry` // Postgres role, via its own connection pool, never the shared `cairnobs`
// role every other store uses) has only INSERT+SELECT grants, and a // role every other store uses) has only INSERT+SELECT grants, and a
// BEFORE UPDATE OR DELETE trigger (metadata/migrations/0015-0016) // BEFORE UPDATE OR DELETE trigger (metadata/migrations/0015-0016)
// rejects the operation for *any* role, including the table owner -- // rejects the operation for *any* role, including the table owner --
// confirmed live: even `sentry` cannot UPDATE a row without first // confirmed live: even `cairnobs` cannot UPDATE a row without first
// disabling the trigger, a privileged operation distinct from ordinary // disabling the trigger, a privileged operation distinct from ordinary
// application access. // application access.
// //
@@ -34,7 +34,7 @@ func testPool(t *testing.T, user, password string) *pgxpool.Pool {
if addr == "" { if addr == "" {
t.Skip("AUDIT_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test") t.Skip("AUDIT_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test")
} }
dsn := fmt.Sprintf("postgres://%s:%s@%s/sentry_metadata", user, password, addr) dsn := fmt.Sprintf("postgres://%s:%s@%s/cairnobs_metadata", user, password, addr)
pool, err := pgxpool.New(context.Background(), dsn) pool, err := pgxpool.New(context.Background(), dsn)
if err != nil { if err != nil {
t.Fatalf("opening pool: %v", err) t.Fatalf("opening pool: %v", err)
@@ -65,7 +65,7 @@ func cleanupAuditLog(t *testing.T, adminPool *pgxpool.Pool) {
func TestAppendAndVerifyChainRealPostgres(t *testing.T) { func TestAppendAndVerifyChainRealPostgres(t *testing.T) {
writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD")) writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD"))
adminPool := testPool(t, "sentry", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD")) adminPool := testPool(t, "cairnobs", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD"))
cleanupAuditLog(t, adminPool) cleanupAuditLog(t, adminPool)
defer cleanupAuditLog(t, adminPool) defer cleanupAuditLog(t, adminPool)
@@ -104,7 +104,7 @@ func TestAppendAndVerifyChainRealPostgres(t *testing.T) {
// field on QueryAuditEntry -- matching that type's own doc comment. // field on QueryAuditEntry -- matching that type's own doc comment.
func TestQueryAPILoggerWritesAttributedToContextIdentity(t *testing.T) { func TestQueryAPILoggerWritesAttributedToContextIdentity(t *testing.T) {
writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD")) writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD"))
adminPool := testPool(t, "sentry", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD")) adminPool := testPool(t, "cairnobs", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD"))
cleanupAuditLog(t, adminPool) cleanupAuditLog(t, adminPool)
defer cleanupAuditLog(t, adminPool) defer cleanupAuditLog(t, adminPool)
@@ -131,7 +131,7 @@ func TestQueryAPILoggerWritesAttributedToContextIdentity(t *testing.T) {
func TestQueryAPILoggerRefusesWithoutIdentity(t *testing.T) { func TestQueryAPILoggerRefusesWithoutIdentity(t *testing.T) {
writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD")) writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD"))
adminPool := testPool(t, "sentry", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD")) adminPool := testPool(t, "cairnobs", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD"))
cleanupAuditLog(t, adminPool) cleanupAuditLog(t, adminPool)
defer cleanupAuditLog(t, adminPool) defer cleanupAuditLog(t, adminPool)
@@ -149,7 +149,7 @@ func TestQueryAPILoggerRefusesWithoutIdentity(t *testing.T) {
// confidence/accepted/edited fields that don't have dedicated columns. // confidence/accepted/edited fields that don't have dedicated columns.
func TestAIInteractionLoggerWritesAttributedToContextIdentity(t *testing.T) { func TestAIInteractionLoggerWritesAttributedToContextIdentity(t *testing.T) {
writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD")) writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD"))
adminPool := testPool(t, "sentry", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD")) adminPool := testPool(t, "cairnobs", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD"))
cleanupAuditLog(t, adminPool) cleanupAuditLog(t, adminPool)
defer cleanupAuditLog(t, adminPool) defer cleanupAuditLog(t, adminPool)
@@ -199,7 +199,7 @@ func TestAIInteractionLoggerWritesAttributedToContextIdentity(t *testing.T) {
func TestAIInteractionLoggerRefusesWithoutIdentity(t *testing.T) { func TestAIInteractionLoggerRefusesWithoutIdentity(t *testing.T) {
writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD")) writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD"))
adminPool := testPool(t, "sentry", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD")) adminPool := testPool(t, "cairnobs", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD"))
cleanupAuditLog(t, adminPool) cleanupAuditLog(t, adminPool)
defer cleanupAuditLog(t, adminPool) defer cleanupAuditLog(t, adminPool)
@@ -216,7 +216,7 @@ func TestAIInteractionLoggerRefusesWithoutIdentity(t *testing.T) {
// would also pass. // would also pass.
func TestVerifyChainDetectsTampering(t *testing.T) { func TestVerifyChainDetectsTampering(t *testing.T) {
writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD")) writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD"))
adminPool := testPool(t, "sentry", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD")) adminPool := testPool(t, "cairnobs", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD"))
cleanupAuditLog(t, adminPool) cleanupAuditLog(t, adminPool)
defer cleanupAuditLog(t, adminPool) defer cleanupAuditLog(t, adminPool)
@@ -270,7 +270,7 @@ func TestVerifyChainDetectsTampering(t *testing.T) {
// unit test of the Go code alone. // unit test of the Go code alone.
func TestAppendConcurrentWritesProduceAValidChain(t *testing.T) { func TestAppendConcurrentWritesProduceAValidChain(t *testing.T) {
writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD")) writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD"))
adminPool := testPool(t, "sentry", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD")) adminPool := testPool(t, "cairnobs", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD"))
cleanupAuditLog(t, adminPool) cleanupAuditLog(t, adminPool)
defer cleanupAuditLog(t, adminPool) defer cleanupAuditLog(t, adminPool)
@@ -316,7 +316,7 @@ func TestAppendConcurrentWritesProduceAValidChain(t *testing.T) {
// the previous checkpoint's hash. // the previous checkpoint's hash.
func TestCheckpointerRun(t *testing.T) { func TestCheckpointerRun(t *testing.T) {
writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD")) writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD"))
adminPool := testPool(t, "sentry", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD")) adminPool := testPool(t, "cairnobs", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD"))
cleanupAuditLog(t, adminPool) cleanupAuditLog(t, adminPool)
defer cleanupAuditLog(t, adminPool) defer cleanupAuditLog(t, adminPool)
+1 -1
View File
@@ -16,7 +16,7 @@
// chrunner.RunSQL resolves exactly one tenant per call from ctx (a // chrunner.RunSQL resolves exactly one tenant per call from ctx (a
// single request always belongs to one identity); WriteBatch resolves // single request always belongs to one identity); WriteBatch resolves
// per *record*, since one Kafka batch pulled off the shared // per *record*, since one Kafka batch pulled off the shared
// sentry.logs.raw topic can freely mix records from many different // cairnobs.logs.raw topic can freely mix records from many different
// tenants -- see ingest/internal/grpcserver's doc comment for why // tenants -- see ingest/internal/grpcserver's doc comment for why
// there's one shared topic, not topic-per-tenant. // there's one shared topic, not topic-per-tenant.
package chwriter package chwriter
+2 -2
View File
@@ -101,8 +101,8 @@ func Load() (Config, error) {
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8082"), HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8082"),
Postgres: PostgresConfig{ Postgres: PostgresConfig{
Addr: getenv("POSTGRES_ADDR", "localhost:5432"), Addr: getenv("POSTGRES_ADDR", "localhost:5432"),
Database: getenv("POSTGRES_DATABASE", "sentry_metadata"), Database: getenv("POSTGRES_DATABASE", "cairnobs_metadata"),
Username: getenv("POSTGRES_USERNAME", "sentry"), Username: getenv("POSTGRES_USERNAME", "cairnobs"),
Password: getenv("POSTGRES_PASSWORD", ""), Password: getenv("POSTGRES_PASSWORD", ""),
}, },
OIDC: OIDCConfig{ OIDC: OIDCConfig{
@@ -74,26 +74,26 @@ func Load() (Config, error) {
ClickHouseAddr: getenv("CLICKHOUSE_ADDR", "localhost:9000"), ClickHouseAddr: getenv("CLICKHOUSE_ADDR", "localhost:9000"),
Postgres: PostgresConfig{ Postgres: PostgresConfig{
Addr: getenv("POSTGRES_ADDR", "localhost:5432"), Addr: getenv("POSTGRES_ADDR", "localhost:5432"),
Database: getenv("POSTGRES_DATABASE", "sentry_metadata"), Database: getenv("POSTGRES_DATABASE", "cairnobs_metadata"),
Username: getenv("POSTGRES_USERNAME", "sentry"), Username: getenv("POSTGRES_USERNAME", "cairnobs"),
Password: getenv("POSTGRES_PASSWORD", ""), Password: getenv("POSTGRES_PASSWORD", ""),
}, },
Redpanda: RedpandaConfig{ Redpanda: RedpandaConfig{
Brokers: strings.Split(getenv("REDPANDA_BROKERS", "localhost:9092"), ","), Brokers: strings.Split(getenv("REDPANDA_BROKERS", "localhost:9092"), ","),
// Same default topic ingest/internal/config uses -- this // Same default topic ingest/internal/config uses -- this
// binary reads the identical shared sentry.logs.raw topic // binary reads the identical shared cairnobs.logs.raw topic
// ingest/cmd/ingest's server half (agent-facing PushBatch) // ingest/cmd/ingest's server half (agent-facing PushBatch)
// produces onto; there's no per-tenant topic, see // produces onto; there's no per-tenant topic, see
// ingest/internal/grpcserver's doc comment. // ingest/internal/grpcserver's doc comment.
Topic: getenv("REDPANDA_TOPIC", "sentry.logs.raw"), Topic: getenv("REDPANDA_TOPIC", "cairnobs.logs.raw"),
// A distinct consumer group from ingest/cmd/ingest's own // A distinct consumer group from ingest/cmd/ingest's own
// default ("sentry-ingest") -- this binary and a // default ("cairnobs-ingest") -- this binary and a
// single-tenant `ingest -mode=consumer` must never share a // single-tenant `ingest -mode=consumer` must never share a
// group (each message would only ever reach one of them, // group (each message would only ever reach one of them,
// silently splitting traffic) even though in practice a // silently splitting traffic) even though in practice a
// real multi-tenant deployment runs this binary *instead // real multi-tenant deployment runs this binary *instead
// of*, not alongside, `ingest -mode=consumer`. // of*, not alongside, `ingest -mode=consumer`.
ConsumerGroup: getenv("REDPANDA_CONSUMER_GROUP", "sentry-enterprise-ingest"), ConsumerGroup: getenv("REDPANDA_CONSUMER_GROUP", "cairnobs-enterprise-ingest"),
}, },
} }
+1 -1
View File
@@ -2,7 +2,7 @@
// role schema (metadata/migrations/0017-0021) described in // role schema (metadata/migrations/0017-0021) described in
// /docs/phase-4-rbac-design.md: users (global SSO identity), tenants, // /docs/phase-4-rbac-design.md: users (global SSO identity), tenants,
// and tenant_memberships (per-tenant role). It uses the same shared // and tenant_memberships (per-tenant role). It uses the same shared
// "sentry" Postgres role/pool every other metadata store does (unlike // "cairnobs" Postgres role/pool every other metadata store does (unlike
// enterprise/internal/audit's deliberately separate, narrower-granted // enterprise/internal/audit's deliberately separate, narrower-granted
// pool) -- ordinary read/write CRUD on control-plane config, not an // pool) -- ordinary read/write CRUD on control-plane config, not an
// append-only ledger, so it has no analogous reason to restrict its own // append-only ledger, so it has no analogous reason to restrict its own
@@ -29,7 +29,7 @@ func testStore(t *testing.T) *Store {
t.Skip("RBACSTORE_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test") t.Skip("RBACSTORE_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test")
} }
password := os.Getenv("RBACSTORE_TEST_POSTGRES_PASSWORD") password := os.Getenv("RBACSTORE_TEST_POSTGRES_PASSWORD")
dsn := fmt.Sprintf("postgres://sentry:%s@%s/sentry_metadata", password, addr) dsn := fmt.Sprintf("postgres://cairnobs:%s@%s/cairnobs_metadata", password, addr)
pool, err := pgxpool.New(context.Background(), dsn) pool, err := pgxpool.New(context.Background(), dsn)
if err != nil { if err != nil {
t.Fatalf("opening pool: %v", err) t.Fatalf("opening pool: %v", err)
+3 -3
View File
@@ -27,7 +27,7 @@ func TestNewRejectsMissingConfig(t *testing.T) {
} }
func TestNewRejectsMissingIDPMetadata(t *testing.T) { func TestNewRejectsMissingIDPMetadata(t *testing.T) {
_, err := New(Config{EntityID: "https://sentry.example.com/saml/metadata", ACSURL: "https://sentry.example.com/saml/acs"}) _, err := New(Config{EntityID: "https://cairnobs.example.com/saml/metadata", ACSURL: "https://cairnobs.example.com/saml/acs"})
if err == nil { if err == nil {
t.Fatalf("expected an error when IDPMetadata is missing") t.Fatalf("expected an error when IDPMetadata is missing")
} }
@@ -40,8 +40,8 @@ func TestNewRejectsMissingIDPMetadata(t *testing.T) {
// redirect rather than just "the code compiles." // redirect rather than just "the code compiles."
func TestLoginURLBuildsAgainstRealIDPMetadata(t *testing.T) { func TestLoginURLBuildsAgainstRealIDPMetadata(t *testing.T) {
sp, err := New(Config{ sp, err := New(Config{
EntityID: "https://sentry.example.com/saml/metadata", EntityID: "https://cairnobs.example.com/saml/metadata",
ACSURL: "https://sentry.example.com/saml/acs", ACSURL: "https://cairnobs.example.com/saml/acs",
IDPMetadata: fakeIDPMetadata(), IDPMetadata: fakeIDPMetadata(),
}) })
if err != nil { if err != nil {
+1 -1
View File
@@ -14,7 +14,7 @@ go run . --count 500000
# 2. Run a webhook-sink so the (never-firing, by design) rules have a # 2. Run a webhook-sink so the (never-firing, by design) rules have a
# valid notification target to point at: # valid notification target to point at:
docker run -d --name sentry-webhook-sink --network sentry_default \ docker run -d --name cairnobs-webhook-sink --network sentry_default \
-p 9099:9099 -v $(pwd)/../webhook-sink:/src -w /src golang:1.25-alpine go run . -p 9099:9099 -v $(pwd)/../webhook-sink:/src -w /src golang:1.25-alpine go run .
# 3. Run the load test: # 3. Run the load test:
+1 -1
View File
@@ -43,7 +43,7 @@ var (
pollInterval = flag.Duration("poll-interval", 5*time.Second, "how often to poll GET /rules while observing") pollInterval = flag.Duration("poll-interval", 5*time.Second, "how often to poll GET /rules while observing")
concurrency = flag.Int("concurrency", 20, "concurrent rule-creation requests") concurrency = flag.Int("concurrency", 20, "concurrent rule-creation requests")
skipCleanup = flag.Bool("no-cleanup", false, "leave the seeded rules/target in place after the run") skipCleanup = flag.Bool("no-cleanup", false, "leave the seeded rules/target in place after the run")
webhookURL = flag.String("webhook-url", "http://sentry-webhook-sink:9099/", "notification target URL -- default assumes a webhook-sink container reachable on the compose network") webhookURL = flag.String("webhook-url", "http://cairnobs-webhook-sink:9099/", "notification target URL -- default assumes a webhook-sink container reachable on the compose network")
) )
func main() { func main() {
+1 -1
View File
@@ -1,6 +1,6 @@
# Build context must be the repo root (sentry/), not ingest/, since this # Build context must be the repo root (sentry/), not ingest/, since this
# needs both ingest/ and proto/: # needs both ingest/ and proto/:
# docker build -f ingest/Dockerfile -t sentry-ingest . # docker build -f ingest/Dockerfile -t cairnobs-ingest .
FROM golang:1.25-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /src WORKDIR /src
+7 -7
View File
@@ -86,13 +86,13 @@ full list and defaults) — no config file format for Phase 0:
| Var | Default | Purpose | | Var | Default | Purpose |
|---|---|---| |---|---|---|
| `GRPC_LISTEN_ADDR` | `:4317` | Agent-facing gRPC listen address | | `GRPC_LISTEN_ADDR` | `:4317` | Agent-facing gRPC listen address |
| `TLS_CERT_FILE` / `TLS_KEY_FILE` | `/etc/sentry-ingest/server{,-key}.pem` | ingest's own mTLS identity | | `TLS_CERT_FILE` / `TLS_KEY_FILE` | `/etc/cairnobs-ingest/server{,-key}.pem` | ingest's own mTLS identity |
| `TLS_CLIENT_CA_FILE` | `/etc/sentry-ingest/ca.pem` | CA used to verify agent client certs | | `TLS_CLIENT_CA_FILE` | `/etc/cairnobs-ingest/ca.pem` | CA used to verify agent client certs |
| `REDPANDA_BROKERS` | `localhost:9092` | Comma-separated broker list | | `REDPANDA_BROKERS` | `localhost:9092` | Comma-separated broker list |
| `REDPANDA_TOPIC` | `sentry.logs.raw` | Must match the topic provisioned in `/transport` | | `REDPANDA_TOPIC` | `cairnobs.logs.raw` | Must match the topic provisioned in `/transport` |
| `REDPANDA_CONSUMER_GROUP` | `sentry-ingest` | Consumer group id | | `REDPANDA_CONSUMER_GROUP` | `cairnobs-ingest` | Consumer group id |
| `CLICKHOUSE_ADDR` | `localhost:9000` | Native protocol port, not HTTP | | `CLICKHOUSE_ADDR` | `localhost:9000` | Native protocol port, not HTTP |
| `CLICKHOUSE_DATABASE` / `_USERNAME` / `_PASSWORD` | `sentry` / `default` / `` | | | `CLICKHOUSE_DATABASE` / `_USERNAME` / `_PASSWORD` | `cairnobs` / `default` / `` | |
| `CONSUMER_BATCH_MAX_SIZE` | `500` | Records per ClickHouse batch insert | | `CONSUMER_BATCH_MAX_SIZE` | `500` | Records per ClickHouse batch insert |
| `CONSUMER_BATCH_FLUSH_INTERVAL_MS` | `2000` | Max time a partial batch waits before flushing | | `CONSUMER_BATCH_FLUSH_INTERVAL_MS` | `2000` | Max time a partial batch waits before flushing |
| `ENTERPRISE_AUTH_URL` | (empty) | Enables `internal/grpcserver.TenantResolver` -- empty means PushBatch never requires a bearer credential and no `tenant_id` header is ever attached, same as every Phase 0-3 deployment | | `ENTERPRISE_AUTH_URL` | (empty) | Enables `internal/grpcserver.TenantResolver` -- empty means PushBatch never requires a bearer credential and no `tenant_id` header is ever attached, same as every Phase 0-3 deployment |
@@ -108,12 +108,12 @@ go test ./...
Requires `google.golang.org/protobuf/cmd/protoc-gen-go` and Requires `google.golang.org/protobuf/cmd/protoc-gen-go` and
`google.golang.org/grpc/cmd/protoc-gen-go-grpc` only if you're `google.golang.org/grpc/cmd/protoc-gen-go-grpc` only if you're
regenerating `/proto`'s Go bindings — ingest itself just imports the regenerating `/proto`'s Go bindings — ingest itself just imports the
already-generated `github.com/sentry/sentry/proto` module (see the already-generated `github.com/cairnobs/cairnobs/proto` module (see the
`replace` directive in `go.mod`, pointing at `../proto`). `replace` directive in `go.mod`, pointing at `../proto`).
```sh ```sh
# from the repo root, not ingest/ # from the repo root, not ingest/
docker build -f ingest/Dockerfile -t sentry-ingest . docker build -f ingest/Dockerfile -t cairnobs-ingest .
``` ```
## Testing notes ## Testing notes
@@ -1,7 +1,7 @@
// Package agentregistry is the Postgres-backed implementation of // Package agentregistry is the Postgres-backed implementation of
// grpcserver.AgentRegistry -- ingest's half of agent inventory/remote // grpcserver.AgentRegistry -- ingest's half of agent inventory/remote
// config (see /docs/agent-management-design.md). Writes into the same // config (see /docs/agent-management-design.md). Writes into the same
// sentry_metadata Postgres api reads/writes from for the web UI's // cairnobs_metadata Postgres api reads/writes from for the web UI's
// inventory and edit-config views (api/agents), the same shared-schema- // inventory and edit-config views (api/agents), the same shared-schema-
// different-services shape alerting and api already use for dashboards/ // different-services shape alerting and api already use for dashboards/
// alert_rules. // alert_rules.
+7 -7
View File
@@ -26,8 +26,8 @@ type Config struct {
// AgentRegistry enables agent inventory/remote config // AgentRegistry enables agent inventory/remote config
// (internal/agentregistry, internal/grpcserver.AgentRegistry) when // (internal/agentregistry, internal/grpcserver.AgentRegistry) when
// Postgres.Addr is set -- same "off unless configured" shape as // Postgres.Addr is set -- same "off unless configured" shape as
// EnterpriseAuthURL above. Writes into the same sentry_metadata // EnterpriseAuthURL above. Writes into the same cairnobs_metadata
// database api/web already use, via the same shared "sentry" role // database api/web already use, via the same shared "cairnobs" role
// every other non-audit table in this schema uses (unlike // every other non-audit table in this schema uses (unlike
// audit_log's dedicated restricted role -- agent inventory carries // audit_log's dedicated restricted role -- agent inventory carries
// no tamper-evidence requirement). // no tamper-evidence requirement).
@@ -110,12 +110,12 @@ func Load() (Config, error) {
}, },
Redpanda: RedpandaConfig{ Redpanda: RedpandaConfig{
Brokers: strings.Split(getenv("REDPANDA_BROKERS", "localhost:9092"), ","), Brokers: strings.Split(getenv("REDPANDA_BROKERS", "localhost:9092"), ","),
Topic: getenv("REDPANDA_TOPIC", "sentry.logs.raw"), Topic: getenv("REDPANDA_TOPIC", "cairnobs.logs.raw"),
ConsumerGroup: getenv("REDPANDA_CONSUMER_GROUP", "sentry-ingest"), ConsumerGroup: getenv("REDPANDA_CONSUMER_GROUP", "cairnobs-ingest"),
}, },
ClickHouse: ClickHouseConfig{ ClickHouse: ClickHouseConfig{
Addr: getenv("CLICKHOUSE_ADDR", "localhost:9000"), Addr: getenv("CLICKHOUSE_ADDR", "localhost:9000"),
Database: getenv("CLICKHOUSE_DATABASE", "sentry"), Database: getenv("CLICKHOUSE_DATABASE", "cairnobs"),
Username: getenv("CLICKHOUSE_USERNAME", "default"), Username: getenv("CLICKHOUSE_USERNAME", "default"),
Password: getenv("CLICKHOUSE_PASSWORD", ""), Password: getenv("CLICKHOUSE_PASSWORD", ""),
}, },
@@ -123,8 +123,8 @@ func Load() (Config, error) {
AgentRegistry: AgentRegistryConfig{ AgentRegistry: AgentRegistryConfig{
Postgres: PostgresConfig{ Postgres: PostgresConfig{
Addr: getenv("AGENT_REGISTRY_POSTGRES_ADDR", ""), Addr: getenv("AGENT_REGISTRY_POSTGRES_ADDR", ""),
Database: getenv("AGENT_REGISTRY_POSTGRES_DATABASE", "sentry_metadata"), Database: getenv("AGENT_REGISTRY_POSTGRES_DATABASE", "cairnobs_metadata"),
Username: getenv("AGENT_REGISTRY_POSTGRES_USERNAME", "sentry"), Username: getenv("AGENT_REGISTRY_POSTGRES_USERNAME", "cairnobs"),
Password: getenv("AGENT_REGISTRY_POSTGRES_PASSWORD", ""), Password: getenv("AGENT_REGISTRY_POSTGRES_PASSWORD", ""),
}, },
}, },
+2 -2
View File
@@ -10,8 +10,8 @@ func TestLoadDefaults(t *testing.T) {
if cfg.GRPC.ListenAddr != ":4317" { if cfg.GRPC.ListenAddr != ":4317" {
t.Errorf("GRPC.ListenAddr = %q, want :4317", cfg.GRPC.ListenAddr) t.Errorf("GRPC.ListenAddr = %q, want :4317", cfg.GRPC.ListenAddr)
} }
if cfg.Redpanda.Topic != "sentry.logs.raw" { if cfg.Redpanda.Topic != "cairnobs.logs.raw" {
t.Errorf("Redpanda.Topic = %q, want sentry.logs.raw", cfg.Redpanda.Topic) t.Errorf("Redpanda.Topic = %q, want cairnobs.logs.raw", cfg.Redpanda.Topic)
} }
if cfg.Batch.MaxSize != 500 { if cfg.Batch.MaxSize != 500 {
t.Errorf("Batch.MaxSize = %d, want 500", cfg.Batch.MaxSize) t.Errorf("Batch.MaxSize = %d, want 500", cfg.Batch.MaxSize)
+1 -1
View File
@@ -1,6 +1,6 @@
# One-shot migration runner: bash + psql client baked in, migrations/*.sql # One-shot migration runner: bash + psql client baked in, migrations/*.sql
# copied in at build time. Mirrors /storage/Dockerfile's shape. # copied in at build time. Mirrors /storage/Dockerfile's shape.
# docker build -f metadata/Dockerfile -t sentry-metadata-migrate metadata/ # docker build -f metadata/Dockerfile -t cairnobs-metadata-migrate metadata/
FROM alpine:3.20 FROM alpine:3.20
RUN apk add --no-cache bash postgresql16-client RUN apk add --no-cache bash postgresql16-client
WORKDIR /metadata WORKDIR /metadata
+6 -6
View File
@@ -11,14 +11,14 @@ doesn't provide.
## Schema ## Schema
Seven tables across three features, one shared database (`sentry_metadata`): Seven tables across three features, one shared database (`cairnobs_metadata`):
- `dashboards`, `dashboard_panels` — owned by `/api` (`api/internal/dashboards`) - `dashboards`, `dashboard_panels` — owned by `/api` (`api/internal/dashboards`)
- `notification_targets`, `alert_rules`, `alert_state`, `delivery_log` - `notification_targets`, `alert_rules`, `alert_state`, `delivery_log`
owned by `/alerting` owned by `/alerting`
- `audit_log` — owned by `enterprise/internal/audit` (Phase 4). Unlike - `audit_log` — owned by `enterprise/internal/audit` (Phase 4). Unlike
every other table here, this one is **not** written through the shared every other table here, this one is **not** written through the shared
`sentry` role/pool — see "The `audit_writer` role" below. `cairnobs` role/pool — see "The `audit_writer` role" below.
"Owned" here is a documentation convention, not a technical boundary — "Owned" here is a documentation convention, not a technical boundary —
both services connect to the same Postgres instance/database, each with both services connect to the same Postgres instance/database, each with
@@ -39,13 +39,13 @@ written:
with **only** `INSERT`/`SELECT` grants on `audit_log` — no with **only** `INSERT`/`SELECT` grants on `audit_log` — no
`UPDATE`/`DELETE`/`TRUNCATE`, ever. `enterprise/internal/audit.Store` `UPDATE`/`DELETE`/`TRUNCATE`, ever. `enterprise/internal/audit.Store`
connects using this role's credentials via its **own** `pgxpool.Pool`, connects using this role's credentials via its **own** `pgxpool.Pool`,
never the shared `sentry` pool `api`/`alerting`'s other stores use — never the shared `cairnobs` pool `api`/`alerting`'s other stores use —
reusing the shared pool for audit writes would give audit_log's reusing the shared pool for audit writes would give audit_log's
application-level credential the same `UPDATE`/`DELETE` grants every application-level credential the same `UPDATE`/`DELETE` grants every
other metadata table has, silently defeating the whole point. other metadata table has, silently defeating the whole point.
2. A `BEFORE UPDATE OR DELETE` trigger (`migrations/0015`-`0016`) that 2. A `BEFORE UPDATE OR DELETE` trigger (`migrations/0015`-`0016`) that
rejects the operation for **any** role, including the table owner rejects the operation for **any** role, including the table owner
(`sentry`) — confirmed live: even `sentry` needs to explicitly (`cairnobs`) — confirmed live: even `cairnobs` needs to explicitly
`ALTER TABLE audit_log DISABLE TRIGGER audit_log_immutable` (a `ALTER TABLE audit_log DISABLE TRIGGER audit_log_immutable` (a
privileged, distinct-from-normal-access operation) before it can privileged, distinct-from-normal-access operation) before it can
modify a row. This is redundant defense-in-depth independent of the modify a row. This is redundant defense-in-depth independent of the
@@ -89,9 +89,9 @@ Environment variables `migrate.sh` reads (all optional except
|---|---| |---|---|
| `POSTGRES_HOST` | `localhost` | | `POSTGRES_HOST` | `localhost` |
| `POSTGRES_PORT` | `5432` | | `POSTGRES_PORT` | `5432` |
| `POSTGRES_USER` | `sentry` | | `POSTGRES_USER` | `cairnobs` |
| `POSTGRES_PASSWORD` | (empty — must be set) | | `POSTGRES_PASSWORD` | (empty — must be set) |
| `POSTGRES_DATABASE` | `sentry_metadata` | | `POSTGRES_DATABASE` | `cairnobs_metadata` |
| `AUDIT_WRITER_PASSWORD` | `audit-writer-dev-only` | | `AUDIT_WRITER_PASSWORD` | `audit-writer-dev-only` |
The database itself isn't created by `migrate.sh` — the `postgres:16-alpine` The database itself isn't created by `migrate.sh` — the `postgres:16-alpine`
+2 -2
View File
@@ -9,8 +9,8 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
environment: environment:
POSTGRES_DB: sentry_metadata POSTGRES_DB: cairnobs_metadata
POSTGRES_USER: sentry POSTGRES_USER: cairnobs
POSTGRES_PASSWORD: "cairnobs-dev-only" # not a real secret, see root docker-compose.yml POSTGRES_PASSWORD: "cairnobs-dev-only" # not a real secret, see root docker-compose.yml
volumes: volumes:
- metadata-postgres-data:/var/lib/postgresql/data - metadata-postgres-data:/var/lib/postgresql/data
+2 -2
View File
@@ -8,9 +8,9 @@ set -euo pipefail
POSTGRES_HOST="${POSTGRES_HOST:-localhost}" POSTGRES_HOST="${POSTGRES_HOST:-localhost}"
POSTGRES_PORT="${POSTGRES_PORT:-5432}" POSTGRES_PORT="${POSTGRES_PORT:-5432}"
POSTGRES_USER="${POSTGRES_USER:-sentry}" POSTGRES_USER="${POSTGRES_USER:-cairnobs}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}" POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}"
POSTGRES_DATABASE="${POSTGRES_DATABASE:-sentry_metadata}" POSTGRES_DATABASE="${POSTGRES_DATABASE:-cairnobs_metadata}"
# Password for the restricted audit-log-writer Postgres role (Phase 4 # Password for the restricted audit-log-writer Postgres role (Phase 4
# task 4, see /docs/phase-4-isolation-design.md's audit logging # task 4, see /docs/phase-4-isolation-design.md's audit logging
# section) -- a second, narrower-granted role, not the shared # section) -- a second, narrower-granted role, not the shared
+2 -2
View File
@@ -18,7 +18,7 @@ planning.
## How it fits together ## How it fits together
``` ```
ingest (gRPC front end) --> Redpanda (sentry.logs.raw) --> ingest's ClickHouse-writer consumer --> ClickHouse ingest (gRPC front end) --> Redpanda (cairnobs.logs.raw) --> ingest's ClickHouse-writer consumer --> ClickHouse
\ \
`--> search's own consumer --> Tantivy index `--> search's own consumer --> Tantivy index
``` ```
@@ -121,7 +121,7 @@ Environment variables (see `src/config.rs`):
|---|---|---| |---|---|---|
| `GRPC_LISTEN_ADDR` | `0.0.0.0:50052` | Full socket address — Rust's parser needs one, unlike Go's `:PORT` shorthand `ingest`/`api` use | | `GRPC_LISTEN_ADDR` | `0.0.0.0:50052` | Full socket address — Rust's parser needs one, unlike Go's `:PORT` shorthand `ingest`/`api` use |
| `REDPANDA_BROKERS` | `localhost:9092` | Comma-separated broker list | | `REDPANDA_BROKERS` | `localhost:9092` | Comma-separated broker list |
| `REDPANDA_TOPIC` | `sentry.logs.raw` | Must match `/ingest`'s topic | | `REDPANDA_TOPIC` | `cairnobs.logs.raw` | Must match `/ingest`'s topic |
| `REDPANDA_TOPIC_PARTITIONS` | `6` | Must match what `/transport/provision-topics.sh` created | | `REDPANDA_TOPIC_PARTITIONS` | `6` | Must match what `/transport/provision-topics.sh` created |
| `INDEX_PATH` | `/var/lib/cairnobs-search/index` | Default (non-tenant) Tantivy index directory | | `INDEX_PATH` | `/var/lib/cairnobs-search/index` | Default (non-tenant) Tantivy index directory |
| `TENANTS_INDEX_PATH` | `/var/lib/cairnobs-search/tenants` | Per-tenant index directories live under here, one subdirectory per tenant_id (Phase 4) | | `TENANTS_INDEX_PATH` | `/var/lib/cairnobs-search/tenants` | Per-tenant index directories live under here, one subdirectory per tenant_id (Phase 4) |
+1 -1
View File
@@ -67,7 +67,7 @@ impl Config {
.split(',') .split(',')
.map(str::to_string) .map(str::to_string)
.collect(), .collect(),
redpanda_topic: getenv("REDPANDA_TOPIC", "sentry.logs.raw"), redpanda_topic: getenv("REDPANDA_TOPIC", "cairnobs.logs.raw"),
index_path: PathBuf::from(getenv("INDEX_PATH", "/var/lib/cairnobs-search/index")), index_path: PathBuf::from(getenv("INDEX_PATH", "/var/lib/cairnobs-search/index")),
offsets_path: PathBuf::from(getenv( offsets_path: PathBuf::from(getenv(
"OFFSETS_PATH", "OFFSETS_PATH",
+1 -1
View File
@@ -23,7 +23,7 @@ use crate::tenants::ActiveTenantTracker;
/// `TestTenantIDHeaderKeyConstantsMatch` does on the Go side. /// `TestTenantIDHeaderKeyConstantsMatch` does on the Go side.
const TENANT_ID_HEADER_KEY: &str = "tenant_id"; const TENANT_ID_HEADER_KEY: &str = "tenant_id";
/// Reads the same `sentry.logs.raw` topic ingest's ClickHouse-writer /// Reads the same `cairnobs.logs.raw` topic ingest's ClickHouse-writer
/// consumer reads, as an independent consumer group in spirit (its own /// consumer reads, as an independent consumer group in spirit (its own
/// offset tracking, own failure domain) even though rskafka doesn't speak /// offset tracking, own failure domain) even though rskafka doesn't speak
/// Kafka's broker-side consumer-group protocol -- see offsets.rs. One /// Kafka's broker-side consumer-group protocol -- see offsets.rs. One
+1 -1
View File
@@ -1,7 +1,7 @@
# One-shot migration runner: bash + curl baked in, migrations/*.sql copied # One-shot migration runner: bash + curl baked in, migrations/*.sql copied
# in at build time. No runtime package install and no host volume mount # in at build time. No runtime package install and no host volume mount
# needed — works offline once built. # needed — works offline once built.
# docker build -f storage/Dockerfile -t sentry-clickhouse-migrate storage/ # docker build -f storage/Dockerfile -t cairnobs-clickhouse-migrate storage/
FROM alpine:3.20 FROM alpine:3.20
RUN apk add --no-cache bash curl RUN apk add --no-cache bash curl
WORKDIR /storage WORKDIR /storage
+1 -1
View File
@@ -100,7 +100,7 @@ defaults so the two stay in sync out of the box):
| `CLICKHOUSE_HTTP` | `http://localhost:8123` | | `CLICKHOUSE_HTTP` | `http://localhost:8123` |
| `CLICKHOUSE_USER` | `default` | | `CLICKHOUSE_USER` | `default` |
| `CLICKHOUSE_PASSWORD` | (empty — override, see above) | | `CLICKHOUSE_PASSWORD` | (empty — override, see above) |
| `CLICKHOUSE_DATABASE` | `sentry` | | `CLICKHOUSE_DATABASE` | `cairnobs` |
There's also a `Dockerfile` (bash + curl baked in, `migrations/` copied in There's also a `Dockerfile` (bash + curl baked in, `migrations/` copied in
at build time) used by the root-level `docker-compose.yml` as a one-shot at build time) used by the root-level `docker-compose.yml` as a one-shot
+1 -1
View File
@@ -13,7 +13,7 @@ set -euo pipefail
CLICKHOUSE_HTTP="${CLICKHOUSE_HTTP:-http://localhost:8123}" CLICKHOUSE_HTTP="${CLICKHOUSE_HTTP:-http://localhost:8123}"
CLICKHOUSE_USER="${CLICKHOUSE_USER:-default}" CLICKHOUSE_USER="${CLICKHOUSE_USER:-default}"
CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-}" CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-}"
DATABASE="${CLICKHOUSE_DATABASE:-sentry}" DATABASE="${CLICKHOUSE_DATABASE:-cairnobs}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MIGRATIONS_DIR="${SCRIPT_DIR}/migrations" MIGRATIONS_DIR="${SCRIPT_DIR}/migrations"
+1 -1
View File
@@ -5,7 +5,7 @@ Redpanda for local development, plus the script that provisions the topic
## Topic naming contract ## Topic naming contract
`ingest` defaults to `REDPANDA_TOPIC=sentry.logs.raw` (see `ingest` defaults to `REDPANDA_TOPIC=cairnobs.logs.raw` (see
`/ingest/internal/config`). `provision-topics.sh` defaults to the same `/ingest/internal/config`). `provision-topics.sh` defaults to the same
name. These aren't wired together automatically — if you change one, name. These aren't wired together automatically — if you change one,
change the other, or override `REDPANDA_TOPIC` consistently wherever change the other, or override `REDPANDA_TOPIC` consistently wherever
+1 -1
View File
@@ -17,7 +17,7 @@ set -euo pipefail
# of ever becoming healthy. # of ever becoming healthy.
BROKERS="${REDPANDA_BROKERS:-localhost:9092}" BROKERS="${REDPANDA_BROKERS:-localhost:9092}"
ADMIN_HOSTS="${REDPANDA_ADMIN_HOSTS:-localhost:9644}" ADMIN_HOSTS="${REDPANDA_ADMIN_HOSTS:-localhost:9644}"
TOPIC="${REDPANDA_TOPIC:-sentry.logs.raw}" TOPIC="${REDPANDA_TOPIC:-cairnobs.logs.raw}"
PARTITIONS="${REDPANDA_TOPIC_PARTITIONS:-6}" PARTITIONS="${REDPANDA_TOPIC_PARTITIONS:-6}"
echo "Waiting for Redpanda admin API at ${ADMIN_HOSTS}..." echo "Waiting for Redpanda admin API at ${ADMIN_HOSTS}..."
+3 -3
View File
@@ -2,8 +2,8 @@
<!-- Favicon: simplified 3-stone mark on terminal-dark rounded square --> <!-- Favicon: simplified 3-stone mark on terminal-dark rounded square -->
<rect x="0" y="0" width="64" height="64" rx="14" fill="#0D1117"/> <rect x="0" y="0" width="64" height="64" rx="14" fill="#0D1117"/>
<g> <g>
<rect x="12" y="38" width="40" height="13" rx="6.5" fill="#FF8A00" transform="rotate(-2 32 44.5)"/> <path d="M14,50 L10,45 L13,40 L22,37 L34,40 L44,37 L53,41 L50,47 L40,51 L26,48 Z" fill="#FF8A00"/>
<rect x="18" y="24" width="28" height="12" rx="6" fill="#FF9D2E" transform="rotate(3 32 30)"/> <path d="M20,35 L16,30 L19,25 L28,23 L36,26 L44,23 L47,28 L43,34 L32,36 L24,33 Z" fill="#FF9D2E"/>
<rect x="23" y="12" width="18" height="10" rx="5" fill="#FFB157" transform="rotate(-3 32 17)"/> <path d="M24,21 L21,16 L25,12 L32,11 L38,13 L41,17 L38,21 L31,22 L27,20 Z" fill="#FFB157"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 551 B

After

Width:  |  Height:  |  Size: 550 B

+4 -4
View File
@@ -1,10 +1,10 @@
<svg width="600" height="140" viewBox="0 0 600 140" xmlns="http://www.w3.org/2000/svg"> <svg width="600" height="140" viewBox="0 0 600 140" xmlns="http://www.w3.org/2000/svg">
<!-- Horizontal lockup for dark backgrounds --> <!-- Horizontal lockup for dark backgrounds -->
<g transform="translate(20,10) scale(0.6)"> <g transform="translate(20,10) scale(0.6)">
<rect x="30" y="142" width="140" height="42" rx="20" fill="#E6690E" transform="rotate(-1.5 100 163)"/> <path d="M32,184 L20,172 L26,155 L18,146 L46,138 L82,144 L112,134 L150,140 L176,150 L182,168 L170,184 L142,192 L100,182 L64,190 Z" fill="#E6690E"/>
<rect x="48" y="106" width="104" height="38" rx="18" fill="#FF8A00" transform="rotate(2.5 100 125)"/> <path d="M54,144 L42,134 L50,118 L44,108 L68,100 L96,106 L118,98 L142,106 L156,116 L150,132 L160,142 L132,150 L100,140 L70,148 Z" fill="#FF8A00"/>
<rect x="64" y="73" width="72" height="34" rx="16" fill="#FF9D2E" transform="rotate(-3 100 90)"/> <path d="M68,106 L58,96 L64,82 L60,72 L80,66 L100,72 L116,64 L132,74 L138,86 L130,98 L138,106 L112,112 L88,104 L72,110 Z" fill="#FF9D2E"/>
<rect x="78" y="43" width="44" height="30" rx="14" fill="#FFB157" transform="rotate(3.5 100 58)"/> <path d="M80,70 L72,60 L78,48 L74,40 L92,36 L106,42 L116,34 L126,44 L124,56 L130,66 L108,72 L92,64 L82,70 Z" fill="#FFB157"/>
</g> </g>
<text x="160" y="87" font-family="'JetBrains Mono','Fira Code',ui-monospace,monospace" font-size="48" font-weight="700" fill="#F2F2F2" letter-spacing="-1">cairn</text> <text x="160" y="87" font-family="'JetBrains Mono','Fira Code',ui-monospace,monospace" font-size="48" font-weight="700" fill="#F2F2F2" letter-spacing="-1">cairn</text>
<text x="342" y="87" font-family="'JetBrains Mono','Fira Code',ui-monospace,monospace" font-size="48" font-weight="700" fill="#FF8A00" letter-spacing="1"> obs</text> <text x="342" y="87" font-family="'JetBrains Mono','Fira Code',ui-monospace,monospace" font-size="48" font-weight="700" fill="#FF8A00" letter-spacing="1"> obs</text>

Before

Width:  |  Height:  |  Size: 1017 B

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,12 @@
<svg width="600" height="140" viewBox="0 0 600 140" xmlns="http://www.w3.org/2000/svg">
<!-- Horizontal lockup for light backgrounds -->
<g transform="translate(20,10) scale(0.6)">
<path d="M32,184 L20,172 L26,155 L18,146 L46,138 L82,144 L112,134 L150,140 L176,150 L182,168 L170,184 L142,192 L100,182 L64,190 Z" fill="#E6690E"/>
<path d="M54,144 L42,134 L50,118 L44,108 L68,100 L96,106 L118,98 L142,106 L156,116 L150,132 L160,142 L132,150 L100,140 L70,148 Z" fill="#FF8A00"/>
<path d="M68,106 L58,96 L64,82 L60,72 L80,66 L100,72 L116,64 L132,74 L138,86 L130,98 L138,106 L112,112 L88,104 L72,110 Z" fill="#FF9D2E"/>
<path d="M80,70 L72,60 L78,48 L74,40 L92,36 L106,42 L116,34 L126,44 L124,56 L130,66 L108,72 L92,64 L82,70 Z" fill="#FFB157"/>
</g>
<text x="160" y="87" font-family="'JetBrains Mono','Fira Code',ui-monospace,monospace" font-size="48" font-weight="700" fill="#111315" letter-spacing="-1">cairn</text>
<text x="342" y="87" font-family="'JetBrains Mono','Fira Code',ui-monospace,monospace" font-size="48" font-weight="700" fill="#E6690E" letter-spacing="1"> obs</text>
<rect x="540" y="49" width="14" height="38" fill="#E6690E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+4 -4
View File
@@ -1,10 +1,10 @@
<svg width="360" height="360" viewBox="0 0 360 360" xmlns="http://www.w3.org/2000/svg"> <svg width="360" height="360" viewBox="0 0 360 360" xmlns="http://www.w3.org/2000/svg">
<!-- Stacked lockup for dark backgrounds / square placements --> <!-- Stacked lockup for dark backgrounds / square placements -->
<g transform="translate(80,30) scale(1.0)"> <g transform="translate(80,30) scale(1.0)">
<rect x="30" y="142" width="140" height="42" rx="20" fill="#E6690E" transform="rotate(-1.5 100 163)"/> <path d="M32,184 L20,172 L26,155 L18,146 L46,138 L82,144 L112,134 L150,140 L176,150 L182,168 L170,184 L142,192 L100,182 L64,190 Z" fill="#E6690E"/>
<rect x="48" y="106" width="104" height="38" rx="18" fill="#FF8A00" transform="rotate(2.5 100 125)"/> <path d="M54,144 L42,134 L50,118 L44,108 L68,100 L96,106 L118,98 L142,106 L156,116 L150,132 L160,142 L132,150 L100,140 L70,148 Z" fill="#FF8A00"/>
<rect x="64" y="73" width="72" height="34" rx="16" fill="#FF9D2E" transform="rotate(-3 100 90)"/> <path d="M68,106 L58,96 L64,82 L60,72 L80,66 L100,72 L116,64 L132,74 L138,86 L130,98 L138,106 L112,112 L88,104 L72,110 Z" fill="#FF9D2E"/>
<rect x="78" y="43" width="44" height="30" rx="14" fill="#FFB157" transform="rotate(3.5 100 58)"/> <path d="M80,70 L72,60 L78,48 L74,40 L92,36 L106,42 L116,34 L126,44 L124,56 L130,66 L108,72 L92,64 L82,70 Z" fill="#FFB157"/>
</g> </g>
<text x="180" y="290" font-family="'JetBrains Mono','Fira Code',ui-monospace,monospace" font-size="42" font-weight="700" fill="#F2F2F2" letter-spacing="-1" text-anchor="middle">cairn <tspan fill="#FF8A00">obs</tspan></text> <text x="180" y="290" font-family="'JetBrains Mono','Fira Code',ui-monospace,monospace" font-size="42" font-weight="700" fill="#F2F2F2" letter-spacing="-1" text-anchor="middle">cairn <tspan fill="#FF8A00">obs</tspan></text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 859 B

After

Width:  |  Height:  |  Size: 1017 B

+10
View File
@@ -0,0 +1,10 @@
<svg width="360" height="360" viewBox="0 0 360 360" xmlns="http://www.w3.org/2000/svg">
<!-- Stacked lockup for light backgrounds / square placements -->
<g transform="translate(80,30) scale(1.0)">
<path d="M32,184 L20,172 L26,155 L18,146 L46,138 L82,144 L112,134 L150,140 L176,150 L182,168 L170,184 L142,192 L100,182 L64,190 Z" fill="#E6690E"/>
<path d="M54,144 L42,134 L50,118 L44,108 L68,100 L96,106 L118,98 L142,106 L156,116 L150,132 L160,142 L132,150 L100,140 L70,148 Z" fill="#FF8A00"/>
<path d="M68,106 L58,96 L64,82 L60,72 L80,66 L100,72 L116,64 L132,74 L138,86 L130,98 L138,106 L112,112 L88,104 L72,110 Z" fill="#FF9D2E"/>
<path d="M80,70 L72,60 L78,48 L74,40 L92,36 L106,42 L116,34 L126,44 L124,56 L130,66 L108,72 L92,64 L82,70 Z" fill="#FFB157"/>
</g>
<text x="180" y="290" font-family="'JetBrains Mono','Fira Code',ui-monospace,monospace" font-size="42" font-weight="700" fill="#111315" letter-spacing="-1" text-anchor="middle">cairn <tspan fill="#E6690E">obs</tspan></text>
</svg>

After

Width:  |  Height:  |  Size: 1018 B

+4 -3
View File
@@ -9,9 +9,10 @@
type CurrentSession, type CurrentSession,
type LocalSession type LocalSession
} from '$lib/api'; } from '$lib/api';
import { getTheme, setTheme, type Theme } from '$lib/theme.svelte'; import { getTheme, setTheme, isLight, type Theme } from '$lib/theme.svelte';
import { getDensity, toggleDensity } from '$lib/density.svelte'; import { getDensity, toggleDensity } from '$lib/density.svelte';
import logo from '$lib/assets/logo-horizontal-dark.svg'; import logoDark from '$lib/assets/logo-horizontal-dark.svg';
import logoLight from '$lib/assets/logo-horizontal-light.svg';
let { let {
onOpenPalette, onOpenPalette,
@@ -84,7 +85,7 @@
<aside class="sidebar" class:mobile-open={mobileOpen}> <aside class="sidebar" class:mobile-open={mobileOpen}>
<div class="brand"> <div class="brand">
<a href="/" class="brand-link" onclick={onCloseMobile}> <a href="/" class="brand-link" onclick={onCloseMobile}>
<img src={logo} alt="Cairn OBS" class="brand-logo" /> <img src={isLight() ? logoLight : logoDark} alt="Cairn OBS" class="brand-logo" />
</a> </a>
<button type="button" class="close-mobile" onclick={onCloseMobile} aria-label="Close menu"></button> <button type="button" class="close-mobile" onclick={onCloseMobile} aria-label="Close menu"></button>
</div> </div>
+23
View File
@@ -34,10 +34,33 @@ const initial = readStored();
let theme = $state<Theme>(initial); let theme = $state<Theme>(initial);
apply(initial); apply(initial);
// Tracks the OS preference live (not just at load) so "system" stays
// accurate across a theme change the user makes outside the app, same
// as app.css/tokens.css's own `prefers-color-scheme` media query does
// automatically for CSS -- anything in JS that needs to know "is the UI
// actually light right now" (e.g. picking a light/dark logo asset) has
// to track this the same way, or it drifts from what's on screen.
let prefersLight = $state(
typeof window !== 'undefined' ? window.matchMedia('(prefers-color-scheme: light)').matches : false
);
if (typeof window !== 'undefined') {
window
.matchMedia('(prefers-color-scheme: light)')
.addEventListener('change', (e) => (prefersLight = e.matches));
}
export function getTheme(): Theme { export function getTheme(): Theme {
return theme; return theme;
} }
// Whether the rendered UI is in light mode right now -- explicit
// "light", or "system" while the OS itself prefers light. Mirrors
// app.css's `html[data-theme='light']` / `prefers-color-scheme: light`
// precedence exactly.
export function isLight(): boolean {
return theme === 'light' || (theme === 'system' && prefersLight);
}
export function setTheme(t: Theme) { export function setTheme(t: Theme) {
theme = t; theme = t;
apply(t); apply(t);
+4 -2
View File
@@ -3,7 +3,9 @@
// /search (the old root; every prior link/shortcut/drill-down that // /search (the old root; every prior link/shortcut/drill-down that
// used to point at "/" expecting the query page now points at // used to point at "/" expecting the query page now points at
// "/search" explicitly, see NavSidebar/CommandPalette/drilldown.ts). // "/search" explicitly, see NavSidebar/CommandPalette/drilldown.ts).
import logo from '$lib/assets/logo-stacked-dark.svg'; import logoDark from '$lib/assets/logo-stacked-dark.svg';
import logoLight from '$lib/assets/logo-stacked-light.svg';
import { isLight } from '$lib/theme.svelte';
import { Button } from '$lib/components/ui'; import { Button } from '$lib/components/ui';
const shortcuts: { href: string; label: string; hint: string }[] = [ const shortcuts: { href: string; label: string; hint: string }[] = [
@@ -15,7 +17,7 @@
</script> </script>
<main> <main>
<img src={logo} alt="Cairn OBS" class="logo" /> <img src={isLight() ? logoLight : logoDark} alt="Cairn OBS" class="logo" />
<p class="tagline"> <p class="tagline">
One query bar for filter/stats queries and free-text search across every host and service One query bar for filter/stats queries and free-text search across every host and service
you're shipping logs from. you're shipping logs from.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 493 B

After

Width:  |  Height:  |  Size: 535 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 912 B

After

Width:  |  Height:  |  Size: 968 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB