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
+330
View File
@@ -0,0 +1,330 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Deserializer};
use std::path::{Path, PathBuf};
use std::time::Duration;
#[cfg(not(windows))]
const DEFAULT_CONFIG_PATH: &str = "/etc/cairnobs-agent/agent.toml";
#[cfg(windows)]
const DEFAULT_CONFIG_PATH: &str = r"C:\ProgramData\CairnObsAgent\agent.toml";
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
pub struct Config {
pub agent: AgentConfig,
pub source: SourceConfig,
pub batch: BatchConfig,
pub heartbeat: HeartbeatConfig,
pub metrics: MetricsConfig,
pub ingest: IngestConfig,
pub tls: TlsConfig,
}
impl Config {
/// Loads config from `explicit_path` if given, else from the
/// platform's conventional config path if it exists
/// (`/etc/cairnobs-agent/agent.toml` on Linux,
/// `C:\ProgramData\CairnObsAgent\agent.toml` on Windows), else falls
/// back to built-in defaults (journald source on Linux, default TLS
/// cert paths). Only an explicitly-passed `--config` path that doesn't
/// exist is an error; the conventional default path is optional.
pub fn load(explicit_path: Option<&Path>) -> Result<Config> {
let path = match explicit_path {
Some(p) => Some(p.to_path_buf()),
None => {
let default = PathBuf::from(DEFAULT_CONFIG_PATH);
default.exists().then_some(default)
}
};
match path {
Some(p) => {
let raw = std::fs::read_to_string(&p)
.with_context(|| format!("reading config file {}", p.display()))?;
toml::from_str(&raw).with_context(|| format!("parsing config file {}", p.display()))
}
None => Ok(Config::default()),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct AgentConfig {
/// Overrides the auto-detected system hostname. Defaults to reading
/// /etc/hostname at startup when unset.
pub host: Option<String>,
pub service: String,
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
host: None,
service: "default".to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "lowercase", tag = "kind")]
pub enum SourceConfig {
Journald {
#[serde(default)]
#[cfg_attr(not(all(feature = "journald", target_os = "linux")), allow(dead_code))]
unit: Option<String>,
},
// Dead-code-on-default-build, same reasoning as EventLog/Etw below:
// these fields are only read by the `file-tail`-gated arm in
// spawn_source (main.rs), which doesn't exist in the default build
// (`default = ["journald"]`). Pre-existing gap from Phase 0 — CLAUDE.md
// mandates plain `cargo clippy --all-targets -- -D warnings` (no
// --all-features), which this broke silently since only
// --all-features clippy was ever actually run.
File {
#[cfg_attr(not(feature = "file-tail"), allow(dead_code))]
path: PathBuf,
#[serde(default)]
#[cfg_attr(not(feature = "file-tail"), allow(dead_code))]
from_beginning: bool,
},
/// Windows Event Log via EvtSubscribe. Requires the agent to be built
/// with the `windows-eventlog` feature; see /agent/README.md.
///
/// `channels`/`providers` below are read only by the Windows-only
/// consumers in `spawn_source` (main.rs), which don't exist at all on
/// non-Windows builds — unlike `File`'s fields (dead only when the
/// `file-tail` feature happens to be off), these are dead on *every*
/// non-Windows build regardless of feature flags, since their sole
/// consumer is `target_os = "windows"`-gated. `cfg_attr` here keeps
/// clippy honest: still flags genuine dead code on an actual Windows
/// build, just not on the platform where these fields can never be
/// read no matter what.
EventLog {
#[serde(default = "default_eventlog_channels")]
#[cfg_attr(not(windows), allow(dead_code))]
channels: Vec<String>,
},
/// ETW (Event Tracing for Windows). Requires the `etw` feature and
/// (usually) elevated privileges — see /agent/README.md before
/// enabling this in any environment that isn't Windows-first.
Etw {
#[cfg_attr(not(windows), allow(dead_code))]
providers: Vec<String>,
},
}
fn default_eventlog_channels() -> Vec<String> {
vec![
"Application".to_string(),
"System".to_string(),
"Security".to_string(),
]
}
impl Default for SourceConfig {
fn default() -> Self {
SourceConfig::Journald { unit: None }
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct BatchConfig {
pub max_size: usize,
pub flush_interval_ms: u64,
}
impl Default for BatchConfig {
fn default() -> Self {
Self {
max_size: 500,
flush_interval_ms: 2000,
}
}
}
/// Sent independently of `batch` -- a heartbeat is a punctual liveness
/// signal, not log data, so it bypasses `Batcher` entirely (see
/// main.rs's `send_heartbeat`) rather than waiting on `max_size`/
/// `flush_interval_ms` like real records do. This is the operator-facing
/// "polling resolution" knob: how often this agent proves it's still
/// alive, which a `condition_type = "absence"` alert rule on the
/// `cairnobs.heartbeat` attribute (see /docs/agent-heartbeat-monitoring.md)
/// turns into "alert when this host goes quiet."
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct HeartbeatConfig {
pub enabled: bool,
#[serde(deserialize_with = "deserialize_duration")]
pub interval: Duration,
}
impl Default for HeartbeatConfig {
fn default() -> Self {
Self {
enabled: true,
interval: Duration::from_secs(60),
}
}
}
/// Off by default -- same "off unless configured" posture as every other
/// optional feature in this codebase -- since collecting host metrics is
/// a deliberate per-host decision (see /agent/README.md's Hosts-feature
/// notes: only one agent process per physical host should have this on,
/// to avoid duplicate/fragmented metric series when several agent
/// processes share a host under different `[agent] host` overrides).
/// Sent independently of `batch`, same reasoning and mechanism as
/// `HeartbeatConfig` above (see main.rs's `send_metrics`).
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct MetricsConfig {
pub enabled: bool,
#[serde(deserialize_with = "deserialize_duration")]
pub interval: Duration,
}
impl Default for MetricsConfig {
fn default() -> Self {
Self {
enabled: false,
interval: Duration::from_secs(60),
}
}
}
/// Parses a human-friendly duration string with an explicit unit suffix
/// -- "30s", "5m", "1h" -- deliberately the same s/m/h vocabulary
/// `earliest=`/`latest=` use in the query language
/// (/docs/query-language-reference.md), so the interval you set here and
/// the window you write in the matching alert rule's query read the same
/// way. Kept as a small hand-rolled parser rather than pulling in a
/// duration-parsing crate for this one field -- this is the
/// statically-linked edge agent every "no glibc runtime deps" constraint
/// in CLAUDE.md is about keeping lean, and the grammar needed here is a
/// handful of lines.
fn deserialize_duration<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
parse_duration(&s).map_err(serde::de::Error::custom)
}
fn parse_duration(s: &str) -> Result<Duration, String> {
let s = s.trim();
let (num, unit) = s.split_at(s.len().saturating_sub(1));
let n: u64 = num
.parse()
.map_err(|_| format!("expected a duration like \"30s\", \"5m\", or \"1h\", got {s:?}"))?;
match unit {
"s" => Ok(Duration::from_secs(n)),
"m" => Ok(Duration::from_secs(n * 60)),
"h" => Ok(Duration::from_secs(n * 3600)),
_ => Err(format!("expected a time unit of s, m, or h after {n}, got {s:?}")),
}
}
#[cfg(test)]
mod heartbeat_config_tests {
use super::*;
#[test]
fn parses_seconds_minutes_hours() {
assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
}
#[test]
fn rejects_missing_or_unknown_unit() {
assert!(parse_duration("30").is_err());
assert!(parse_duration("30x").is_err());
assert!(parse_duration("").is_err());
}
#[test]
fn default_is_60_seconds_and_enabled() {
let cfg = HeartbeatConfig::default();
assert!(cfg.enabled);
assert_eq!(cfg.interval, Duration::from_secs(60));
}
#[test]
fn toml_field_parses_via_deserialize() {
#[derive(Deserialize)]
struct Wrapper {
#[serde(default)]
heartbeat: HeartbeatConfig,
}
let w: Wrapper = toml::from_str("[heartbeat]\nenabled = true\ninterval = \"90s\"\n").unwrap();
assert_eq!(w.heartbeat.interval, Duration::from_secs(90));
}
}
#[cfg(test)]
mod metrics_config_tests {
use super::*;
#[test]
fn default_is_60_seconds_and_disabled() {
let cfg = MetricsConfig::default();
assert!(!cfg.enabled);
assert_eq!(cfg.interval, Duration::from_secs(60));
}
#[test]
fn toml_field_parses_via_deserialize() {
#[derive(Deserialize)]
struct Wrapper {
#[serde(default)]
metrics: MetricsConfig,
}
let w: Wrapper = toml::from_str("[metrics]\nenabled = true\ninterval = \"30s\"\n").unwrap();
assert!(w.metrics.enabled);
assert_eq!(w.metrics.interval, Duration::from_secs(30));
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct IngestConfig {
pub endpoint: String,
}
impl Default for IngestConfig {
fn default() -> Self {
Self {
endpoint: "https://127.0.0.1:4317".to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct TlsConfig {
pub ca_cert: PathBuf,
pub client_cert: PathBuf,
pub client_key: PathBuf,
}
impl Default for TlsConfig {
fn default() -> Self {
Self {
ca_cert: default_cert_path("ca.pem"),
client_cert: default_cert_path("client.pem"),
client_key: default_cert_path("client-key.pem"),
}
}
}
#[cfg(not(windows))]
fn default_cert_path(name: &str) -> PathBuf {
PathBuf::from(format!("/etc/cairnobs-agent/{name}"))
}
#[cfg(windows)]
fn default_cert_path(name: &str) -> PathBuf {
PathBuf::from(format!(r"C:\ProgramData\CairnObsAgent\{name}"))
}