1use std::fs;
2use std::path::PathBuf;
3
4use greentic_config::{ConfigFileFormat, ConfigLayer, ConfigResolver, ProvenanceMap};
5use greentic_config_types::{GreenticConfig, PathsConfig, TelemetryConfig};
6use greentic_types::ConnectionKind;
7use greentic_types::pack::PackRef;
8use semver::Version;
9use serde::{Deserialize, Serialize};
10
11use crate::adapter::{AdapterFamily, MultiTargetKind, UnifiedTargetSelection};
12use crate::contract::DeployerCapability;
13use crate::error::{DeployerError, Result};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub enum Provider {
18 Local,
19 Aws,
20 Azure,
21 Gcp,
22 K8s,
23 Generic,
24}
25
26impl Provider {
27 pub fn as_str(&self) -> &'static str {
28 match self {
29 Provider::Local => "local",
30 Provider::Aws => "aws",
31 Provider::Azure => "azure",
32 Provider::Gcp => "gcp",
33 Provider::K8s => "k8s",
34 Provider::Generic => "generic",
35 }
36 }
37
38 pub fn adapter_family(&self) -> AdapterFamily {
40 AdapterFamily::MultiTarget
41 }
42
43 pub fn unified_target(&self) -> UnifiedTargetSelection {
44 UnifiedTargetSelection::MultiTarget(MultiTargetKind::from(*self))
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
50pub enum OutputFormat {
51 #[default]
52 Text,
53 Json,
54 Yaml,
55}
56
57#[derive(Debug, Clone)]
59pub struct DeployerRequest {
60 pub capability: DeployerCapability,
61 pub provider: Provider,
62 pub strategy: String,
63 pub tenant: String,
64 pub environment: Option<String>,
65 pub pack_path: Option<PathBuf>,
66 pub bundle_root: Option<PathBuf>,
67 pub bundle_source: Option<String>,
68 pub bundle_digest: Option<String>,
69 pub repo_registry_base: Option<String>,
70 pub store_registry_base: Option<String>,
71 pub providers_dir: PathBuf,
72 pub packs_dir: PathBuf,
73 pub provider_pack: Option<PathBuf>,
74 pub pack_id: Option<String>,
75 pub pack_version: Option<String>,
76 pub pack_digest: Option<String>,
77 pub distributor_url: Option<String>,
78 pub distributor_token: Option<String>,
79 pub preview: bool,
80 pub dry_run: bool,
81 pub execute_local: bool,
82 pub output: OutputFormat,
83 pub config_path: Option<PathBuf>,
84 pub allow_remote_in_offline: bool,
85 pub deploy_pack_id_override: Option<String>,
86 pub deploy_flow_id_override: Option<String>,
87}
88
89impl DeployerRequest {
90 pub fn new(
91 capability: DeployerCapability,
92 provider: Provider,
93 tenant: impl Into<String>,
94 pack_path: Option<PathBuf>,
95 ) -> Self {
96 Self {
97 capability,
98 provider,
99 strategy: "iac-only".into(),
100 tenant: tenant.into(),
101 environment: None,
102 pack_path,
103 bundle_root: None,
104 bundle_source: None,
105 bundle_digest: None,
106 repo_registry_base: None,
107 store_registry_base: None,
108 providers_dir: PathBuf::from("providers/deployer"),
109 packs_dir: PathBuf::from("packs"),
110 provider_pack: None,
111 pack_id: None,
112 pack_version: None,
113 pack_digest: None,
114 distributor_url: None,
115 distributor_token: None,
116 preview: false,
117 dry_run: false,
118 execute_local: false,
119 output: OutputFormat::Text,
120 config_path: None,
121 allow_remote_in_offline: false,
122 deploy_pack_id_override: None,
123 deploy_flow_id_override: None,
124 }
125 }
126}
127
128#[derive(Debug, Clone)]
130pub struct DeployerConfig {
131 pub capability: DeployerCapability,
132 pub provider: Provider,
133 pub strategy: String,
134 pub tenant: String,
135 pub environment: String,
136 pub pack_path: Option<PathBuf>,
137 pub bundle_root: Option<PathBuf>,
138 pub bundle_source: Option<String>,
139 pub bundle_digest: Option<String>,
140 pub repo_registry_base: Option<String>,
141 pub store_registry_base: Option<String>,
142 pub providers_dir: PathBuf,
143 pub packs_dir: PathBuf,
144 pub provider_pack: Option<PathBuf>,
145 pub pack_ref: Option<PackRef>,
146 pub distributor_url: Option<String>,
147 pub distributor_token: Option<String>,
148 pub preview: bool,
149 pub dry_run: bool,
150 pub execute_local: bool,
151 pub output: OutputFormat,
152 pub greentic: GreenticConfig,
153 pub provenance: ProvenanceMap,
154 pub config_warnings: Vec<String>,
155 pub deploy_pack_id_override: Option<String>,
156 pub deploy_flow_id_override: Option<String>,
157}
158
159impl DeployerConfig {
160 pub fn resolve(request: DeployerRequest) -> Result<Self> {
161 let mut resolver = ConfigResolver::new();
162 if let Some(layer) = load_explicit_config(request.config_path.as_ref())? {
163 resolver = resolver.with_cli_overrides(layer);
164 }
165 let resolved = resolver
166 .load()
167 .map_err(|err| DeployerError::Config(err.to_string()))?;
168 let greentic = resolved.config;
169
170 if let Some(ref path) = request.pack_path
171 && !path.exists()
172 && request.pack_id.is_none()
173 {
174 return Err(DeployerError::Config(format!(
175 "pack path {} does not exist (and no pack_id provided)",
176 path.display()
177 )));
178 }
179
180 let environment = env_id_to_string(
181 request
182 .environment
183 .clone()
184 .or_else(|| Some(greentic.environment.env_id.to_string())),
185 );
186
187 let pack_ref = build_pack_ref(
188 request.pack_id.as_deref(),
189 request.pack_version.as_deref(),
190 request.pack_digest.as_deref(),
191 )?;
192
193 validate_offline_policy(
194 greentic.environment.connection.as_ref(),
195 &pack_ref,
196 request.distributor_url.as_deref(),
197 request.allow_remote_in_offline,
198 )?;
199
200 if request.deploy_pack_id_override.is_some() ^ request.deploy_flow_id_override.is_some() {
201 return Err(DeployerError::Config(
202 "deploy_pack_id_override and deploy_flow_id_override must be set together"
203 .to_string(),
204 ));
205 }
206
207 Ok(Self {
208 capability: request.capability,
209 provider: request.provider,
210 strategy: request.strategy,
211 tenant: request.tenant,
212 environment,
213 pack_path: request.pack_path,
214 bundle_root: request.bundle_root,
215 bundle_source: request.bundle_source,
216 bundle_digest: request.bundle_digest,
217 repo_registry_base: request.repo_registry_base,
218 store_registry_base: request.store_registry_base,
219 providers_dir: request.providers_dir,
220 packs_dir: request.packs_dir,
221 provider_pack: request.provider_pack,
222 pack_ref,
223 distributor_url: request.distributor_url,
224 distributor_token: request.distributor_token,
225 preview: request.preview,
226 dry_run: request.dry_run,
227 execute_local: request.execute_local,
228 output: request.output,
229 greentic,
230 provenance: resolved.provenance,
231 config_warnings: resolved.warnings,
232 deploy_pack_id_override: request.deploy_pack_id_override,
233 deploy_flow_id_override: request.deploy_flow_id_override,
234 })
235 }
236
237 pub fn deploy_base(&self) -> PathBuf {
238 self.greentic.paths.state_dir.join("deploy")
239 }
240
241 pub fn runtime_base(&self) -> PathBuf {
242 self.greentic.paths.state_dir.join("runtime")
243 }
244
245 pub fn output_scope_key(&self) -> String {
246 match &self.pack_path {
247 Some(path) => scope_key_for_path(path),
248 None => {
249 let raw = format!(
250 "{}-{}-{}",
251 self.provider.as_str(),
252 self.tenant,
253 self.environment
254 );
255 let mut scoped = String::with_capacity(raw.len());
256 for ch in raw.chars() {
257 if ch.is_ascii_alphanumeric() {
258 scoped.push(ch.to_ascii_lowercase());
259 } else {
260 scoped.push('-');
261 }
262 }
263 while scoped.contains("--") {
264 scoped = scoped.replace("--", "-");
265 }
266 scoped.trim_matches('-').to_string()
267 }
268 }
269 }
270
271 pub fn provider_output_dir(&self) -> PathBuf {
272 self.deploy_base()
273 .join(self.provider.as_str())
274 .join(&self.tenant)
275 .join(&self.environment)
276 .join(self.output_scope_key())
277 }
278
279 pub fn runtime_output_dir(&self) -> PathBuf {
280 self.runtime_base()
281 .join(&self.tenant)
282 .join(&self.environment)
283 .join(self.output_scope_key())
284 }
285
286 pub fn telemetry_config(&self) -> &TelemetryConfig {
287 &self.greentic.telemetry
288 }
289
290 pub fn paths(&self) -> &PathsConfig {
291 &self.greentic.paths
292 }
293}
294
295fn scope_key_for_path(path: &std::path::Path) -> String {
296 let canonical = path
297 .canonicalize()
298 .unwrap_or_else(|_| path.to_path_buf())
299 .display()
300 .to_string();
301 let mut scoped = String::with_capacity(canonical.len());
302 for ch in canonical.chars() {
303 if ch.is_ascii_alphanumeric() {
304 scoped.push(ch.to_ascii_lowercase());
305 } else {
306 scoped.push('-');
307 }
308 }
309 while scoped.contains("--") {
310 scoped = scoped.replace("--", "-");
311 }
312 scoped.trim_matches('-').to_string()
313}
314
315fn load_explicit_config(path: Option<&PathBuf>) -> Result<Option<ConfigLayer>> {
316 let Some(path) = path else {
317 return Ok(None);
318 };
319
320 let contents = fs::read_to_string(path).map_err(|err| {
321 DeployerError::Config(format!(
322 "failed to read config file {}: {err}",
323 path.display()
324 ))
325 })?;
326
327 let format = match path.extension().and_then(|s| s.to_str()) {
328 Some("json") => ConfigFileFormat::Json,
329 _ => ConfigFileFormat::Toml,
330 };
331
332 let layer = match format {
333 ConfigFileFormat::Toml => toml::from_str::<ConfigLayer>(&contents)
334 .map_err(|err| format!("toml parse error: {err}")),
335 ConfigFileFormat::Json => serde_json::from_str::<ConfigLayer>(&contents)
336 .map_err(|err| format!("json parse error: {err}")),
337 }
338 .map_err(|err| {
339 DeployerError::Config(format!("invalid config file {}: {err}", path.display()))
340 })?;
341
342 Ok(Some(layer))
343}
344
345fn build_pack_ref(
346 pack_id: Option<&str>,
347 pack_version: Option<&str>,
348 pack_digest: Option<&str>,
349) -> Result<Option<PackRef>> {
350 let Some(pack_id) = pack_id else {
351 return Ok(None);
352 };
353 let version_str = pack_version.ok_or_else(|| {
354 DeployerError::Config("when using pack_id you must set pack_version".into())
355 })?;
356 let digest = pack_digest.ok_or_else(|| {
357 DeployerError::Config("when using pack_id you must set pack_digest".into())
358 })?;
359 let version = Version::parse(version_str).map_err(|err| {
360 DeployerError::Config(format!("invalid pack version '{}': {}", version_str, err))
361 })?;
362 Ok(Some(PackRef::new(
363 pack_id.to_string(),
364 version,
365 digest.to_string(),
366 )))
367}
368
369fn env_id_to_string(env_id: Option<String>) -> String {
370 env_id.unwrap_or_else(|| "dev".to_string())
371}
372
373fn validate_offline_policy(
374 connection: Option<&ConnectionKind>,
375 pack_ref: &Option<PackRef>,
376 distributor_url: Option<&str>,
377 allow_remote_in_offline: bool,
378) -> Result<()> {
379 if matches!(connection, Some(ConnectionKind::Offline))
380 && !allow_remote_in_offline
381 && (pack_ref.is_some() || distributor_url.is_some())
382 {
383 return Err(DeployerError::OfflineDisallowed(
384 "connection is Offline but remote pack/distributor requested; set allow_remote_in_offline to override".into(),
385 ));
386 }
387 Ok(())
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use std::fs;
394 use std::path::Path;
395 use tempfile::tempdir;
396
397 #[test]
398 fn provider_targets_stay_on_multi_target_adapter_family() {
399 for provider in [
400 Provider::Local,
401 Provider::Aws,
402 Provider::Azure,
403 Provider::Gcp,
404 Provider::K8s,
405 Provider::Generic,
406 ] {
407 assert_eq!(provider.adapter_family(), AdapterFamily::MultiTarget);
408 assert!(matches!(
409 provider.unified_target(),
410 UnifiedTargetSelection::MultiTarget(_)
411 ));
412 }
413 }
414
415 fn base_request() -> DeployerRequest {
416 DeployerRequest::new(
417 DeployerCapability::Plan,
418 Provider::Aws,
419 "acme",
420 Some(PathBuf::from("examples/acme-pack")),
421 )
422 }
423
424 fn write_config(dir: &Path) -> PathBuf {
425 let cfg = r#"
426[environment]
427env_id = "prod"
428connection = "offline"
429
430[paths]
431greentic_root = "."
432state_dir = ".greentic/state"
433cache_dir = ".greentic/cache"
434logs_dir = ".greentic/logs"
435
436[telemetry]
437enabled = false
438
439[network]
440tls_mode = "system"
441
442[secrets]
443kind = "none"
444"#;
445 let path = dir.join("config.toml");
446 fs::write(&path, cfg).expect("write config");
447 path
448 }
449
450 #[test]
451 fn defaults_to_dev_environment_when_missing() {
452 let config = DeployerConfig::resolve(base_request()).expect("config builds");
456 assert_eq!(config.environment, "local");
457 }
458
459 #[test]
460 fn accepts_explicit_environment_field() {
461 let mut request = base_request();
462 request.environment = Some("prod".into());
463 let config = DeployerConfig::resolve(request).expect("config builds");
464 assert_eq!(config.environment, "prod");
465 }
466
467 #[test]
468 fn rejects_pack_id_without_version_or_digest() {
469 let mut request = base_request();
470 request.pack_id = Some("dev.greentic.sample".into());
471 let err = DeployerConfig::resolve(request).unwrap_err();
472 assert!(
473 format!("{err}").contains("pack_version"),
474 "expected version requirement error, got {err}"
475 );
476 }
477
478 #[test]
479 fn builds_pack_ref_when_provided() {
480 let mut request = base_request();
481 request.pack_id = Some("dev.greentic.sample".into());
482 request.pack_version = Some("0.1.0".into());
483 request.pack_digest = Some("sha256:deadbeef".into());
484 let config = DeployerConfig::resolve(request).expect("config builds");
485 let pack_ref = config.pack_ref.expect("pack_ref present");
486 assert_eq!(pack_ref.oci_url, "dev.greentic.sample");
487 assert_eq!(pack_ref.version.to_string(), "0.1.0");
488 assert_eq!(pack_ref.digest, "sha256:deadbeef");
489 }
490
491 #[test]
492 fn explicit_config_file_overrides_default_env() {
493 let dir = tempdir().unwrap();
494 let cfg_path = write_config(dir.path());
495
496 let mut request = base_request();
497 request.config_path = Some(cfg_path);
498 let config = DeployerConfig::resolve(request).expect("config builds");
499 assert_eq!(config.greentic.environment.env_id.to_string(), "prod");
500 }
501
502 #[test]
503 fn offline_connection_blocks_remote_pack_without_override() {
504 let dir = tempdir().unwrap();
505 let cfg_path = write_config(dir.path());
506
507 let mut request = base_request();
508 request.pack_path = Some(dir.path().to_path_buf());
509 request.pack_id = Some("dev.greentic.sample".into());
510 request.pack_version = Some("0.1.0".into());
511 request.pack_digest = Some("sha256:deadbeef".into());
512 request.distributor_url = Some("https://distributor.greentic.ai".into());
513 request.config_path = Some(cfg_path);
514
515 let err = DeployerConfig::resolve(request).unwrap_err();
516 assert!(
517 format!("{err}").contains("Offline"),
518 "expected offline validation error, got {err}"
519 );
520 }
521
522 #[test]
523 fn provider_output_dir_is_scoped_by_pack_path() {
524 let dir = tempdir().unwrap();
525 let first_pack = dir.path().join("bundle-a").join("packs").join("app.gtpack");
526 let second_pack = dir.path().join("bundle-b").join("packs").join("app.gtpack");
527 fs::create_dir_all(first_pack.parent().unwrap()).expect("create first pack dir");
528 fs::create_dir_all(second_pack.parent().unwrap()).expect("create second pack dir");
529 fs::write(&first_pack, "").expect("write first pack");
530 fs::write(&second_pack, "").expect("write second pack");
531
532 let mut first_request = base_request();
533 first_request.pack_path = Some(first_pack);
534 let first_config = DeployerConfig::resolve(first_request).expect("first config");
535
536 let mut second_request = base_request();
537 second_request.pack_path = Some(second_pack);
538 let second_config = DeployerConfig::resolve(second_request).expect("second config");
539
540 assert_ne!(
541 first_config.provider_output_dir(),
542 second_config.provider_output_dir()
543 );
544 assert_ne!(
545 first_config.runtime_output_dir(),
546 second_config.runtime_output_dir()
547 );
548 }
549}