Skip to main content

toolkit/bootstrap/
run.rs

1use super::config::{get_gear_runtime_config, render_gear_config_for_oop};
2use super::host::{init_logging_unified, init_panic_tracing, normalize_path};
3use super::{AppConfig, RuntimeKind};
4use crate::backends::LocalProcessBackend;
5use crate::runtime::{
6    DbOptions, OopGearSpawnConfig, OopSpawnOptions, RunOptions, ShutdownOptions, run, shutdown,
7};
8use anyhow::Result;
9use figment::Figment;
10use figment::providers::Serialized;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use tokio_util::sync::CancellationToken;
14
15/// Spawn a signal handler task that cancels the provided token on SIGTERM/SIGINT.
16///
17/// This helper consolidates signal handling logic used by both `run_server` and `run_migrate`.
18/// The `context` parameter customizes log messages for better diagnostics.
19fn spawn_signal_handler(cancel: CancellationToken, context: &str) {
20    let context_owned = context.to_owned();
21    tokio::spawn(async move {
22        match shutdown::wait_for_shutdown().await {
23            Ok(()) => {
24                tracing::info!(target: "", "------------------");
25                tracing::info!("{}: shutdown signal received", context_owned);
26            }
27            Err(e) => {
28                tracing::warn!(
29                    error = %e,
30                    "{}: signal handler failed, falling back to ctrl_c()",
31                    context_owned
32                );
33                _ = tokio::signal::ctrl_c().await;
34            }
35        }
36        cancel.cancel();
37    });
38}
39
40/// # Errors
41///
42/// Returns an error if:
43/// - There was a critical error during initialization of the gears
44/// - Problems with the database or third-party services
45/// - An issue during runtime or shutdown
46///
47/// The TLS crypto provider is installed automatically as the first step of
48/// [`init_procedure`] (idempotent), so callers do not need to invoke
49/// [`super::init_crypto_provider`] explicitly.
50pub async fn run_server(config: AppConfig) -> Result<()> {
51    init_procedure(&config).map_err(|e| {
52        tracing::error!(error = %e, "Initialization failed");
53        e
54    })?;
55    tracing::info!("Initializing gears...");
56
57    // Generate process-level instance ID once at startup.
58    // This is shared by all gears in this process.
59    let instance_id = uuid::Uuid::new_v4();
60    tracing::info!(instance_id = %instance_id, "Generated process instance ID");
61
62    // Create root cancellation token for the entire process.
63    // This token drives shutdown for the gear runtime and all lifecycle/stateful gears.
64    let cancel = CancellationToken::new();
65
66    // Hook OS signals to the root token at the host level.
67    // This replaces the use of ShutdownOptions::Signals inside the runtime.
68    spawn_signal_handler(cancel.clone(), "server");
69
70    // Build config provider and resolve database options
71    let db_options = resolve_db_options(&config)?;
72
73    // Create OoP backend with cancellation token - it will auto-shutdown all processes on cancel
74    let oop_backend = LocalProcessBackend::new(cancel.clone());
75
76    // Build OoP spawn configuration
77    let oop_options = build_oop_spawn_options(&config, oop_backend)?;
78
79    // Run the ToolKit runtime with the root cancellation token.
80    // Shutdown is driven by the signal handler spawned above, not by ShutdownOptions::Signals.
81    // OoP gears are spawned after the start phase (once grpc-hub has bound its port).
82    let run_options = RunOptions {
83        gears_cfg: Arc::new(config),
84        db: db_options,
85        shutdown: ShutdownOptions::Token(cancel.clone()),
86        clients: vec![],
87        instance_id,
88        oop: oop_options,
89        shutdown_deadline: None,
90    };
91
92    let result = run(run_options).await;
93
94    // Graceful shutdown - flush remaining telemetry
95    #[cfg(feature = "otel")]
96    tracing_shutdown();
97
98    result
99}
100
101/// Run database migrations and exit.
102///
103/// This mode is designed for cloud deployment workflows where database
104/// migrations need to run as a separate step before starting the application.
105///
106/// Phases executed:
107/// - Pre-init (wire runtime internals)
108/// - DB migration (run all pending migrations)
109///
110/// The process exits after migrations complete. Any errors are reported
111/// and propagated as non-zero exit codes.
112///
113/// # Errors
114///
115/// Returns an error if:
116/// - No database configuration is found
117/// - Gear discovery fails
118/// - Pre-init phase fails
119/// - Migration phase fails
120///
121/// The TLS crypto provider is installed automatically as the first step of
122/// [`init_procedure`] (idempotent), so callers do not need to invoke
123/// [`super::init_crypto_provider`] explicitly.
124pub async fn run_migrate(config: AppConfig) -> Result<()> {
125    init_procedure(&config).map_err(|e| {
126        tracing::error!(error = %e, "Initialization failed");
127        e
128    })?;
129    tracing::info!("Starting migration mode...");
130
131    // Generate process-level instance ID for this migration run
132    let instance_id = uuid::Uuid::new_v4();
133    tracing::info!(instance_id = %instance_id, "Generated migration instance ID");
134
135    // Create cancellation token and wire it to OS signals
136    let cancel = CancellationToken::new();
137
138    // Hook OS signals to enable graceful cancellation of migrations
139    spawn_signal_handler(cancel.clone(), "migration");
140
141    // Build database options from configuration
142    let db_options = resolve_db_options(&config)?;
143
144    // Verify we have database configuration
145    if matches!(db_options, DbOptions::None) {
146        anyhow::bail!("Cannot run migrations: no database configuration found");
147    }
148
149    // Discover and build the gear registry
150    let registry = crate::registry::GearRegistry::discover_and_build()?;
151    tracing::info!(
152        gear_count = registry.gears().len(),
153        "Discovered gears for migration"
154    );
155
156    // Create the host runtime
157    let host = crate::runtime::HostRuntime::new(
158        registry,
159        Arc::new(config),
160        db_options,
161        Arc::new(crate::client_hub::ClientHub::new()),
162        cancel,
163        instance_id,
164        None, // No OoP spawning during migration
165    );
166
167    // Run only the migration phases (pre-init + DB migration)
168    let result = host.run_migration_phases().await;
169
170    // Graceful shutdown - flush remaining telemetry
171    #[cfg(feature = "otel")]
172    tracing_shutdown();
173
174    result?;
175
176    tracing::info!("All migrations completed successfully");
177    Ok(())
178}
179
180fn resolve_db_options(config: &AppConfig) -> Result<DbOptions> {
181    if config.database.is_none() {
182        tracing::warn!("No global database section found; running without databases");
183        return Ok(DbOptions::None);
184    }
185
186    tracing::info!("Using DbManager with Figment-based configuration");
187    let figment = Figment::new().merge(Serialized::defaults(config));
188    let db_manager = Arc::new(toolkit_db::DbManager::from_figment(
189        figment,
190        config.server.home_dir.clone(),
191    )?);
192    Ok(DbOptions::Manager(db_manager))
193}
194
195/// Build `OoP` spawn configuration from `AppConfig`.
196///
197/// This collects all gears with `type=oop` and prepares their spawn configuration.
198/// The actual spawning happens in the `HostRuntime` after the start phase.
199fn build_oop_spawn_options(
200    config: &AppConfig,
201    backend: LocalProcessBackend,
202) -> Result<Option<OopSpawnOptions>> {
203    let home_dir = PathBuf::from(&config.server.home_dir);
204    let mut gears = Vec::new();
205
206    for gear_name in config.gears.keys() {
207        if let Some(spawn_config) = try_build_oop_gear_config(config, gear_name, &home_dir)? {
208            gears.push(spawn_config);
209        }
210    }
211
212    if gears.is_empty() {
213        Ok(None)
214    } else {
215        tracing::info!(count = gears.len(), "Prepared OoP gears for spawning");
216        Ok(Some(OopSpawnOptions {
217            gears,
218            backend: Box::new(backend),
219        }))
220    }
221}
222
223/// Try to build `OoP` gear spawn config if gear is of type `OoP`
224fn try_build_oop_gear_config(
225    config: &AppConfig,
226    gear_name: &str,
227    home_dir: &Path,
228) -> Result<Option<OopGearSpawnConfig>> {
229    let Some(runtime_cfg) = get_gear_runtime_config(config, gear_name)? else {
230        return Ok(None);
231    };
232
233    if !matches!(runtime_cfg.mod_type, RuntimeKind::Oop) {
234        return Ok(None);
235    }
236
237    let exec_cfg = runtime_cfg.execution.as_ref().ok_or_else(|| {
238        anyhow::anyhow!("gear '{gear_name}' is type=oop but execution config is missing")
239    })?;
240
241    let binary = normalize_path(&exec_cfg.executable_path)?;
242    let spawn_args = exec_cfg.args.clone();
243    let env = exec_cfg.environment.clone();
244
245    // Render the complete gear config (with resolved DB)
246    let rendered_config = render_gear_config_for_oop(config, gear_name, home_dir)?;
247    let rendered_json = rendered_config.to_json()?;
248
249    tracing::debug!(
250        gear =  %gear_name,
251        "Prepared OoP gear config: db={}",
252        rendered_config.database.is_some()
253    );
254
255    Ok(Some(OopGearSpawnConfig {
256        gear_name: gear_name.to_owned(),
257        binary,
258        args: spawn_args,
259        env,
260        working_directory: exec_cfg.working_directory.clone(),
261        rendered_config_json: rendered_json,
262    }))
263}
264
265/// Initialize process-wide bootstrap state from a provided `&AppConfig`.
266///
267/// This helper performs the common startup sequence shared by server and migration modes.
268/// It does **not** load configuration; the caller is responsible for building and passing
269/// a valid `AppConfig`.
270///
271/// Steps performed:
272///
273/// - initializes tracing/logging (once, guarded by a process-wide `Once`) and metrics
274///   when OpenTelemetry is enabled
275/// - registers the panic hook used to route panics through tracing
276/// - emits a small startup span and version metadata for diagnostics
277///
278/// The rustls crypto provider is installed inside this function (idempotent
279/// `OnceLock`), so callers do not need to invoke
280/// [`super::init_crypto_provider`] separately. Direct callers of
281/// [`super::init_crypto_provider`] outside the bootstrap path (e.g. ad-hoc
282/// probe binaries that do not call `run_server` / `run_migrate`) are still
283/// supported.
284///
285/// # Errors
286///
287/// Returns an error if OpenTelemetry tracing initialization fails while
288/// tracing is enabled, or if the crypto provider installation fails.
289#[cfg_attr(not(feature = "otel"), allow(clippy::unnecessary_wraps))]
290pub fn init_procedure(config: &AppConfig) -> Result<()> {
291    // Install the rustls crypto provider FIRST — before anything that may
292    // touch TLS (OTLP exporter over HTTPS, DB connections, etc.). The call
293    // is process-wide and idempotent; calling it twice is safe.
294    super::init_crypto_provider()?;
295
296    // Build OpenTelemetry layer before logging
297    #[cfg(feature = "otel")]
298    let otel_layer = if config.opentelemetry.tracing.enabled {
299        Some(crate::telemetry::init::init_tracing(&config.opentelemetry)?)
300    } else {
301        None
302    };
303    #[cfg(not(feature = "otel"))]
304    let otel_layer = None;
305
306    // Initialize logging + otel in one Registry
307    init_logging_unified(&config.logging, &config.server.home_dir, otel_layer);
308
309    // Register custom panic hook to reroute panic backtrace into tracing.
310    init_panic_tracing();
311
312    // Initialize OpenTelemetry metrics (or confirm noop when disabled)
313    #[cfg(feature = "otel")]
314    if let Err(e) = crate::telemetry::init::init_metrics_provider(&config.opentelemetry) {
315        tracing::error!(error = %e, "OpenTelemetry metrics not initialized");
316    }
317
318    // One-time connectivity probe
319    #[cfg(feature = "otel")]
320    if config.opentelemetry.tracing.enabled
321        && let Err(e) = crate::telemetry::init::otel_connectivity_probe(&config.opentelemetry)
322    {
323        tracing::error!(error = %e, "OTLP connectivity probe failed");
324    }
325
326    // Smoke test span to confirm traces flow to Jaeger
327    tracing::info_span!("startup_check", app = config.server.name).in_scope(|| {
328        tracing::info!("startup span alive - traces should be visible in Jaeger");
329    });
330
331    tracing::info!(
332        version = env!("CARGO_PKG_VERSION"),
333        rust_version = env!("CARGO_PKG_RUST_VERSION"),
334        "{} Server starting",
335        config.server.name,
336    );
337
338    Ok(())
339}
340
341#[cfg(feature = "otel")]
342/// Flush compatibility shutdown hooks for OpenTelemetry tracing and metrics.
343///
344/// This delegates to the current telemetry shutdown helpers so callers can use a
345/// single bootstrap-level function during graceful shutdown.
346pub fn tracing_shutdown() {
347    crate::telemetry::init::shutdown_metrics();
348    crate::telemetry::init::shutdown_tracing();
349}