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//! - 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;