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: 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: 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: 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 !request.pack_path.exists() && request.pack_id.is_none() {
171 return Err(DeployerError::Config(format!(
172 "pack path {} does not exist (and no pack_id provided)",
173 request.pack_path.display()
174 )));
175 }
176
177 let environment = env_id_to_string(
178 request
179 .environment
180 .clone()
181 .or_else(|| Some(greentic.environment.env_id.to_string())),
182 );
183
184 let pack_ref = build_pack_ref(
185 request.pack_id.as_deref(),
186 request.pack_version.as_deref(),
187 request.pack_digest.as_deref(),
188 )?;
189
190 validate_offline_policy(
191 greentic.environment.connection.as_ref(),
192 &pack_ref,
193 request.distributor_url.as_deref(),
194 request.allow_remote_in_offline,
195 )?;
196
197 if request.deploy_pack_id_override.is_some() ^ request.deploy_flow_id_override.is_some() {
198 return Err(DeployerError::Config(
199 "deploy_pack_id_override and deploy_flow_id_override must be set together"
200 .to_string(),
201 ));
202 }
203
204 Ok(Self {
205 capability: request.capability,
206 provider: request.provider,
207 strategy: request.strategy,
208 tenant: request.tenant,
209 environment,
210 pack_path: request.pack_path,
211 bundle_root: request.bundle_root,
212 bundle_source: request.bundle_source,
213 bundle_digest: request.bundle_digest,
214 repo_registry_base: request.repo_registry_base,
215 store_registry_base: request.store_registry_base,
216 providers_dir: request.providers_dir,
217 packs_dir: request.packs_dir,
218 provider_pack: request.provider_pack,
219 pack_ref,
220 distributor_url: request.distributor_url,
221 distributor_token: request.distributor_token,
222 preview: request.preview,
223 dry_run: request.dry_run,
224 execute_local: request.execute_local,
225 output: request.output,
226 greentic,
227 provenance: resolved.provenance,
228 config_warnings: resolved.warnings,
229 deploy_pack_id_override: request.deploy_pack_id_override,
230 deploy_flow_id_override: request.deploy_flow_id_override,
231 })
232 }
233
234 pub fn deploy_base(&self) -> PathBuf {
235 self.greentic.paths.state_dir.join("deploy")
236 }
237
238 pub fn runtime_base(&self) -> PathBuf {
239 self.greentic.paths.state_dir.join("runtime")
240 }
241
242 pub fn output_scope_key(&self) -> String {
243 scope_key_for_path(&self.pack_path)
244 }
245
246 pub fn provider_output_dir(&self) -> PathBuf {
247 self.deploy_base()
248 .join(self.provider.as_str())
249 .join(&self.tenant)
250 .join(&self.environment)
251 .join(self.output_scope_key())
252 }
253
254 pub fn runtime_output_dir(&self) -> PathBuf {
255 self.runtime_base()
256 .join(&self.tenant)
257 .join(&self.environment)
258 .join(self.output_scope_key())
259 }
260
261 pub fn telemetry_config(&self) -> &TelemetryConfig {
262 &self.greentic.telemetry
263 }
264
265 pub fn paths(&self) -> &PathsConfig {
266 &self.greentic.paths
267 }
268}
269
270fn scope_key_for_path(path: &std::path::Path) -> String {
271 let canonical = path
272 .canonicalize()
273 .unwrap_or_else(|_| path.to_path_buf())
274 .display()
275 .to_string();
276 let mut scoped = String::with_capacity(canonical.len());
277 for ch in canonical.chars() {
278 if ch.is_ascii_alphanumeric() {
279 scoped.push(ch.to_ascii_lowercase());
280 } else {
281 scoped.push('-');
282 }
283 }
284 while scoped.contains("--") {
285 scoped = scoped.replace("--", "-");
286 }
287 scoped.trim_matches('-').to_string()
288}
289
290fn load_explicit_config(path: Option<&PathBuf>) -> Result<Option<ConfigLayer>> {
291 let Some(path) = path else {
292 return Ok(None);
293 };
294
295 let contents = fs::read_to_string(path).map_err(|err| {
296 DeployerError::Config(format!(
297 "failed to read config file {}: {err}",
298 path.display()
299 ))
300 })?;
301
302 let format = match path.extension().and_then(|s| s.to_str()) {
303 Some("json") => ConfigFileFormat::Json,
304 _ => ConfigFileFormat::Toml,
305 };
306
307 let layer = match format {
308 ConfigFileFormat::Toml => toml::from_str::<ConfigLayer>(&contents)
309 .map_err(|err| format!("toml parse error: {err}")),
310 ConfigFileFormat::Json => serde_json::from_str::<ConfigLayer>(&contents)
311 .map_err(|err| format!("json parse error: {err}")),
312 }
313 .map_err(|err| {
314 DeployerError::Config(format!("invalid config file {}: {err}", path.display()))
315 })?;
316
317 Ok(Some(layer))
318}
319
320fn build_pack_ref(
321 pack_id: Option<&str>,
322 pack_version: Option<&str>,
323 pack_digest: Option<&str>,
324) -> Result<Option<PackRef>> {
325 let Some(pack_id) = pack_id else {
326 return Ok(None);
327 };
328 let version_str = pack_version.ok_or_else(|| {
329 DeployerError::Config("when using pack_id you must set pack_version".into())
330 })?;
331 let digest = pack_digest.ok_or_else(|| {
332 DeployerError::Config("when using pack_id you must set pack_digest".into())
333 })?;
334 let version = Version::parse(version_str).map_err(|err| {
335 DeployerError::Config(format!("invalid pack version '{}': {}", version_str, err))
336 })?;
337 Ok(Some(PackRef::new(
338 pack_id.to_string(),
339 version,
340 digest.to_string(),
341 )))
342}
343
344fn env_id_to_string(env_id: Option<String>) -> String {
345 env_id.unwrap_or_else(|| "dev".to_string())
346}
347
348fn validate_offline_policy(
349 connection: Option<&ConnectionKind>,
350 pack_ref: &Option<PackRef>,
351 distributor_url: Option<&str>,
352 allow_remote_in_offline: bool,
353) -> Result<()> {
354 if matches!(connection, Some(ConnectionKind::Offline))
355 && !allow_remote_in_offline
356 && (pack_ref.is_some() || distributor_url.is_some())
357 {
358 return Err(DeployerError::OfflineDisallowed(
359 "connection is Offline but remote pack/distributor requested; set allow_remote_in_offline to override".into(),
360 ));
361 }
362 Ok(())
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use std::fs;
369 use std::path::Path;
370 use tempfile::tempdir;
371
372 #[test]
373 fn provider_targets_stay_on_multi_target_adapter_family() {
374 for provider in [
375 Provider::Local,
376 Provider::Aws,
377 Provider::Azure,
378 Provider::Gcp,
379 Provider::K8s,
380 Provider::Generic,
381 ] {
382 assert_eq!(provider.adapter_family(), AdapterFamily::MultiTarget);
383 assert!(matches!(
384 provider.unified_target(),
385 UnifiedTargetSelection::MultiTarget(_)
386 ));
387 }
388 }
389
390 fn base_request() -> DeployerRequest {
391 DeployerRequest::new(
392 DeployerCapability::Plan,
393 Provider::Aws,
394 "acme",
395 PathBuf::from("examples/acme-pack"),
396 )
397 }
398
399 fn write_config(dir: &Path) -> PathBuf {
400 let cfg = r#"
401[environment]
402env_id = "prod"
403connection = "offline"
404
405[paths]
406greentic_root = "."
407state_dir = ".greentic/state"
408cache_dir = ".greentic/cache"
409logs_dir = ".greentic/logs"
410
411[telemetry]
412enabled = false
413
414[network]
415tls_mode = "system"
416
417[secrets]
418kind = "none"
419"#;
420 let path = dir.join("config.toml");
421 fs::write(&path, cfg).expect("write config");
422 path
423 }
424
425 #[test]
426 fn defaults_to_dev_environment_when_missing() {
427 let config = DeployerConfig::resolve(base_request()).expect("config builds");
428 assert_eq!(config.environment, "dev");
429 }
430
431 #[test]
432 fn accepts_explicit_environment_field() {
433 let mut request = base_request();
434 request.environment = Some("prod".into());
435 let config = DeployerConfig::resolve(request).expect("config builds");
436 assert_eq!(config.environment, "prod");
437 }
438
439 #[test]
440 fn rejects_pack_id_without_version_or_digest() {
441 let mut request = base_request();
442 request.pack_id = Some("dev.greentic.sample".into());
443 let err = DeployerConfig::resolve(request).unwrap_err();
444 assert!(
445 format!("{err}").contains("pack_version"),
446 "expected version requirement error, got {err}"
447 );
448 }
449
450 #[test]
451 fn builds_pack_ref_when_provided() {
452 let mut request = base_request();
453 request.pack_id = Some("dev.greentic.sample".into());
454 request.pack_version = Some("0.1.0".into());
455 request.pack_digest = Some("sha256:deadbeef".into());
456 let config = DeployerConfig::resolve(request).expect("config builds");
457 let pack_ref = config.pack_ref.expect("pack_ref present");
458 assert_eq!(pack_ref.oci_url, "dev.greentic.sample");
459 assert_eq!(pack_ref.version.to_string(), "0.1.0");
460 assert_eq!(pack_ref.digest, "sha256:deadbeef");
461 }
462
463 #[test]
464 fn explicit_config_file_overrides_default_env() {
465 let dir = tempdir().unwrap();
466 let cfg_path = write_config(dir.path());
467
468 let mut request = base_request();
469 request.config_path = Some(cfg_path);
470 let config = DeployerConfig::resolve(request).expect("config builds");
471 assert_eq!(config.greentic.environment.env_id.to_string(), "prod");
472 }
473
474 #[test]
475 fn offline_connection_blocks_remote_pack_without_override() {
476 let dir = tempdir().unwrap();
477 let cfg_path = write_config(dir.path());
478
479 let mut request = base_request();
480 request.pack_path = dir.path().to_path_buf();
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 request.distributor_url = Some("https://distributor.greentic.ai".into());
485 request.config_path = Some(cfg_path);
486
487 let err = DeployerConfig::resolve(request).unwrap_err();
488 assert!(
489 format!("{err}").contains("Offline"),
490 "expected offline validation error, got {err}"
491 );
492 }
493
494 #[test]
495 fn provider_output_dir_is_scoped_by_pack_path() {
496 let dir = tempdir().unwrap();
497 let first_pack = dir.path().join("bundle-a").join("packs").join("app.gtpack");
498 let second_pack = dir.path().join("bundle-b").join("packs").join("app.gtpack");
499 fs::create_dir_all(first_pack.parent().unwrap()).expect("create first pack dir");
500 fs::create_dir_all(second_pack.parent().unwrap()).expect("create second pack dir");
501 fs::write(&first_pack, "").expect("write first pack");
502 fs::write(&second_pack, "").expect("write second pack");
503
504 let mut first_request = base_request();
505 first_request.pack_path = first_pack;
506 let first_config = DeployerConfig::resolve(first_request).expect("first config");
507
508 let mut second_request = base_request();
509 second_request.pack_path = second_pack;
510 let second_config = DeployerConfig::resolve(second_request).expect("second config");
511
512 assert_ne!(
513 first_config.provider_output_dir(),
514 second_config.provider_output_dir()
515 );
516 assert_ne!(
517 first_config.runtime_output_dir(),
518 second_config.runtime_output_dir()
519 );
520 }
521}