From c920e0f2c44371859aa4a68d0bace76eebe78019 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 22 Aug 2026 16:11:56 -0700 Subject: [PATCH] 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. --- alerting/Dockerfile | 2 +- alerting/README.md | 8 ++-- alerting/internal/config/config.go | 4 +- .../sessioncheck_integration_test.go | 2 +- api/Dockerfile | 2 +- api/README.md | 4 +- api/dashboards/store_integration_test.go | 2 +- api/internal/config/config.go | 10 ++--- api/localauth/store_integration_test.go | 3 +- docker-compose.yml | 40 +++++++++--------- docs/agent-management-design.md | 12 +++--- docs/architecture.md | 4 +- docs/compliance/license-inventory.json | 2 +- docs/phase-0-runbook.md | 12 +++--- docs/phase-1-runbook.md | 2 +- docs/phase-3-runbook.md | 20 ++++----- docs/phase-4-rbac-design.md | 2 +- docs/phase-7-ai-design.md | 8 ++-- docs/phase-7-runbook.md | 12 +++--- docs/query-language-design.md | 9 +++- docs/security/threat-model.md | 16 +++---- enterprise/Dockerfile | 2 +- enterprise/README.md | 26 ++++++------ enterprise/cmd/enterprise-api/Dockerfile | 2 +- enterprise/cmd/enterprise-api/main.go | 2 +- enterprise/cmd/enterprise-ingest/Dockerfile | 2 +- enterprise/cmd/enterprise-ingest/main.go | 4 +- enterprise/internal/apiconfig/apiconfig.go | 4 +- enterprise/internal/audit/audit.go | 4 +- enterprise/internal/audit/integration_test.go | 18 ++++---- enterprise/internal/chwriter/chwriter.go | 2 +- enterprise/internal/config/config.go | 4 +- .../internal/ingestconfig/ingestconfig.go | 12 +++--- enterprise/internal/rbacstore/rbacstore.go | 2 +- .../internal/rbacstore/rbacstore_test.go | 2 +- enterprise/internal/saml/saml_test.go | 6 +-- hack/alert-load-test/README.md | 2 +- hack/alert-load-test/main.go | 2 +- ingest/Dockerfile | 2 +- ingest/README.md | 14 +++--- .../internal/agentregistry/agentregistry.go | 2 +- ingest/internal/config/config.go | 14 +++--- ingest/internal/config/config_test.go | 4 +- metadata/Dockerfile | 2 +- metadata/README.md | 12 +++--- metadata/docker-compose.yml | 4 +- metadata/migrate.sh | 4 +- search/README.md | 4 +- search/src/config.rs | 2 +- search/src/consumer.rs | 2 +- storage/Dockerfile | 2 +- storage/README.md | 2 +- storage/migrate.sh | 2 +- transport/README.md | 2 +- transport/provision-topics.sh | 2 +- web/src/lib/assets/favicon.svg | 6 +-- web/src/lib/assets/logo-horizontal-dark.svg | 8 ++-- web/src/lib/assets/logo-horizontal-light.svg | 12 ++++++ web/src/lib/assets/logo-stacked-dark.svg | 8 ++-- web/src/lib/assets/logo-stacked-light.svg | 10 +++++ web/src/lib/components/NavSidebar.svelte | 7 +-- web/src/lib/theme.svelte.ts | 23 ++++++++++ web/src/routes/+page.svelte | 6 ++- web/static/icons/favicon-16.png | Bin 493 -> 535 bytes web/static/icons/favicon-180.png | Bin 5161 -> 4723 bytes web/static/icons/favicon-32.png | Bin 912 -> 968 bytes web/static/icons/favicon-48.png | Bin 1392 -> 1491 bytes web/static/icons/favicon-512.png | Bin 15615 -> 11522 bytes 68 files changed, 242 insertions(+), 186 deletions(-) create mode 100644 web/src/lib/assets/logo-horizontal-light.svg create mode 100644 web/src/lib/assets/logo-stacked-light.svg diff --git a/alerting/Dockerfile b/alerting/Dockerfile index cc42b53..8e24c50 100644 --- a/alerting/Dockerfile +++ b/alerting/Dockerfile @@ -1,7 +1,7 @@ # 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, # 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 WORKDIR /src COPY . . diff --git a/alerting/README.md b/alerting/README.md index 480a47f..5a33dad 100644 --- a/alerting/README.md +++ b/alerting/README.md @@ -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 ``` -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 `POST /query` over plain HTTP for rule evaluation. Never connects to ClickHouse or Tantivy directly. @@ -48,8 +48,8 @@ only, not separate delivery paths. |---|---| | `HTTP_LISTEN_ADDR` | `:8081` | | `POSTGRES_ADDR` | `localhost:5432` | -| `POSTGRES_DATABASE` | `sentry_metadata` | -| `POSTGRES_USERNAME` | `sentry` | +| `POSTGRES_DATABASE` | `cairnobs_metadata` | +| `POSTGRES_USERNAME` | `cairnobs` | | `POSTGRES_PASSWORD` | (empty — must be set) | | `API_QUERY_URL` | `http://localhost:8080` | | `CORS_ALLOWED_ORIGIN` | `*` | @@ -94,5 +94,5 @@ go test ./... ``` ```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 ``` diff --git a/alerting/internal/config/config.go b/alerting/internal/config/config.go index 42ad5e0..b5974db 100644 --- a/alerting/internal/config/config.go +++ b/alerting/internal/config/config.go @@ -72,8 +72,8 @@ func Load() (Config, error) { HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8081"), Postgres: PostgresConfig{ Addr: getenv("POSTGRES_ADDR", "localhost:5432"), - Database: getenv("POSTGRES_DATABASE", "sentry_metadata"), - Username: getenv("POSTGRES_USERNAME", "sentry"), + Database: getenv("POSTGRES_DATABASE", "cairnobs_metadata"), + Username: getenv("POSTGRES_USERNAME", "cairnobs"), Password: getenv("POSTGRES_PASSWORD", ""), }, APIQueryURL: getenv("API_QUERY_URL", "http://localhost:8080"), diff --git a/alerting/internal/sessioncheck/sessioncheck_integration_test.go b/alerting/internal/sessioncheck/sessioncheck_integration_test.go index 9f04523..4f46bb0 100644 --- a/alerting/internal/sessioncheck/sessioncheck_integration_test.go +++ b/alerting/internal/sessioncheck/sessioncheck_integration_test.go @@ -37,7 +37,7 @@ func integrationPool(t *testing.T) *pgxpool.Pool { t.Skip("SESSIONCHECK_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test") } 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) if err != nil { t.Fatalf("opening pool: %v", err) diff --git a/api/Dockerfile b/api/Dockerfile index bf36a90..8117756 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,7 +1,7 @@ # 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 # 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 WORKDIR /src diff --git a/api/README.md b/api/README.md index 5c5b2b4..e19057b 100644 --- a/api/README.md +++ b/api/README.md @@ -46,7 +46,7 @@ Environment variables (see `internal/config/config.go`): |---|---|---| | `HTTP_LISTEN_ADDR` | `:8080` | | | `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` | | `QUERY_TIMEOUT_SECONDS` | `30` | Per-request timeout | | `CORS_ALLOWED_ORIGIN` | `*` | Wide open by default since there's no auth yet; tighten together | @@ -66,7 +66,7 @@ go test ./... ```sh # from the repo root, not api/ -docker build -f api/Dockerfile -t sentry-api . +docker build -f api/Dockerfile -t cairnobs-api . ``` ## Testing notes diff --git a/api/dashboards/store_integration_test.go b/api/dashboards/store_integration_test.go index 89a2534..f384e99 100644 --- a/api/dashboards/store_integration_test.go +++ b/api/dashboards/store_integration_test.go @@ -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") } 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) if err != nil { t.Fatalf("opening pool: %v", err) diff --git a/api/internal/config/config.go b/api/internal/config/config.go index fa80084..cd5cacd 100644 --- a/api/internal/config/config.go +++ b/api/internal/config/config.go @@ -37,8 +37,8 @@ type LocalAuthConfig struct { SessionTTL time.Duration // CookieDomain empty means a host-only cookie (fine for local dev, // where web/api are both localhost:). Set to e.g. - // ".sentry.example.com" in production so the cookie is also sent to - // api.sentry.example.com/alerting.sentry.example.com. + // ".cairnobs.example.com" in production so the cookie is also sent to + // api.cairnobs.example.com/alerting.cairnobs.example.com. CookieDomain string // CookieSecure defaults true (never sent over plain HTTP) -- // deliberately opt-out via LOCAL_AUTH_COOKIE_SECURE=false, only @@ -108,14 +108,14 @@ func Load() (Config, error) { HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8080"), ClickHouse: ClickHouseConfig{ Addr: getenv("CLICKHOUSE_ADDR", "localhost:9000"), - Database: getenv("CLICKHOUSE_DATABASE", "sentry"), + Database: getenv("CLICKHOUSE_DATABASE", "cairnobs"), Username: getenv("CLICKHOUSE_USERNAME", "default"), Password: getenv("CLICKHOUSE_PASSWORD", ""), }, Postgres: PostgresConfig{ Addr: getenv("POSTGRES_ADDR", "localhost:5432"), - Database: getenv("POSTGRES_DATABASE", "sentry_metadata"), - Username: getenv("POSTGRES_USERNAME", "sentry"), + Database: getenv("POSTGRES_DATABASE", "cairnobs_metadata"), + Username: getenv("POSTGRES_USERNAME", "cairnobs"), Password: getenv("POSTGRES_PASSWORD", ""), }, // Search service's gRPC address (see /search) -- default matches diff --git a/api/localauth/store_integration_test.go b/api/localauth/store_integration_test.go index 5b11ec0..5d23feb 100644 --- a/api/localauth/store_integration_test.go +++ b/api/localauth/store_integration_test.go @@ -35,7 +35,7 @@ func integrationStore(t *testing.T) *Store { t.Skip("LOCALAUTH_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test") } 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) if err != nil { 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) } } + diff --git a/docker-compose.yml b/docker-compose.yml index 0066aff..0eb7dac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,7 @@ services: timeout: 5s 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. redpanda-provision: build: @@ -120,13 +120,13 @@ services: image: postgres:16-alpine container_name: cairnobs-metadata-postgres environment: - POSTGRES_DB: sentry_metadata - POSTGRES_USER: sentry + POSTGRES_DB: cairnobs_metadata + POSTGRES_USER: cairnobs POSTGRES_PASSWORD: "cairnobs-dev-only" # not a real secret, same framing as CLICKHOUSE_PASSWORD above volumes: - metadata-postgres-data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U sentry -d sentry_metadata"] + test: ["CMD-SHELL", "pg_isready -U cairnobs -d cairnobs_metadata"] interval: 5s timeout: 5s retries: 30 @@ -143,12 +143,12 @@ services: environment: POSTGRES_HOST: "metadata-postgres" POSTGRES_PORT: "5432" - POSTGRES_USER: "sentry" + POSTGRES_USER: "cairnobs" POSTGRES_PASSWORD: "cairnobs-dev-only" - POSTGRES_DATABASE: "sentry_metadata" + POSTGRES_DATABASE: "cairnobs_metadata" # Password for the restricted audit_writer Postgres role (Phase 4 # 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. AUDIT_WRITER_PASSWORD: "audit-writer-dev-only" @@ -185,19 +185,19 @@ services: # compose file provisions one. # # 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 # inventory carries no tamper-evidence requirement, unlike # audit_log's dedicated restricted role). Set here (unlike # ENTERPRISE_AUTH_URL above) since this feature has no multi- # tenancy prerequisite -- it works the same in single-tenant core. AGENT_REGISTRY_POSTGRES_ADDR: "metadata-postgres:5432" - AGENT_REGISTRY_POSTGRES_USERNAME: "sentry" + AGENT_REGISTRY_POSTGRES_USERNAME: "cairnobs" AGENT_REGISTRY_POSTGRES_PASSWORD: "cairnobs-dev-only" volumes: - ./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 # builds a Tantivy full-text index over the message field. search: @@ -255,8 +255,8 @@ services: CLICKHOUSE_PASSWORD: "cairnobs-dev-only" SEARCH_GRPC_ADDR: "search:50052" POSTGRES_ADDR: "metadata-postgres:5432" - POSTGRES_DATABASE: "sentry_metadata" - POSTGRES_USERNAME: "sentry" + POSTGRES_DATABASE: "cairnobs_metadata" + POSTGRES_USERNAME: "cairnobs" POSTGRES_PASSWORD: "cairnobs-dev-only" healthcheck: # alerting (Phase 3 task 5) depends_on api -- without this, that @@ -293,8 +293,8 @@ services: - "8081:8081" environment: POSTGRES_ADDR: "metadata-postgres:5432" - POSTGRES_DATABASE: "sentry_metadata" - POSTGRES_USERNAME: "sentry" + POSTGRES_DATABASE: "cairnobs_metadata" + POSTGRES_USERNAME: "cairnobs" POSTGRES_PASSWORD: "cairnobs-dev-only" # Resolves to whichever of api/enterprise-api is actually active -- # 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). ENTERPRISE_SESSION_SIGNING_KEY: "cairnobs-dev-only-session-signing-key-32bytes+" POSTGRES_ADDR: "metadata-postgres:5432" - POSTGRES_DATABASE: "sentry_metadata" - POSTGRES_USERNAME: "sentry" + POSTGRES_DATABASE: "cairnobs_metadata" + POSTGRES_USERNAME: "cairnobs" POSTGRES_PASSWORD: "cairnobs-dev-only" # Where the browser lands after internal/loginhandler sets a # session cookie -- web's mapped host port (see web's build args @@ -391,8 +391,8 @@ services: CLICKHOUSE_ADMIN_PASSWORD: "cairnobs-dev-only" SEARCH_GRPC_ADDR: "search:50052" POSTGRES_ADDR: "metadata-postgres:5432" - POSTGRES_DATABASE: "sentry_metadata" - POSTGRES_USERNAME: "sentry" + POSTGRES_DATABASE: "cairnobs_metadata" + POSTGRES_USERNAME: "cairnobs" POSTGRES_PASSWORD: "cairnobs-dev-only" AUDIT_WRITER_USERNAME: "audit_writer" AUDIT_WRITER_PASSWORD: "audit-writer-dev-only" @@ -434,8 +434,8 @@ services: REDPANDA_BROKERS: "redpanda:9092" CLICKHOUSE_ADDR: "clickhouse:9000" POSTGRES_ADDR: "metadata-postgres:5432" - POSTGRES_DATABASE: "sentry_metadata" - POSTGRES_USERNAME: "sentry" + POSTGRES_DATABASE: "cairnobs_metadata" + POSTGRES_USERNAME: "cairnobs" POSTGRES_PASSWORD: "cairnobs-dev-only" healthcheck: test: ["CMD", "/enterprise-ingest", "-healthcheck"] diff --git a/docs/agent-management-design.md b/docs/agent-management-design.md index f8ea8f1..49f7797 100644 --- a/docs/agent-management-design.md +++ b/docs/agent-management-design.md @@ -135,7 +135,7 @@ already configured for. ## Data model `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 this project's established "shared schema, different services own 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) -`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 (mirroring `dashboards permissions`) since an override has its own get/set/clear lifecycle distinct from the agent resource itself: ``` -sentryctl agents list|get -sentryctl agents config get |clear -sentryctl agents config set [--batch-max-size N] [--batch-flush-interval-ms N] +cairnobsctl agents list|get +cairnobsctl agents config get |clear +cairnobsctl agents config set [--batch-max-size N] [--batch-flush-interval-ms N] [--heartbeat-enabled true|false] [--heartbeat-interval-ms N] [--journald-unit UNIT] -sentryctl agents restart [--yes] +cairnobsctl agents restart [--yes] ``` `config set` is the one command with real logic beyond a thin HTTP diff --git a/docs/architecture.md b/docs/architecture.md index 7d18052..a0b52c0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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). | | `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. | -| `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. | ## 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. **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`, rendering to the same Service name and port either way, so a Helm-deployed cluster can't accidentally run the wrong binary — the same diff --git a/docs/compliance/license-inventory.json b/docs/compliance/license-inventory.json index f0de707..55d7e70 100644 --- a/docs/compliance/license-inventory.json +++ b/docs/compliance/license-inventory.json @@ -8510,7 +8510,7 @@ "direct_or_transitive": "direct", "ecosystem": "docker-image", "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" }, { diff --git a/docs/phase-0-runbook.md b/docs/phase-0-runbook.md index 62428b7..ae68f24 100644 --- a/docs/phase-0-runbook.md +++ b/docs/phase-0-runbook.md @@ -49,7 +49,7 @@ docker compose up -d --build ``` 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) → `ingest` and `api` → `web`. @@ -133,7 +133,7 @@ In another terminal, **after** the agent is running and connected started won't be picked up): ```sh -logger "hello from sentry phase 0" +logger "hello from cairnobs phase 0" ``` `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 default query (`SELECT * FROM logs 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:** @@ -160,11 +160,11 @@ curl -X POST http://localhost:8080/query \ -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):** ```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 @@ -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 more time rather than intervention. 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`.** You're likely on Compose v1 (`docker-compose`, hyphenated) rather than v2 diff --git a/docs/phase-1-runbook.md b/docs/phase-1-runbook.md index 1ee14ae..bc4c633 100644 --- a/docs/phase-1-runbook.md +++ b/docs/phase-1-runbook.md @@ -54,7 +54,7 @@ docker compose logs search ``` 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`. ### A2. Generate a log line and confirm both query paths agree diff --git a/docs/phase-3-runbook.md b/docs/phase-3-runbook.md index daf198f..d145ac7 100644 --- a/docs/phase-3-runbook.md +++ b/docs/phase-3-runbook.md @@ -43,10 +43,10 @@ go run . --count 500000 Create a dashboard and a couple of panels, either through the web UI (`http://localhost:3000/dashboards` → "+ Create" → "+ Add panel") or via -`sentryctl`: +`cairnobsctl`: ```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 @@ -95,7 +95,7 @@ Bring up a local webhook receiver for testing (no real Slack/PagerDuty needed): ```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 . ``` @@ -104,7 +104,7 @@ Create a notification target and a rule, either via the web UI ```sh 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 '{ @@ -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): the rule transitions `ok` → `firing` on its first evaluation (`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** 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 explicitly out of scope for this phase. -## 5. Confirm `sentryctl` +## 5. Confirm `cairnobsctl` ```sh -sentryctl dashboards list -sentryctl dashboards apply exported-dashboard.json -sentryctl alerts list -sentryctl alerts apply rule.json +cairnobsctl dashboards list +cairnobsctl dashboards apply exported-dashboard.json +cairnobsctl alerts list +cairnobsctl alerts apply rule.json ``` Both `dashboards` and `alerts` hit the exact same REST endpoints the web diff --git a/docs/phase-4-rbac-design.md b/docs/phase-4-rbac-design.md index a50c26a..5d184bd 100644 --- a/docs/phase-4-rbac-design.md +++ b/docs/phase-4-rbac-design.md @@ -108,7 +108,7 @@ yet. Stated plainly so this doesn't read as more built than it is. ## 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 additions (`tenants`, the `tenant_id` backfill on `alert_state`/ `delivery_log`). New tables, continuing that migration sequence: diff --git a/docs/phase-7-ai-design.md b/docs/phase-7-ai-design.md index c3a086f..7b869ef 100644 --- a/docs/phase-7-ai-design.md +++ b/docs/phase-7-ai-design.md @@ -265,7 +265,7 @@ with three levels (`ok`/`warn`/`reject`) and human-readable reasons: query"), decided here rather than left ambiguous: hand-written queries get the identical assessment an AI-generated one would, so there's 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 real existing usage. `warnings` is `omitempty` -- a client that 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 "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 result was produced: a low-confidence, non-compiling, or cost-guard-blocked 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 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+ audit_log` before/after showing `ai_interaction` added to the `event_type` CHECK constraint), and two new tests in @@ -622,7 +622,7 @@ ends up with: handler/routing logic via `fakeProvider` -- fast, deterministic, no network, all run in CI today. 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 written during Track B, task 11) -- proves the plumbing (HTTP routing, JSON contracts, `planner.Compile`/`costguard.Assess` integration, diff --git a/docs/phase-7-runbook.md b/docs/phase-7-runbook.md index e0b9fc4..f5f707e 100644 --- a/docs/phase-7-runbook.md +++ b/docs/phase-7-runbook.md @@ -12,7 +12,7 @@ document is verification only. Every AI operation (`complete`, `explain`, `fix`, `optimize`, `translate`, and the audit-logging endpoint behind it) has been run 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 process answering Ollama's actual `/api/chat` wire contract. **No real 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: ```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 \ sh -c "go build -o /tmp/mock-ollama . && /tmp/mock-ollama" @@ -106,7 +106,7 @@ wired into a stack anyone else might reach: ```sh 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 curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/ai/translate -d '{}' # 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: - 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 console noise) — this is the "graceful degradation" requirement, not incidental behavior. @@ -149,7 +149,7 @@ separately click "Run query". CLI: ```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 ``` @@ -164,7 +164,7 @@ pattern applied to that service instead: 1. Accept or dismiss a Fix/Optimize/Translate suggestion in the web UI. 2. Confirm a row landed in `audit_log`: ```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;" ``` `detail` should show `operation`/`accepted`/`edited` matching what you diff --git a/docs/query-language-design.md b/docs/query-language-design.md index 57be6ee..ebf9fd3 100644 --- a/docs/query-language-design.md +++ b/docs/query-language-design.md @@ -23,8 +23,15 @@ new cross-backend join strategy from scratch. 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 + // 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 | "stats" agg_call ("," agg_call)* ["by" field ("," field)*] | "sort" sort_field ("," sort_field)* diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 408db70..b2b63d9 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -97,13 +97,13 @@ wire-level `SearchRequest` carries the right `tenant_id`. All pass, for real, no disclaimer needed for this specific claim. **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 `enterprise.enabled` flag, rendering to the same Service name/port — so 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 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 `api`/`enterprise-api` services are now the analogous mutually-exclusive 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) └─▶ 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 → @@ -442,12 +442,12 @@ layer": 1. A dedicated `audit_writer` Postgres role with only `INSERT`+`SELECT` 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. 2. A `BEFORE UPDATE OR DELETE ... RAISE EXCEPTION` trigger (`metadata/migrations/0015-0016`) that rejects the operation for *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 access. @@ -547,10 +547,10 @@ terms: cluster/trusted network (`api`/`alerting`/`web`), never exposed publicly. Nothing in this codebase enforces that at the network layer; 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 (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 encrypted at rest without a cluster-level `EncryptionConfiguration`). 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 *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 | -| 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` | | 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 | diff --git a/enterprise/Dockerfile b/enterprise/Dockerfile index e6460bc..fbe6f8f 100644 --- a/enterprise/Dockerfile +++ b/enterprise/Dockerfile @@ -13,7 +13,7 @@ # imports existed; the repo-root context below is the same shape # enterprise-api's and enterprise-ingest's Dockerfiles already use for # 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 WORKDIR /src COPY . . diff --git a/enterprise/README.md b/enterprise/README.md index 5a72c1a..dda0972 100644 --- a/enterprise/README.md +++ b/enterprise/README.md @@ -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 (`API_SERVICE_TOKEN`) when configured -- see `/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. - `internal/rbacstore`: full CRUD over `users`/`tenants`/ `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 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 -`sentry_pending_login` cookie (`Path=/auth`) and redirects to +`cairnobs_pending_login` cookie (`Path=/auth`) and redirects to `SELECT_TENANT_REDIRECT_URL` (defaults to `{POST_LOGIN_REDIRECT_URL}/select-tenant`) instead of completing the 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 `IndexRegistry` already lived in AGPL core); and deployment-topology 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 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, tags records, unchanged) alongside `enterprise-ingest` (consumer, 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 write-routing is only meaningful once records actually carry a 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/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/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/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 @@ -431,7 +431,7 @@ SEARCH_TOKEN=$(docker compose run --rm enterprise-auth -mint-service-token=searc ``` ```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 @@ -484,7 +484,7 @@ comment already worries about). Changing a non-Owner role is just re-running `-grant-membership-*` with a different `-grant-membership-role` (`SetMembership`'s upsert already supports 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 exposes (`PUT`/`DELETE /dashboards/{id}/permissions/{userId}`, `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 shape, same as everywhere else this codebase has an "off unless configured" optional dependency. It only does anything in a real -cluster with `deploy/helm/sentry`'s `tenantOperator.enabled=true` -- see -`/deploy/helm/sentry/README.md`'s "Trying the two-tenant example." +cluster with `deploy/helm/cairnobs`'s `tenantOperator.enabled=true` -- see +`/deploy/helm/cairnobs/README.md`'s "Trying the two-tenant example." `-provision-tenant` creates the tenant/data_source rows in rbacstore if 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` | | `POSTGRES_ADDR` | `localhost:5432` | -| `POSTGRES_DATABASE` | `sentry_metadata` | -| `POSTGRES_USERNAME` | `sentry` | +| `POSTGRES_DATABASE` | `cairnobs_metadata` | +| `POSTGRES_USERNAME` | `cairnobs` | | `POSTGRES_PASSWORD` | (empty) | | `OIDC_ISSUER_URL` | (empty — OIDC discovery skipped if unset) | | `OIDC_CLIENT_ID` | (empty) | @@ -554,8 +554,8 @@ to, see `tenantprovision.ProvisionClickHouse`'s doc comment). | `CLICKHOUSE_ADMIN_PASSWORD` | (empty) | | `SEARCH_GRPC_ADDR` | `localhost:50052` | | `POSTGRES_ADDR` | `localhost:5432` | -| `POSTGRES_DATABASE` | `sentry_metadata` | -| `POSTGRES_USERNAME` | `sentry` | +| `POSTGRES_DATABASE` | `cairnobs_metadata` | +| `POSTGRES_USERNAME` | `cairnobs` | | `POSTGRES_PASSWORD` | (empty) | | `AUDIT_WRITER_USERNAME` | `audit_writer` | | `AUDIT_WRITER_PASSWORD` | (empty) | diff --git a/enterprise/cmd/enterprise-api/Dockerfile b/enterprise/cmd/enterprise-api/Dockerfile index c4f58dc..de3ae0a 100644 --- a/enterprise/cmd/enterprise-api/Dockerfile +++ b/enterprise/cmd/enterprise-api/Dockerfile @@ -1,7 +1,7 @@ # Same shape as every other Go service's Dockerfile in this repo -- # context must be the repo root (needs both enterprise/ and proto/, like # 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 WORKDIR /src COPY . . diff --git a/enterprise/cmd/enterprise-api/main.go b/enterprise/cmd/enterprise-api/main.go index c1e3a75..4aae840 100644 --- a/enterprise/cmd/enterprise-api/main.go +++ b/enterprise/cmd/enterprise-api/main.go @@ -17,7 +17,7 @@ // keeps running plain api/cmd/api, unchanged; a real multi-tenant // deployment runs this one instead. // -// Both Helm (deploy/helm/sentry/templates/api.yaml vs +// Both Helm (deploy/helm/cairnobs/templates/api.yaml vs // enterprise-api.yaml) and docker-compose.yml (COMPOSE_PROFILES) now // make this the deployment-topology choice, not just a binary sitting // unused alongside api's -- see this repo's CLAUDE.md. `search`'s write diff --git a/enterprise/cmd/enterprise-ingest/Dockerfile b/enterprise/cmd/enterprise-ingest/Dockerfile index 352dba3..423d21b 100644 --- a/enterprise/cmd/enterprise-ingest/Dockerfile +++ b/enterprise/cmd/enterprise-ingest/Dockerfile @@ -2,7 +2,7 @@ # context must be the repo root (needs ingest/, proto/, and enterprise/, # like enterprise-api/Dockerfile does for api/ + proto/ + enterprise/), # 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 WORKDIR /src COPY . . diff --git a/enterprise/cmd/enterprise-ingest/main.go b/enterprise/cmd/enterprise-ingest/main.go index 57c42b1..0bb5688 100644 --- a/enterprise/cmd/enterprise-ingest/main.go +++ b/enterprise/cmd/enterprise-ingest/main.go @@ -1,6 +1,6 @@ // Command enterprise-ingest is the multi-tenant-aware alternative to // 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 // comment), but writes each record into its own tenant's dedicated // ClickHouse database (enterprise/internal/chwriter) instead of the one @@ -21,7 +21,7 @@ // alongside) `ingest -mode=consumer` -- `ingest -mode=server` (the // agent-facing half, which tags records with a tenant_id via // 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. package main diff --git a/enterprise/internal/apiconfig/apiconfig.go b/enterprise/internal/apiconfig/apiconfig.go index 38c498f..78abbc1 100644 --- a/enterprise/internal/apiconfig/apiconfig.go +++ b/enterprise/internal/apiconfig/apiconfig.go @@ -125,8 +125,8 @@ func Load() (Config, error) { }, Postgres: PostgresConfig{ Addr: getenv("POSTGRES_ADDR", "localhost:5432"), - Database: getenv("POSTGRES_DATABASE", "sentry_metadata"), - Username: getenv("POSTGRES_USERNAME", "sentry"), + Database: getenv("POSTGRES_DATABASE", "cairnobs_metadata"), + Username: getenv("POSTGRES_USERNAME", "cairnobs"), Password: getenv("POSTGRES_PASSWORD", ""), }, AI: AIConfig{ diff --git a/enterprise/internal/audit/audit.go b/enterprise/internal/audit/audit.go index 7e74629..fba3e46 100644 --- a/enterprise/internal/audit/audit.go +++ b/enterprise/internal/audit/audit.go @@ -3,11 +3,11 @@ // section. Two independent defenses back the "no update/delete path // from the application layer" requirement -- both verified against a // 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 // BEFORE UPDATE OR DELETE trigger (metadata/migrations/0015-0016) // 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 // application access. // diff --git a/enterprise/internal/audit/integration_test.go b/enterprise/internal/audit/integration_test.go index 7982d59..7d30315 100644 --- a/enterprise/internal/audit/integration_test.go +++ b/enterprise/internal/audit/integration_test.go @@ -34,7 +34,7 @@ func testPool(t *testing.T, user, password string) *pgxpool.Pool { if addr == "" { 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) if err != nil { t.Fatalf("opening pool: %v", err) @@ -65,7 +65,7 @@ func cleanupAuditLog(t *testing.T, adminPool *pgxpool.Pool) { func TestAppendAndVerifyChainRealPostgres(t *testing.T) { 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) defer cleanupAuditLog(t, adminPool) @@ -104,7 +104,7 @@ func TestAppendAndVerifyChainRealPostgres(t *testing.T) { // field on QueryAuditEntry -- matching that type's own doc comment. func TestQueryAPILoggerWritesAttributedToContextIdentity(t *testing.T) { 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) defer cleanupAuditLog(t, adminPool) @@ -131,7 +131,7 @@ func TestQueryAPILoggerWritesAttributedToContextIdentity(t *testing.T) { func TestQueryAPILoggerRefusesWithoutIdentity(t *testing.T) { 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) defer cleanupAuditLog(t, adminPool) @@ -149,7 +149,7 @@ func TestQueryAPILoggerRefusesWithoutIdentity(t *testing.T) { // confidence/accepted/edited fields that don't have dedicated columns. func TestAIInteractionLoggerWritesAttributedToContextIdentity(t *testing.T) { 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) defer cleanupAuditLog(t, adminPool) @@ -199,7 +199,7 @@ func TestAIInteractionLoggerWritesAttributedToContextIdentity(t *testing.T) { func TestAIInteractionLoggerRefusesWithoutIdentity(t *testing.T) { 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) defer cleanupAuditLog(t, adminPool) @@ -216,7 +216,7 @@ func TestAIInteractionLoggerRefusesWithoutIdentity(t *testing.T) { // would also pass. func TestVerifyChainDetectsTampering(t *testing.T) { 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) defer cleanupAuditLog(t, adminPool) @@ -270,7 +270,7 @@ func TestVerifyChainDetectsTampering(t *testing.T) { // unit test of the Go code alone. func TestAppendConcurrentWritesProduceAValidChain(t *testing.T) { 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) defer cleanupAuditLog(t, adminPool) @@ -316,7 +316,7 @@ func TestAppendConcurrentWritesProduceAValidChain(t *testing.T) { // the previous checkpoint's hash. func TestCheckpointerRun(t *testing.T) { 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) defer cleanupAuditLog(t, adminPool) diff --git a/enterprise/internal/chwriter/chwriter.go b/enterprise/internal/chwriter/chwriter.go index 6678506..64cf706 100644 --- a/enterprise/internal/chwriter/chwriter.go +++ b/enterprise/internal/chwriter/chwriter.go @@ -16,7 +16,7 @@ // chrunner.RunSQL resolves exactly one tenant per call from ctx (a // single request always belongs to one identity); WriteBatch resolves // 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 // there's one shared topic, not topic-per-tenant. package chwriter diff --git a/enterprise/internal/config/config.go b/enterprise/internal/config/config.go index afe4ae1..b0a3cff 100644 --- a/enterprise/internal/config/config.go +++ b/enterprise/internal/config/config.go @@ -101,8 +101,8 @@ func Load() (Config, error) { HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8082"), Postgres: PostgresConfig{ Addr: getenv("POSTGRES_ADDR", "localhost:5432"), - Database: getenv("POSTGRES_DATABASE", "sentry_metadata"), - Username: getenv("POSTGRES_USERNAME", "sentry"), + Database: getenv("POSTGRES_DATABASE", "cairnobs_metadata"), + Username: getenv("POSTGRES_USERNAME", "cairnobs"), Password: getenv("POSTGRES_PASSWORD", ""), }, OIDC: OIDCConfig{ diff --git a/enterprise/internal/ingestconfig/ingestconfig.go b/enterprise/internal/ingestconfig/ingestconfig.go index 8bea8da..2c9a985 100644 --- a/enterprise/internal/ingestconfig/ingestconfig.go +++ b/enterprise/internal/ingestconfig/ingestconfig.go @@ -74,26 +74,26 @@ func Load() (Config, error) { ClickHouseAddr: getenv("CLICKHOUSE_ADDR", "localhost:9000"), Postgres: PostgresConfig{ Addr: getenv("POSTGRES_ADDR", "localhost:5432"), - Database: getenv("POSTGRES_DATABASE", "sentry_metadata"), - Username: getenv("POSTGRES_USERNAME", "sentry"), + Database: getenv("POSTGRES_DATABASE", "cairnobs_metadata"), + Username: getenv("POSTGRES_USERNAME", "cairnobs"), Password: getenv("POSTGRES_PASSWORD", ""), }, Redpanda: RedpandaConfig{ Brokers: strings.Split(getenv("REDPANDA_BROKERS", "localhost:9092"), ","), // 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) // produces onto; there's no per-tenant topic, see // 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 - // default ("sentry-ingest") -- this binary and a + // default ("cairnobs-ingest") -- this binary and a // single-tenant `ingest -mode=consumer` must never share a // group (each message would only ever reach one of them, // silently splitting traffic) even though in practice a // real multi-tenant deployment runs this binary *instead // of*, not alongside, `ingest -mode=consumer`. - ConsumerGroup: getenv("REDPANDA_CONSUMER_GROUP", "sentry-enterprise-ingest"), + ConsumerGroup: getenv("REDPANDA_CONSUMER_GROUP", "cairnobs-enterprise-ingest"), }, } diff --git a/enterprise/internal/rbacstore/rbacstore.go b/enterprise/internal/rbacstore/rbacstore.go index cac0747..01a5c0e 100644 --- a/enterprise/internal/rbacstore/rbacstore.go +++ b/enterprise/internal/rbacstore/rbacstore.go @@ -2,7 +2,7 @@ // role schema (metadata/migrations/0017-0021) described in // /docs/phase-4-rbac-design.md: users (global SSO identity), tenants, // 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 // pool) -- ordinary read/write CRUD on control-plane config, not an // append-only ledger, so it has no analogous reason to restrict its own diff --git a/enterprise/internal/rbacstore/rbacstore_test.go b/enterprise/internal/rbacstore/rbacstore_test.go index 84a49d9..00061a5 100644 --- a/enterprise/internal/rbacstore/rbacstore_test.go +++ b/enterprise/internal/rbacstore/rbacstore_test.go @@ -29,7 +29,7 @@ func testStore(t *testing.T) *Store { t.Skip("RBACSTORE_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test") } 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) if err != nil { t.Fatalf("opening pool: %v", err) diff --git a/enterprise/internal/saml/saml_test.go b/enterprise/internal/saml/saml_test.go index cd22304..caf7738 100644 --- a/enterprise/internal/saml/saml_test.go +++ b/enterprise/internal/saml/saml_test.go @@ -27,7 +27,7 @@ func TestNewRejectsMissingConfig(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 { 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." func TestLoginURLBuildsAgainstRealIDPMetadata(t *testing.T) { sp, err := New(Config{ - EntityID: "https://sentry.example.com/saml/metadata", - ACSURL: "https://sentry.example.com/saml/acs", + EntityID: "https://cairnobs.example.com/saml/metadata", + ACSURL: "https://cairnobs.example.com/saml/acs", IDPMetadata: fakeIDPMetadata(), }) if err != nil { diff --git a/hack/alert-load-test/README.md b/hack/alert-load-test/README.md index a709914..f6c798d 100644 --- a/hack/alert-load-test/README.md +++ b/hack/alert-load-test/README.md @@ -14,7 +14,7 @@ go run . --count 500000 # 2. Run a webhook-sink so the (never-firing, by design) rules have a # 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 . # 3. Run the load test: diff --git a/hack/alert-load-test/main.go b/hack/alert-load-test/main.go index 5b847c0..863fcdd 100644 --- a/hack/alert-load-test/main.go +++ b/hack/alert-load-test/main.go @@ -43,7 +43,7 @@ var ( 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") 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() { diff --git a/ingest/Dockerfile b/ingest/Dockerfile index 9612b39..2d76bef 100644 --- a/ingest/Dockerfile +++ b/ingest/Dockerfile @@ -1,6 +1,6 @@ # Build context must be the repo root (sentry/), not ingest/, since this # 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 WORKDIR /src diff --git a/ingest/README.md b/ingest/README.md index 914c09e..edaf1e4 100644 --- a/ingest/README.md +++ b/ingest/README.md @@ -86,13 +86,13 @@ full list and defaults) — no config file format for Phase 0: | Var | Default | Purpose | |---|---|---| | `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_CLIENT_CA_FILE` | `/etc/sentry-ingest/ca.pem` | CA used to verify agent client certs | +| `TLS_CERT_FILE` / `TLS_KEY_FILE` | `/etc/cairnobs-ingest/server{,-key}.pem` | ingest's own mTLS identity | +| `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_TOPIC` | `sentry.logs.raw` | Must match the topic provisioned in `/transport` | -| `REDPANDA_CONSUMER_GROUP` | `sentry-ingest` | Consumer group id | +| `REDPANDA_TOPIC` | `cairnobs.logs.raw` | Must match the topic provisioned in `/transport` | +| `REDPANDA_CONSUMER_GROUP` | `cairnobs-ingest` | Consumer group id | | `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_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 | @@ -108,12 +108,12 @@ go test ./... Requires `google.golang.org/protobuf/cmd/protoc-gen-go` and `google.golang.org/grpc/cmd/protoc-gen-go-grpc` only if you're 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`). ```sh # from the repo root, not ingest/ -docker build -f ingest/Dockerfile -t sentry-ingest . +docker build -f ingest/Dockerfile -t cairnobs-ingest . ``` ## Testing notes diff --git a/ingest/internal/agentregistry/agentregistry.go b/ingest/internal/agentregistry/agentregistry.go index cba25da..392f4cc 100644 --- a/ingest/internal/agentregistry/agentregistry.go +++ b/ingest/internal/agentregistry/agentregistry.go @@ -1,7 +1,7 @@ // Package agentregistry is the Postgres-backed implementation of // grpcserver.AgentRegistry -- ingest's half of agent inventory/remote // 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- // different-services shape alerting and api already use for dashboards/ // alert_rules. diff --git a/ingest/internal/config/config.go b/ingest/internal/config/config.go index b52ee27..bd205d7 100644 --- a/ingest/internal/config/config.go +++ b/ingest/internal/config/config.go @@ -26,8 +26,8 @@ type Config struct { // AgentRegistry enables agent inventory/remote config // (internal/agentregistry, internal/grpcserver.AgentRegistry) when // Postgres.Addr is set -- same "off unless configured" shape as - // EnterpriseAuthURL above. Writes into the same sentry_metadata - // database api/web already use, via the same shared "sentry" role + // EnterpriseAuthURL above. Writes into the same cairnobs_metadata + // database api/web already use, via the same shared "cairnobs" role // every other non-audit table in this schema uses (unlike // audit_log's dedicated restricted role -- agent inventory carries // no tamper-evidence requirement). @@ -110,12 +110,12 @@ func Load() (Config, error) { }, Redpanda: RedpandaConfig{ Brokers: strings.Split(getenv("REDPANDA_BROKERS", "localhost:9092"), ","), - Topic: getenv("REDPANDA_TOPIC", "sentry.logs.raw"), - ConsumerGroup: getenv("REDPANDA_CONSUMER_GROUP", "sentry-ingest"), + Topic: getenv("REDPANDA_TOPIC", "cairnobs.logs.raw"), + ConsumerGroup: getenv("REDPANDA_CONSUMER_GROUP", "cairnobs-ingest"), }, ClickHouse: ClickHouseConfig{ Addr: getenv("CLICKHOUSE_ADDR", "localhost:9000"), - Database: getenv("CLICKHOUSE_DATABASE", "sentry"), + Database: getenv("CLICKHOUSE_DATABASE", "cairnobs"), Username: getenv("CLICKHOUSE_USERNAME", "default"), Password: getenv("CLICKHOUSE_PASSWORD", ""), }, @@ -123,8 +123,8 @@ func Load() (Config, error) { AgentRegistry: AgentRegistryConfig{ Postgres: PostgresConfig{ Addr: getenv("AGENT_REGISTRY_POSTGRES_ADDR", ""), - Database: getenv("AGENT_REGISTRY_POSTGRES_DATABASE", "sentry_metadata"), - Username: getenv("AGENT_REGISTRY_POSTGRES_USERNAME", "sentry"), + Database: getenv("AGENT_REGISTRY_POSTGRES_DATABASE", "cairnobs_metadata"), + Username: getenv("AGENT_REGISTRY_POSTGRES_USERNAME", "cairnobs"), Password: getenv("AGENT_REGISTRY_POSTGRES_PASSWORD", ""), }, }, diff --git a/ingest/internal/config/config_test.go b/ingest/internal/config/config_test.go index 63a6f2c..d54f8f6 100644 --- a/ingest/internal/config/config_test.go +++ b/ingest/internal/config/config_test.go @@ -10,8 +10,8 @@ func TestLoadDefaults(t *testing.T) { if cfg.GRPC.ListenAddr != ":4317" { t.Errorf("GRPC.ListenAddr = %q, want :4317", cfg.GRPC.ListenAddr) } - if cfg.Redpanda.Topic != "sentry.logs.raw" { - t.Errorf("Redpanda.Topic = %q, want sentry.logs.raw", cfg.Redpanda.Topic) + if cfg.Redpanda.Topic != "cairnobs.logs.raw" { + t.Errorf("Redpanda.Topic = %q, want cairnobs.logs.raw", cfg.Redpanda.Topic) } if cfg.Batch.MaxSize != 500 { t.Errorf("Batch.MaxSize = %d, want 500", cfg.Batch.MaxSize) diff --git a/metadata/Dockerfile b/metadata/Dockerfile index d6be65a..461e208 100644 --- a/metadata/Dockerfile +++ b/metadata/Dockerfile @@ -1,6 +1,6 @@ # One-shot migration runner: bash + psql client baked in, migrations/*.sql # 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 RUN apk add --no-cache bash postgresql16-client WORKDIR /metadata diff --git a/metadata/README.md b/metadata/README.md index 2a727cd..6ddff1c 100644 --- a/metadata/README.md +++ b/metadata/README.md @@ -11,14 +11,14 @@ doesn't provide. ## 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`) - `notification_targets`, `alert_rules`, `alert_state`, `delivery_log` — owned by `/alerting` - `audit_log` — owned by `enterprise/internal/audit` (Phase 4). Unlike 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 — 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 `UPDATE`/`DELETE`/`TRUNCATE`, ever. `enterprise/internal/audit.Store` 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 application-level credential the same `UPDATE`/`DELETE` grants every other metadata table has, silently defeating the whole point. 2. A `BEFORE UPDATE OR DELETE` trigger (`migrations/0015`-`0016`) that 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 privileged, distinct-from-normal-access operation) before it can 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_PORT` | `5432` | -| `POSTGRES_USER` | `sentry` | +| `POSTGRES_USER` | `cairnobs` | | `POSTGRES_PASSWORD` | (empty — must be set) | -| `POSTGRES_DATABASE` | `sentry_metadata` | +| `POSTGRES_DATABASE` | `cairnobs_metadata` | | `AUDIT_WRITER_PASSWORD` | `audit-writer-dev-only` | The database itself isn't created by `migrate.sh` — the `postgres:16-alpine` diff --git a/metadata/docker-compose.yml b/metadata/docker-compose.yml index 52eae6a..15c27e6 100644 --- a/metadata/docker-compose.yml +++ b/metadata/docker-compose.yml @@ -9,8 +9,8 @@ services: ports: - "5432:5432" environment: - POSTGRES_DB: sentry_metadata - POSTGRES_USER: sentry + POSTGRES_DB: cairnobs_metadata + POSTGRES_USER: cairnobs POSTGRES_PASSWORD: "cairnobs-dev-only" # not a real secret, see root docker-compose.yml volumes: - metadata-postgres-data:/var/lib/postgresql/data diff --git a/metadata/migrate.sh b/metadata/migrate.sh index 93b8d09..6a40529 100644 --- a/metadata/migrate.sh +++ b/metadata/migrate.sh @@ -8,9 +8,9 @@ set -euo pipefail POSTGRES_HOST="${POSTGRES_HOST:-localhost}" POSTGRES_PORT="${POSTGRES_PORT:-5432}" -POSTGRES_USER="${POSTGRES_USER:-sentry}" +POSTGRES_USER="${POSTGRES_USER:-cairnobs}" 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 # task 4, see /docs/phase-4-isolation-design.md's audit logging # section) -- a second, narrower-granted role, not the shared diff --git a/search/README.md b/search/README.md index 59d949d..cb59f5e 100644 --- a/search/README.md +++ b/search/README.md @@ -18,7 +18,7 @@ planning. ## 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 ``` @@ -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 | | `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 | | `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) | diff --git a/search/src/config.rs b/search/src/config.rs index f6f9487..70af6e4 100644 --- a/search/src/config.rs +++ b/search/src/config.rs @@ -67,7 +67,7 @@ impl Config { .split(',') .map(str::to_string) .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")), offsets_path: PathBuf::from(getenv( "OFFSETS_PATH", diff --git a/search/src/consumer.rs b/search/src/consumer.rs index 29fe122..56c6814 100644 --- a/search/src/consumer.rs +++ b/search/src/consumer.rs @@ -23,7 +23,7 @@ use crate::tenants::ActiveTenantTracker; /// `TestTenantIDHeaderKeyConstantsMatch` does on the Go side. 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 /// offset tracking, own failure domain) even though rskafka doesn't speak /// Kafka's broker-side consumer-group protocol -- see offsets.rs. One diff --git a/storage/Dockerfile b/storage/Dockerfile index 1dbfde2..71cf257 100644 --- a/storage/Dockerfile +++ b/storage/Dockerfile @@ -1,7 +1,7 @@ # One-shot migration runner: bash + curl baked in, migrations/*.sql copied # in at build time. No runtime package install and no host volume mount # 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 RUN apk add --no-cache bash curl WORKDIR /storage diff --git a/storage/README.md b/storage/README.md index 9bea3bc..4b8030a 100644 --- a/storage/README.md +++ b/storage/README.md @@ -100,7 +100,7 @@ defaults so the two stay in sync out of the box): | `CLICKHOUSE_HTTP` | `http://localhost:8123` | | `CLICKHOUSE_USER` | `default` | | `CLICKHOUSE_PASSWORD` | (empty — override, see above) | -| `CLICKHOUSE_DATABASE` | `sentry` | +| `CLICKHOUSE_DATABASE` | `cairnobs` | 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 diff --git a/storage/migrate.sh b/storage/migrate.sh index 155071f..cf601e3 100755 --- a/storage/migrate.sh +++ b/storage/migrate.sh @@ -13,7 +13,7 @@ set -euo pipefail CLICKHOUSE_HTTP="${CLICKHOUSE_HTTP:-http://localhost:8123}" CLICKHOUSE_USER="${CLICKHOUSE_USER:-default}" CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-}" -DATABASE="${CLICKHOUSE_DATABASE:-sentry}" +DATABASE="${CLICKHOUSE_DATABASE:-cairnobs}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" MIGRATIONS_DIR="${SCRIPT_DIR}/migrations" diff --git a/transport/README.md b/transport/README.md index 830fb15..36d192a 100644 --- a/transport/README.md +++ b/transport/README.md @@ -5,7 +5,7 @@ Redpanda for local development, plus the script that provisions the topic ## 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 name. These aren't wired together automatically — if you change one, change the other, or override `REDPANDA_TOPIC` consistently wherever diff --git a/transport/provision-topics.sh b/transport/provision-topics.sh index a7910c8..18f816e 100755 --- a/transport/provision-topics.sh +++ b/transport/provision-topics.sh @@ -17,7 +17,7 @@ set -euo pipefail # of ever becoming healthy. BROKERS="${REDPANDA_BROKERS:-localhost:9092}" ADMIN_HOSTS="${REDPANDA_ADMIN_HOSTS:-localhost:9644}" -TOPIC="${REDPANDA_TOPIC:-sentry.logs.raw}" +TOPIC="${REDPANDA_TOPIC:-cairnobs.logs.raw}" PARTITIONS="${REDPANDA_TOPIC_PARTITIONS:-6}" echo "Waiting for Redpanda admin API at ${ADMIN_HOSTS}..." diff --git a/web/src/lib/assets/favicon.svg b/web/src/lib/assets/favicon.svg index 2f6a1e8..4462b29 100644 --- a/web/src/lib/assets/favicon.svg +++ b/web/src/lib/assets/favicon.svg @@ -2,8 +2,8 @@ - - - + + + diff --git a/web/src/lib/assets/logo-horizontal-dark.svg b/web/src/lib/assets/logo-horizontal-dark.svg index f822879..0fa3526 100644 --- a/web/src/lib/assets/logo-horizontal-dark.svg +++ b/web/src/lib/assets/logo-horizontal-dark.svg @@ -1,10 +1,10 @@ - - - - + + + + cairn obs diff --git a/web/src/lib/assets/logo-horizontal-light.svg b/web/src/lib/assets/logo-horizontal-light.svg new file mode 100644 index 0000000..8dcd73a --- /dev/null +++ b/web/src/lib/assets/logo-horizontal-light.svg @@ -0,0 +1,12 @@ + + + + + + + + + cairn + obs + + diff --git a/web/src/lib/assets/logo-stacked-dark.svg b/web/src/lib/assets/logo-stacked-dark.svg index 3ba5c74..5a14136 100644 --- a/web/src/lib/assets/logo-stacked-dark.svg +++ b/web/src/lib/assets/logo-stacked-dark.svg @@ -1,10 +1,10 @@ - - - - + + + + cairn obs diff --git a/web/src/lib/assets/logo-stacked-light.svg b/web/src/lib/assets/logo-stacked-light.svg new file mode 100644 index 0000000..b187a8f --- /dev/null +++ b/web/src/lib/assets/logo-stacked-light.svg @@ -0,0 +1,10 @@ + + + + + + + + + cairn obs + diff --git a/web/src/lib/components/NavSidebar.svelte b/web/src/lib/components/NavSidebar.svelte index a92a549..446fc16 100644 --- a/web/src/lib/components/NavSidebar.svelte +++ b/web/src/lib/components/NavSidebar.svelte @@ -9,9 +9,10 @@ type CurrentSession, type LocalSession } 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 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 { onOpenPalette, @@ -84,7 +85,7 @@
- +
diff --git a/web/src/lib/theme.svelte.ts b/web/src/lib/theme.svelte.ts index 900eef0..0504b4b 100644 --- a/web/src/lib/theme.svelte.ts +++ b/web/src/lib/theme.svelte.ts @@ -34,10 +34,33 @@ const initial = readStored(); let theme = $state(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 { 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) { theme = t; apply(t); diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 5353a38..3829e79 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -3,7 +3,9 @@ // /search (the old root; every prior link/shortcut/drill-down that // used to point at "/" expecting the query page now points at // "/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'; const shortcuts: { href: string; label: string; hint: string }[] = [ @@ -15,7 +17,7 @@
- +

One query bar for filter/stats queries and free-text search across every host and service you're shipping logs from. diff --git a/web/static/icons/favicon-16.png b/web/static/icons/favicon-16.png index a6c81ae2598faeb7c7b76ab301037092be0140bc..b2c751fa5ac20d442ec6e6508b1a769ebd53171e 100644 GIT binary patch delta 491 zcmVs-^{(_&X(RM3 z@43%=pZg^M_U_ySnkj^1fJy&qqWL5ucWRZT7ZSkmoSl@`mw${zD*Xh=hkHp=F`6Gh z1nO>Qn*=%N8@OTZb4Lexdh;+=T+#6~&)zO`9<#Uz)-A`J&0~ z={omc*LwnD;eXX{ok<1ox`{A;B(c$Jw7ke3By4cw^`_dle>#L;A z=v{3tr!5?Mkm2Xw^$^u<6f$j6@&2%{|M+8a1MV8Q-DI~gS#VLJnOlvqP&FuK+l=K~ zoXEFWuB!l^U+Xz{SL{8x_h?Fem)=&18N;&dMc~R#coDf~xQ##aR?_}}W&|PYzuy0F h{&z0>aILa5{SUJ1mb>jL9rpkL002ovPDHLkV1haa^b`O9 delta 449 zcmV;y0Y3hh1nmQmHh-c?L_t(Ijg6AKYZOrw#((F|Yj?7fIIb`fP$3VjWI>WfNkXs* zm{tTkOG&MbAmrbOtrn6>gzdzTE(;q-u+tPavMKh#vdQeca_!<~XWaO;_kO?cJBK3x zo*!(Yd52H~Y$hv3I1*v2-#$GM&kr_%FV~og6pNA-!*MGgfq&V5{cZ|Al~|si#x@0jxxgiuu?h={=Lflh zjtMUw2h87|qCH4C{x#t1_fFv)IlzNillm*4nX&>PPGJyfj(#V+{LrIwb~R6p6IT}O zOEZ9N!p$jReSgvB`NMLqK@RY#Ipo`qm|mz!vcGA)P_zFto;-r*Xf_riDwgI(=>id` zx?o#E*~mDHVCT@~q+?tH%u(9zvo4SBB)ODPJ(-JH+NlJm*L0vv#? z`ZRA>2UrS*i4bNmOocEJqD<(9!uzH>>W{?pgFRsF+B)bWX&OqDyaDp-{SS%Aw(7TA r4N8_X diff --git a/web/static/icons/favicon-180.png b/web/static/icons/favicon-180.png index f09003ec75e6e41ee3178db19ebe452981ee2b8a..36b590b9c6ec61bb02735d4fd1aee5c3f530cb57 100644 GIT binary patch delta 4713 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 delta 5155 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 RY)$|G002ovPDHLkV1gnN9Yp{D diff --git a/web/static/icons/favicon-32.png b/web/static/icons/favicon-32.png index dad619de839c8e7709f7f3af1851e88f1ee1eadc..1b9499eb155eff9475354abf76095a8a182fb588 100644 GIT binary patch delta 928 zcmV;R17G}*2gnDIHh+CdL_t(og~gZ4PZL2L$G_60>B;yYygmg`C*8Gw0}^-9wk&0}D%t5mK%U^xPX0FzWpFL_rG-V}u;!A_xyVk??I1Rz8^^(}-J;Q|#ve zlwk3(QjtVO29%l^B?%O3vaHB+*^uk9!9S?tX=6VkYQnUF1VgC^AkXN4wOKMApY`bN zM7VP<;`Gf{_`#(3CpVsG&2} zg0(CDUHN3TR@R-Lu;82s~-%Hzj}=-2zUAT|{v~I%->kDBGTc ziXE#E2*=Roi{QI2f=ksMrgFfv07`(3P7zzyN$3wJ@PDjXg%9aJM0fTlG=Bf4E52ZL{nY*J~pBZ3eRYA5xfZIuR0UVxWJlS~CWv2=7cE|9!OG8sv9NumX zUw!eUB25Xfi442k!>~yVHJui8280=Hm&*y1rI|aCEwdC!VB2ong<+5KjP4 zzgcm$KE-?$9QQW1qqt4Jp$2G`~Uusy@o7YP9L! zXMck@(p|y1{v#GBke?aT#rVS%&)Tf|>J1Pisp+l;26(UpFpvfcf@&-gl;Gh~UQ7ar z32w?Siu|soM`ZC}jIr`isH;y;Q8l7`u`F|V39yTw8w>0_z!*EM4Epc$&RC7;B1A_e zzb-K_2La(*GWG2IKNJSmL7fUUUX^Djk>m+b^o6Q00#gaXzG(5VMDn(PMRSFc0>-hCVKU)BH=lSOG13Ni!E&Dixn|(Ds^Vr#;RT?SV$q3M_LE$On}1cvk~CBiK6s|egL`)TCl)0d zFMzh6;0BuW&4|-yeI{nYOaPfWaQewXj@?_zR1nr?;i>oAeDz0g&D=~wczCp!*&M)7 z!mAHknQ}7$%sX)|0DxNujQ#<<@wG>sWS&(3zHM|e^dL zpSyNOm;!b&!B8+z)F^>!srMbAHN5-tO}sK;6Q`u60vs9%Idg0YGute6l?c3W;4-a_ z;_V;1KQB}JbLx<{CG(9szRypMr2_07iu&UYD1S*Gvs8eoi)P;dzG$T1qNxBce`n(a zTYn^f{#S|DC)005v1|2ZV6a>8_Q9B!?ptB^P|W2}2ou2zg(v|x5MnK~J3^wN-BBbO zoIr@Q-~~dXS>U4Aa}6D_YxVDehqiLc>y<{P6vBu9hdF=}z7(Kf*Xq9lcW<+Qo5*vhU5OeeGM@IFe}1_d_!m|AAU9oCUlsrW002ovPDHLkV1lf?uqgll diff --git a/web/static/icons/favicon-48.png b/web/static/icons/favicon-48.png index d5f5d8ceb9c6b9242a75bab7d2ba67d3bb983b09..37cf2ff8ec2ec9b54518f9d43e388f7277086cd7 100644 GIT binary patch delta 1455 zcmV;g1yK6%3eyXaHh+joL_t(&fz_H@Y!p=-#=moBcDB3QrB}LzvRJuDD@qHk0)~(n zk)S9Sfd>^6>J zQHcm+!0@nX&81ru75%=h&9iquQ?CH^8ugS*ZU&4;tIj(3L+F$b#I60AwZ-A_sOH-11yoeM+XsJGy|Xs@;!?Bj!E+)qgPOz`N?5?l zD@@QE#(-@#ehfs?AYglqACcq?BQ77;hj6BCXpY=*0gW9pb{-p>MSUT4#AOnty6$*ZHntOqh0BeOTon(GCuk(gu!U? zsVM0@Ac=si@58Vm_Lk{6~y4|7Z6sGd%mBM zts<-do)AsX5aM}YX^D)DWnpY652H9+MSn~MT3iPF)Gp!Ys}dTn8xV@c7gSyt!EuGU0lOU*P0;O_aNv~{#((fi zReFQxR20i7Fi&jF6AE~LbpVFDu-tpqBjBqRQ)&X9oFBo;dy>nLsj{cuqM1WV0?xEc zxYV0eu1ibrPbgp@O0e}v4gwLK)*dGy5+kVnD+@0i%bis3PuhjJUw^sDJMsdh7J002ov JPDHLkV1lZus!{*| delta 1355 zcmV-R1+@Cp3-Ah%Hh(2aL_t(&fz6p)Y!p=($A90M*`3|)ZZFUaSP21JUIeMPR3S9M z@PNT6iX|A0(L{;y#YE!+4<;C6V#F7`B|eA`27?I@d_j~DFCd@@#)e=(ps`v>OKH2e z?(Aje_|TRB>1B3yVEsL9&V1*b|NqRH?_5@xhFE=5hZ5C|W`7OB0-zZ%ri%}ll7@dp zIIp$rm6EY5o$kLfRiNBhkZ5dNf@8c3Ja*G%dMAtMt?A*x?LhI`2iF;>ZJ4)CqrU)Z zZ>2i#$Pwj+A5A9vMy{4j{+JN|6}N7h_~o>6t*%KE3dX0+#K`~O#RDvLmA#V*?FN8l zB|Zg~S5sAaNPl}cniwqDnG;e|-*mSj>QuEQm5nfHDW^>tqMod_7GG$Z^RCftPUQKU zh7}TGmO*Vzh^*~!IaLU*am@x3SQtcGVN-Z@-9pxO%wrGALWW}hmgRJ|)p?4)IFMu2)_;?vbEQhU1t=*3NS)8Gp6^*a zz~YuDFRf{0yN(T@wfFd< zAWNas>E-N=s9~JIu4G{@JHm}JPG|8MMlhull||;`Szk7gtm+9)DoP;xOCR$9>oLWnlG^FcRPfaMQg} zmfhxWHv2LVHUi1MR}q0#ZDC(^eJT0zLZPgM%L@gc*~*uJp8g_Vp33>_aSB`whtB!i zTKJv|ukXuntT&j$$;XGY^bYx3+$vhg3?*#nuvp!0F|SsLTZ)(^BrKsWs)$=)s_DP> zTz^sX{`XnlIXLEfEeLcgF%=OL;t@sM66&Jk=At3NF2e8qCExjflCpD`5||%%sE=qu zrX~^Am;wn)V~D0Mq7k4z;-Um(cy3=E2hLlb;yoEK73|(P!b6J!yiu5e zTuJEKF^98PW}hIQve4%H#%`H-fT*e2vMTE-){}u}mIasdz2bGiJQ--48{7fJD~5PW zj-Ct*kKM$pyJ;5`;`*55?y?@?z zr;=!BDdJ&4Th!pN^NCTGHV1pi%@l-^D-7p^p1}~G{2Jw_KF?cgM`{~d`ZO%?5^XA2 z*Pf^I_5!i6CTS~N4M|&YHKg*2k~=P{=nC0_kau9LB;<=it|a7&<7L^RqF8xu=$Ax8 z^L~(rD{iVf99M|!skREgh{!k6*nilV$UDXbpsCt|%0#>94mC71l-9_b)mBqBTJOn@ z4E7iR1v_)niY7XNwkpf=%Ba%PozG`SCf5O19P?@5bTw6Z#1V0yN+t&`!Ziz7D)rB> za;+|qBh}M3E2ARrBdOub2PO-yTVC_|q+PHxpIbGtG{{Q8-(^kkXtds=v_y2LMh8z^ z`(WB#S|ZUpSDVfzrS*C(v;(bFa2Z@C4ln@piO4}mySuX^gFRCP{s(2(*7W-$ptk@3 N002ovPDHLkV1hv2ntT8N diff --git a/web/static/icons/favicon-512.png b/web/static/icons/favicon-512.png index de3994f6f5c3092bdb28380b1802ab7d16cc4f9d..0d389a1160c9bb350922058b1db831d54fd3ca13 100644 GIT binary patch literal 11522 zcmZvC2{_c>_y3(Sn6iy1yP2Y*vK1A=C?#ZxLiV+kNOohHv4kR`B$6zlB3t%7g_K?P zU1c3x_HE{Wr_bm6dw$>F^XGXu@AE$Q+hOO<6W?_K}^i`r!sT>y}f zL;^Ge{I}xyV+;Ocu+mUf0aV&=Qf0)ydu7ExO_gS*(%EFLdr9sd3pq|WUNevHeu?32s(&Wl*?(+A7nf}y;O(iP9TnOd( zv;MJw{N$_8disG8(jv3WUth>Xzc;6gzF}|UEu1ENz1pBqZHC!%8b}J=zP~;odAKnz z|EcaBm3-^ydYicySMCTOR`|)SpfD8WWn^63veZrg9ksLdutQ2R_u%ZC2$#Dvo{d!| z9U8W-C0X6{m8ZdCOxKg}(;5?d)FR)6FYCzuK6g@HuI$xFkP;dQPS4HFxm}r;=J76j z)0x$^RPMeENY}%;Gee%8T)!Ms-swJ_`0(MwJGT(xLZIYL#^gocn3eGyK6~?{)68lO zWXC=_5I=c&t2C;#Q>skpXA|+Bv$J#bnF2Qy>458Mj#9T_<*=c5gbd;Fn=Gbk4p8&$ z*vWq3%ms~>GwK{d9t*5{>@bL=cPaOBo?P4Q@16RI0NI2VUf(Xm$>$wkzM2+JBY!*q zIwA8iv)Xz4z28ZdeN_+XTJJ`J9h)?<`gmjA?m=mp!d=AhZWlYyZ|mvl`DoKWY?C#7 zJB@?&k0KJ({+4R=G1HjVj#Y2Ht`rfviveqTQ^wxu&j{sj!i0aXQAZFU>g3TlW8q1? zw1;Ce>Zlb|pWl2MI!v+vb%kpGe`y0A}y}tJ5htnLNxL#|shsOA(*WEslkrC|o_J!pA(!2Ni3U<@- zwtO8wF@eRQA)kFlGSPs?2 z`ntpBI;xTz;B{X(TCY4WipZwam9ltT{<;66j<7s1wLi@-)-Y^mqs>g4g35NBP^H&R zt10%2SJc_{rX;GG&$a||5&<4v(R5i!d?mYDmz^V7h$J*nzV(rKD@)PPfy!9P3&8s; zg-3WydlMJ>OU^2JmP_w@DCB>cAMvXvOIG-&jpJ|qNTs@ltfCYUQ?^^$w zNl(~;$y-%ojaNfov<-c|b#o~*T2^T)4S>;Mrkl;}?d{R$ah!;xV)$d#UoASlLfa9fTUqmGwALyl2d{`M0v(aN{JC*y z3%qep1I(BJNIXT*`Q3EynR@GE`toqny`SYa{Z$f~PXkRD0q{H7apY$~wd0*T--Dvv z1xSzi&bXft-FADV1aP;rTmzmOZA|nR+LuS~2doOx=JA<67f#+{_yeOZYN6nP`iwm< zf79W^z{bP(hxRS{#eM*Sf(zx!C1pI8vzZv{cFyAq!*;%!%&gBM0Y!IStVZsG7@hd~%K4v9UN5#RdfE==eHfRsy3>d+x%uR7vb= z(C}lmao$IDqk=U?6E#=gJ3~(_X=DRnSY2(R6RsgWMU-^jyoiv8&0b>!B#9=xW1bb)MEZi=^bI2~ zC(Gvg)L+*Gv(1*PVeWPQQB3IhFvZx}>VAk?0C0zD*3l=mE~(nmd1ZSzKEV?C$Ok z*}n8mC;Y5Ve-5EUg>-j&Iu{!YOLIv-LgcuMOdVYDHltI;NmR21wjMrkvOAvIw-XOF zL#HgCtrW+bB9h8;JRe4x{rK|@vSS&_w|b~W_J|rI)6PH9kTV%h-Zt3-9#j?H`ZmRB zpb!1H!q&|>`Q8wZxGT`T=W$yN&Y10z0d_}Kza6eHbj;VMbVD+k=m0Q_*N~kHxuWS1 zXZN0N^zE(~ZTyFhnyZPK74@@a@q(=O0Tc6m04yd%`Ksz6lDwO@W=2Pr60;yw@|^pv zpunen=Pc-bRQcoMVYp(gvhUI~_*g(aZ)JNqjgi9>r(wXmVSXBLZgnSqilG7!diuCP zcV)V#`VgY#nA^k2ObE3SKkj!?t3=drw^QIIXARHelJH=5!h@;lKAl(GgHr>vxo0$h zSCzA7er-TUs%3LW5UPrNCN+tb2HZk4xVGdxOd;mB#^JSF!?qHB)aJek&-o5tm>GSu zyKiA-Wp$^I6@R$WNt|Z&-o|GRYvf9*#cN=ZWj49}*Oa<9M|i8L%r!j25^(GrtqFN# zX8b+)Q&$ivKxXxam@oPmk65;sAFG9Sp2wAWSDKgS#kgNti)p1cn{`r(XwH4*-mOc4 z_AYWh4rYO~e+sL!{gk$zur^Da-D_u*n zB4JA|)%*E*B$J+Weq>AO895}-j1$9kRs3&jc3ZNy#?Q~hbsw-1V3UI;XyTs8lEZYO97 z&}nmN50f9K8=i1)5x*lh)deiiFL_|$3l`(A8Rb_Ds9V;L3ze7%GI&Fsok~@1fPAiw zbtv2JHn*^?W?wNPNhWP?)?1URvWoR48JOaZ*Rw8356+&m-MMz?ADU z;e&S0o}hhoO8f-*k8+!#j;8k~7);#>S3`2X+Y^#gK3Fp;OR$7;Wo~!$nN0FM-azA` z!^W{%L5P|QqRY9{^T#P+G2*L#+(@}88>(y#DEw@etF_$ASwe`ET6cWFJ+@a zRn^<*HMP>(egq+1^Zv|X(Vnl?B$3pOhSsZm@^@YVu=-KgSOyfxEOO)WMJP*dn{l)D z=3vG;tA7u;jCw@ekzxbVHEjqyQfKF>zZXdI({}|J7fz~;r6714*(U(FUtc_Nb?50r z_)dKK4ggjm^^RBaOyB_0>09vy&`V>J}P7G9$>~`6#+%qp-7RJr&bTqMWgLIzDbIWBzy?q zRutEo#R_rYuNoF8bH>1ZrMhaz2Ff5Mq0B*m+gDY2`^re4^nsO=Pe2nLP81D7nHi8{ zj1!0_oYQMQJ==k1D4od0xWl#`mu?AmedlA)X5q~5ZyU(IM{#}T!JBuH5k%GEaiAm+ zkS<%|Fn*ps16jk{Y37SJg}R|OeyA<2@4=(&oiDBSBmQTFV6fmu2N}GVr#2+M9Amje zhBHz#IfyYUSyJ-&QAHBQ0bD+mczJeRwF6r41~aNwEMZ&dAyg&}l`*vJMyWu%3DWu7 z(N9t?A47sek^Xq;Fc%`tSa~&g9B*BzQgj3$8Cu{lkH-hGYH*eBN@79@_vhK5##79w zaf`F-jPA*FBttml%&|eNCjvJ8jK}HMmMSeGk#K|f<4=UR5G~B%w%AAK-^({isqu$H z5Mw}Ih3a$9L76ar{57cZL?rCW8YkHYb<(y=mK5fNNy0W=CY;#{)vfw*3w4oJ@zGrVs~wKNsfAm@h08m`}H3P0zml7%}a# zTbQ@7Fjh!hvY11Ini({>{A^j|-fT1b&pMW-z4uMP~M_k(iO%!hpO%o4SA%zi19gh~eLr=9|j|{rwNxrVS zIu$A5qc$h6Utg-!2Ca&*az(kDezsixT9GIimpL^0ZmXwjAyVN?rhp!kGW8MZ>mdNx zG3z*d6yuI6JeP&PDU%Md1u-E&Xi_c%;?X&b07P)L0ca$r7fuij@XRFwD3L$-9d4|mxk~Xt0QXiLfmFzKf4g_G-M_6#t*C(S{JR|;y9(DEWv>%q2b{vtmEr(0UmmJ z=ivhmu0Lc!ZwDk490YiRhu{e83x$91NZSB2@Psq&S^M|X>2CdF4suIv;aUQBJ9nbH zs?SMg@GZRVSuEgK**6wAT+z-K(K`Jh!+k2SSbJ7JQhc#W2xEdqFMbW4FVK2>VC(aM zYyTL}D<`{`&c{{eA6c(rwPRLW*PCWuMvB`-g{{=7%h|5X2<4Z}6tJ2)T`BK(r@G{N zWu@p*ZeCm7sbYuUXhz%T{hTz*-c80Qd@P$w_8M9lt(f;BM2%<%379rbbv#`z+u1Ms zVb+N9W%WXF>(qr}?d_`5B#2)s%J0?vyr99)u2NEGzI*GJ#l$!LoSlI@qcdQU1H#$+ z9wR3r&?$Z6IX%T#)3%q2^3z>^NVd?rB=_~^@lc$R7=>W^r%w$!>v1I5C&pa9t?#6Q zp&TGqLcboN?Zn)L`ImL<$|c4-t5z^(z-U#JQdNHMfGTS2?ov;nKYrsO1S6rd^Hux# z*@H6b%q5^QF6il^@5;At-dDjy{G$vDJrd=9EuTJ=yai0NQ!wkwqec>?&#h^tc}h z&bd&e=C-~K5WGUHab#80RX9GHG{m{uzdl}Cr+ng7jOV&wDR(H85LRslgB24Bc#fC3 z$h!@dnpTucaeZM|9{jgg9H?>YV)`?}Ez zEsk%^LIH0IHfHPaxIq;_%H-a;kg1AN`=|2~IMg1=jgXs>XuG8z82n1$XlM*VxR!j` z?~LWLCI8L-5pzFhEw0xuI)NCatVu#GW2f2f10N^|0KhX% zu#5M*gO!a79s$zxC9F^lG05>R%}7<;F{U5Bh$C4SdsIh+IV8iAui#hzN4b(g2@Hm^ zd*L`5*zw9^aE7jt2pl-EQIC^^?oU+9sVbcS&rfplQLjO&l1^@C> z(V&;{U;5Plp#wiD;RtrSsW(u7{Am=65i6jyL$loB-l0ng=~gmQ1~e_G_AS*+G_Y7? z3*|PVI?*9;WB&>a;=$#!3{;X`@+w;~2JCa()|%&LP@(@ogvHg*Ye$*9Xp3qVBPQ6V7k-Hb_<3j-6W3pxA%X&tJ2D>EK4OMpRnRV5HG(|IluhBACSkABY=| z0jW%i6YUw;trP$UcY%8^YEX3+s|?rvAna;BWi>Jh1KK2mkq8gC7*F6n+Y7szwc8cC z4ENLHWPFlW0fk)}35;rWaBXLesA#(XL)Yuzbd(@J%+tMIXkaJ6E4i7fgRqyGM3vw2bjCeql@tmBn-%%;?Y{APSNQe^laL%_vBo4GO@ZmDR#iw?JxTMWb zZz-|4Pd$G|g*FzzL)Mefz%9OlaHZMzBjxfpE-K7*= zNn4G3{cE*U(@V3Z^Th)tc&7Tao3r=>Wp45ZviJs*+|S(n{*I#T22BZG-|6HE$`U2Q zNCZ@=cDsJ2Hwf5U(@+8(yl!j(dXN_%AFSw_g9SZm>KK!$R=cJNyo zW2`0te-Z<%!c!u1|323z;49dHRq(&h?Vl6y=ioEx-{+-I33wk2IP@qbQj#XW)R2H* zVF!nT{(Wxzl7M$*2j?T8{M^?3U`IOD^N8{G@N@mS=yC`w65M-JPQFVr2u-@iKRuH8 zb(&+{ZK`TAf9KF45(L&=$6xvWPHBXJ2C%@f^P<7Qm{!9f?^Qzw&%GceRMy`~2n79b z%ZcxHyVZiKWCnITF32$zfKHytrdAb4iZ-xlfiLJD4m1}Mj~?<_vT=C37mPoW^&f?! z3jU-7)M^v(PEc>{D3*(F0Ro=DV6AR`1g`#TsSQT3ZM6=!0I3z8#w6doZ8io(jVdz*QB&2s%NPPi zIiUJcJed-B0W9u;>T@OBshvimZXqp~)DdYMN|35U1uLb34ZE5n5=K2q9 z_FpvVz<=60!c5}-rX5~Sh@nWkaPms;4Z#B{u|8p9iDZRU@bcmY6>-WCT#4m6T668RrusgL~ z8924aVjE{&UjA`ZJPbdIlvAhR*Msn6JWzw0jL<;bS_FgF&D)MVfFvYbiay(iFFAHebDJ01S>`?@NlmCU+oUL?jB?!XEw>l-6wKIi9ncqKvY!y!ND;>eYW zpA##MJqygvdw>&g-)?-ehk#JS?)CYdY+Jb+R0{jTjrGnQWfLHgAPB-;Ca*mW0~jO( zK8qaXzSp2X1;A08SrM(fWrV)M15f9xQ+(}Fg>qGfEcCXoN~*Frx+SLH`XBBwXS67eyo3SgLz8Rl z4s2bzJLP;hmM$ z2-aMF_iZO81nyU~9OsKSe_7EFhZG?!MVFSa(d$kbe{NT<$QuZWMZ@cTNHTtJCGo6W z=xO)uMcC?4%1O@4$!td>SZgDP6n8Fx&?k~CuuchnL;PgY)G+j!{rye7$o*cMwSg z<9ydPCIrU(zM>o)P3Ze;bTwITeA{NB*33c#qnQ7W-1%3BFUwicAP(k{_VvDp z9mF2<(kopj0ZB#0D!q=>#42e3&_OZ2cCM}k1x@TEo0aqSaGGBlt7QCMgHi6?w)Iu` z+{$B5EU^M#&n}NVE%>#25^9S4#-mqdT?Zs(Wo1R#xZV z=fDAOH1O*Tu%W^2qq`0x-H>r^@~do8(Mwyu7I@!iRhRp+P0JKX+_?RHlOCjHl{R+Ly3v;He#Zc0I6-=Pk&?w5&BPCT z;g_I>sZVi@QJO4_q@hSkIHLwMqu|GGKSCkZw+lS!uI2h5J-hYSdJ`wzm(tVe@T+B? zE=^=+OECRyoo>3mexpx00=z%=+WEd3Tw<5C;K1E4>OF{F)#Mz*$_t~%O>e-xv>4H~ zo7R}D$GT?i<_d)ET`P}=!Ojx%MYqQYe%aZqcsIe^HUppY9U4Z)2cZ1MH=@Lc#F6q2 z#9PIvyseP#9(tJIv$9n*(q6hJ4Bm?Ay))yms7Q-}bIS>Loz#1sxgOHFN*N`S$yVM~ z$AzZJ>9Mrbqa}H}A=qQ*?Jzc{oKB~!Jw5WfUCFx#v}R@Az2*E>@zXKJlbn{?C?R!Oq1@X4Z+ldM?`Pa&N{O;INAwj=SBA{L%fvCD9Gs zIdU|%|IY(2A=9=+Yv>r|;lt%;3wDR=vE2zXZV10)(6 z$#PlLo}wdW=Yw{VU}8GOQR-u$)sboJ1v7&>O7?T+G3mk7*G6q*%&1w!U5S+#zeCe6 zx?jT;ANKJtgXX8FM6&7z4v z4_Kn#V=qEC5X|jR($Z!oetSIWJchQ2t*WX@JZwum5_e&Upano~!>PIHZ}(GEQxBKc zJP;z8(wren(|njb33C@UkNEAIjfaj=vIP+5?%ns>id*x!6>HPtwCsfdmrn}$=1R-1 z+=zYM>DG7t-jX;^Wo_Uj1Hjz~7d-KpEkS(Or@cADZ4PZAVf#|c^mf_}wnRP{w&Je7 z|8uiAtl9v9@3bDkYBW|2h0&Uch8HdzdmN!SR_9VKN0(dtU^7nDd9%eWdQ1d5@1+~2 z=JEWL&~xK;%r(RL531aho;1Y?R>RQ4<9bbpN&&~l?nF{Nq1nKf|eo=bRmSJ zh$b^N&*#@_26+BdbY(yFddEG6zttbjJ?b16xHf$y1k7)OwU4llrsVR;kv*e@2$)c` zUftEzrsU%s-*Bk_oz+wpSOfJqdN881LGt#4@Xc%LAfL&!+Q^}v07FKgc5u($h@Iwe>I zbN!gYndchvIWexPw4$UM<29v^@BYZjdpHUh)F9cwbs-_4&IdkQtNLXMQ-v@-M#($# zY>(U-TEL)P8Ad^1U}^1-!Db&7n5{W}sW_&dDOOEeTtwKX64R!vsUeJ?CVLQ>HPMD7FYj2<1TPoQC_$}_=C(z1D>eqY>%HJ5! zS3<{ozVxo+OWVtDA3y3nB3(w2h7#&F;}#~JUsXSo>KdylN`hq;q&K&POs~gtZ|QJ{ z>>lz(U~ZOCtheTyGj? z+rdxk%=V8dmISNO{JOqd!TXh?$bkGI(_{CBsI}6)w9mAnBpiEU-s#on(~WacRcXGk z0L=4NrmkxPeq{*0JsJ^DRw&oGFae(|>s zjy=`fu%W`2;M`Ds{Kz3#bTTkL*z_@MnNRWQtcXE|q;|}+Jp@hodFW+vH>GRkOaB>( z7Ny;1R>JxMq{`phX9S<;wJ-{MU!<8(rQ-~&u#1c5-Ac0(DMDB*98|U@uSQ3((aO;- zdp2=z&%c5h`Rn0zih%rle#Y3NFlzIZ8egCpGr)zD^{+1spHW=7p|-!yL>F1(*if{8 zOEo9DYrC`*11yT!8+{6X#BF*z49R5#T{Z;0X@9fr@guS1ZQ&1l2nv9N4_BWMg6_aE zP~`AZvwr~>g|K*y)9L=5zornq*_G5o2Nvr(>t}v$y89CB?Cg%bTkuMut1;7kP&G~; zO|e_A3j#pO+LjodzqQfQI3}TCq+84Ww+PZBI6S+k+vM(>2n#Ae13kck^P9Jjh4~$BrM2Ud23T;aV(C{eST0*aK)-N6xL@ZCO$q& zt0Ot=lJ`b=fe1}=_%?7zM%~vAsjtiIwAd;lgy)~COjoV*_w_hCe$AAFazZINU zjLt0Z58TIAM{cnLqqa^xpNBGAnrb=4mvWx}=>E2h0Ics1rNW{wzI3Z9MOf>3w0xl> zgn=EHbiis)z0-XzHqG{W9hq6KCWDSeD1dlt7#DKbJ2r1Er0+)u8!Z(4d1)trsQG-b z_R+})%w)k8jbj?l!kS&fteH|g zr*7Id*hnACSo0OOCEv8qf2r}HsJXwwE3sJ1e~T*r`+TkC%ZNT0n}OOzEtTx^76Jbc DEohL< literal 15615 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