1use std::{
4 collections::HashSet,
5 fs,
6 net::SocketAddr,
7 path::{Path, PathBuf},
8 time::Duration,
9};
10
11use serde::Deserialize;
12
13use crate::error::ServerError;
14
15pub mod env;
17pub mod file;
19
20const DEFAULT_HTTP_ADDRESS: SocketAddr =
21 SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 8080);
22const DEFAULT_GRPC_ADDRESS: SocketAddr =
23 SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 50051);
24
25#[derive(Debug, Default)]
27pub struct CliOverrides {
28 pub config_path: Option<PathBuf>,
30 pub listen_address: Option<SocketAddr>,
32 pub store_url: Option<String>,
34 pub scheduler_threads: Option<usize>,
36 pub drain_timeout_seconds: Option<u64>,
38 pub workflow_packages: Vec<PathBuf>,
40}
41
42#[derive(Clone, Debug, Deserialize)]
44#[serde(default, deny_unknown_fields)]
45#[derive(Default)]
46pub struct ServerConfig {
47 pub server: ServerSection,
49 pub store: StoreConfig,
51 pub runtime: RuntimeSection,
53 pub drain: DrainConfig,
55 pub auth: AuthConfig,
57 pub metrics: MetricsConfig,
59 pub namespaces: NamespacesConfig,
61 pub tls: Option<TlsConfig>,
63 pub dashboard: DashboardConfig,
65 pub namespace: NamespaceConfig,
67 pub worker: WorkerConfig,
69 pub websocket: WebSocketConfig,
71 pub workflow_packages: Vec<PathBuf>,
73 pub deploy: DeployConfig,
75}
76
77#[derive(Clone, Debug, Deserialize)]
79#[serde(default, deny_unknown_fields)]
80pub struct ServerSection {
81 pub listen_address: SocketAddr,
83 pub grpc_address: SocketAddr,
85}
86
87#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
89#[serde(rename_all = "lowercase")]
90pub enum StoreBackend {
91 Memory,
93 LibSql,
95}
96
97#[derive(Clone, Debug, Deserialize)]
99#[serde(default, deny_unknown_fields)]
100pub struct StoreConfig {
101 pub backend: StoreBackend,
103 pub url: Option<String>,
105}
106
107#[derive(Clone, Debug, Deserialize)]
109#[serde(default, deny_unknown_fields)]
110pub struct RuntimeSection {
111 pub scheduler_threads: usize,
113 pub query_timeout_ms: Option<u64>,
118}
119
120#[derive(Clone, Debug, Deserialize)]
122#[serde(default, deny_unknown_fields)]
123pub struct DrainConfig {
124 pub timeout_seconds: u64,
126}
127
128#[derive(Clone, Debug, Deserialize)]
130#[serde(default, deny_unknown_fields)]
131pub struct AuthConfig {
132 pub enabled: bool,
134 pub jwks_url: Option<String>,
136 pub jwks_refresh_seconds: u64,
138}
139
140#[derive(Clone, Debug, Deserialize)]
142#[serde(default, deny_unknown_fields)]
143pub struct MetricsConfig {
144 pub enabled: bool,
146}
147
148#[derive(Clone, Debug, Deserialize)]
150#[serde(default, deny_unknown_fields)]
151pub struct NamespacesConfig {
152 pub default: String,
154}
155
156#[derive(Clone, Debug, Deserialize)]
158#[serde(default, deny_unknown_fields)]
159pub struct ListenConfig {
160 pub grpc: SocketAddr,
162 pub http: SocketAddr,
164}
165
166#[derive(Clone, Debug, Deserialize)]
168#[serde(deny_unknown_fields)]
169pub struct TlsConfig {
170 pub certificate_chain_path: PathBuf,
172 pub private_key_path: PathBuf,
174}
175
176#[derive(Clone, Debug, Deserialize)]
178#[serde(default, deny_unknown_fields)]
179pub struct DashboardConfig {
180 pub source: DashboardAssetSource,
182}
183
184#[derive(Clone, Debug, Deserialize)]
186pub enum DashboardAssetSource {
187 FileSystem {
189 asset_path: PathBuf,
191 },
192 Embedded,
194}
195
196#[derive(Clone, Debug, Deserialize)]
198#[serde(default, deny_unknown_fields)]
199pub struct NamespaceConfig {
200 pub mode: NamespaceMode,
202}
203
204#[derive(Clone, Debug, Deserialize)]
206pub enum NamespaceMode {
207 SharedEngine,
209 SingleTenant {
211 namespace: String,
213 },
214}
215
216#[derive(Clone, Debug, Deserialize)]
218#[serde(default, deny_unknown_fields)]
219pub struct WorkerConfig {
220 #[serde(with = "duration_millis")]
222 pub heartbeat_window: Duration,
223}
224
225#[derive(Clone, Debug, Deserialize)]
227#[serde(default, deny_unknown_fields)]
228pub struct WebSocketConfig {
229 pub outbound_buffer_bound: usize,
231 pub event_broadcast_capacity: Option<usize>,
237}
238
239pub(crate) const EVENT_BROADCAST_CAPACITY_REQUIRED: &str = "websocket.event_broadcast_capacity is required and has no default: the server always mounts /events/stream, so live event streaming capacity must be configured explicitly; set websocket.event_broadcast_capacity (or AION_WEBSOCKET_EVENT_BROADCAST_CAPACITY) to a positive integer sized for global event volume across all namespaces";
241
242#[derive(Clone, Debug, Default, Deserialize)]
249#[serde(default, deny_unknown_fields)]
250pub struct DeployConfig {
251 pub enabled: bool,
253 pub max_archive_bytes: Option<u64>,
257 pub max_inflated_bytes: Option<u64>,
263}
264
265pub(crate) const DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED: &str = "deploy.max_archive_bytes is required and has no default when deploy.enabled is true: the archive upload ceiling must be an explicit operator decision sized for the deployment's packages; set deploy.max_archive_bytes (or AION_DEPLOY_MAX_ARCHIVE_BYTES) to a positive number of bytes";
267
268pub(crate) const DEPLOY_MAX_INFLATED_BYTES_REQUIRED: &str = "deploy.max_inflated_bytes is required and has no default when deploy.enabled is true: the decompressed-contents ceiling for uploaded archives must be an explicit operator decision (a compressed upload under deploy.max_archive_bytes can inflate ~1000:1); set deploy.max_inflated_bytes (or AION_DEPLOY_MAX_INFLATED_BYTES) to a positive number of bytes no smaller than deploy.max_archive_bytes";
270
271pub(crate) const QUERY_TIMEOUT_REQUIRED: &str = "runtime.query_timeout_ms is required and has no default: the server always mounts /workflows/query, so the workflow query reply deadline must be configured explicitly; set runtime.query_timeout_ms (or AION_RUNTIME_QUERY_TIMEOUT_MS) to a positive number of milliseconds";
273
274#[derive(Clone, Debug)]
276pub struct RuntimeConfig {
277 pub listen: ListenConfig,
279 pub tls: Option<TlsConfig>,
281 pub auth: AuthConfig,
283 pub dashboard: DashboardConfig,
285 pub namespace: NamespaceConfig,
287 pub worker: WorkerConfig,
289 pub websocket: WebSocketConfig,
291 pub workflow_packages: Vec<PathBuf>,
293 pub deploy: DeployConfig,
295 pub scheduler_threads: usize,
297 pub query_timeout: Option<Duration>,
302 pub default_namespace: String,
304 pub drain_timeout: Duration,
306 pub metrics: MetricsConfig,
308}
309
310impl ServerConfig {
311 pub fn load(cli: &CliOverrides) -> Result<Self, ServerError> {
318 let mut config = file::load(cli.config_path.as_deref())?.unwrap_or_default();
319 env::overlay(&mut config)?;
320 config.apply_cli_overrides(cli);
321 config.load_discovered_workflow_packages(cli, Path::new("."))?;
322 config.validate()?;
323 Ok(config)
324 }
325
326 fn load_discovered_workflow_packages(
327 &mut self,
328 cli: &CliOverrides,
329 directory: &Path,
330 ) -> Result<(), ServerError> {
331 let discovered_packages = discover_workflow_packages(directory)?;
332 merge_workflow_packages(
333 &mut self.workflow_packages,
334 discovered_packages,
335 &cli.workflow_packages,
336 );
337 Ok(())
338 }
339
340 pub fn from_slice(bytes: &[u8]) -> Result<Self, ServerError> {
346 let config: Self = toml::from_slice(bytes).map_err(|source| ServerError::Config {
347 message: format!("invalid server config: {source}"),
348 })?;
349 config.validate()?;
350 Ok(config)
351 }
352
353 pub fn load_from_path(path: impl Into<PathBuf>) -> Result<Self, ServerError> {
359 file::load_required(&path.into())
360 }
361
362 #[must_use]
364 pub fn into_parts(self) -> (StoreConfig, RuntimeConfig) {
365 let runtime = RuntimeConfig {
366 listen: ListenConfig {
367 grpc: self.server.grpc_address,
368 http: self.server.listen_address,
369 },
370 tls: self.tls,
371 auth: self.auth,
372 dashboard: self.dashboard,
373 namespace: self.namespace,
374 worker: self.worker,
375 websocket: self.websocket,
376 workflow_packages: self.workflow_packages,
377 deploy: self.deploy,
378 scheduler_threads: self.runtime.scheduler_threads,
379 query_timeout: self.runtime.query_timeout_ms.map(Duration::from_millis),
380 default_namespace: self.namespaces.default,
381 drain_timeout: Duration::from_secs(self.drain.timeout_seconds),
382 metrics: self.metrics,
383 };
384 (self.store, runtime)
385 }
386
387 fn apply_cli_overrides(&mut self, cli: &CliOverrides) {
388 if let Some(address) = cli.listen_address {
389 self.server.listen_address = address;
390 }
391 if let Some(url) = &cli.store_url {
392 self.store.url = Some(url.clone());
393 if self.store.backend == StoreBackend::Memory {
394 self.store.backend = StoreBackend::LibSql;
395 }
396 }
397 if let Some(threads) = cli.scheduler_threads {
398 self.runtime.scheduler_threads = threads;
399 }
400 if let Some(timeout) = cli.drain_timeout_seconds {
401 self.drain.timeout_seconds = timeout;
402 }
403 }
404
405 fn validate(&self) -> Result<(), ServerError> {
406 if self.server.listen_address.port() == 0 {
407 return config_error("server.listen_address must use an explicit non-zero port");
408 }
409 if self.server.grpc_address.port() == 0 {
410 return config_error("server.grpc_address must use an explicit non-zero port");
411 }
412 if self.runtime.scheduler_threads == 0 {
413 return config_error("runtime.scheduler_threads must be greater than zero");
414 }
415 if self.drain.timeout_seconds == 0 {
416 return config_error("drain.timeout_seconds must be greater than zero");
417 }
418 if self.auth.enabled && self.auth.jwks_url.as_deref().is_none_or(str::is_empty) {
419 return config_error("auth.jwks_url must not be empty when auth.enabled is true");
420 }
421 if self.auth.jwks_refresh_seconds == 0 {
422 return config_error("auth.jwks_refresh_seconds must be greater than zero");
423 }
424 if self.namespaces.default.is_empty() {
425 return config_error("namespaces.default must not be empty");
426 }
427 if matches!(self.store.backend, StoreBackend::LibSql)
428 && self.store.url.as_deref().is_none_or(str::is_empty)
429 {
430 return config_error("store.url must not be empty when store.backend is libsql");
431 }
432 if let Some(url) = &self.store.url {
433 if url.is_empty() {
434 return config_error("store.url must not be empty");
435 }
436 }
437 if let DashboardAssetSource::FileSystem { asset_path } = &self.dashboard.source {
438 if asset_path.as_os_str().is_empty() {
439 return config_error("dashboard.source.FileSystem.asset_path must not be empty");
440 }
441 }
442 if let NamespaceMode::SingleTenant { namespace } = &self.namespace.mode {
443 if namespace.is_empty() {
444 return config_error("namespace.mode.SingleTenant.namespace must not be empty");
445 }
446 }
447 if self.worker.heartbeat_window.is_zero() {
448 return config_error("worker.heartbeat_window must be greater than zero");
449 }
450 if self.websocket.outbound_buffer_bound == 0 {
451 return config_error("websocket.outbound_buffer_bound must be greater than zero");
452 }
453 match self.websocket.event_broadcast_capacity {
454 None | Some(0) => return config_error(EVENT_BROADCAST_CAPACITY_REQUIRED),
455 Some(_) => {}
456 }
457 match self.runtime.query_timeout_ms {
458 None | Some(0) => return config_error(QUERY_TIMEOUT_REQUIRED),
459 Some(_) => {}
460 }
461 if self.deploy.enabled {
462 let max_archive_bytes = match self.deploy.max_archive_bytes {
463 None | Some(0) => return config_error(DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED),
464 Some(value) => value,
465 };
466 let max_inflated_bytes = match self.deploy.max_inflated_bytes {
467 None | Some(0) => return config_error(DEPLOY_MAX_INFLATED_BYTES_REQUIRED),
468 Some(value) => value,
469 };
470 ensure_fits_usize("deploy.max_archive_bytes", max_archive_bytes)?;
473 ensure_fits_usize("deploy.max_inflated_bytes", max_inflated_bytes)?;
474 if max_inflated_bytes < max_archive_bytes {
475 return config_error(format!(
476 "deploy.max_inflated_bytes ({max_inflated_bytes}) must be at least deploy.max_archive_bytes ({max_archive_bytes}): an inflate ceiling below the upload ceiling would refuse archives the upload ceiling admits, even stored uncompressed"
477 ));
478 }
479 }
480 Ok(())
481 }
482}
483
484fn ensure_fits_usize(key: &str, value: u64) -> Result<(), ServerError> {
486 if usize::try_from(value).is_err() {
487 return config_error(format!(
488 "{key} ({value}) exceeds this platform's addressable memory; set it to at most {}",
489 usize::MAX
490 ));
491 }
492 Ok(())
493}
494
495impl Default for ServerSection {
496 fn default() -> Self {
497 Self {
498 listen_address: DEFAULT_HTTP_ADDRESS,
499 grpc_address: DEFAULT_GRPC_ADDRESS,
500 }
501 }
502}
503
504impl Default for StoreConfig {
505 fn default() -> Self {
506 Self {
507 backend: StoreBackend::Memory,
508 url: None,
509 }
510 }
511}
512
513impl Default for RuntimeSection {
514 fn default() -> Self {
515 Self {
516 scheduler_threads: 1,
517 query_timeout_ms: None,
520 }
521 }
522}
523
524impl Default for DrainConfig {
525 fn default() -> Self {
526 Self {
527 timeout_seconds: 30,
528 }
529 }
530}
531
532impl Default for AuthConfig {
533 fn default() -> Self {
534 Self {
535 enabled: false,
536 jwks_url: None,
537 jwks_refresh_seconds: 300,
538 }
539 }
540}
541
542impl Default for MetricsConfig {
543 fn default() -> Self {
544 Self { enabled: true }
545 }
546}
547
548impl Default for NamespacesConfig {
549 fn default() -> Self {
550 Self {
551 default: "default".to_owned(),
552 }
553 }
554}
555
556impl Default for ListenConfig {
557 fn default() -> Self {
558 Self {
559 grpc: DEFAULT_GRPC_ADDRESS,
560 http: DEFAULT_HTTP_ADDRESS,
561 }
562 }
563}
564
565impl Default for DashboardConfig {
566 fn default() -> Self {
567 Self {
568 source: DashboardAssetSource::Embedded,
569 }
570 }
571}
572
573impl Default for NamespaceConfig {
574 fn default() -> Self {
575 Self {
576 mode: NamespaceMode::SharedEngine,
577 }
578 }
579}
580
581impl Default for WorkerConfig {
582 fn default() -> Self {
583 Self {
584 heartbeat_window: Duration::from_secs(30),
585 }
586 }
587}
588
589impl Default for WebSocketConfig {
590 fn default() -> Self {
591 Self {
592 outbound_buffer_bound: 32,
593 event_broadcast_capacity: None,
596 }
597 }
598}
599
600pub(crate) fn config_error<T>(message: impl Into<String>) -> Result<T, ServerError> {
601 Err(ServerError::Config {
602 message: message.into(),
603 })
604}
605
606fn discover_workflow_packages(directory: &Path) -> Result<Vec<PathBuf>, ServerError> {
607 let mut packages = Vec::new();
608 let entries = fs::read_dir(directory).map_err(|source| ServerError::Config {
609 message: format!(
610 "failed to scan workflow packages in `{}`: {source}",
611 directory.display()
612 ),
613 })?;
614
615 for entry in entries {
616 let entry = entry.map_err(|source| ServerError::Config {
617 message: format!(
618 "failed to read workflow package entry in `{}`: {source}",
619 directory.display()
620 ),
621 })?;
622 let path = entry.path();
623 let has_aion_extension = path
624 .extension()
625 .is_some_and(|extension| extension == "aion");
626 if path.is_file() && has_aion_extension {
627 packages.push(path);
628 }
629 }
630
631 packages.sort_by(|left, right| left.as_os_str().cmp(right.as_os_str()));
632 Ok(packages)
633}
634
635fn merge_workflow_packages(
636 workflow_packages: &mut Vec<PathBuf>,
637 discovered_packages: Vec<PathBuf>,
638 cli_packages: &[PathBuf],
639) {
640 let mut seen: HashSet<PathBuf> = workflow_packages
641 .iter()
642 .map(|package| deduplicated_package_key(package))
643 .collect();
644 for package in discovered_packages
645 .into_iter()
646 .chain(cli_packages.iter().cloned())
647 {
648 if seen.insert(deduplicated_package_key(&package)) {
649 workflow_packages.push(package);
650 }
651 }
652}
653
654fn deduplicated_package_key(path: &Path) -> PathBuf {
655 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
656}
657
658mod duration_millis {
659 use std::time::Duration;
660
661 use serde::{Deserialize, Deserializer};
662
663 pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
664 where
665 D: Deserializer<'de>,
666 {
667 let millis = u64::deserialize(deserializer)?;
668 Ok(Duration::from_millis(millis))
669 }
670}
671
672#[cfg(test)]
673mod tests {
674 use super::{
675 CliOverrides, ServerConfig, StoreBackend, discover_workflow_packages,
676 merge_workflow_packages,
677 };
678
679 #[test]
680 fn valid_toml_is_parsed_into_typed_config() -> Result<(), Box<dyn std::error::Error>> {
681 let config = ServerConfig::from_slice(
682 br#"
683 [server]
684 listen_address = "127.0.0.1:18080"
685 grpc_address = "127.0.0.1:15051"
686
687 [store]
688 backend = "libsql"
689 url = "aion.db"
690
691 [runtime]
692 scheduler_threads = 2
693 query_timeout_ms = 10000
694
695 [drain]
696 timeout_seconds = 45
697
698 [auth]
699 enabled = true
700 jwks_url = "https://issuer.example.com/.well-known/jwks.json"
701 jwks_refresh_seconds = 60
702
703 [metrics]
704 enabled = true
705
706 [namespaces]
707 default = "production"
708
709 [websocket]
710 outbound_buffer_bound = 16
711 event_broadcast_capacity = 1024
712 "#,
713 )?;
714
715 assert_eq!(config.store.backend, StoreBackend::LibSql);
716 assert_eq!(config.store.url.as_deref(), Some("aion.db"));
717 assert_eq!(config.runtime.scheduler_threads, 2);
718 assert_eq!(config.runtime.query_timeout_ms, Some(10_000));
719 assert_eq!(config.namespaces.default, "production");
720 assert_eq!(config.websocket.outbound_buffer_bound, 16);
721 assert_eq!(config.websocket.event_broadcast_capacity, Some(1024));
722 Ok(())
723 }
724
725 #[test]
726 fn missing_event_broadcast_capacity_fails_startup_validation_naming_the_key() {
727 let result = ServerConfig::default().validate();
731
732 let message = result
733 .err()
734 .map_or_else(String::new, |error| error.to_string());
735 assert!(
736 message.contains("websocket.event_broadcast_capacity"),
737 "validation message must name the missing key: {message}"
738 );
739 assert!(
740 message.contains("AION_WEBSOCKET_EVENT_BROADCAST_CAPACITY"),
741 "validation message must name the environment override: {message}"
742 );
743 }
744
745 #[test]
746 fn zero_event_broadcast_capacity_fails_startup_validation() {
747 let result = ServerConfig::from_slice(
748 br"
749 [websocket]
750 event_broadcast_capacity = 0
751 ",
752 );
753
754 let message = result
755 .err()
756 .map_or_else(String::new, |error| error.to_string());
757 assert!(
758 message.contains("websocket.event_broadcast_capacity"),
759 "validation message must name the zero-valued key: {message}"
760 );
761 }
762
763 #[test]
764 fn missing_query_timeout_fails_startup_validation_naming_the_key() {
765 let result = ServerConfig::from_slice(
769 br"
770 [runtime]
771 scheduler_threads = 1
772
773 [websocket]
774 event_broadcast_capacity = 64
775 ",
776 );
777
778 let message = result
779 .err()
780 .map_or_else(String::new, |error| error.to_string());
781 assert!(
782 message.contains("runtime.query_timeout_ms"),
783 "validation message must name the missing key: {message}"
784 );
785 assert!(
786 message.contains("AION_RUNTIME_QUERY_TIMEOUT_MS"),
787 "validation message must name the environment override: {message}"
788 );
789 }
790
791 #[test]
792 fn zero_query_timeout_fails_startup_validation() {
793 let result = ServerConfig::from_slice(
794 br"
795 [runtime]
796 query_timeout_ms = 0
797
798 [websocket]
799 event_broadcast_capacity = 64
800 ",
801 );
802
803 let message = result
804 .err()
805 .map_or_else(String::new, |error| error.to_string());
806 assert!(
807 message.contains("runtime.query_timeout_ms"),
808 "validation message must name the zero-valued key: {message}"
809 );
810 }
811
812 #[test]
817 fn deploy_enabled_without_max_archive_bytes_fails_naming_key_and_env() {
818 let result = ServerConfig::from_slice(
819 br"
820 [runtime]
821 query_timeout_ms = 10000
822
823 [websocket]
824 event_broadcast_capacity = 64
825
826 [deploy]
827 enabled = true
828 ",
829 );
830
831 let message = result
832 .err()
833 .map_or_else(String::new, |error| error.to_string());
834 assert!(
835 message.contains("deploy.max_archive_bytes"),
836 "validation message must name the missing key: {message}"
837 );
838 assert!(
839 message.contains("AION_DEPLOY_MAX_ARCHIVE_BYTES"),
840 "validation message must name the environment override: {message}"
841 );
842 }
843
844 #[test]
845 fn deploy_zero_max_archive_bytes_fails_startup_validation() {
846 let result = ServerConfig::from_slice(
847 br"
848 [runtime]
849 query_timeout_ms = 10000
850
851 [websocket]
852 event_broadcast_capacity = 64
853
854 [deploy]
855 enabled = true
856 max_archive_bytes = 0
857 ",
858 );
859
860 let message = result
861 .err()
862 .map_or_else(String::new, |error| error.to_string());
863 assert!(
864 message.contains("deploy.max_archive_bytes"),
865 "validation message must name the zero-valued key: {message}"
866 );
867 }
868
869 #[test]
874 fn deploy_enabled_without_max_inflated_bytes_fails_naming_key_and_env() {
875 let result = ServerConfig::from_slice(
876 br"
877 [runtime]
878 query_timeout_ms = 10000
879
880 [websocket]
881 event_broadcast_capacity = 64
882
883 [deploy]
884 enabled = true
885 max_archive_bytes = 16777216
886 ",
887 );
888
889 let message = result
890 .err()
891 .map_or_else(String::new, |error| error.to_string());
892 assert!(
893 message.contains("deploy.max_inflated_bytes"),
894 "validation message must name the missing key: {message}"
895 );
896 assert!(
897 message.contains("AION_DEPLOY_MAX_INFLATED_BYTES"),
898 "validation message must name the environment override: {message}"
899 );
900 }
901
902 #[test]
903 fn deploy_zero_max_inflated_bytes_fails_startup_validation() {
904 let result = ServerConfig::from_slice(
905 br"
906 [runtime]
907 query_timeout_ms = 10000
908
909 [websocket]
910 event_broadcast_capacity = 64
911
912 [deploy]
913 enabled = true
914 max_archive_bytes = 16777216
915 max_inflated_bytes = 0
916 ",
917 );
918
919 let message = result
920 .err()
921 .map_or_else(String::new, |error| error.to_string());
922 assert!(
923 message.contains("deploy.max_inflated_bytes"),
924 "validation message must name the zero-valued key: {message}"
925 );
926 }
927
928 #[test]
931 fn deploy_max_inflated_below_max_archive_fails_startup_validation() {
932 let result = ServerConfig::from_slice(
933 br"
934 [runtime]
935 query_timeout_ms = 10000
936
937 [websocket]
938 event_broadcast_capacity = 64
939
940 [deploy]
941 enabled = true
942 max_archive_bytes = 16777216
943 max_inflated_bytes = 16777215
944 ",
945 );
946
947 let message = result
948 .err()
949 .map_or_else(String::new, |error| error.to_string());
950 assert!(
951 message.contains("deploy.max_inflated_bytes")
952 && message.contains("deploy.max_archive_bytes"),
953 "validation message must name both ceilings: {message}"
954 );
955 }
956
957 #[test]
960 fn deploy_disabled_requires_no_archive_ceiling() -> Result<(), Box<dyn std::error::Error>> {
961 let config = ServerConfig::from_slice(
962 br"
963 [runtime]
964 query_timeout_ms = 10000
965
966 [websocket]
967 event_broadcast_capacity = 64
968 ",
969 )?;
970
971 assert!(!config.deploy.enabled);
972 assert_eq!(config.deploy.max_archive_bytes, None);
973 assert_eq!(config.deploy.max_inflated_bytes, None);
974 Ok(())
975 }
976
977 #[test]
978 fn deploy_section_parses_enabled_with_ceilings() -> Result<(), Box<dyn std::error::Error>> {
979 let config = ServerConfig::from_slice(
980 br"
981 [runtime]
982 query_timeout_ms = 10000
983
984 [websocket]
985 event_broadcast_capacity = 64
986
987 [deploy]
988 enabled = true
989 max_archive_bytes = 16777216
990 max_inflated_bytes = 67108864
991 ",
992 )?;
993
994 assert!(config.deploy.enabled);
995 assert_eq!(config.deploy.max_archive_bytes, Some(16_777_216));
996 assert_eq!(config.deploy.max_inflated_bytes, Some(67_108_864));
997 Ok(())
998 }
999
1000 #[test]
1001 fn invalid_values_name_problematic_field() {
1002 let result = ServerConfig::from_slice(
1003 br"
1004 [runtime]
1005 scheduler_threads = 0
1006 ",
1007 );
1008
1009 let message = result
1010 .err()
1011 .map_or_else(String::new, |error| error.to_string());
1012 assert!(message.contains("runtime.scheduler_threads"));
1013 }
1014
1015 #[test]
1016 fn cli_overrides_win_over_loaded_values() -> Result<(), Box<dyn std::error::Error>> {
1017 let mut config = ServerConfig::from_slice(
1018 br#"
1019 [store]
1020 backend = "libsql"
1021 url = "file.db"
1022
1023 [runtime]
1024 query_timeout_ms = 10000
1025
1026 [websocket]
1027 event_broadcast_capacity = 64
1028 "#,
1029 )?;
1030 let cli = CliOverrides {
1031 store_url: Some("cli.db".to_owned()),
1032 scheduler_threads: Some(3),
1033 ..CliOverrides::default()
1034 };
1035
1036 config.apply_cli_overrides(&cli);
1037 config.validate()?;
1038
1039 assert_eq!(config.store.url.as_deref(), Some("cli.db"));
1040 assert_eq!(config.runtime.scheduler_threads, 3);
1041 Ok(())
1042 }
1043
1044 #[test]
1045 fn default_config_defaults() -> Result<(), Box<dyn std::error::Error>> {
1046 let mut config = ServerConfig::default();
1047
1048 assert_eq!(config.store.backend, StoreBackend::Memory);
1049 assert_eq!(config.store.url, None);
1050 assert_eq!(config.server.grpc_address.to_string(), "127.0.0.1:50051");
1051 assert_eq!(config.server.listen_address.to_string(), "127.0.0.1:8080");
1052 assert_eq!(config.namespaces.default, "default");
1053 assert!(!config.auth.enabled);
1054 assert!(config.metrics.enabled);
1055 assert_eq!(config.websocket.event_broadcast_capacity, None);
1059 assert_eq!(config.runtime.query_timeout_ms, None);
1060 config.websocket.event_broadcast_capacity = Some(64);
1061 config.runtime.query_timeout_ms = Some(10_000);
1062 config.validate()?;
1063 Ok(())
1064 }
1065
1066 #[test]
1067 fn package_discovery_is_sorted() -> Result<(), Box<dyn std::error::Error>> {
1068 let temp_dir = tempfile::tempdir()?;
1069 std::fs::write(temp_dir.path().join("zeta.aion"), b"package")?;
1070 std::fs::write(temp_dir.path().join("alpha.aion"), b"package")?;
1071 std::fs::write(temp_dir.path().join("ignored.txt"), b"package")?;
1072 std::fs::create_dir(temp_dir.path().join("nested"))?;
1073 std::fs::write(
1074 temp_dir.path().join("nested").join("nested.aion"),
1075 b"package",
1076 )?;
1077
1078 let packages = discover_workflow_packages(temp_dir.path())?;
1079
1080 assert_eq!(
1081 packages,
1082 vec![
1083 temp_dir.path().join("alpha.aion"),
1084 temp_dir.path().join("zeta.aion"),
1085 ]
1086 );
1087 Ok(())
1088 }
1089
1090 #[test]
1091 fn workflow_package_merge_is_additive_and_deduplicated() {
1092 let mut packages = vec!["config.aion".into(), "shared.aion".into()];
1093 let discovered = vec!["auto.aion".into(), "shared.aion".into()];
1094 let cli = vec!["cli.aion".into(), "auto.aion".into()];
1095
1096 merge_workflow_packages(&mut packages, discovered, &cli);
1097
1098 assert_eq!(
1099 packages,
1100 vec![
1101 std::path::PathBuf::from("config.aion"),
1102 std::path::PathBuf::from("shared.aion"),
1103 std::path::PathBuf::from("auto.aion"),
1104 std::path::PathBuf::from("cli.aion"),
1105 ]
1106 );
1107 }
1108
1109 #[test]
1110 fn package_merge_deduplicates_canonical_files() -> Result<(), Box<dyn std::error::Error>> {
1111 let temp_dir = tempfile::tempdir()?;
1112 let package = temp_dir.path().join("hello.aion");
1113 std::fs::write(&package, b"package")?;
1114 let mut packages = vec![package.clone()];
1115 let discovered = vec![temp_dir.path().join(".").join("hello.aion")];
1116
1117 merge_workflow_packages(&mut packages, discovered, &[]);
1118
1119 assert_eq!(packages, vec![package]);
1120 Ok(())
1121 }
1122
1123 #[test]
1124 fn zero_config_cli_workflow_package_uses_in_memory_defaults()
1125 -> Result<(), Box<dyn std::error::Error>> {
1126 let temp_dir = tempfile::tempdir()?;
1127
1128 let cli = CliOverrides {
1129 workflow_packages: vec!["hello-world.aion".into()],
1130 ..CliOverrides::default()
1131 };
1132 let mut config = ServerConfig::default();
1133 config.websocket.event_broadcast_capacity = Some(64);
1138 config.runtime.query_timeout_ms = Some(10_000);
1139 config.load_discovered_workflow_packages(&cli, temp_dir.path())?;
1140
1141 config.validate()?;
1142
1143 assert_eq!(config.store.backend, StoreBackend::Memory);
1144 assert_eq!(config.store.url, None);
1145 assert_eq!(
1146 config.workflow_packages,
1147 vec![std::path::PathBuf::from("hello-world.aion")]
1148 );
1149 Ok(())
1150 }
1151
1152 #[test]
1153 fn cli_packages_are_additive() -> Result<(), Box<dyn std::error::Error>> {
1154 let mut config = ServerConfig::from_slice(
1155 br#"
1156 workflow_packages = ["config.aion"]
1157
1158 [runtime]
1159 query_timeout_ms = 10000
1160
1161 [websocket]
1162 event_broadcast_capacity = 64
1163 "#,
1164 )?;
1165 let cli = CliOverrides {
1166 workflow_packages: vec!["cli-one.aion".into(), "cli-two.aion".into()],
1167 ..CliOverrides::default()
1168 };
1169
1170 merge_workflow_packages(
1171 &mut config.workflow_packages,
1172 Vec::new(),
1173 &cli.workflow_packages,
1174 );
1175
1176 assert_eq!(
1177 config.workflow_packages,
1178 vec![
1179 std::path::PathBuf::from("config.aion"),
1180 std::path::PathBuf::from("cli-one.aion"),
1181 std::path::PathBuf::from("cli-two.aion"),
1182 ]
1183 );
1184 Ok(())
1185 }
1186}