use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener;
use crate::auth::{AuthConfig, AuthContext};
use crate::cli::error::CliError;
use crate::config::Config;
use crate::error::Error;
use crate::signing_key::SigningKey;
use crate::{
admin_router, create_report_router, did_document_router, health_router, spawn_writer, storage,
subscribe_router, wellknown_router,
};
const DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
pub async fn run<F>(config: Config, shutdown: F) -> Result<(), CliError>
where
F: std::future::Future<Output = ()> + Send + 'static,
{
config
.validate()
.map_err(|e| CliError::Config(e.to_string()))?;
let key = SigningKey::load_from_file(&config.signing_key_path)?;
let pool = storage::open(&config.db_path)
.await
.map_err(|e| CliError::MigrationFailed(e.to_string()))?;
let writer = spawn_writer(
pool.clone(),
key,
config.service_did.clone(),
crate::SubscribeConfig::default().retention_days,
config.retention.clone().into(),
)
.await
.map_err(map_spawn_writer_error)?;
if let Err(verify_err) = verify::verify_service_record(&config).await {
if let Err(e) = writer.shutdown().await {
tracing::warn!(error = %e, "writer shutdown failed during verify-induced exit");
}
return Err(verify_err);
}
let auth = Arc::new(AuthContext::new(AuthConfig {
service_did: config.service_did.clone(),
..AuthConfig::default()
}));
let router = admin_router(
pool.clone(),
writer.clone(),
auth.clone(),
config.admin.clone().into(),
)
.merge(create_report_router(
pool.clone(),
auth.clone(),
crate::CreateReportConfig {
db_path: config.db_path.clone(),
..crate::CreateReportConfig::default()
},
))
.merge(subscribe_router(
pool.clone(),
writer.clone(),
crate::SubscribeConfig::default(),
))
.merge(wellknown_router())
.merge(did_document_router(pool.clone(), config.clone()))
.merge(health_router(pool.clone(), writer.clone()));
let listener = TcpListener::bind(config.bind_addr)
.await
.map_err(|source| CliError::BindFailed {
addr: config.bind_addr,
source,
})?;
let local_addr = listener.local_addr().unwrap_or(config.bind_addr);
tracing::info!(
bind_addr = %local_addr,
service_did = %config.service_did,
"cairn listening; lease acquired"
);
let (drain_start_tx, drain_start_rx) = tokio::sync::oneshot::channel::<()>();
let shutdown_wrapper = async move {
shutdown.await;
let _ = drain_start_tx.send(());
};
let serve_fut = axum::serve(
listener,
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(shutdown_wrapper);
let drain_timer = async move {
match drain_start_rx.await {
Ok(()) => tokio::time::sleep(DRAIN_TIMEOUT).await,
Err(_) => std::future::pending::<()>().await,
}
};
enum Outcome {
Clean,
AxumError(std::io::Error),
DrainTimeout,
}
let outcome = tokio::select! {
res = serve_fut => match res {
Ok(()) => Outcome::Clean,
Err(e) => Outcome::AxumError(e),
},
_ = drain_timer => Outcome::DrainTimeout,
};
if let Err(e) = writer.shutdown().await {
tracing::warn!(error = %e, "writer shutdown failed during serve exit");
}
match outcome {
Outcome::Clean => Ok(()),
Outcome::AxumError(e) => Err(CliError::Startup(format!("axum serve error: {e}"))),
Outcome::DrainTimeout => {
tracing::warn!(
drain_timeout_secs = DRAIN_TIMEOUT.as_secs(),
"drain timeout exceeded after shutdown signal; forcing exit"
);
Ok(())
}
}
}
fn map_spawn_writer_error(e: Error) -> CliError {
match e {
Error::LeaseHeld {
instance_id,
age_secs,
} => CliError::LeaseConflict {
instance_id,
age_secs,
},
other => CliError::Startup(format!("writer spawn: {other}")),
}
}
mod verify {
use crate::cli::error::CliError;
use crate::cli::pds::{PdsClient, PdsError};
use crate::config::Config;
use crate::service_record::{self, RECORD_COLLECTION, RECORD_RKEY};
use serde_json::Value;
pub(super) async fn verify_service_record(config: &Config) -> Result<(), CliError> {
let Some(labeler_cfg) = config.labeler.as_ref() else {
tracing::info!(
"no [labeler] config block — skipping service record verify (#8 narrow scope)"
);
return Ok(());
};
let operator_cfg = config.operator.as_ref().ok_or_else(|| {
CliError::Config(
"[labeler] is configured but [operator] is missing — verify needs operator.pds_url"
.into(),
)
})?;
let local_record = service_record::render(labeler_cfg, "1970-01-01T00:00:00.000Z")
.map_err(|e| CliError::Config(format!("could not render local service record: {e}")))?;
let local_hash = service_record::content_hash(&local_record);
let pds = PdsClient::new(&operator_cfg.pds_url).map_err(|e| {
CliError::ServiceRecordUnreachable {
pds_url: operator_cfg.pds_url.clone(),
cause: e.to_string(),
}
})?;
let fetched = match pds
.get_record(&config.service_did, RECORD_COLLECTION, RECORD_RKEY)
.await
{
Ok(Some(r)) => r,
Ok(None) => {
return Err(CliError::ServiceRecordAbsent {
pds_url: operator_cfg.pds_url.clone(),
service_did: config.service_did.clone(),
});
}
Err(PdsError::Network { source, .. }) => {
return Err(CliError::ServiceRecordUnreachable {
pds_url: operator_cfg.pds_url.clone(),
cause: source.to_string(),
});
}
Err(other) => {
return Err(CliError::ServiceRecordUnreachable {
pds_url: operator_cfg.pds_url.clone(),
cause: other.to_string(),
});
}
};
let pds_hash = service_record::content_hash_value(fetched.value.clone());
if local_hash == pds_hash {
tracing::info!(
cid = ?fetched.cid,
"service record verified: local config matches PDS"
);
return Ok(());
}
let summary = drift_summary(&local_record, &fetched.value);
tracing::error!(
pds_url = %operator_cfg.pds_url,
"service record drift detected — see error for details"
);
Err(CliError::ServiceRecordDrift {
pds_url: operator_cfg.pds_url.clone(),
service_did: config.service_did.clone(),
summary,
})
}
fn drift_summary(local: &service_record::ServiceRecord, pds: &Value) -> String {
use std::fmt::Write;
let mut out = String::new();
let local_lv = &local.policies.label_values;
let pds_lv = pds_label_values(pds);
if local_lv != &pds_lv {
let _ = writeln!(out, " - label values:");
let _ = writeln!(out, " local: {local_lv:?}");
let _ = writeln!(out, " published: {pds_lv:?}");
}
let local_defs = local.policies.label_value_definitions.len();
let pds_defs = pds_definition_count(pds);
if local_defs != pds_defs {
let _ = writeln!(out, " - label value definitions:");
let _ = writeln!(out, " local: {local_defs} entries");
let _ = writeln!(out, " published: {pds_defs} entries");
}
let local_rt = &local.reason_types;
let pds_rt = pds_string_array(pds, "reasonTypes");
if local_rt != &pds_rt {
let _ = writeln!(out, " - reason types:");
let _ = writeln!(out, " local: {local_rt:?}");
let _ = writeln!(out, " published: {pds_rt:?}");
}
let local_st = &local.subject_types;
let pds_st = pds_string_array(pds, "subjectTypes");
if local_st != &pds_st {
let _ = writeln!(out, " - subject types:");
let _ = writeln!(out, " local: {local_st:?}");
let _ = writeln!(out, " published: {pds_st:?}");
}
if out.is_empty() {
out.push_str(
" - per-label definition contents (severity / blurs / locales) differ; \
inspect the published record alongside the local config to identify which.\n",
);
}
if out.ends_with('\n') {
out.pop();
}
out
}
fn pds_label_values(v: &Value) -> Vec<String> {
v.get("policies")
.and_then(|p| p.get("labelValues"))
.and_then(|x| x.as_array())
.map(|a| {
a.iter()
.filter_map(|s| s.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
fn pds_definition_count(v: &Value) -> usize {
v.get("policies")
.and_then(|p| p.get("labelValueDefinitions"))
.and_then(|x| x.as_array())
.map(|a| a.len())
.unwrap_or(0)
}
fn pds_string_array(v: &Value, key: &str) -> Vec<String> {
v.get(key)
.and_then(|x| x.as_array())
.map(|a| {
a.iter()
.filter_map(|s| s.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{
BlursToml, LabelValueDefinitionToml, LabelerConfigToml, LocaleToml, SeverityToml,
};
fn sample_cfg() -> LabelerConfigToml {
LabelerConfigToml {
label_values: vec!["spam".into()],
label_value_definitions: vec![LabelValueDefinitionToml {
identifier: "spam".into(),
severity: SeverityToml::Alert,
blurs: BlursToml::None,
default_setting: None,
adult_only: None,
locales: vec![LocaleToml {
lang: "en".into(),
name: "Spam".into(),
description: "x".into(),
}],
}],
reason_types: vec![],
subject_types: vec!["account".into()],
subject_collections: vec![],
}
}
#[test]
fn drift_summary_label_values_differ() {
let local = service_record::render(&sample_cfg(), "1970-01-01T00:00:00.000Z").unwrap();
let pds_value = serde_json::json!({
"policies": { "labelValues": ["other"] },
});
let s = drift_summary(&local, &pds_value);
assert!(s.contains("label values"));
assert!(s.contains("\"spam\""));
assert!(s.contains("\"other\""));
assert!(!s.contains("reason types"), "no drift on reasonTypes here");
}
#[test]
fn drift_summary_definition_count_differs() {
let local = service_record::render(&sample_cfg(), "1970-01-01T00:00:00.000Z").unwrap();
let pds_value = serde_json::json!({
"policies": {
"labelValues": ["spam"],
"labelValueDefinitions": [],
},
"subjectTypes": ["account"],
});
let s = drift_summary(&local, &pds_value);
assert!(s.contains("label value definitions"));
assert!(s.contains("local: 1 entries"));
assert!(s.contains("published: 0 entries"));
}
#[test]
fn drift_summary_falls_back_when_no_top_level_field_differs() {
let local = service_record::render(&sample_cfg(), "1970-01-01T00:00:00.000Z").unwrap();
let pds_value = serde_json::json!({
"policies": {
"labelValues": ["spam"],
"labelValueDefinitions": [{
"identifier": "spam",
"severity": "inform",
"blurs": "content",
"locales": [{ "lang": "fr", "name": "Spam", "description": "y" }],
}],
},
"subjectTypes": ["account"],
});
let s = drift_summary(&local, &pds_value);
assert!(
s.contains("per-label definition contents"),
"fallback message expected; got: {s}"
);
}
}
}