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, HomeSource,
32 ListenConfig, MetricsConfig, NamespaceConfig, NamespaceMode, NamespacesConfig,
33 OUTBOX_BACKOFF_BASE_REQUIRED, OUTBOX_BACKOFF_MAX_REQUIRED, OUTBOX_BACKOFF_MULTIPLIER_REQUIRED,
34 OUTBOX_BATCH_SIZE_REQUIRED, 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.path, home.source, &working_dir, true)
109 }
110
111 fn load_in(
112 cli: &CliOverrides,
113 home: &Path,
114 home_source: HomeSource,
115 working_dir: &Path,
116 overlay_environment: bool,
117 ) -> Result<LoadedConfig, ServerError> {
118 let discovered = file::discover(cli.config_path.as_deref(), home, working_dir)?;
119 let mut config = match discovered.bytes {
120 Some(bytes) => Self::parse_unresolved(&bytes).map_err(|error| ServerError::Config {
121 message: format!("failed to parse {}: {error}", discovered.source),
122 })?,
123 None => Self::default(),
124 };
125 if overlay_environment {
126 env::overlay(&mut config)?;
127 }
128 config.apply_cli_overrides(cli);
129 #[cfg(not(unix))]
130 let home_explicit = !overlay_environment || std::env::var_os("AION_HOME").is_some();
131 #[cfg(not(unix))]
132 let data_dir_explicit = config.store.data_dir.is_some();
133 #[cfg(not(unix))]
134 let data_root_required = matches!(config.store.backend, StoreBackend::Haematite);
135 #[cfg(not(unix))]
136 let authoring_workspace_explicit = config.authoring.workspace_dir.is_some();
137 config.load_discovered_workflow_packages(cli, working_dir)?;
138 let legacy_notices = fill_home_defaults(&mut config, home, home_source, working_dir)?;
139 config.fill_operational_defaults();
140 config.validate()?;
141 let resolution = ConfigResolution {
142 home: home.to_owned(),
143 source: discovered.source,
144 data_dir: config.store.data_dir.clone(),
145 authoring_workspace: config.authoring.workspace_dir.clone(),
146 legacy_notices,
147 #[cfg(not(unix))]
148 home_explicit,
149 #[cfg(not(unix))]
150 data_dir_explicit,
151 #[cfg(not(unix))]
152 data_root_required,
153 #[cfg(not(unix))]
154 authoring_workspace_explicit,
155 };
156 Ok(LoadedConfig { config, resolution })
157 }
158
159 #[cfg(test)]
168 fn load_for_test(
169 cli: &CliOverrides,
170 home: &Path,
171 home_source: HomeSource,
172 working_dir: &Path,
173 ) -> Result<LoadedConfig, ServerError> {
174 Self::load_in(cli, home, home_source, working_dir, false)
175 }
176
177 fn fill_operational_defaults(&mut self) {
183 self.runtime
184 .query_timeout_ms
185 .get_or_insert(DEFAULT_QUERY_TIMEOUT_MS);
186 self.websocket
187 .event_broadcast_capacity
188 .get_or_insert(DEFAULT_EVENT_BROADCAST_CAPACITY);
189 self.websocket
190 .cluster_broadcast_capacity
191 .get_or_insert(DEFAULT_CLUSTER_BROADCAST_CAPACITY);
192 self.fill_outbox_defaults();
193 self.fill_deploy_defaults();
194 }
195
196 fn fill_outbox_defaults(&mut self) {
205 if !self.outbox.enabled {
206 return;
207 }
208 self.outbox
209 .poll_interval_ms
210 .get_or_insert(DEFAULT_OUTBOX_POLL_INTERVAL_MS);
211 self.outbox
212 .batch_size
213 .get_or_insert(DEFAULT_OUTBOX_BATCH_SIZE);
214 self.outbox
215 .max_attempts
216 .get_or_insert(DEFAULT_OUTBOX_MAX_ATTEMPTS);
217 self.outbox
218 .backoff_base_ms
219 .get_or_insert(DEFAULT_OUTBOX_BACKOFF_BASE_MS);
220 self.outbox
221 .backoff_multiplier
222 .get_or_insert(DEFAULT_OUTBOX_BACKOFF_MULTIPLIER);
223 self.outbox
224 .backoff_max_ms
225 .get_or_insert(DEFAULT_OUTBOX_BACKOFF_MAX_MS);
226 }
227
228 fn fill_deploy_defaults(&mut self) {
236 if !self.deploy.enabled {
237 return;
238 }
239 self.deploy
240 .max_archive_bytes
241 .get_or_insert(DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES);
242 self.deploy
243 .max_inflated_bytes
244 .get_or_insert(DEFAULT_DEPLOY_MAX_INFLATED_BYTES);
245 }
246
247 fn load_discovered_workflow_packages(
248 &mut self,
249 cli: &CliOverrides,
250 directory: &Path,
251 ) -> Result<(), ServerError> {
252 let discovered_packages = discover_workflow_packages(directory)?;
253 merge_workflow_packages(
254 &mut self.workflow_packages,
255 discovered_packages,
256 &cli.workflow_packages,
257 );
258 Ok(())
259 }
260
261 pub fn from_slice(bytes: &[u8]) -> Result<Self, ServerError> {
267 let home = aion_home()?;
268 Self::from_slice_with_home(bytes, &home.path)
269 }
270
271 pub fn from_slice_with_home(bytes: &[u8], home: &Path) -> Result<Self, ServerError> {
286 let working_dir = std::env::current_dir().map_err(|source| ServerError::Config {
287 message: format!(
288 "failed to resolve the current directory for config defaults: {source}"
289 ),
290 })?;
291 let mut config = Self::parse_unresolved(bytes)?;
292 fill_home_defaults(&mut config, home, HomeSource::Explicit, &working_dir)?;
296 config.fill_operational_defaults();
297 config.validate()?;
298 Ok(config)
299 }
300
301 fn parse_unresolved(bytes: &[u8]) -> Result<Self, ServerError> {
302 toml::from_slice(bytes).map_err(|source| ServerError::Config {
303 message: format!("invalid server config: {source}"),
304 })
305 }
306
307 pub fn load_from_path(path: impl Into<PathBuf>) -> Result<Self, ServerError> {
313 file::load_required(&path.into())
314 }
315
316 #[must_use]
318 pub fn into_parts(self) -> (StoreConfig, RuntimeConfig) {
319 let runtime = RuntimeConfig {
320 listen: ListenConfig {
321 grpc: self.server.grpc_address,
322 http: self.server.listen_address,
323 },
324 tls: self.tls,
325 auth: self.auth,
326 ops_console: self.ops_console,
327 namespace: self.namespace,
328 worker: self.worker,
329 websocket: self.websocket,
330 workflow_packages: self.workflow_packages,
331 deploy: self.deploy,
332 authoring: self.authoring,
333 dev: self.dev,
334 outbox: self.outbox,
335 observability: self.observability,
336 scheduler_threads: self.runtime.scheduler_threads,
337 query_timeout: self.runtime.query_timeout_ms.map(Duration::from_millis),
338 default_namespace: self.namespaces.default,
339 auto_create: self.namespaces.auto_create,
340 max_in_flight_activities: self.namespaces.max_in_flight_activities,
341 drain_timeout: Duration::from_secs(self.drain.timeout_seconds),
342 metrics: self.metrics,
343 owned_shards: self.store.owned_shards.clone(),
344 cors_allowed_origins: self.server.cors_allowed_origins.clone(),
345 };
346 (self.store, runtime)
347 }
348
349 fn apply_cli_overrides(&mut self, cli: &CliOverrides) {
350 if let Some(address) = cli.listen_address {
351 self.server.listen_address = address;
352 }
353 if let Some(address) = cli.grpc_address {
354 self.server.grpc_address = address;
355 }
356 if let Some(url) = &cli.store_url {
357 self.store.url = Some(url.clone());
358 if matches!(
365 self.store.backend,
366 StoreBackend::Memory | StoreBackend::Haematite
367 ) {
368 self.store.backend = StoreBackend::LibSql;
369 }
370 }
371 if let Some(threads) = cli.scheduler_threads {
372 self.runtime.scheduler_threads = threads;
373 }
374 if let Some(timeout) = cli.drain_timeout_seconds {
375 self.drain.timeout_seconds = timeout;
376 }
377 if let Some(gleam_path) = &cli.gleam_path {
378 self.authoring.gleam_path = Some(gleam_path.clone());
379 }
380 if let Some(project_root) = &cli.authoring_project_root {
381 self.authoring.project_root = Some(project_root.clone());
382 }
383 }
384
385 fn validate(&self) -> Result<(), ServerError> {
386 if self.server.listen_address.port() == 0 {
387 return config_error("server.listen_address must use an explicit non-zero port");
388 }
389 if self.server.grpc_address.port() == 0 {
390 return config_error("server.grpc_address must use an explicit non-zero port");
391 }
392 validate_cors_origins(&self.server.cors_allowed_origins)?;
393 if self.runtime.scheduler_threads == 0 {
394 return config_error("runtime.scheduler_threads must be greater than zero");
395 }
396 if self.drain.timeout_seconds == 0 {
397 return config_error("drain.timeout_seconds must be greater than zero");
398 }
399 if self.auth.enabled && self.auth.jwks_url.as_deref().is_none_or(str::is_empty) {
400 return config_error("auth.jwks_url must not be empty when auth.enabled is true");
401 }
402 if self.auth.jwks_refresh_seconds == 0 {
403 return config_error("auth.jwks_refresh_seconds must be greater than zero");
404 }
405 if self.namespaces.default.is_empty() {
406 return config_error("namespaces.default must not be empty");
407 }
408 if matches!(self.store.backend, StoreBackend::LibSql)
409 && self.store.url.as_deref().is_none_or(str::is_empty)
410 {
411 return config_error("store.url must not be empty when store.backend is libsql");
412 }
413 if let Some(url) = &self.store.url {
414 if url.is_empty() {
415 return config_error("store.url must not be empty");
416 }
417 }
418 if matches!(self.store.backend, StoreBackend::Haematite) {
419 if self.store.data_dir.as_deref().is_none_or(str::is_empty) {
420 return config_error(
421 "store.data_dir must not be empty when store.backend is haematite",
422 );
423 }
424 if self.store.shard_count == 0 {
425 return config_error("store.shard_count must be greater than zero");
426 }
427 if let Some(cluster) = &self.store.cluster {
428 validate_cluster(cluster)?;
429 }
430 } else if self.store.cluster.is_some() {
431 return config_error("store.cluster is only valid when store.backend is haematite");
432 }
433 if let OpsConsoleAssetSource::FileSystem { asset_path } = &self.ops_console.source {
434 if asset_path.as_os_str().is_empty() {
435 return config_error("ops_console.source.FileSystem.asset_path must not be empty");
436 }
437 }
438 if let NamespaceMode::SingleTenant { namespace } = &self.namespace.mode {
439 if namespace.is_empty() {
440 return config_error("namespace.mode.SingleTenant.namespace must not be empty");
441 }
442 }
443 if self.worker.heartbeat_window.is_zero() {
444 return config_error("worker.heartbeat_window must be greater than zero");
445 }
446 self.websocket.validate()?;
447 self.observability.validate()?;
448 match self.runtime.query_timeout_ms {
449 None | Some(0) => return config_error(QUERY_TIMEOUT_REQUIRED),
450 Some(_) => {}
451 }
452 if self.deploy.enabled {
453 let max_archive_bytes = match self.deploy.max_archive_bytes {
454 None | Some(0) => return config_error(DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED),
455 Some(value) => value,
456 };
457 let max_inflated_bytes = match self.deploy.max_inflated_bytes {
458 None | Some(0) => return config_error(DEPLOY_MAX_INFLATED_BYTES_REQUIRED),
459 Some(value) => value,
460 };
461 ensure_fits_usize("deploy.max_archive_bytes", max_archive_bytes)?;
464 ensure_fits_usize("deploy.max_inflated_bytes", max_inflated_bytes)?;
465 if max_inflated_bytes < max_archive_bytes {
466 return config_error(format!(
467 "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"
468 ));
469 }
470 }
471 if let Some(gleam_path) = &self.authoring.gleam_path {
472 if gleam_path.as_os_str().is_empty() {
475 return config_error(AUTHORING_GLEAM_PATH_EMPTY);
476 }
477 match &self.authoring.project_root {
480 Some(root) if !root.as_os_str().is_empty() => {}
481 _ => return config_error(AUTHORING_PROJECT_ROOT_REQUIRED),
482 }
483 }
484 self.validate_outbox()?;
485 Ok(())
486 }
487
488 fn validate_outbox(&self) -> Result<(), ServerError> {
496 if !self.outbox.enabled {
497 return Ok(());
498 }
499 match self.outbox.poll_interval_ms {
500 None | Some(0) => return config_error(OUTBOX_POLL_INTERVAL_REQUIRED),
501 Some(_) => {}
502 }
503 match self.outbox.batch_size {
504 None | Some(0) => return config_error(OUTBOX_BATCH_SIZE_REQUIRED),
505 Some(_) => {}
506 }
507 match self.outbox.max_attempts {
508 None | Some(0) => return config_error(OUTBOX_MAX_ATTEMPTS_REQUIRED),
509 Some(_) => {}
510 }
511 let backoff_base_ms = match self.outbox.backoff_base_ms {
512 None | Some(0) => return config_error(OUTBOX_BACKOFF_BASE_REQUIRED),
513 Some(value) => value,
514 };
515 match self.outbox.backoff_multiplier {
516 None | Some(0) => return config_error(OUTBOX_BACKOFF_MULTIPLIER_REQUIRED),
517 Some(_) => {}
518 }
519 match self.outbox.backoff_max_ms {
520 Some(max) if max >= backoff_base_ms => {}
521 _ => return config_error(OUTBOX_BACKOFF_MAX_REQUIRED),
522 }
523 match (
524 self.outbox.reconcile_interval_ms,
525 self.outbox.reconcile_stale_after_ms,
526 ) {
527 (None, None) => {}
528 (None | Some(0), _) => return config_error(OUTBOX_RECONCILE_INTERVAL_REQUIRED),
529 (_, None | Some(0)) => return config_error(OUTBOX_RECONCILE_STALE_AFTER_REQUIRED),
530 (Some(_), Some(_)) => {}
531 }
532 Ok(())
533 }
534}
535
536fn validate_cluster(cluster: &ClusterConfig) -> Result<(), ServerError> {
540 if cluster.node_id.is_empty() {
541 return config_error("store.cluster.node_id must not be empty");
542 }
543 if cluster.members.iter().any(String::is_empty) {
544 return config_error("store.cluster.members entries must not be empty");
545 }
546 if cluster.peers.iter().any(|peer| peer.name.is_empty()) {
547 return config_error("store.cluster.peers entries must name a non-empty node");
548 }
549 if matches!(cluster.failover_poll_interval_ms, Some(0)) {
550 return config_error(
551 "store.cluster.failover_poll_interval_ms must be greater than zero when set",
552 );
553 }
554 if matches!(cluster.failover_confirmations, Some(0)) {
555 return config_error("store.cluster.failover_confirmations must be at least one when set");
556 }
557 Ok(())
558}
559
560fn validate_cors_origins(origins: &[String]) -> Result<(), ServerError> {
562 for origin in origins {
563 validate_cors_origin(origin)?;
564 }
565 Ok(())
566}
567
568fn validate_cors_origin(origin: &str) -> Result<(), ServerError> {
573 if origin.is_empty() {
574 return config_error(CORS_ALLOWED_ORIGIN_INVALID);
575 }
576 let scheme_split = origin.split_once("://");
580 let Some((scheme, authority)) = scheme_split else {
581 return config_error(CORS_ALLOWED_ORIGIN_INVALID);
582 };
583 if scheme.is_empty() || authority.is_empty() || authority.contains('/') {
584 return config_error(CORS_ALLOWED_ORIGIN_INVALID);
585 }
586 if origin.parse::<axum::http::HeaderValue>().is_err() {
588 return config_error(CORS_ALLOWED_ORIGIN_INVALID);
589 }
590 Ok(())
591}
592
593fn ensure_fits_usize(key: &str, value: u64) -> Result<(), ServerError> {
595 if usize::try_from(value).is_err() {
596 return config_error(format!(
597 "{key} ({value}) exceeds this platform's addressable memory; set it to at most {}",
598 usize::MAX
599 ));
600 }
601 Ok(())
602}
603
604fn discover_workflow_packages(directory: &Path) -> Result<Vec<PathBuf>, ServerError> {
605 let mut packages = Vec::new();
606 let entries = fs::read_dir(directory).map_err(|source| ServerError::Config {
607 message: format!(
608 "failed to scan workflow packages in `{}`: {source}",
609 directory.display()
610 ),
611 })?;
612
613 for entry in entries {
614 let entry = entry.map_err(|source| ServerError::Config {
615 message: format!(
616 "failed to read workflow package entry in `{}`: {source}",
617 directory.display()
618 ),
619 })?;
620 let path = entry.path();
621 let has_aion_extension = path
622 .extension()
623 .is_some_and(|extension| extension == "aion");
624 if path.is_file() && has_aion_extension {
625 packages.push(path);
626 }
627 }
628
629 packages.sort_by(|left, right| left.as_os_str().cmp(right.as_os_str()));
630 Ok(packages)
631}
632
633fn merge_workflow_packages(
634 workflow_packages: &mut Vec<PathBuf>,
635 discovered_packages: Vec<PathBuf>,
636 cli_packages: &[PathBuf],
637) {
638 let mut seen: HashSet<PathBuf> = workflow_packages
639 .iter()
640 .map(|package| deduplicated_package_key(package))
641 .collect();
642 for package in discovered_packages
643 .into_iter()
644 .chain(cli_packages.iter().cloned())
645 {
646 if seen.insert(deduplicated_package_key(&package)) {
647 workflow_packages.push(package);
648 }
649 }
650}
651
652fn deduplicated_package_key(path: &Path) -> PathBuf {
653 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
654}
655
656#[cfg(test)]
657#[path = "load_home_tests.rs"]
658mod home_tests;
659
660#[cfg(test)]
661mod tests {
662 use crate::config::{
663 AutoCreate, DEFAULT_AUTHORING_WORKSPACE_DIR, DEFAULT_MAX_IN_FLIGHT_ACTIVITIES, HomeSource,
664 OpsConsoleAssetSource,
665 };
666
667 use super::{
668 CliOverrides, DEFAULT_CLUSTER_BROADCAST_CAPACITY, DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES,
669 DEFAULT_DEPLOY_MAX_INFLATED_BYTES, DEFAULT_EVENT_BROADCAST_CAPACITY,
670 DEFAULT_OUTBOX_BACKOFF_BASE_MS, DEFAULT_OUTBOX_BACKOFF_MAX_MS,
671 DEFAULT_OUTBOX_BACKOFF_MULTIPLIER, DEFAULT_OUTBOX_BATCH_SIZE, DEFAULT_OUTBOX_MAX_ATTEMPTS,
672 DEFAULT_OUTBOX_POLL_INTERVAL_MS, DEFAULT_QUERY_TIMEOUT_MS, ServerConfig, StoreBackend,
673 discover_workflow_packages, merge_workflow_packages,
674 };
675
676 #[test]
677 fn valid_toml_is_parsed_into_typed_config() -> Result<(), Box<dyn std::error::Error>> {
678 let config = ServerConfig::from_slice(
679 br#"
680 [server]
681 listen_address = "127.0.0.1:18080"
682 grpc_address = "127.0.0.1:15051"
683
684 [store]
685 backend = "libsql"
686 url = "aion.db"
687
688 [runtime]
689 scheduler_threads = 2
690 query_timeout_ms = 10000
691
692 [drain]
693 timeout_seconds = 45
694
695 [auth]
696 enabled = true
697 jwks_url = "https://issuer.example.com/.well-known/jwks.json"
698 jwks_refresh_seconds = 60
699
700 [metrics]
701 enabled = true
702
703 [namespaces]
704 default = "production"
705
706 [websocket]
707 outbound_buffer_bound = 16
708 event_broadcast_capacity = 1024
709 cluster_broadcast_capacity = 1024
710 "#,
711 )?;
712
713 assert_eq!(config.store.backend, StoreBackend::LibSql);
714 assert_eq!(config.store.url.as_deref(), Some("aion.db"));
715 assert_eq!(config.runtime.scheduler_threads, 2);
716 assert_eq!(config.runtime.query_timeout_ms, Some(10_000));
717 assert_eq!(config.namespaces.default, "production");
718 assert_eq!(config.namespaces.auto_create, AutoCreate::Open);
720 assert_eq!(
723 config.namespaces.max_in_flight_activities,
724 DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
725 );
726 assert_eq!(config.websocket.outbound_buffer_bound, 16);
727 assert_eq!(config.websocket.event_broadcast_capacity, Some(1024));
728 Ok(())
729 }
730
731 #[test]
732 fn namespaces_auto_create_closed_parses() -> Result<(), Box<dyn std::error::Error>> {
733 let config = ServerConfig::from_slice(
734 br#"
735 [namespaces]
736 default = "production"
737 auto_create = "closed"
738 "#,
739 )?;
740 assert_eq!(config.namespaces.default, "production");
741 assert_eq!(config.namespaces.auto_create, AutoCreate::Closed);
742 Ok(())
743 }
744
745 #[test]
746 fn namespaces_auto_create_open_parses() -> Result<(), Box<dyn std::error::Error>> {
747 let config = ServerConfig::from_slice(
748 br#"
749 [namespaces]
750 auto_create = "open"
751 "#,
752 )?;
753 assert_eq!(config.namespaces.auto_create, AutoCreate::Open);
754 Ok(())
755 }
756
757 #[test]
758 fn namespaces_max_in_flight_activities_override_parses()
759 -> Result<(), Box<dyn std::error::Error>> {
760 let config = ServerConfig::from_slice(
761 br#"
762 [namespaces]
763 default = "production"
764 max_in_flight_activities = 32
765 "#,
766 )?;
767 assert_eq!(config.namespaces.max_in_flight_activities, 32);
768 let (_store, runtime) = config.into_parts();
770 assert_eq!(runtime.max_in_flight_activities, 32);
771 Ok(())
772 }
773
774 #[test]
775 fn namespaces_max_in_flight_activities_defaults_when_omitted()
776 -> Result<(), Box<dyn std::error::Error>> {
777 let config = ServerConfig::from_slice(
780 br#"
781 [namespaces]
782 default = "production"
783 "#,
784 )?;
785 assert_eq!(
786 config.namespaces.max_in_flight_activities,
787 DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
788 );
789 Ok(())
790 }
791
792 #[test]
793 fn namespaces_auto_create_rejects_unknown_variant() {
794 let result = ServerConfig::from_slice(
795 br#"
796 [namespaces]
797 auto_create = "sometimes"
798 "#,
799 );
800 assert!(
801 result.is_err(),
802 "an unknown auto_create variant must fail to parse"
803 );
804 }
805
806 #[test]
807 fn missing_event_broadcast_capacity_uses_default() -> Result<(), Box<dyn std::error::Error>> {
808 let config = ServerConfig::from_slice(
812 br"
813 [runtime]
814 query_timeout_ms = 10000
815
816 [websocket]
817 cluster_broadcast_capacity = 64
818 ",
819 )?;
820 assert_eq!(
821 config.websocket.event_broadcast_capacity,
822 Some(DEFAULT_EVENT_BROADCAST_CAPACITY),
823 "omitted event_broadcast_capacity must resolve to the default"
824 );
825 Ok(())
826 }
827
828 #[test]
829 fn zero_event_broadcast_capacity_fails_startup_validation() {
830 let result = ServerConfig::from_slice(
831 br"
832 [websocket]
833 event_broadcast_capacity = 0
834 ",
835 );
836
837 let message = result
838 .err()
839 .map_or_else(String::new, |error| error.to_string());
840 assert!(
841 message.contains("websocket.event_broadcast_capacity"),
842 "validation message must name the zero-valued key: {message}"
843 );
844 }
845
846 #[test]
847 fn missing_cluster_broadcast_capacity_uses_default() -> Result<(), Box<dyn std::error::Error>> {
848 let config = ServerConfig::from_slice(
852 br"
853 [runtime]
854 scheduler_threads = 1
855 query_timeout_ms = 10000
856
857 [websocket]
858 event_broadcast_capacity = 64
859 ",
860 )?;
861 assert_eq!(
862 config.websocket.cluster_broadcast_capacity,
863 Some(DEFAULT_CLUSTER_BROADCAST_CAPACITY),
864 "omitted cluster_broadcast_capacity must resolve to the default"
865 );
866 Ok(())
867 }
868
869 #[test]
870 fn zero_cluster_broadcast_capacity_fails_startup_validation() {
871 let result = ServerConfig::from_slice(
872 br"
873 [runtime]
874 query_timeout_ms = 10000
875
876 [websocket]
877 event_broadcast_capacity = 64
878 cluster_broadcast_capacity = 0
879 ",
880 );
881
882 let message = result
883 .err()
884 .map_or_else(String::new, |error| error.to_string());
885 assert!(
886 message.contains("websocket.cluster_broadcast_capacity"),
887 "validation message must name the zero-valued cluster key: {message}"
888 );
889 }
890
891 #[test]
895 fn missing_observability_section_uses_defaults() -> Result<(), Box<dyn std::error::Error>> {
896 let config = ServerConfig::from_slice(
897 br"
898 [runtime]
899 query_timeout_ms = 10000
900
901 [websocket]
902 event_broadcast_capacity = 64
903 cluster_broadcast_capacity = 64
904 ",
905 )?;
906 assert_eq!(
907 config.observability.max_event_bytes,
908 crate::config::DEFAULT_OBSERVABILITY_MAX_EVENT_BYTES
909 );
910 assert_eq!(
911 config.observability.max_stream_events,
912 crate::config::DEFAULT_OBSERVABILITY_MAX_STREAM_EVENTS
913 );
914 let (_store, runtime) = config.into_parts();
916 assert_eq!(
917 runtime.observability.max_event_bytes,
918 crate::config::DEFAULT_OBSERVABILITY_MAX_EVENT_BYTES
919 );
920 Ok(())
921 }
922
923 #[test]
926 fn observability_section_parses_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
927 let config = ServerConfig::from_slice(
928 br"
929 [runtime]
930 query_timeout_ms = 10000
931
932 [websocket]
933 event_broadcast_capacity = 64
934 cluster_broadcast_capacity = 64
935
936 [observability]
937 max_event_bytes = 512
938 max_stream_events = 3
939 ",
940 )?;
941 assert_eq!(config.observability.max_event_bytes, 512);
942 assert_eq!(config.observability.max_stream_events, 3);
943 let (_store, runtime) = config.into_parts();
944 assert_eq!(runtime.observability.max_event_bytes, 512);
945 assert_eq!(runtime.observability.max_stream_events, 3);
946 Ok(())
947 }
948
949 #[test]
950 fn zero_observability_max_event_bytes_fails_startup_validation() {
951 let result = ServerConfig::from_slice(
952 br"
953 [runtime]
954 query_timeout_ms = 10000
955
956 [websocket]
957 event_broadcast_capacity = 64
958 cluster_broadcast_capacity = 64
959
960 [observability]
961 max_event_bytes = 0
962 ",
963 );
964 let message = result
965 .err()
966 .map_or_else(String::new, |error| error.to_string());
967 assert!(
968 message.contains("observability.max_event_bytes"),
969 "validation message must name the zero-valued key: {message}"
970 );
971 }
972
973 #[test]
974 fn zero_observability_max_stream_events_fails_startup_validation() {
975 let result = ServerConfig::from_slice(
976 br"
977 [runtime]
978 query_timeout_ms = 10000
979
980 [websocket]
981 event_broadcast_capacity = 64
982 cluster_broadcast_capacity = 64
983
984 [observability]
985 max_stream_events = 0
986 ",
987 );
988 let message = result
989 .err()
990 .map_or_else(String::new, |error| error.to_string());
991 assert!(
992 message.contains("observability.max_stream_events"),
993 "validation message must name the zero-valued key: {message}"
994 );
995 }
996
997 #[test]
998 fn missing_query_timeout_uses_default() -> Result<(), Box<dyn std::error::Error>> {
999 let config = ServerConfig::from_slice(
1003 br"
1004 [runtime]
1005 scheduler_threads = 1
1006
1007 [websocket]
1008 event_broadcast_capacity = 64
1009 cluster_broadcast_capacity = 64
1010 ",
1011 )?;
1012 assert_eq!(
1013 config.runtime.query_timeout_ms,
1014 Some(DEFAULT_QUERY_TIMEOUT_MS),
1015 "omitted query_timeout_ms must resolve to the default"
1016 );
1017 Ok(())
1018 }
1019
1020 #[test]
1021 fn empty_config_boots_on_operational_defaults() -> Result<(), Box<dyn std::error::Error>> {
1022 let config = ServerConfig::from_slice(b"")?;
1027 assert_eq!(config.store.backend, StoreBackend::Haematite);
1028 assert_eq!(
1029 config.runtime.query_timeout_ms,
1030 Some(DEFAULT_QUERY_TIMEOUT_MS)
1031 );
1032 assert_eq!(
1033 config.websocket.event_broadcast_capacity,
1034 Some(DEFAULT_EVENT_BROADCAST_CAPACITY)
1035 );
1036 assert_eq!(
1037 config.websocket.cluster_broadcast_capacity,
1038 Some(DEFAULT_CLUSTER_BROADCAST_CAPACITY)
1039 );
1040 Ok(())
1041 }
1042
1043 #[test]
1044 fn zero_query_timeout_fails_startup_validation() {
1045 let result = ServerConfig::from_slice(
1046 br"
1047 [runtime]
1048 query_timeout_ms = 0
1049
1050 [websocket]
1051 event_broadcast_capacity = 64
1052 cluster_broadcast_capacity = 64
1053 ",
1054 );
1055
1056 let message = result
1057 .err()
1058 .map_or_else(String::new, |error| error.to_string());
1059 assert!(
1060 message.contains("runtime.query_timeout_ms"),
1061 "validation message must name the zero-valued key: {message}"
1062 );
1063 }
1064
1065 #[test]
1070 fn deploy_enabled_defaults_max_archive_bytes() -> Result<(), Box<dyn std::error::Error>> {
1071 let config = ServerConfig::from_slice(
1072 br"
1073 [runtime]
1074 query_timeout_ms = 10000
1075
1076 [websocket]
1077 event_broadcast_capacity = 64
1078 cluster_broadcast_capacity = 64
1079
1080 [deploy]
1081 enabled = true
1082 ",
1083 )?;
1084
1085 assert_eq!(
1086 config.deploy.max_archive_bytes,
1087 Some(DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES),
1088 "omitted max_archive_bytes must resolve to the conservative default"
1089 );
1090 assert_eq!(
1091 config.deploy.max_inflated_bytes,
1092 Some(DEFAULT_DEPLOY_MAX_INFLATED_BYTES),
1093 "omitted max_inflated_bytes must resolve to the conservative default"
1094 );
1095 Ok(())
1096 }
1097
1098 #[test]
1099 fn deploy_zero_max_archive_bytes_fails_startup_validation() {
1100 let result = ServerConfig::from_slice(
1101 br"
1102 [runtime]
1103 query_timeout_ms = 10000
1104
1105 [websocket]
1106 event_broadcast_capacity = 64
1107 cluster_broadcast_capacity = 64
1108
1109 [deploy]
1110 enabled = true
1111 max_archive_bytes = 0
1112 ",
1113 );
1114
1115 let message = result
1116 .err()
1117 .map_or_else(String::new, |error| error.to_string());
1118 assert!(
1119 message.contains("deploy.max_archive_bytes"),
1120 "validation message must name the zero-valued key: {message}"
1121 );
1122 }
1123
1124 #[test]
1129 fn deploy_enabled_defaults_max_inflated_bytes() -> Result<(), Box<dyn std::error::Error>> {
1130 let config = ServerConfig::from_slice(
1131 br"
1132 [runtime]
1133 query_timeout_ms = 10000
1134
1135 [websocket]
1136 event_broadcast_capacity = 64
1137 cluster_broadcast_capacity = 64
1138
1139 [deploy]
1140 enabled = true
1141 max_archive_bytes = 16777216
1142 ",
1143 )?;
1144
1145 assert_eq!(
1146 config.deploy.max_archive_bytes,
1147 Some(16_777_216),
1148 "explicit max_archive_bytes must be left untouched"
1149 );
1150 assert_eq!(
1151 config.deploy.max_inflated_bytes,
1152 Some(DEFAULT_DEPLOY_MAX_INFLATED_BYTES),
1153 "omitted max_inflated_bytes must resolve to the conservative default"
1154 );
1155 Ok(())
1156 }
1157
1158 #[test]
1159 fn deploy_zero_max_inflated_bytes_fails_startup_validation() {
1160 let result = ServerConfig::from_slice(
1161 br"
1162 [runtime]
1163 query_timeout_ms = 10000
1164
1165 [websocket]
1166 event_broadcast_capacity = 64
1167 cluster_broadcast_capacity = 64
1168
1169 [deploy]
1170 enabled = true
1171 max_archive_bytes = 16777216
1172 max_inflated_bytes = 0
1173 ",
1174 );
1175
1176 let message = result
1177 .err()
1178 .map_or_else(String::new, |error| error.to_string());
1179 assert!(
1180 message.contains("deploy.max_inflated_bytes"),
1181 "validation message must name the zero-valued key: {message}"
1182 );
1183 }
1184
1185 #[test]
1188 fn deploy_max_inflated_below_max_archive_fails_startup_validation() {
1189 let result = ServerConfig::from_slice(
1190 br"
1191 [runtime]
1192 query_timeout_ms = 10000
1193
1194 [websocket]
1195 event_broadcast_capacity = 64
1196 cluster_broadcast_capacity = 64
1197
1198 [deploy]
1199 enabled = true
1200 max_archive_bytes = 16777216
1201 max_inflated_bytes = 16777215
1202 ",
1203 );
1204
1205 let message = result
1206 .err()
1207 .map_or_else(String::new, |error| error.to_string());
1208 assert!(
1209 message.contains("deploy.max_inflated_bytes")
1210 && message.contains("deploy.max_archive_bytes"),
1211 "validation message must name both ceilings: {message}"
1212 );
1213 }
1214
1215 #[test]
1218 fn deploy_disabled_requires_no_archive_ceiling() -> 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 )?;
1229
1230 assert!(!config.deploy.enabled);
1231 assert_eq!(config.deploy.max_archive_bytes, None);
1232 assert_eq!(config.deploy.max_inflated_bytes, None);
1233 Ok(())
1234 }
1235
1236 #[test]
1237 fn deploy_section_parses_enabled_with_ceilings() -> Result<(), Box<dyn std::error::Error>> {
1238 let config = ServerConfig::from_slice(
1239 br"
1240 [runtime]
1241 query_timeout_ms = 10000
1242
1243 [websocket]
1244 event_broadcast_capacity = 64
1245 cluster_broadcast_capacity = 64
1246
1247 [deploy]
1248 enabled = true
1249 max_archive_bytes = 16777216
1250 max_inflated_bytes = 67108864
1251 ",
1252 )?;
1253
1254 assert!(config.deploy.enabled);
1255 assert_eq!(config.deploy.max_archive_bytes, Some(16_777_216));
1256 assert_eq!(config.deploy.max_inflated_bytes, Some(67_108_864));
1257 Ok(())
1258 }
1259
1260 #[test]
1264 fn cors_allowed_origins_default_empty() -> Result<(), Box<dyn std::error::Error>> {
1265 let config = ServerConfig::from_slice(
1266 br"
1267 [runtime]
1268 query_timeout_ms = 10000
1269
1270 [websocket]
1271 event_broadcast_capacity = 64
1272 cluster_broadcast_capacity = 64
1273 ",
1274 )?;
1275
1276 assert!(config.server.cors_allowed_origins.is_empty());
1277 let (_, runtime) = config.into_parts();
1278 assert!(runtime.cors_allowed_origins.is_empty());
1279 Ok(())
1280 }
1281
1282 #[test]
1285 fn cors_allowed_origins_parse_and_round_trip() -> Result<(), Box<dyn std::error::Error>> {
1286 let config = ServerConfig::from_slice(
1287 br#"
1288 [server]
1289 cors_allowed_origins = ["http://localhost:5173", "http://127.0.0.1:5173"]
1290
1291 [runtime]
1292 query_timeout_ms = 10000
1293
1294 [websocket]
1295 event_broadcast_capacity = 64
1296 cluster_broadcast_capacity = 64
1297 "#,
1298 )?;
1299
1300 assert_eq!(
1301 config.server.cors_allowed_origins,
1302 vec![
1303 "http://localhost:5173".to_owned(),
1304 "http://127.0.0.1:5173".to_owned()
1305 ]
1306 );
1307 let (_, runtime) = config.into_parts();
1308 assert_eq!(
1309 runtime.cors_allowed_origins,
1310 vec![
1311 "http://localhost:5173".to_owned(),
1312 "http://127.0.0.1:5173".to_owned()
1313 ]
1314 );
1315 Ok(())
1316 }
1317
1318 #[test]
1322 fn cors_allowed_origins_reject_malformed() {
1323 for bad in ["", "localhost:5173", "http://localhost:5173/"] {
1324 let toml = format!(
1325 "[server]\ncors_allowed_origins = [\"{bad}\"]\n\n[runtime]\nquery_timeout_ms = 10000\n\n[websocket]\nevent_broadcast_capacity = 64\n"
1326 );
1327 let result = ServerConfig::from_slice(toml.as_bytes());
1328 let message = result
1329 .err()
1330 .map_or_else(String::new, |error| error.to_string());
1331 assert!(
1332 message.contains("cors_allowed_origins"),
1333 "malformed origin `{bad}` must be rejected naming the key: {message}"
1334 );
1335 }
1336 }
1337
1338 #[test]
1340 fn dev_absent_leaves_surface_dark() -> 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 )?;
1351
1352 assert!(!config.dev.enabled);
1353 Ok(())
1354 }
1355
1356 #[test]
1359 fn dev_section_parses_enabled() -> Result<(), Box<dyn std::error::Error>> {
1360 let config = ServerConfig::from_slice(
1361 br"
1362 [runtime]
1363 query_timeout_ms = 10000
1364
1365 [websocket]
1366 event_broadcast_capacity = 64
1367 cluster_broadcast_capacity = 64
1368
1369 [dev]
1370 enabled = true
1371 ",
1372 )?;
1373
1374 assert!(config.dev.enabled);
1375 Ok(())
1376 }
1377
1378 #[test]
1381 fn authoring_absent_defaults_awl_workspace_but_keeps_gleam_dark()
1382 -> Result<(), Box<dyn std::error::Error>> {
1383 let home = crate::test_support::private_tempdir()?;
1384 let config = ServerConfig::from_slice_with_home(b"", home.path())?;
1385
1386 assert_eq!(config.authoring.gleam_path, None);
1387 assert_eq!(config.authoring.project_root, None);
1388 assert_eq!(
1389 config.authoring.workspace_dir.as_deref(),
1390 Some(home.path().join(DEFAULT_AUTHORING_WORKSPACE_DIR).as_path())
1391 );
1392 Ok(())
1393 }
1394
1395 #[test]
1397 fn authoring_explicit_workspace_is_honored() -> Result<(), Box<dyn std::error::Error>> {
1398 let config = ServerConfig::from_slice(
1399 br#"
1400 [authoring]
1401 workspace_dir = "/srv/aion/studio"
1402 "#,
1403 )?;
1404
1405 assert_eq!(
1406 config.authoring.workspace_dir.as_deref(),
1407 Some(std::path::Path::new("/srv/aion/studio"))
1408 );
1409 Ok(())
1410 }
1411
1412 #[test]
1415 fn authoring_section_parses_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
1416 let config = ServerConfig::from_slice(
1417 br#"
1418 [runtime]
1419 query_timeout_ms = 10000
1420
1421 [websocket]
1422 event_broadcast_capacity = 64
1423 cluster_broadcast_capacity = 64
1424
1425 [authoring]
1426 gleam_path = "/usr/local/bin/gleam"
1427 project_root = "/srv/aion/authoring"
1428 "#,
1429 )?;
1430
1431 assert_eq!(
1432 config.authoring.gleam_path.as_deref(),
1433 Some(std::path::Path::new("/usr/local/bin/gleam"))
1434 );
1435 let (_, runtime) = config.into_parts();
1436 assert_eq!(
1437 runtime.authoring.gleam_path.as_deref(),
1438 Some(std::path::Path::new("/usr/local/bin/gleam"))
1439 );
1440 assert_eq!(
1441 runtime.authoring.project_root.as_deref(),
1442 Some(std::path::Path::new("/srv/aion/authoring"))
1443 );
1444 Ok(())
1445 }
1446
1447 #[test]
1451 fn authoring_gleam_path_without_project_root_fails_naming_key_and_env() {
1452 let result = ServerConfig::from_slice(
1453 br#"
1454 [runtime]
1455 query_timeout_ms = 10000
1456
1457 [websocket]
1458 event_broadcast_capacity = 64
1459 cluster_broadcast_capacity = 64
1460
1461 [authoring]
1462 gleam_path = "/usr/local/bin/gleam"
1463 "#,
1464 );
1465
1466 let message = result
1467 .err()
1468 .map_or_else(String::new, |error| error.to_string());
1469 assert!(
1470 message.contains("authoring.project_root"),
1471 "validation message must name the missing key: {message}"
1472 );
1473 assert!(
1474 message.contains("AION_AUTHORING_PROJECT_ROOT"),
1475 "validation message must name the environment override: {message}"
1476 );
1477 }
1478
1479 #[test]
1482 fn authoring_empty_gleam_path_fails_naming_key_and_env() {
1483 let result = ServerConfig::from_slice(
1484 br#"
1485 [runtime]
1486 query_timeout_ms = 10000
1487
1488 [websocket]
1489 event_broadcast_capacity = 64
1490 cluster_broadcast_capacity = 64
1491
1492 [authoring]
1493 gleam_path = ""
1494 "#,
1495 );
1496
1497 let message = result
1498 .err()
1499 .map_or_else(String::new, |error| error.to_string());
1500 assert!(
1501 message.contains("authoring.gleam_path"),
1502 "validation message must name the empty key: {message}"
1503 );
1504 assert!(
1505 message.contains("AION_AUTHORING_GLEAM_PATH"),
1506 "validation message must name the environment override: {message}"
1507 );
1508 }
1509
1510 #[test]
1512 fn cli_overrides_set_authoring_paths() -> Result<(), Box<dyn std::error::Error>> {
1513 let mut config = ServerConfig::from_slice(
1514 br"
1515 [runtime]
1516 query_timeout_ms = 10000
1517
1518 [websocket]
1519 event_broadcast_capacity = 64
1520 cluster_broadcast_capacity = 64
1521 ",
1522 )?;
1523 let cli = CliOverrides {
1524 gleam_path: Some(std::path::PathBuf::from("/opt/gleam")),
1525 authoring_project_root: Some(std::path::PathBuf::from("/opt/project")),
1526 ..CliOverrides::default()
1527 };
1528
1529 config.apply_cli_overrides(&cli);
1530 config.validate()?;
1531
1532 assert_eq!(
1533 config.authoring.gleam_path.as_deref(),
1534 Some(std::path::Path::new("/opt/gleam"))
1535 );
1536 assert_eq!(
1537 config.authoring.project_root.as_deref(),
1538 Some(std::path::Path::new("/opt/project"))
1539 );
1540 Ok(())
1541 }
1542
1543 #[test]
1548 fn queue_service_settings_are_read_from_the_worker_section()
1549 -> Result<(), Box<dyn std::error::Error>> {
1550 use crate::worker::QueueServicePolicy;
1551 use std::time::Duration;
1552
1553 let bare = ServerConfig::from_slice(
1554 br"
1555 [websocket]
1556 event_broadcast_capacity = 64
1557 cluster_broadcast_capacity = 64
1558 ",
1559 )?;
1560 assert_eq!(
1561 bare.worker.queue_service.default_policy,
1562 QueueServicePolicy::Strict,
1563 "strict is the default with nothing written"
1564 );
1565 assert_eq!(
1566 bare.worker.queue_service.service_availability_deadline,
1567 None
1568 );
1569 assert_eq!(bare.worker.queue_service.schedule_to_start_timeout, None);
1570
1571 let written = ServerConfig::from_slice(
1572 br#"
1573 [websocket]
1574 event_broadcast_capacity = 64
1575 cluster_broadcast_capacity = 64
1576
1577 [worker.queue_service]
1578 service_availability_deadline = 45000
1579 schedule_to_start_timeout = 5000
1580
1581 [[worker.queue_service.overrides]]
1582 task_queue = "general"
1583 policy = "durable_pending"
1584 "#,
1585 )?;
1586 assert_eq!(
1587 written.worker.queue_service.service_availability_deadline,
1588 Some(Duration::from_secs(45))
1589 );
1590 assert_eq!(
1591 written.worker.queue_service.schedule_to_start_timeout,
1592 Some(Duration::from_secs(5))
1593 );
1594 assert_eq!(
1595 written
1596 .worker
1597 .queue_service
1598 .policy_for("default", "general"),
1599 QueueServicePolicy::DurablePending,
1600 "the written opt-in must reach the dispatch seam"
1601 );
1602 assert_eq!(
1603 written
1604 .worker
1605 .queue_service
1606 .policy_for("default", "billing"),
1607 QueueServicePolicy::Strict,
1608 "an override must not leak onto other queues"
1609 );
1610
1611 let (_store, runtime) = written.into_parts();
1613 assert_eq!(
1614 runtime
1615 .worker
1616 .queue_service
1617 .policy_for("default", "general"),
1618 QueueServicePolicy::DurablePending
1619 );
1620 Ok(())
1621 }
1622
1623 #[test]
1624 fn legacy_dashboard_section_alias_still_parses() -> Result<(), Box<dyn std::error::Error>> {
1625 let config = ServerConfig::from_slice(
1626 br#"
1627 [runtime]
1628 query_timeout_ms = 10000
1629
1630 [websocket]
1631 event_broadcast_capacity = 64
1632 cluster_broadcast_capacity = 64
1633
1634 [dashboard]
1635 source = { FileSystem = { asset_path = "/srv/aion/ui" } }
1636 "#,
1637 )?;
1638 match &config.ops_console.source {
1639 OpsConsoleAssetSource::FileSystem { asset_path } => {
1640 assert_eq!(asset_path.as_os_str(), "/srv/aion/ui");
1641 }
1642 OpsConsoleAssetSource::Embedded => {
1643 return Err("legacy [dashboard] section must map to ops_console".into());
1644 }
1645 }
1646 Ok(())
1647 }
1648
1649 #[test]
1651 fn ops_console_section_parses() -> Result<(), Box<dyn std::error::Error>> {
1652 let config = ServerConfig::from_slice(
1653 br#"
1654 [runtime]
1655 query_timeout_ms = 10000
1656
1657 [websocket]
1658 event_broadcast_capacity = 64
1659 cluster_broadcast_capacity = 64
1660
1661 [ops_console]
1662 source = { FileSystem = { asset_path = "/srv/aion/ui" } }
1663 "#,
1664 )?;
1665 assert!(matches!(
1666 config.ops_console.source,
1667 OpsConsoleAssetSource::FileSystem { .. }
1668 ));
1669 Ok(())
1670 }
1671
1672 #[test]
1673 fn invalid_values_name_problematic_field() {
1674 let result = ServerConfig::from_slice(
1675 br"
1676 [runtime]
1677 scheduler_threads = 0
1678 ",
1679 );
1680
1681 let message = result
1682 .err()
1683 .map_or_else(String::new, |error| error.to_string());
1684 assert!(message.contains("runtime.scheduler_threads"));
1685 }
1686
1687 #[test]
1688 fn cli_overrides_win_over_loaded_values() -> Result<(), Box<dyn std::error::Error>> {
1689 let mut config = ServerConfig::from_slice(
1690 br#"
1691 [store]
1692 backend = "libsql"
1693 url = "file.db"
1694
1695 [runtime]
1696 query_timeout_ms = 10000
1697
1698 [websocket]
1699 event_broadcast_capacity = 64
1700 cluster_broadcast_capacity = 64
1701 "#,
1702 )?;
1703 let cli = CliOverrides {
1704 store_url: Some("cli.db".to_owned()),
1705 scheduler_threads: Some(3),
1706 ..CliOverrides::default()
1707 };
1708
1709 config.apply_cli_overrides(&cli);
1710 config.validate()?;
1711
1712 assert_eq!(config.store.url.as_deref(), Some("cli.db"));
1713 assert_eq!(config.runtime.scheduler_threads, 3);
1714 Ok(())
1715 }
1716
1717 #[test]
1718 fn default_config_defaults() -> Result<(), Box<dyn std::error::Error>> {
1719 let mut config = ServerConfig::default();
1720
1721 assert_eq!(config.store.backend, StoreBackend::Haematite);
1724 assert_eq!(config.store.data_dir, None);
1725 assert_eq!(config.store.shard_count, 64);
1729 assert_eq!(config.store.url, None);
1730 assert_eq!(config.server.grpc_address.to_string(), "127.0.0.1:50051");
1731 assert_eq!(config.server.listen_address.to_string(), "127.0.0.1:8080");
1732 assert_eq!(config.namespaces.default, "default");
1733 assert_eq!(config.namespaces.auto_create, AutoCreate::Open);
1737 assert_eq!(
1740 config.namespaces.max_in_flight_activities,
1741 DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
1742 );
1743 assert_eq!(config.namespaces.max_in_flight_activities, 1024);
1744 assert!(!config.auth.enabled);
1745 assert!(config.metrics.enabled);
1746 assert_eq!(config.websocket.event_broadcast_capacity, None);
1750 assert_eq!(config.websocket.cluster_broadcast_capacity, None);
1751 assert_eq!(config.runtime.query_timeout_ms, None);
1752 config.websocket.event_broadcast_capacity = Some(64);
1753 config.websocket.cluster_broadcast_capacity = Some(64);
1754 config.runtime.query_timeout_ms = Some(10_000);
1755 let home = crate::test_support::private_tempdir()?;
1756 let working_dir = crate::test_support::private_tempdir()?;
1757 super::fill_home_defaults(
1758 &mut config,
1759 home.path(),
1760 HomeSource::Derived,
1761 working_dir.path(),
1762 )?;
1763 assert_eq!(
1764 config.store.data_dir.as_deref(),
1765 home.path().join("data").to_str()
1766 );
1767 config.validate()?;
1768 Ok(())
1769 }
1770
1771 #[test]
1772 fn outbox_is_disabled_by_default_and_needs_no_knobs() -> Result<(), Box<dyn std::error::Error>>
1773 {
1774 let mut config = ServerConfig::default();
1775 config.store.data_dir = Some("test-data".to_owned());
1776 config.websocket.event_broadcast_capacity = Some(64);
1777 config.websocket.cluster_broadcast_capacity = Some(64);
1778 config.runtime.query_timeout_ms = Some(10_000);
1779
1780 assert!(!config.outbox.enabled);
1784 assert_eq!(config.outbox.poll_interval_ms, None);
1785 assert_eq!(config.outbox.batch_size, None);
1786 assert_eq!(config.outbox.max_attempts, None);
1787 assert_eq!(config.outbox.backoff_base_ms, None);
1788 assert_eq!(config.outbox.backoff_multiplier, None);
1789 assert_eq!(config.outbox.backoff_max_ms, None);
1790 assert_eq!(config.outbox.reconcile_interval_ms, None);
1791 assert_eq!(config.outbox.reconcile_stale_after_ms, None);
1792 config.validate()?;
1793 Ok(())
1794 }
1795
1796 fn outbox_enabled_base() -> ServerConfig {
1797 let mut config = ServerConfig::default();
1798 config.store.data_dir = Some("test-data".to_owned());
1799 config.websocket.event_broadcast_capacity = Some(64);
1800 config.websocket.cluster_broadcast_capacity = Some(64);
1801 config.runtime.query_timeout_ms = Some(10_000);
1802 config.outbox.enabled = true;
1803 config.outbox.poll_interval_ms = Some(250);
1804 config.outbox.batch_size = Some(64);
1805 config.outbox.max_attempts = Some(5);
1806 config.outbox.backoff_base_ms = Some(100);
1807 config.outbox.backoff_multiplier = Some(2);
1808 config.outbox.backoff_max_ms = Some(30_000);
1809 config.outbox.reconcile_interval_ms = Some(1_000);
1810 config.outbox.reconcile_stale_after_ms = Some(60_000);
1811 config
1812 }
1813
1814 #[test]
1815 fn outbox_enabled_with_all_knobs_validates() -> Result<(), Box<dyn std::error::Error>> {
1816 outbox_enabled_base().validate()?;
1817 Ok(())
1818 }
1819
1820 #[test]
1821 fn outbox_enabled_defaults_poll_interval() -> Result<(), Box<dyn std::error::Error>> {
1822 let config = ServerConfig::from_slice(
1826 br"
1827 [runtime]
1828 query_timeout_ms = 10000
1829
1830 [websocket]
1831 event_broadcast_capacity = 64
1832 cluster_broadcast_capacity = 64
1833
1834 [outbox]
1835 enabled = true
1836 ",
1837 )?;
1838 assert_eq!(
1839 config.outbox.poll_interval_ms,
1840 Some(DEFAULT_OUTBOX_POLL_INTERVAL_MS),
1841 "omitted poll_interval_ms must resolve to the default"
1842 );
1843 Ok(())
1844 }
1845
1846 #[test]
1847 fn outbox_enabled_defaults_max_attempts() -> Result<(), Box<dyn std::error::Error>> {
1848 let config = ServerConfig::from_slice(
1851 br"
1852 [runtime]
1853 query_timeout_ms = 10000
1854
1855 [websocket]
1856 event_broadcast_capacity = 64
1857 cluster_broadcast_capacity = 64
1858
1859 [outbox]
1860 enabled = true
1861 poll_interval_ms = 250
1862 ",
1863 )?;
1864 assert_eq!(
1865 config.outbox.poll_interval_ms,
1866 Some(250),
1867 "explicit poll_interval_ms must be left untouched"
1868 );
1869 assert_eq!(
1870 config.outbox.max_attempts,
1871 Some(DEFAULT_OUTBOX_MAX_ATTEMPTS),
1872 "omitted max_attempts must resolve to the default"
1873 );
1874 Ok(())
1875 }
1876
1877 #[test]
1878 fn outbox_enabled_with_only_enabled_flag_uses_all_defaults()
1879 -> Result<(), Box<dyn std::error::Error>> {
1880 let config = ServerConfig::from_slice(
1884 br"
1885 [runtime]
1886 query_timeout_ms = 10000
1887
1888 [websocket]
1889 event_broadcast_capacity = 64
1890 cluster_broadcast_capacity = 64
1891
1892 [outbox]
1893 enabled = true
1894 ",
1895 )?;
1896 assert!(config.outbox.enabled);
1897 assert_eq!(
1898 config.outbox.poll_interval_ms,
1899 Some(DEFAULT_OUTBOX_POLL_INTERVAL_MS)
1900 );
1901 assert_eq!(config.outbox.batch_size, Some(DEFAULT_OUTBOX_BATCH_SIZE));
1902 assert_eq!(
1903 config.outbox.max_attempts,
1904 Some(DEFAULT_OUTBOX_MAX_ATTEMPTS)
1905 );
1906 assert_eq!(
1907 config.outbox.backoff_base_ms,
1908 Some(DEFAULT_OUTBOX_BACKOFF_BASE_MS)
1909 );
1910 assert_eq!(
1911 config.outbox.backoff_multiplier,
1912 Some(DEFAULT_OUTBOX_BACKOFF_MULTIPLIER)
1913 );
1914 assert_eq!(
1915 config.outbox.backoff_max_ms,
1916 Some(DEFAULT_OUTBOX_BACKOFF_MAX_MS)
1917 );
1918 assert_eq!(config.outbox.reconcile_interval_ms, None);
1921 assert_eq!(config.outbox.reconcile_stale_after_ms, None);
1922 Ok(())
1923 }
1924
1925 #[test]
1926 fn outbox_enabled_zero_poll_interval_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
1927 let mut config = outbox_enabled_base();
1930 config.outbox.poll_interval_ms = Some(0);
1931 let error = config
1932 .validate()
1933 .err()
1934 .ok_or("enabled outbox with zero poll interval must fail")?;
1935 assert!(
1936 error.to_string().contains("outbox.poll_interval_ms"),
1937 "error must name the zero-valued key: {error}"
1938 );
1939 Ok(())
1940 }
1941
1942 #[test]
1943 fn outbox_enabled_zero_max_attempts_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
1944 let mut config = outbox_enabled_base();
1945 config.outbox.max_attempts = Some(0);
1946 let error = config
1947 .validate()
1948 .err()
1949 .ok_or("enabled outbox with zero max attempts must fail")?;
1950 assert!(
1951 error.to_string().contains("outbox.max_attempts"),
1952 "error must name the zero-valued key: {error}"
1953 );
1954 Ok(())
1955 }
1956
1957 #[test]
1958 fn outbox_backoff_max_below_base_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
1959 let mut config = outbox_enabled_base();
1960 config.outbox.backoff_base_ms = Some(1_000);
1961 config.outbox.backoff_max_ms = Some(500);
1962 let error = config
1963 .validate()
1964 .err()
1965 .ok_or("backoff_max below backoff_base must fail")?;
1966 assert!(
1967 error.to_string().contains("outbox.backoff_max_ms"),
1968 "error must name the offending key: {error}"
1969 );
1970 Ok(())
1971 }
1972
1973 #[test]
1974 fn outbox_enabled_can_leave_reconciliation_dark() -> Result<(), Box<dyn std::error::Error>> {
1975 let mut config = outbox_enabled_base();
1976 config.outbox.reconcile_interval_ms = None;
1977 config.outbox.reconcile_stale_after_ms = None;
1978 config.validate()?;
1979 Ok(())
1980 }
1981
1982 #[test]
1983 fn outbox_reconciliation_requires_interval_when_partially_enabled()
1984 -> Result<(), Box<dyn std::error::Error>> {
1985 let mut config = outbox_enabled_base();
1986 config.outbox.reconcile_interval_ms = None;
1987 let error = config
1988 .validate()
1989 .err()
1990 .ok_or("reconciliation without interval must fail")?;
1991 assert!(error.to_string().contains("outbox.reconcile_interval_ms"));
1992 Ok(())
1993 }
1994
1995 #[test]
1996 fn outbox_reconciliation_requires_stale_threshold_when_partially_enabled()
1997 -> Result<(), Box<dyn std::error::Error>> {
1998 let mut config = outbox_enabled_base();
1999 config.outbox.reconcile_stale_after_ms = None;
2000 let error = config
2001 .validate()
2002 .err()
2003 .ok_or("reconciliation without stale threshold must fail")?;
2004 assert!(
2005 error
2006 .to_string()
2007 .contains("outbox.reconcile_stale_after_ms")
2008 );
2009 Ok(())
2010 }
2011
2012 #[test]
2013 fn package_discovery_is_sorted() -> Result<(), Box<dyn std::error::Error>> {
2014 let temp_dir = crate::test_support::private_tempdir()?;
2015 std::fs::write(temp_dir.path().join("zeta.aion"), b"package")?;
2016 std::fs::write(temp_dir.path().join("alpha.aion"), b"package")?;
2017 std::fs::write(temp_dir.path().join("ignored.txt"), b"package")?;
2018 std::fs::create_dir(temp_dir.path().join("nested"))?;
2019 std::fs::write(
2020 temp_dir.path().join("nested").join("nested.aion"),
2021 b"package",
2022 )?;
2023
2024 let packages = discover_workflow_packages(temp_dir.path())?;
2025
2026 assert_eq!(
2027 packages,
2028 vec![
2029 temp_dir.path().join("alpha.aion"),
2030 temp_dir.path().join("zeta.aion"),
2031 ]
2032 );
2033 Ok(())
2034 }
2035
2036 #[test]
2037 fn workflow_package_merge_is_additive_and_deduplicated() {
2038 let mut packages = vec!["config.aion".into(), "shared.aion".into()];
2039 let discovered = vec!["auto.aion".into(), "shared.aion".into()];
2040 let cli = vec!["cli.aion".into(), "auto.aion".into()];
2041
2042 merge_workflow_packages(&mut packages, discovered, &cli);
2043
2044 assert_eq!(
2045 packages,
2046 vec![
2047 std::path::PathBuf::from("config.aion"),
2048 std::path::PathBuf::from("shared.aion"),
2049 std::path::PathBuf::from("auto.aion"),
2050 std::path::PathBuf::from("cli.aion"),
2051 ]
2052 );
2053 }
2054
2055 #[test]
2056 fn package_merge_deduplicates_canonical_files() -> Result<(), Box<dyn std::error::Error>> {
2057 let temp_dir = crate::test_support::private_tempdir()?;
2058 let package = temp_dir.path().join("hello.aion");
2059 std::fs::write(&package, b"package")?;
2060 let mut packages = vec![package.clone()];
2061 let discovered = vec![temp_dir.path().join(".").join("hello.aion")];
2062
2063 merge_workflow_packages(&mut packages, discovered, &[]);
2064
2065 assert_eq!(packages, vec![package]);
2066 Ok(())
2067 }
2068
2069 #[test]
2070 fn zero_config_cli_workflow_package_uses_in_memory_defaults()
2071 -> Result<(), Box<dyn std::error::Error>> {
2072 let temp_dir = crate::test_support::private_tempdir()?;
2073
2074 let cli = CliOverrides {
2075 workflow_packages: vec!["hello-world.aion".into()],
2076 ..CliOverrides::default()
2077 };
2078 let mut config = ServerConfig::default();
2079 config.store.backend = StoreBackend::Memory;
2083 config.store.data_dir = None;
2084 config.websocket.event_broadcast_capacity = Some(64);
2089 config.websocket.cluster_broadcast_capacity = Some(64);
2090 config.runtime.query_timeout_ms = Some(10_000);
2091 config.load_discovered_workflow_packages(&cli, temp_dir.path())?;
2092
2093 config.validate()?;
2094
2095 assert_eq!(config.store.backend, StoreBackend::Memory);
2096 assert_eq!(config.store.url, None);
2097 assert_eq!(
2098 config.workflow_packages,
2099 vec![std::path::PathBuf::from("hello-world.aion")]
2100 );
2101 Ok(())
2102 }
2103
2104 #[test]
2105 fn cli_packages_are_additive() -> Result<(), Box<dyn std::error::Error>> {
2106 let mut config = ServerConfig::from_slice(
2107 br#"
2108 workflow_packages = ["config.aion"]
2109
2110 [runtime]
2111 query_timeout_ms = 10000
2112
2113 [websocket]
2114 event_broadcast_capacity = 64
2115 cluster_broadcast_capacity = 64
2116 "#,
2117 )?;
2118 let cli = CliOverrides {
2119 workflow_packages: vec!["cli-one.aion".into(), "cli-two.aion".into()],
2120 ..CliOverrides::default()
2121 };
2122
2123 merge_workflow_packages(
2124 &mut config.workflow_packages,
2125 Vec::new(),
2126 &cli.workflow_packages,
2127 );
2128
2129 assert_eq!(
2130 config.workflow_packages,
2131 vec![
2132 std::path::PathBuf::from("config.aion"),
2133 std::path::PathBuf::from("cli-one.aion"),
2134 std::path::PathBuf::from("cli-two.aion"),
2135 ]
2136 );
2137 Ok(())
2138 }
2139}