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: 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/// 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: 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        // A4b flipped `greentic-config::default_env_id()` from `dev` to `local`;
453        // the cargo-update bump in this PR pulled that change into the deployer's
454        // dep graph for the first time. The test name kept its historical form.
455        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}