Skip to main content

liminal_server/config/validation/
profiles.rs

1use crate::ServerError;
2use crate::config::types::{SelfStreamsConfig, ServerConfig, ServiceProfile};
3
4/// Validates the `[services]` profile selection.
5///
6/// An unrecognised `profile` value is a typed config validation error. When the
7/// profile is `worker-front-door`, config that asks for machinery the profile does
8/// not build is rejected via [`worker_front_door_field_errors`].
9impl SelfStreamsConfig {
10    /// Validates both stream bounds before service construction.
11    ///
12    /// # Errors
13    /// Returns a named configuration error for each zero bound.
14    pub fn validate(&self) -> Result<(), ServerError> {
15        let mut errors = Vec::new();
16        collect_self_stream_bound_errors(self, &mut errors);
17        if errors.is_empty() {
18            Ok(())
19        } else {
20            Err(ServerError::ConfigValidation {
21                message: errors.join("; "),
22            })
23        }
24    }
25}
26
27fn collect_self_stream_bound_errors(config: &SelfStreamsConfig, errors: &mut Vec<String>) {
28    errors.extend(
29        [
30            ("cost_rows", config.cost_rows),
31            ("events_rows", config.events_rows),
32        ]
33        .into_iter()
34        .filter(|(_, rows)| *rows == 0)
35        .map(|(name, _)| format!("services.self_streams.{name}: must be non-zero")),
36    );
37}
38
39pub(super) fn validate_services(config: &ServerConfig, errors: &mut Vec<String>) {
40    collect_self_stream_bound_errors(&config.services.self_streams, errors);
41    let profile = match config.services.profile() {
42        Ok(profile) => profile,
43        Err(error) => {
44            // `profile()` only ever yields a `ConfigValidation` carrying the bare
45            // field message; surface it directly so it reads like every other
46            // accumulated error rather than the wrapped `Display` prefix.
47            match error {
48                crate::ServerError::ConfigValidation { message } => errors.push(message),
49                other => errors.push(other.to_string()),
50            }
51            return;
52        }
53    };
54
55    if profile == ServiceProfile::WorkerFrontDoor {
56        errors.extend(worker_front_door_field_errors(config));
57    }
58}
59
60/// Cross-field checks for the worker-front-door profile: config that asks for
61/// machinery the profile does not build — channels, routing rules, a persistence
62/// path, or a cluster — is rejected rather than silently ignored. The front door
63/// constructs no channel, conversation, haematite, or distribution services, so
64/// honouring any of those keys is impossible and accepting them quietly would be a
65/// silent tradeoff.
66///
67/// Called from BOTH the file-loading validation pass ([`validate_services`]) and
68/// the runtime construction path
69/// ([`build_connection_services`](crate::server::connection::build_connection_services)),
70/// so a directly-constructed `ServerConfig` that skips file validation still cannot
71/// combine the worker profile with full-only machinery.
72pub fn worker_front_door_field_errors(config: &ServerConfig) -> Vec<String> {
73    let mut errors = Vec::new();
74    if !config.channels.is_empty() {
75        errors.push(
76            "services.profile: \"worker-front-door\" builds no channels; remove the \
77             [[channels]] entries or use profile = \"full\""
78                .to_owned(),
79        );
80    }
81    if !config.routing_rules.is_empty() {
82        errors.push(
83            "services.profile: \"worker-front-door\" builds no channels to route between; \
84             remove the [[routing_rules]] entries or use profile = \"full\""
85                .to_owned(),
86        );
87    }
88    if config.persistence_path.is_some() {
89        errors.push(
90            "services.profile: \"worker-front-door\" builds no durable store; remove \
91             persistence_path or use profile = \"full\""
92                .to_owned(),
93        );
94    }
95    if config.cluster.is_some() {
96        errors.push(
97            "services.profile: \"worker-front-door\" builds no channel cluster; remove the \
98             [cluster] section or use profile = \"full\""
99                .to_owned(),
100        );
101    }
102    if config.participant.is_some() {
103        errors.push(
104            "services.profile: \"worker-front-door\" installs no participant service; remove \
105             the [participant] section or use profile = \"full\""
106                .to_owned(),
107        );
108    }
109    errors
110}