greentic_config/
validate.rs

1use greentic_config_types::{
2    BackoffConfig, GreenticConfig, MetricsConfig, PackSourceConfig, ServiceConfig,
3    ServiceEndpointConfig, ServiceTransportConfig, ServicesConfig, TelemetryExporterKind,
4};
5use greentic_types::{ConnectionKind, EnvId};
6use thiserror::Error;
7
8#[derive(Debug, Error)]
9pub enum ValidationError {
10    #[error("dev configuration is not permitted for non-dev env ({0}) without allow_dev")]
11    DevNotAllowed(String),
12    #[error("path must be absolute: {0}")]
13    RelativePath(String),
14    #[error("telemetry sampling must be between 0.0 and 1.0 (got {0})")]
15    TelemetrySampling(f32),
16    #[error("packs.source requires connectivity but environment.connection is offline")]
17    PacksSourceOffline,
18    #[error("events endpoint not allowed for offline connection: {0}")]
19    EventsEndpointOffline(String),
20    #[error("events backoff.initial_ms must be greater than 0 (got {0})")]
21    EventsBackoffInitial(u64),
22    #[error("events backoff.max_ms must be >= initial_ms (got max={max}, initial={initial})")]
23    EventsBackoffMax { max: u64, initial: u64 },
24    #[error("events backoff.multiplier must be finite and >= 1.0 (got {0})")]
25    EventsBackoffMultiplier(f64),
26    #[error("deployer.base_domain must be a valid DNS name (got {0})")]
27    DeployerBaseDomain(String),
28}
29
30pub fn validate_config(
31    config: &GreenticConfig,
32    allow_dev: bool,
33) -> Result<Vec<String>, ValidationError> {
34    validate_config_with_overrides(config, allow_dev, false)
35}
36
37pub fn validate_config_with_overrides(
38    config: &GreenticConfig,
39    allow_dev: bool,
40    allow_network: bool,
41) -> Result<Vec<String>, ValidationError> {
42    let mut warnings = Vec::new();
43
44    if let Some(dev) = &config.dev {
45        if !allow_dev && !is_dev_env(&config.environment.env_id) {
46            let env_label = env_id_label(&config.environment.env_id);
47            return Err(ValidationError::DevNotAllowed(env_label));
48        }
49        if dev.default_team.is_none() {
50            warnings.push("dev.default_team not set; proceeding without team scoping".to_string());
51        }
52    }
53
54    for path in [
55        &config.paths.greentic_root,
56        &config.paths.state_dir,
57        &config.paths.cache_dir,
58        &config.paths.logs_dir,
59    ] {
60        if !path.is_absolute() {
61            return Err(ValidationError::RelativePath(path.display().to_string()));
62        }
63    }
64
65    if !(0.0..=1.0).contains(&config.telemetry.sampling) {
66        return Err(ValidationError::TelemetrySampling(
67            config.telemetry.sampling,
68        ));
69    }
70
71    if config.telemetry.enabled && matches!(config.telemetry.exporter, TelemetryExporterKind::None)
72    {
73        warnings
74            .push("telemetry.enabled=true but exporter=none; telemetry will be disabled".into());
75    }
76
77    if !allow_network
78        && matches!(config.environment.connection, Some(ConnectionKind::Offline))
79        && telemetry_endpoint_is_non_loopback(config.telemetry.endpoint.as_deref())
80    {
81        warnings.push(
82            "environment.connection=offline but telemetry.endpoint is non-loopback; telemetry may attempt outbound network"
83                .into(),
84        );
85    }
86
87    if let Some(packs) = &config.packs {
88        ensure_absolute(&packs.cache_dir)?;
89        match &packs.source {
90            PackSourceConfig::LocalIndex { path } => ensure_absolute(path)?,
91            PackSourceConfig::HttpIndex { .. } | PackSourceConfig::OciRegistry { .. } => {
92                if !allow_network
93                    && matches!(config.environment.connection, Some(ConnectionKind::Offline))
94                {
95                    return Err(ValidationError::PacksSourceOffline);
96                }
97            }
98        }
99    }
100
101    if let Some(services) = &config.services
102        && let Some(events) = &services.events
103        && !allow_network
104    {
105        validate_events_endpoint(events, &config.environment.connection)?;
106    }
107
108    if let Some(services) = &config.services {
109        validate_service_bindings(&mut warnings, services);
110
111        if !allow_network && matches!(config.environment.connection, Some(ConnectionKind::Offline))
112        {
113            warn_offline_transport(
114                &mut warnings,
115                "runner",
116                services.runner.as_ref().and_then(|s| s.transport.as_ref()),
117            );
118            warn_offline_transport(
119                &mut warnings,
120                "deployer",
121                services
122                    .deployer
123                    .as_ref()
124                    .and_then(|s| s.transport.as_ref()),
125            );
126            warn_offline_transport(
127                &mut warnings,
128                "events_transport",
129                services
130                    .events_transport
131                    .as_ref()
132                    .and_then(|s| s.transport.as_ref()),
133            );
134            warn_offline_transport(
135                &mut warnings,
136                "source",
137                services.source.as_ref().and_then(|s| s.transport.as_ref()),
138            );
139            warn_offline_transport(
140                &mut warnings,
141                "publish",
142                services.publish.as_ref().and_then(|s| s.transport.as_ref()),
143            );
144            warn_offline_transport(
145                &mut warnings,
146                "metadata",
147                services
148                    .metadata
149                    .as_ref()
150                    .and_then(|s| s.transport.as_ref()),
151            );
152            warn_offline_transport(
153                &mut warnings,
154                "oauth_broker",
155                services
156                    .oauth_broker
157                    .as_ref()
158                    .and_then(|s| s.transport.as_ref()),
159            );
160        }
161    }
162
163    if let Some(events) = &config.events
164        && let Some(backoff) = &events.backoff
165    {
166        validate_backoff(backoff)?;
167    }
168
169    if let Some(deployer) = &config.deployer
170        && let Some(base_domain) = &deployer.base_domain
171    {
172        validate_base_domain(base_domain)?;
173    }
174
175    Ok(warnings)
176}
177
178fn is_dev_env(env: &EnvId) -> bool {
179    env_id_label(env).to_ascii_lowercase().contains("dev")
180}
181
182fn env_id_label(env: &EnvId) -> String {
183    serde_json::to_value(env)
184        .map(|v| v.to_string())
185        .unwrap_or_else(|_| format!("{env:?}"))
186}
187
188fn ensure_absolute(path: &std::path::Path) -> Result<(), ValidationError> {
189    if path.is_absolute() {
190        Ok(())
191    } else {
192        Err(ValidationError::RelativePath(path.display().to_string()))
193    }
194}
195
196fn validate_events_endpoint(
197    endpoint: &ServiceEndpointConfig,
198    connection: &Option<ConnectionKind>,
199) -> Result<(), ValidationError> {
200    if matches!(connection, Some(ConnectionKind::Offline)) && !is_local_url(&endpoint.url) {
201        return Err(ValidationError::EventsEndpointOffline(
202            endpoint.url.to_string(),
203        ));
204    }
205    Ok(())
206}
207
208fn is_local_url(url: &url::Url) -> bool {
209    match url.host_str() {
210        Some("localhost") => true,
211        Some(host) => host
212            .parse::<std::net::IpAddr>()
213            .map(|ip| ip.is_loopback())
214            .unwrap_or(false),
215        None => false,
216    }
217}
218
219fn telemetry_endpoint_is_non_loopback(endpoint: Option<&str>) -> bool {
220    let Some(raw) = endpoint else {
221        return false;
222    };
223    let Ok(url) = url::Url::parse(raw) else {
224        return true;
225    };
226    !is_local_url(&url)
227}
228
229fn validate_backoff(backoff: &BackoffConfig) -> Result<(), ValidationError> {
230    if let Some(initial) = backoff.initial_ms
231        && initial == 0
232    {
233        return Err(ValidationError::EventsBackoffInitial(initial));
234    }
235    if let (Some(max), Some(initial)) = (backoff.max_ms, backoff.initial_ms)
236        && max < initial
237    {
238        return Err(ValidationError::EventsBackoffMax { max, initial });
239    }
240    if let Some(multiplier) = backoff.multiplier
241        && (!multiplier.is_finite() || multiplier < 1.0)
242    {
243        return Err(ValidationError::EventsBackoffMultiplier(multiplier));
244    }
245    Ok(())
246}
247
248fn validate_base_domain(domain: &str) -> Result<(), ValidationError> {
249    let trimmed = domain.trim();
250    if trimmed.is_empty()
251        || trimmed.contains("://")
252        || trimmed.contains('/')
253        || trimmed.contains(' ')
254    {
255        return Err(ValidationError::DeployerBaseDomain(domain.to_string()));
256    }
257
258    let labels = trimmed.split('.').collect::<Vec<_>>();
259    if labels.iter().any(|label| label.is_empty()) {
260        return Err(ValidationError::DeployerBaseDomain(domain.to_string()));
261    }
262
263    for label in labels {
264        if label.len() > 63
265            || label.starts_with('-')
266            || label.ends_with('-')
267            || !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
268        {
269            return Err(ValidationError::DeployerBaseDomain(domain.to_string()));
270        }
271    }
272
273    Ok(())
274}
275
276fn warn_offline_transport(
277    warnings: &mut Vec<String>,
278    name: &str,
279    transport: Option<&ServiceTransportConfig>,
280) {
281    let Some(transport) = transport else {
282        return;
283    };
284    match transport {
285        ServiceTransportConfig::Noop => {}
286        ServiceTransportConfig::Http { url, .. } | ServiceTransportConfig::Nats { url, .. } => {
287            if !is_local_url(url) {
288                warnings.push(format!(
289                    "environment.connection=offline but services.{name} transport configured at {url}; network operations may be disallowed"
290                ));
291            }
292        }
293    }
294}
295
296fn validate_service_bindings(warnings: &mut Vec<String>, services: &ServicesConfig) {
297    for (name, service) in [
298        ("runner", services.runner.as_ref()),
299        ("deployer", services.deployer.as_ref()),
300        ("events_transport", services.events_transport.as_ref()),
301        ("source", services.source.as_ref()),
302        ("publish", services.publish.as_ref()),
303        ("metadata", services.metadata.as_ref()),
304        ("oauth_broker", services.oauth_broker.as_ref()),
305    ] {
306        if let Some(binding) = service.and_then(|svc| svc.service.as_ref()) {
307            validate_service_binding(warnings, name, binding);
308        }
309    }
310}
311
312fn validate_service_binding(warnings: &mut Vec<String>, name: &str, binding: &ServiceConfig) {
313    if let Some(bind_addr) = binding.bind_addr.as_deref()
314        && !bind_addr.is_empty()
315        && !host_like(bind_addr)
316    {
317        warnings.push(format!(
318            "services.{name}.service.bind_addr '{bind_addr}' does not look like an IP/hostname; continuing"
319        ));
320    }
321    if let Some(port) = binding.port
322        && port == 0
323    {
324        warnings.push(format!(
325            "services.{name}.service.port must be between 1-65535; ignoring zero"
326        ));
327    }
328    if let Some(public) = binding.public_base_url.as_deref()
329        && url::Url::parse(public).is_err()
330    {
331        warnings.push(format!(
332            "services.{name}.service.public_base_url '{public}' is not a valid URL"
333        ));
334    }
335    if let Some(metrics) = binding.metrics.as_ref() {
336        validate_metrics(warnings, name, metrics);
337    }
338}
339
340fn validate_metrics(warnings: &mut Vec<String>, name: &str, metrics: &MetricsConfig) {
341    if let Some(bind_addr) = metrics.bind_addr.as_deref()
342        && !bind_addr.is_empty()
343        && !host_like(bind_addr)
344    {
345        warnings.push(format!(
346            "services.{name}.service.metrics.bind_addr '{bind_addr}' does not look like an IP/hostname; continuing"
347        ));
348    }
349    if let Some(port) = metrics.port
350        && port == 0
351    {
352        warnings.push(format!(
353            "services.{name}.service.metrics.port must be between 1-65535; ignoring zero"
354        ));
355    }
356    if let Some(path) = metrics.path.as_deref()
357        && !path.starts_with('/')
358    {
359        warnings.push(format!(
360            "services.{name}.service.metrics.path should start with '/'; got '{path}'"
361        ));
362    }
363}
364
365fn host_like(value: &str) -> bool {
366    value
367        .parse::<std::net::IpAddr>()
368        .map(|_| true)
369        .or_else(|_| url::Host::parse(value).map(|_| true))
370        .unwrap_or(false)
371}