Rebrand: Sentry -> Cairn OBS

Full rebrand across cosmetic branding, code identifiers, and
infrastructure/data-plane naming, using the supplied Cairn OBS logo
package. Cosmetic: favicon/logo swap (also closes a stale license-audit
finding -- the old favicon was SvelteKit's unreplaced scaffold logo),
new centered welcome landing page, larger/legible sidebar logo, page
titles, CLAUDE.md/README/docs prose.

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

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

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

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

Verified at every layer: all 13 Go modules build/vet/test clean, both
Rust workspaces (agent, search) build/clippy/test clean, npm run check/
build clean, docker compose config validates on all four compose files.
Live-verified against a real docker stack multiple times through this
work, including a final fresh-volume run confirming the actual renamed
Postgres database/role, ClickHouse database, and Kafka topic all work
end to end with a real login and query, zero console errors.
This commit is contained in:
2026-08-21 20:53:32 -07:00
parent 9e21ea17bb
commit 13cf9a30cb
291 changed files with 1565 additions and 1441 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
run: | run: |
go-licenses check ./... \ go-licenses check ./... \
--allowed_licenses=MIT,Apache-2.0,BSD-2-Clause,BSD-3-Clause,ISC,MPL-2.0,0BSD,Unlicense \ --allowed_licenses=MIT,Apache-2.0,BSD-2-Clause,BSD-3-Clause,ISC,MPL-2.0,0BSD,Unlicense \
--ignore github.com/sentry/sentry \ --ignore github.com/cairnobs/cairnobs \
--ignore github.com/segmentio/asm --ignore github.com/segmentio/asm
npm-licenses: npm-licenses:
+1 -1
View File
@@ -6,7 +6,7 @@ search/target/
# binaries in-tree, but be defensive) # binaries in-tree, but be defensive)
/ingest/ingest /ingest/ingest
/api/api /api/api
/cli/sentryctl /cli/cairnobsctl
/terraform/terraform-provider-sentry /terraform/terraform-provider-sentry
/alerting/alerting /alerting/alerting
/hack/windows-fixture/windows-fixture /hack/windows-fixture/windows-fixture
+17 -17
View File
@@ -1,4 +1,4 @@
# Project: Sentry — Distributed Log Aggregation & Observability Platform # Project: Cairn OBS — Distributed Log Aggregation & Observability Platform
## Mission ## Mission
Build an open-core, Kubernetes-native centralized logging platform that rivals Build an open-core, Kubernetes-native centralized logging platform that rivals
@@ -22,19 +22,19 @@ described there without flagging it to me first.
- Schema-on-write with OTel semantic conventions as the default schema, with - Schema-on-write with OTel semantic conventions as the default schema, with
schema-on-read fallback for unstructured text. schema-on-read fallback for unstructured text.
- Every UI action must correspond to a documented REST/gRPC call. No - Every UI action must correspond to a documented REST/gRPC call. No
UI-only logic. CLI (`sentryctl`) and Terraform provider are first-class, UI-only logic. CLI (`cairnobsctl`) and Terraform provider are first-class,
not afterthoughts. **Status**: `sentryctl` has been built out phase by not afterthoughts. **Status**: `cairnobsctl` has been built out phase by
phase since Phase 3. The Terraform provider (`/terraform`) only exists phase since Phase 3. The Terraform provider (`/terraform`) only exists
as of this note -- four resources (`sentry_dashboard` and as of this note -- four resources (`cairnobs_dashboard` and
`sentry_dashboard_panel`, both full CRUD, panels as their own resource `cairnobs_dashboard_panel`, both full CRUD, panels as their own resource
rather than a nested block since the API manages them independently rather than a nested block since the API manages them independently
of their parent dashboard; `sentry_alert_rule` and of their parent dashboard; `cairnobs_alert_rule` and
`sentry_notification_target`, both create/destroy only -- `alerting` `cairnobs_notification_target`, both create/destroy only -- `alerting`
has no `PUT /rules/{id}` or `PUT /targets/{id}` to update against), has no `PUT /rules/{id}` or `PUT /targets/{id}` to update against),
each paired with a read-only data source, built on HashiCorp's each paired with a read-only data source, built on HashiCorp's
`terraform-plugin-framework`, reusing the exact same REST contracts `terraform-plugin-framework`, reusing the exact same REST contracts
`sentryctl dashboards apply`/web's dashboard export and `cairnobsctl dashboards apply`/web's dashboard export and
`sentryctl alerts apply` already use. Tenant/RBAC resources are real, `cairnobsctl alerts apply` already use. Tenant/RBAC resources are real,
disclosed future work -- see disclosed future work -- see
`/terraform/README.md` for the full accounting of what is and isn't `/terraform/README.md` for the full accounting of what is and isn't
built, and the same built, and the same
@@ -94,7 +94,7 @@ prominently in `/agent/README.md` and the runbook.
## What "done" looks like for Phase 2 ## What "done" looks like for Phase 2
A single query bar in the web UI and a single `sentryctl query` command A single query bar in the web UI and a single `cairnobsctl query` command
can express filter + free-text + stats in one query (e.g. `service=api | can express filter + free-text + stats in one query (e.g. `service=api |
where status>=500 | stats count by host | sort -count`, or where status>=500 | stats count by host | sort -count`, or
`message:"connection refused" | stats count by host`), execute correctly `message:"connection refused" | stats count by host`), execute correctly
@@ -212,7 +212,7 @@ the fix verified Docker-free (`chrunner_test.go`'s and
tests) — see `api/queryapi/tenant_isolation_gap_test.go` for the full tests) — see `api/queryapi/tenant_isolation_gap_test.go` for the full
accounting of all four probes, now all closed. The deployment- accounting of all four probes, now all closed. The deployment-
topology gap that briefly was the largest one is now closed for both topology gap that briefly was the largest one is now closed for both
Helm and docker-compose: `deploy/helm/sentry/templates/api.yaml`/ Helm and docker-compose: `deploy/helm/cairnobs/templates/api.yaml`/
`enterprise-api.yaml` are mutually exclusive on the same `enterprise-api.yaml` are mutually exclusive on the same
`enterprise.enabled` flag that turns on RBAC/audit/SSO, rendering to the `enterprise.enabled` flag that turns on RBAC/audit/SSO, rendering to the
same Service name/port either way — a Helm-deployed cluster can't same Service name/port either way — a Helm-deployed cluster can't
@@ -234,7 +234,7 @@ stricter still (creator/Admin/Owner only, closing a self-escalation
path). Verified against a fake store (`api/dashboards/handler_test.go`); path). Verified against a fake store (`api/dashboards/handler_test.go`);
real integration tests exist but haven't run against a live Postgres, real integration tests exist but haven't run against a live Postgres,
same disclosed gap as the rest of this phase's Postgres-backed pieces. same disclosed gap as the rest of this phase's Postgres-backed pieces.
`sentryctl dashboards permissions list|grant|revoke` is now the CLI `cairnobsctl dashboards permissions list|grant|revoke` is now the CLI
surface for this — `PUT`/`DELETE /dashboards/{id}/permissions/{userId}` surface for this — `PUT`/`DELETE /dashboards/{id}/permissions/{userId}`
previously had no caller but Go tests and curl. previously had no caller but Go tests and curl.
`deploy/operator`'s `Tenant` CRD and `enterprise-api -provision-tenant` `deploy/operator`'s `Tenant` CRD and `enterprise-api -provision-tenant`
@@ -483,19 +483,19 @@ phase); CI enforcement wired up and every command verified locally.
**The one real flag — Redpanda's BSL 1.1 license (confirmed against **The one real flag — Redpanda's BSL 1.1 license (confirmed against
primary sources for the pinned v24.2.7, not assumed to still be primary sources for the pinned v24.2.7, not assumed to still be
Apache-2.0) — is resolved, not outstanding**: decision recorded Apache-2.0) — is resolved, not outstanding**: decision recorded
2026-08-16, accept as-is. Sentry's own use (internal Kafka-protocol 2026-08-16, accept as-is. Cairn OBS's own use (internal Kafka-protocol
transport, no resale of broker access) sits within BSL's Additional Use transport, no resale of broker access) sits within BSL's Additional Use
Grant; the harder question — whether a third party self-hosting Sentry Grant; the harder question — whether a third party self-hosting Cairn OBS
"as a service" using the bundled `docker-compose.yml` could trip BSL's "as a service" using the bundled `docker-compose.yml` could trip BSL's
anti-resale restriction on Redpanda specifically — was judged unlikely anti-resale restriction on Redpanda specifically — was judged unlikely
given Sentry's ingest pipeline creates fixed internal topics, not given Cairn OBS's ingest pipeline creates fixed internal topics, not
per-end-user ones, and was accepted as a disclosed, known risk rather per-end-user ones, and was accepted as a disclosed, known risk rather
than triggering a swap to Apache Kafka (real resource-footprint cost) or than triggering a swap to Apache Kafka (real resource-footprint cost) or
dropping the bundled broker image (rougher local dev experience). See dropping the bundled broker image (rougher local dev experience). See
the audit report's Redpanda section for the full reasoning, the other the audit report's Redpanda section for the full reasoning, the other
two options that were considered and not chosen, and the condition under two options that were considered and not chosen, and the condition under
which this should be revisited (an official hosted/managed Sentry which this should be revisited (an official hosted/managed Cairn OBS
offering, which would make the third-party-SaaS scenario Sentry's own offering, which would make the third-party-SaaS scenario Cairn OBS's own
rather than a hypothetical one). rather than a hypothetical one).
Non-goals for this phase: replacing permissively-licensed dependencies Non-goals for this phase: replacing permissively-licensed dependencies
+25 -25
View File
@@ -177,6 +177,31 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cairnobs-agent"
version = "0.1.0"
dependencies = [
"anyhow",
"cairnobs-parser",
"clap",
"prost",
"quick-xml",
"serde",
"serde_json",
"tokio",
"toml",
"tonic",
"tonic-build",
"tracing",
"tracing-subscriber",
"windows",
"windows-service",
]
[[package]]
name = "cairnobs-parser"
version = "0.1.0"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.4.2" version = "1.4.2"
@@ -895,31 +920,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "sentry-agent"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"prost",
"quick-xml",
"sentry-parser",
"serde",
"serde_json",
"tokio",
"toml",
"tonic",
"tonic-build",
"tracing",
"tracing-subscriber",
"windows",
"windows-service",
]
[[package]]
name = "sentry-parser"
version = "0.1.0"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.229" version = "1.0.229"
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["sentry-parser", "sentry-agent"] members = ["cairnobs-parser", "cairnobs-agent"]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.0"
+4 -4
View File
@@ -1,6 +1,6 @@
# Build context must be the repo root (sentry/), not agent/, since this # Build context must be the repo root (sentry/), not agent/, since this
# needs both agent/ and proto/: # needs both agent/ and proto/:
# docker build -f agent/Dockerfile -t sentry-agent . # docker build -f agent/Dockerfile -t cairnobs-agent .
FROM rust:1-alpine AS builder FROM rust:1-alpine AS builder
RUN apk add --no-cache musl-dev protobuf-dev protobuf RUN apk add --no-cache musl-dev protobuf-dev protobuf
@@ -14,8 +14,8 @@ WORKDIR /src/agent
# alongside the journald default rather than left as an opt-in most # alongside the journald default rather than left as an opt-in most
# deployments would otherwise have to remember to ask for. # deployments would otherwise have to remember to ask for.
RUN rustup target add x86_64-unknown-linux-musl \ RUN rustup target add x86_64-unknown-linux-musl \
&& cargo build --release --target x86_64-unknown-linux-musl -p sentry-agent --features journald,file-tail && cargo build --release --target x86_64-unknown-linux-musl -p cairnobs-agent --features journald,file-tail
FROM scratch FROM scratch
COPY --from=builder /src/agent/target/x86_64-unknown-linux-musl/release/sentry-agent /sentry-agent COPY --from=builder /src/agent/target/x86_64-unknown-linux-musl/release/cairnobs-agent /cairnobs-agent
ENTRYPOINT ["/sentry-agent"] ENTRYPOINT ["/cairnobs-agent"]
+18 -18
View File
@@ -1,4 +1,4 @@
# sentry-agent # cairnobs-agent
Distro-agnostic Linux/Windows log collector. On Linux, statically linked Distro-agnostic Linux/Windows log collector. On Linux, statically linked
against musl, no glibc runtime dependency. Tails journald (Linux default), against musl, no glibc runtime dependency. Tails journald (Linux default),
@@ -17,9 +17,9 @@ real before trusting it. See `/docs/phase-1-runbook.md`.
## Workspace layout ## Workspace layout
- `sentry-parser` — pure-`std` RFC 5424 syslog parser with raw-passthrough - `cairnobs-parser` — pure-`std` RFC 5424 syslog parser with raw-passthrough
fallback. No I/O, easy to unit test in isolation. fallback. No I/O, easy to unit test in isolation.
- `sentry-agent` — the binary: config loading, sourcing (journald/file/ - `cairnobs-agent` — the binary: config loading, sourcing (journald/file/
Windows Event Log/ETW), batching, mTLS gRPC client, Windows service Windows Event Log/ETW), batching, mTLS gRPC client, Windows service
wrapper. wrapper.
@@ -75,7 +75,7 @@ Container build (see caveat below):
```sh ```sh
# from the repo root, not agent/ # from the repo root, not agent/
docker build -f agent/Dockerfile -t sentry-agent . docker build -f agent/Dockerfile -t cairnobs-agent .
``` ```
**Caveat:** the container image is provided for CI/completeness, but **Caveat:** the container image is provided for CI/completeness, but
@@ -117,11 +117,11 @@ automatable vs. manual-only.
No CLI flags are required for the common case: No CLI flags are required for the common case:
```sh ```sh
./sentry-agent ./cairnobs-agent
``` ```
This uses the platform's conventional config path if present This uses the platform's conventional config path if present
(`/etc/sentry-agent/agent.toml` on Linux, `C:\ProgramData\SentryAgent\agent.toml` (`/etc/cairnobs-agent/agent.toml` on Linux, `C:\ProgramData\CairnObsAgent\agent.toml`
on Windows), otherwise built-in defaults: journald source on Linux (whole on Windows), otherwise built-in defaults: journald source on Linux (whole
journal, no unit filter), service name `default`, and mTLS material journal, no unit filter), service name `default`, and mTLS material
expected under the same conventional directory expected under the same conventional directory
@@ -132,7 +132,7 @@ fail fast with a clear error rather than connecting insecurely.
See `config/agent.example.toml` for all fields. See `config/agent.example.toml` for all fields.
```sh ```sh
./sentry-agent --config /path/to/agent.toml ./cairnobs-agent --config /path/to/agent.toml
``` ```
## Heartbeat and unavailability alerting ## Heartbeat and unavailability alerting
@@ -142,7 +142,7 @@ schedule (`[heartbeat]` in the config, default every 60s), separate from
whatever real log traffic is flowing — see `config/agent.example.toml`. whatever real log traffic is flowing — see `config/agent.example.toml`.
This isn't a new wire protocol: it's an ordinary record through the same This isn't a new wire protocol: it's an ordinary record through the same
`PushBatch` RPC and mTLS identity every log line uses, tagged with a `PushBatch` RPC and mTLS identity every log line uses, tagged with a
`sentry.heartbeat=true` attribute so it's easy to filter for and doesn't `cairnobs.heartbeat=true` attribute so it's easy to filter for and doesn't
show up as noise in normal log views. Set `interval` to a plain number show up as noise in normal log views. Set `interval` to a plain number
plus `s`/`m`/`h` (matches the query language's own `earliest=`/`latest=` plus `s`/`m`/`h` (matches the query language's own `earliest=`/`latest=`
units); `enabled = false` turns it off entirely. units); `enabled = false` turns it off entirely.
@@ -157,7 +157,7 @@ to create.
Same shape as heartbeat, same reasoning: `[metrics]` in the config Same shape as heartbeat, same reasoning: `[metrics]` in the config
(`enabled = false` by default) sends a periodic record — CPU%, memory (`enabled = false` by default) sends a periodic record — CPU%, memory
used/total, disk used/total for `/` — tagged `sentry.metrics=true`, with used/total, disk used/total for `/` — tagged `cairnobs.metrics=true`, with
the individual numbers as their own attributes (`cpu_percent`, the individual numbers as their own attributes (`cpu_percent`,
`mem_used_bytes`, `mem_total_bytes`, `disk_used_bytes`, `mem_used_bytes`, `mem_total_bytes`, `disk_used_bytes`,
`disk_total_bytes`), queryable directly (e.g. `cpu_percent > 80`) since `disk_total_bytes`), queryable directly (e.g. `cpu_percent > 80`) since
@@ -169,7 +169,7 @@ dependencies, same "shell out to a boring, ubiquitous tool" precedent
`journalctl` already sets. `journalctl` already sets.
**Enable this on only one agent process per physical host.** It's **Enable this on only one agent process per physical host.** It's
common for one host to run several `sentry-agent` processes (one per common for one host to run several `cairnobs-agent` processes (one per
log source, each needing its own `[agent] host` value to work around log source, each needing its own `[agent] host` value to work around
the `agents` table's `UNIQUE (tenant_id, host)` constraint — see the `agents` table's `UNIQUE (tenant_id, host)` constraint — see
`/docs/agent-management-design.md`) — turning `[metrics]` on for more `/docs/agent-management-design.md`) — turning `[metrics]` on for more
@@ -185,14 +185,14 @@ console — that's what `service.rs` (via the `windows-service` crate)
does. From an administrator shell: does. From an administrator shell:
```powershell ```powershell
sentry-agent.exe install # registers the service, Automatic start, LocalSystem account cairnobs-agent.exe install # registers the service, Automatic start, LocalSystem account
sc.exe start SentryAgent sc.exe start CairnObsAgent
sc.exe stop SentryAgent sc.exe stop CairnObsAgent
sentry-agent.exe uninstall cairnobs-agent.exe uninstall
``` ```
`install`/`uninstall`/`run-service` are subcommands only present in `install`/`uninstall`/`run-service` are subcommands only present in
Windows builds (`sentry-agent` with no subcommand is still the normal Windows builds (`cairnobs-agent` with no subcommand is still the normal
foreground/console run, same as on Linux) — `run-service` specifically is foreground/console run, same as on Linux) — `run-service` specifically is
what the SCM itself invokes at service start; don't run it directly. what the SCM itself invokes at service start; don't run it directly.
@@ -224,7 +224,7 @@ about since they're very different amounts of work:
1. **What this repo supports today, with zero extra code:** WEF is a 1. **What this repo supports today, with zero extra code:** WEF is a
native Windows-to-Windows mechanism (`wecsvc`, the built-in Windows native Windows-to-Windows mechanism (`wecsvc`, the built-in Windows
Event Collector role) — endpoints forward to a Windows Server acting Event Collector role) — endpoints forward to a Windows Server acting
as collector using Windows' own mechanism, no Sentry code involved in as collector using Windows' own mechanism, no Cairn OBS code involved in
the forwarding itself. Run this agent *on the collector box*, the forwarding itself. Run this agent *on the collector box*,
subscribed to the `ForwardedEvents` channel instead of the usual three: subscribed to the `ForwardedEvents` channel instead of the usual three:
```toml ```toml
@@ -233,9 +233,9 @@ about since they're very different amounts of work:
channels = ["ForwardedEvents"] channels = ["ForwardedEvents"]
``` ```
2. **What this repo does *not* implement:** a true agentless receiver — 2. **What this repo does *not* implement:** a true agentless receiver —
Sentry itself speaking the WS-Management/WinRM event-subscription Cairn OBS itself speaking the WS-Management/WinRM event-subscription
protocol so endpoints can forward directly to `ingest` without any protocol so endpoints can forward directly to `ingest` without any
Windows Event Collector role or Sentry agent anywhere. That's a Windows Event Collector role or Cairn OBS agent anywhere. That's a
standalone protocol implementation (SOAP-ish subscription/heartbeat/ standalone protocol implementation (SOAP-ish subscription/heartbeat/
delivery over WinRM), not an agent or ingest-side tweak, and it's out delivery over WinRM), not an agent or ingest-side tweak, and it's out
of scope for Phase 1. If you need this, it's a real project of its of scope for Phase 1. If you need this, it's a real project of its
@@ -1,12 +1,12 @@
[package] [package]
name = "sentry-agent" name = "cairnobs-agent"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
description = "Sentry distro-agnostic Linux/Windows log collector" description = "Cairn OBS distro-agnostic Linux/Windows log collector"
[[bin]] [[bin]]
name = "sentry-agent" name = "cairnobs-agent"
path = "src/main.rs" path = "src/main.rs"
[features] [features]
@@ -21,7 +21,7 @@ windows-eventlog = []
etw = [] etw = []
[dependencies] [dependencies]
sentry-parser = { path = "../sentry-parser" } cairnobs-parser = { path = "../cairnobs-parser" }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util", "io-std", "time", "fs", "sync", "signal"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util", "io-std", "time", "fs", "sync", "signal"] }
tonic = { version = "0.12", features = ["tls"] } tonic = { version = "0.12", features = ["tls"] }
@@ -1,12 +1,12 @@
# Example sentry-agent config. Copy to the platform's conventional path # Example cairnobs-agent config. Copy to the platform's conventional path
# (/etc/sentry-agent/agent.toml on Linux, C:\ProgramData\SentryAgent\agent.toml # (/etc/cairnobs-agent/agent.toml on Linux, C:\ProgramData\CairnObsAgent\agent.toml
# on Windows), or pass --config /path/to/this/file. # on Windows), or pass --config /path/to/this/file.
# #
# Every field has a built-in default (see src/config.rs), so this file only # Every field has a built-in default (see src/config.rs), so this file only
# needs to contain what you're overriding. An agent with NO config file at # needs to contain what you're overriding. An agent with NO config file at
# all still runs: on Linux it defaults to journald, service = "default", # all still runs: on Linux it defaults to journald, service = "default",
# and expects mTLS material at /etc/sentry-agent/{ca,client,client-key}.pem # and expects mTLS material at /etc/cairnobs-agent/{ca,client,client-key}.pem
# (Windows equivalents under C:\ProgramData\SentryAgent\). # (Windows equivalents under C:\ProgramData\CairnObsAgent\).
[agent] [agent]
# host = "explicit-hostname-override" # defaults to /etc/hostname (Linux) or %COMPUTERNAME% (Windows) # host = "explicit-hostname-override" # defaults to /etc/hostname (Linux) or %COMPUTERNAME% (Windows)
@@ -41,7 +41,7 @@ flush_interval_ms = 2000
[heartbeat] [heartbeat]
# How often this agent proves it's still alive to the platform, sent as # How often this agent proves it's still alive to the platform, sent as
# its own record independent of whatever real log traffic is flowing -- # its own record independent of whatever real log traffic is flowing --
# pair with an "absence" alert rule on the sentry.heartbeat attribute to # pair with an "absence" alert rule on the cairnobs.heartbeat attribute to
# get paged when a host goes quiet. Accepts a plain number + unit: s # get paged when a host goes quiet. Accepts a plain number + unit: s
# (seconds), m (minutes), or h (hours) -- same vocabulary as # (seconds), m (minutes), or h (hours) -- same vocabulary as
# earliest=/latest= in the query language. See # earliest=/latest= in the query language. See
@@ -55,7 +55,7 @@ interval = "60s"
# Host CPU/memory/disk usage, sent as its own periodic record the same # Host CPU/memory/disk usage, sent as its own periodic record the same
# way heartbeat is (see web/'s "Hosts" nav section). Off by default -- # way heartbeat is (see web/'s "Hosts" nav section). Off by default --
# unlike heartbeat, this is a deliberate per-host decision: if several # unlike heartbeat, this is a deliberate per-host decision: if several
# sentry-agent processes run on the same physical host (e.g. one per log # cairnobs-agent processes run on the same physical host (e.g. one per log
# source), enable this on only ONE of them, or the same host will report # source), enable this on only ONE of them, or the same host will report
# multiple conflicting metric series. Linux-only for now. Root disk ("/") # multiple conflicting metric series. Linux-only for now. Root disk ("/")
# only -- not configurable in this release. # only -- not configurable in this release.
@@ -66,6 +66,6 @@ interval = "60s"
endpoint = "https://ingest.internal:4317" endpoint = "https://ingest.internal:4317"
[tls] [tls]
ca_cert = "/etc/sentry-agent/ca.pem" ca_cert = "/etc/cairnobs-agent/ca.pem"
client_cert = "/etc/sentry-agent/client.pem" client_cert = "/etc/cairnobs-agent/client.pem"
client_key = "/etc/sentry-agent/client-key.pem" client_key = "/etc/cairnobs-agent/client-key.pem"
@@ -4,9 +4,9 @@ use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::Duration;
#[cfg(not(windows))] #[cfg(not(windows))]
const DEFAULT_CONFIG_PATH: &str = "/etc/sentry-agent/agent.toml"; const DEFAULT_CONFIG_PATH: &str = "/etc/cairnobs-agent/agent.toml";
#[cfg(windows)] #[cfg(windows)]
const DEFAULT_CONFIG_PATH: &str = r"C:\ProgramData\SentryAgent\agent.toml"; const DEFAULT_CONFIG_PATH: &str = r"C:\ProgramData\CairnObsAgent\agent.toml";
#[derive(Debug, Clone, Deserialize, Default)] #[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)] #[serde(default)]
@@ -23,8 +23,8 @@ pub struct Config {
impl Config { impl Config {
/// Loads config from `explicit_path` if given, else from the /// Loads config from `explicit_path` if given, else from the
/// platform's conventional config path if it exists /// platform's conventional config path if it exists
/// (`/etc/sentry-agent/agent.toml` on Linux, /// (`/etc/cairnobs-agent/agent.toml` on Linux,
/// `C:\ProgramData\SentryAgent\agent.toml` on Windows), else falls /// `C:\ProgramData\CairnObsAgent\agent.toml` on Windows), else falls
/// back to built-in defaults (journald source on Linux, default TLS /// back to built-in defaults (journald source on Linux, default TLS
/// cert paths). Only an explicitly-passed `--config` path that doesn't /// cert paths). Only an explicitly-passed `--config` path that doesn't
/// exist is an error; the conventional default path is optional. /// exist is an error; the conventional default path is optional.
@@ -150,7 +150,7 @@ impl Default for BatchConfig {
/// `flush_interval_ms` like real records do. This is the operator-facing /// `flush_interval_ms` like real records do. This is the operator-facing
/// "polling resolution" knob: how often this agent proves it's still /// "polling resolution" knob: how often this agent proves it's still
/// alive, which a `condition_type = "absence"` alert rule on the /// alive, which a `condition_type = "absence"` alert rule on the
/// `sentry.heartbeat` attribute (see /docs/agent-heartbeat-monitoring.md) /// `cairnobs.heartbeat` attribute (see /docs/agent-heartbeat-monitoring.md)
/// turns into "alert when this host goes quiet." /// turns into "alert when this host goes quiet."
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(default)] #[serde(default)]
@@ -321,10 +321,10 @@ impl Default for TlsConfig {
#[cfg(not(windows))] #[cfg(not(windows))]
fn default_cert_path(name: &str) -> PathBuf { fn default_cert_path(name: &str) -> PathBuf {
PathBuf::from(format!("/etc/sentry-agent/{name}")) PathBuf::from(format!("/etc/cairnobs-agent/{name}"))
} }
#[cfg(windows)] #[cfg(windows)]
fn default_cert_path(name: &str) -> PathBuf { fn default_cert_path(name: &str) -> PathBuf {
PathBuf::from(format!(r"C:\ProgramData\SentryAgent\{name}")) PathBuf::from(format!(r"C:\ProgramData\CairnObsAgent\{name}"))
} }
@@ -31,7 +31,7 @@ use tokio::sync::mpsc;
use tonic::transport::Channel; use tonic::transport::Channel;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "sentry-agent", about = "Sentry Linux/Windows log collector")] #[command(name = "cairnobs-agent", about = "Cairn OBS Linux/Windows log collector")]
struct Cli { struct Cli {
/// Path to a TOML config file. Defaults to the platform's conventional /// Path to a TOML config file. Defaults to the platform's conventional
/// path if present, otherwise built-in defaults — see config::Config::load. /// path if present, otherwise built-in defaults — see config::Config::load.
@@ -53,7 +53,7 @@ enum WindowsCommand {
Uninstall, Uninstall,
/// Entry point the Service Control Manager invokes when starting the /// Entry point the Service Control Manager invokes when starting the
/// registered service. Not meant to be run directly by a user — use /// registered service. Not meant to be run directly by a user — use
/// `sentry-agent` with no subcommand for a normal foreground/console /// `cairnobs-agent` with no subcommand for a normal foreground/console
/// run, same as on Linux. /// run, same as on Linux.
RunService, RunService,
} }
@@ -244,7 +244,7 @@ pub async fn run_agent(config_path: Option<PathBuf>) -> Result<()> {
tracing::warn!("source exited, flushing remaining batch and shutting down"); tracing::warn!("source exited, flushing remaining batch and shutting down");
break; break;
}; };
let parsed = sentry_parser::parse(&raw.line); let parsed = cairnobs_parser::parse(&raw.line);
let severity = to_pb_severity(raw.severity_hint.or(parsed.severity)); let severity = to_pb_severity(raw.severity_hint.or(parsed.severity));
let mut attributes: std::collections::HashMap<String, String> = let mut attributes: std::collections::HashMap<String, String> =
parsed.attributes.into_iter().collect(); parsed.attributes.into_iter().collect();
@@ -476,7 +476,7 @@ async fn spawn_source(source: config::SourceConfig, tx: source::LineSender) {
/// ticker's own comment above): a heartbeat that got queued behind /// ticker's own comment above): a heartbeat that got queued behind
/// `batch.max_size` or `batch.flush_interval_ms` would defeat the point /// `batch.max_size` or `batch.flush_interval_ms` would defeat the point
/// of a punctual "still alive" signal. Distinguished from a real log /// of a punctual "still alive" signal. Distinguished from a real log
/// record purely by the `sentry.heartbeat` attribute -- `service` stays /// record purely by the `cairnobs.heartbeat` attribute -- `service` stays
/// the agent's real configured service so it doesn't pollute /// the agent's real configured service so it doesn't pollute
/// service-based dashboards/faceting with a fake value. See /// service-based dashboards/faceting with a fake value. See
/// /docs/agent-heartbeat-monitoring.md for how an absence alert rule /// /docs/agent-heartbeat-monitoring.md for how an absence alert rule
@@ -488,7 +488,7 @@ async fn send_heartbeat(client: &mut LogIngestClient<Channel>, host: &str, servi
service: service.to_string(), service: service.to_string(),
severity: Severity::Info as i32, severity: Severity::Info as i32,
message: "agent heartbeat".to_string(), message: "agent heartbeat".to_string(),
attributes: std::collections::HashMap::from([("sentry.heartbeat".to_string(), "true".to_string())]), attributes: std::collections::HashMap::from([("cairnobs.heartbeat".to_string(), "true".to_string())]),
record_id: String::new(), record_id: String::new(),
}; };
match grpc::send_batch(client, format!("heartbeat-{}", batch_id()), vec![record]).await { match grpc::send_batch(client, format!("heartbeat-{}", batch_id()), vec![record]).await {
@@ -499,7 +499,7 @@ async fn send_heartbeat(client: &mut LogIngestClient<Channel>, host: &str, servi
/// Same "no new proto, no new ingest code, no new ClickHouse schema" /// Same "no new proto, no new ingest code, no new ClickHouse schema"
/// shape as `send_heartbeat` above -- a metrics sample is just another /// shape as `send_heartbeat` above -- a metrics sample is just another
/// tagged `LogRecord`, distinguished by the `sentry.metrics` attribute. /// tagged `LogRecord`, distinguished by the `cairnobs.metrics` attribute.
/// Unlike heartbeat, the numeric fields themselves are real query-language /// Unlike heartbeat, the numeric fields themselves are real query-language
/// attributes too (`cpu_percent`, `mem_used_bytes`, etc.) rather than /// attributes too (`cpu_percent`, `mem_used_bytes`, etc.) rather than
/// being folded into `message` -- confirmed before building this that /// being folded into `message` -- confirmed before building this that
@@ -523,7 +523,7 @@ async fn send_metrics(client: &mut LogIngestClient<Channel>, host: &str, service
severity: Severity::Info as i32, severity: Severity::Info as i32,
message: "host metrics".to_string(), message: "host metrics".to_string(),
attributes: std::collections::HashMap::from([ attributes: std::collections::HashMap::from([
("sentry.metrics".to_string(), "true".to_string()), ("cairnobs.metrics".to_string(), "true".to_string()),
("cpu_percent".to_string(), format!("{:.2}", m.cpu_percent)), ("cpu_percent".to_string(), format!("{:.2}", m.cpu_percent)),
("mem_used_bytes".to_string(), m.mem_used_bytes.to_string()), ("mem_used_bytes".to_string(), m.mem_used_bytes.to_string()),
("mem_total_bytes".to_string(), m.mem_total_bytes.to_string()), ("mem_total_bytes".to_string(), m.mem_total_bytes.to_string()),
@@ -27,12 +27,12 @@ use windows_service::service_control_handler::{self, ServiceControlHandlerResult
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
use windows_service::{define_windows_service, service_dispatcher}; use windows_service::{define_windows_service, service_dispatcher};
pub const SERVICE_NAME: &str = "SentryAgent"; pub const SERVICE_NAME: &str = "CairnObsAgent";
const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS; const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS;
/// Registers this binary as a Windows service: Automatic start, /// Registers this binary as a Windows service: Automatic start,
/// LocalSystem account, invoked with the `run-service` subcommand (which /// LocalSystem account, invoked with the `run-service` subcommand (which
/// is what the SCM actually launches — not a bare `sentry-agent` with no /// is what the SCM actually launches — not a bare `cairnobs-agent` with no
/// arguments). Requires an administrator shell. /// arguments). Requires an administrator shell.
pub fn install() -> Result<()> { pub fn install() -> Result<()> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CREATE_SERVICE) let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CREATE_SERVICE)
@@ -42,7 +42,7 @@ pub fn install() -> Result<()> {
let service_info = ServiceInfo { let service_info = ServiceInfo {
name: OsString::from(SERVICE_NAME), name: OsString::from(SERVICE_NAME),
display_name: OsString::from("Sentry Log Agent"), display_name: OsString::from("Cairn OBS Log Agent"),
service_type: SERVICE_TYPE, service_type: SERVICE_TYPE,
start_type: ServiceStartType::AutoStart, start_type: ServiceStartType::AutoStart,
error_control: ServiceErrorControl::Normal, error_control: ServiceErrorControl::Normal,
@@ -57,7 +57,7 @@ pub fn install() -> Result<()> {
.create_service(&service_info, ServiceAccess::CHANGE_CONFIG) .create_service(&service_info, ServiceAccess::CHANGE_CONFIG)
.context("creating service")?; .context("creating service")?;
service service
.set_description("Ships local logs to Sentry ingest over mTLS.") .set_description("Ships local logs to Cairn OBS ingest over mTLS.")
.context("setting service description")?; .context("setting service description")?;
tracing::info!(service = SERVICE_NAME, "installed Windows service"); tracing::info!(service = SERVICE_NAME, "installed Windows service");
@@ -44,7 +44,7 @@ use windows::Win32::System::Diagnostics::Etw::{
PROCESS_TRACE_MODE_REAL_TIME, TRACE_LEVEL_VERBOSE, PROCESS_TRACE_MODE_REAL_TIME, TRACE_LEVEL_VERBOSE,
}; };
const SESSION_NAME: &str = "SentryAgentEtw"; const SESSION_NAME: &str = "CairnObsAgentEtw";
pub async fn run(providers: &[String], tx: LineSender) -> Result<()> { pub async fn run(providers: &[String], tx: LineSender) -> Result<()> {
let providers = providers.to_vec(); let providers = providers.to_vec();
@@ -212,7 +212,7 @@ unsafe extern "system" fn event_record_callback(record: *mut EVENT_RECORD) {
// No TDH-based message rendering (see module doc comment) -- this is // No TDH-based message rendering (see module doc comment) -- this is
// a coarse, structured summary rather than a human-authored message. // a coarse, structured summary rather than a human-authored message.
// Downstream (sentry_parser's raw-passthrough fallback) handles a // Downstream (cairnobs_parser's raw-passthrough fallback) handles a
// non-RFC5424 line like this the same as any other raw line. // non-RFC5424 line like this the same as any other raw line.
let message = format!( let message = format!(
"ETW event: provider={:?} id={} level={}", "ETW event: provider={:?} id={} level={}",
@@ -226,7 +226,7 @@ fn parse_event_xml(xml: &str, channel: &str) -> Option<RawLine> {
// rendered properly. Real message-template rendering needs // rendered properly. Real message-template rendering needs
// EvtFormatMessage against the provider's message-table resource -- // EvtFormatMessage against the provider's message-table resource --
// worth a follow-up, not required for a raw-passthrough-shaped record // worth a follow-up, not required for a raw-passthrough-shaped record
// (sentry_parser's raw fallback handles this fine either way). // (cairnobs_parser's raw fallback handles this fine either way).
let message = if event_data_values.is_empty() { let message = if event_data_values.is_empty() {
xml.to_string() xml.to_string()
} else { } else {
@@ -1,5 +1,5 @@
[package] [package]
name = "sentry-parser" name = "cairnobs-parser"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
+1 -1
View File
@@ -88,7 +88,7 @@ ignore = [
# List of explicitly allowed licenses # List of explicitly allowed licenses
# See https://spdx.org/licenses/ for list of possible licenses # See https://spdx.org/licenses/ for list of possible licenses
# [possible values: any SPDX 3.11 short identifier (+ optional exception)]. # [possible values: any SPDX 3.11 short identifier (+ optional exception)].
# Sentry's own AGPLv3-project license policy (Phase 6 license audit -- # Cairn OBS's own AGPLv3-project license policy (Phase 6 license audit --
# see /docs/compliance/license-policy.md for the full rationale per # see /docs/compliance/license-policy.md for the full rationale per
# category). Every license actually found in this crate's dependency # category). Every license actually found in this crate's dependency
# tree at audit time is listed explicitly here, not just the common # tree at audit time is listed explicitly here, not just the common
+1 -1
View File
@@ -8,7 +8,7 @@ correctness properties this implementation follows exactly).
## Running ## Running
```sh ```sh
POSTGRES_PASSWORD=sentry-dev-only API_QUERY_URL=http://localhost:8080 go run ./cmd/alerting POSTGRES_PASSWORD=cairnobs-dev-only API_QUERY_URL=http://localhost:8080 go run ./cmd/alerting
``` ```
Talks to the same `sentry_metadata` Postgres database as `/api` Talks to the same `sentry_metadata` Postgres database as `/api`
+11 -11
View File
@@ -1,9 +1,9 @@
// Command alerting is Sentry's alert rule evaluator and delivery // Command alerting is Cairn OBS's alert rule evaluator and delivery
// service: rule/target CRUD, the ticker-driven ok/pending/firing // service: rule/target CRUD, the ticker-driven ok/pending/firing
// evaluator, and the webhook/Slack/PagerDuty delivery worker. See // evaluator, and the webhook/Slack/PagerDuty delivery worker. See
// /docs/phase-3-alerting-design.md. Never talks to ClickHouse/Tantivy // /docs/phase-3-alerting-design.md. Never talks to ClickHouse/Tantivy
// directly -- rule queries run through /api's POST /query // directly -- rule queries run through /api's POST /query
// (internal/queryclient), same precedent sentryctl query and the web // (internal/queryclient), same precedent cairnobsctl query and the web
// UI's dashboard panels already set. // UI's dashboard panels already set.
package main package main
@@ -21,15 +21,15 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"github.com/sentry/sentry/alerting/internal/config" "github.com/cairnobs/cairnobs/alerting/internal/config"
"github.com/sentry/sentry/alerting/internal/delivery" "github.com/cairnobs/cairnobs/alerting/internal/delivery"
"github.com/sentry/sentry/alerting/internal/evaluator" "github.com/cairnobs/cairnobs/alerting/internal/evaluator"
"github.com/sentry/sentry/alerting/internal/httpapi" "github.com/cairnobs/cairnobs/alerting/internal/httpapi"
"github.com/sentry/sentry/alerting/internal/httpserver" "github.com/cairnobs/cairnobs/alerting/internal/httpserver"
"github.com/sentry/sentry/alerting/internal/notifystore" "github.com/cairnobs/cairnobs/alerting/internal/notifystore"
"github.com/sentry/sentry/alerting/internal/queryclient" "github.com/cairnobs/cairnobs/alerting/internal/queryclient"
"github.com/sentry/sentry/alerting/internal/rulestore" "github.com/cairnobs/cairnobs/alerting/internal/rulestore"
"github.com/sentry/sentry/alerting/internal/sessioncheck" "github.com/cairnobs/cairnobs/alerting/internal/sessioncheck"
) )
func main() { func main() {
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/sentry/sentry/alerting module github.com/cairnobs/cairnobs/alerting
go 1.25.0 go 1.25.0
+1 -1
View File
@@ -53,7 +53,7 @@ type EvaluatorConfig struct {
// api/internal/config.Config.DevCredentialWarnings for the full // api/internal/config.Config.DevCredentialWarnings for the full
// reasoning (duplicated here per this repo's no-shared-code-between- // reasoning (duplicated here per this repo's no-shared-code-between-
// services convention). // services convention).
const devOnlyCredential = "sentry-dev-only" const devOnlyCredential = "cairnobs-dev-only"
// DevCredentialWarnings reports whether the configured Postgres // DevCredentialWarnings reports whether the configured Postgres
// credential still equals the literal dev-only default -- // credential still equals the literal dev-only default --
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/sentry/sentry/alerting/internal/notifystore" "github.com/cairnobs/cairnobs/alerting/internal/notifystore"
) )
func TestBuildPayloadGenericDefaultShape(t *testing.T) { func TestBuildPayloadGenericDefaultShape(t *testing.T) {
+2 -2
View File
@@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/sentry/sentry/alerting/internal/notifystore" "github.com/cairnobs/cairnobs/alerting/internal/notifystore"
) )
// pagerDutyPayload is PagerDuty's Events API v2 shape. routing_key comes // pagerDutyPayload is PagerDuty's Events API v2 shape. routing_key comes
@@ -50,7 +50,7 @@ func buildPagerDutyPayload(target notifystore.Target, event Event) ([]byte, erro
DedupKey: event.RuleID, DedupKey: event.RuleID,
Payload: pagerDutyEventPayload{ Payload: pagerDutyEventPayload{
Summary: summary, Summary: summary,
Source: "sentry", Source: "cairnobs",
Severity: severity, Severity: severity,
}, },
}) })
+1 -1
View File
@@ -19,7 +19,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/sentry/sentry/alerting/internal/notifystore" "github.com/cairnobs/cairnobs/alerting/internal/notifystore"
) )
// Event is the firing/resolved occurrence a payload is rendered from. // Event is the firing/resolved occurrence a payload is rendered from.
+2 -2
View File
@@ -3,8 +3,8 @@ package evaluator
import ( import (
"fmt" "fmt"
"github.com/sentry/sentry/alerting/internal/queryclient" "github.com/cairnobs/cairnobs/alerting/internal/queryclient"
"github.com/sentry/sentry/alerting/internal/rulestore" "github.com/cairnobs/cairnobs/alerting/internal/rulestore"
) )
// evaluateCondition implements fixes 3 and 4 from // evaluateCondition implements fixes 3 and 4 from
@@ -3,8 +3,8 @@ package evaluator
import ( import (
"testing" "testing"
"github.com/sentry/sentry/alerting/internal/queryclient" "github.com/cairnobs/cairnobs/alerting/internal/queryclient"
"github.com/sentry/sentry/alerting/internal/rulestore" "github.com/cairnobs/cairnobs/alerting/internal/rulestore"
) )
func thresholdRule(comparator rulestore.Comparator, threshold float64) rulestore.Rule { func thresholdRule(comparator rulestore.Comparator, threshold float64) rulestore.Rule {
+4 -4
View File
@@ -6,10 +6,10 @@ import (
"sync" "sync"
"time" "time"
"github.com/sentry/sentry/alerting/internal/delivery" "github.com/cairnobs/cairnobs/alerting/internal/delivery"
"github.com/sentry/sentry/alerting/internal/notifystore" "github.com/cairnobs/cairnobs/alerting/internal/notifystore"
"github.com/sentry/sentry/alerting/internal/queryclient" "github.com/cairnobs/cairnobs/alerting/internal/queryclient"
"github.com/sentry/sentry/alerting/internal/rulestore" "github.com/cairnobs/cairnobs/alerting/internal/rulestore"
) )
// Evaluator is the ticker-driven scheduler -- a bounded worker pool, not // Evaluator is the ticker-driven scheduler -- a bounded worker pool, not
+1 -1
View File
@@ -10,7 +10,7 @@ package evaluator
import ( import (
"time" "time"
"github.com/sentry/sentry/alerting/internal/rulestore" "github.com/cairnobs/cairnobs/alerting/internal/rulestore"
) )
// TransitionInput is everything ComputeTransition needs: the rule's // TransitionInput is everything ComputeTransition needs: the rule's
@@ -4,7 +4,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/sentry/sentry/alerting/internal/rulestore" "github.com/cairnobs/cairnobs/alerting/internal/rulestore"
) )
var t0 = time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC) var t0 = time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC)
+2 -2
View File
@@ -11,8 +11,8 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"github.com/sentry/sentry/alerting/internal/notifystore" "github.com/cairnobs/cairnobs/alerting/internal/notifystore"
"github.com/sentry/sentry/alerting/internal/rulestore" "github.com/cairnobs/cairnobs/alerting/internal/rulestore"
) )
type ruleStore interface { type ruleStore interface {
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/sentry/sentry/alerting/internal/notifystore" "github.com/cairnobs/cairnobs/alerting/internal/notifystore"
"github.com/sentry/sentry/alerting/internal/rulestore" "github.com/cairnobs/cairnobs/alerting/internal/rulestore"
) )
type fakeRuleStore struct { type fakeRuleStore struct {
+1 -1
View File
@@ -1,6 +1,6 @@
// Package queryclient is a thin HTTP client to /api's POST /query -- // Package queryclient is a thin HTTP client to /api's POST /query --
// alerting never imports querylang or talks to ClickHouse/Tantivy // alerting never imports querylang or talks to ClickHouse/Tantivy
// directly, same precedent sentryctl query and the web UI's dashboard // directly, same precedent cairnobsctl query and the web UI's dashboard
// panels already set: one query-execution path, reused everywhere. // panels already set: one query-execution path, reused everywhere.
package queryclient package queryclient
+1 -1
View File
@@ -10,7 +10,7 @@ import (
// imported -- see this package's doc comment) -- the same cookie // imported -- see this package's doc comment) -- the same cookie
// api/localauth.Handler.setCookie writes, scoped (via SESSION_COOKIE_ // api/localauth.Handler.setCookie writes, scoped (via SESSION_COOKIE_
// DOMAIN) to cover both api's and alerting's subdomains in production. // DOMAIN) to cover both api's and alerting's subdomains in production.
const sessionCookieName = "sentry_local_session" const sessionCookieName = "cairnobs_local_session"
type errorResponse struct { type errorResponse struct {
Error string `json:"error"` Error string `json:"error"`
@@ -9,7 +9,7 @@
// //
// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/alerting \ // docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/alerting \
// -e SESSIONCHECK_TEST_POSTGRES_ADDR=metadata-postgres:5432 \ // -e SESSIONCHECK_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
// -e SESSIONCHECK_TEST_POSTGRES_PASSWORD=sentry-dev-only \ // -e SESSIONCHECK_TEST_POSTGRES_PASSWORD=cairnobs-dev-only \
// golang:1.25-alpine go test ./internal/sessioncheck/... -run Integration -v // golang:1.25-alpine go test ./internal/sessioncheck/... -run Integration -v
package sessioncheck package sessioncheck
+1 -1
View File
@@ -1,6 +1,6 @@
# api # api
Sentry's query API: a single `POST /query` endpoint accepting either the Cairn OBS's query API: a single `POST /query` endpoint accepting either the
pipe syntax or raw SQL, compiled and routed across ClickHouse and Tantivy pipe syntax or raw SQL, compiled and routed across ClickHouse and Tantivy
by `internal/querylang`. Replaces Phase 0/1's two separate placeholder by `internal/querylang`. Replaces Phase 0/1's two separate placeholder
endpoints (raw-SQL-only `/query`, free-text-only `/search`) — see endpoints (raw-SQL-only `/query`, free-text-only `/search`) — see
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"path" "path"
"strings" "strings"
"github.com/sentry/sentry/api/authz" "github.com/cairnobs/cairnobs/api/authz"
) )
// store is the narrow interface Handler depends on -- *Store (store.go) // store is the narrow interface Handler depends on -- *Store (store.go)
+2 -2
View File
@@ -11,7 +11,7 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/sentry/sentry/api/authz" "github.com/cairnobs/cairnobs/api/authz"
) )
func discardLogger() *slog.Logger { func discardLogger() *slog.Logger {
@@ -259,7 +259,7 @@ func TestHandleSetConfigDenylistsSensitivePaths(t *testing.T) {
"/home/alice/.ssh/id_rsa", "/home/alice/.ssh/id_rsa",
"/home/alice/.ssh/authorized_keys", "/home/alice/.ssh/authorized_keys",
"/proc/1/environ", "/proc/1/environ",
"/etc/sentry-agent/client-key.pem", "/etc/cairnobs-agent/client-key.pem",
"/opt/app/../../etc/shadow", "/opt/app/../../etc/shadow",
"/opt/app/id_ed25519", "/opt/app/id_ed25519",
} }
+6 -6
View File
@@ -22,12 +22,12 @@ import (
"strings" "strings"
"time" "time"
"github.com/sentry/sentry/api/ai/costguard" "github.com/cairnobs/cairnobs/api/ai/costguard"
"github.com/sentry/sentry/api/ai/provider" "github.com/cairnobs/cairnobs/api/ai/provider"
"github.com/sentry/sentry/api/ai/router" "github.com/cairnobs/cairnobs/api/ai/router"
"github.com/sentry/sentry/api/authz" "github.com/cairnobs/cairnobs/api/authz"
"github.com/sentry/sentry/api/internal/querylang/ir" "github.com/cairnobs/cairnobs/api/internal/querylang/ir"
"github.com/sentry/sentry/api/internal/querylang/planner" "github.com/cairnobs/cairnobs/api/internal/querylang/planner"
) )
// SchemaContextSource resolves the calling tenant's grounding data. // SchemaContextSource resolves the calling tenant's grounding data.
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/sentry/sentry/api/ai/provider" "github.com/cairnobs/cairnobs/api/ai/provider"
"github.com/sentry/sentry/api/ai/router" "github.com/cairnobs/cairnobs/api/ai/router"
) )
type fakeProvider struct { type fakeProvider struct {
+2 -2
View File
@@ -29,8 +29,8 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/sentry/sentry/api/ai/provider/ollama" "github.com/cairnobs/cairnobs/api/ai/provider/ollama"
"github.com/sentry/sentry/api/ai/router" "github.com/cairnobs/cairnobs/api/ai/router"
) )
// jsonBody marshals v for use as an http.Post body -- the integration // jsonBody marshals v for use as an http.Post body -- the integration
+1 -1
View File
@@ -21,7 +21,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/sentry/sentry/api/internal/querylang/ir" "github.com/cairnobs/cairnobs/api/internal/querylang/ir"
) )
type Level string type Level string
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/sentry/sentry/api/internal/querylang/ir" "github.com/cairnobs/cairnobs/api/internal/querylang/ir"
) )
// A raw-row (non-aggregation) query with no time range and no explicit // A raw-row (non-aggregation) query with no time range and no explicit
+2 -2
View File
@@ -25,8 +25,8 @@ import (
"sync" "sync"
"time" "time"
"github.com/sentry/sentry/api/ai/provider" "github.com/cairnobs/cairnobs/api/ai/provider"
"github.com/sentry/sentry/api/querylang/executor" "github.com/cairnobs/cairnobs/api/querylang/executor"
) )
// staticFields are always present regardless of what's actually been // staticFields are always present regardless of what's actually been
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/sentry/sentry/api/ai/provider" "github.com/cairnobs/cairnobs/api/ai/provider"
"github.com/sentry/sentry/api/querylang/executor" "github.com/cairnobs/cairnobs/api/querylang/executor"
) )
// routingFakeRunner returns a canned result keyed by a substring match // routingFakeRunner returns a canned result keyed by a substring match
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/sentry/sentry/api/ai/provider" "github.com/cairnobs/cairnobs/api/ai/provider"
) )
// Client implements provider.Provider against one Ollama server and one // Client implements provider.Provider against one Ollama server and one
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/sentry/sentry/api/ai/provider" "github.com/cairnobs/cairnobs/api/ai/provider"
) )
// fakeOllamaServer stands in for a real Ollama server, returning the // fakeOllamaServer stands in for a real Ollama server, returning the
+8 -8
View File
@@ -4,18 +4,18 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/sentry/sentry/api/ai/provider" "github.com/cairnobs/cairnobs/api/ai/provider"
) )
// grammarReference is a condensed version of // grammarReference is a condensed version of
// /docs/query-language-reference.md -- every operation's system prompt // /docs/query-language-reference.md -- every operation's system prompt
// includes this so the model is grounded in Sentry's actual pipe syntax, // includes this so the model is grounded in Cairn OBS's actual pipe syntax,
// not whatever generic log-query DSL it may have seen in training. // not whatever generic log-query DSL it may have seen in training.
// Trimmed to the parts that matter for generation/explanation (the full // Trimmed to the parts that matter for generation/explanation (the full
// doc's prose and examples aren't needed here); kept in sync with that // doc's prose and examples aren't needed here); kept in sync with that
// doc by hand -- if the grammar changes, this needs updating too, same // doc by hand -- if the grammar changes, this needs updating too, same
// as any other place the language is described outside its own parser. // as any other place the language is described outside its own parser.
const grammarReference = `Sentry query language (pipe syntax): const grammarReference = `Cairn OBS query language (pipe syntax):
<base search> | <stage> | <stage> | ... <base search> | <stage> | <stage> | ...
@@ -64,7 +64,7 @@ func renderSchema(s provider.SchemaContext) string {
} }
func translateSystemPrompt(schema provider.SchemaContext) string { func translateSystemPrompt(schema provider.SchemaContext) string {
return fmt.Sprintf(`You translate a plain-English question into a Sentry pipe-syntax query. You never explain, never execute anything, never write raw SQL unless the pipe syntax genuinely cannot express the request. return fmt.Sprintf(`You translate a plain-English question into a Cairn OBS pipe-syntax query. You never explain, never execute anything, never write raw SQL unless the pipe syntax genuinely cannot express the request.
%s %s
@@ -77,7 +77,7 @@ If you cannot produce a query you're reasonably confident in, set confidence to
} }
func completeSystemPrompt(schema provider.SchemaContext) string { func completeSystemPrompt(schema provider.SchemaContext) string {
return fmt.Sprintf(`You suggest how to continue a partially-typed Sentry query. You are given everything typed so far; respond with ONLY the suggested continuation text (what should appear after the cursor), not the text already typed, not an explanation. return fmt.Sprintf(`You suggest how to continue a partially-typed Cairn OBS query. You are given everything typed so far; respond with ONLY the suggested continuation text (what should appear after the cursor), not the text already typed, not an explanation.
%s %s
@@ -94,12 +94,12 @@ Respond with ONLY a JSON object, no other text, no markdown fences:
// ExplainRequest.RuleFindings' doc comment. // ExplainRequest.RuleFindings' doc comment.
func explainSystemPrompt(hasIntent, hasFindings bool) string { func explainSystemPrompt(hasIntent, hasFindings bool) string {
if hasFindings { if hasFindings {
return fmt.Sprintf(`A rule-based check already found one or more real issues with a Sentry query's efficiency (e.g. a missing time range). Your only job is to phrase those findings as a short, clear, actionable suggestion for the person who wrote the query -- do not invent additional issues, do not restate the query's own syntax back at them, do not hedge with "might" or "could" about something the check already confirmed. One or two sentences. return fmt.Sprintf(`A rule-based check already found one or more real issues with a Cairn OBS query's efficiency (e.g. a missing time range). Your only job is to phrase those findings as a short, clear, actionable suggestion for the person who wrote the query -- do not invent additional issues, do not restate the query's own syntax back at them, do not hedge with "might" or "could" about something the check already confirmed. One or two sentences.
%s`, grammarReference) %s`, grammarReference)
} }
base := fmt.Sprintf(`You explain what a Sentry query does in plain English, for someone who may not know the query language. Be concise -- two or three sentences, not a line-by-line breakdown unless the query is unusually complex. base := fmt.Sprintf(`You explain what a Cairn OBS query does in plain English, for someone who may not know the query language. Be concise -- two or three sentences, not a line-by-line breakdown unless the query is unusually complex.
%s`, grammarReference) %s`, grammarReference)
if hasIntent { if hasIntent {
@@ -109,7 +109,7 @@ func explainSystemPrompt(hasIntent, hasFindings bool) string {
} }
func fixSystemPrompt(schema provider.SchemaContext) string { func fixSystemPrompt(schema provider.SchemaContext) string {
return fmt.Sprintf(`You fix a broken Sentry query given its error message. Produce a corrected query and a short explanation of what was wrong. return fmt.Sprintf(`You fix a broken Cairn OBS query given its error message. Produce a corrected query and a short explanation of what was wrong.
%s %s
+1 -1
View File
@@ -14,7 +14,7 @@
// consume it). // consume it).
package router package router
import "github.com/sentry/sentry/api/ai/provider" import "github.com/cairnobs/cairnobs/api/ai/provider"
type Operation string type Operation string
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context" "context"
"testing" "testing"
"github.com/sentry/sentry/api/ai/provider" "github.com/cairnobs/cairnobs/api/ai/provider"
) )
// namedFakeProvider lets a test tell which configured provider actually // namedFakeProvider lets a test tell which configured provider actually
+2 -2
View File
@@ -19,7 +19,7 @@ func TestHTTPAuthorizerForwardsCredentialsAndParsesIdentity(t *testing.T) {
a := NewHTTPAuthorizer(srv.URL) a := NewHTTPAuthorizer(srv.URL)
incoming := httptest.NewRequest(http.MethodPost, "/query", nil) incoming := httptest.NewRequest(http.MethodPost, "/query", nil)
incoming.Header.Set("Cookie", "sentry_session=abc123") incoming.Header.Set("Cookie", "cairnobs_session=abc123")
incoming.Header.Set("Authorization", "Bearer service-token-xyz") incoming.Header.Set("Authorization", "Bearer service-token-xyz")
identity, err := a.Authorize(incoming) identity, err := a.Authorize(incoming)
@@ -29,7 +29,7 @@ func TestHTTPAuthorizerForwardsCredentialsAndParsesIdentity(t *testing.T) {
if identity.TenantID != "acme" || identity.UserID != "u1" || identity.Role != RoleEditor { if identity.TenantID != "acme" || identity.UserID != "u1" || identity.Role != RoleEditor {
t.Fatalf("unexpected identity: %+v", identity) t.Fatalf("unexpected identity: %+v", identity)
} }
if gotCookie != "sentry_session=abc123" { if gotCookie != "cairnobs_session=abc123" {
t.Fatalf("Cookie header not forwarded, got %q", gotCookie) t.Fatalf("Cookie header not forwarded, got %q", gotCookie)
} }
if gotAuth != "Bearer service-token-xyz" { if gotAuth != "Bearer service-token-xyz" {
+15 -15
View File
@@ -1,4 +1,4 @@
// Command api is Sentry's query API: a single POST /query endpoint // Command api is Cairn OBS's query API: a single POST /query endpoint
// accepting either the pipe syntax or raw SQL, compiled and routed // accepting either the pipe syntax or raw SQL, compiled and routed
// across ClickHouse and search by internal/querylang. See // across ClickHouse and search by internal/querylang. See
// queryapi and /docs/query-language-design.md for why this is // queryapi and /docs/query-language-design.md for why this is
@@ -23,20 +23,20 @@ import (
"github.com/ClickHouse/clickhouse-go/v2" "github.com/ClickHouse/clickhouse-go/v2"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/sentry/sentry/api/agents" "github.com/cairnobs/cairnobs/api/agents"
"github.com/sentry/sentry/api/ai/aiapi" "github.com/cairnobs/cairnobs/api/ai/aiapi"
"github.com/sentry/sentry/api/ai/grounding" "github.com/cairnobs/cairnobs/api/ai/grounding"
"github.com/sentry/sentry/api/ai/provider/ollama" "github.com/cairnobs/cairnobs/api/ai/provider/ollama"
"github.com/sentry/sentry/api/ai/router" "github.com/cairnobs/cairnobs/api/ai/router"
"github.com/sentry/sentry/api/authz" "github.com/cairnobs/cairnobs/api/authz"
"github.com/sentry/sentry/api/dashboards" "github.com/cairnobs/cairnobs/api/dashboards"
"github.com/sentry/sentry/api/httpserver" "github.com/cairnobs/cairnobs/api/httpserver"
"github.com/sentry/sentry/api/internal/config" "github.com/cairnobs/cairnobs/api/internal/config"
"github.com/sentry/sentry/api/localauth" "github.com/cairnobs/cairnobs/api/localauth"
"github.com/sentry/sentry/api/logretention" "github.com/cairnobs/cairnobs/api/logretention"
"github.com/sentry/sentry/api/queryapi" "github.com/cairnobs/cairnobs/api/queryapi"
"github.com/sentry/sentry/api/querylang/executor" "github.com/cairnobs/cairnobs/api/querylang/executor"
"github.com/sentry/sentry/api/searchclient" "github.com/cairnobs/cairnobs/api/searchclient"
) )
// groundingRefreshInterval matches chwriter.Registry/search's // groundingRefreshInterval matches chwriter.Registry/search's
+2 -2
View File
@@ -7,7 +7,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"github.com/sentry/sentry/api/authz" "github.com/cairnobs/cairnobs/api/authz"
) )
// store is the narrow interface Handler depends on -- *Store (store.go) // store is the narrow interface Handler depends on -- *Store (store.go)
@@ -187,7 +187,7 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
func (h *Handler) handleExport(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleExport(w http.ResponseWriter, r *http.Request) {
// Export is the same document GET /dashboards/{id} returns -- the // Export is the same document GET /dashboards/{id} returns -- the
// import endpoint below consumes exactly this shape, and so does // import endpoint below consumes exactly this shape, and so does
// `sentryctl dashboards apply`, so there's one JSON contract used // `cairnobsctl dashboards apply`, so there's one JSON contract used
// from every call site rather than a bespoke export format. // from every call site rather than a bespoke export format.
h.handleGet(w, r) h.handleGet(w, r)
} }
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/sentry/sentry/api/authz" "github.com/cairnobs/cairnobs/api/authz"
) )
// fakeStore enforces tenant scoping the same way store.go's real // fakeStore enforces tenant scoping the same way store.go's real
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context" "context"
"time" "time"
"github.com/sentry/sentry/api/authz" "github.com/cairnobs/cairnobs/api/authz"
) )
// Permission is one dashboard_permissions row -- see // Permission is one dashboard_permissions row -- see
+1 -1
View File
@@ -11,7 +11,7 @@
// //
// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/api \ // docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/api \
// -e DASHBOARDS_TEST_POSTGRES_ADDR=metadata-postgres:5432 \ // -e DASHBOARDS_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
// -e DASHBOARDS_TEST_POSTGRES_PASSWORD=sentry-dev-only \ // -e DASHBOARDS_TEST_POSTGRES_PASSWORD=cairnobs-dev-only \
// golang:1.25-alpine go test ./dashboards/... -run Integration -v // golang:1.25-alpine go test ./dashboards/... -run Integration -v
package dashboards package dashboards
+3 -3
View File
@@ -1,4 +1,4 @@
module github.com/sentry/sentry/api module github.com/cairnobs/cairnobs/api
go 1.25.0 go 1.25.0
@@ -6,12 +6,12 @@ require (
github.com/ClickHouse/clickhouse-go/v2 v2.48.0 github.com/ClickHouse/clickhouse-go/v2 v2.48.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.10.0
github.com/sentry/sentry/proto v0.0.0-00010101000000-000000000000 github.com/cairnobs/cairnobs/proto v0.0.0-00010101000000-000000000000
golang.org/x/crypto v0.55.0 golang.org/x/crypto v0.55.0
google.golang.org/grpc v1.83.0 google.golang.org/grpc v1.83.0
) )
replace github.com/sentry/sentry/proto => ../proto replace github.com/cairnobs/cairnobs/proto => ../proto
require ( require (
github.com/ClickHouse/ch-go v0.74.0 // indirect github.com/ClickHouse/ch-go v0.74.0 // indirect
+1 -1
View File
@@ -82,7 +82,7 @@ type PostgresConfig struct {
// real deployment that skips docker-compose.override.yml would // real deployment that skips docker-compose.override.yml would
// otherwise go live with a password anyone can read straight off // otherwise go live with a password anyone can read straight off
// GitHub. See DevCredentialWarnings. // GitHub. See DevCredentialWarnings.
const devOnlyCredential = "sentry-dev-only" const devOnlyCredential = "cairnobs-dev-only"
// DevCredentialWarnings reports which configured credentials still // DevCredentialWarnings reports which configured credentials still
// equal docker-compose.yml's literal dev-only default -- cmd/api/main.go // equal docker-compose.yml's literal dev-only default -- cmd/api/main.go
+1 -1
View File
@@ -33,7 +33,7 @@ func TestLoadInvalidTimeoutErrors(t *testing.T) {
// TestDevCredentialWarnings is the regression test for the // TestDevCredentialWarnings is the regression test for the
// security-audit finding that docker-compose.yml's hardcoded // security-audit finding that docker-compose.yml's hardcoded
// "sentry-dev-only" password has no runtime fail-safe if an operator // "cairnobs-dev-only" password has no runtime fail-safe if an operator
// forgets to override it for a real deployment. // forgets to override it for a real deployment.
func TestDevCredentialWarnings(t *testing.T) { func TestDevCredentialWarnings(t *testing.T) {
if got := (Config{}).DevCredentialWarnings(); len(got) != 0 { if got := (Config{}).DevCredentialWarnings(); len(got) != 0 {
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"github.com/sentry/sentry/api/internal/querylang/ast" "github.com/cairnobs/cairnobs/api/internal/querylang/ast"
"github.com/sentry/sentry/api/internal/querylang/lexer" "github.com/cairnobs/cairnobs/api/internal/querylang/lexer"
) )
// Parse parses a pipe-syntax query. Callers are responsible for routing // Parse parses a pipe-syntax query. Callers are responsible for routing
+1 -1
View File
@@ -3,7 +3,7 @@ package parser
import ( import (
"testing" "testing"
"github.com/sentry/sentry/api/internal/querylang/ast" "github.com/cairnobs/cairnobs/api/internal/querylang/ast"
) )
func TestParseSimpleFilter(t *testing.T) { func TestParseSimpleFilter(t *testing.T) {
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"strings" "strings"
"time" "time"
"github.com/sentry/sentry/api/internal/querylang/ast" "github.com/cairnobs/cairnobs/api/internal/querylang/ast"
"github.com/sentry/sentry/api/internal/querylang/ir" "github.com/cairnobs/cairnobs/api/internal/querylang/ir"
"github.com/sentry/sentry/api/internal/querylang/parser" "github.com/cairnobs/cairnobs/api/internal/querylang/parser"
) )
// Language selects which syntax a query is written in. // Language selects which syntax a query is written in.
@@ -5,7 +5,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/sentry/sentry/api/internal/querylang/ir" "github.com/cairnobs/cairnobs/api/internal/querylang/ir"
) )
var fixedNow = time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) var fixedNow = time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
+3 -3
View File
@@ -5,7 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"github.com/sentry/sentry/api/authz" "github.com/cairnobs/cairnobs/api/authz"
) )
// sessionStore is the narrow interface Authorizer depends on -- *Store // sessionStore is the narrow interface Authorizer depends on -- *Store
@@ -19,7 +19,7 @@ type sessionStore interface {
// needs to know about implicitly (via credentials: 'include', not by // needs to know about implicitly (via credentials: 'include', not by
// name -- the browser handles the cookie, JS never reads it since it's // name -- the browser handles the cookie, JS never reads it since it's
// HttpOnly). // HttpOnly).
const sessionCookieName = "sentry_local_session" const sessionCookieName = "cairnobs_local_session"
// Authorizer implements api/authz.Authorizer against local_sessions -- // Authorizer implements api/authz.Authorizer against local_sessions --
// wiring a non-nil *Authorizer into api/cmd/api/main.go's authorizer // wiring a non-nil *Authorizer into api/cmd/api/main.go's authorizer
@@ -37,7 +37,7 @@ func NewAuthorizer(store sessionStore) *Authorizer {
var errNoCredential = errors.New("localauth: no session credential presented") var errNoCredential = errors.New("localauth: no session credential presented")
// Authorize checks Authorization: Bearer first (sentryctl and other // Authorize checks Authorization: Bearer first (cairnobsctl and other
// non-browser callers), then the session cookie (the web UI) -- same // non-browser callers), then the session cookie (the web UI) -- same
// precedence authz.HTTPAuthorizer's caller-side forwarding implies, // precedence authz.HTTPAuthorizer's caller-side forwarding implies,
// and the same reason POST /auth/login's response body returns the raw // and the same reason POST /auth/login's response body returns the raw
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/sentry/sentry/api/authz" "github.com/cairnobs/cairnobs/api/authz"
) )
// fakeStore implements both store (handler.go) and sessionStore // fakeStore implements both store (handler.go) and sessionStore
+4 -4
View File
@@ -8,7 +8,7 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/sentry/sentry/api/authz" "github.com/cairnobs/cairnobs/api/authz"
) )
const maxBodyBytes = 1 << 20 // 1 MiB, same cap as queryapi/dashboards/agents const maxBodyBytes = 1 << 20 // 1 MiB, same cap as queryapi/dashboards/agents
@@ -36,8 +36,8 @@ type store interface {
type CookieConfig struct { type CookieConfig struct {
// Domain is typically empty for local dev (host-only cookie, works // Domain is typically empty for local dev (host-only cookie, works
// fine when web/api are both localhost:<port>) and something like // fine when web/api are both localhost:<port>) and something like
// ".sentry.example.com" in production, so the same cookie is sent to // ".cairnobs.example.com" in production, so the same cookie is sent to
// api.sentry.example.com and alerting.sentry.example.com too -- see // api.cairnobs.example.com and alerting.cairnobs.example.com too -- see
// /docs (deployment runbook) for the subdomain scheme this assumes. // /docs (deployment runbook) for the subdomain scheme this assumes.
Domain string Domain string
// Secure defaults to true (the cookie is never sent over plain // Secure defaults to true (the cookie is never sent over plain
@@ -129,7 +129,7 @@ type loginRequest struct {
type sessionResponse struct { type sessionResponse struct {
// Token duplicates what the Set-Cookie header already carries, // Token duplicates what the Set-Cookie header already carries,
// specifically for non-browser callers with no cookie jar -- // specifically for non-browser callers with no cookie jar --
// sentryctl captures this into SENTRYCTL_TOKEN and sends it back as // cairnobsctl captures this into CAIRNOBSCTL_TOKEN and sends it back as
// Authorization: Bearer (see authorizer.go's credentialFromRequest, // Authorization: Bearer (see authorizer.go's credentialFromRequest,
// which accepts either). The web UI ignores this field entirely and // which accepts either). The web UI ignores this field entirely and
// relies on the cookie. // relies on the cookie.
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/sentry/sentry/api/authz" "github.com/cairnobs/cairnobs/api/authz"
) )
func newTestHandler(t *testing.T, fs *fakeStore) (*Handler, *http.ServeMux) { func newTestHandler(t *testing.T, fs *fakeStore) (*Handler, *http.ServeMux) {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/sentry/sentry/api/authz" "github.com/cairnobs/cairnobs/api/authz"
) )
func TestLoginLimiterAllowsUpToMax(t *testing.T) { func TestLoginLimiterAllowsUpToMax(t *testing.T) {
+1 -1
View File
@@ -37,7 +37,7 @@ import (
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/sentry/sentry/api/authz" "github.com/cairnobs/cairnobs/api/authz"
) )
var ( var (
+2 -2
View File
@@ -10,7 +10,7 @@
// //
// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/api \ // docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/api \
// -e LOCALAUTH_TEST_POSTGRES_ADDR=metadata-postgres:5432 \ // -e LOCALAUTH_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
// -e LOCALAUTH_TEST_POSTGRES_PASSWORD=sentry-dev-only \ // -e LOCALAUTH_TEST_POSTGRES_PASSWORD=cairnobs-dev-only \
// golang:1.25-alpine go test ./localauth/... -run Integration -v // golang:1.25-alpine go test ./localauth/... -run Integration -v
package localauth package localauth
@@ -25,7 +25,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/sentry/sentry/api/authz" "github.com/cairnobs/cairnobs/api/authz"
) )
func integrationStore(t *testing.T) *Store { func integrationStore(t *testing.T) *Store {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/sentry/sentry/api/authz" "github.com/cairnobs/cairnobs/api/authz"
) )
const maxBodyBytes = 1 << 20 // 1 MiB, same cap as localauth/dashboards/agents const maxBodyBytes = 1 << 20 // 1 MiB, same cap as localauth/dashboards/agents
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/sentry/sentry/api/authz" "github.com/cairnobs/cairnobs/api/authz"
) )
func discardLogger() *slog.Logger { func discardLogger() *slog.Logger {
+1 -1
View File
@@ -13,7 +13,7 @@
// service is a genuine per-log-record dimension already, not something // service is a genuine per-log-record dimension already, not something
// this package invents: storage/migrations/0001_create_logs_table.sql // this package invents: storage/migrations/0001_create_logs_table.sql
// has always had a `service` column, and distinct services on one host // has always had a `service` column, and distinct services on one host
// are a real, already-supported shape (separate sentry-agent processes // are a real, already-supported shape (separate cairnobs-agent processes
// on the same machine, each with its own agent.toml `service` -- see // on the same machine, each with its own agent.toml `service` -- see
// /docs/agent-management-design.md), not merely a per-agent label. // /docs/agent-management-design.md), not merely a per-agent label.
// //
+5 -5
View File
@@ -1,4 +1,4 @@
// Package queryapi is Sentry's query API: a single POST /query endpoint // Package queryapi is Cairn OBS's query API: a single POST /query endpoint
// accepting either the pipe syntax or raw SQL, compiled by // accepting either the pipe syntax or raw SQL, compiled by
// querylang/planner and executed by querylang/executor. Replaces Phase // querylang/planner and executed by querylang/executor. Replaces Phase
// 0/1's two separate placeholder endpoints (raw-SQL-only /query, // 0/1's two separate placeholder endpoints (raw-SQL-only /query,
@@ -19,10 +19,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/sentry/sentry/api/ai/costguard" "github.com/cairnobs/cairnobs/api/ai/costguard"
"github.com/sentry/sentry/api/authz" "github.com/cairnobs/cairnobs/api/authz"
"github.com/sentry/sentry/api/internal/querylang/planner" "github.com/cairnobs/cairnobs/api/internal/querylang/planner"
"github.com/sentry/sentry/api/querylang/executor" "github.com/cairnobs/cairnobs/api/querylang/executor"
) )
// AuditLogger is core's extension point for query audit logging -- // AuditLogger is core's extension point for query audit logging --
+2 -2
View File
@@ -12,8 +12,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/sentry/sentry/api/authz" "github.com/cairnobs/cairnobs/api/authz"
"github.com/sentry/sentry/api/querylang/executor" "github.com/cairnobs/cairnobs/api/querylang/executor"
) )
type fakeSQLRunner struct { type fakeSQLRunner struct {
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/sentry/sentry/api/internal/querylang/ir" "github.com/cairnobs/cairnobs/api/internal/querylang/ir"
) )
type Result struct { type Result struct {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/sentry/sentry/api/internal/querylang/ir" "github.com/cairnobs/cairnobs/api/internal/querylang/ir"
) )
func mustParseTime(t *testing.T, s string) time.Time { func mustParseTime(t *testing.T, s string) time.Time {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/sentry/sentry/api/internal/querylang/ir" "github.com/cairnobs/cairnobs/api/internal/querylang/ir"
) )
// defaultRowLimit is the safety net when a raw-row query has neither an // defaultRowLimit is the safety net when a raw-row query has neither an
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
searchv1 "github.com/sentry/sentry/proto/sentry/search/v1" searchv1 "github.com/cairnobs/cairnobs/proto/sentry/search/v1"
) )
type Client struct { type Client struct {
+4 -4
View File
@@ -1,9 +1,9 @@
# docker build -f cli/Dockerfile -t sentryctl cli/ # docker build -f cli/Dockerfile -t cairnobsctl cli/
FROM golang:1.25-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /src WORKDIR /src
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/sentryctl ./cmd/sentryctl RUN CGO_ENABLED=0 GOOS=linux go build -o /out/cairnobsctl ./cmd/cairnobsctl
FROM gcr.io/distroless/static-debian12 FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/sentryctl /sentryctl COPY --from=builder /out/cairnobsctl /cairnobsctl
ENTRYPOINT ["/sentryctl"] ENTRYPOINT ["/cairnobsctl"]
+19 -19
View File
@@ -1,20 +1,20 @@
# sentryctl # cairnobsctl
Sentry's control CLI. Cairn OBS's control CLI.
```sh ```sh
sentryctl ping # checks http://localhost:8080/healthz cairnobsctl ping # checks http://localhost:8080/healthz
sentryctl ping --api http://api.internal:8080 cairnobsctl ping --api http://api.internal:8080
SENTRYCTL_API_URL=http://api.internal:8080 sentryctl ping CAIRNOBSCTL_API_URL=http://api.internal:8080 cairnobsctl ping
``` ```
Exits 0 and prints `ok` if `/api`'s `/healthz` responds 200; exits 1 with an Exits 0 and prints `ok` if `/api`'s `/healthz` responds 200; exits 1 with an
error on `stderr` otherwise. error on `stderr` otherwise.
```sh ```sh
sentryctl query 'service=api | where status>=500 | stats count by host' cairnobsctl query 'service=api | where status>=500 | stats count by host'
sentryctl query 'SELECT * FROM logs LIMIT 10' --language sql cairnobsctl query 'SELECT * FROM logs LIMIT 10' --language sql
sentryctl query 'message:"connection refused"' --json cairnobsctl query 'message:"connection refused"' --json
``` ```
Quote the query in your shell — pipe syntax uses `|`, which your shell Quote the query in your shell — pipe syntax uses `|`, which your shell
@@ -27,17 +27,17 @@ Prints a table by default (stdlib `text/tabwriter`, no new dependency);
`--json` prints the raw `{columns, rows}` response instead. `--json` prints the raw `{columns, rows}` response instead.
```sh ```sh
sentryctl dashboards list cairnobsctl dashboards list
sentryctl dashboards get <id> cairnobsctl dashboards get <id>
sentryctl dashboards apply dashboard.json # imports a dashboard exported via the web UI's "Export JSON" button cairnobsctl dashboards apply dashboard.json # imports a dashboard exported via the web UI's "Export JSON" button
sentryctl dashboards permissions list <dashboard-id> cairnobsctl dashboards permissions list <dashboard-id>
sentryctl dashboards permissions grant <dashboard-id> <user-id> viewer|editor cairnobsctl dashboards permissions grant <dashboard-id> <user-id> viewer|editor
sentryctl dashboards permissions revoke <dashboard-id> <user-id> cairnobsctl dashboards permissions revoke <dashboard-id> <user-id>
sentryctl alerts list cairnobsctl alerts list
sentryctl alerts get <id> cairnobsctl alerts get <id>
sentryctl alerts apply rule.json # creates a rule from a JSON file shaped like POST /rules's body cairnobsctl alerts apply rule.json # creates a rule from a JSON file shaped like POST /rules's body
``` ```
`dashboards permissions` is Phase 4's per-resource dashboard grant `dashboards permissions` is Phase 4's per-resource dashboard grant
@@ -51,7 +51,7 @@ CLI bug.
`dashboards` talks to `/api` (`--api`, same override as `query`/`ping`). `dashboards` talks to `/api` (`--api`, same override as `query`/`ping`).
`alerts` talks to `/alerting`, a separate service with its own base URL `alerts` talks to `/alerting`, a separate service with its own base URL
(`--alerting-api`, or `$SENTRYCTL_ALERTING_API_URL`, default (`--alerting-api`, or `$CAIRNOBSCTL_ALERTING_API_URL`, default
`http://localhost:8081`) — see `/docs/phase-3-alerting-design.md`'s `http://localhost:8081`) — see `/docs/phase-3-alerting-design.md`'s
component boundary for why alerting isn't just another `/api` route. component boundary for why alerting isn't just another `/api` route.
`apply` in both cases sends the file's JSON as-is to the corresponding `apply` in both cases sends the file's JSON as-is to the corresponding
@@ -78,5 +78,5 @@ go test ./...
``` ```
```sh ```sh
docker build -f Dockerfile -t sentryctl . # context is cli/, not the repo root docker build -f Dockerfile -t cairnobsctl . # context is cli/, not the repo root
``` ```
@@ -10,7 +10,7 @@ import (
func TestResolveTokenFromEnv(t *testing.T) { func TestResolveTokenFromEnv(t *testing.T) {
env := func(k string) string { env := func(k string) string {
if k == "SENTRYCTL_TOKEN" { if k == "CAIRNOBSCTL_TOKEN" {
return "secret-token" return "secret-token"
} }
return "" return ""
@@ -91,7 +91,7 @@ func TestCmdPingForwardsBearerToken(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
t.Setenv("SENTRYCTL_TOKEN", "ping-token") t.Setenv("CAIRNOBSCTL_TOKEN", "ping-token")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
code := cmdPing([]string{"--api", srv.URL}, &stdout, &stderr) code := cmdPing([]string{"--api", srv.URL}, &stdout, &stderr)
if code != 0 { if code != 0 {
@@ -111,7 +111,7 @@ func TestCmdQueryForwardsBearerToken(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
t.Setenv("SENTRYCTL_TOKEN", "query-token") t.Setenv("CAIRNOBSCTL_TOKEN", "query-token")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
code := cmdQuery([]string{"--api", srv.URL, "service=api"}, &stdout, &stderr) code := cmdQuery([]string{"--api", srv.URL, "service=api"}, &stdout, &stderr)
if code != 0 { if code != 0 {
@@ -17,7 +17,7 @@ import (
func cmdAgents(args []string, stdout, stderr io.Writer) int { func cmdAgents(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 { if len(args) == 0 {
fmt.Fprintln(stderr, "sentryctl agents: expected a subcommand (list, get, config, restart)") fmt.Fprintln(stderr, "cairnobsctl agents: expected a subcommand (list, get, config, restart)")
return 1 return 1
} }
apiURL, rest := extractAPIFlag(args[1:], os.Getenv) apiURL, rest := extractAPIFlag(args[1:], os.Getenv)
@@ -28,19 +28,19 @@ func cmdAgents(args []string, stdout, stderr io.Writer) int {
return httpGetJSON(apiURL, "/agents", token, stdout, stderr) return httpGetJSON(apiURL, "/agents", token, stdout, stderr)
case "get": case "get":
if len(rest) == 0 { if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl agents get: missing host") fmt.Fprintln(stderr, "cairnobsctl agents get: missing host")
return 1 return 1
} }
return httpGetJSON(apiURL, "/agents/"+rest[0], token, stdout, stderr) return httpGetJSON(apiURL, "/agents/"+rest[0], token, stdout, stderr)
case "config": case "config":
if len(rest) == 0 { if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl agents config: expected a subcommand (get, set, clear)") fmt.Fprintln(stderr, "cairnobsctl agents config: expected a subcommand (get, set, clear)")
return 1 return 1
} }
return cmdAgentsConfig(rest, apiURL, token, stdout, stderr) return cmdAgentsConfig(rest, apiURL, token, stdout, stderr)
case "restart": case "restart":
if len(rest) == 0 { if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl agents restart: missing host") fmt.Fprintln(stderr, "cairnobsctl agents restart: missing host")
return 1 return 1
} }
// os.Stdin passed explicitly at this inner layer (not threaded // os.Stdin passed explicitly at this inner layer (not threaded
@@ -49,7 +49,7 @@ func cmdAgents(args []string, stdout, stderr io.Writer) int {
// call cmdAgentsRestart directly with a fake reader. // call cmdAgentsRestart directly with a fake reader.
return cmdAgentsRestart(rest, apiURL, token, os.Stdin, stdout, stderr) return cmdAgentsRestart(rest, apiURL, token, os.Stdin, stdout, stderr)
default: default:
fmt.Fprintf(stderr, "sentryctl agents: unknown subcommand %q (want list, get, config, restart)\n", args[0]) fmt.Fprintf(stderr, "cairnobsctl agents: unknown subcommand %q (want list, get, config, restart)\n", args[0])
return 1 return 1
} }
} }
@@ -59,7 +59,7 @@ func cmdAgentsConfig(args []string, apiURL, token string, stdout, stderr io.Writ
switch sub { switch sub {
case "get": case "get":
if len(rest) == 0 { if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl agents config get: missing host") fmt.Fprintln(stderr, "cairnobsctl agents config get: missing host")
return 1 return 1
} }
// Same GET /agents/{host} as plain "get" -- an agent's reported // Same GET /agents/{host} as plain "get" -- an agent's reported
@@ -69,18 +69,18 @@ func cmdAgentsConfig(args []string, apiURL, token string, stdout, stderr io.Writ
return httpGetJSON(apiURL, "/agents/"+rest[0], token, stdout, stderr) return httpGetJSON(apiURL, "/agents/"+rest[0], token, stdout, stderr)
case "set": case "set":
if len(rest) == 0 { if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl agents config set: missing host") fmt.Fprintln(stderr, "cairnobsctl agents config set: missing host")
return 1 return 1
} }
return cmdAgentsConfigSet(rest[0], rest[1:], apiURL, token, stdout, stderr) return cmdAgentsConfigSet(rest[0], rest[1:], apiURL, token, stdout, stderr)
case "clear": case "clear":
if len(rest) == 0 { if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl agents config clear: missing host") fmt.Fprintln(stderr, "cairnobsctl agents config clear: missing host")
return 1 return 1
} }
return httpMutateNoBody(http.MethodDelete, apiURL, "/agents/"+rest[0]+"/config", token, "", "config override cleared -- agent will run its local agent.toml again", stdout, stderr) return httpMutateNoBody(http.MethodDelete, apiURL, "/agents/"+rest[0]+"/config", token, "", "config override cleared -- agent will run its local agent.toml again", stdout, stderr)
default: default:
fmt.Fprintf(stderr, "sentryctl agents config: unknown subcommand %q (want get, set, clear)\n", sub) fmt.Fprintf(stderr, "cairnobsctl agents config: unknown subcommand %q (want get, set, clear)\n", sub)
return 1 return 1
} }
} }
@@ -135,71 +135,71 @@ func cmdAgentsConfigSet(host string, flagArgs []string, apiURL, token string, st
case "--batch-max-size": case "--batch-max-size":
v, ok := next() v, ok := next()
if !ok { if !ok {
fmt.Fprintln(stderr, "sentryctl agents config set: --batch-max-size requires a value") fmt.Fprintln(stderr, "cairnobsctl agents config set: --batch-max-size requires a value")
return 1 return 1
} }
n, err := strconv.ParseInt(v, 10, 64) n, err := strconv.ParseInt(v, 10, 64)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "sentryctl agents config set: invalid --batch-max-size %q: %v\n", v, err) fmt.Fprintf(stderr, "cairnobsctl agents config set: invalid --batch-max-size %q: %v\n", v, err)
return 1 return 1
} }
batchMaxSize = &n batchMaxSize = &n
case "--batch-flush-interval-ms": case "--batch-flush-interval-ms":
v, ok := next() v, ok := next()
if !ok { if !ok {
fmt.Fprintln(stderr, "sentryctl agents config set: --batch-flush-interval-ms requires a value") fmt.Fprintln(stderr, "cairnobsctl agents config set: --batch-flush-interval-ms requires a value")
return 1 return 1
} }
n, err := strconv.ParseInt(v, 10, 64) n, err := strconv.ParseInt(v, 10, 64)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "sentryctl agents config set: invalid --batch-flush-interval-ms %q: %v\n", v, err) fmt.Fprintf(stderr, "cairnobsctl agents config set: invalid --batch-flush-interval-ms %q: %v\n", v, err)
return 1 return 1
} }
batchFlushMS = &n batchFlushMS = &n
case "--heartbeat-interval-ms": case "--heartbeat-interval-ms":
v, ok := next() v, ok := next()
if !ok { if !ok {
fmt.Fprintln(stderr, "sentryctl agents config set: --heartbeat-interval-ms requires a value") fmt.Fprintln(stderr, "cairnobsctl agents config set: --heartbeat-interval-ms requires a value")
return 1 return 1
} }
n, err := strconv.ParseInt(v, 10, 64) n, err := strconv.ParseInt(v, 10, 64)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "sentryctl agents config set: invalid --heartbeat-interval-ms %q: %v\n", v, err) fmt.Fprintf(stderr, "cairnobsctl agents config set: invalid --heartbeat-interval-ms %q: %v\n", v, err)
return 1 return 1
} }
heartbeatMS = &n heartbeatMS = &n
case "--heartbeat-enabled": case "--heartbeat-enabled":
v, ok := next() v, ok := next()
if !ok { if !ok {
fmt.Fprintln(stderr, "sentryctl agents config set: --heartbeat-enabled requires true or false") fmt.Fprintln(stderr, "cairnobsctl agents config set: --heartbeat-enabled requires true or false")
return 1 return 1
} }
b, err := strconv.ParseBool(v) b, err := strconv.ParseBool(v)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "sentryctl agents config set: invalid --heartbeat-enabled %q: %v\n", v, err) fmt.Fprintf(stderr, "cairnobsctl agents config set: invalid --heartbeat-enabled %q: %v\n", v, err)
return 1 return 1
} }
heartbeatEnabled = &b heartbeatEnabled = &b
case "--journald-unit": case "--journald-unit":
v, ok := next() v, ok := next()
if !ok { if !ok {
fmt.Fprintln(stderr, "sentryctl agents config set: --journald-unit requires a value (empty string clears the filter)") fmt.Fprintln(stderr, "cairnobsctl agents config set: --journald-unit requires a value (empty string clears the filter)")
return 1 return 1
} }
journaldUnit = &v journaldUnit = &v
default: default:
fmt.Fprintf(stderr, "sentryctl agents config set: unknown flag %q\n", flag) fmt.Fprintf(stderr, "cairnobsctl agents config set: unknown flag %q\n", flag)
return 1 return 1
} }
} }
if batchMaxSize == nil && batchFlushMS == nil && heartbeatMS == nil && heartbeatEnabled == nil && journaldUnit == nil { if batchMaxSize == nil && batchFlushMS == nil && heartbeatMS == nil && heartbeatEnabled == nil && journaldUnit == nil {
fmt.Fprintln(stderr, "sentryctl agents config set: at least one of --batch-max-size, --batch-flush-interval-ms, --heartbeat-enabled, --heartbeat-interval-ms, --journald-unit is required") fmt.Fprintln(stderr, "cairnobsctl agents config set: at least one of --batch-max-size, --batch-flush-interval-ms, --heartbeat-enabled, --heartbeat-interval-ms, --journald-unit is required")
return 1 return 1
} }
current, err := fetchAgent(apiURL, host, token) current, err := fetchAgent(apiURL, host, token)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "sentryctl agents config set: fetching current state: %v\n", err) fmt.Fprintf(stderr, "cairnobsctl agents config set: fetching current state: %v\n", err)
return 1 return 1
} }
@@ -232,7 +232,7 @@ func cmdAgentsConfigSet(host string, flagArgs []string, apiURL, token string, st
body, err := json.Marshal(merged) body, err := json.Marshal(merged)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "sentryctl agents config set: encoding request: %v\n", err) fmt.Fprintf(stderr, "cairnobsctl agents config set: encoding request: %v\n", err)
return 1 return 1
} }
return httpPutJSON(apiURL, "/agents/"+host+"/config", token, string(body), stdout, stderr) return httpPutJSON(apiURL, "/agents/"+host+"/config", token, string(body), stdout, stderr)
@@ -8,7 +8,7 @@ import (
func cmdAlerts(args []string, stdout, stderr io.Writer) int { func cmdAlerts(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 { if len(args) == 0 {
fmt.Fprintln(stderr, "sentryctl alerts: expected a subcommand (list, get, apply)") fmt.Fprintln(stderr, "cairnobsctl alerts: expected a subcommand (list, get, apply)")
return 1 return 1
} }
alertingURL, rest := extractAlertingAPIFlag(args[1:], os.Getenv) alertingURL, rest := extractAlertingAPIFlag(args[1:], os.Getenv)
@@ -19,13 +19,13 @@ func cmdAlerts(args []string, stdout, stderr io.Writer) int {
return httpGetJSON(alertingURL, "/rules", token, stdout, stderr) return httpGetJSON(alertingURL, "/rules", token, stdout, stderr)
case "get": case "get":
if len(rest) == 0 { if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl alerts get: missing rule id") fmt.Fprintln(stderr, "cairnobsctl alerts get: missing rule id")
return 1 return 1
} }
return httpGetJSON(alertingURL, "/rules/"+rest[0], token, stdout, stderr) return httpGetJSON(alertingURL, "/rules/"+rest[0], token, stdout, stderr)
case "apply": case "apply":
if len(rest) == 0 { if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl alerts apply: missing file path") fmt.Fprintln(stderr, "cairnobsctl alerts apply: missing file path")
return 1 return 1
} }
// POST /rules accepts the same shape it returns -- a rule // POST /rules accepts the same shape it returns -- a rule
@@ -33,7 +33,7 @@ func cmdAlerts(args []string, stdout, stderr io.Writer) int {
// target ID) applies directly with no reshaping. // target ID) applies directly with no reshaping.
return httpPostFileJSON(alertingURL, "/rules", token, rest[0], stdout, stderr) return httpPostFileJSON(alertingURL, "/rules", token, rest[0], stdout, stderr)
default: default:
fmt.Fprintf(stderr, "sentryctl alerts: unknown subcommand %q (want list, get, apply)\n", args[0]) fmt.Fprintf(stderr, "cairnobsctl alerts: unknown subcommand %q (want list, get, apply)\n", args[0])
return 1 return 1
} }
} }
@@ -28,7 +28,7 @@ func TestExtractAlertingAPIFlagOverride(t *testing.T) {
func TestExtractAlertingAPIFlagFromEnv(t *testing.T) { func TestExtractAlertingAPIFlagFromEnv(t *testing.T) {
alertingURL, _ := extractAlertingAPIFlag(nil, func(k string) string { alertingURL, _ := extractAlertingAPIFlag(nil, func(k string) string {
if k == "SENTRYCTL_ALERTING_API_URL" { if k == "CAIRNOBSCTL_ALERTING_API_URL" {
return "http://env-alerting:8081" return "http://env-alerting:8081"
} }
return "" return ""
@@ -9,7 +9,7 @@ import (
func cmdDashboards(args []string, stdout, stderr io.Writer) int { func cmdDashboards(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 { if len(args) == 0 {
fmt.Fprintln(stderr, "sentryctl dashboards: expected a subcommand (list, get, apply, permissions)") fmt.Fprintln(stderr, "cairnobsctl dashboards: expected a subcommand (list, get, apply, permissions)")
return 1 return 1
} }
apiURL, rest := extractAPIFlag(args[1:], os.Getenv) apiURL, rest := extractAPIFlag(args[1:], os.Getenv)
@@ -20,13 +20,13 @@ func cmdDashboards(args []string, stdout, stderr io.Writer) int {
return httpGetJSON(apiURL, "/dashboards", token, stdout, stderr) return httpGetJSON(apiURL, "/dashboards", token, stdout, stderr)
case "get": case "get":
if len(rest) == 0 { if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl dashboards get: missing dashboard id") fmt.Fprintln(stderr, "cairnobsctl dashboards get: missing dashboard id")
return 1 return 1
} }
return httpGetJSON(apiURL, "/dashboards/"+rest[0], token, stdout, stderr) return httpGetJSON(apiURL, "/dashboards/"+rest[0], token, stdout, stderr)
case "apply": case "apply":
if len(rest) == 0 { if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl dashboards apply: missing file path") fmt.Fprintln(stderr, "cairnobsctl dashboards apply: missing file path")
return 1 return 1
} }
// The import endpoint consumes exactly the shape GET // The import endpoint consumes exactly the shape GET
@@ -35,12 +35,12 @@ func cmdDashboards(args []string, stdout, stderr io.Writer) int {
return httpPostFileJSON(apiURL, "/dashboards/import", token, rest[0], stdout, stderr) return httpPostFileJSON(apiURL, "/dashboards/import", token, rest[0], stdout, stderr)
case "permissions": case "permissions":
if len(rest) == 0 { if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl dashboards permissions: expected a subcommand (list, grant, revoke)") fmt.Fprintln(stderr, "cairnobsctl dashboards permissions: expected a subcommand (list, grant, revoke)")
return 1 return 1
} }
return cmdDashboardsPermissions(rest, apiURL, token, stdout, stderr) return cmdDashboardsPermissions(rest, apiURL, token, stdout, stderr)
default: default:
fmt.Fprintf(stderr, "sentryctl dashboards: unknown subcommand %q (want list, get, apply, permissions)\n", args[0]) fmt.Fprintf(stderr, "cairnobsctl dashboards: unknown subcommand %q (want list, get, apply, permissions)\n", args[0])
return 1 return 1
} }
} }
@@ -49,8 +49,8 @@ func cmdDashboards(args []string, stdout, stderr io.Writer) int {
// surface -- PUT/DELETE /dashboards/{id}/permissions/{userId} existed // surface -- PUT/DELETE /dashboards/{id}/permissions/{userId} existed
// with no caller but Go tests and curl until now (see // with no caller but Go tests and curl until now (see
// /docs/phase-4-runbook.md's "Known gaps"). Kept as dashboards' // /docs/phase-4-runbook.md's "Known gaps"). Kept as dashboards'
// own sub-subcommand rather than a flat sentryctl command (like // own sub-subcommand rather than a flat cairnobsctl command (like
// "sentryctl dashboard-permissions grant ...") since a grant only ever // "cairnobsctl dashboard-permissions grant ...") since a grant only ever
// makes sense in the context of one specific dashboard -- args[0] // makes sense in the context of one specific dashboard -- args[0]
// selects list/grant/revoke. // selects list/grant/revoke.
func cmdDashboardsPermissions(args []string, apiURL, token string, stdout, stderr io.Writer) int { func cmdDashboardsPermissions(args []string, apiURL, token string, stdout, stderr io.Writer) int {
@@ -58,13 +58,13 @@ func cmdDashboardsPermissions(args []string, apiURL, token string, stdout, stder
switch sub { switch sub {
case "list": case "list":
if len(rest) == 0 { if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl dashboards permissions list: missing dashboard id") fmt.Fprintln(stderr, "cairnobsctl dashboards permissions list: missing dashboard id")
return 1 return 1
} }
return httpGetJSON(apiURL, "/dashboards/"+rest[0]+"/permissions", token, stdout, stderr) return httpGetJSON(apiURL, "/dashboards/"+rest[0]+"/permissions", token, stdout, stderr)
case "grant": case "grant":
if len(rest) < 3 { if len(rest) < 3 {
fmt.Fprintln(stderr, "sentryctl dashboards permissions grant: usage: grant <dashboard-id> <user-id> <viewer|editor>") fmt.Fprintln(stderr, "cairnobsctl dashboards permissions grant: usage: grant <dashboard-id> <user-id> <viewer|editor>")
return 1 return 1
} }
dashboardID, userID, role := rest[0], rest[1], rest[2] dashboardID, userID, role := rest[0], rest[1], rest[2]
@@ -73,7 +73,7 @@ func cmdDashboardsPermissions(args []string, apiURL, token string, stdout, stder
// only ever raises someone as high as Editor; the server // only ever raises someone as high as Editor; the server
// rejects anything else too, this just fails faster/locally. // rejects anything else too, this just fails faster/locally.
if role != "viewer" && role != "editor" { if role != "viewer" && role != "editor" {
fmt.Fprintf(stderr, "sentryctl dashboards permissions grant: role must be \"viewer\" or \"editor\", got %q\n", role) fmt.Fprintf(stderr, "cairnobsctl dashboards permissions grant: role must be \"viewer\" or \"editor\", got %q\n", role)
return 1 return 1
} }
body := fmt.Sprintf(`{"role":%q}`, role) body := fmt.Sprintf(`{"role":%q}`, role)
@@ -81,14 +81,14 @@ func cmdDashboardsPermissions(args []string, apiURL, token string, stdout, stder
return httpMutateNoBody(http.MethodPut, apiURL, path, token, body, "granted", stdout, stderr) return httpMutateNoBody(http.MethodPut, apiURL, path, token, body, "granted", stdout, stderr)
case "revoke": case "revoke":
if len(rest) < 2 { if len(rest) < 2 {
fmt.Fprintln(stderr, "sentryctl dashboards permissions revoke: usage: revoke <dashboard-id> <user-id>") fmt.Fprintln(stderr, "cairnobsctl dashboards permissions revoke: usage: revoke <dashboard-id> <user-id>")
return 1 return 1
} }
dashboardID, userID := rest[0], rest[1] dashboardID, userID := rest[0], rest[1]
path := "/dashboards/" + dashboardID + "/permissions/" + userID path := "/dashboards/" + dashboardID + "/permissions/" + userID
return httpMutateNoBody(http.MethodDelete, apiURL, path, token, "", "revoked", stdout, stderr) return httpMutateNoBody(http.MethodDelete, apiURL, path, token, "", "revoked", stdout, stderr)
default: default:
fmt.Fprintf(stderr, "sentryctl dashboards permissions: unknown subcommand %q (want list, grant, revoke)\n", sub) fmt.Fprintf(stderr, "cairnobsctl dashboards permissions: unknown subcommand %q (want list, grant, revoke)\n", sub)
return 1 return 1
} }
} }
@@ -9,7 +9,7 @@ import (
) )
// parsePingArgs resolves the api base URL for ping: --api flag wins, then // parsePingArgs resolves the api base URL for ping: --api flag wins, then
// $SENTRYCTL_API_URL, then the hardcoded default. Kept pure (env passed in // $CAIRNOBSCTL_API_URL, then the hardcoded default. Kept pure (env passed in
// as a function) and separate from the HTTP call so it's unit-testable // as a function) and separate from the HTTP call so it's unit-testable
// without a real environment or server. // without a real environment or server.
func parsePingArgs(args []string, env func(string) string) string { func parsePingArgs(args []string, env func(string) string) string {
@@ -28,7 +28,7 @@ type queryArgs struct {
// parseQueryArgs is pure (env passed in, no I/O), same testability // parseQueryArgs is pure (env passed in, no I/O), same testability
// reasoning as parsePingArgs. Non-flag arguments are joined with spaces // reasoning as parsePingArgs. Non-flag arguments are joined with spaces
// to form the query, so `sentryctl query service=api status=500` (no // to form the query, so `cairnobsctl query service=api status=500` (no
// quotes, no shell-special characters) works without requiring users to // quotes, no shell-special characters) works without requiring users to
// quote every query -- though anything using "|" still needs shell // quote every query -- though anything using "|" still needs shell
// quoting regardless, since that's a real shell pipe character otherwise. // quoting regardless, since that's a real shell pipe character otherwise.
@@ -97,7 +97,7 @@ func cmdQuery(args []string, stdout, stderr io.Writer) int {
} }
if strings.TrimSpace(qa.query) == "" { if strings.TrimSpace(qa.query) == "" {
fmt.Fprintln(stderr, "sentryctl query: missing query string") fmt.Fprintln(stderr, "cairnobsctl query: missing query string")
return 1 return 1
} }
return runAndPrintQuery(qa.apiURL, qa.query, qa.language, qa.jsonOut, stdout, stderr) return runAndPrintQuery(qa.apiURL, qa.query, qa.language, qa.jsonOut, stdout, stderr)
@@ -3,7 +3,7 @@
// runbook, and api/localauth's package doc comment for the full // runbook, and api/localauth's package doc comment for the full
// feature). Same list/create/delete shape as agents/dashboards, plus a // feature). Same list/create/delete shape as agents/dashboards, plus a
// "login" subcommand: unlike every other resource this CLI manages, // "login" subcommand: unlike every other resource this CLI manages,
// there's no way to get a first SENTRYCTL_TOKEN without one. // there's no way to get a first CAIRNOBSCTL_TOKEN without one.
package main package main
import ( import (
@@ -18,7 +18,7 @@ import (
func cmdUsers(args []string, stdin io.Reader, stdout, stderr io.Writer) int { func cmdUsers(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if len(args) == 0 { if len(args) == 0 {
fmt.Fprintln(stderr, "sentryctl users: expected a subcommand (login, list, create, delete, reset-password)") fmt.Fprintln(stderr, "cairnobsctl users: expected a subcommand (login, list, create, delete, reset-password)")
return 1 return 1
} }
apiURL, rest := extractAPIFlag(args[1:], os.Getenv) apiURL, rest := extractAPIFlag(args[1:], os.Getenv)
@@ -27,7 +27,7 @@ func cmdUsers(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
switch args[0] { switch args[0] {
case "login": case "login":
if len(rest) == 0 { if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl users login: missing username") fmt.Fprintln(stderr, "cairnobsctl users login: missing username")
return 1 return 1
} }
return cmdUsersLogin(rest[0], rest[1:], apiURL, stdin, stdout, stderr) return cmdUsersLogin(rest[0], rest[1:], apiURL, stdin, stdout, stderr)
@@ -35,24 +35,24 @@ func cmdUsers(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return httpGetJSON(apiURL, "/auth/users", token, stdout, stderr) return httpGetJSON(apiURL, "/auth/users", token, stdout, stderr)
case "create": case "create":
if len(rest) == 0 { if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl users create: missing username") fmt.Fprintln(stderr, "cairnobsctl users create: missing username")
return 1 return 1
} }
return cmdUsersCreate(rest[0], rest[1:], apiURL, token, stdin, stdout, stderr) return cmdUsersCreate(rest[0], rest[1:], apiURL, token, stdin, stdout, stderr)
case "delete": case "delete":
if len(rest) == 0 { if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl users delete: missing user id") fmt.Fprintln(stderr, "cairnobsctl users delete: missing user id")
return 1 return 1
} }
return httpMutateNoBody(http.MethodDelete, apiURL, "/auth/users/"+rest[0], token, "", "user deleted", stdout, stderr) return httpMutateNoBody(http.MethodDelete, apiURL, "/auth/users/"+rest[0], token, "", "user deleted", stdout, stderr)
case "reset-password": case "reset-password":
if len(rest) == 0 { if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl users reset-password: missing user id") fmt.Fprintln(stderr, "cairnobsctl users reset-password: missing user id")
return 1 return 1
} }
return cmdUsersResetPassword(rest[0], rest[1:], apiURL, token, stdin, stdout, stderr) return cmdUsersResetPassword(rest[0], rest[1:], apiURL, token, stdin, stdout, stderr)
default: default:
fmt.Fprintf(stderr, "sentryctl users: unknown subcommand %q (want login, list, create, delete, reset-password)\n", args[0]) fmt.Fprintf(stderr, "cairnobsctl users: unknown subcommand %q (want login, list, create, delete, reset-password)\n", args[0])
return 1 return 1
} }
} }
@@ -81,7 +81,7 @@ func extractPasswordStdinFlag(args []string) (useStdin bool, rest []string) {
// readPasswordFromStdin reads a single line from stdin. Not masked // readPasswordFromStdin reads a single line from stdin. Not masked
// (this codebase has no terminal/raw-mode dependency to draw on -- see // (this codebase has no terminal/raw-mode dependency to draw on -- see
// resolveToken's doc comment for the same tradeoff already accepted for // resolveToken's doc comment for the same tradeoff already accepted for
// SENTRYCTL_TOKEN); pipe the value in (`echo "$PW" | sentryctl users // CAIRNOBSCTL_TOKEN); pipe the value in (`echo "$PW" | cairnobsctl users
// login admin`) rather than typing it at an interactive terminal where // login admin`) rather than typing it at an interactive terminal where
// that matters. // that matters.
func readPasswordFromStdin(stdin io.Reader) (string, error) { func readPasswordFromStdin(stdin io.Reader) (string, error) {
@@ -103,7 +103,7 @@ type loginResponseBody struct {
} }
// cmdUsersLogin prints only the raw token to stdout on success (nothing // cmdUsersLogin prints only the raw token to stdout on success (nothing
// else) -- deliberately pipeable: `export SENTRYCTL_TOKEN=$(sentryctl // else) -- deliberately pipeable: `export CAIRNOBSCTL_TOKEN=$(cairnobsctl
// users login admin)`. // users login admin)`.
func cmdUsersLogin(username string, _ []string, apiURL string, stdin io.Reader, stdout, stderr io.Writer) int { func cmdUsersLogin(username string, _ []string, apiURL string, stdin io.Reader, stdout, stderr io.Writer) int {
password, err := readPasswordFromStdin(stdin) password, err := readPasswordFromStdin(stdin)
@@ -39,7 +39,7 @@ func TestCmdUsersLoginPrintsOnlyTheToken(t *testing.T) {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String()) t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
} }
if got := strings.TrimSpace(stdout.String()); got != "abc123" { if got := strings.TrimSpace(stdout.String()); got != "abc123" {
t.Fatalf("stdout = %q, want exactly the raw token (pipeable into SENTRYCTL_TOKEN)", got) t.Fatalf("stdout = %q, want exactly the raw token (pipeable into CAIRNOBSCTL_TOKEN)", got)
} }
} }
@@ -59,7 +59,7 @@ func TestCmdUsersLoginFailure(t *testing.T) {
t.Fatalf("stderr = %q, want it to surface the server's error message", stderr.String()) t.Fatalf("stderr = %q, want it to surface the server's error message", stderr.String())
} }
if stdout.String() != "" { if stdout.String() != "" {
t.Fatalf("stdout = %q, want empty on failure (nothing pipeable into SENTRYCTL_TOKEN)", stdout.String()) t.Fatalf("stdout = %q, want empty on failure (nothing pipeable into CAIRNOBSCTL_TOKEN)", stdout.String())
} }
} }
@@ -13,7 +13,7 @@ import (
var httpClient = &http.Client{Timeout: 30 * time.Second} var httpClient = &http.Client{Timeout: 30 * time.Second}
// setAuth attaches SENTRYCTL_TOKEN (see resolveToken) as a Bearer // setAuth attaches CAIRNOBSCTL_TOKEN (see resolveToken) as a Bearer
// credential, a no-op when token is empty -- matches every backend's // credential, a no-op when token is empty -- matches every backend's
// nil-authorizer no-op default (see api/internal/authz.RequireRole*). // nil-authorizer no-op default (see api/internal/authz.RequireRole*).
func setAuth(req *http.Request, token string) { func setAuth(req *http.Request, token string) {

Some files were not shown because too many files have changed in this diff Show More