1/*
2 * SPDX-FileCopyrightText: 2025 Copyright (c) Contributors to the Eclipse Foundation
3 *
4 * See the NOTICE file(s) distributed with this work for additional
5 * information regarding copyright ownership.
6 *
7 * This program and the accompanying materials are made available under the
8 * terms of the Apache License Version 2.0 which is available at
9 * https://www.apache.org/licenses/LICENSE-2.0
10 *
11 * SPDX-License-Identifier: Apache-2.0
12 */
13
14// The `run_with_ext` async state machine grows with every enabled transport;
15// with `--all-features` its layout computation overflows rustc's default
16// query depth (128) on stable 1.97. Default limit otherwise.
17#![recursion_limit = "256"]
18
19use std::{
20 path::{Path, PathBuf},
21 sync::Arc,
22 time::Duration,
23};
24
25use backon::Retryable;
26use cda_interfaces::{config::ConfigSanity, dlt_ctx, storage_api::StorageError};
27use cda_plugin_communication_management::plugin::CommunicationPluginBuilder;
28use cda_plugin_security::{
29 DefaultSecurityPlugin, DefaultSecurityPluginData, SecurityPlugin, SecurityPluginLoader,
30};
31use cda_storage::LocalStorage;
32use cda_tracing::{OtelGuard, TracingSetupError, TracingWorkerGuard};
33use clap::{Parser, Subcommand};
34use tracing_subscriber::layer::SubscriberExt;
35
36use crate::{
37 config::{configfile::Configuration, generate::generate_config_cmd},
38 setup::PreLoadHook,
39 update::{UpdatePluginBuilder, create_default_update_plugin, update_plugin_fn},
40};
41
42pub mod cda_factory;
43pub mod config;
44pub mod error;
45pub mod mdd;
46pub mod setup;
47pub mod update;
48pub mod vehicle;
49
50pub use error::AppError;
51pub use setup::Setup;
52
53// Valgrind and other profing tools intercept the system allocator, whereas mimalloc
54// manages allocations internally. Keep mimalloc in normal builds but omit it
55// from profiling builds so allocation call stacks remain visible.
56#[cfg(not(feature = "heap-profiling"))]
57#[global_allocator]
58static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
59
60/// Environment variable to override the purge delay of mimalloc in milliseconds.
61#[cfg(not(feature = "heap-profiling"))]
62const MIMALLOC_PURGE_DELAY_ENV: &str = "MIMALLOC_PURGE_DELAY";
63
64/// Configures mimalloc to return unused memory to the OS immediately.
65///
66/// mimalloc only executes delayed purges when the owning thread calls into the
67/// allocator again. Idle tokio worker threads therefore keep freed memory (e.g. from
68/// startup or from serving `openapi.json`) resident indefinitely. Purging immediately
69/// cuts the resident memory after startup roughly in half.
70/// Can be overridden with the `MIMALLOC_PURGE_DELAY` environment variable.
71fn configure_allocator() {
72 #[cfg(not(feature = "heap-profiling"))]
73 if std::env::var_os(MIMALLOC_PURGE_DELAY_ENV).is_none() {
74 mimalloc::options::option_set(mimalloc::options::MiOption::PurgeDelay, 0);
75 }
76}
77
78const DOIP_HEALTH_COMPONENT_KEY: &str = "doip";
79
80#[cfg(feature = "health")]
81const MAIN_HEALTH_COMPONENT_KEY: &str = "main";
82
83#[derive(Subcommand, Debug)]
84pub enum Command {
85 /// Generate a reference TOML configuration file with all fields commented out
86 GenerateConfig {
87 /// Output file path (defaults to opensovd-cda.toml). Use "-" for stdout.
88 #[arg(short, long)]
89 output: Option<PathBuf>,
90 },
91}
92
93#[derive(Parser, Debug)]
94#[command(version, about, long_about = None)]
95pub struct AppArgs {
96 #[arg(short, long, env = "CDA_CONFIG_FILE")]
97 pub config: Option<PathBuf>,
98
99 #[command(subcommand)]
100 pub command: Option<Command>,
101
102 /// Directory with diagnostic databases to load, if none have
103 /// been loaded into storage before via the Update plugin.
104 #[arg(short = 'd', long)]
105 pub databases_dir: Option<String>,
106
107 #[arg(short, long)]
108 pub tester_address: Option<String>,
109
110 #[arg(long)]
111 pub tester_subnet: Option<String>,
112
113 #[arg(long)]
114 pub gateway_port: Option<u16>,
115
116 /// Protocol name used for com-param lookups
117 /// in the diagnostic database (matched case-insensitively).
118 /// Examples: `UDS_Ethernet_DoIP`, `UDS_Ethernet_DoIP_DOBT`
119 #[arg(long)]
120 pub protocol_name: Option<String>,
121
122 #[arg(long)]
123 pub listen_address: Option<String>,
124
125 #[arg(long)]
126 pub listen_port: Option<u16>,
127
128 /// Path to a Unix domain socket to bind the server to. Takes priority over
129 /// `listen_address`/`listen_port` when set.
130 #[arg(long)]
131 pub unix_socket: Option<String>,
132
133 #[arg(short, long)]
134 pub flash_files_path: Option<String>,
135
136 #[arg(long)]
137 pub file_logging: Option<bool>,
138
139 #[arg(long)]
140 pub log_file_dir: Option<String>,
141
142 #[arg(long)]
143 pub log_file_name: Option<String>,
144
145 #[arg(long)]
146 pub exit_no_database_loaded: Option<bool>,
147
148 #[arg(long)]
149 pub fallback_to_base_variant: Option<bool>,
150
151 /// Set to true, to rewrite mdd files without compression, which
152 /// reduces memory usage due to mmap significantly.
153 // Could use Action::SetFalse here, as the default is false but then we would have
154 // two different ways to set booleans (with and without `true`)
155 #[arg(long)]
156 pub mdd_decompress: Option<bool>,
157}
158
159impl AppArgs {
160 #[tracing::instrument(skip(self, config),
161 fields(
162 dlt_context = dlt_ctx!("MAIN"),
163 )
164 )]
165 pub fn update_config(self, config: &mut Configuration) {
166 if let Some(databases_dir) = self.databases_dir {
167 config.database.dir = databases_dir;
168 }
169 if let Some(exit_no_database_loaded) = self.exit_no_database_loaded {
170 config.database.exit_no_database_loaded = exit_no_database_loaded;
171 }
172 if let Some(fallback_to_base_variant) = self.fallback_to_base_variant {
173 config.database.fallback_to_base_variant = fallback_to_base_variant;
174 }
175 if let Some(flash_files_path) = self.flash_files_path {
176 config.flash_files_path = flash_files_path;
177 }
178 if let Some(tester_address) = self.tester_address {
179 config.doip.tester_address = tester_address;
180 }
181 if let Some(tester_subnet) = self.tester_subnet {
182 config.doip.tester_subnet = tester_subnet;
183 }
184 if let Some(gateway_port) = self.gateway_port {
185 config.doip.gateway_port = gateway_port;
186 }
187 if let Some(protocol_name) = self.protocol_name {
188 config.doip.protocol_name = protocol_name;
189 }
190 if self.listen_address.is_some() || self.listen_port.is_some() || self.unix_socket.is_some()
191 {
192 use crate::config::configfile::ServerTransport;
193
194 config.server = if let Some(unix_socket) = self.unix_socket {
195 // `--unix-socket` takes priority over `--listen-address`/`--listen-port`,
196 // matching the config file's documented priority.
197 ServerTransport::UnixSocket {
198 unix_socket,
199 address: crate::config::configfile::default_server_address(),
200 port: crate::config::configfile::default_server_port(),
201 }
202 } else {
203 let mut address = config.server.address().to_owned();
204 let mut port = config.server.port();
205 if let Some(listen_address) = self.listen_address {
206 address = listen_address;
207 }
208 if let Some(listen_port) = self.listen_port {
209 port = listen_port;
210 }
211 ServerTransport::Tcp {
212 address,
213 port,
214 unix_socket: None,
215 }
216 };
217 }
218
219 if let Some(file_logging) = self.file_logging {
220 config.logging.log_file_config.enabled = file_logging;
221 }
222 if let Some(log_file_dir) = self.log_file_dir {
223 config.logging.log_file_config.path = log_file_dir;
224 }
225 if let Some(log_file_name) = self.log_file_name {
226 config.logging.log_file_config.name = log_file_name;
227 }
228 if let Some(mdd_decompress) = self.mdd_decompress {
229 config.flat_buf.mdd_decompress = mdd_decompress;
230 }
231 }
232}
233
234/// Parse CLI arguments and start the CDA with the default startup flow.
235///
236/// # Errors
237/// Returns [`AppError`] if configuration loading, validation, or startup fails.
238pub async fn run_from_cli() -> Result<(), AppError> {
239 // Box is needed because it's a large future with a size of 16392 bytes
240 Box::pin(run(AppArgs::parse())).await
241}
242
243#[tracing::instrument(
244 skip(args, setup),
245 fields(
246 dlt_context = dlt_ctx!("MAIN"),
247 )
248)]
249/// Run the CDA from parsed CLI arguments with a custom [`Setup`].
250///
251/// This is the primary setup-aware entry point. Pass a [`Setup`] created with
252/// [`Setup::new`] and optionally configured with
253/// [`Setup::with_preload`] / [`Setup::with_update_plugin`].
254///
255/// # Errors
256/// Returns [`AppError`] if configuration loading, validation, or startup fails.
257pub async fn run_with_ext<SP, SL, UPB, CPB>(
258 args: AppArgs,
259 setup: Setup<SP, SL, UPB, CPB>,
260) -> Result<(), AppError>
261where
262 SP: SecurityPlugin,
263 SL: SecurityPluginLoader,
264 UPB: UpdatePluginBuilder<SP>,
265 CPB: CommunicationPluginBuilder,
266{
267 if let Some(Command::GenerateConfig { output }) = args.command.as_ref() {
268 // Exiting after generating config is on purpose.
269 return generate_config_cmd(output.as_ref());
270 }
271
272 configure_allocator();
273
274 let config_file = match &args.config {
275 Some(config_file) => {
276 if config_file.exists() {
277 config_file
278 } else {
279 // ignore `config-optional` feature here, because it's specifically for when no config was specified
280 return Err(AppError::ConfigurationError {
281 message: format!(
282 "Specified configuration file {} does not exist",
283 config_file.display()
284 ),
285 source: None,
286 });
287 }
288 }
289 None => Path::new("opensovd-cda.toml"),
290 };
291
292 let (mut config, disk_loaded) = config::load_config_with_fallback(config_file);
293 if !disk_loaded {
294 config::require_config_source()?;
295 }
296
297 // Command line arguments always take precedence over file configuration.
298 args.update_config(&mut config);
299
300 config.validate_sanity().map_err(AppError::from)?;
301
302 run_with_ext_from_config(config, setup).await
303}
304
305/// Waits until a stage during startup is done or shutdown is requested.
306/// This makes sure we can cancel the startup.
307async fn await_startup_stage<T>(
308 stage: impl Future<Output = Result<T, AppError>>,
309 shutdown_signal: cda_interfaces::ShutdownSignal,
310) -> Result<Option<T>, AppError> {
311 tokio::select! {
312 result = stage => result.map(Some),
313 () = shutdown_signal => Ok(None),
314 }
315}
316
317/// Start the CDA runtime from a prepared configuration with a custom [`Setup`].
318///
319/// This is the setup-aware version of [`run_with_config`]. Supply a [`Setup`] to
320/// configure a custom update plugin and/or a preload hook:
321///
322/// ```rust,ignore
323/// use opensovd_cda_lib::{Setup, run_with_ext_from_config, update::update_plugin_fn};
324/// use opensovd_cda_lib::config::configfile::Configuration;
325///
326/// let config: Configuration = // ... load or construct ...
327/// # todo!();
328///
329/// run_with_ext_from_config::<MySecurityPlugin, MySecurityLoader, _, _>(
330/// config,
331/// Setup::new().with_update_plugin(update_plugin_fn(|infra| async move {
332/// Ok(MyPlugin::new(infra))
333/// })),
334/// ).await?;
335/// ```
336///
337/// # Errors
338/// Returns [`AppError`] if tracing setup, webserver startup, data loading, or route setup fails.
339pub async fn run_with_ext_from_config<SP, SL, UPB, CPB>(
340 config: Configuration,
341 setup: Setup<SP, SL, UPB, CPB>,
342) -> Result<(), AppError>
343where
344 SP: SecurityPlugin,
345 SL: SecurityPluginLoader,
346 UPB: UpdatePluginBuilder<SP>,
347 CPB: CommunicationPluginBuilder,
348{
349 let tracing_guards = if setup.initialize_tracing {
350 setup_tracing(&config)?
351 } else {
352 TracingGuards {
353 _file: None,
354 _otel: None,
355 }
356 };
357 tracing::info!("Starting CDA - version {}", cda_version());
358 // Emitted here rather than in `validate_sanity`, which runs before tracing is set up.
359 config
360 .database
361 .naming_convention
362 .warn_on_non_standard_categories();
363
364 let webserver_state = init_webserver(
365 &config,
366 setup.pre_load,
367 tracing_guards,
368 setup.shutdown_signal,
369 )
370 .await?;
371
372 tracing::debug!("Webserver is running. Loading SOVD routes...");
373
374 // After the webserver, so health is served while waiting for the storage.
375 // Before loading, so nothing reads from storage that is not recovered yet.
376 let Some(storage) = await_startup_stage(
377 initialize_storage(&config),
378 webserver_state.shutdown_signal.clone(),
379 )
380 .await?
381 else {
382 tracing::info!("Shutdown requested while waiting for storage, exiting cleanly");
383 return Ok(());
384 };
385
386 let Some(vehicle_data) = await_startup_stage(
387 vehicle::load_vehicle_data::<SP>(
388 &config,
389 webserver_state.health_state.as_ref(),
390 &storage,
391 setup.lock_priority_policy,
392 ),
393 webserver_state.shutdown_signal.clone(),
394 )
395 .await?
396 else {
397 tracing::info!("Shutdown requested during database load, exiting cleanly");
398 return Ok(());
399 };
400
401 if vehicle_data.databases.is_empty() && config.database.exit_no_database_loaded {
402 return Err(AppError::ResourceError(
403 "No database loaded, exiting as configured".to_string(),
404 ));
405 }
406
407 // Retained for the full server lifetime, so its event dispatcher keeps
408 // running until explicit shutdown.
409 let Some(communication_runtime) = await_startup_stage(
410 setup::setup_runtime_routes::<SP, SL, UPB, CPB>(
411 config,
412 vehicle_data,
413 &webserver_state,
414 setup.build_update_plugin,
415 setup.build_communication_plugin,
416 storage,
417 ),
418 webserver_state.shutdown_signal.clone(),
419 )
420 .await?
421 else {
422 tracing::info!("Shutdown requested during runtime setup, exiting cleanly");
423 return Ok(());
424 };
425
426 tracing::info!("CDA fully initialized and ready to serve requests");
427 if let Some(provider) = &webserver_state.main_health_provider {
428 provider.update_status(cda_health::Status::Up).await;
429 }
430
431 // signal readiness only once loading has finished, so a `Type=notify` unit
432 // doesn't consider the CDA started while it is still loading databases.
433 #[cfg(feature = "systemd-notify")]
434 cda_extra::notify_ready();
435
436 // Wait for shutdown signal
437 webserver_state.shutdown_signal.clone().await;
438 tracing::info!("Shutting down...");
439 cda_interfaces::Shutdown::shutdown(&*communication_runtime.plugin).await;
440 webserver_state.join().await
441}
442
443/// Run the CDA from parsed CLI arguments.
444///
445/// Uses the default security, lock-priority, and runtime update plugins.
446/// To customize startup behavior, use [`run_with_ext`] instead.
447///
448/// # Errors
449/// Returns [`AppError`] if configuration loading, validation, or startup fails.
450pub async fn run(args: AppArgs) -> Result<(), AppError> {
451 Box::pin(run_with_ext::<
452 DefaultSecurityPluginData,
453 DefaultSecurityPlugin,
454 _,
455 _,
456 >(
457 args,
458 Setup::new().with_update_plugin(update_plugin_fn(|infra| async move {
459 create_default_update_plugin::<DefaultSecurityPluginData, DefaultSecurityPlugin>(infra)
460 .await
461 })),
462 ))
463 .await
464}
465
466/// Start the CDA runtime from a prepared configuration.
467///
468/// Uses the default security, lock-priority, and runtime update plugins.
469/// To customize startup behavior, use [`run_with_ext_from_config`] instead.
470///
471/// # Errors
472/// Returns [`AppError`] if tracing setup, webserver startup, data loading, or route setup fails.
473pub async fn run_with_config(config: Configuration) -> Result<(), AppError> {
474 Box::pin(run_with_ext_from_config::<
475 DefaultSecurityPluginData,
476 DefaultSecurityPlugin,
477 _,
478 _,
479 >(
480 config,
481 Setup::new().with_update_plugin(update_plugin_fn(
482 |infra: setup::CdaRuntime<DefaultSecurityPluginData>| async move {
483 create_default_update_plugin::<DefaultSecurityPluginData, DefaultSecurityPlugin>(
484 infra,
485 )
486 .await
487 },
488 )),
489 ))
490 .await
491}
492
493async fn init_webserver(
494 config: &Configuration,
495 pre_load: Option<PreLoadHook>,
496 tracing_guards: TracingGuards,
497 shutdown_signal: Option<cda_interfaces::ShutdownSignal>,
498) -> Result<ApplicationState, AppError> {
499 // Vendor overrides are registered via `linkme` distributed slices, whose
500 // final contents are only known after linking; this checks that at most
501 // one override is linked in per overridable function before any of them
502 // are used. Every crate that defines vendor-overridable functions must
503 // be listed here.
504 if let Err(errors) = cda_core::validate_vendor_overrides() {
505 return Err(AppError::InitializationFailed(format!(
506 "Vendor override configuration error(s): {}",
507 errors.join("; ")
508 )));
509 }
510
511 let webserver_config = match config.server.clone() {
512 crate::config::configfile::ServerTransport::Tcp { address, port, .. } => {
513 cda_sovd::WebServerConfig::Tcp {
514 host: address,
515 port,
516 }
517 }
518 crate::config::configfile::ServerTransport::UnixSocket { unix_socket, .. } => {
519 cda_sovd::WebServerConfig::UnixSocket { path: unix_socket }
520 }
521 };
522
523 let clonable_shutdown_signal = shutdown_signal
524 .unwrap_or_else(|| cda_interfaces::shutdown_signal(crate::shutdown_signal()));
525
526 let (dynamic_router, webserver_task) =
527 cda_sovd::launch_webserver(webserver_config.clone(), clonable_shutdown_signal.clone())
528 .await?;
529
530 let mut webserver_state = ApplicationState {
531 _tracing_guards: tracing_guards,
532 dynamic_router,
533 webserver_task: Some(webserver_task),
534 shutdown_signal: clonable_shutdown_signal,
535 health_state: None,
536 main_health_provider: None,
537 };
538
539 #[cfg(feature = "health")]
540 let (health_state, main_health_provider) = if config.health.enabled {
541 let health_state = cda_health::add_health_routes(
542 &webserver_state.dynamic_router,
543 cda_version().to_owned(),
544 )
545 .await;
546 let main_health_provider = Arc::new(cda_health::StatusHealthProvider::new(
547 cda_health::Status::Starting,
548 ));
549 let registration = health_state
550 .register_provider(
551 MAIN_HEALTH_COMPONENT_KEY,
552 Arc::clone(&main_health_provider) as Arc<dyn cda_health::HealthProvider>,
553 )
554 .await
555 .map_err(|e| AppError::InitializationFailed(e.to_string()));
556 registration?;
557 (Some(health_state), Some(main_health_provider))
558 } else {
559 (None, None)
560 };
561
562 #[cfg(not(feature = "health"))]
563 let (health_state, main_health_provider): (
564 Option<cda_health::HealthState>,
565 Option<Arc<cda_health::StatusHealthProvider>>,
566 ) = (None, None);
567
568 webserver_state.health_state = health_state;
569 webserver_state.main_health_provider = main_health_provider;
570
571 #[cfg(feature = "systemd-notify")]
572 let _sd_notify_task = cda_extra::create_sd_notify_task(
573 webserver_state.health_state.clone(),
574 webserver_state.shutdown_signal.clone(),
575 );
576
577 register_version_endpoints(&webserver_state.dynamic_router).await;
578 cda_sovd::add_openapi_routes(&webserver_state.dynamic_router).await;
579
580 if let Some(hook) = pre_load {
581 hook(webserver_state.dynamic_router.clone()).await?;
582 }
583
584 Ok(webserver_state)
585}
586
587async fn register_version_endpoints(dynamic_router: &cda_sovd::dynamic_router::DynamicRouter) {
[docs]588 // [[ dimpl~sovd-api-version-endpoint, Register Version Endpoint ]]
589 let serde_json::Value::Object(version_info) = serde_json::json!({
590 "id": "version",
591 "data": {
592 "name": "Eclipse OpenSOVD Classic Diagnostic Adapter",
593 "api": {
594 "version": "1.1"
595 },
596 "implementation": {
597 "version": cda_version(),
598 "commit": env!("GIT_COMMIT_HASH").to_owned(),
599 "build_date": env!("BUILD_DATE").to_owned(),
600 }
601 }
602 }) else {
603 tracing::error!("Failed to build version information");
604 return;
605 };
606 cda_sovd::add_static_data_endpoint(
607 dynamic_router,
608 version_info.clone(),
609 "/vehicle/v15/apps/sovd2uds/data/version",
610 )
611 .await;
612 cda_sovd::add_static_data_endpoint(dynamic_router, version_info, "/vehicle/v15/data/version")
613 .await;
614}
615
616/// Collected webserver state produced by [`init_webserver`].
617///
618/// Passed to [`setup::setup_runtime_routes`] and [`run_with_ext_from_config`] so that
619/// the shutdown signal and health provider are accessible after the webserver is started.
620///
621/// Dropping this value aborts the webserver task, so all error paths are covered
622/// automatically without any explicit cleanup calls.
623pub(crate) struct ApplicationState {
624 _tracing_guards: TracingGuards,
625 pub dynamic_router: cda_sovd::dynamic_router::DynamicRouter,
626 webserver_task: Option<tokio::task::JoinHandle<()>>,
627 pub shutdown_signal: cda_interfaces::ShutdownSignal,
628 health_state: Option<cda_health::HealthState>,
629 main_health_provider: Option<Arc<cda_health::StatusHealthProvider>>,
630}
631
632impl Drop for ApplicationState {
633 fn drop(&mut self) {
634 if let Some(task) = self.webserver_task.take() {
635 task.abort();
636 }
637 }
638}
639
640impl ApplicationState {
641 /// Waits for the normally signaled webserver task to finish.
642 async fn join(mut self) -> Result<(), AppError> {
643 if let Some(task) = self.webserver_task.take() {
644 task.await
645 .map_err(|e| AppError::RuntimeError(format!("Webserver task join error: {e}")))?;
646 }
647 Ok(())
648 }
649}
650
651/// # Panics
652/// Panics if the OS signal handlers cannot be installed.
653pub async fn shutdown_signal() {
654 let ctrl_c = async {
655 tokio::signal::ctrl_c()
656 .await
657 .expect("failed to install Ctrl+C handler");
658 };
659
660 #[cfg(unix)]
661 let terminate = async {
662 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
663 .expect("failed to install signal handler")
664 .recv()
665 .await;
666 };
667
668 #[cfg(not(unix))]
669 let terminate = std::future::pending::<()>();
670
671 tokio::select! {
672 () = ctrl_c => {},
673 () = terminate => {},
674 }
675}
676
677pub struct TracingGuards {
678 _file: Option<TracingWorkerGuard>,
679 _otel: Option<OtelGuard>,
680}
681
682/// # Errors
683/// Returns [`TracingSetupError`] if subscriber or exporter initialization fails.
684pub fn setup_tracing(config: &Configuration) -> Result<TracingGuards, TracingSetupError> {
685 let tracing = cda_tracing::new();
686 let mut layers = vec![];
687 layers.push(cda_tracing::new_term_subscriber(&config.logging));
688 #[cfg(feature = "tokio-tracing")]
689 layers.push(cda_tracing::new_tokio_tracing(
690 &config.logging.tokio_tracing,
691 )?);
692 let otel_guard = if config.logging.otel.enabled {
693 println!(
694 "Starting OpenTelemetry tracing with {}",
695 config.logging.otel.endpoint
696 );
697 let (guard, metrics_layer, otel_layer) =
698 cda_tracing::new_otel_subscriber(&config.logging.otel)?;
699 layers.push(metrics_layer);
700 layers.push(otel_layer);
701 Some(guard)
702 } else {
703 None
704 };
705
706 let file_guard = if config.logging.log_file_config.enabled {
707 let (guard, file_layer) =
708 cda_tracing::new_file_subscriber(&config.logging.log_file_config)?;
709 layers.push(file_layer);
710 Some(guard)
711 } else {
712 None
713 };
714
715 #[cfg(feature = "dlt-tracing")]
716 if config.logging.dlt_tracing.enabled {
717 layers.push(cda_tracing::new_dlt_tracing(&config.logging.dlt_tracing)?);
718 }
719
720 cda_tracing::init_tracing(tracing.with(layers))?;
721 Ok(TracingGuards {
722 _file: file_guard,
723 _otel: otel_guard,
724 })
725}
726
727/// Opens the storage, retrying while its directory does not exist yet, e.g.
728/// because the storage is not mounted yet.
729///
730/// Opening recovers an interrupted transaction, so that must not happen before
731/// the storage is there: there would be nothing to recover, and the storage
732/// would later be used in an indeterminate state.
733///
734/// # Errors
735/// Returns [`AppError::InitializationFailed`] if the storage directory does not
736/// appear within the retry budget, or opening fails otherwise, e.g. because
737/// recovery fails.
738async fn initialize_storage(config: &Configuration) -> Result<Arc<LocalStorage>, AppError> {
739 let storage_dir = &config.runtime_update_config.storage_dir;
740 let delay = Duration::from_millis(config.runtime_update_config.storage_dir_load_retry_delay_ms);
741 let attempts = config.runtime_update_config.storage_dir_load_retry_attempts;
742
743 let storage = (async || LocalStorage::new(storage_dir))
744 .retry(
745 backon::ConstantBuilder::new()
746 .with_delay(delay)
747 .with_max_times(attempts),
748 )
749 .when(|error| {
750 matches!(error, StorageError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
751 })
752 .notify(|error, delay: Duration| {
753 tracing::warn!(
754 storage_dir = %storage_dir,
755 error = %error,
756 "Storage not available, retrying in {delay:?}"
757 );
758 })
759 .await
760 .map_err(|e| {
761 AppError::InitializationFailed(format!(
762 "Failed to open storage {storage_dir}: {e}"
763 ))
764 })?;
765
766 Ok(Arc::new(storage))
767}
768
769/// Returns the CDA version string, which is either
770/// the value of the `CDA_VERSION` environment variable (if set)
771/// or the Cargo package version.
772#[must_use]
773pub fn cda_version() -> &'static str {
774 option_env!("CDA_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))
775}
776
777#[cfg(test)]
778mod cli_args_tests {
779 use clap::Parser;
780
781 use super::*;
782
783 #[test]
784 fn unix_socket_flag_updates_server_config() {
785 let args = AppArgs::parse_from(["cda", "--unix-socket", "/run/cda.sock"]);
786 let mut config = crate::config::configfile::Configuration::default();
787 args.update_config(&mut config);
788
789 assert!(matches!(
790 config.server,
791 crate::config::configfile::ServerTransport::UnixSocket { ref unix_socket, .. }
792 if unix_socket == "/run/cda.sock"
793 ));
794 }
795
796 #[test]
797 fn without_unix_socket_flag_server_config_unix_socket_stays_none() {
798 let args = AppArgs::parse_from(["cda"]);
799 let mut config = crate::config::configfile::Configuration::default();
800 args.update_config(&mut config);
801
802 assert!(matches!(
803 config.server,
804 crate::config::configfile::ServerTransport::Tcp {
805 unix_socket: None,
806 ..
807 }
808 ));
809 }
810}
811
812#[cfg(test)]
813mod webserver_lifecycle_tests {
814 use super::*;
815
816 #[tokio::test]
817 async fn startup_stage_is_cancelled_when_shutdown_arrives() {
818 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
819 let shutdown_signal = cda_interfaces::shutdown_signal(async move {
820 let _ = shutdown_rx.await;
821 });
822 shutdown_tx.send(()).unwrap();
823
824 let result = await_startup_stage(
825 std::future::pending::<Result<(), AppError>>(),
826 shutdown_signal,
827 )
828 .await
829 .unwrap();
830
831 assert!(result.is_none());
832 }
833
834 #[tokio::test]
835 async fn startup_stage_completes_before_shutdown() {
836 let result = await_startup_stage(
837 std::future::ready(Ok(42)),
838 cda_interfaces::shutdown_signal(std::future::pending()),
839 )
840 .await
841 .unwrap();
842
843 assert_eq!(result, Some(42));
844 }
845
846 #[tokio::test]
847 async fn drop_aborts_webserver_task() {
848 let task = tokio::spawn(std::future::pending::<()>());
849 let abort_handle = task.abort_handle();
850
851 let state = ApplicationState {
852 _tracing_guards: TracingGuards {
853 _file: None,
854 _otel: None,
855 },
856 dynamic_router: cda_sovd::dynamic_router::DynamicRouter::new(),
857 webserver_task: Some(task),
858 shutdown_signal: cda_interfaces::shutdown_signal(std::future::pending()),
859 health_state: None,
860 main_health_provider: None,
861 };
862
863 drop(state);
864
865 // abort() is asynchronous; yield to let the cancellation propagate.
866 tokio::task::yield_now().await;
867 assert!(abort_handle.is_finished());
868 }
869
870 #[tokio::test]
871 async fn cda_stays_running_when_no_database_loaded_and_exit_flag_is_false() {
872 fn available_port() -> u16 {
873 std::net::TcpListener::bind("127.0.0.1:0")
874 .expect("Bind temporary port")
875 .local_addr()
876 .expect("Read temporary port")
877 .port()
878 }
879
880 let database_dir = tempfile::tempdir().expect("Create empty database directory");
881 let storage_dir = tempfile::tempdir().expect("Create empty storage directory");
882 let mut config = Configuration::default();
883 config.server = crate::config::configfile::ServerTransport::Tcp {
884 address: "0.0.0.0".to_owned(),
885 port: available_port(),
886 unix_socket: None,
887 };
888 config.doip.tester_address = "127.0.0.1".to_owned();
889 config.doip.gateway_port = available_port();
890 config.database.dir = database_dir.path().to_string_lossy().into_owned();
891 config.database.exit_no_database_loaded = false;
892 config.runtime_update_config.storage_dir =
893 storage_dir.path().to_string_lossy().into_owned();
894
895 let task = tokio::spawn(run_with_ext_from_config(
896 config,
897 Setup::<DefaultSecurityPluginData, DefaultSecurityPlugin>::new()
898 .with_existing_tracing()
899 .with_update_plugin(update_plugin_fn(
900 |infra: setup::CdaRuntime<DefaultSecurityPluginData>| async move {
901 create_default_update_plugin::<
902 DefaultSecurityPluginData,
903 DefaultSecurityPlugin,
904 >(infra)
905 .await
906 },
907 )),
908 ));
909
910 cda_interfaces::util::tokio_ext::sleep_for(Duration::from_millis(250)).await;
911 assert!(
912 !task.is_finished(),
913 "CDA exited without any loaded database"
914 );
915
916 task.abort();
917 task.await.expect_err("CDA task must be cancelled");
918 }
919}
920
921#[cfg(test)]
922mod storage_init_tests {
923 use super::*;
924
925 fn config_for(storage_dir: &Path, attempts: usize) -> Configuration {
926 let mut config = Configuration::default();
927 config.runtime_update_config.storage_dir = storage_dir.to_string_lossy().into_owned();
928 config.runtime_update_config.storage_dir_load_retry_attempts = attempts;
929 config.runtime_update_config.storage_dir_load_retry_delay_ms = 10;
930 config
931 }
932
933 #[tokio::test]
934 async fn recovers_storage_that_appears_after_startup() {
935 let mount_point = tempfile::tempdir().unwrap();
936 let storage_dir = mount_point.path().join("cda");
937 let config = config_for(&storage_dir, 100);
938
939 // Mounted late, carrying leftovers of an interrupted transaction.
940 let mount = {
941 let storage_dir = storage_dir.clone();
942 tokio::spawn(async move {
943 cda_interfaces::util::tokio_ext::sleep_for(Duration::from_millis(50)).await;
944 let staging = storage_dir.join("journal").join("staging");
945 std::fs::create_dir_all(&staging).unwrap();
946 std::fs::write(staging.join("orphan.tmp"), b"partial").unwrap();
947 })
948 };
949
950 let storage = initialize_storage(&config).await;
951 mount.await.unwrap();
952
953 assert!(storage.is_ok());
954 assert!(
955 !storage_dir.join("journal/staging/orphan.tmp").exists(),
956 "recovery must run on the storage once it is present"
957 );
958 }
959
960 #[tokio::test]
961 async fn storage_that_never_appears_fails_startup() {
962 let mount_point = tempfile::tempdir().unwrap();
963 let config = config_for(&mount_point.path().join("cda"), 2);
964
965 let storage = initialize_storage(&config).await;
966
967 assert!(matches!(storage, Err(AppError::InitializationFailed(_))));
968 }
969}