use anyhow::{Context, Result};
use figment::{Figment, providers::Serialized};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::time::sleep;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
use uuid::Uuid;
use super::config::{
AppConfig, CliArgs, LoggingConfig, RenderedDbConfig, RenderedGearConfig,
TOOLKIT_MODULE_CONFIG_ENV,
};
use crate::bootstrap::host::{init_logging_unified, init_panic_tracing};
use crate::runtime::{
ClientRegistration, DbOptions, OopServeOptions, RunOptions, ShutdownOptions,
TOOLKIT_DIRECTORY_ENDPOINT_ENV, run, run_oop_serving, shutdown,
};
use cf_system_sdks::directory::{DirectoryClient, DirectoryGrpcClient};
#[derive(Debug, Clone)]
pub struct OopRunOptions {
pub gear_name: String,
pub instance_id: Option<Uuid>,
pub directory_endpoint: String,
pub config_path: Option<PathBuf>,
pub verbose: u8,
pub print_config: bool,
pub heartbeat_interval_secs: u64,
pub version: Option<String>,
}
impl Default for OopRunOptions {
fn default() -> Self {
let config_path = std::env::var("TOOLKIT_CONFIG_PATH").ok().map(PathBuf::from);
let directory_endpoint = std::env::var(TOOLKIT_DIRECTORY_ENDPOINT_ENV)
.unwrap_or_else(|_| "http://127.0.0.1:50051".to_owned());
Self {
gear_name: String::new(),
instance_id: None,
directory_endpoint,
config_path,
verbose: 0,
print_config: false,
heartbeat_interval_secs: 5,
version: None,
}
}
}
#[tracing::instrument(
level = "debug",
skip(local_config, rendered_config),
fields(
has_rendered = rendered_config.is_some(),
has_local_db = local_config.database.is_some()
)
)]
fn build_oop_config_and_db(
local_config: &AppConfig,
gear_name: &str,
rendered_config: Option<&RenderedGearConfig>,
) -> Result<(AppConfig, LoggingConfig, DbOptions)> {
let home_dir = PathBuf::from(&local_config.server.home_dir);
let final_config = if let Some(rendered) = rendered_config {
let mut config = local_config.clone();
let gear_entry = config
.gears
.entry(gear_name.to_owned())
.or_insert_with(|| serde_json::json!({}));
if let Some(obj) = gear_entry.as_object_mut() {
if !obj.contains_key("config") || obj["config"].is_null() {
obj.insert("config".to_owned(), rendered.config.clone());
}
}
debug!(
gear = %gear_name,
has_rendered_db = %rendered.database.is_some(),
has_rendered_logging = %rendered.logging.is_some(),
"Using rendered config from master as base, local config as override"
);
config
} else {
debug!(
gear = %gear_name,
"No rendered config from master, using local config entirely (standalone mode)"
);
local_config.clone()
};
let final_logging = merge_logging_configs(
rendered_config.as_ref().and_then(|r| r.logging.as_ref()),
&local_config.logging,
);
let db_options = build_merged_db_options(
&home_dir,
gear_name,
rendered_config.as_ref().and_then(|r| r.database.as_ref()),
local_config,
)?;
Ok((final_config, final_logging, db_options))
}
fn merge_logging_configs(master: Option<&LoggingConfig>, local: &LoggingConfig) -> LoggingConfig {
master
.cloned()
.unwrap_or_default()
.into_iter()
.chain(local.clone())
.collect()
}
fn build_merged_db_options(
home_dir: &Path,
gear_name: &str,
rendered_db: Option<&RenderedDbConfig>,
local_config: &AppConfig,
) -> Result<DbOptions> {
let has_rendered_db = rendered_db.is_some_and(|db| db.gear.is_some() || db.global.is_some());
let has_local_db = local_config.database.is_some()
|| local_config
.gears
.get(gear_name)
.and_then(|m| m.get("database"))
.is_some();
if !has_rendered_db && !has_local_db {
debug!(
gear = %gear_name,
"No database config available"
);
return Ok(DbOptions::None);
}
let mut merged_config = serde_json::Map::new();
if let Some(rendered) = rendered_db {
if let Some(ref global) = rendered.global {
let global_json = serde_json::to_value(global)
.context("Failed to serialize rendered global db config")?;
merged_config.insert("database".to_owned(), global_json);
}
if let Some(ref gear_db) = rendered.gear {
let gear_db_json = serde_json::to_value(gear_db)
.context("Failed to serialize rendered gear db config")?;
let mut gears = serde_json::Map::new();
let mut gear_entry = serde_json::Map::new();
gear_entry.insert("database".to_owned(), gear_db_json);
gears.insert(gear_name.to_owned(), serde_json::Value::Object(gear_entry));
merged_config.insert("gears".to_owned(), serde_json::Value::Object(gears));
}
}
if let Some(ref local_db) = local_config.database {
let local_db_json =
serde_json::to_value(local_db).context("Failed to serialize local global db config")?;
if let Some(existing) = merged_config.get_mut("database") {
merge_json_objects(existing, &local_db_json);
} else {
merged_config.insert("database".to_owned(), local_db_json);
}
}
if let Some(local_gear) = local_config.gears.get(gear_name)
&& let Some(local_gear_db) = local_gear.get("database")
{
let gears = merged_config
.entry("gears".to_owned())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if let Some(gears_obj) = gears.as_object_mut() {
let gear_entry = gears_obj
.entry(gear_name.to_owned())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if let Some(gear_obj) = gear_entry.as_object_mut() {
if let Some(existing_db) = gear_obj.get_mut("database") {
merge_json_objects(existing_db, local_gear_db);
} else {
gear_obj.insert("database".to_owned(), local_gear_db.clone());
}
}
}
}
debug!(
gear = %gear_name,
has_rendered = %rendered_db.is_some(),
has_local_global = %local_config.database.is_some(),
"Building DbManager with merged config"
);
let figment = Figment::new().merge(Serialized::defaults(serde_json::Value::Object(
merged_config,
)));
let db_manager = Arc::new(
toolkit_db::DbManager::from_figment(figment, home_dir.to_path_buf())
.context("Failed to create DbManager from merged config")?,
);
Ok(DbOptions::Manager(db_manager))
}
fn merge_json_objects(target: &mut serde_json::Value, source: &serde_json::Value) {
if let (Some(target_obj), Some(source_obj)) = (target.as_object_mut(), source.as_object()) {
for (key, value) in source_obj {
if let Some(target_value) = target_obj.get_mut(key) {
if target_value.is_object() && value.is_object() {
merge_json_objects(target_value, value);
} else {
*target_value = value.clone();
}
} else {
target_obj.insert(key.clone(), value.clone());
}
}
} else {
*target = source.clone();
}
}
#[tracing::instrument(
level = "info",
name = "oop_bootstrap",
skip(opts),
fields(
gear = %opts.gear_name,
directory = %opts.directory_endpoint
)
)]
pub async fn run_oop_with_options(opts: OopRunOptions) -> Result<()> {
let instance_id = opts.instance_id.unwrap_or_else(Uuid::new_v4);
let cancel = CancellationToken::new();
let cancel_for_signals = cancel.clone();
tokio::spawn(async move {
match shutdown::wait_for_shutdown().await {
Ok(()) => {
info!(target: "", "------------------");
info!("shutdown: signal received in OoP bootstrap");
}
Err(e) => {
warn!(
error = %e,
"shutdown: primary waiter failed in OoP bootstrap, falling back to ctrl_c()"
);
_ = tokio::signal::ctrl_c().await;
}
}
cancel_for_signals.cancel();
});
let args = CliArgs {
config: opts
.config_path
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
print_config: opts.print_config,
verbose: opts.verbose,
mock: false,
};
let mut config = AppConfig::load_or_default(opts.config_path.as_ref())?;
config.apply_cli_overrides(args.verbose);
let rendered_config = match std::env::var(TOOLKIT_MODULE_CONFIG_ENV) {
Ok(json) => RenderedGearConfig::from_json(&json).ok(),
Err(_) => None,
};
let (final_config, merged_logging, db_options) =
build_oop_config_and_db(&config, &opts.gear_name, rendered_config.as_ref())?;
#[cfg(feature = "otel")]
let otel_cfg = rendered_config
.as_ref()
.and_then(|rc| rc.opentelemetry.as_ref());
#[cfg(feature = "otel")]
let otel_layer = otel_cfg
.filter(|cfg| cfg.tracing.enabled)
.map(crate::telemetry::init_tracing)
.transpose()?;
#[cfg(not(feature = "otel"))]
let otel_layer = None;
#[cfg(feature = "otel")]
let metrics_init_error = otel_cfg
.filter(|cfg| cfg.metrics.enabled)
.and_then(|cfg| crate::telemetry::init::init_metrics_provider(cfg).err());
#[cfg(feature = "otel")]
let inject_trace_ids =
otel_cfg.is_some_and(crate::telemetry::OpenTelemetryConfig::inject_trace_ids_into_logs);
#[cfg(not(feature = "otel"))]
let inject_trace_ids = false;
init_logging_unified(
&merged_logging,
&config.server.home_dir,
otel_layer,
inject_trace_ids,
);
#[cfg(feature = "otel")]
if let Some(e) = metrics_init_error {
tracing::error!(error = %e, "OpenTelemetry metrics not initialized (OoP)");
}
init_panic_tracing();
if let Some(ref rc) = rendered_config {
info!(
env_var = TOOLKIT_MODULE_CONFIG_ENV,
has_database = rc.database.is_some(),
has_config = !rc.config.is_null(),
has_logging = rc.logging.is_some(),
has_opentelemetry = rc.opentelemetry.is_some(),
"Received rendered config from master host"
);
} else if std::env::var(TOOLKIT_MODULE_CONFIG_ENV).is_ok() {
warn!(
env_var = TOOLKIT_MODULE_CONFIG_ENV,
"Failed to parse rendered config from master host, using local config only"
);
} else {
debug!(
env_var = TOOLKIT_MODULE_CONFIG_ENV,
"No rendered config from master host, using local config only"
);
}
info!(
gear = %opts.gear_name,
instance_id = %instance_id,
directory_endpoint = %opts.directory_endpoint,
"OoP gear bootstrap starting"
);
if opts.print_config {
print_config(&config);
return Ok(());
}
info!(
"Creating directory service client (lazy connect) for {}",
opts.directory_endpoint
);
let internal_auth_cfg = final_config
.oop_http
.as_ref()
.and_then(|h| h.internal_auth.as_ref());
let (directory_client, internal_token_provider) =
build_directory_client(&opts.directory_endpoint, internal_auth_cfg, &cancel).await?;
let directory_api: Arc<dyn DirectoryClient> = Arc::new(directory_client);
info!("Directory service client ready (will connect on first use)");
let oop_http = final_config.oop_http.clone();
let config_provider = Arc::new(final_config);
let run_options = RunOptions::new(
config_provider,
db_options,
ShutdownOptions::Token(cancel.clone()),
instance_id,
)
.with_clients(vec![ClientRegistration::new::<dyn DirectoryClient>(
Arc::clone(&directory_api),
)])
.with_internal_token_provider(internal_token_provider);
let result = if let Some(http_cfg) = oop_http {
info!("Starting OoP HTTP-serving lifecycle");
let serve = build_oop_serve_options(
&http_cfg,
&opts.gear_name,
instance_id,
opts.version.clone(),
Duration::from_secs(opts.heartbeat_interval_secs),
Arc::clone(&directory_api),
)
.await?;
run_oop_serving(run_options, serve).await
} else {
info!("Starting gear lifecycle (legacy gRPC-only)");
let heartbeat_directory = Arc::clone(&directory_api);
let heartbeat_gear = opts.gear_name.clone();
let heartbeat_instance_id_str = instance_id.to_string();
let heartbeat_interval = Duration::from_secs(opts.heartbeat_interval_secs.max(1));
let heartbeat_cancel = cancel.child_token();
tokio::spawn(async move {
info!(interval_secs = ?heartbeat_interval, "Starting legacy heartbeat loop");
loop {
tokio::select! {
() = heartbeat_cancel.cancelled() => {
info!("Heartbeat loop stopping due to cancellation");
break;
}
() = sleep(heartbeat_interval) => {
if let Err(e) = heartbeat_directory
.send_heartbeat(&heartbeat_gear, &heartbeat_instance_id_str)
.await
{
warn!(error = %e, "Failed to send heartbeat, will retry");
}
}
}
}
});
run(run_options).await
};
if let Err(ref e) = result {
error!(error = %e, "Gear runtime failed");
} else {
info!("Gear runtime completed successfully");
}
#[cfg(feature = "otel")]
crate::bootstrap::run::tracing_shutdown().await;
result
}
async fn build_oop_serve_options(
cfg: &super::config::OopHttpConfig,
gear_name: &str,
instance_id: Uuid,
version: Option<String>,
heartbeat_interval: Duration,
directory: Arc<dyn DirectoryClient>,
) -> Result<OopServeOptions> {
let listen_addr: std::net::SocketAddr = cfg
.listen_addr
.parse()
.with_context(|| format!("invalid oop_http.listen_addr: {}", cfg.listen_addr))?;
let probe_bind_addr = cfg
.probe_bind_addr
.as_deref()
.map(|s| {
s.parse::<std::net::SocketAddr>()
.with_context(|| format!("invalid oop_http.probe_bind_addr: {s}"))
})
.transpose()?;
let advertise_uri = cfg
.advertise_uri
.clone()
.unwrap_or_else(|| default_advertise_uri(listen_addr));
validate_advertise_uri(&advertise_uri, cfg.allow_loopback_advertise)?;
cf_system_sdks::directory::validate_labels(&cfg.labels).with_context(|| {
"invalid oop_http.labels: label keys/values must be <=63 chars, <=64 entries, and use \
only ASCII alphanumerics plus '-', '_', '.' (starting and ending alphanumeric)"
})?;
let internal_authenticator = build_internal_authenticator(cfg.internal_auth.as_ref()).await?;
Ok(OopServeOptions {
gear_name: gear_name.to_owned(),
instance_id: instance_id.to_string(),
version,
advertise_uri,
listen_addr,
probe_bind_addr,
drain_timeout: Duration::from_secs(cfg.drain_timeout_secs),
heartbeat_interval,
healthcheck_timeout: Duration::from_millis(cfg.healthcheck_timeout_ms),
directory,
bearer_authenticator: None,
internal_authenticator,
labels: cfg.labels.clone(),
})
}
#[cfg_attr(not(feature = "k8s-auth"), allow(clippy::unused_async))]
async fn build_internal_authenticator(
cfg: Option<&toolkit_security::InternalAuthConfig>,
) -> Result<Option<toolkit_security::DynInternalAuthenticator>> {
let Some(cfg) = cfg else {
return Ok(None);
};
match cfg.build_authenticator()? {
toolkit_security::BuiltAuthenticator::Built(authenticator) => {
info!("Initializing shared-secret platform-plane authenticator");
return Ok(Some(authenticator));
}
toolkit_security::BuiltAuthenticator::RequiresExternalBackend => {}
}
#[cfg(feature = "k8s-auth")]
{
if cfg.is_kube() {
info!("Initializing Kubernetes TokenReview platform-plane authenticator");
let audiences = cfg.kube_audiences().unwrap_or_default().to_vec();
let authenticator = toolkit_k8s_auth::build_cached_k8s_authenticator(
audiences,
Some(toolkit_security::DEFAULT_TOKEN_REVIEW_CACHE_TTL),
None,
)
.await
.context("failed to initialize Kubernetes TokenReview authenticator")?;
return Ok(Some(authenticator));
}
}
#[cfg(not(feature = "k8s-auth"))]
{
if cfg.is_kube() {
anyhow::bail!("oop_http.internal_auth provider=kube requires the `k8s-auth` feature");
}
}
anyhow::bail!(
"internal_auth is configured but no authenticator could be built for the selected provider"
)
}
async fn build_directory_client(
directory_endpoint: &str,
internal_auth_cfg: Option<&toolkit_security::InternalAuthConfig>,
cancel: &CancellationToken,
) -> Result<(
DirectoryGrpcClient,
Option<toolkit_contract::runtime::config::InternalTokenProvider>,
)> {
let client = DirectoryGrpcClient::connect_lazy(directory_endpoint)?;
let Some(cfg) = internal_auth_cfg else {
return Ok((client, None));
};
let (interceptor, provider) = build_platform_credentials(cfg, cancel).await?;
let client =
DirectoryGrpcClient::connect_lazy_with_interceptor(directory_endpoint, interceptor)?;
Ok((client, provider))
}
async fn build_platform_credentials(
cfg: &toolkit_security::InternalAuthConfig,
cancel: &CancellationToken,
) -> Result<(
toolkit_transport_grpc::InternalAuthInterceptor,
Option<toolkit_contract::runtime::config::InternalTokenProvider>,
)> {
use secrecy::SecretString;
use toolkit_contract::runtime::config::{CredentialState, InternalTokenProvider};
use toolkit_security::InternalAuthConfig;
use toolkit_transport_grpc::{
DEFAULT_REFRESH_INTERVAL, InternalAuthInterceptor, ServiceAccountTokenReader,
};
match cfg {
InternalAuthConfig::SharedSecret { secret, .. } => {
let token = SecretString::from(secret.clone());
Ok((
InternalAuthInterceptor::from_token(token.clone()),
Some(InternalTokenProvider::from_token(token)),
))
}
InternalAuthConfig::Kube {
token_path: Some(path),
..
} => {
let reader = ServiceAccountTokenReader::with_cancellation(
path,
DEFAULT_REFRESH_INTERVAL,
cancel.child_token(),
)
.await
.context("failed to read projected service-account token for outbound credential")?;
let interceptor = reader.interceptor();
let token_fn = reader.token_provider();
let provider = InternalTokenProvider::new(move || match token_fn() {
Some(token) => CredentialState::Available(token),
None => CredentialState::Unavailable(
"projected service-account token is currently unavailable \
(file empty or not yet read)"
.into(),
),
});
Ok((interceptor, Some(provider)))
}
InternalAuthConfig::Kube {
token_path: None, ..
} => {
warn!(
"oop_http.internal_auth: provider=kube without token_path - this participant \
validates inbound platform tokens but will attach NO outbound credential"
);
Ok((InternalAuthInterceptor::disabled(), None))
}
}
}
fn default_advertise_uri(listen_addr: std::net::SocketAddr) -> String {
let host = match listen_addr {
std::net::SocketAddr::V4(addr) if addr.ip().is_unspecified() => "127.0.0.1".to_owned(),
std::net::SocketAddr::V4(addr) => addr.ip().to_string(),
std::net::SocketAddr::V6(addr) if addr.ip().is_unspecified() => "[::1]".to_owned(),
std::net::SocketAddr::V6(addr) => format!("[{}]", addr.ip()),
};
format!("http://{host}:{}", listen_addr.port())
}
fn validate_advertise_uri(uri: &str, allow_loopback: bool) -> Result<()> {
let parsed = url::Url::parse(uri)
.with_context(|| format!("invalid oop_http.advertise_uri: not a valid URL: {uri}"))?;
if !matches!(parsed.scheme(), "http" | "https") {
anyhow::bail!(
"invalid oop_http.advertise_uri: scheme must be http or https (got '{}')",
parsed.scheme()
);
}
if parsed.host_str().is_none_or(str::is_empty) {
anyhow::bail!("invalid oop_http.advertise_uri: missing host: {uri}");
}
if !parsed.username().is_empty() || parsed.password().is_some() {
anyhow::bail!("invalid oop_http.advertise_uri: must not contain userinfo: {uri}");
}
let is_loopback = match parsed.host() {
Some(url::Host::Ipv4(ip)) => ip.is_loopback() || ip.is_unspecified(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback() || ip.is_unspecified(),
Some(url::Host::Domain(d)) => d.trim_end_matches('.').eq_ignore_ascii_case("localhost"),
None => false,
};
if !allow_loopback && is_loopback {
anyhow::bail!(
"invalid oop_http.advertise_uri: '{uri}' is a loopback/unspecified address, which is \
unreachable by other gears in multi-host Profile 2 / Profile 3 (a registered-but-\
unreachable instance). Set oop_http.advertise_uri to a routable host, or set \
oop_http.allow_loopback_advertise = true for single-host / local-dev."
);
}
Ok(())
}
#[allow(unknown_lints, de1301_no_print_macros)] fn print_config(config: &AppConfig) {
match config.to_yaml() {
Ok(yaml) => {
println!("{yaml}");
}
Err(e) => {
eprintln!("Failed to render config as YAML: {e}");
}
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
#[path = "oop_tests.rs"]
mod tests;