Skip to main content

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//! - gRPC connection to `DirectoryService`
11//! - Gear instance registration
12//! - Heartbeat management
13//! - Gear lifecycle execution
14//!
15//! ## Shutdown Model
16//!
17//! Shutdown is driven by a single root `CancellationToken` per process:
18//! - OS signals (SIGTERM, SIGINT, Ctrl+C) are hooked at bootstrap level
19//! - The root token is passed to `RunOptions::Token` for gear runtime shutdown
20//! - Background tasks (like heartbeat) use child tokens derived from the root
21//!
22//! On shutdown, the gear deregisters itself from the `DirectoryService` before exiting.
23//!
24//! ## Example
25//!
26//! ```rust,no_run
27//! use toolkit::bootstrap::oop::{OopRunOptions, run_oop_with_options};
28//!
29//! #[tokio::main]
30//! async fn main() -> anyhow::Result<()> {
31//!     let opts = OopRunOptions {
32//!         gear_name: "my_gear".to_string(),
33//!         instance_id: None,
34//!         directory_endpoint: "http://127.0.0.1:50051".to_string(),
35//!         config_path: None,
36//!         verbose: 0,
37//!         print_config: false,
38//!         heartbeat_interval_secs: 5,
39//!     };
40//!
41//!     run_oop_with_options(opts).await
42//! }
43//! ```
44
45use anyhow::{Context, Result};
46use figment::{Figment, providers::Serialized};
47use std::path::{Path, PathBuf};
48use std::sync::Arc;
49use std::time::Duration;
50use tokio::time::sleep;
51use tokio_util::sync::CancellationToken;
52use tracing::{debug, error, info, warn};
53use uuid::Uuid;
54
55use super::config::{
56    AppConfig, CliArgs, LoggingConfig, RenderedDbConfig, RenderedGearConfig,
57    TOOLKIT_MODULE_CONFIG_ENV,
58};
59use crate::bootstrap::host::{init_logging_unified, init_panic_tracing};
60use crate::runtime::{
61    ClientRegistration, DbOptions, RunOptions, ShutdownOptions, TOOLKIT_DIRECTORY_ENDPOINT_ENV,
62    run, shutdown,
63};
64use cf_system_sdks::directory::{DirectoryClient, DirectoryGrpcClient};
65
66/// Configuration options for `OoP` gear bootstrap
67#[derive(Debug, Clone)]
68pub struct OopRunOptions {
69    /// Logical gear name (e.g., "`file-parser`")
70    pub gear_name: String,
71
72    /// Instance ID (defaults to a random UUID if None)
73    pub instance_id: Option<Uuid>,
74
75    /// Directory service gRPC endpoint (e.g., "<http://127.0.0.1:50051>")
76    pub directory_endpoint: String,
77
78    /// Path to configuration file
79    pub config_path: Option<PathBuf>,
80
81    /// Log verbosity level (0=default, 1=debug, 2=trace)
82    pub verbose: u8,
83
84    /// Print effective configuration and exit
85    pub print_config: bool,
86
87    /// Heartbeat interval in seconds (default: 5)
88    pub heartbeat_interval_secs: u64,
89}
90
91impl Default for OopRunOptions {
92    fn default() -> Self {
93        // Check for config path in environment variable as fallback
94        let config_path = std::env::var("TOOLKIT_CONFIG_PATH").ok().map(PathBuf::from);
95
96        // Check for directory endpoint in environment variable (set by master host)
97        // This is the preferred way to get the endpoint when spawned by master host
98        let directory_endpoint = std::env::var(TOOLKIT_DIRECTORY_ENDPOINT_ENV)
99            .unwrap_or_else(|_| "http://127.0.0.1:50051".to_owned());
100
101        Self {
102            gear_name: String::new(),
103            instance_id: None,
104            directory_endpoint,
105            config_path,
106            verbose: 0,
107            print_config: false,
108            heartbeat_interval_secs: 5,
109        }
110    }
111}
112
113/// Builds the final configuration and `DbOptions` for an `OoP` gear.
114///
115/// Configuration merge strategy (for each section):
116/// - **Database**: field-by-field merge using `DbManager` (master as base, local as override)
117/// - **Logging**: key-by-key merge (each subsystem key is overridden by local)
118/// - **Config**: local completely replaces master if present
119///
120/// The local config file (--config) can override any settings from master's `TOOLKIT_MODULE_CONFIG`.
121///
122/// For database, the merge happens at 3 levels:
123/// 1. Global database.servers.* from master
124/// 2. Gear's database section from master (gears.<name>.database)
125/// 3. Gear's database section from local --config (overrides master)
126#[tracing::instrument(
127    level = "debug",
128    skip(local_config, rendered_config),
129    fields(
130        has_rendered = rendered_config.is_some(),
131        has_local_db = local_config.database.is_some()
132    )
133)]
134fn build_oop_config_and_db(
135    local_config: &AppConfig,
136    gear_name: &str,
137    rendered_config: Option<&RenderedGearConfig>,
138) -> Result<(AppConfig, LoggingConfig, DbOptions)> {
139    let home_dir = PathBuf::from(&local_config.server.home_dir);
140
141    // Build final_config for gear's "config" section
142    let final_config = if let Some(rendered) = rendered_config {
143        // TOOLKIT_MODULE_CONFIG exists: use rendered config as BASE, local config as OVERRIDE
144        let mut config = local_config.clone();
145
146        // Get or create the gear entry
147        let gear_entry = config
148            .gears
149            .entry(gear_name.to_owned())
150            .or_insert_with(|| serde_json::json!({}));
151
152        // Merge rendered.config as base, local gear config as override
153        if let Some(obj) = gear_entry.as_object_mut() {
154            // If local doesn't have "config" section, use rendered entirely
155            // If local has "config" section, it takes precedence (local overrides master)
156            if !obj.contains_key("config") || obj["config"].is_null() {
157                obj.insert("config".to_owned(), rendered.config.clone());
158            }
159            // If local has "config", it already overrides - no action needed
160        }
161
162        debug!(
163            gear =  %gear_name,
164            has_rendered_db = %rendered.database.is_some(),
165            has_rendered_logging = %rendered.logging.is_some(),
166            "Using rendered config from master as base, local config as override"
167        );
168
169        config
170    } else {
171        // No TOOLKIT_MODULE_CONFIG: use local config entirely (standalone mode)
172        debug!(
173            gear =  %gear_name,
174            "No rendered config from master, using local config entirely (standalone mode)"
175        );
176        local_config.clone()
177    };
178
179    // Merge logging: master logging (base) + local logging (override by key)
180    let final_logging = merge_logging_configs(
181        rendered_config.as_ref().and_then(|r| r.logging.as_ref()),
182        &local_config.logging,
183    );
184
185    // Build DbOptions using Figment merge + DbManager
186    // This allows field-by-field merge: master db config (base) -> local db config (override)
187    let db_options = build_merged_db_options(
188        &home_dir,
189        gear_name,
190        rendered_config.as_ref().and_then(|r| r.database.as_ref()),
191        local_config,
192    )?;
193
194    Ok((final_config, final_logging, db_options))
195}
196
197/// Merges logging configurations: master as base, local as override (by key).
198///
199/// Each key in the logging `HashMap` (e.g., "default", "calculator", "sqlx")
200/// is overridden by local if present.
201fn merge_logging_configs(master: Option<&LoggingConfig>, local: &LoggingConfig) -> LoggingConfig {
202    master
203        .cloned()
204        .unwrap_or_default()
205        .into_iter()
206        .chain(local.clone())
207        .collect()
208}
209
210/// Builds `DbOptions` by merging rendered config from master with local config.
211///
212/// Uses Figment to merge configurations and `DbManager` to handle the actual
213/// database connection setup with field-by-field merge logic.
214fn build_merged_db_options(
215    home_dir: &Path,
216    gear_name: &str,
217    rendered_db: Option<&RenderedDbConfig>,
218    local_config: &AppConfig,
219) -> Result<DbOptions> {
220    // Check if we have any database configuration
221    let has_rendered_db = rendered_db.is_some_and(|db| db.gear.is_some() || db.global.is_some());
222    let has_local_db = local_config.database.is_some()
223        || local_config
224            .gears
225            .get(gear_name)
226            .and_then(|m| m.get("database"))
227            .is_some();
228
229    if !has_rendered_db && !has_local_db {
230        debug!(
231            gear =  %gear_name,
232            "No database config available"
233        );
234        return Ok(DbOptions::None);
235    }
236
237    // Build a merged configuration for DbManager:
238    // 1. Start with rendered config from master (global servers + gear db)
239    // 2. Overlay local config (local can override any field)
240
241    let mut merged_config = serde_json::Map::new();
242
243    // Step 1: Add rendered database config from master as base
244    if let Some(rendered) = rendered_db {
245        // Add global servers from master
246        if let Some(ref global) = rendered.global {
247            let global_json = serde_json::to_value(global)
248                .context("Failed to serialize rendered global db config")?;
249            merged_config.insert("database".to_owned(), global_json);
250        }
251
252        // Add gear's database config from master
253        if let Some(ref gear_db) = rendered.gear {
254            let gear_db_json = serde_json::to_value(gear_db)
255                .context("Failed to serialize rendered gear db config")?;
256
257            let mut gears = serde_json::Map::new();
258            let mut gear_entry = serde_json::Map::new();
259            gear_entry.insert("database".to_owned(), gear_db_json);
260            gears.insert(gear_name.to_owned(), serde_json::Value::Object(gear_entry));
261            merged_config.insert("gears".to_owned(), serde_json::Value::Object(gears));
262        }
263    }
264
265    // Step 2: Overlay local config (local overrides master)
266    // Local global database config
267    if let Some(ref local_db) = local_config.database {
268        let local_db_json =
269            serde_json::to_value(local_db).context("Failed to serialize local global db config")?;
270
271        // Merge with existing or replace
272        if let Some(existing) = merged_config.get_mut("database") {
273            merge_json_objects(existing, &local_db_json);
274        } else {
275            merged_config.insert("database".to_owned(), local_db_json);
276        }
277    }
278
279    // Local gear database config
280    if let Some(local_gear) = local_config.gears.get(gear_name)
281        && let Some(local_gear_db) = local_gear.get("database")
282    {
283        let gears = merged_config
284            .entry("gears".to_owned())
285            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
286
287        if let Some(gears_obj) = gears.as_object_mut() {
288            let gear_entry = gears_obj
289                .entry(gear_name.to_owned())
290                .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
291
292            if let Some(gear_obj) = gear_entry.as_object_mut() {
293                if let Some(existing_db) = gear_obj.get_mut("database") {
294                    merge_json_objects(existing_db, local_gear_db);
295                } else {
296                    gear_obj.insert("database".to_owned(), local_gear_db.clone());
297                }
298            }
299        }
300    }
301
302    debug!(
303        gear =  %gear_name,
304        has_rendered = %rendered_db.is_some(),
305        has_local_global = %local_config.database.is_some(),
306        "Building DbManager with merged config"
307    );
308
309    // Create DbManager from merged Figment
310    let figment = Figment::new().merge(Serialized::defaults(serde_json::Value::Object(
311        merged_config,
312    )));
313    let db_manager = Arc::new(
314        toolkit_db::DbManager::from_figment(figment, home_dir.to_path_buf())
315            .context("Failed to create DbManager from merged config")?,
316    );
317
318    Ok(DbOptions::Manager(db_manager))
319}
320
321/// Recursively merges source JSON object into target.
322/// Source values override target values for matching keys.
323fn merge_json_objects(target: &mut serde_json::Value, source: &serde_json::Value) {
324    if let (Some(target_obj), Some(source_obj)) = (target.as_object_mut(), source.as_object()) {
325        for (key, value) in source_obj {
326            if let Some(target_value) = target_obj.get_mut(key) {
327                // Recursively merge objects, otherwise replace
328                if target_value.is_object() && value.is_object() {
329                    merge_json_objects(target_value, value);
330                } else {
331                    *target_value = value.clone();
332                }
333            } else {
334                target_obj.insert(key.clone(), value.clone());
335            }
336        }
337    } else {
338        // If target is not an object, replace entirely
339        *target = source.clone();
340    }
341}
342
343/// Run an out-of-process gear with the given options
344///
345/// This function:
346/// 1. Creates a root `CancellationToken` for the process
347/// 2. Hooks OS signals (SIGTERM, SIGINT, Ctrl+C) to trigger cancellation
348/// 3. Loads configuration and initializes logging
349/// 4. Connects to the `DirectoryService`
350/// 5. Registers the gear instance
351/// 6. Starts a background heartbeat loop (using a child token)
352/// 7. Runs the gear lifecycle with `ShutdownOptions::Token`
353/// 8. Deregisters from `DirectoryService` on shutdown
354///
355/// ## Shutdown Model
356///
357/// A single root cancellation token drives shutdown for the entire process.
358/// OS signals are hooked at this bootstrap level (not via `ShutdownOptions::Signals`).
359/// The heartbeat loop and gear runtime both observe this token tree.
360///
361/// # Arguments
362///
363/// * `opts` - Bootstrap configuration options
364///
365/// # Returns
366///
367/// * `Ok(())` - If the gear lifecycle completed successfully
368/// * `Err(e)` - If any step failed
369///
370/// # Example
371///
372/// ```rust,no_run
373/// use toolkit::bootstrap::oop::{OopRunOptions, run_oop_with_options};
374///
375/// #[tokio::main]
376/// async fn main() -> anyhow::Result<()> {
377///     let opts = OopRunOptions {
378///         gear_name: "file-parser".to_string(),
379///         instance_id: None,
380///         directory_endpoint: "http://127.0.0.1:50051".to_string(),
381///         config_path: None,
382///         verbose: 1,
383///         print_config: false,
384///         heartbeat_interval_secs: 5,
385///     };
386///
387///     run_oop_with_options(opts).await
388/// }
389/// ```
390///
391/// # Errors
392/// Returns an error if the `OoP` gear fails to start or run.
393#[tracing::instrument(
394    level = "info",
395    name = "oop_bootstrap",
396    skip(opts),
397    fields(
398        gear =  %opts.gear_name,
399        directory = %opts.directory_endpoint
400    )
401)]
402pub async fn run_oop_with_options(opts: OopRunOptions) -> Result<()> {
403    // Generate instance ID if not provided
404    let instance_id = opts.instance_id.unwrap_or_else(Uuid::new_v4);
405
406    // Create root cancellation token for the entire process.
407    // This token drives shutdown for the gear runtime and all background tasks.
408    let cancel = CancellationToken::new();
409
410    // Hook OS signals to the root token at bootstrap level.
411    // This replaces the use of ShutdownOptions::Signals inside the runtime.
412    let cancel_for_signals = cancel.clone();
413    tokio::spawn(async move {
414        match shutdown::wait_for_shutdown().await {
415            Ok(()) => {
416                info!(target: "", "------------------");
417                info!("shutdown: signal received in OoP bootstrap");
418            }
419            Err(e) => {
420                warn!(
421                    error = %e,
422                    "shutdown: primary waiter failed in OoP bootstrap, falling back to ctrl_c()"
423                );
424                _ = tokio::signal::ctrl_c().await;
425            }
426        }
427        cancel_for_signals.cancel();
428    });
429
430    // Prepare CLI args for AppConfig loading
431    let args = CliArgs {
432        config: opts
433            .config_path
434            .as_ref()
435            .map(|p| p.to_string_lossy().to_string()),
436        print_config: opts.print_config,
437        verbose: opts.verbose,
438        mock: false,
439    };
440
441    // Load configuration
442    let mut config = AppConfig::load_or_default(opts.config_path.as_ref())?;
443    config.apply_cli_overrides(args.verbose);
444
445    // Try to read rendered gear config from master host via env var BEFORE logging init
446    // so we can use the tracing config from master for OTEL
447    let rendered_config = match std::env::var(TOOLKIT_MODULE_CONFIG_ENV) {
448        Ok(json) => RenderedGearConfig::from_json(&json).ok(),
449        Err(_) => None,
450    };
451
452    // Build final config by merging:
453    // 1. Rendered config from master host (base)
454    // 2. Local config file (override)
455    // This also merges logging configuration for proper initialization
456    let (final_config, merged_logging, db_options) =
457        build_oop_config_and_db(&config, &opts.gear_name, rendered_config.as_ref())?;
458
459    // Use OpenTelemetry config from rendered (master) config only.
460    // OoP gears do not fall back to local config for telemetry — if the master
461    // does not provide an opentelemetry section, telemetry is skipped entirely.
462    #[cfg(feature = "otel")]
463    let otel_cfg = rendered_config
464        .as_ref()
465        .and_then(|rc| rc.opentelemetry.as_ref());
466
467    // Initialize OTEL tracing layer (if tracing is enabled)
468    #[cfg(feature = "otel")]
469    let otel_layer = otel_cfg
470        .filter(|cfg| cfg.tracing.enabled)
471        .map(crate::telemetry::init_tracing)
472        .transpose()?;
473    #[cfg(not(feature = "otel"))]
474    let otel_layer = None;
475
476    // Initialize OpenTelemetry metrics provider (if configured and enabled).
477    // Store error to log after logging is initialized.
478    #[cfg(feature = "otel")]
479    let metrics_init_error = otel_cfg
480        .filter(|cfg| cfg.metrics.enabled)
481        .and_then(|cfg| crate::telemetry::init::init_metrics_provider(cfg).err());
482
483    // Initialize logging with MERGED config (master base + local override)
484    init_logging_unified(&merged_logging, &config.server.home_dir, otel_layer);
485
486    // Now that logging is available, report deferred metrics init error
487    #[cfg(feature = "otel")]
488    if let Some(e) = metrics_init_error {
489        tracing::error!(error = %e, "OpenTelemetry metrics not initialized (OoP)");
490    }
491
492    // Register custom panic hook to reroute panic backtrace into tracing.
493    init_panic_tracing();
494
495    // Now we can log - report what we received from master
496    if let Some(ref rc) = rendered_config {
497        info!(
498            env_var = TOOLKIT_MODULE_CONFIG_ENV,
499            has_database = rc.database.is_some(),
500            has_config = !rc.config.is_null(),
501            has_logging = rc.logging.is_some(),
502            has_opentelemetry = rc.opentelemetry.is_some(),
503            "Received rendered config from master host"
504        );
505    } else if std::env::var(TOOLKIT_MODULE_CONFIG_ENV).is_ok() {
506        warn!(
507            env_var = TOOLKIT_MODULE_CONFIG_ENV,
508            "Failed to parse rendered config from master host, using local config only"
509        );
510    } else {
511        debug!(
512            env_var = TOOLKIT_MODULE_CONFIG_ENV,
513            "No rendered config from master host, using local config only"
514        );
515    }
516
517    info!(
518        gear =  %opts.gear_name,
519        instance_id = %instance_id,
520        directory_endpoint = %opts.directory_endpoint,
521        "OoP gear bootstrap starting"
522    );
523
524    // Print config and exit if requested
525    if opts.print_config {
526        print_config(&config);
527        return Ok(());
528    }
529
530    // Connect to DirectoryService
531    info!(
532        "Connecting to directory service at {}",
533        opts.directory_endpoint
534    );
535    let directory_client = DirectoryGrpcClient::connect(&opts.directory_endpoint).await?;
536    let directory_api: Arc<dyn DirectoryClient> = Arc::new(directory_client);
537
538    info!("Successfully connected to directory service");
539
540    // Start heartbeat loop in background using a child token from the root.
541    // This allows the heartbeat to be cancelled when the root token is cancelled.
542    let heartbeat_directory = Arc::clone(&directory_api);
543    let heartbeat_gear = opts.gear_name.clone();
544    let heartbeat_instance_id_str = instance_id.to_string();
545    let heartbeat_interval = Duration::from_secs(opts.heartbeat_interval_secs);
546    let heartbeat_cancel = cancel.child_token();
547
548    tokio::spawn(async move {
549        info!(
550            interval_secs = opts.heartbeat_interval_secs,
551            "Starting heartbeat loop"
552        );
553
554        loop {
555            tokio::select! {
556                () = heartbeat_cancel.cancelled() => {
557                    info!("Heartbeat loop stopping due to cancellation");
558                    break;
559                }
560                () = sleep(heartbeat_interval) => {
561                    match heartbeat_directory
562                        .send_heartbeat(&heartbeat_gear, &heartbeat_instance_id_str)
563                        .await
564                    {
565                        Ok(()) => {
566                            tracing::debug!("Heartbeat sent successfully");
567                        }
568                        Err(e) => {
569                            warn!(error = %e, "Failed to send heartbeat, will retry");
570                        }
571                    }
572                }
573            }
574        }
575    });
576
577    // Build config provider for gears
578    let config_provider = Arc::new(final_config);
579
580    // Keep a reference to directory_api for deregistration after shutdown
581    // Run the gear lifecycle with the root cancellation token.
582    // Shutdown is driven by the signal handler spawned above, not by ShutdownOptions::Signals.
583    // The DirectoryClient (gRPC client) is injected into the ClientHub so gears can access it.
584    info!("Starting gear lifecycle");
585    let run_options = RunOptions {
586        gears_cfg: config_provider,
587        db: db_options,
588        shutdown: ShutdownOptions::Token(cancel.clone()),
589        clients: vec![ClientRegistration::new::<dyn DirectoryClient>(
590            directory_api,
591        )],
592        instance_id,
593        oop: None, // OoP gears don't spawn other OoP gears
594        shutdown_deadline: None,
595    };
596
597    let result = run(run_options).await;
598
599    if let Err(ref e) = result {
600        error!(error = %e, "Gear runtime failed");
601    } else {
602        info!("Gear runtime completed successfully");
603    }
604
605    result
606}
607
608#[allow(unknown_lints, de1301_no_print_macros)] // direct stdout config print before exit
609fn print_config(config: &AppConfig) {
610    match config.to_yaml() {
611        Ok(yaml) => {
612            println!("{yaml}");
613        }
614        Err(e) => {
615            eprintln!("Failed to render config as YAML: {e}");
616        }
617    }
618}
619
620#[cfg(test)]
621#[cfg_attr(coverage_nightly, coverage(off))]
622#[path = "oop_tests.rs"]
623mod tests;