Skip to main content

greentic_deployer/
config.rs

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/// Supported deployment targets.
16#[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    /// The provider-oriented flow must stay outside the dedicated single-vm adapter path.
39    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/// Output format for plan rendering.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
50pub enum OutputFormat {
51    #[default]
52    Text,
53    Json,
54    Yaml,
55}
56
57/// Library-facing request used to resolve deployer configuration.
58#[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/// Complete configuration used by the deployer runtime.
129#[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}