toolkit/bootstrap/oop.rs
1//! Out-of-process gear bootstrap library
2//!
3//! This gear provides reusable functionality for bootstrapping `OoP` (out-of-process)
4//! `ToolKit` gears in local (non-k8s) environments.
5//!
6//! ## Features
7//!
8//! - Configuration loading using `toolkit-bootstrap`
9//! - Logging initialization with tracing
10//! - Lazy gRPC client to the `DirectoryService` (connects on first use, so a
11//! cold `DirectoryService` never blocks or crashes bootstrap —
12//! `cpt-cf-adr-eventual-readiness`)
13//! - Gear instance registration
14//! - Heartbeat management
15//! - Gear lifecycle execution
16//!
17//! ## Shutdown Model
18//!
19//! Shutdown is driven by a single root `CancellationToken` per process:
20//! - OS signals (SIGTERM, SIGINT, Ctrl+C) are hooked at bootstrap level
21//! - The root token is passed to `RunOptions::Token` for gear runtime shutdown
22//! - Background tasks (like heartbeat) use child tokens derived from the root
23//!
24//! On shutdown, the gear deregisters itself from the `DirectoryService` before exiting.
25//!
26//! ## Example
27//!
28//! ```rust,no_run
29//! use toolkit::bootstrap::oop::{OopRunOptions, run_oop_with_options};
30//!
31//! #[tokio::main]
32//! async fn main() -> anyhow::Result<()> {
33//! let opts = OopRunOptions {
34//! gear_name: "my_gear".to_string(),
35//! config_gear_name: None,
36//! instance_id: None,
37//! directory_endpoint: "http://127.0.0.1:50051".to_string(),
38//! config_path: None,
39//! verbose: 0,
40//! print_config: false,
41//! heartbeat_interval_secs: 5,
42//! version: None,
43//! };
44//!
45//! run_oop_with_options(opts).await
46//! }
47//! ```
48
49use anyhow::{Context, Result};
50use figment::{Figment, providers::Serialized};
51use std::path::{Path, PathBuf};
52use std::sync::Arc;
53use std::time::Duration;
54use tokio::time::sleep;
55use tokio_util::sync::CancellationToken;
56use tracing::{debug, error, info, warn};
57use uuid::Uuid;
58
59use super::config::{
60 AppConfig, CliArgs, LoggingConfig, RenderedDbConfig, RenderedGearConfig,
61 TOOLKIT_MODULE_CONFIG_ENV,
62};
63use crate::bootstrap::host::{init_logging_unified, init_panic_tracing};
64use crate::runtime::{
65 ClientRegistration, DbOptions, OopServeOptions, RunOptions, ShutdownOptions,
66 TOOLKIT_DIRECTORY_ENDPOINT_ENV, run, run_oop_serving, shutdown,
67};
68use cf_system_sdks::directory::{DirectoryClient, DirectoryGrpcClient};
69
70/// Configuration options for `OoP` gear bootstrap
71#[derive(Debug, Clone)]
72pub struct OopRunOptions {
73 /// Logical gear name (e.g., "`file-parser`")
74 ///
75 /// **This is the directory registration name, and since roles it is not
76 /// always the gear's own id.** A role-split gear registers under its role's
77 /// directory name -- `event-broker-ingest` for the `cluster_ingest` mode --
78 /// while its configuration still lives under the gear id the
79 /// `#[toolkit::gear(name = ...)]` attribute declares, because that is the
80 /// key `GearCtx::config()` looks up. See [`Self::config_gear_name`].
81 pub gear_name: String,
82
83 /// The `gears.<key>` section this process's configuration lives under.
84 ///
85 /// `None` means the same as [`Self::gear_name`], which is the answer for
86 /// every gear that runs in one undifferentiated shape -- so no existing
87 /// caller changes behaviour.
88 ///
89 /// **It exists because `gear_name` was doing two jobs that used to be one
90 /// string.** The registry names a gear by its attribute, and
91 /// `GearCtx::config()` reads `gears.<that name>.config`; a role changes the
92 /// name the instance *registers* under and not the name it *reads* under.
93 /// With the two conflated, a worker started as a role filed the master's
94 /// rendered blob under `gears["event-broker-ingest"]` while the gear read
95 /// `gears["event-broker"]`, so `TOOLKIT_MODULE_CONFIG` was inert under any
96 /// role and only the local `--config` file had any effect. Nothing failed;
97 /// the override simply did not arrive.
98 pub config_gear_name: Option<String>,
99
100 /// Instance ID (defaults to a random UUID if None)
101 pub instance_id: Option<Uuid>,
102
103 /// Directory service gRPC endpoint (e.g., "<http://127.0.0.1:50051>")
104 pub directory_endpoint: String,
105
106 /// Path to configuration file
107 pub config_path: Option<PathBuf>,
108
109 /// Log verbosity level (0=default, 1=debug, 2=trace)
110 pub verbose: u8,
111
112 /// Print effective configuration and exit
113 pub print_config: bool,
114
115 /// Heartbeat interval in seconds (default: 5)
116 pub heartbeat_interval_secs: u64,
117
118 /// Gear version (used for `DirectoryService` registration and `OpenAPI` version).
119 /// Defaults to `None`.
120 pub version: Option<String>,
121}
122
123impl OopRunOptions {
124 /// The `gears.<key>` section to read configuration from.
125 ///
126 /// [`Self::config_gear_name`] when set, and [`Self::gear_name`] otherwise --
127 /// so a caller that does not know about roles keeps exactly the behaviour it
128 /// had.
129 #[must_use]
130 pub fn config_gear_name(&self) -> &str {
131 self.config_gear_name.as_deref().unwrap_or(&self.gear_name)
132 }
133}
134
135impl Default for OopRunOptions {
136 fn default() -> Self {
137 // Check for config path in environment variable as fallback
138 let config_path = std::env::var("TOOLKIT_CONFIG_PATH").ok().map(PathBuf::from);
139
140 // Check for directory endpoint in environment variable (set by master host)
141 // This is the preferred way to get the endpoint when spawned by master host
142 let directory_endpoint = std::env::var(TOOLKIT_DIRECTORY_ENDPOINT_ENV)
143 .unwrap_or_else(|_| "http://127.0.0.1:50051".to_owned());
144
145 Self {
146 gear_name: String::new(),
147 config_gear_name: None,
148 instance_id: None,
149 directory_endpoint,
150 config_path,
151 verbose: 0,
152 print_config: false,
153 heartbeat_interval_secs: 5,
154 version: None,
155 }
156 }
157}
158
159/// Builds the final configuration and `DbOptions` for an `OoP` gear.
160///
161/// Configuration merge strategy (for each section):
162/// - **Database**: field-by-field merge using `DbManager` (master as base, local as override)
163/// - **Logging**: key-by-key merge (each subsystem key is overridden by local)
164/// - **Config**: local completely replaces master if present
165///
166/// The local config file (--config) can override any settings from master's `TOOLKIT_MODULE_CONFIG`.
167///
168/// For database, the merge happens at 3 levels:
169/// 1. Global database.servers.* from master
170/// 2. Gear's database section from master (gears.<name>.database)
171/// 3. Gear's database section from local --config (overrides master)
172#[tracing::instrument(
173 level = "debug",
174 skip(local_config, rendered_config),
175 fields(
176 has_rendered = rendered_config.is_some(),
177 has_local_db = local_config.database.is_some()
178 )
179)]
180fn build_oop_config_and_db(
181 local_config: &AppConfig,
182 gear_name: &str,
183 rendered_config: Option<&RenderedGearConfig>,
184) -> Result<(AppConfig, LoggingConfig, DbOptions)> {
185 let home_dir = PathBuf::from(&local_config.server.home_dir);
186
187 // Build final_config for gear's "config" section
188 let final_config = if let Some(rendered) = rendered_config {
189 // TOOLKIT_MODULE_CONFIG exists: use rendered config as BASE, local config as OVERRIDE
190 let mut config = local_config.clone();
191
192 // Get or create the gear entry
193 let gear_entry = config
194 .gears
195 .entry(gear_name.to_owned())
196 .or_insert_with(|| serde_json::json!({}));
197
198 // Merge rendered.config as base, local gear config as override
199 if let Some(obj) = gear_entry.as_object_mut() {
200 // If local doesn't have "config" section, use rendered entirely
201 // If local has "config" section, it takes precedence (local overrides master)
202 if !obj.contains_key("config") || obj["config"].is_null() {
203 obj.insert("config".to_owned(), rendered.config.clone());
204 }
205 // If local has "config", it already overrides - no action needed
206 }
207
208 debug!(
209 gear = %gear_name,
210 has_rendered_db = %rendered.database.is_some(),
211 has_rendered_logging = %rendered.logging.is_some(),
212 "Using rendered config from master as base, local config as override"
213 );
214
215 config
216 } else {
217 // No TOOLKIT_MODULE_CONFIG: use local config entirely (standalone mode)
218 debug!(
219 gear = %gear_name,
220 "No rendered config from master, using local config entirely (standalone mode)"
221 );
222 local_config.clone()
223 };
224
225 // Merge logging: master logging (base) + local logging (override by key)
226 let final_logging = merge_logging_configs(
227 rendered_config.as_ref().and_then(|r| r.logging.as_ref()),
228 &local_config.logging,
229 );
230
231 // Build DbOptions using Figment merge + DbManager
232 // This allows field-by-field merge: master db config (base) -> local db config (override)
233 let db_options = build_merged_db_options(
234 &home_dir,
235 gear_name,
236 rendered_config.as_ref().and_then(|r| r.database.as_ref()),
237 local_config,
238 )?;
239
240 Ok((final_config, final_logging, db_options))
241}
242
243/// Merges logging configurations: master as base, local as override (by key).
244///
245/// Each key in the logging `HashMap` (e.g., "default", "calculator", "sqlx")
246/// is overridden by local if present.
247fn merge_logging_configs(master: Option<&LoggingConfig>, local: &LoggingConfig) -> LoggingConfig {
248 master
249 .cloned()
250 .unwrap_or_default()
251 .into_iter()
252 .chain(local.clone())
253 .collect()
254}
255
256/// Builds `DbOptions` by merging rendered config from master with local config.
257///
258/// Uses Figment to merge configurations and `DbManager` to handle the actual
259/// database connection setup with field-by-field merge logic.
260fn build_merged_db_options(
261 home_dir: &Path,
262 gear_name: &str,
263 rendered_db: Option<&RenderedDbConfig>,
264 local_config: &AppConfig,
265) -> Result<DbOptions> {
266 // Check if we have any database configuration
267 let has_rendered_db = rendered_db.is_some_and(|db| db.gear.is_some() || db.global.is_some());
268 let has_local_db = local_config.database.is_some()
269 || local_config
270 .gears
271 .get(gear_name)
272 .and_then(|m| m.get("database"))
273 .is_some();
274
275 if !has_rendered_db && !has_local_db {
276 debug!(
277 gear = %gear_name,
278 "No database config available"
279 );
280 return Ok(DbOptions::None);
281 }
282
283 // Build a merged configuration for DbManager:
284 // 1. Start with rendered config from master (global servers + gear db)
285 // 2. Overlay local config (local can override any field)
286
287 let mut merged_config = serde_json::Map::new();
288
289 // Step 1: Add rendered database config from master as base
290 if let Some(rendered) = rendered_db {
291 // Add global servers from master
292 if let Some(ref global) = rendered.global {
293 let global_json = serde_json::to_value(global)
294 .context("Failed to serialize rendered global db config")?;
295 merged_config.insert("database".to_owned(), global_json);
296 }
297
298 // Add gear's database config from master
299 if let Some(ref gear_db) = rendered.gear {
300 let gear_db_json = serde_json::to_value(gear_db)
301 .context("Failed to serialize rendered gear db config")?;
302
303 let mut gears = serde_json::Map::new();
304 let mut gear_entry = serde_json::Map::new();
305 gear_entry.insert("database".to_owned(), gear_db_json);
306 gears.insert(gear_name.to_owned(), serde_json::Value::Object(gear_entry));
307 merged_config.insert("gears".to_owned(), serde_json::Value::Object(gears));
308 }
309 }
310
311 // Step 2: Overlay local config (local overrides master)
312 // Local global database config
313 if let Some(ref local_db) = local_config.database {
314 let local_db_json =
315 serde_json::to_value(local_db).context("Failed to serialize local global db config")?;
316
317 // Merge with existing or replace
318 if let Some(existing) = merged_config.get_mut("database") {
319 merge_json_objects(existing, &local_db_json);
320 } else {
321 merged_config.insert("database".to_owned(), local_db_json);
322 }
323 }
324
325 // Local gear database config
326 if let Some(local_gear) = local_config.gears.get(gear_name)
327 && let Some(local_gear_db) = local_gear.get("database")
328 {
329 let gears = merged_config
330 .entry("gears".to_owned())
331 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
332
333 if let Some(gears_obj) = gears.as_object_mut() {
334 let gear_entry = gears_obj
335 .entry(gear_name.to_owned())
336 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
337
338 if let Some(gear_obj) = gear_entry.as_object_mut() {
339 if let Some(existing_db) = gear_obj.get_mut("database") {
340 merge_json_objects(existing_db, local_gear_db);
341 } else {
342 gear_obj.insert("database".to_owned(), local_gear_db.clone());
343 }
344 }
345 }
346 }
347
348 debug!(
349 gear = %gear_name,
350 has_rendered = %rendered_db.is_some(),
351 has_local_global = %local_config.database.is_some(),
352 "Building DbManager with merged config"
353 );
354
355 // Create DbManager from merged Figment
356 let figment = Figment::new().merge(Serialized::defaults(serde_json::Value::Object(
357 merged_config,
358 )));
359 let db_manager = Arc::new(
360 toolkit_db::DbManager::from_figment(figment, home_dir.to_path_buf())
361 .context("Failed to create DbManager from merged config")?,
362 );
363
364 Ok(DbOptions::Manager(db_manager))
365}
366
367/// Recursively merges source JSON object into target.
368/// Source values override target values for matching keys.
369fn merge_json_objects(target: &mut serde_json::Value, source: &serde_json::Value) {
370 if let (Some(target_obj), Some(source_obj)) = (target.as_object_mut(), source.as_object()) {
371 for (key, value) in source_obj {
372 if let Some(target_value) = target_obj.get_mut(key) {
373 // Recursively merge objects, otherwise replace
374 if target_value.is_object() && value.is_object() {
375 merge_json_objects(target_value, value);
376 } else {
377 *target_value = value.clone();
378 }
379 } else {
380 target_obj.insert(key.clone(), value.clone());
381 }
382 }
383 } else {
384 // If target is not an object, replace entirely
385 *target = source.clone();
386 }
387}
388
389/// Run an out-of-process gear with the given options
390///
391/// This function:
392/// 1. Creates a root `CancellationToken` for the process
393/// 2. Hooks OS signals (SIGTERM, SIGINT, Ctrl+C) to trigger cancellation
394/// 3. Loads configuration and initializes logging
395/// 4. Creates a lazy `DirectoryService` client (connects on first use, not at
396/// bootstrap — `cpt-cf-adr-eventual-readiness`)
397/// 5. Registers the gear instance (background presence loop, with backoff)
398/// 6. Starts a background heartbeat loop (using a child token)
399/// 7. Runs the gear lifecycle with `ShutdownOptions::Token`
400/// 8. Deregisters from `DirectoryService` on shutdown
401///
402/// ## Shutdown Model
403///
404/// A single root cancellation token drives shutdown for the entire process.
405/// OS signals are hooked at this bootstrap level (not via `ShutdownOptions::Signals`).
406/// The heartbeat loop and gear runtime both observe this token tree.
407///
408/// # Arguments
409///
410/// * `opts` - Bootstrap configuration options
411///
412/// # Returns
413///
414/// * `Ok(())` - If the gear lifecycle completed successfully
415/// * `Err(e)` - If any step failed
416///
417/// # Example
418///
419/// ```rust,no_run
420/// use toolkit::bootstrap::oop::{OopRunOptions, run_oop_with_options};
421///
422/// #[tokio::main]
423/// async fn main() -> anyhow::Result<()> {
424/// let opts = OopRunOptions {
425/// gear_name: "file-parser".to_string(),
426/// config_gear_name: None,
427/// instance_id: None,
428/// directory_endpoint: "http://127.0.0.1:50051".to_string(),
429/// config_path: None,
430/// verbose: 1,
431/// print_config: false,
432/// heartbeat_interval_secs: 5,
433/// version: None,
434/// };
435///
436/// run_oop_with_options(opts).await
437/// }
438/// ```
439///
440/// # Errors
441/// Returns an error if the `OoP` gear fails to start or run.
442#[tracing::instrument(
443 level = "info",
444 name = "oop_bootstrap",
445 skip(opts),
446 fields(
447 gear = %opts.gear_name,
448 directory = %opts.directory_endpoint
449 )
450)]
451pub async fn run_oop_with_options(opts: OopRunOptions) -> Result<()> {
452 // Generate instance ID if not provided
453 let instance_id = opts.instance_id.unwrap_or_else(Uuid::new_v4);
454
455 // Create root cancellation token for the entire process.
456 // This token drives shutdown for the gear runtime and all background tasks.
457 let cancel = CancellationToken::new();
458
459 // Hook OS signals to the root token at bootstrap level.
460 // This replaces the use of ShutdownOptions::Signals inside the runtime.
461 let cancel_for_signals = cancel.clone();
462 tokio::spawn(async move {
463 match shutdown::wait_for_shutdown().await {
464 Ok(()) => {
465 info!(target: "", "------------------");
466 info!("shutdown: signal received in OoP bootstrap");
467 }
468 Err(e) => {
469 warn!(
470 error = %e,
471 "shutdown: primary waiter failed in OoP bootstrap, falling back to ctrl_c()"
472 );
473 _ = tokio::signal::ctrl_c().await;
474 }
475 }
476 cancel_for_signals.cancel();
477 });
478
479 // Prepare CLI args for AppConfig loading
480 let args = CliArgs {
481 config: opts
482 .config_path
483 .as_ref()
484 .map(|p| p.to_string_lossy().to_string()),
485 print_config: opts.print_config,
486 verbose: opts.verbose,
487 mock: false,
488 };
489
490 // Load configuration
491 let mut config = AppConfig::load_or_default(opts.config_path.as_ref())?;
492 config.apply_cli_overrides(args.verbose);
493
494 // Try to read rendered gear config from master host via env var BEFORE logging init
495 // so we can use the tracing config from master for OTEL
496 let rendered_config = match std::env::var(TOOLKIT_MODULE_CONFIG_ENV) {
497 Ok(json) => RenderedGearConfig::from_json(&json).ok(),
498 Err(_) => None,
499 };
500
501 // Build final config by merging:
502 // 1. Rendered config from master host (base)
503 // 2. Local config file (override)
504 // This also merges logging configuration for proper initialization
505 let (final_config, merged_logging, db_options) =
506 // The *config* key, not the registration name: a role changes what this
507 // process registers as and not what section it reads. Conflating them
508 // filed the master's rendered blob under a key the gear never looks up.
509 build_oop_config_and_db(&config, opts.config_gear_name(), rendered_config.as_ref())?;
510
511 // Use OpenTelemetry config from rendered (master) config only.
512 // OoP gears do not fall back to local config for telemetry — if the master
513 // does not provide an opentelemetry section, telemetry is skipped entirely.
514 #[cfg(feature = "otel")]
515 let otel_cfg = rendered_config
516 .as_ref()
517 .and_then(|rc| rc.opentelemetry.as_ref());
518
519 // Initialize OTEL tracing layer (if tracing is enabled)
520 #[cfg(feature = "otel")]
521 let otel_layer = otel_cfg
522 .filter(|cfg| cfg.tracing.enabled)
523 .map(crate::telemetry::init_tracing)
524 .transpose()?;
525 #[cfg(not(feature = "otel"))]
526 let otel_layer = None;
527
528 // Initialize OpenTelemetry metrics provider (if configured and enabled).
529 // Store error to log after logging is initialized.
530 #[cfg(feature = "otel")]
531 let metrics_init_error = otel_cfg
532 .filter(|cfg| cfg.metrics.enabled)
533 .and_then(|cfg| crate::telemetry::init::init_metrics_provider(cfg).err());
534
535 // Initialize logging with MERGED config (master base + local override)
536 // Trace-id injection follows the same rule as the other telemetry
537 // settings: it comes from the master's rendered config, never the local one.
538 //
539 // `is_none_or`, not `is_some_and`: an absent `opentelemetry` section resolves
540 // the splice ON, matching the in-process default (`bootstrap::run` over
541 // `OpenTelemetryConfig::default()` -> `unwrap_or(true)`). `is_some_and` would
542 // silently disable it for every OoP gear with no section.
543 #[cfg(feature = "otel")]
544 let inject_trace_ids =
545 otel_cfg.is_none_or(crate::telemetry::OpenTelemetryConfig::inject_trace_ids_into_logs);
546 #[cfg(not(feature = "otel"))]
547 let inject_trace_ids = false;
548
549 init_logging_unified(
550 &merged_logging,
551 &config.server.home_dir,
552 otel_layer,
553 inject_trace_ids,
554 );
555
556 // Now that logging is available, report deferred metrics init error
557 #[cfg(feature = "otel")]
558 if let Some(e) = metrics_init_error {
559 tracing::error!(error = %e, "OpenTelemetry metrics not initialized (OoP)");
560 }
561
562 // Register custom panic hook to reroute panic backtrace into tracing.
563 init_panic_tracing();
564
565 // Now we can log - report what we received from master
566 if let Some(ref rc) = rendered_config {
567 info!(
568 env_var = TOOLKIT_MODULE_CONFIG_ENV,
569 has_database = rc.database.is_some(),
570 has_config = !rc.config.is_null(),
571 has_logging = rc.logging.is_some(),
572 has_opentelemetry = rc.opentelemetry.is_some(),
573 "Received rendered config from master host"
574 );
575 } else if std::env::var(TOOLKIT_MODULE_CONFIG_ENV).is_ok() {
576 warn!(
577 env_var = TOOLKIT_MODULE_CONFIG_ENV,
578 "Failed to parse rendered config from master host, using local config only"
579 );
580 } else {
581 debug!(
582 env_var = TOOLKIT_MODULE_CONFIG_ENV,
583 "No rendered config from master host, using local config only"
584 );
585 }
586
587 info!(
588 gear = %opts.gear_name,
589 instance_id = %instance_id,
590 directory_endpoint = %opts.directory_endpoint,
591 "OoP gear bootstrap starting"
592 );
593
594 // Print config and exit if requested
595 if opts.print_config {
596 print_config(&config);
597 return Ok(());
598 }
599
600 // Create the DirectoryService client on a LAZY channel. Per
601 // `cpt-cf-adr-eventual-readiness`, an OoP gear must start even when the
602 // DirectoryService is not yet reachable; blocking or crashing on it at
603 // bootstrap would make it a hard startup dependency (the SPOF the ADR
604 // rejects). A lazy channel performs no eager connection: it fails fast only
605 // on a malformed endpoint and defers the connect to the first RPC.
606 //
607 // Recovery from a cold directory differs by lifecycle:
608 // - `oop_http` configured: the presence loop retries registration with
609 // exponential backoff (100ms -> 30s, forever).
610 // - Legacy path (no `oop_http`): only a fixed-interval heartbeat runs, with
611 // no registration retry — it relies on an external orchestrator.
612 //
613 // When a platform-plane credential is configured, it is attached (as
614 // `x-toolkit-internal-token`) to outbound system calls via an
615 // InternalAuthInterceptor (`cpt-cf-adr-two-plane-auth`).
616 info!(
617 "Creating directory service client (lazy connect) for {}",
618 opts.directory_endpoint
619 );
620 let internal_auth_cfg = final_config
621 .oop_http
622 .as_ref()
623 .and_then(|h| h.internal_auth.as_ref());
624 let (directory_client, internal_token_provider) =
625 build_directory_client(&opts.directory_endpoint, internal_auth_cfg, &cancel).await?;
626 let directory_api: Arc<dyn DirectoryClient> = Arc::new(directory_client);
627
628 info!("Directory service client ready (will connect on first use)");
629
630 // Capture OoP HTTP config (if any) before moving the config into the provider.
631 let oop_http = final_config.oop_http.clone();
632
633 // Build config provider for gears
634 let config_provider = Arc::new(final_config);
635
636 // The DirectoryClient (gRPC client) is injected into the ClientHub so gears can access it.
637 // `oop` is left unset: OoP gears don't spawn other OoP gears.
638 let run_options = RunOptions::new(
639 config_provider,
640 db_options,
641 ShutdownOptions::Token(cancel.clone()),
642 instance_id,
643 )
644 .with_clients(vec![ClientRegistration::new::<dyn DirectoryClient>(
645 Arc::clone(&directory_api),
646 )])
647 .with_internal_token_provider(internal_token_provider);
648
649 // When `oop_http` is configured, run the HTTP-serving lifecycle:
650 // Axum server + probes + directory presence (registration + heartbeat +
651 // self-heal, owned by `presence_loop`) + dependency resolution + drain.
652 // Otherwise fall back to the legacy gRPC-only lifecycle.
653 let result = if let Some(http_cfg) = oop_http {
654 info!("Starting OoP HTTP-serving lifecycle");
655 let serve = build_oop_serve_options(
656 &http_cfg,
657 &opts.gear_name,
658 instance_id,
659 opts.version.clone(),
660 Duration::from_secs(opts.heartbeat_interval_secs),
661 Arc::clone(&directory_api),
662 )
663 .await?;
664 run_oop_serving(run_options, serve).await
665 } else {
666 info!("Starting gear lifecycle (legacy gRPC-only)");
667 // Legacy path: presence is not self-managed by an HTTP serve lifecycle,
668 // so run a standalone heartbeat on a child token. (A no-op unless the
669 // instance is registered by an external orchestrator.)
670 let heartbeat_directory = Arc::clone(&directory_api);
671 let heartbeat_gear = opts.gear_name.clone();
672 let heartbeat_instance_id_str = instance_id.to_string();
673 let heartbeat_interval = Duration::from_secs(opts.heartbeat_interval_secs.max(1));
674 let heartbeat_cancel = cancel.child_token();
675 tokio::spawn(async move {
676 info!(interval_secs = ?heartbeat_interval, "Starting legacy heartbeat loop");
677 loop {
678 tokio::select! {
679 () = heartbeat_cancel.cancelled() => {
680 info!("Heartbeat loop stopping due to cancellation");
681 break;
682 }
683 () = sleep(heartbeat_interval) => {
684 if let Err(e) = heartbeat_directory
685 .send_heartbeat(&heartbeat_gear, &heartbeat_instance_id_str)
686 .await
687 {
688 warn!(error = %e, "Failed to send heartbeat, will retry");
689 }
690 }
691 }
692 }
693 });
694 run(run_options).await
695 };
696
697 if let Err(ref e) = result {
698 error!(error = %e, "Gear runtime failed");
699 } else {
700 info!("Gear runtime completed successfully");
701 }
702
703 // Graceful shutdown - flush remaining telemetry. An OoP gear is a separate
704 // OS process owning its own global providers, so the in-process host's
705 // flush in `bootstrap::run` does not cover it.
706 #[cfg(feature = "otel")]
707 crate::bootstrap::run::tracing_shutdown().await;
708
709 result
710}
711
712/// Build [`OopServeOptions`] from configuration.
713///
714/// The tenant-plane `BearerAuthenticator` injection point is left unset here;
715/// the app/gear binary supplies that adapter when it needs the tenant-plane
716/// middleware installed. The platform-plane authenticator is constructed from
717/// `oop_http.internal_auth` when the `k8s-auth` feature is enabled.
718async fn build_oop_serve_options(
719 cfg: &super::config::OopHttpConfig,
720 gear_name: &str,
721 instance_id: Uuid,
722 version: Option<String>,
723 heartbeat_interval: Duration,
724 directory: Arc<dyn DirectoryClient>,
725) -> Result<OopServeOptions> {
726 let listen_addr: std::net::SocketAddr = cfg
727 .listen_addr
728 .parse()
729 .with_context(|| format!("invalid oop_http.listen_addr: {}", cfg.listen_addr))?;
730
731 let probe_bind_addr = cfg
732 .probe_bind_addr
733 .as_deref()
734 .map(|s| {
735 s.parse::<std::net::SocketAddr>()
736 .with_context(|| format!("invalid oop_http.probe_bind_addr: {s}"))
737 })
738 .transpose()?;
739
740 let advertise_uri = cfg
741 .advertise_uri
742 .clone()
743 .unwrap_or_else(|| default_advertise_uri(listen_addr));
744
745 // Fail fast on a malformed or unreachable advertise_uri rather than only
746 // when the directory rejects the registration — the same late-failure the
747 // label validation below avoids.
748 validate_advertise_uri(&advertise_uri, cfg.allow_loopback_advertise)?;
749 log_auth_disabled_when_routable(cfg.auth_disabled, &advertise_uri);
750
751 // Fail fast on a mis-configured label (bad charset, over-long, too many)
752 // at start-up, using the same shared rules the directory enforces. Without
753 // this the process would come up, attempt to register, and only then be
754 // permanently rejected by the directory — a confusing late failure for what
755 // is a static configuration error. Checked before authenticator init so a
756 // static config error is reported without any async backend setup.
757 cf_system_sdks::directory::validate_labels(&cfg.labels).with_context(|| {
758 "invalid oop_http.labels: label keys/values must be <=63 chars, <=64 entries, and use \
759 only ASCII alphanumerics plus '-', '_', '.' (starting and ending alphanumeric)"
760 })?;
761
762 let internal_authenticator = build_internal_authenticator(cfg.internal_auth.as_ref()).await?;
763
764 Ok(OopServeOptions {
765 gear_name: gear_name.to_owned(),
766 instance_id: instance_id.to_string(),
767 version,
768 advertise_uri,
769 listen_addr,
770 probe_bind_addr,
771 drain_timeout: Duration::from_secs(cfg.drain_timeout_secs),
772 heartbeat_interval,
773 healthcheck_timeout: Duration::from_millis(cfg.healthcheck_timeout_ms),
774 directory,
775 bearer_authenticator: None,
776 internal_authenticator,
777 auth_disabled: cfg.auth_disabled,
778 labels: cfg.labels.clone(),
779 })
780}
781
782/// Construct the inbound platform-plane authenticator from configuration.
783///
784/// The `shared_secret` provider is built here directly (no external backend).
785/// The `kube` provider is built (with the default positive/negative
786/// validation cache) by the single shared
787/// `toolkit_k8s_auth::build_cached_k8s_authenticator` helper also used by
788/// `grpc-hub`, so the two never drift on what `provider: kube` means or how
789/// it is cached. The `kube` provider requires the `k8s-auth` feature; without
790/// it, `provider: kube` is an error rather than a silent enforcement
791/// downgrade, as is any other unrecognized provider value. `Ok(None)` is
792/// returned only when no `internal_auth` is configured at all (Profile 1).
793#[cfg_attr(not(feature = "k8s-auth"), allow(clippy::unused_async))]
794async fn build_internal_authenticator(
795 cfg: Option<&toolkit_security::InternalAuthConfig>,
796) -> Result<Option<toolkit_security::DynInternalAuthenticator>> {
797 let Some(cfg) = cfg else {
798 return Ok(None);
799 };
800
801 // Dependency-light providers (shared-secret) build directly here. An
802 // unusable secret is an error rather than a fallthrough: dropping to the
803 // kube branch below would report the wrong problem entirely.
804 match cfg.build_authenticator()? {
805 toolkit_security::BuiltAuthenticator::Built(authenticator) => {
806 info!("Initializing shared-secret platform-plane authenticator");
807 return Ok(Some(authenticator));
808 }
809 toolkit_security::BuiltAuthenticator::RequiresExternalBackend => {}
810 }
811
812 #[cfg(feature = "k8s-auth")]
813 {
814 if cfg.is_kube() {
815 info!("Initializing Kubernetes TokenReview platform-plane authenticator");
816 let audiences = cfg.kube_audiences().unwrap_or_default().to_vec();
817 let authenticator = toolkit_k8s_auth::build_cached_k8s_authenticator(
818 audiences,
819 Some(toolkit_security::DEFAULT_TOKEN_REVIEW_CACHE_TTL),
820 None,
821 )
822 .await
823 .context("failed to initialize Kubernetes TokenReview authenticator")?;
824 return Ok(Some(authenticator));
825 }
826 }
827 #[cfg(not(feature = "k8s-auth"))]
828 {
829 if cfg.is_kube() {
830 anyhow::bail!("oop_http.internal_auth provider=kube requires the `k8s-auth` feature");
831 }
832 }
833
834 anyhow::bail!(
835 "internal_auth is configured but no authenticator could be built for the selected provider"
836 )
837}
838
839/// Build the `OoP` gear's `DirectoryService` client on a **lazy** channel, plus
840/// the outbound
841/// [`InternalTokenProvider`](toolkit_contract::runtime::config::InternalTokenProvider)
842/// when a platform-plane credential is configured (`None` => no credential).
843///
844/// The lazy channel performs no eager connection, so this succeeds even when
845/// `directory_endpoint` is unreachable (`cpt-cf-adr-eventual-readiness`).
846/// Interceptor and provider come from one credential source (see
847/// [`build_platform_credentials`]) so they can't diverge after a rotation.
848///
849/// # Errors
850/// Only if the endpoint is malformed or a configured credential source fails to
851/// initialise — never for an unreachable directory. The endpoint is validated
852/// before any credential work, so a malformed endpoint is reported ahead of a
853/// credential-source failure.
854async fn build_directory_client(
855 directory_endpoint: &str,
856 internal_auth_cfg: Option<&toolkit_security::InternalAuthConfig>,
857 cancel: &CancellationToken,
858) -> Result<(
859 DirectoryGrpcClient,
860 Option<toolkit_contract::runtime::config::InternalTokenProvider>,
861)> {
862 // Validate the endpoint up front — before any credential-source work — so a
863 // malformed endpoint is reported ahead of a credential failure. The lazy
864 // client performs no connection, only URI validation; it is reused as-is on
865 // the no-credential path.
866 let client = DirectoryGrpcClient::connect_lazy(directory_endpoint)?;
867
868 let Some(cfg) = internal_auth_cfg else {
869 return Ok((client, None));
870 };
871
872 let (interceptor, provider) = build_platform_credentials(cfg, cancel).await?;
873 // Rebuild with the interceptor attached (it must be set at construction);
874 // the endpoint was already validated above.
875 let client =
876 DirectoryGrpcClient::connect_lazy_with_interceptor(directory_endpoint, interceptor)?;
877 Ok((client, provider))
878}
879
880/// Build the platform-plane credential material from config: the **outbound**
881/// gRPC [`InternalAuthInterceptor`] (for `DirectoryService` calls) and the
882/// transport-agnostic
883/// [`InternalTokenProvider`](toolkit_contract::runtime::config::InternalTokenProvider)
884/// that `#[toolkit::provides]` clients attach as `X-ToolKit-Internal-Token` on
885/// platform-plane methods (`cpt-cf-adr-two-plane-auth`).
886///
887/// Both derive from ONE credential source so they never disagree after a
888/// rotation:
889/// - `shared_secret` → a static token for both.
890/// - `kube` with `token_path` → one [`ServiceAccountTokenReader`] drives both;
891/// its "no token yet" maps to [`CredentialState::Unavailable`] so an attach
892/// site warns rather than silently sending nothing.
893/// - `kube` without `token_path` → inbound-only: disabled interceptor + `None`
894/// provider (warned).
895///
896/// The `match` is exhaustive so a future variant fails to compile here rather
897/// than silently downgrading to no outbound credential.
898async fn build_platform_credentials(
899 cfg: &toolkit_security::InternalAuthConfig,
900 cancel: &CancellationToken,
901) -> Result<(
902 toolkit_transport_grpc::InternalAuthInterceptor,
903 Option<toolkit_contract::runtime::config::InternalTokenProvider>,
904)> {
905 use secrecy::SecretString;
906 use toolkit_contract::runtime::config::{CredentialState, InternalTokenProvider};
907 use toolkit_security::InternalAuthConfig;
908 use toolkit_transport_grpc::{
909 DEFAULT_REFRESH_INTERVAL, InternalAuthInterceptor, ServiceAccountTokenReader,
910 };
911
912 match cfg {
913 InternalAuthConfig::SharedSecret { secret, .. } => {
914 let token = SecretString::from(secret.clone());
915 Ok((
916 InternalAuthInterceptor::from_token(token.clone()),
917 Some(InternalTokenProvider::from_token(token)),
918 ))
919 }
920 InternalAuthConfig::Kube {
921 token_path: Some(path),
922 ..
923 } => {
924 let reader = ServiceAccountTokenReader::with_cancellation(
925 path,
926 DEFAULT_REFRESH_INTERVAL,
927 cancel.child_token(),
928 )
929 .await
930 .context("failed to read projected service-account token for outbound credential")?;
931 let interceptor = reader.interceptor();
932 // The reader yields `None` while the projected file is empty or the
933 // refresh has not populated it; surface that as `Unavailable` (warn)
934 // rather than `NotConfigured` (silent).
935 let token_fn = reader.token_provider();
936 let provider = InternalTokenProvider::new(move || match token_fn() {
937 Some(token) => CredentialState::Available(token),
938 None => CredentialState::Unavailable(
939 "projected service-account token is currently unavailable \
940 (file empty or not yet read)"
941 .into(),
942 ),
943 });
944 Ok((interceptor, Some(provider)))
945 }
946 InternalAuthConfig::Kube {
947 token_path: None, ..
948 } => {
949 warn!(
950 "oop_http.internal_auth: provider=kube without token_path - this participant \
951 validates inbound platform tokens but will attach NO outbound credential"
952 );
953 Ok((InternalAuthInterceptor::disabled(), None))
954 }
955 }
956}
957
958/// Derive a default advertise URI from the bind address. Unspecified hosts
959/// (`0.0.0.0` / `[::]`) are rewritten to loopback, and every IPv6 host is
960/// enclosed in brackets so the resulting URI is valid when registered as a
961/// REST endpoint.
962fn default_advertise_uri(listen_addr: std::net::SocketAddr) -> String {
963 let host = match listen_addr {
964 std::net::SocketAddr::V4(addr) if addr.ip().is_unspecified() => "127.0.0.1".to_owned(),
965 std::net::SocketAddr::V4(addr) => addr.ip().to_string(),
966 std::net::SocketAddr::V6(addr) if addr.ip().is_unspecified() => "[::1]".to_owned(),
967 std::net::SocketAddr::V6(addr) => format!("[{}]", addr.ip()),
968 };
969 format!("http://{host}:{}", listen_addr.port())
970}
971
972/// Reject a malformed or unreachable `advertise_uri` at start-up (fail fast).
973///
974/// Checks shape (parseable `http`/`https` URL, non-empty host, no userinfo) and,
975/// unless `allow_loopback`, rejects a loopback / unspecified host - a
976/// registered-but-unreachable instance in multi-host Profile 2 / Profile 3
977/// (`cpt-cf-adr-instance-addressable-discovery` section 5). The default derives
978/// loopback from an unspecified bind, so this also covers an *unset* value. The
979/// directory enforces its full endpoint ruleset server-side.
980fn validate_advertise_uri(uri: &str, allow_loopback: bool) -> Result<()> {
981 let parsed = url::Url::parse(uri)
982 .with_context(|| format!("invalid oop_http.advertise_uri: not a valid URL: {uri}"))?;
983 if !matches!(parsed.scheme(), "http" | "https") {
984 anyhow::bail!(
985 "invalid oop_http.advertise_uri: scheme must be http or https (got '{}')",
986 parsed.scheme()
987 );
988 }
989 if parsed.host_str().is_none_or(str::is_empty) {
990 anyhow::bail!("invalid oop_http.advertise_uri: missing host: {uri}");
991 }
992 if !parsed.username().is_empty() || parsed.password().is_some() {
993 anyhow::bail!("invalid oop_http.advertise_uri: must not contain userinfo: {uri}");
994 }
995 if !allow_loopback && is_loopback_host(&parsed) {
996 anyhow::bail!(
997 "invalid oop_http.advertise_uri: '{uri}' is a loopback/unspecified address, which is \
998 unreachable by other gears in multi-host Profile 2 / Profile 3 (a registered-but-\
999 unreachable instance). Set oop_http.advertise_uri to a routable host, or set \
1000 oop_http.allow_loopback_advertise = true for single-host / local-dev."
1001 );
1002 }
1003 Ok(())
1004}
1005
1006/// Whether `uri`'s host is loopback / unspecified, i.e. unreachable from other hosts.
1007fn is_loopback_host(uri: &url::Url) -> bool {
1008 match uri.host() {
1009 Some(url::Host::Ipv4(ip)) => ip.is_loopback() || ip.is_unspecified(),
1010 Some(url::Host::Ipv6(ip)) => ip.is_loopback() || ip.is_unspecified(),
1011 Some(url::Host::Domain(d)) => d.trim_end_matches('.').eq_ignore_ascii_case("localhost"),
1012 None => false,
1013 }
1014}
1015
1016/// Log an `error!` when the development-only `auth_disabled` is set on a worker advertised
1017/// at a routable address: other hosts reach it through the directory, and every tenant's
1018/// request then runs as the default tenant.
1019fn log_auth_disabled_when_routable(auth_disabled: bool, advertise_uri: &str) {
1020 if !auth_disabled {
1021 return;
1022 }
1023 let routable = url::Url::parse(advertise_uri).is_ok_and(|uri| !is_loopback_host(&uri));
1024 if routable {
1025 tracing::error!(
1026 advertise_uri,
1027 "oop_http.auth_disabled is set on a worker advertised at a routable address: every \
1028 tenant request reaching it runs as the default tenant. Development only; unset it \
1029 or advertise a loopback address."
1030 );
1031 }
1032}
1033
1034#[allow(unknown_lints, de1301_no_print_macros)] // direct stdout config print before exit
1035fn print_config(config: &AppConfig) {
1036 match config.to_yaml() {
1037 Ok(yaml) => {
1038 println!("{yaml}");
1039 }
1040 Err(e) => {
1041 eprintln!("Failed to render config as YAML: {e}");
1042 }
1043 }
1044}
1045
1046#[cfg(test)]
1047#[cfg_attr(coverage_nightly, coverage(off))]
1048#[path = "oop_tests.rs"]
1049mod tests;