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}