Skip to main content

aion_server/config/
mod.rs

1//! Runtime configuration loading and validation for `aion-server`.
2
3use std::{
4    collections::HashSet,
5    fs,
6    net::SocketAddr,
7    path::{Path, PathBuf},
8    time::Duration,
9};
10
11use serde::Deserialize;
12
13use crate::error::ServerError;
14
15/// Environment variable configuration loader.
16pub mod env;
17/// File-based configuration loader.
18pub mod file;
19
20const DEFAULT_HTTP_ADDRESS: SocketAddr =
21    SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 8080);
22const DEFAULT_GRPC_ADDRESS: SocketAddr =
23    SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 50051);
24
25/// Command-line configuration overrides applied after file and environment values.
26#[derive(Debug, Default)]
27pub struct CliOverrides {
28    /// Optional explicit config path from `--config`.
29    pub config_path: Option<PathBuf>,
30    /// Override for `[server].listen_address`.
31    pub listen_address: Option<SocketAddr>,
32    /// Override for `[store].url`.
33    pub store_url: Option<String>,
34    /// Override for `[runtime].scheduler_threads`.
35    pub scheduler_threads: Option<usize>,
36    /// Override for `[drain].timeout_seconds`.
37    pub drain_timeout_seconds: Option<u64>,
38    /// Additional workflow package archives loaded after config and auto-discovered packages.
39    pub workflow_packages: Vec<PathBuf>,
40}
41
42/// Complete merged server configuration.
43#[derive(Clone, Debug, Deserialize)]
44#[serde(default, deny_unknown_fields)]
45#[derive(Default)]
46pub struct ServerConfig {
47    /// Public listener and transport addresses.
48    pub server: ServerSection,
49    /// Event-store backend configuration.
50    pub store: StoreConfig,
51    /// Engine runtime settings.
52    pub runtime: RuntimeSection,
53    /// Shutdown drain settings.
54    pub drain: DrainConfig,
55    /// Authentication settings defined by the operations config surface.
56    pub auth: AuthConfig,
57    /// Metrics endpoint settings.
58    pub metrics: MetricsConfig,
59    /// Namespace defaults.
60    pub namespaces: NamespacesConfig,
61    /// Optional TLS material for transports that require it.
62    pub tls: Option<TlsConfig>,
63    /// Static dashboard asset bundle location.
64    pub dashboard: DashboardConfig,
65    /// Namespace resolver construction mode retained for existing transports.
66    pub namespace: NamespaceConfig,
67    /// Remote-worker heartbeat policy.
68    pub worker: WorkerConfig,
69    /// WebSocket event streaming policy.
70    pub websocket: WebSocketConfig,
71    /// Workflow package archives loaded into the engine at startup.
72    pub workflow_packages: Vec<PathBuf>,
73    /// Operator deploy API settings.
74    pub deploy: DeployConfig,
75}
76
77/// Public transport listener addresses from `[server]`.
78#[derive(Clone, Debug, Deserialize)]
79#[serde(default, deny_unknown_fields)]
80pub struct ServerSection {
81    /// HTTP/JSON and dashboard listener.
82    pub listen_address: SocketAddr,
83    /// gRPC API and worker-protocol listener.
84    pub grpc_address: SocketAddr,
85}
86
87/// Supported event-store backend names.
88#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
89#[serde(rename_all = "lowercase")]
90pub enum StoreBackend {
91    /// In-memory store for local development.
92    Memory,
93    /// libSQL durable store.
94    LibSql,
95}
96
97/// Event-store backend configuration from `[store]`.
98#[derive(Clone, Debug, Deserialize)]
99#[serde(default, deny_unknown_fields)]
100pub struct StoreConfig {
101    /// Selected backing store implementation.
102    pub backend: StoreBackend,
103    /// Backend URL/path. For libSQL this is the embedded database path; for memory it is ignored.
104    pub url: Option<String>,
105}
106
107/// Engine runtime settings from `[runtime]`.
108#[derive(Clone, Debug, Deserialize)]
109#[serde(default, deny_unknown_fields)]
110pub struct RuntimeSection {
111    /// Number of scheduler worker threads.
112    pub scheduler_threads: usize,
113    /// Engine reply deadline for workflow queries, in milliseconds.
114    /// REQUIRED — the server always mounts `/workflows/query`, so the query
115    /// reply deadline must be an explicit operator decision; there is no
116    /// default. The engine builder is equally explicit-no-default.
117    pub query_timeout_ms: Option<u64>,
118}
119
120/// Graceful drain settings from `[drain]`.
121#[derive(Clone, Debug, Deserialize)]
122#[serde(default, deny_unknown_fields)]
123pub struct DrainConfig {
124    /// Maximum drain duration in seconds.
125    pub timeout_seconds: u64,
126}
127
128/// Authentication configuration applied at adapter boundaries.
129#[derive(Clone, Debug, Deserialize)]
130#[serde(default, deny_unknown_fields)]
131pub struct AuthConfig {
132    /// Whether authentication is enabled.
133    pub enabled: bool,
134    /// JWKS URL used by AO-006 auth validation.
135    pub jwks_url: Option<String>,
136    /// JWKS refresh interval in seconds.
137    pub jwks_refresh_seconds: u64,
138}
139
140/// Metrics endpoint settings from `[metrics]`.
141#[derive(Clone, Debug, Deserialize)]
142#[serde(default, deny_unknown_fields)]
143pub struct MetricsConfig {
144    /// Whether metrics are exposed.
145    pub enabled: bool,
146}
147
148/// Namespace defaults from `[namespaces]`.
149#[derive(Clone, Debug, Deserialize)]
150#[serde(default, deny_unknown_fields)]
151pub struct NamespacesConfig {
152    /// Default namespace used for local callers and worker dispatch.
153    pub default: String,
154}
155
156/// Public transport listener addresses retained for existing adapter code.
157#[derive(Clone, Debug, Deserialize)]
158#[serde(default, deny_unknown_fields)]
159pub struct ListenConfig {
160    /// gRPC API and worker-protocol listener.
161    pub grpc: SocketAddr,
162    /// HTTP/JSON and dashboard listener.
163    pub http: SocketAddr,
164}
165
166/// TLS certificate and private-key material.
167#[derive(Clone, Debug, Deserialize)]
168#[serde(deny_unknown_fields)]
169pub struct TlsConfig {
170    /// Certificate chain path supplied by the operator.
171    pub certificate_chain_path: PathBuf,
172    /// Private-key path supplied by the operator.
173    pub private_key_path: PathBuf,
174}
175
176/// Static dashboard asset configuration.
177#[derive(Clone, Debug, Deserialize)]
178#[serde(default, deny_unknown_fields)]
179pub struct DashboardConfig {
180    /// Operator-selected bundle source.
181    pub source: DashboardAssetSource,
182}
183
184/// Static dashboard bundle source.
185#[derive(Clone, Debug, Deserialize)]
186pub enum DashboardAssetSource {
187    /// Serve the built bundle from an operator-supplied directory.
188    FileSystem {
189        /// Directory containing `index.html` and built asset files.
190        asset_path: PathBuf,
191    },
192    /// Serve the compile-time embedded bundle.
193    Embedded,
194}
195
196/// Namespace resolver construction mode.
197#[derive(Clone, Debug, Deserialize)]
198#[serde(default, deny_unknown_fields)]
199pub struct NamespaceConfig {
200    /// Deployment-selected namespace mapping mode.
201    pub mode: NamespaceMode,
202}
203
204/// Supported namespace mapping modes.
205#[derive(Clone, Debug, Deserialize)]
206pub enum NamespaceMode {
207    /// All authorized namespaces share the configured engine instance.
208    SharedEngine,
209    /// Namespace authorization is disabled only for single-tenant deployments.
210    SingleTenant {
211        /// The only namespace accepted by the deployment.
212        namespace: String,
213    },
214}
215
216/// Remote worker heartbeat configuration.
217#[derive(Clone, Debug, Deserialize)]
218#[serde(default, deny_unknown_fields)]
219pub struct WorkerConfig {
220    /// Window after which a silent worker is considered lost.
221    #[serde(with = "duration_millis")]
222    pub heartbeat_window: Duration,
223}
224
225/// WebSocket stream configuration.
226#[derive(Clone, Debug, Deserialize)]
227#[serde(default, deny_unknown_fields)]
228pub struct WebSocketConfig {
229    /// Per-connection outbound buffer bound.
230    pub outbound_buffer_bound: usize,
231    /// Capacity of the engine-global event broadcast channel that backs
232    /// `/events/stream`. REQUIRED — the server always mounts the streaming
233    /// endpoint, so streaming capacity must be an explicit operator decision;
234    /// there is no default. Lag is filter-blind, so size this for global event
235    /// volume across all namespaces, not per-subscription volume.
236    pub event_broadcast_capacity: Option<usize>,
237}
238
239/// Operator-facing message for an absent or zero `event_broadcast_capacity`.
240pub(crate) const EVENT_BROADCAST_CAPACITY_REQUIRED: &str = "websocket.event_broadcast_capacity is required and has no default: the server always mounts /events/stream, so live event streaming capacity must be configured explicitly; set websocket.event_broadcast_capacity (or AION_WEBSOCKET_EVENT_BROADCAST_CAPACITY) to a positive integer sized for global event volume across all namespaces";
241
242/// Operator deploy API settings from `[deploy]`.
243///
244/// The deploy surface is dark by default: with `enabled = false` (or the
245/// section absent) neither the `/deploy/*` HTTP routes nor the gRPC
246/// `DeployService` are mounted, so a workflow server that is not a deploy
247/// target exposes no deploy attack surface at all.
248#[derive(Clone, Debug, Default, Deserialize)]
249#[serde(default, deny_unknown_fields)]
250pub struct DeployConfig {
251    /// Whether the deploy surface is mounted. Defaults to false.
252    pub enabled: bool,
253    /// Upload-size ceiling for `.aion` archives, in bytes. REQUIRED when
254    /// `enabled = true`; no default (house rule) — the operator sizes it for
255    /// their packages.
256    pub max_archive_bytes: Option<u64>,
257    /// Inflate ceiling for uploaded archive contents, in bytes: the total
258    /// decompressed size of all archive entries an upload may extract to
259    /// (DEFLATE bombs inflate ~1000:1 past `max_archive_bytes`). REQUIRED
260    /// when `enabled = true`; no default (house rule); must be at least
261    /// `max_archive_bytes`.
262    pub max_inflated_bytes: Option<u64>,
263}
264
265/// Operator-facing message for an absent or zero `deploy.max_archive_bytes`.
266pub(crate) const DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED: &str = "deploy.max_archive_bytes is required and has no default when deploy.enabled is true: the archive upload ceiling must be an explicit operator decision sized for the deployment's packages; set deploy.max_archive_bytes (or AION_DEPLOY_MAX_ARCHIVE_BYTES) to a positive number of bytes";
267
268/// Operator-facing message for an absent or zero `deploy.max_inflated_bytes`.
269pub(crate) const DEPLOY_MAX_INFLATED_BYTES_REQUIRED: &str = "deploy.max_inflated_bytes is required and has no default when deploy.enabled is true: the decompressed-contents ceiling for uploaded archives must be an explicit operator decision (a compressed upload under deploy.max_archive_bytes can inflate ~1000:1); set deploy.max_inflated_bytes (or AION_DEPLOY_MAX_INFLATED_BYTES) to a positive number of bytes no smaller than deploy.max_archive_bytes";
270
271/// Operator-facing message for an absent or zero `query_timeout_ms`.
272pub(crate) const QUERY_TIMEOUT_REQUIRED: &str = "runtime.query_timeout_ms is required and has no default: the server always mounts /workflows/query, so the workflow query reply deadline must be configured explicitly; set runtime.query_timeout_ms (or AION_RUNTIME_QUERY_TIMEOUT_MS) to a positive number of milliseconds";
273
274/// Runtime settings retained in shared server state for transport adapters.
275#[derive(Clone, Debug)]
276pub struct RuntimeConfig {
277    /// Listener addresses for public transports.
278    pub listen: ListenConfig,
279    /// Optional TLS material for public transports.
280    pub tls: Option<TlsConfig>,
281    /// Authentication configuration shared by transports.
282    pub auth: AuthConfig,
283    /// Dashboard asset location.
284    pub dashboard: DashboardConfig,
285    /// Namespace resolver construction mode.
286    pub namespace: NamespaceConfig,
287    /// Remote worker heartbeat configuration.
288    pub worker: WorkerConfig,
289    /// WebSocket stream configuration.
290    pub websocket: WebSocketConfig,
291    /// Workflow package archives loaded into the engine at startup.
292    pub workflow_packages: Vec<PathBuf>,
293    /// Operator deploy API settings.
294    pub deploy: DeployConfig,
295    /// Engine scheduler thread count.
296    pub scheduler_threads: usize,
297    /// Engine reply deadline for workflow queries. REQUIRED — carried as an
298    /// [`Option`] only so state construction can re-validate (defense in
299    /// depth, like `websocket.event_broadcast_capacity`); validated
300    /// configurations always hold [`Some`] non-zero duration.
301    pub query_timeout: Option<Duration>,
302    /// Default namespace used by worker dispatch and unauthenticated local callers.
303    pub default_namespace: String,
304    /// Graceful drain timeout.
305    pub drain_timeout: Duration,
306    /// Metrics endpoint settings.
307    pub metrics: MetricsConfig,
308}
309
310impl ServerConfig {
311    /// Load and merge config from defaults, optional TOML file, environment, and CLI overrides.
312    ///
313    /// # Errors
314    ///
315    /// Returns [`ServerError::Config`] when file discovery, parsing, environment parsing, CLI
316    /// values, or validation fail.
317    pub fn load(cli: &CliOverrides) -> Result<Self, ServerError> {
318        let mut config = file::load(cli.config_path.as_deref())?.unwrap_or_default();
319        env::overlay(&mut config)?;
320        config.apply_cli_overrides(cli);
321        config.load_discovered_workflow_packages(cli, Path::new("."))?;
322        config.validate()?;
323        Ok(config)
324    }
325
326    fn load_discovered_workflow_packages(
327        &mut self,
328        cli: &CliOverrides,
329        directory: &Path,
330    ) -> Result<(), ServerError> {
331        let discovered_packages = discover_workflow_packages(directory)?;
332        merge_workflow_packages(
333            &mut self.workflow_packages,
334            discovered_packages,
335            &cli.workflow_packages,
336        );
337        Ok(())
338    }
339
340    /// Parse server configuration from TOML bytes and validate it.
341    ///
342    /// # Errors
343    ///
344    /// Returns [`ServerError::Config`] when parsing fails or values are invalid.
345    pub fn from_slice(bytes: &[u8]) -> Result<Self, ServerError> {
346        let config: Self = toml::from_slice(bytes).map_err(|source| ServerError::Config {
347            message: format!("invalid server config: {source}"),
348        })?;
349        config.validate()?;
350        Ok(config)
351    }
352
353    /// Load server configuration from an explicit TOML file path.
354    ///
355    /// # Errors
356    ///
357    /// Returns [`ServerError::Config`] when the file is missing, unreadable, unparsable, or invalid.
358    pub fn load_from_path(path: impl Into<PathBuf>) -> Result<Self, ServerError> {
359        file::load_required(&path.into())
360    }
361
362    /// Split store configuration from non-secret runtime settings.
363    #[must_use]
364    pub fn into_parts(self) -> (StoreConfig, RuntimeConfig) {
365        let runtime = RuntimeConfig {
366            listen: ListenConfig {
367                grpc: self.server.grpc_address,
368                http: self.server.listen_address,
369            },
370            tls: self.tls,
371            auth: self.auth,
372            dashboard: self.dashboard,
373            namespace: self.namespace,
374            worker: self.worker,
375            websocket: self.websocket,
376            workflow_packages: self.workflow_packages,
377            deploy: self.deploy,
378            scheduler_threads: self.runtime.scheduler_threads,
379            query_timeout: self.runtime.query_timeout_ms.map(Duration::from_millis),
380            default_namespace: self.namespaces.default,
381            drain_timeout: Duration::from_secs(self.drain.timeout_seconds),
382            metrics: self.metrics,
383        };
384        (self.store, runtime)
385    }
386
387    fn apply_cli_overrides(&mut self, cli: &CliOverrides) {
388        if let Some(address) = cli.listen_address {
389            self.server.listen_address = address;
390        }
391        if let Some(url) = &cli.store_url {
392            self.store.url = Some(url.clone());
393            if self.store.backend == StoreBackend::Memory {
394                self.store.backend = StoreBackend::LibSql;
395            }
396        }
397        if let Some(threads) = cli.scheduler_threads {
398            self.runtime.scheduler_threads = threads;
399        }
400        if let Some(timeout) = cli.drain_timeout_seconds {
401            self.drain.timeout_seconds = timeout;
402        }
403    }
404
405    fn validate(&self) -> Result<(), ServerError> {
406        if self.server.listen_address.port() == 0 {
407            return config_error("server.listen_address must use an explicit non-zero port");
408        }
409        if self.server.grpc_address.port() == 0 {
410            return config_error("server.grpc_address must use an explicit non-zero port");
411        }
412        if self.runtime.scheduler_threads == 0 {
413            return config_error("runtime.scheduler_threads must be greater than zero");
414        }
415        if self.drain.timeout_seconds == 0 {
416            return config_error("drain.timeout_seconds must be greater than zero");
417        }
418        if self.auth.enabled && self.auth.jwks_url.as_deref().is_none_or(str::is_empty) {
419            return config_error("auth.jwks_url must not be empty when auth.enabled is true");
420        }
421        if self.auth.jwks_refresh_seconds == 0 {
422            return config_error("auth.jwks_refresh_seconds must be greater than zero");
423        }
424        if self.namespaces.default.is_empty() {
425            return config_error("namespaces.default must not be empty");
426        }
427        if matches!(self.store.backend, StoreBackend::LibSql)
428            && self.store.url.as_deref().is_none_or(str::is_empty)
429        {
430            return config_error("store.url must not be empty when store.backend is libsql");
431        }
432        if let Some(url) = &self.store.url {
433            if url.is_empty() {
434                return config_error("store.url must not be empty");
435            }
436        }
437        if let DashboardAssetSource::FileSystem { asset_path } = &self.dashboard.source {
438            if asset_path.as_os_str().is_empty() {
439                return config_error("dashboard.source.FileSystem.asset_path must not be empty");
440            }
441        }
442        if let NamespaceMode::SingleTenant { namespace } = &self.namespace.mode {
443            if namespace.is_empty() {
444                return config_error("namespace.mode.SingleTenant.namespace must not be empty");
445            }
446        }
447        if self.worker.heartbeat_window.is_zero() {
448            return config_error("worker.heartbeat_window must be greater than zero");
449        }
450        if self.websocket.outbound_buffer_bound == 0 {
451            return config_error("websocket.outbound_buffer_bound must be greater than zero");
452        }
453        match self.websocket.event_broadcast_capacity {
454            None | Some(0) => return config_error(EVENT_BROADCAST_CAPACITY_REQUIRED),
455            Some(_) => {}
456        }
457        match self.runtime.query_timeout_ms {
458            None | Some(0) => return config_error(QUERY_TIMEOUT_REQUIRED),
459            Some(_) => {}
460        }
461        if self.deploy.enabled {
462            let max_archive_bytes = match self.deploy.max_archive_bytes {
463                None | Some(0) => return config_error(DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED),
464                Some(value) => value,
465            };
466            let max_inflated_bytes = match self.deploy.max_inflated_bytes {
467                None | Some(0) => return config_error(DEPLOY_MAX_INFLATED_BYTES_REQUIRED),
468                Some(value) => value,
469            };
470            // Both ceilings size in-memory buffers, so they must be
471            // addressable on this platform (32-bit targets).
472            ensure_fits_usize("deploy.max_archive_bytes", max_archive_bytes)?;
473            ensure_fits_usize("deploy.max_inflated_bytes", max_inflated_bytes)?;
474            if max_inflated_bytes < max_archive_bytes {
475                return config_error(format!(
476                    "deploy.max_inflated_bytes ({max_inflated_bytes}) must be at least deploy.max_archive_bytes ({max_archive_bytes}): an inflate ceiling below the upload ceiling would refuse archives the upload ceiling admits, even stored uncompressed"
477                ));
478            }
479        }
480        Ok(())
481    }
482}
483
484/// Refuses byte-ceiling values that cannot index memory on this platform.
485fn ensure_fits_usize(key: &str, value: u64) -> Result<(), ServerError> {
486    if usize::try_from(value).is_err() {
487        return config_error(format!(
488            "{key} ({value}) exceeds this platform's addressable memory; set it to at most {}",
489            usize::MAX
490        ));
491    }
492    Ok(())
493}
494
495impl Default for ServerSection {
496    fn default() -> Self {
497        Self {
498            listen_address: DEFAULT_HTTP_ADDRESS,
499            grpc_address: DEFAULT_GRPC_ADDRESS,
500        }
501    }
502}
503
504impl Default for StoreConfig {
505    fn default() -> Self {
506        Self {
507            backend: StoreBackend::Memory,
508            url: None,
509        }
510    }
511}
512
513impl Default for RuntimeSection {
514    fn default() -> Self {
515        Self {
516            scheduler_threads: 1,
517            // Deliberately absent: validation fails loudly until the operator
518            // sets the workflow query reply deadline for the deployment.
519            query_timeout_ms: None,
520        }
521    }
522}
523
524impl Default for DrainConfig {
525    fn default() -> Self {
526        Self {
527            timeout_seconds: 30,
528        }
529    }
530}
531
532impl Default for AuthConfig {
533    fn default() -> Self {
534        Self {
535            enabled: false,
536            jwks_url: None,
537            jwks_refresh_seconds: 300,
538        }
539    }
540}
541
542impl Default for MetricsConfig {
543    fn default() -> Self {
544        Self { enabled: true }
545    }
546}
547
548impl Default for NamespacesConfig {
549    fn default() -> Self {
550        Self {
551            default: "default".to_owned(),
552        }
553    }
554}
555
556impl Default for ListenConfig {
557    fn default() -> Self {
558        Self {
559            grpc: DEFAULT_GRPC_ADDRESS,
560            http: DEFAULT_HTTP_ADDRESS,
561        }
562    }
563}
564
565impl Default for DashboardConfig {
566    fn default() -> Self {
567        Self {
568            source: DashboardAssetSource::Embedded,
569        }
570    }
571}
572
573impl Default for NamespaceConfig {
574    fn default() -> Self {
575        Self {
576            mode: NamespaceMode::SharedEngine,
577        }
578    }
579}
580
581impl Default for WorkerConfig {
582    fn default() -> Self {
583        Self {
584            heartbeat_window: Duration::from_secs(30),
585        }
586    }
587}
588
589impl Default for WebSocketConfig {
590    fn default() -> Self {
591        Self {
592            outbound_buffer_bound: 32,
593            // Deliberately absent: validation fails loudly until the operator
594            // sizes the engine-global broadcast channel for the deployment.
595            event_broadcast_capacity: None,
596        }
597    }
598}
599
600pub(crate) fn config_error<T>(message: impl Into<String>) -> Result<T, ServerError> {
601    Err(ServerError::Config {
602        message: message.into(),
603    })
604}
605
606fn discover_workflow_packages(directory: &Path) -> Result<Vec<PathBuf>, ServerError> {
607    let mut packages = Vec::new();
608    let entries = fs::read_dir(directory).map_err(|source| ServerError::Config {
609        message: format!(
610            "failed to scan workflow packages in `{}`: {source}",
611            directory.display()
612        ),
613    })?;
614
615    for entry in entries {
616        let entry = entry.map_err(|source| ServerError::Config {
617            message: format!(
618                "failed to read workflow package entry in `{}`: {source}",
619                directory.display()
620            ),
621        })?;
622        let path = entry.path();
623        let has_aion_extension = path
624            .extension()
625            .is_some_and(|extension| extension == "aion");
626        if path.is_file() && has_aion_extension {
627            packages.push(path);
628        }
629    }
630
631    packages.sort_by(|left, right| left.as_os_str().cmp(right.as_os_str()));
632    Ok(packages)
633}
634
635fn merge_workflow_packages(
636    workflow_packages: &mut Vec<PathBuf>,
637    discovered_packages: Vec<PathBuf>,
638    cli_packages: &[PathBuf],
639) {
640    let mut seen: HashSet<PathBuf> = workflow_packages
641        .iter()
642        .map(|package| deduplicated_package_key(package))
643        .collect();
644    for package in discovered_packages
645        .into_iter()
646        .chain(cli_packages.iter().cloned())
647    {
648        if seen.insert(deduplicated_package_key(&package)) {
649            workflow_packages.push(package);
650        }
651    }
652}
653
654fn deduplicated_package_key(path: &Path) -> PathBuf {
655    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
656}
657
658mod duration_millis {
659    use std::time::Duration;
660
661    use serde::{Deserialize, Deserializer};
662
663    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
664    where
665        D: Deserializer<'de>,
666    {
667        let millis = u64::deserialize(deserializer)?;
668        Ok(Duration::from_millis(millis))
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use super::{
675        CliOverrides, ServerConfig, StoreBackend, discover_workflow_packages,
676        merge_workflow_packages,
677    };
678
679    #[test]
680    fn valid_toml_is_parsed_into_typed_config() -> Result<(), Box<dyn std::error::Error>> {
681        let config = ServerConfig::from_slice(
682            br#"
683                [server]
684                listen_address = "127.0.0.1:18080"
685                grpc_address = "127.0.0.1:15051"
686
687                [store]
688                backend = "libsql"
689                url = "aion.db"
690
691                [runtime]
692                scheduler_threads = 2
693                query_timeout_ms = 10000
694
695                [drain]
696                timeout_seconds = 45
697
698                [auth]
699                enabled = true
700                jwks_url = "https://issuer.example.com/.well-known/jwks.json"
701                jwks_refresh_seconds = 60
702
703                [metrics]
704                enabled = true
705
706                [namespaces]
707                default = "production"
708
709                [websocket]
710                outbound_buffer_bound = 16
711                event_broadcast_capacity = 1024
712            "#,
713        )?;
714
715        assert_eq!(config.store.backend, StoreBackend::LibSql);
716        assert_eq!(config.store.url.as_deref(), Some("aion.db"));
717        assert_eq!(config.runtime.scheduler_threads, 2);
718        assert_eq!(config.runtime.query_timeout_ms, Some(10_000));
719        assert_eq!(config.namespaces.default, "production");
720        assert_eq!(config.websocket.outbound_buffer_bound, 16);
721        assert_eq!(config.websocket.event_broadcast_capacity, Some(1024));
722        Ok(())
723    }
724
725    #[test]
726    fn missing_event_broadcast_capacity_fails_startup_validation_naming_the_key() {
727        // The server unconditionally mounts /events/stream; a configuration
728        // without explicit broadcast capacity must fail loudly at startup
729        // instead of leaving streaming dark.
730        let result = ServerConfig::default().validate();
731
732        let message = result
733            .err()
734            .map_or_else(String::new, |error| error.to_string());
735        assert!(
736            message.contains("websocket.event_broadcast_capacity"),
737            "validation message must name the missing key: {message}"
738        );
739        assert!(
740            message.contains("AION_WEBSOCKET_EVENT_BROADCAST_CAPACITY"),
741            "validation message must name the environment override: {message}"
742        );
743    }
744
745    #[test]
746    fn zero_event_broadcast_capacity_fails_startup_validation() {
747        let result = ServerConfig::from_slice(
748            br"
749                [websocket]
750                event_broadcast_capacity = 0
751            ",
752        );
753
754        let message = result
755            .err()
756            .map_or_else(String::new, |error| error.to_string());
757        assert!(
758            message.contains("websocket.event_broadcast_capacity"),
759            "validation message must name the zero-valued key: {message}"
760        );
761    }
762
763    #[test]
764    fn missing_query_timeout_fails_startup_validation_naming_the_key() {
765        // The server unconditionally mounts /workflows/query; a configuration
766        // without an explicit query reply deadline must fail loudly at
767        // startup instead of mounting an unanswerable surface.
768        let result = ServerConfig::from_slice(
769            br"
770                [runtime]
771                scheduler_threads = 1
772
773                [websocket]
774                event_broadcast_capacity = 64
775            ",
776        );
777
778        let message = result
779            .err()
780            .map_or_else(String::new, |error| error.to_string());
781        assert!(
782            message.contains("runtime.query_timeout_ms"),
783            "validation message must name the missing key: {message}"
784        );
785        assert!(
786            message.contains("AION_RUNTIME_QUERY_TIMEOUT_MS"),
787            "validation message must name the environment override: {message}"
788        );
789    }
790
791    #[test]
792    fn zero_query_timeout_fails_startup_validation() {
793        let result = ServerConfig::from_slice(
794            br"
795                [runtime]
796                query_timeout_ms = 0
797
798                [websocket]
799                event_broadcast_capacity = 64
800            ",
801        );
802
803        let message = result
804            .err()
805            .map_or_else(String::new, |error| error.to_string());
806        assert!(
807            message.contains("runtime.query_timeout_ms"),
808            "validation message must name the zero-valued key: {message}"
809        );
810    }
811
812    /// The deploy surface is commissioned explicitly: enabling it without
813    /// the archive ceiling must fail startup naming the key and the
814    /// environment override (the `query_timeout_ms` /
815    /// `event_broadcast_capacity` required-config pattern).
816    #[test]
817    fn deploy_enabled_without_max_archive_bytes_fails_naming_key_and_env() {
818        let result = ServerConfig::from_slice(
819            br"
820                [runtime]
821                query_timeout_ms = 10000
822
823                [websocket]
824                event_broadcast_capacity = 64
825
826                [deploy]
827                enabled = true
828            ",
829        );
830
831        let message = result
832            .err()
833            .map_or_else(String::new, |error| error.to_string());
834        assert!(
835            message.contains("deploy.max_archive_bytes"),
836            "validation message must name the missing key: {message}"
837        );
838        assert!(
839            message.contains("AION_DEPLOY_MAX_ARCHIVE_BYTES"),
840            "validation message must name the environment override: {message}"
841        );
842    }
843
844    #[test]
845    fn deploy_zero_max_archive_bytes_fails_startup_validation() {
846        let result = ServerConfig::from_slice(
847            br"
848                [runtime]
849                query_timeout_ms = 10000
850
851                [websocket]
852                event_broadcast_capacity = 64
853
854                [deploy]
855                enabled = true
856                max_archive_bytes = 0
857            ",
858        );
859
860        let message = result
861            .err()
862            .map_or_else(String::new, |error| error.to_string());
863        assert!(
864            message.contains("deploy.max_archive_bytes"),
865            "validation message must name the zero-valued key: {message}"
866        );
867    }
868
869    /// The inflate ceiling is commissioned alongside the upload ceiling:
870    /// enabling deploy without `max_inflated_bytes` must fail startup naming
871    /// the key and the environment override (same pattern as
872    /// `max_archive_bytes`).
873    #[test]
874    fn deploy_enabled_without_max_inflated_bytes_fails_naming_key_and_env() {
875        let result = ServerConfig::from_slice(
876            br"
877                [runtime]
878                query_timeout_ms = 10000
879
880                [websocket]
881                event_broadcast_capacity = 64
882
883                [deploy]
884                enabled = true
885                max_archive_bytes = 16777216
886            ",
887        );
888
889        let message = result
890            .err()
891            .map_or_else(String::new, |error| error.to_string());
892        assert!(
893            message.contains("deploy.max_inflated_bytes"),
894            "validation message must name the missing key: {message}"
895        );
896        assert!(
897            message.contains("AION_DEPLOY_MAX_INFLATED_BYTES"),
898            "validation message must name the environment override: {message}"
899        );
900    }
901
902    #[test]
903    fn deploy_zero_max_inflated_bytes_fails_startup_validation() {
904        let result = ServerConfig::from_slice(
905            br"
906                [runtime]
907                query_timeout_ms = 10000
908
909                [websocket]
910                event_broadcast_capacity = 64
911
912                [deploy]
913                enabled = true
914                max_archive_bytes = 16777216
915                max_inflated_bytes = 0
916            ",
917        );
918
919        let message = result
920            .err()
921            .map_or_else(String::new, |error| error.to_string());
922        assert!(
923            message.contains("deploy.max_inflated_bytes"),
924            "validation message must name the zero-valued key: {message}"
925        );
926    }
927
928    /// An inflate ceiling below the upload ceiling is incoherent: archives
929    /// the upload ceiling admits would be refused even stored uncompressed.
930    #[test]
931    fn deploy_max_inflated_below_max_archive_fails_startup_validation() {
932        let result = ServerConfig::from_slice(
933            br"
934                [runtime]
935                query_timeout_ms = 10000
936
937                [websocket]
938                event_broadcast_capacity = 64
939
940                [deploy]
941                enabled = true
942                max_archive_bytes = 16777216
943                max_inflated_bytes = 16777215
944            ",
945        );
946
947        let message = result
948            .err()
949            .map_or_else(String::new, |error| error.to_string());
950        assert!(
951            message.contains("deploy.max_inflated_bytes")
952                && message.contains("deploy.max_archive_bytes"),
953            "validation message must name both ceilings: {message}"
954        );
955    }
956
957    /// An absent `[deploy]` section means the surface stays dark and the
958    /// ceilings are not required.
959    #[test]
960    fn deploy_disabled_requires_no_archive_ceiling() -> Result<(), Box<dyn std::error::Error>> {
961        let config = ServerConfig::from_slice(
962            br"
963                [runtime]
964                query_timeout_ms = 10000
965
966                [websocket]
967                event_broadcast_capacity = 64
968            ",
969        )?;
970
971        assert!(!config.deploy.enabled);
972        assert_eq!(config.deploy.max_archive_bytes, None);
973        assert_eq!(config.deploy.max_inflated_bytes, None);
974        Ok(())
975    }
976
977    #[test]
978    fn deploy_section_parses_enabled_with_ceilings() -> Result<(), Box<dyn std::error::Error>> {
979        let config = ServerConfig::from_slice(
980            br"
981                [runtime]
982                query_timeout_ms = 10000
983
984                [websocket]
985                event_broadcast_capacity = 64
986
987                [deploy]
988                enabled = true
989                max_archive_bytes = 16777216
990                max_inflated_bytes = 67108864
991            ",
992        )?;
993
994        assert!(config.deploy.enabled);
995        assert_eq!(config.deploy.max_archive_bytes, Some(16_777_216));
996        assert_eq!(config.deploy.max_inflated_bytes, Some(67_108_864));
997        Ok(())
998    }
999
1000    #[test]
1001    fn invalid_values_name_problematic_field() {
1002        let result = ServerConfig::from_slice(
1003            br"
1004                [runtime]
1005                scheduler_threads = 0
1006            ",
1007        );
1008
1009        let message = result
1010            .err()
1011            .map_or_else(String::new, |error| error.to_string());
1012        assert!(message.contains("runtime.scheduler_threads"));
1013    }
1014
1015    #[test]
1016    fn cli_overrides_win_over_loaded_values() -> Result<(), Box<dyn std::error::Error>> {
1017        let mut config = ServerConfig::from_slice(
1018            br#"
1019                [store]
1020                backend = "libsql"
1021                url = "file.db"
1022
1023                [runtime]
1024                query_timeout_ms = 10000
1025
1026                [websocket]
1027                event_broadcast_capacity = 64
1028            "#,
1029        )?;
1030        let cli = CliOverrides {
1031            store_url: Some("cli.db".to_owned()),
1032            scheduler_threads: Some(3),
1033            ..CliOverrides::default()
1034        };
1035
1036        config.apply_cli_overrides(&cli);
1037        config.validate()?;
1038
1039        assert_eq!(config.store.url.as_deref(), Some("cli.db"));
1040        assert_eq!(config.runtime.scheduler_threads, 3);
1041        Ok(())
1042    }
1043
1044    #[test]
1045    fn default_config_defaults() -> Result<(), Box<dyn std::error::Error>> {
1046        let mut config = ServerConfig::default();
1047
1048        assert_eq!(config.store.backend, StoreBackend::Memory);
1049        assert_eq!(config.store.url, None);
1050        assert_eq!(config.server.grpc_address.to_string(), "127.0.0.1:50051");
1051        assert_eq!(config.server.listen_address.to_string(), "127.0.0.1:8080");
1052        assert_eq!(config.namespaces.default, "default");
1053        assert!(!config.auth.enabled);
1054        assert!(config.metrics.enabled);
1055        // event_broadcast_capacity and query_timeout_ms are the deliberately
1056        // defaultless values: defaults validate only once the operator
1057        // supplies them.
1058        assert_eq!(config.websocket.event_broadcast_capacity, None);
1059        assert_eq!(config.runtime.query_timeout_ms, None);
1060        config.websocket.event_broadcast_capacity = Some(64);
1061        config.runtime.query_timeout_ms = Some(10_000);
1062        config.validate()?;
1063        Ok(())
1064    }
1065
1066    #[test]
1067    fn package_discovery_is_sorted() -> Result<(), Box<dyn std::error::Error>> {
1068        let temp_dir = tempfile::tempdir()?;
1069        std::fs::write(temp_dir.path().join("zeta.aion"), b"package")?;
1070        std::fs::write(temp_dir.path().join("alpha.aion"), b"package")?;
1071        std::fs::write(temp_dir.path().join("ignored.txt"), b"package")?;
1072        std::fs::create_dir(temp_dir.path().join("nested"))?;
1073        std::fs::write(
1074            temp_dir.path().join("nested").join("nested.aion"),
1075            b"package",
1076        )?;
1077
1078        let packages = discover_workflow_packages(temp_dir.path())?;
1079
1080        assert_eq!(
1081            packages,
1082            vec![
1083                temp_dir.path().join("alpha.aion"),
1084                temp_dir.path().join("zeta.aion"),
1085            ]
1086        );
1087        Ok(())
1088    }
1089
1090    #[test]
1091    fn workflow_package_merge_is_additive_and_deduplicated() {
1092        let mut packages = vec!["config.aion".into(), "shared.aion".into()];
1093        let discovered = vec!["auto.aion".into(), "shared.aion".into()];
1094        let cli = vec!["cli.aion".into(), "auto.aion".into()];
1095
1096        merge_workflow_packages(&mut packages, discovered, &cli);
1097
1098        assert_eq!(
1099            packages,
1100            vec![
1101                std::path::PathBuf::from("config.aion"),
1102                std::path::PathBuf::from("shared.aion"),
1103                std::path::PathBuf::from("auto.aion"),
1104                std::path::PathBuf::from("cli.aion"),
1105            ]
1106        );
1107    }
1108
1109    #[test]
1110    fn package_merge_deduplicates_canonical_files() -> Result<(), Box<dyn std::error::Error>> {
1111        let temp_dir = tempfile::tempdir()?;
1112        let package = temp_dir.path().join("hello.aion");
1113        std::fs::write(&package, b"package")?;
1114        let mut packages = vec![package.clone()];
1115        let discovered = vec![temp_dir.path().join(".").join("hello.aion")];
1116
1117        merge_workflow_packages(&mut packages, discovered, &[]);
1118
1119        assert_eq!(packages, vec![package]);
1120        Ok(())
1121    }
1122
1123    #[test]
1124    fn zero_config_cli_workflow_package_uses_in_memory_defaults()
1125    -> Result<(), Box<dyn std::error::Error>> {
1126        let temp_dir = tempfile::tempdir()?;
1127
1128        let cli = CliOverrides {
1129            workflow_packages: vec!["hello-world.aion".into()],
1130            ..CliOverrides::default()
1131        };
1132        let mut config = ServerConfig::default();
1133        // Even zero-config development runs must size event streaming and the
1134        // query reply deadline explicitly (config keys or the
1135        // AION_WEBSOCKET_EVENT_BROADCAST_CAPACITY /
1136        // AION_RUNTIME_QUERY_TIMEOUT_MS environment overrides).
1137        config.websocket.event_broadcast_capacity = Some(64);
1138        config.runtime.query_timeout_ms = Some(10_000);
1139        config.load_discovered_workflow_packages(&cli, temp_dir.path())?;
1140
1141        config.validate()?;
1142
1143        assert_eq!(config.store.backend, StoreBackend::Memory);
1144        assert_eq!(config.store.url, None);
1145        assert_eq!(
1146            config.workflow_packages,
1147            vec![std::path::PathBuf::from("hello-world.aion")]
1148        );
1149        Ok(())
1150    }
1151
1152    #[test]
1153    fn cli_packages_are_additive() -> Result<(), Box<dyn std::error::Error>> {
1154        let mut config = ServerConfig::from_slice(
1155            br#"
1156                workflow_packages = ["config.aion"]
1157
1158                [runtime]
1159                query_timeout_ms = 10000
1160
1161                [websocket]
1162                event_broadcast_capacity = 64
1163            "#,
1164        )?;
1165        let cli = CliOverrides {
1166            workflow_packages: vec!["cli-one.aion".into(), "cli-two.aion".into()],
1167            ..CliOverrides::default()
1168        };
1169
1170        merge_workflow_packages(
1171            &mut config.workflow_packages,
1172            Vec::new(),
1173            &cli.workflow_packages,
1174        );
1175
1176        assert_eq!(
1177            config.workflow_packages,
1178            vec![
1179                std::path::PathBuf::from("config.aion"),
1180                std::path::PathBuf::from("cli-one.aion"),
1181                std::path::PathBuf::from("cli-two.aion"),
1182            ]
1183        );
1184        Ok(())
1185    }
1186}