1use std::{
13 collections::HashSet,
14 fs,
15 path::{Path, PathBuf},
16 time::Duration,
17};
18
19use serde::Deserialize;
20
21use crate::error::ServerError;
22
23use super::{
24 AUTHORING_GLEAM_PATH_EMPTY, AUTHORING_PROJECT_ROOT_REQUIRED, AuthConfig, AuthoringConfig,
25 CORS_ALLOWED_ORIGIN_INVALID, CliOverrides, ClusterConfig, ConfigResolution,
26 DEFAULT_CLUSTER_BROADCAST_CAPACITY, DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES,
27 DEFAULT_DEPLOY_MAX_INFLATED_BYTES, DEFAULT_EVENT_BROADCAST_CAPACITY,
28 DEFAULT_OUTBOX_BACKOFF_BASE_MS, DEFAULT_OUTBOX_BACKOFF_MAX_MS,
29 DEFAULT_OUTBOX_BACKOFF_MULTIPLIER, DEFAULT_OUTBOX_BATCH_SIZE, DEFAULT_OUTBOX_MAX_ATTEMPTS,
30 DEFAULT_OUTBOX_POLL_INTERVAL_MS, DEFAULT_QUERY_TIMEOUT_MS, DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED,
31 DEPLOY_MAX_INFLATED_BYTES_REQUIRED, DeployConfig, DevConfig, DrainConfig, ListenConfig,
32 MetricsConfig, NamespaceConfig, NamespaceMode, NamespacesConfig, OUTBOX_BACKOFF_BASE_REQUIRED,
33 OUTBOX_BACKOFF_MAX_REQUIRED, OUTBOX_BACKOFF_MULTIPLIER_REQUIRED, OUTBOX_BATCH_SIZE_REQUIRED,
34 OUTBOX_MAX_ATTEMPTS_REQUIRED, OUTBOX_POLL_INTERVAL_REQUIRED,
35 OUTBOX_RECONCILE_INTERVAL_REQUIRED, OUTBOX_RECONCILE_STALE_AFTER_REQUIRED, ObservabilityConfig,
36 OpsConsoleAssetSource, OpsConsoleConfig, OutboxConfig, QUERY_TIMEOUT_REQUIRED, RuntimeConfig,
37 RuntimeSection, ServerSection, StoreBackend, StoreConfig, TlsConfig, WebSocketConfig,
38 WorkerConfig, aion_home, config_error, env, file, resolution::fill_home_defaults,
39};
40
41#[derive(Clone, Debug, Deserialize)]
43#[serde(default, deny_unknown_fields)]
44#[derive(Default)]
45pub struct ServerConfig {
46 pub server: ServerSection,
48 pub store: StoreConfig,
50 pub runtime: RuntimeSection,
52 pub drain: DrainConfig,
54 pub auth: AuthConfig,
56 pub metrics: MetricsConfig,
58 pub namespaces: NamespacesConfig,
60 pub tls: Option<TlsConfig>,
62 #[serde(alias = "dashboard")]
64 pub ops_console: OpsConsoleConfig,
65 pub namespace: NamespaceConfig,
67 pub worker: WorkerConfig,
69 pub websocket: WebSocketConfig,
71 pub workflow_packages: Vec<PathBuf>,
73 pub deploy: DeployConfig,
75 pub authoring: AuthoringConfig,
77 pub dev: DevConfig,
79 pub outbox: OutboxConfig,
81 pub observability: ObservabilityConfig,
83}
84
85pub(crate) struct LoadedConfig {
86 pub(crate) config: ServerConfig,
87 pub(crate) resolution: ConfigResolution,
88}
89
90impl ServerConfig {
91 pub fn load(cli: &CliOverrides) -> Result<Self, ServerError> {
98 Ok(Self::load_resolved(cli)?.config)
99 }
100
101 pub(crate) fn load_resolved(cli: &CliOverrides) -> Result<LoadedConfig, ServerError> {
102 let home = aion_home()?;
103 let working_dir = std::env::current_dir().map_err(|source| ServerError::Config {
104 message: format!(
105 "failed to resolve the current directory for config discovery: {source}"
106 ),
107 })?;
108 Self::load_in(cli, &home, &working_dir, true)
109 }
110
111 fn load_in(
112 cli: &CliOverrides,
113 home: &Path,
114 working_dir: &Path,
115 overlay_environment: bool,
116 ) -> Result<LoadedConfig, ServerError> {
117 let discovered = file::discover(cli.config_path.as_deref(), home, working_dir)?;
118 let mut config = match discovered.bytes {
119 Some(bytes) => Self::parse_unresolved(&bytes).map_err(|error| ServerError::Config {
120 message: format!("failed to parse {}: {error}", discovered.source),
121 })?,
122 None => Self::default(),
123 };
124 if overlay_environment {
125 env::overlay(&mut config)?;
126 }
127 config.apply_cli_overrides(cli);
128 #[cfg(not(unix))]
129 let home_explicit = !overlay_environment || std::env::var_os("AION_HOME").is_some();
130 #[cfg(not(unix))]
131 let data_dir_explicit = config.store.data_dir.is_some();
132 #[cfg(not(unix))]
133 let data_root_required = matches!(config.store.backend, StoreBackend::Haematite);
134 #[cfg(not(unix))]
135 let authoring_workspace_explicit = config.authoring.workspace_dir.is_some();
136 config.load_discovered_workflow_packages(cli, working_dir)?;
137 let migrations = fill_home_defaults(&mut config, home, working_dir)?;
138 config.fill_operational_defaults();
139 config.validate()?;
140 let resolution = ConfigResolution {
141 home: home.to_owned(),
142 source: discovered.source,
143 data_dir: config.store.data_dir.clone(),
144 authoring_workspace: config.authoring.workspace_dir.clone(),
145 migrations,
146 #[cfg(not(unix))]
147 home_explicit,
148 #[cfg(not(unix))]
149 data_dir_explicit,
150 #[cfg(not(unix))]
151 data_root_required,
152 #[cfg(not(unix))]
153 authoring_workspace_explicit,
154 };
155 Ok(LoadedConfig { config, resolution })
156 }
157
158 #[cfg(test)]
159 fn load_for_test(
160 cli: &CliOverrides,
161 home: &Path,
162 working_dir: &Path,
163 ) -> Result<LoadedConfig, ServerError> {
164 Self::load_in(cli, home, working_dir, false)
165 }
166
167 fn fill_operational_defaults(&mut self) {
173 self.runtime
174 .query_timeout_ms
175 .get_or_insert(DEFAULT_QUERY_TIMEOUT_MS);
176 self.websocket
177 .event_broadcast_capacity
178 .get_or_insert(DEFAULT_EVENT_BROADCAST_CAPACITY);
179 self.websocket
180 .cluster_broadcast_capacity
181 .get_or_insert(DEFAULT_CLUSTER_BROADCAST_CAPACITY);
182 self.fill_outbox_defaults();
183 self.fill_deploy_defaults();
184 }
185
186 fn fill_outbox_defaults(&mut self) {
195 if !self.outbox.enabled {
196 return;
197 }
198 self.outbox
199 .poll_interval_ms
200 .get_or_insert(DEFAULT_OUTBOX_POLL_INTERVAL_MS);
201 self.outbox
202 .batch_size
203 .get_or_insert(DEFAULT_OUTBOX_BATCH_SIZE);
204 self.outbox
205 .max_attempts
206 .get_or_insert(DEFAULT_OUTBOX_MAX_ATTEMPTS);
207 self.outbox
208 .backoff_base_ms
209 .get_or_insert(DEFAULT_OUTBOX_BACKOFF_BASE_MS);
210 self.outbox
211 .backoff_multiplier
212 .get_or_insert(DEFAULT_OUTBOX_BACKOFF_MULTIPLIER);
213 self.outbox
214 .backoff_max_ms
215 .get_or_insert(DEFAULT_OUTBOX_BACKOFF_MAX_MS);
216 }
217
218 fn fill_deploy_defaults(&mut self) {
226 if !self.deploy.enabled {
227 return;
228 }
229 self.deploy
230 .max_archive_bytes
231 .get_or_insert(DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES);
232 self.deploy
233 .max_inflated_bytes
234 .get_or_insert(DEFAULT_DEPLOY_MAX_INFLATED_BYTES);
235 }
236
237 fn load_discovered_workflow_packages(
238 &mut self,
239 cli: &CliOverrides,
240 directory: &Path,
241 ) -> Result<(), ServerError> {
242 let discovered_packages = discover_workflow_packages(directory)?;
243 merge_workflow_packages(
244 &mut self.workflow_packages,
245 discovered_packages,
246 &cli.workflow_packages,
247 );
248 Ok(())
249 }
250
251 pub fn from_slice(bytes: &[u8]) -> Result<Self, ServerError> {
257 let home = aion_home()?;
258 Self::from_slice_with_home(bytes, &home)
259 }
260
261 pub fn from_slice_with_home(bytes: &[u8], home: &Path) -> Result<Self, ServerError> {
273 let working_dir = std::env::current_dir().map_err(|source| ServerError::Config {
274 message: format!(
275 "failed to resolve the current directory for config defaults: {source}"
276 ),
277 })?;
278 let mut config = Self::parse_unresolved(bytes)?;
279 fill_home_defaults(&mut config, home, &working_dir)?;
280 config.fill_operational_defaults();
281 config.validate()?;
282 Ok(config)
283 }
284
285 fn parse_unresolved(bytes: &[u8]) -> Result<Self, ServerError> {
286 toml::from_slice(bytes).map_err(|source| ServerError::Config {
287 message: format!("invalid server config: {source}"),
288 })
289 }
290
291 pub fn load_from_path(path: impl Into<PathBuf>) -> Result<Self, ServerError> {
297 file::load_required(&path.into())
298 }
299
300 #[must_use]
302 pub fn into_parts(self) -> (StoreConfig, RuntimeConfig) {
303 let runtime = RuntimeConfig {
304 listen: ListenConfig {
305 grpc: self.server.grpc_address,
306 http: self.server.listen_address,
307 },
308 tls: self.tls,
309 auth: self.auth,
310 ops_console: self.ops_console,
311 namespace: self.namespace,
312 worker: self.worker,
313 websocket: self.websocket,
314 workflow_packages: self.workflow_packages,
315 deploy: self.deploy,
316 authoring: self.authoring,
317 dev: self.dev,
318 outbox: self.outbox,
319 observability: self.observability,
320 scheduler_threads: self.runtime.scheduler_threads,
321 query_timeout: self.runtime.query_timeout_ms.map(Duration::from_millis),
322 default_namespace: self.namespaces.default,
323 auto_create: self.namespaces.auto_create,
324 max_in_flight_activities: self.namespaces.max_in_flight_activities,
325 drain_timeout: Duration::from_secs(self.drain.timeout_seconds),
326 metrics: self.metrics,
327 owned_shards: self.store.owned_shards.clone(),
328 cors_allowed_origins: self.server.cors_allowed_origins.clone(),
329 };
330 (self.store, runtime)
331 }
332
333 fn apply_cli_overrides(&mut self, cli: &CliOverrides) {
334 if let Some(address) = cli.listen_address {
335 self.server.listen_address = address;
336 }
337 if let Some(url) = &cli.store_url {
338 self.store.url = Some(url.clone());
339 if matches!(
346 self.store.backend,
347 StoreBackend::Memory | StoreBackend::Haematite
348 ) {
349 self.store.backend = StoreBackend::LibSql;
350 }
351 }
352 if let Some(threads) = cli.scheduler_threads {
353 self.runtime.scheduler_threads = threads;
354 }
355 if let Some(timeout) = cli.drain_timeout_seconds {
356 self.drain.timeout_seconds = timeout;
357 }
358 if let Some(gleam_path) = &cli.gleam_path {
359 self.authoring.gleam_path = Some(gleam_path.clone());
360 }
361 if let Some(project_root) = &cli.authoring_project_root {
362 self.authoring.project_root = Some(project_root.clone());
363 }
364 }
365
366 fn validate(&self) -> Result<(), ServerError> {
367 if self.server.listen_address.port() == 0 {
368 return config_error("server.listen_address must use an explicit non-zero port");
369 }
370 if self.server.grpc_address.port() == 0 {
371 return config_error("server.grpc_address must use an explicit non-zero port");
372 }
373 validate_cors_origins(&self.server.cors_allowed_origins)?;
374 if self.runtime.scheduler_threads == 0 {
375 return config_error("runtime.scheduler_threads must be greater than zero");
376 }
377 if self.drain.timeout_seconds == 0 {
378 return config_error("drain.timeout_seconds must be greater than zero");
379 }
380 if self.auth.enabled && self.auth.jwks_url.as_deref().is_none_or(str::is_empty) {
381 return config_error("auth.jwks_url must not be empty when auth.enabled is true");
382 }
383 if self.auth.jwks_refresh_seconds == 0 {
384 return config_error("auth.jwks_refresh_seconds must be greater than zero");
385 }
386 if self.namespaces.default.is_empty() {
387 return config_error("namespaces.default must not be empty");
388 }
389 if matches!(self.store.backend, StoreBackend::LibSql)
390 && self.store.url.as_deref().is_none_or(str::is_empty)
391 {
392 return config_error("store.url must not be empty when store.backend is libsql");
393 }
394 if let Some(url) = &self.store.url {
395 if url.is_empty() {
396 return config_error("store.url must not be empty");
397 }
398 }
399 if matches!(self.store.backend, StoreBackend::Haematite) {
400 if self.store.data_dir.as_deref().is_none_or(str::is_empty) {
401 return config_error(
402 "store.data_dir must not be empty when store.backend is haematite",
403 );
404 }
405 if self.store.shard_count == 0 {
406 return config_error("store.shard_count must be greater than zero");
407 }
408 if let Some(cluster) = &self.store.cluster {
409 validate_cluster(cluster)?;
410 }
411 } else if self.store.cluster.is_some() {
412 return config_error("store.cluster is only valid when store.backend is haematite");
413 }
414 if let OpsConsoleAssetSource::FileSystem { asset_path } = &self.ops_console.source {
415 if asset_path.as_os_str().is_empty() {
416 return config_error("ops_console.source.FileSystem.asset_path must not be empty");
417 }
418 }
419 if let NamespaceMode::SingleTenant { namespace } = &self.namespace.mode {
420 if namespace.is_empty() {
421 return config_error("namespace.mode.SingleTenant.namespace must not be empty");
422 }
423 }
424 if self.worker.heartbeat_window.is_zero() {
425 return config_error("worker.heartbeat_window must be greater than zero");
426 }
427 self.websocket.validate()?;
428 self.observability.validate()?;
429 match self.runtime.query_timeout_ms {
430 None | Some(0) => return config_error(QUERY_TIMEOUT_REQUIRED),
431 Some(_) => {}
432 }
433 if self.deploy.enabled {
434 let max_archive_bytes = match self.deploy.max_archive_bytes {
435 None | Some(0) => return config_error(DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED),
436 Some(value) => value,
437 };
438 let max_inflated_bytes = match self.deploy.max_inflated_bytes {
439 None | Some(0) => return config_error(DEPLOY_MAX_INFLATED_BYTES_REQUIRED),
440 Some(value) => value,
441 };
442 ensure_fits_usize("deploy.max_archive_bytes", max_archive_bytes)?;
445 ensure_fits_usize("deploy.max_inflated_bytes", max_inflated_bytes)?;
446 if max_inflated_bytes < max_archive_bytes {
447 return config_error(format!(
448 "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"
449 ));
450 }
451 }
452 if let Some(gleam_path) = &self.authoring.gleam_path {
453 if gleam_path.as_os_str().is_empty() {
456 return config_error(AUTHORING_GLEAM_PATH_EMPTY);
457 }
458 match &self.authoring.project_root {
461 Some(root) if !root.as_os_str().is_empty() => {}
462 _ => return config_error(AUTHORING_PROJECT_ROOT_REQUIRED),
463 }
464 }
465 self.validate_outbox()?;
466 Ok(())
467 }
468
469 fn validate_outbox(&self) -> Result<(), ServerError> {
477 if !self.outbox.enabled {
478 return Ok(());
479 }
480 match self.outbox.poll_interval_ms {
481 None | Some(0) => return config_error(OUTBOX_POLL_INTERVAL_REQUIRED),
482 Some(_) => {}
483 }
484 match self.outbox.batch_size {
485 None | Some(0) => return config_error(OUTBOX_BATCH_SIZE_REQUIRED),
486 Some(_) => {}
487 }
488 match self.outbox.max_attempts {
489 None | Some(0) => return config_error(OUTBOX_MAX_ATTEMPTS_REQUIRED),
490 Some(_) => {}
491 }
492 let backoff_base_ms = match self.outbox.backoff_base_ms {
493 None | Some(0) => return config_error(OUTBOX_BACKOFF_BASE_REQUIRED),
494 Some(value) => value,
495 };
496 match self.outbox.backoff_multiplier {
497 None | Some(0) => return config_error(OUTBOX_BACKOFF_MULTIPLIER_REQUIRED),
498 Some(_) => {}
499 }
500 match self.outbox.backoff_max_ms {
501 Some(max) if max >= backoff_base_ms => {}
502 _ => return config_error(OUTBOX_BACKOFF_MAX_REQUIRED),
503 }
504 match (
505 self.outbox.reconcile_interval_ms,
506 self.outbox.reconcile_stale_after_ms,
507 ) {
508 (None, None) => {}
509 (None | Some(0), _) => return config_error(OUTBOX_RECONCILE_INTERVAL_REQUIRED),
510 (_, None | Some(0)) => return config_error(OUTBOX_RECONCILE_STALE_AFTER_REQUIRED),
511 (Some(_), Some(_)) => {}
512 }
513 Ok(())
514 }
515}
516
517fn validate_cluster(cluster: &ClusterConfig) -> Result<(), ServerError> {
521 if cluster.node_id.is_empty() {
522 return config_error("store.cluster.node_id must not be empty");
523 }
524 if cluster.members.iter().any(String::is_empty) {
525 return config_error("store.cluster.members entries must not be empty");
526 }
527 if cluster.peers.iter().any(|peer| peer.name.is_empty()) {
528 return config_error("store.cluster.peers entries must name a non-empty node");
529 }
530 if matches!(cluster.failover_poll_interval_ms, Some(0)) {
531 return config_error(
532 "store.cluster.failover_poll_interval_ms must be greater than zero when set",
533 );
534 }
535 if matches!(cluster.failover_confirmations, Some(0)) {
536 return config_error("store.cluster.failover_confirmations must be at least one when set");
537 }
538 Ok(())
539}
540
541fn validate_cors_origins(origins: &[String]) -> Result<(), ServerError> {
543 for origin in origins {
544 validate_cors_origin(origin)?;
545 }
546 Ok(())
547}
548
549fn validate_cors_origin(origin: &str) -> Result<(), ServerError> {
554 if origin.is_empty() {
555 return config_error(CORS_ALLOWED_ORIGIN_INVALID);
556 }
557 let scheme_split = origin.split_once("://");
561 let Some((scheme, authority)) = scheme_split else {
562 return config_error(CORS_ALLOWED_ORIGIN_INVALID);
563 };
564 if scheme.is_empty() || authority.is_empty() || authority.contains('/') {
565 return config_error(CORS_ALLOWED_ORIGIN_INVALID);
566 }
567 if origin.parse::<axum::http::HeaderValue>().is_err() {
569 return config_error(CORS_ALLOWED_ORIGIN_INVALID);
570 }
571 Ok(())
572}
573
574fn ensure_fits_usize(key: &str, value: u64) -> Result<(), ServerError> {
576 if usize::try_from(value).is_err() {
577 return config_error(format!(
578 "{key} ({value}) exceeds this platform's addressable memory; set it to at most {}",
579 usize::MAX
580 ));
581 }
582 Ok(())
583}
584
585fn discover_workflow_packages(directory: &Path) -> Result<Vec<PathBuf>, ServerError> {
586 let mut packages = Vec::new();
587 let entries = fs::read_dir(directory).map_err(|source| ServerError::Config {
588 message: format!(
589 "failed to scan workflow packages in `{}`: {source}",
590 directory.display()
591 ),
592 })?;
593
594 for entry in entries {
595 let entry = entry.map_err(|source| ServerError::Config {
596 message: format!(
597 "failed to read workflow package entry in `{}`: {source}",
598 directory.display()
599 ),
600 })?;
601 let path = entry.path();
602 let has_aion_extension = path
603 .extension()
604 .is_some_and(|extension| extension == "aion");
605 if path.is_file() && has_aion_extension {
606 packages.push(path);
607 }
608 }
609
610 packages.sort_by(|left, right| left.as_os_str().cmp(right.as_os_str()));
611 Ok(packages)
612}
613
614fn merge_workflow_packages(
615 workflow_packages: &mut Vec<PathBuf>,
616 discovered_packages: Vec<PathBuf>,
617 cli_packages: &[PathBuf],
618) {
619 let mut seen: HashSet<PathBuf> = workflow_packages
620 .iter()
621 .map(|package| deduplicated_package_key(package))
622 .collect();
623 for package in discovered_packages
624 .into_iter()
625 .chain(cli_packages.iter().cloned())
626 {
627 if seen.insert(deduplicated_package_key(&package)) {
628 workflow_packages.push(package);
629 }
630 }
631}
632
633fn deduplicated_package_key(path: &Path) -> PathBuf {
634 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
635}
636
637#[cfg(test)]
638#[path = "load_home_tests.rs"]
639mod home_tests;
640
641#[cfg(test)]
642mod tests {
643 use crate::config::{
644 AutoCreate, DEFAULT_AUTHORING_WORKSPACE_DIR, DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
645 OpsConsoleAssetSource,
646 };
647
648 use super::{
649 CliOverrides, DEFAULT_CLUSTER_BROADCAST_CAPACITY, DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES,
650 DEFAULT_DEPLOY_MAX_INFLATED_BYTES, DEFAULT_EVENT_BROADCAST_CAPACITY,
651 DEFAULT_OUTBOX_BACKOFF_BASE_MS, DEFAULT_OUTBOX_BACKOFF_MAX_MS,
652 DEFAULT_OUTBOX_BACKOFF_MULTIPLIER, DEFAULT_OUTBOX_BATCH_SIZE, DEFAULT_OUTBOX_MAX_ATTEMPTS,
653 DEFAULT_OUTBOX_POLL_INTERVAL_MS, DEFAULT_QUERY_TIMEOUT_MS, ServerConfig, StoreBackend,
654 discover_workflow_packages, merge_workflow_packages,
655 };
656
657 #[test]
658 fn valid_toml_is_parsed_into_typed_config() -> Result<(), Box<dyn std::error::Error>> {
659 let config = ServerConfig::from_slice(
660 br#"
661 [server]
662 listen_address = "127.0.0.1:18080"
663 grpc_address = "127.0.0.1:15051"
664
665 [store]
666 backend = "libsql"
667 url = "aion.db"
668
669 [runtime]
670 scheduler_threads = 2
671 query_timeout_ms = 10000
672
673 [drain]
674 timeout_seconds = 45
675
676 [auth]
677 enabled = true
678 jwks_url = "https://issuer.example.com/.well-known/jwks.json"
679 jwks_refresh_seconds = 60
680
681 [metrics]
682 enabled = true
683
684 [namespaces]
685 default = "production"
686
687 [websocket]
688 outbound_buffer_bound = 16
689 event_broadcast_capacity = 1024
690 cluster_broadcast_capacity = 1024
691 "#,
692 )?;
693
694 assert_eq!(config.store.backend, StoreBackend::LibSql);
695 assert_eq!(config.store.url.as_deref(), Some("aion.db"));
696 assert_eq!(config.runtime.scheduler_threads, 2);
697 assert_eq!(config.runtime.query_timeout_ms, Some(10_000));
698 assert_eq!(config.namespaces.default, "production");
699 assert_eq!(config.namespaces.auto_create, AutoCreate::Open);
701 assert_eq!(
704 config.namespaces.max_in_flight_activities,
705 DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
706 );
707 assert_eq!(config.websocket.outbound_buffer_bound, 16);
708 assert_eq!(config.websocket.event_broadcast_capacity, Some(1024));
709 Ok(())
710 }
711
712 #[test]
713 fn namespaces_auto_create_closed_parses() -> Result<(), Box<dyn std::error::Error>> {
714 let config = ServerConfig::from_slice(
715 br#"
716 [namespaces]
717 default = "production"
718 auto_create = "closed"
719 "#,
720 )?;
721 assert_eq!(config.namespaces.default, "production");
722 assert_eq!(config.namespaces.auto_create, AutoCreate::Closed);
723 Ok(())
724 }
725
726 #[test]
727 fn namespaces_auto_create_open_parses() -> Result<(), Box<dyn std::error::Error>> {
728 let config = ServerConfig::from_slice(
729 br#"
730 [namespaces]
731 auto_create = "open"
732 "#,
733 )?;
734 assert_eq!(config.namespaces.auto_create, AutoCreate::Open);
735 Ok(())
736 }
737
738 #[test]
739 fn namespaces_max_in_flight_activities_override_parses()
740 -> Result<(), Box<dyn std::error::Error>> {
741 let config = ServerConfig::from_slice(
742 br#"
743 [namespaces]
744 default = "production"
745 max_in_flight_activities = 32
746 "#,
747 )?;
748 assert_eq!(config.namespaces.max_in_flight_activities, 32);
749 let (_store, runtime) = config.into_parts();
751 assert_eq!(runtime.max_in_flight_activities, 32);
752 Ok(())
753 }
754
755 #[test]
756 fn namespaces_max_in_flight_activities_defaults_when_omitted()
757 -> Result<(), Box<dyn std::error::Error>> {
758 let config = ServerConfig::from_slice(
761 br#"
762 [namespaces]
763 default = "production"
764 "#,
765 )?;
766 assert_eq!(
767 config.namespaces.max_in_flight_activities,
768 DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
769 );
770 Ok(())
771 }
772
773 #[test]
774 fn namespaces_auto_create_rejects_unknown_variant() {
775 let result = ServerConfig::from_slice(
776 br#"
777 [namespaces]
778 auto_create = "sometimes"
779 "#,
780 );
781 assert!(
782 result.is_err(),
783 "an unknown auto_create variant must fail to parse"
784 );
785 }
786
787 #[test]
788 fn missing_event_broadcast_capacity_uses_default() -> Result<(), Box<dyn std::error::Error>> {
789 let config = ServerConfig::from_slice(
793 br"
794 [runtime]
795 query_timeout_ms = 10000
796
797 [websocket]
798 cluster_broadcast_capacity = 64
799 ",
800 )?;
801 assert_eq!(
802 config.websocket.event_broadcast_capacity,
803 Some(DEFAULT_EVENT_BROADCAST_CAPACITY),
804 "omitted event_broadcast_capacity must resolve to the default"
805 );
806 Ok(())
807 }
808
809 #[test]
810 fn zero_event_broadcast_capacity_fails_startup_validation() {
811 let result = ServerConfig::from_slice(
812 br"
813 [websocket]
814 event_broadcast_capacity = 0
815 ",
816 );
817
818 let message = result
819 .err()
820 .map_or_else(String::new, |error| error.to_string());
821 assert!(
822 message.contains("websocket.event_broadcast_capacity"),
823 "validation message must name the zero-valued key: {message}"
824 );
825 }
826
827 #[test]
828 fn missing_cluster_broadcast_capacity_uses_default() -> Result<(), Box<dyn std::error::Error>> {
829 let config = ServerConfig::from_slice(
833 br"
834 [runtime]
835 scheduler_threads = 1
836 query_timeout_ms = 10000
837
838 [websocket]
839 event_broadcast_capacity = 64
840 ",
841 )?;
842 assert_eq!(
843 config.websocket.cluster_broadcast_capacity,
844 Some(DEFAULT_CLUSTER_BROADCAST_CAPACITY),
845 "omitted cluster_broadcast_capacity must resolve to the default"
846 );
847 Ok(())
848 }
849
850 #[test]
851 fn zero_cluster_broadcast_capacity_fails_startup_validation() {
852 let result = ServerConfig::from_slice(
853 br"
854 [runtime]
855 query_timeout_ms = 10000
856
857 [websocket]
858 event_broadcast_capacity = 64
859 cluster_broadcast_capacity = 0
860 ",
861 );
862
863 let message = result
864 .err()
865 .map_or_else(String::new, |error| error.to_string());
866 assert!(
867 message.contains("websocket.cluster_broadcast_capacity"),
868 "validation message must name the zero-valued cluster key: {message}"
869 );
870 }
871
872 #[test]
876 fn missing_observability_section_uses_defaults() -> Result<(), Box<dyn std::error::Error>> {
877 let config = ServerConfig::from_slice(
878 br"
879 [runtime]
880 query_timeout_ms = 10000
881
882 [websocket]
883 event_broadcast_capacity = 64
884 cluster_broadcast_capacity = 64
885 ",
886 )?;
887 assert_eq!(
888 config.observability.max_event_bytes,
889 crate::config::DEFAULT_OBSERVABILITY_MAX_EVENT_BYTES
890 );
891 assert_eq!(
892 config.observability.max_stream_events,
893 crate::config::DEFAULT_OBSERVABILITY_MAX_STREAM_EVENTS
894 );
895 let (_store, runtime) = config.into_parts();
897 assert_eq!(
898 runtime.observability.max_event_bytes,
899 crate::config::DEFAULT_OBSERVABILITY_MAX_EVENT_BYTES
900 );
901 Ok(())
902 }
903
904 #[test]
907 fn observability_section_parses_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
908 let config = ServerConfig::from_slice(
909 br"
910 [runtime]
911 query_timeout_ms = 10000
912
913 [websocket]
914 event_broadcast_capacity = 64
915 cluster_broadcast_capacity = 64
916
917 [observability]
918 max_event_bytes = 512
919 max_stream_events = 3
920 ",
921 )?;
922 assert_eq!(config.observability.max_event_bytes, 512);
923 assert_eq!(config.observability.max_stream_events, 3);
924 let (_store, runtime) = config.into_parts();
925 assert_eq!(runtime.observability.max_event_bytes, 512);
926 assert_eq!(runtime.observability.max_stream_events, 3);
927 Ok(())
928 }
929
930 #[test]
931 fn zero_observability_max_event_bytes_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 cluster_broadcast_capacity = 64
940
941 [observability]
942 max_event_bytes = 0
943 ",
944 );
945 let message = result
946 .err()
947 .map_or_else(String::new, |error| error.to_string());
948 assert!(
949 message.contains("observability.max_event_bytes"),
950 "validation message must name the zero-valued key: {message}"
951 );
952 }
953
954 #[test]
955 fn zero_observability_max_stream_events_fails_startup_validation() {
956 let result = ServerConfig::from_slice(
957 br"
958 [runtime]
959 query_timeout_ms = 10000
960
961 [websocket]
962 event_broadcast_capacity = 64
963 cluster_broadcast_capacity = 64
964
965 [observability]
966 max_stream_events = 0
967 ",
968 );
969 let message = result
970 .err()
971 .map_or_else(String::new, |error| error.to_string());
972 assert!(
973 message.contains("observability.max_stream_events"),
974 "validation message must name the zero-valued key: {message}"
975 );
976 }
977
978 #[test]
979 fn missing_query_timeout_uses_default() -> Result<(), Box<dyn std::error::Error>> {
980 let config = ServerConfig::from_slice(
984 br"
985 [runtime]
986 scheduler_threads = 1
987
988 [websocket]
989 event_broadcast_capacity = 64
990 cluster_broadcast_capacity = 64
991 ",
992 )?;
993 assert_eq!(
994 config.runtime.query_timeout_ms,
995 Some(DEFAULT_QUERY_TIMEOUT_MS),
996 "omitted query_timeout_ms must resolve to the default"
997 );
998 Ok(())
999 }
1000
1001 #[test]
1002 fn empty_config_boots_on_operational_defaults() -> Result<(), Box<dyn std::error::Error>> {
1003 let config = ServerConfig::from_slice(b"")?;
1008 assert_eq!(config.store.backend, StoreBackend::Haematite);
1009 assert_eq!(
1010 config.runtime.query_timeout_ms,
1011 Some(DEFAULT_QUERY_TIMEOUT_MS)
1012 );
1013 assert_eq!(
1014 config.websocket.event_broadcast_capacity,
1015 Some(DEFAULT_EVENT_BROADCAST_CAPACITY)
1016 );
1017 assert_eq!(
1018 config.websocket.cluster_broadcast_capacity,
1019 Some(DEFAULT_CLUSTER_BROADCAST_CAPACITY)
1020 );
1021 Ok(())
1022 }
1023
1024 #[test]
1025 fn zero_query_timeout_fails_startup_validation() {
1026 let result = ServerConfig::from_slice(
1027 br"
1028 [runtime]
1029 query_timeout_ms = 0
1030
1031 [websocket]
1032 event_broadcast_capacity = 64
1033 cluster_broadcast_capacity = 64
1034 ",
1035 );
1036
1037 let message = result
1038 .err()
1039 .map_or_else(String::new, |error| error.to_string());
1040 assert!(
1041 message.contains("runtime.query_timeout_ms"),
1042 "validation message must name the zero-valued key: {message}"
1043 );
1044 }
1045
1046 #[test]
1051 fn deploy_enabled_defaults_max_archive_bytes() -> Result<(), Box<dyn std::error::Error>> {
1052 let config = ServerConfig::from_slice(
1053 br"
1054 [runtime]
1055 query_timeout_ms = 10000
1056
1057 [websocket]
1058 event_broadcast_capacity = 64
1059 cluster_broadcast_capacity = 64
1060
1061 [deploy]
1062 enabled = true
1063 ",
1064 )?;
1065
1066 assert_eq!(
1067 config.deploy.max_archive_bytes,
1068 Some(DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES),
1069 "omitted max_archive_bytes must resolve to the conservative default"
1070 );
1071 assert_eq!(
1072 config.deploy.max_inflated_bytes,
1073 Some(DEFAULT_DEPLOY_MAX_INFLATED_BYTES),
1074 "omitted max_inflated_bytes must resolve to the conservative default"
1075 );
1076 Ok(())
1077 }
1078
1079 #[test]
1080 fn deploy_zero_max_archive_bytes_fails_startup_validation() {
1081 let result = ServerConfig::from_slice(
1082 br"
1083 [runtime]
1084 query_timeout_ms = 10000
1085
1086 [websocket]
1087 event_broadcast_capacity = 64
1088 cluster_broadcast_capacity = 64
1089
1090 [deploy]
1091 enabled = true
1092 max_archive_bytes = 0
1093 ",
1094 );
1095
1096 let message = result
1097 .err()
1098 .map_or_else(String::new, |error| error.to_string());
1099 assert!(
1100 message.contains("deploy.max_archive_bytes"),
1101 "validation message must name the zero-valued key: {message}"
1102 );
1103 }
1104
1105 #[test]
1110 fn deploy_enabled_defaults_max_inflated_bytes() -> Result<(), Box<dyn std::error::Error>> {
1111 let config = ServerConfig::from_slice(
1112 br"
1113 [runtime]
1114 query_timeout_ms = 10000
1115
1116 [websocket]
1117 event_broadcast_capacity = 64
1118 cluster_broadcast_capacity = 64
1119
1120 [deploy]
1121 enabled = true
1122 max_archive_bytes = 16777216
1123 ",
1124 )?;
1125
1126 assert_eq!(
1127 config.deploy.max_archive_bytes,
1128 Some(16_777_216),
1129 "explicit max_archive_bytes must be left untouched"
1130 );
1131 assert_eq!(
1132 config.deploy.max_inflated_bytes,
1133 Some(DEFAULT_DEPLOY_MAX_INFLATED_BYTES),
1134 "omitted max_inflated_bytes must resolve to the conservative default"
1135 );
1136 Ok(())
1137 }
1138
1139 #[test]
1140 fn deploy_zero_max_inflated_bytes_fails_startup_validation() {
1141 let result = ServerConfig::from_slice(
1142 br"
1143 [runtime]
1144 query_timeout_ms = 10000
1145
1146 [websocket]
1147 event_broadcast_capacity = 64
1148 cluster_broadcast_capacity = 64
1149
1150 [deploy]
1151 enabled = true
1152 max_archive_bytes = 16777216
1153 max_inflated_bytes = 0
1154 ",
1155 );
1156
1157 let message = result
1158 .err()
1159 .map_or_else(String::new, |error| error.to_string());
1160 assert!(
1161 message.contains("deploy.max_inflated_bytes"),
1162 "validation message must name the zero-valued key: {message}"
1163 );
1164 }
1165
1166 #[test]
1169 fn deploy_max_inflated_below_max_archive_fails_startup_validation() {
1170 let result = ServerConfig::from_slice(
1171 br"
1172 [runtime]
1173 query_timeout_ms = 10000
1174
1175 [websocket]
1176 event_broadcast_capacity = 64
1177 cluster_broadcast_capacity = 64
1178
1179 [deploy]
1180 enabled = true
1181 max_archive_bytes = 16777216
1182 max_inflated_bytes = 16777215
1183 ",
1184 );
1185
1186 let message = result
1187 .err()
1188 .map_or_else(String::new, |error| error.to_string());
1189 assert!(
1190 message.contains("deploy.max_inflated_bytes")
1191 && message.contains("deploy.max_archive_bytes"),
1192 "validation message must name both ceilings: {message}"
1193 );
1194 }
1195
1196 #[test]
1199 fn deploy_disabled_requires_no_archive_ceiling() -> Result<(), Box<dyn std::error::Error>> {
1200 let config = ServerConfig::from_slice(
1201 br"
1202 [runtime]
1203 query_timeout_ms = 10000
1204
1205 [websocket]
1206 event_broadcast_capacity = 64
1207 cluster_broadcast_capacity = 64
1208 ",
1209 )?;
1210
1211 assert!(!config.deploy.enabled);
1212 assert_eq!(config.deploy.max_archive_bytes, None);
1213 assert_eq!(config.deploy.max_inflated_bytes, None);
1214 Ok(())
1215 }
1216
1217 #[test]
1218 fn deploy_section_parses_enabled_with_ceilings() -> Result<(), Box<dyn std::error::Error>> {
1219 let config = ServerConfig::from_slice(
1220 br"
1221 [runtime]
1222 query_timeout_ms = 10000
1223
1224 [websocket]
1225 event_broadcast_capacity = 64
1226 cluster_broadcast_capacity = 64
1227
1228 [deploy]
1229 enabled = true
1230 max_archive_bytes = 16777216
1231 max_inflated_bytes = 67108864
1232 ",
1233 )?;
1234
1235 assert!(config.deploy.enabled);
1236 assert_eq!(config.deploy.max_archive_bytes, Some(16_777_216));
1237 assert_eq!(config.deploy.max_inflated_bytes, Some(67_108_864));
1238 Ok(())
1239 }
1240
1241 #[test]
1245 fn cors_allowed_origins_default_empty() -> Result<(), Box<dyn std::error::Error>> {
1246 let config = ServerConfig::from_slice(
1247 br"
1248 [runtime]
1249 query_timeout_ms = 10000
1250
1251 [websocket]
1252 event_broadcast_capacity = 64
1253 cluster_broadcast_capacity = 64
1254 ",
1255 )?;
1256
1257 assert!(config.server.cors_allowed_origins.is_empty());
1258 let (_, runtime) = config.into_parts();
1259 assert!(runtime.cors_allowed_origins.is_empty());
1260 Ok(())
1261 }
1262
1263 #[test]
1266 fn cors_allowed_origins_parse_and_round_trip() -> Result<(), Box<dyn std::error::Error>> {
1267 let config = ServerConfig::from_slice(
1268 br#"
1269 [server]
1270 cors_allowed_origins = ["http://localhost:5173", "http://127.0.0.1:5173"]
1271
1272 [runtime]
1273 query_timeout_ms = 10000
1274
1275 [websocket]
1276 event_broadcast_capacity = 64
1277 cluster_broadcast_capacity = 64
1278 "#,
1279 )?;
1280
1281 assert_eq!(
1282 config.server.cors_allowed_origins,
1283 vec![
1284 "http://localhost:5173".to_owned(),
1285 "http://127.0.0.1:5173".to_owned()
1286 ]
1287 );
1288 let (_, runtime) = config.into_parts();
1289 assert_eq!(
1290 runtime.cors_allowed_origins,
1291 vec![
1292 "http://localhost:5173".to_owned(),
1293 "http://127.0.0.1:5173".to_owned()
1294 ]
1295 );
1296 Ok(())
1297 }
1298
1299 #[test]
1303 fn cors_allowed_origins_reject_malformed() {
1304 for bad in ["", "localhost:5173", "http://localhost:5173/"] {
1305 let toml = format!(
1306 "[server]\ncors_allowed_origins = [\"{bad}\"]\n\n[runtime]\nquery_timeout_ms = 10000\n\n[websocket]\nevent_broadcast_capacity = 64\n"
1307 );
1308 let result = ServerConfig::from_slice(toml.as_bytes());
1309 let message = result
1310 .err()
1311 .map_or_else(String::new, |error| error.to_string());
1312 assert!(
1313 message.contains("cors_allowed_origins"),
1314 "malformed origin `{bad}` must be rejected naming the key: {message}"
1315 );
1316 }
1317 }
1318
1319 #[test]
1321 fn dev_absent_leaves_surface_dark() -> Result<(), Box<dyn std::error::Error>> {
1322 let config = ServerConfig::from_slice(
1323 br"
1324 [runtime]
1325 query_timeout_ms = 10000
1326
1327 [websocket]
1328 event_broadcast_capacity = 64
1329 cluster_broadcast_capacity = 64
1330 ",
1331 )?;
1332
1333 assert!(!config.dev.enabled);
1334 Ok(())
1335 }
1336
1337 #[test]
1340 fn dev_section_parses_enabled() -> Result<(), Box<dyn std::error::Error>> {
1341 let config = ServerConfig::from_slice(
1342 br"
1343 [runtime]
1344 query_timeout_ms = 10000
1345
1346 [websocket]
1347 event_broadcast_capacity = 64
1348 cluster_broadcast_capacity = 64
1349
1350 [dev]
1351 enabled = true
1352 ",
1353 )?;
1354
1355 assert!(config.dev.enabled);
1356 Ok(())
1357 }
1358
1359 #[test]
1362 fn authoring_absent_defaults_awl_workspace_but_keeps_gleam_dark()
1363 -> Result<(), Box<dyn std::error::Error>> {
1364 let home = crate::test_support::private_tempdir()?;
1365 let config = ServerConfig::from_slice_with_home(b"", home.path())?;
1366
1367 assert_eq!(config.authoring.gleam_path, None);
1368 assert_eq!(config.authoring.project_root, None);
1369 assert_eq!(
1370 config.authoring.workspace_dir.as_deref(),
1371 Some(home.path().join(DEFAULT_AUTHORING_WORKSPACE_DIR).as_path())
1372 );
1373 Ok(())
1374 }
1375
1376 #[test]
1378 fn authoring_explicit_workspace_is_honored() -> Result<(), Box<dyn std::error::Error>> {
1379 let config = ServerConfig::from_slice(
1380 br#"
1381 [authoring]
1382 workspace_dir = "/srv/aion/studio"
1383 "#,
1384 )?;
1385
1386 assert_eq!(
1387 config.authoring.workspace_dir.as_deref(),
1388 Some(std::path::Path::new("/srv/aion/studio"))
1389 );
1390 Ok(())
1391 }
1392
1393 #[test]
1396 fn authoring_section_parses_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
1397 let config = ServerConfig::from_slice(
1398 br#"
1399 [runtime]
1400 query_timeout_ms = 10000
1401
1402 [websocket]
1403 event_broadcast_capacity = 64
1404 cluster_broadcast_capacity = 64
1405
1406 [authoring]
1407 gleam_path = "/usr/local/bin/gleam"
1408 project_root = "/srv/aion/authoring"
1409 "#,
1410 )?;
1411
1412 assert_eq!(
1413 config.authoring.gleam_path.as_deref(),
1414 Some(std::path::Path::new("/usr/local/bin/gleam"))
1415 );
1416 let (_, runtime) = config.into_parts();
1417 assert_eq!(
1418 runtime.authoring.gleam_path.as_deref(),
1419 Some(std::path::Path::new("/usr/local/bin/gleam"))
1420 );
1421 assert_eq!(
1422 runtime.authoring.project_root.as_deref(),
1423 Some(std::path::Path::new("/srv/aion/authoring"))
1424 );
1425 Ok(())
1426 }
1427
1428 #[test]
1432 fn authoring_gleam_path_without_project_root_fails_naming_key_and_env() {
1433 let result = ServerConfig::from_slice(
1434 br#"
1435 [runtime]
1436 query_timeout_ms = 10000
1437
1438 [websocket]
1439 event_broadcast_capacity = 64
1440 cluster_broadcast_capacity = 64
1441
1442 [authoring]
1443 gleam_path = "/usr/local/bin/gleam"
1444 "#,
1445 );
1446
1447 let message = result
1448 .err()
1449 .map_or_else(String::new, |error| error.to_string());
1450 assert!(
1451 message.contains("authoring.project_root"),
1452 "validation message must name the missing key: {message}"
1453 );
1454 assert!(
1455 message.contains("AION_AUTHORING_PROJECT_ROOT"),
1456 "validation message must name the environment override: {message}"
1457 );
1458 }
1459
1460 #[test]
1463 fn authoring_empty_gleam_path_fails_naming_key_and_env() {
1464 let result = ServerConfig::from_slice(
1465 br#"
1466 [runtime]
1467 query_timeout_ms = 10000
1468
1469 [websocket]
1470 event_broadcast_capacity = 64
1471 cluster_broadcast_capacity = 64
1472
1473 [authoring]
1474 gleam_path = ""
1475 "#,
1476 );
1477
1478 let message = result
1479 .err()
1480 .map_or_else(String::new, |error| error.to_string());
1481 assert!(
1482 message.contains("authoring.gleam_path"),
1483 "validation message must name the empty key: {message}"
1484 );
1485 assert!(
1486 message.contains("AION_AUTHORING_GLEAM_PATH"),
1487 "validation message must name the environment override: {message}"
1488 );
1489 }
1490
1491 #[test]
1493 fn cli_overrides_set_authoring_paths() -> Result<(), Box<dyn std::error::Error>> {
1494 let mut config = ServerConfig::from_slice(
1495 br"
1496 [runtime]
1497 query_timeout_ms = 10000
1498
1499 [websocket]
1500 event_broadcast_capacity = 64
1501 cluster_broadcast_capacity = 64
1502 ",
1503 )?;
1504 let cli = CliOverrides {
1505 gleam_path: Some(std::path::PathBuf::from("/opt/gleam")),
1506 authoring_project_root: Some(std::path::PathBuf::from("/opt/project")),
1507 ..CliOverrides::default()
1508 };
1509
1510 config.apply_cli_overrides(&cli);
1511 config.validate()?;
1512
1513 assert_eq!(
1514 config.authoring.gleam_path.as_deref(),
1515 Some(std::path::Path::new("/opt/gleam"))
1516 );
1517 assert_eq!(
1518 config.authoring.project_root.as_deref(),
1519 Some(std::path::Path::new("/opt/project"))
1520 );
1521 Ok(())
1522 }
1523
1524 #[test]
1527 fn legacy_dashboard_section_alias_still_parses() -> Result<(), Box<dyn std::error::Error>> {
1528 let config = ServerConfig::from_slice(
1529 br#"
1530 [runtime]
1531 query_timeout_ms = 10000
1532
1533 [websocket]
1534 event_broadcast_capacity = 64
1535 cluster_broadcast_capacity = 64
1536
1537 [dashboard]
1538 source = { FileSystem = { asset_path = "/srv/aion/ui" } }
1539 "#,
1540 )?;
1541 match &config.ops_console.source {
1542 OpsConsoleAssetSource::FileSystem { asset_path } => {
1543 assert_eq!(asset_path.as_os_str(), "/srv/aion/ui");
1544 }
1545 OpsConsoleAssetSource::Embedded => {
1546 return Err("legacy [dashboard] section must map to ops_console".into());
1547 }
1548 }
1549 Ok(())
1550 }
1551
1552 #[test]
1554 fn ops_console_section_parses() -> Result<(), Box<dyn std::error::Error>> {
1555 let config = ServerConfig::from_slice(
1556 br#"
1557 [runtime]
1558 query_timeout_ms = 10000
1559
1560 [websocket]
1561 event_broadcast_capacity = 64
1562 cluster_broadcast_capacity = 64
1563
1564 [ops_console]
1565 source = { FileSystem = { asset_path = "/srv/aion/ui" } }
1566 "#,
1567 )?;
1568 assert!(matches!(
1569 config.ops_console.source,
1570 OpsConsoleAssetSource::FileSystem { .. }
1571 ));
1572 Ok(())
1573 }
1574
1575 #[test]
1576 fn invalid_values_name_problematic_field() {
1577 let result = ServerConfig::from_slice(
1578 br"
1579 [runtime]
1580 scheduler_threads = 0
1581 ",
1582 );
1583
1584 let message = result
1585 .err()
1586 .map_or_else(String::new, |error| error.to_string());
1587 assert!(message.contains("runtime.scheduler_threads"));
1588 }
1589
1590 #[test]
1591 fn cli_overrides_win_over_loaded_values() -> Result<(), Box<dyn std::error::Error>> {
1592 let mut config = ServerConfig::from_slice(
1593 br#"
1594 [store]
1595 backend = "libsql"
1596 url = "file.db"
1597
1598 [runtime]
1599 query_timeout_ms = 10000
1600
1601 [websocket]
1602 event_broadcast_capacity = 64
1603 cluster_broadcast_capacity = 64
1604 "#,
1605 )?;
1606 let cli = CliOverrides {
1607 store_url: Some("cli.db".to_owned()),
1608 scheduler_threads: Some(3),
1609 ..CliOverrides::default()
1610 };
1611
1612 config.apply_cli_overrides(&cli);
1613 config.validate()?;
1614
1615 assert_eq!(config.store.url.as_deref(), Some("cli.db"));
1616 assert_eq!(config.runtime.scheduler_threads, 3);
1617 Ok(())
1618 }
1619
1620 #[test]
1621 fn default_config_defaults() -> Result<(), Box<dyn std::error::Error>> {
1622 let mut config = ServerConfig::default();
1623
1624 assert_eq!(config.store.backend, StoreBackend::Haematite);
1627 assert_eq!(config.store.data_dir, None);
1628 assert_eq!(config.store.shard_count, 64);
1632 assert_eq!(config.store.url, None);
1633 assert_eq!(config.server.grpc_address.to_string(), "127.0.0.1:50051");
1634 assert_eq!(config.server.listen_address.to_string(), "127.0.0.1:8080");
1635 assert_eq!(config.namespaces.default, "default");
1636 assert_eq!(config.namespaces.auto_create, AutoCreate::Open);
1640 assert_eq!(
1643 config.namespaces.max_in_flight_activities,
1644 DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
1645 );
1646 assert_eq!(config.namespaces.max_in_flight_activities, 1024);
1647 assert!(!config.auth.enabled);
1648 assert!(config.metrics.enabled);
1649 assert_eq!(config.websocket.event_broadcast_capacity, None);
1653 assert_eq!(config.websocket.cluster_broadcast_capacity, None);
1654 assert_eq!(config.runtime.query_timeout_ms, None);
1655 config.websocket.event_broadcast_capacity = Some(64);
1656 config.websocket.cluster_broadcast_capacity = Some(64);
1657 config.runtime.query_timeout_ms = Some(10_000);
1658 let home = crate::test_support::private_tempdir()?;
1659 let working_dir = crate::test_support::private_tempdir()?;
1660 super::fill_home_defaults(&mut config, home.path(), working_dir.path())?;
1661 assert_eq!(
1662 config.store.data_dir.as_deref(),
1663 home.path().join("data").to_str()
1664 );
1665 config.validate()?;
1666 Ok(())
1667 }
1668
1669 #[test]
1670 fn outbox_is_disabled_by_default_and_needs_no_knobs() -> Result<(), Box<dyn std::error::Error>>
1671 {
1672 let mut config = ServerConfig::default();
1673 config.store.data_dir = Some("test-data".to_owned());
1674 config.websocket.event_broadcast_capacity = Some(64);
1675 config.websocket.cluster_broadcast_capacity = Some(64);
1676 config.runtime.query_timeout_ms = Some(10_000);
1677
1678 assert!(!config.outbox.enabled);
1682 assert_eq!(config.outbox.poll_interval_ms, None);
1683 assert_eq!(config.outbox.batch_size, None);
1684 assert_eq!(config.outbox.max_attempts, None);
1685 assert_eq!(config.outbox.backoff_base_ms, None);
1686 assert_eq!(config.outbox.backoff_multiplier, None);
1687 assert_eq!(config.outbox.backoff_max_ms, None);
1688 assert_eq!(config.outbox.reconcile_interval_ms, None);
1689 assert_eq!(config.outbox.reconcile_stale_after_ms, None);
1690 config.validate()?;
1691 Ok(())
1692 }
1693
1694 fn outbox_enabled_base() -> ServerConfig {
1695 let mut config = ServerConfig::default();
1696 config.store.data_dir = Some("test-data".to_owned());
1697 config.websocket.event_broadcast_capacity = Some(64);
1698 config.websocket.cluster_broadcast_capacity = Some(64);
1699 config.runtime.query_timeout_ms = Some(10_000);
1700 config.outbox.enabled = true;
1701 config.outbox.poll_interval_ms = Some(250);
1702 config.outbox.batch_size = Some(64);
1703 config.outbox.max_attempts = Some(5);
1704 config.outbox.backoff_base_ms = Some(100);
1705 config.outbox.backoff_multiplier = Some(2);
1706 config.outbox.backoff_max_ms = Some(30_000);
1707 config.outbox.reconcile_interval_ms = Some(1_000);
1708 config.outbox.reconcile_stale_after_ms = Some(60_000);
1709 config
1710 }
1711
1712 #[test]
1713 fn outbox_enabled_with_all_knobs_validates() -> Result<(), Box<dyn std::error::Error>> {
1714 outbox_enabled_base().validate()?;
1715 Ok(())
1716 }
1717
1718 #[test]
1719 fn outbox_enabled_defaults_poll_interval() -> Result<(), Box<dyn std::error::Error>> {
1720 let config = ServerConfig::from_slice(
1724 br"
1725 [runtime]
1726 query_timeout_ms = 10000
1727
1728 [websocket]
1729 event_broadcast_capacity = 64
1730 cluster_broadcast_capacity = 64
1731
1732 [outbox]
1733 enabled = true
1734 ",
1735 )?;
1736 assert_eq!(
1737 config.outbox.poll_interval_ms,
1738 Some(DEFAULT_OUTBOX_POLL_INTERVAL_MS),
1739 "omitted poll_interval_ms must resolve to the default"
1740 );
1741 Ok(())
1742 }
1743
1744 #[test]
1745 fn outbox_enabled_defaults_max_attempts() -> Result<(), Box<dyn std::error::Error>> {
1746 let config = ServerConfig::from_slice(
1749 br"
1750 [runtime]
1751 query_timeout_ms = 10000
1752
1753 [websocket]
1754 event_broadcast_capacity = 64
1755 cluster_broadcast_capacity = 64
1756
1757 [outbox]
1758 enabled = true
1759 poll_interval_ms = 250
1760 ",
1761 )?;
1762 assert_eq!(
1763 config.outbox.poll_interval_ms,
1764 Some(250),
1765 "explicit poll_interval_ms must be left untouched"
1766 );
1767 assert_eq!(
1768 config.outbox.max_attempts,
1769 Some(DEFAULT_OUTBOX_MAX_ATTEMPTS),
1770 "omitted max_attempts must resolve to the default"
1771 );
1772 Ok(())
1773 }
1774
1775 #[test]
1776 fn outbox_enabled_with_only_enabled_flag_uses_all_defaults()
1777 -> Result<(), Box<dyn std::error::Error>> {
1778 let config = ServerConfig::from_slice(
1782 br"
1783 [runtime]
1784 query_timeout_ms = 10000
1785
1786 [websocket]
1787 event_broadcast_capacity = 64
1788 cluster_broadcast_capacity = 64
1789
1790 [outbox]
1791 enabled = true
1792 ",
1793 )?;
1794 assert!(config.outbox.enabled);
1795 assert_eq!(
1796 config.outbox.poll_interval_ms,
1797 Some(DEFAULT_OUTBOX_POLL_INTERVAL_MS)
1798 );
1799 assert_eq!(config.outbox.batch_size, Some(DEFAULT_OUTBOX_BATCH_SIZE));
1800 assert_eq!(
1801 config.outbox.max_attempts,
1802 Some(DEFAULT_OUTBOX_MAX_ATTEMPTS)
1803 );
1804 assert_eq!(
1805 config.outbox.backoff_base_ms,
1806 Some(DEFAULT_OUTBOX_BACKOFF_BASE_MS)
1807 );
1808 assert_eq!(
1809 config.outbox.backoff_multiplier,
1810 Some(DEFAULT_OUTBOX_BACKOFF_MULTIPLIER)
1811 );
1812 assert_eq!(
1813 config.outbox.backoff_max_ms,
1814 Some(DEFAULT_OUTBOX_BACKOFF_MAX_MS)
1815 );
1816 assert_eq!(config.outbox.reconcile_interval_ms, None);
1819 assert_eq!(config.outbox.reconcile_stale_after_ms, None);
1820 Ok(())
1821 }
1822
1823 #[test]
1824 fn outbox_enabled_zero_poll_interval_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
1825 let mut config = outbox_enabled_base();
1828 config.outbox.poll_interval_ms = Some(0);
1829 let error = config
1830 .validate()
1831 .err()
1832 .ok_or("enabled outbox with zero poll interval must fail")?;
1833 assert!(
1834 error.to_string().contains("outbox.poll_interval_ms"),
1835 "error must name the zero-valued key: {error}"
1836 );
1837 Ok(())
1838 }
1839
1840 #[test]
1841 fn outbox_enabled_zero_max_attempts_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
1842 let mut config = outbox_enabled_base();
1843 config.outbox.max_attempts = Some(0);
1844 let error = config
1845 .validate()
1846 .err()
1847 .ok_or("enabled outbox with zero max attempts must fail")?;
1848 assert!(
1849 error.to_string().contains("outbox.max_attempts"),
1850 "error must name the zero-valued key: {error}"
1851 );
1852 Ok(())
1853 }
1854
1855 #[test]
1856 fn outbox_backoff_max_below_base_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
1857 let mut config = outbox_enabled_base();
1858 config.outbox.backoff_base_ms = Some(1_000);
1859 config.outbox.backoff_max_ms = Some(500);
1860 let error = config
1861 .validate()
1862 .err()
1863 .ok_or("backoff_max below backoff_base must fail")?;
1864 assert!(
1865 error.to_string().contains("outbox.backoff_max_ms"),
1866 "error must name the offending key: {error}"
1867 );
1868 Ok(())
1869 }
1870
1871 #[test]
1872 fn outbox_enabled_can_leave_reconciliation_dark() -> Result<(), Box<dyn std::error::Error>> {
1873 let mut config = outbox_enabled_base();
1874 config.outbox.reconcile_interval_ms = None;
1875 config.outbox.reconcile_stale_after_ms = None;
1876 config.validate()?;
1877 Ok(())
1878 }
1879
1880 #[test]
1881 fn outbox_reconciliation_requires_interval_when_partially_enabled()
1882 -> Result<(), Box<dyn std::error::Error>> {
1883 let mut config = outbox_enabled_base();
1884 config.outbox.reconcile_interval_ms = None;
1885 let error = config
1886 .validate()
1887 .err()
1888 .ok_or("reconciliation without interval must fail")?;
1889 assert!(error.to_string().contains("outbox.reconcile_interval_ms"));
1890 Ok(())
1891 }
1892
1893 #[test]
1894 fn outbox_reconciliation_requires_stale_threshold_when_partially_enabled()
1895 -> Result<(), Box<dyn std::error::Error>> {
1896 let mut config = outbox_enabled_base();
1897 config.outbox.reconcile_stale_after_ms = None;
1898 let error = config
1899 .validate()
1900 .err()
1901 .ok_or("reconciliation without stale threshold must fail")?;
1902 assert!(
1903 error
1904 .to_string()
1905 .contains("outbox.reconcile_stale_after_ms")
1906 );
1907 Ok(())
1908 }
1909
1910 #[test]
1911 fn package_discovery_is_sorted() -> Result<(), Box<dyn std::error::Error>> {
1912 let temp_dir = crate::test_support::private_tempdir()?;
1913 std::fs::write(temp_dir.path().join("zeta.aion"), b"package")?;
1914 std::fs::write(temp_dir.path().join("alpha.aion"), b"package")?;
1915 std::fs::write(temp_dir.path().join("ignored.txt"), b"package")?;
1916 std::fs::create_dir(temp_dir.path().join("nested"))?;
1917 std::fs::write(
1918 temp_dir.path().join("nested").join("nested.aion"),
1919 b"package",
1920 )?;
1921
1922 let packages = discover_workflow_packages(temp_dir.path())?;
1923
1924 assert_eq!(
1925 packages,
1926 vec![
1927 temp_dir.path().join("alpha.aion"),
1928 temp_dir.path().join("zeta.aion"),
1929 ]
1930 );
1931 Ok(())
1932 }
1933
1934 #[test]
1935 fn workflow_package_merge_is_additive_and_deduplicated() {
1936 let mut packages = vec!["config.aion".into(), "shared.aion".into()];
1937 let discovered = vec!["auto.aion".into(), "shared.aion".into()];
1938 let cli = vec!["cli.aion".into(), "auto.aion".into()];
1939
1940 merge_workflow_packages(&mut packages, discovered, &cli);
1941
1942 assert_eq!(
1943 packages,
1944 vec![
1945 std::path::PathBuf::from("config.aion"),
1946 std::path::PathBuf::from("shared.aion"),
1947 std::path::PathBuf::from("auto.aion"),
1948 std::path::PathBuf::from("cli.aion"),
1949 ]
1950 );
1951 }
1952
1953 #[test]
1954 fn package_merge_deduplicates_canonical_files() -> Result<(), Box<dyn std::error::Error>> {
1955 let temp_dir = crate::test_support::private_tempdir()?;
1956 let package = temp_dir.path().join("hello.aion");
1957 std::fs::write(&package, b"package")?;
1958 let mut packages = vec![package.clone()];
1959 let discovered = vec![temp_dir.path().join(".").join("hello.aion")];
1960
1961 merge_workflow_packages(&mut packages, discovered, &[]);
1962
1963 assert_eq!(packages, vec![package]);
1964 Ok(())
1965 }
1966
1967 #[test]
1968 fn zero_config_cli_workflow_package_uses_in_memory_defaults()
1969 -> Result<(), Box<dyn std::error::Error>> {
1970 let temp_dir = crate::test_support::private_tempdir()?;
1971
1972 let cli = CliOverrides {
1973 workflow_packages: vec!["hello-world.aion".into()],
1974 ..CliOverrides::default()
1975 };
1976 let mut config = ServerConfig::default();
1977 config.store.backend = StoreBackend::Memory;
1981 config.store.data_dir = None;
1982 config.websocket.event_broadcast_capacity = Some(64);
1987 config.websocket.cluster_broadcast_capacity = Some(64);
1988 config.runtime.query_timeout_ms = Some(10_000);
1989 config.load_discovered_workflow_packages(&cli, temp_dir.path())?;
1990
1991 config.validate()?;
1992
1993 assert_eq!(config.store.backend, StoreBackend::Memory);
1994 assert_eq!(config.store.url, None);
1995 assert_eq!(
1996 config.workflow_packages,
1997 vec![std::path::PathBuf::from("hello-world.aion")]
1998 );
1999 Ok(())
2000 }
2001
2002 #[test]
2003 fn cli_packages_are_additive() -> Result<(), Box<dyn std::error::Error>> {
2004 let mut config = ServerConfig::from_slice(
2005 br#"
2006 workflow_packages = ["config.aion"]
2007
2008 [runtime]
2009 query_timeout_ms = 10000
2010
2011 [websocket]
2012 event_broadcast_capacity = 64
2013 cluster_broadcast_capacity = 64
2014 "#,
2015 )?;
2016 let cli = CliOverrides {
2017 workflow_packages: vec!["cli-one.aion".into(), "cli-two.aion".into()],
2018 ..CliOverrides::default()
2019 };
2020
2021 merge_workflow_packages(
2022 &mut config.workflow_packages,
2023 Vec::new(),
2024 &cli.workflow_packages,
2025 );
2026
2027 assert_eq!(
2028 config.workflow_packages,
2029 vec![
2030 std::path::PathBuf::from("config.aion"),
2031 std::path::PathBuf::from("cli-one.aion"),
2032 std::path::PathBuf::from("cli-two.aion"),
2033 ]
2034 );
2035 Ok(())
2036 }
2037}