Skip to main content

greentic_deployer/
deployment.rs

1use std::collections::{BTreeMap, HashMap};
2use std::env;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, RwLock};
6
7use crate::config::DeployerConfig;
8use crate::contract::{CloudTargetRequirementsV1, DeployerCapability, get_deployer_contract_v1};
9use crate::error::{DeployerError, Result};
10use crate::extension_sources::resolve_pack_deployment_dispatch;
11use crate::pack_introspect::{read_manifest_from_directory, read_manifest_from_gtpack};
12use crate::plan::PlanContext;
13use async_trait::async_trait;
14use greentic_types::pack_manifest::PackManifest;
15use once_cell::sync::Lazy;
16use serde::{Deserialize, Serialize};
17
18/// Logical deployment target keyed by provider + strategy.
19#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20pub struct DeploymentTarget {
21    pub provider: String,
22    pub strategy: String,
23}
24
25/// Dispatch details describing which deployment pack/flow to run.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct DeploymentDispatch {
28    pub capability: DeployerCapability,
29    pub pack_id: String,
30    pub flow_id: String,
31    pub handler_id: String,
32}
33
34/// Resolved deployment pack selection including discovered manifest.
35#[derive(Debug)]
36pub struct DeploymentPackSelection {
37    pub dispatch: DeploymentDispatch,
38    pub pack_path: PathBuf,
39    pub manifest: PackManifest,
40    pub origin: String,
41    pub candidates: Vec<String>,
42}
43
44/// Built-in provider/strategy defaults. Override them with `DEPLOY_TARGET_*` env vars as
45/// deployment packs become available in your environment.
46pub fn default_dispatch_table() -> HashMap<DeploymentTarget, DeploymentDispatch> {
47    let mut map = HashMap::new();
48    map.insert(
49        DeploymentTarget {
50            provider: "aws".into(),
51            strategy: "iac-only".into(),
52        },
53        DeploymentDispatch {
54            capability: DeployerCapability::Apply,
55            pack_id: "greentic.deploy.aws".into(),
56            flow_id: "deploy_aws_iac".into(),
57            handler_id: "builtin.aws".into(),
58        },
59    );
60    map.insert(
61        DeploymentTarget {
62            provider: "local".into(),
63            strategy: "iac-only".into(),
64        },
65        DeploymentDispatch {
66            capability: DeployerCapability::Apply,
67            pack_id: "greentic.deploy.local".into(),
68            flow_id: "deploy_local_iac".into(),
69            handler_id: "builtin.juju_machine".into(),
70        },
71    );
72    map.insert(
73        DeploymentTarget {
74            provider: "azure".into(),
75            strategy: "iac-only".into(),
76        },
77        DeploymentDispatch {
78            capability: DeployerCapability::Apply,
79            pack_id: "greentic.deploy.azure".into(),
80            flow_id: "deploy_azure_iac".into(),
81            handler_id: "builtin.azure".into(),
82        },
83    );
84    map.insert(
85        DeploymentTarget {
86            provider: "gcp".into(),
87            strategy: "iac-only".into(),
88        },
89        DeploymentDispatch {
90            capability: DeployerCapability::Apply,
91            pack_id: "greentic.deploy.gcp".into(),
92            flow_id: "deploy_gcp_iac".into(),
93            handler_id: "builtin.gcp".into(),
94        },
95    );
96    map.insert(
97        DeploymentTarget {
98            provider: "k8s".into(),
99            strategy: "iac-only".into(),
100        },
101        DeploymentDispatch {
102            capability: DeployerCapability::Apply,
103            pack_id: "greentic.deploy.k8s".into(),
104            flow_id: "deploy_k8s_iac".into(),
105            handler_id: "builtin.helm".into(),
106        },
107    );
108    map.insert(
109        DeploymentTarget {
110            provider: "generic".into(),
111            strategy: "iac-only".into(),
112        },
113        DeploymentDispatch {
114            capability: DeployerCapability::Apply,
115            pack_id: "greentic.deploy.generic".into(),
116            flow_id: "deploy_generic_iac".into(),
117            handler_id: "builtin.terraform".into(),
118        },
119    );
120    map
121}
122
123/// Resolve the dispatch entry for a target, honoring environment overrides.
124pub fn resolve_dispatch(target: &DeploymentTarget) -> Result<DeploymentDispatch> {
125    resolve_dispatch_with_env(target, |key| env::var(key).ok())
126}
127
128pub fn resolve_deployment_pack(
129    config: &DeployerConfig,
130    target: &DeploymentTarget,
131) -> Result<DeploymentPackSelection> {
132    resolve_deployment_pack_for_capability(config, target, config.capability)
133}
134
135pub fn resolve_deployment_pack_for_capability(
136    config: &DeployerConfig,
137    target: &DeploymentTarget,
138    capability: DeployerCapability,
139) -> Result<DeploymentPackSelection> {
140    let default_dispatch = if let Some(dispatch) = explicit_dispatch_override(config, capability)? {
141        dispatch
142    } else if let Some(dispatch) = dispatch_from_provider_pack(config, capability)? {
143        dispatch
144    } else {
145        resolve_dispatch(target)?
146    };
147    let mut discovery = find_pack_for_dispatch(config, target, &default_dispatch)?;
148    let dispatch = resolve_contract_dispatch(&discovery.manifest, capability, &default_dispatch)?;
149    ensure_flow_available(&dispatch, &discovery.manifest)?;
150    discovery.candidates.push(format!(
151        "capability={} flow={}",
152        dispatch.capability.as_str(),
153        dispatch.flow_id
154    ));
155    Ok(DeploymentPackSelection {
156        dispatch,
157        pack_path: discovery.pack_path,
158        manifest: discovery.manifest,
159        origin: discovery.origin,
160        candidates: discovery.candidates,
161    })
162}
163
164fn explicit_dispatch_override(
165    config: &DeployerConfig,
166    capability: DeployerCapability,
167) -> Result<Option<DeploymentDispatch>> {
168    match (
169        config.deploy_pack_id_override.as_ref(),
170        config.deploy_flow_id_override.as_ref(),
171    ) {
172        (Some(pack_id), Some(flow_id)) => Ok(Some(DeploymentDispatch {
173            capability,
174            pack_id: pack_id.clone(),
175            flow_id: flow_id.clone(),
176            handler_id: format!("override.{}", pack_id),
177        })),
178        (None, None) => Ok(None),
179        _ => Err(DeployerError::Config(
180            "deploy_pack_id_override and deploy_flow_id_override must be set together".to_string(),
181        )),
182    }
183}
184
185fn dispatch_from_provider_pack(
186    config: &DeployerConfig,
187    capability: DeployerCapability,
188) -> Result<Option<DeploymentDispatch>> {
189    let Some(path) = config.provider_pack.as_ref() else {
190        return Ok(None);
191    };
192    Ok(
193        resolve_pack_deployment_dispatch(path, capability)?.map(|dispatch| DeploymentDispatch {
194            capability: dispatch.capability,
195            pack_id: dispatch.pack_id,
196            flow_id: dispatch.flow_id,
197            handler_id: dispatch.handler_id,
198        }),
199    )
200}
201
202fn resolve_contract_dispatch(
203    manifest: &PackManifest,
204    capability: DeployerCapability,
205    fallback: &DeploymentDispatch,
206) -> Result<DeploymentDispatch> {
207    let Some(contract) = get_deployer_contract_v1(manifest)? else {
208        return Ok(DeploymentDispatch {
209            capability,
210            pack_id: fallback.pack_id.clone(),
211            flow_id: fallback.flow_id.clone(),
212            handler_id: fallback.handler_id.clone(),
213        });
214    };
215
216    let Some(spec) = contract.capability(capability) else {
217        return Err(DeployerError::Contract(format!(
218            "deployment pack {} does not declare `{}` capability in {}",
219            manifest.pack_id,
220            capability.as_str(),
221            crate::contract::EXT_DEPLOYER_V1
222        )));
223    };
224
225    Ok(DeploymentDispatch {
226        capability,
227        pack_id: manifest.pack_id.to_string(),
228        flow_id: spec.flow_id.clone(),
229        handler_id: fallback.handler_id.clone(),
230    })
231}
232
233fn resolve_dispatch_with_env<F>(target: &DeploymentTarget, get_env: F) -> Result<DeploymentDispatch>
234where
235    F: Fn(&str) -> Option<String>,
236{
237    if let Some(dispatch) = env_override(target, &get_env)? {
238        return Ok(dispatch);
239    }
240
241    let mut defaults = default_dispatch_table();
242    if let Some(dispatch) = defaults.remove(target) {
243        return Ok(dispatch);
244    }
245
246    Err(DeployerError::Config(format!(
247        "No deployment pack mapping for provider={} strategy={}. Configure DEPLOY_TARGET_{}_{}_PACK_ID / _FLOW_ID or extend the defaults.",
248        target.provider,
249        target.strategy,
250        sanitize_key(&target.provider),
251        sanitize_key(&target.strategy),
252    )))
253}
254
255fn env_override<F>(target: &DeploymentTarget, get_env: &F) -> Result<Option<DeploymentDispatch>>
256where
257    F: Fn(&str) -> Option<String>,
258{
259    let strategy_prefix = format!(
260        "DEPLOY_TARGET_{}_{}",
261        sanitize_key(&target.provider),
262        sanitize_key(&target.strategy)
263    );
264    if let Some(dispatch) = env_override_with_prefix(&strategy_prefix, get_env)? {
265        return Ok(Some(dispatch));
266    }
267    let provider_prefix = format!("DEPLOY_TARGET_{}", sanitize_key(&target.provider));
268    env_override_with_prefix(&provider_prefix, get_env)
269}
270
271fn env_override_with_prefix<F>(prefix: &str, get_env: &F) -> Result<Option<DeploymentDispatch>>
272where
273    F: Fn(&str) -> Option<String>,
274{
275    let pack_key = format!("{prefix}_PACK_ID");
276    let flow_key = format!("{prefix}_FLOW_ID");
277    let pack = get_env(&pack_key);
278    let flow = get_env(&flow_key);
279    match (pack, flow) {
280        (Some(pack_id), Some(flow_id)) => Ok(Some(DeploymentDispatch {
281            capability: DeployerCapability::Apply,
282            pack_id,
283            flow_id,
284            handler_id: format!("override.{}", prefix.to_ascii_lowercase()),
285        })),
286        (None, None) => Ok(None),
287        (Some(_), None) | (None, Some(_)) => Err(DeployerError::Config(format!(
288            "Incomplete deployment mapping overrides. Both {pack_key} and {flow_key} must be set."
289        ))),
290    }
291}
292
293struct SearchPath {
294    label: &'static str,
295    path: PathBuf,
296}
297
298struct PackDiscovery {
299    pack_path: PathBuf,
300    manifest: PackManifest,
301    origin: String,
302    candidates: Vec<String>,
303}
304
305fn find_pack_for_dispatch(
306    config: &DeployerConfig,
307    target: &DeploymentTarget,
308    dispatch: &DeploymentDispatch,
309) -> Result<PackDiscovery> {
310    if let Some(ref override_path) = config.provider_pack {
311        let manifest = load_manifest(override_path)?;
312        let actual = manifest.pack_id.to_string();
313        return Ok(PackDiscovery {
314            pack_path: override_path.clone(),
315            manifest,
316            origin: format!("override -> {}", override_path.display()),
317            candidates: vec![format!(
318                "{} (override {}, requested {})",
319                actual,
320                override_path.display(),
321                dispatch.pack_id
322            )],
323        });
324    }
325
326    if let Some((direct_path, manifest)) =
327        resolve_direct_pack_path(config, target).and_then(|direct_path| {
328            if !direct_path.exists() {
329                return None;
330            }
331            match load_manifest(&direct_path) {
332                Ok(manifest) if manifest.pack_id.to_string() == dispatch.pack_id => {
333                    Some((direct_path, manifest))
334                }
335                _ => None,
336            }
337        })
338    {
339        let candidate_display = direct_path.display().to_string();
340        let entry = format!("{} ({})", manifest.pack_id, candidate_display);
341        return Ok(PackDiscovery {
342            pack_path: direct_path.clone(),
343            manifest,
344            origin: format!("providers-dir -> {}", candidate_display),
345            candidates: vec![entry],
346        });
347    }
348
349    let search_paths = build_search_paths(config);
350    let mut candidates = Vec::new();
351    for search in &search_paths {
352        for candidate in gather_candidates(&search.path) {
353            if let Ok(manifest) = load_manifest(&candidate) {
354                let entry = format!("{} ({})", manifest.pack_id, candidate.display());
355                candidates.push(entry.clone());
356                if manifest.pack_id.to_string() == dispatch.pack_id {
357                    let candidate_display = candidate.display().to_string();
358                    let pack_path = candidate.clone();
359                    return Ok(PackDiscovery {
360                        pack_path,
361                        manifest,
362                        origin: format!("{} -> {}", search.label, candidate_display),
363                        candidates,
364                    });
365                }
366            }
367        }
368    }
369
370    let summary = build_search_summary(&search_paths);
371    Err(DeployerError::Config(format!(
372        "Deployment pack {} not found; searched {} (candidates: {})",
373        dispatch.pack_id,
374        summary,
375        if candidates.is_empty() {
376            "none".into()
377        } else {
378            candidates.join("; ")
379        }
380    )))
381}
382
383fn ensure_flow_available(dispatch: &DeploymentDispatch, manifest: &PackManifest) -> Result<()> {
384    let available: Vec<String> = manifest
385        .flows
386        .iter()
387        .map(|entry| entry.id.to_string())
388        .collect();
389    if available.iter().any(|flow| flow == &dispatch.flow_id) {
390        return Ok(());
391    }
392
393    Err(DeployerError::Config(format!(
394        "Flow {} not found in {} (available flows: {})",
395        dispatch.flow_id,
396        dispatch.pack_id,
397        if available.is_empty() {
398            "none".into()
399        } else {
400            available.join(", ")
401        }
402    )))
403}
404
405fn build_search_paths(config: &DeployerConfig) -> Vec<SearchPath> {
406    vec![
407        SearchPath {
408            label: "providers-dir",
409            path: config.providers_dir.clone(),
410        },
411        SearchPath {
412            label: "packs-dir",
413            path: config.packs_dir.clone(),
414        },
415        SearchPath {
416            label: "dist",
417            path: PathBuf::from("dist"),
418        },
419        SearchPath {
420            label: "examples",
421            path: PathBuf::from("examples"),
422        },
423    ]
424}
425
426fn resolve_direct_pack_path(config: &DeployerConfig, target: &DeploymentTarget) -> Option<PathBuf> {
427    direct_pack_candidates(config, target)
428        .into_iter()
429        .find(|path| path.exists())
430}
431
432fn direct_pack_candidates(config: &DeployerConfig, target: &DeploymentTarget) -> Vec<PathBuf> {
433    let mut candidates = Vec::new();
434    if let Some(filename) = provider_pack_filename_for_provider(config.provider) {
435        candidates.push(config.providers_dir.join(filename));
436    }
437    candidates.push(config.providers_dir.join(&target.provider));
438    candidates.push(
439        config
440            .providers_dir
441            .join(format!("{}.gtpack", target.provider.trim())),
442    );
443    candidates
444}
445
446fn provider_pack_filename_for_provider(provider: crate::config::Provider) -> Option<String> {
447    CloudTargetRequirementsV1::for_provider(provider)
448        .map(|requirements| requirements.provider_pack_filename)
449}
450
451fn gather_candidates(path: &Path) -> Vec<PathBuf> {
452    let mut candidates = Vec::new();
453    if let Ok(entries) = fs::read_dir(path) {
454        for entry in entries.flatten() {
455            let candidate = entry.path();
456            if candidate.is_dir()
457                || candidate.extension().and_then(|ext| ext.to_str()) == Some("gtpack")
458            {
459                candidates.push(candidate);
460            }
461        }
462    }
463    candidates
464}
465
466fn load_manifest(path: &Path) -> Result<PackManifest> {
467    if path.is_dir() {
468        read_manifest_from_directory(path)
469    } else {
470        read_manifest_from_gtpack(path)
471    }
472}
473
474fn build_search_summary(paths: &[SearchPath]) -> String {
475    paths
476        .iter()
477        .map(|entry| format!("{} ({})", entry.label, entry.path.display()))
478        .collect::<Vec<_>>()
479        .join(", ")
480}
481
482fn sanitize_key(input: &str) -> String {
483    input
484        .chars()
485        .map(|c| {
486            if c.is_ascii_alphanumeric() {
487                c.to_ascii_uppercase()
488            } else {
489                '_'
490            }
491        })
492        .collect()
493}
494
495/// Executes the resolved deployment pack via a registered executor.
496///
497/// Returns `Ok(true)` when an executor was registered and ran, `Ok(false)` when no executor is
498/// available yet, and `Err` on fatal failures.
499pub async fn execute_deployment_pack(
500    config: &DeployerConfig,
501    plan: &PlanContext,
502    dispatch: &DeploymentDispatch,
503) -> Result<Option<ExecutionOutcome>> {
504    if let Some(executor) = deployment_executor() {
505        let outcome = executor.execute(config, plan, dispatch).await?;
506        return Ok(Some(outcome));
507    }
508    tracing::info!(
509        capability = %dispatch.capability.as_str(),
510        provider = %plan.deployment.provider,
511        strategy = %plan.deployment.strategy,
512        pack_id = %dispatch.pack_id,
513        flow_id = %dispatch.flow_id,
514        handler_id = %dispatch.handler_id,
515        "deployment executor not registered"
516    );
517    Ok(None)
518}
519
520#[async_trait]
521pub trait DeploymentExecutor: Send + Sync {
522    async fn execute(
523        &self,
524        config: &DeployerConfig,
525        plan: &PlanContext,
526        dispatch: &DeploymentDispatch,
527    ) -> Result<ExecutionOutcome>;
528}
529
530#[derive(Debug, Clone, Default, PartialEq, Eq)]
531pub struct ExecutionOutcome {
532    pub status: Option<String>,
533    pub message: Option<String>,
534    pub output_files: Vec<String>,
535    pub payload: Option<ExecutionOutcomePayload>,
536}
537
538#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
539#[serde(tag = "kind", rename_all = "snake_case")]
540pub enum ExecutionOutcomePayload {
541    Apply(ApplyExecutionOutcome),
542    Destroy(DestroyExecutionOutcome),
543    Status(StatusExecutionOutcome),
544}
545
546#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
547pub struct ApplyExecutionOutcome {
548    pub deployment_id: String,
549    pub state: String,
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub provider: Option<String>,
552    #[serde(default, skip_serializing_if = "Option::is_none")]
553    pub strategy: Option<String>,
554    #[serde(default, skip_serializing_if = "Vec::is_empty")]
555    pub endpoints: Vec<String>,
556    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
557    pub output_refs: BTreeMap<String, String>,
558}
559
560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
561pub struct DestroyExecutionOutcome {
562    pub deployment_id: String,
563    pub state: String,
564    #[serde(default)]
565    pub destroyed_resources: Vec<String>,
566}
567
568#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
569pub struct StatusExecutionOutcome {
570    pub deployment_id: String,
571    pub state: String,
572    #[serde(default, skip_serializing_if = "Option::is_none")]
573    pub provider: Option<String>,
574    #[serde(default, skip_serializing_if = "Option::is_none")]
575    pub strategy: Option<String>,
576    #[serde(default, skip_serializing_if = "Option::is_none")]
577    pub status_source: Option<String>,
578    #[serde(default, skip_serializing_if = "Vec::is_empty")]
579    pub endpoints: Vec<String>,
580    #[serde(default, skip_serializing_if = "Vec::is_empty")]
581    pub health_checks: Vec<String>,
582    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
583    pub output_refs: BTreeMap<String, String>,
584}
585
586static EXECUTOR: Lazy<RwLock<Option<Arc<dyn DeploymentExecutor>>>> =
587    Lazy::new(|| RwLock::new(None));
588
589pub fn set_deployment_executor(executor: Arc<dyn DeploymentExecutor>) {
590    let mut slot = EXECUTOR.write().expect("deployment executor lock poisoned");
591    *slot = Some(executor);
592}
593
594#[cfg(test)]
595pub fn clear_deployment_executor() {
596    let mut slot = EXECUTOR.write().expect("deployment executor lock poisoned");
597    *slot = None;
598}
599
600/// Serializes tests that mutate the global `EXECUTOR` singleton.
601/// Must be held by every test in any module that calls
602/// `set_deployment_executor` or `clear_deployment_executor`.
603#[cfg(test)]
604pub static EXECUTOR_TEST_LOCK: once_cell::sync::Lazy<tokio::sync::Mutex<()>> =
605    once_cell::sync::Lazy::new(|| tokio::sync::Mutex::new(()));
606
607fn deployment_executor() -> Option<Arc<dyn DeploymentExecutor>> {
608    EXECUTOR
609        .read()
610        .expect("deployment executor lock poisoned")
611        .clone()
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use crate::config::{DeployerConfig, Provider};
618    use crate::contract::{
619        CapabilitySpecV1, DeployerCapability, DeployerContractV1, PlannerSpecV1,
620        set_deployer_contract_v1,
621    };
622    use crate::pack_introspect;
623    use greentic_types::cbor::encode_pack_manifest;
624    use greentic_types::component::{ComponentCapabilities, ComponentManifest, ComponentProfiles};
625    use greentic_types::flow::{Flow, FlowHasher, FlowKind, FlowMetadata};
626    use greentic_types::pack_manifest::{PackFlowEntry, PackKind, PackManifest};
627    use greentic_types::{ComponentId, FlowId, PackId};
628    use indexmap::IndexMap;
629    use semver::Version;
630    use std::path::PathBuf;
631    use std::sync::Arc;
632    use std::sync::atomic::{AtomicUsize, Ordering};
633
634    #[test]
635    fn resolves_default_entry() {
636        let target = DeploymentTarget {
637            provider: "generic".into(),
638            strategy: "iac-only".into(),
639        };
640        let dispatch = resolve_dispatch(&target).expect("default mapping");
641        assert_eq!(dispatch.pack_id, "greentic.deploy.generic");
642        assert_eq!(dispatch.flow_id, "deploy_generic_iac");
643        assert_eq!(dispatch.capability, DeployerCapability::Apply);
644    }
645
646    #[test]
647    fn honors_env_override() {
648        let target = DeploymentTarget {
649            provider: "aws".into(),
650            strategy: "serverless".into(),
651        };
652        let dispatch = resolve_dispatch_with_env(&target, |key| match key {
653            "DEPLOY_TARGET_AWS_SERVERLESS_PACK_ID" => Some("custom.pack".into()),
654            "DEPLOY_TARGET_AWS_SERVERLESS_FLOW_ID" => Some("flow_one".into()),
655            _ => None,
656        })
657        .expect("env mapping");
658        assert_eq!(dispatch.pack_id, "custom.pack");
659        assert_eq!(dispatch.flow_id, "flow_one");
660    }
661
662    #[test]
663    fn honors_provider_only_override() {
664        let target = DeploymentTarget {
665            provider: "aws".into(),
666            strategy: "serverless".into(),
667        };
668        let dispatch = resolve_dispatch_with_env(&target, |key| match key {
669            "DEPLOY_TARGET_AWS_PACK_ID" => Some("provider.pack".into()),
670            "DEPLOY_TARGET_AWS_FLOW_ID" => Some("provider_flow".into()),
671            _ => None,
672        })
673        .expect("provider fallback");
674        assert_eq!(dispatch.pack_id, "provider.pack");
675        assert_eq!(dispatch.flow_id, "provider_flow");
676    }
677
678    #[test]
679    fn errors_when_override_incomplete() {
680        let target = DeploymentTarget {
681            provider: "aws".into(),
682            strategy: "serverless".into(),
683        };
684        let err = resolve_dispatch_with_env(&target, |key| {
685            if key == "DEPLOY_TARGET_AWS_SERVERLESS_PACK_ID" {
686                Some("only-pack".into())
687            } else {
688                None
689            }
690        })
691        .expect_err("missing flow");
692        assert!(format!("{err}").contains("Incomplete deployment mapping overrides"));
693    }
694
695    struct TestExecutor {
696        hits: Arc<AtomicUsize>,
697    }
698
699    #[async_trait]
700    impl DeploymentExecutor for TestExecutor {
701        async fn execute(
702            &self,
703            _config: &DeployerConfig,
704            _plan: &PlanContext,
705            _dispatch: &DeploymentDispatch,
706        ) -> Result<ExecutionOutcome> {
707            self.hits.fetch_add(1, Ordering::SeqCst);
708            Ok(ExecutionOutcome {
709                status: Some("applied".into()),
710                message: Some("runner completed".into()),
711                output_files: vec!["result.json".into()],
712                payload: Some(ExecutionOutcomePayload::Apply(ApplyExecutionOutcome {
713                    deployment_id: "dep-123".into(),
714                    state: "ready".into(),
715                    provider: Some("aws".into()),
716                    strategy: Some("iac-only".into()),
717                    endpoints: vec!["https://deploy.example.test".into()],
718                    output_refs: BTreeMap::from([(
719                        "operator_endpoint".into(),
720                        "https://deploy.example.test".into(),
721                    )]),
722                })),
723            })
724        }
725    }
726
727    #[tokio::test]
728    async fn executes_via_registered_executor() {
729        let _guard = EXECUTOR_TEST_LOCK.lock().await;
730        clear_deployment_executor();
731        let hits = Arc::new(AtomicUsize::new(0));
732        set_deployment_executor(Arc::new(TestExecutor { hits: hits.clone() }));
733        let pack_path = write_test_pack();
734        let config = DeployerConfig {
735            capability: DeployerCapability::Plan,
736            provider: Provider::Aws,
737            strategy: "iac-only".into(),
738            tenant: "acme".into(),
739            environment: "staging".into(),
740            pack_path,
741            bundle_root: None,
742            providers_dir: PathBuf::from("providers/deployer"),
743            packs_dir: PathBuf::from("packs"),
744            provider_pack: None,
745            pack_ref: None,
746            distributor_url: None,
747            distributor_token: None,
748            preview: false,
749            dry_run: false,
750            execute_local: false,
751            output: crate::config::OutputFormat::Text,
752            greentic: greentic_config::ConfigResolver::new()
753                .load()
754                .expect("load default config")
755                .config,
756            provenance: greentic_config::ProvenanceMap::new(),
757            config_warnings: Vec::new(),
758            deploy_pack_id_override: None,
759            deploy_flow_id_override: None,
760            bundle_source: None,
761            bundle_digest: None,
762            repo_registry_base: None,
763            store_registry_base: None,
764        };
765        let plan = pack_introspect::build_plan(&config).expect("plan builds");
766        let dispatch = DeploymentDispatch {
767            capability: DeployerCapability::Apply,
768            pack_id: "test.pack".into(),
769            flow_id: "deploy_flow".into(),
770            handler_id: "pack.test.pack".into(),
771        };
772        let ran = execute_deployment_pack(&config, &plan, &dispatch)
773            .await
774            .expect("executor runs");
775        let outcome = ran.expect("outcome");
776        assert!(
777            hits.load(Ordering::SeqCst) >= 1,
778            "registered executor should be invoked at least once"
779        );
780        assert_eq!(outcome.status.as_deref(), Some("applied"));
781        assert_eq!(outcome.message.as_deref(), Some("runner completed"));
782        assert_eq!(outcome.output_files, vec!["result.json".to_string()]);
783        match outcome.payload.expect("payload") {
784            ExecutionOutcomePayload::Apply(payload) => {
785                assert_eq!(payload.deployment_id, "dep-123");
786                assert_eq!(payload.state, "ready");
787                assert_eq!(payload.provider.as_deref(), Some("aws"));
788                assert_eq!(payload.strategy.as_deref(), Some("iac-only"));
789                assert_eq!(payload.endpoints, vec!["https://deploy.example.test"]);
790                assert_eq!(
791                    payload
792                        .output_refs
793                        .get("operator_endpoint")
794                        .map(String::as_str),
795                    Some("https://deploy.example.test")
796                );
797            }
798            other => panic!("unexpected outcome payload: {:?}", other),
799        }
800        clear_deployment_executor();
801    }
802
803    #[allow(deprecated)]
804    fn write_test_pack() -> PathBuf {
805        write_test_pack_with_id("dev.greentic.sample")
806    }
807
808    #[allow(deprecated)]
809    fn write_test_pack_with_id(pack_id: &str) -> PathBuf {
810        let base = env::current_dir().expect("cwd").join("target/tmp-tests");
811        std::fs::create_dir_all(&base).expect("create tmp base");
812        let dir = tempfile::tempdir_in(base).expect("temp dir");
813        let manifest = PackManifest {
814            schema_version: "pack-v1".to_string(),
815            pack_id: PackId::try_from(pack_id).unwrap(),
816            name: None,
817            version: Version::new(0, 1, 0),
818            kind: PackKind::Application,
819            publisher: "greentic".to_string(),
820            secret_requirements: Vec::new(),
821            components: vec![ComponentManifest {
822                id: ComponentId::try_from("dev.greentic.component").unwrap(),
823                version: Version::new(0, 1, 0),
824                supports: Vec::new(),
825                world: "greentic:test/world".to_string(),
826                profiles: ComponentProfiles::default(),
827                capabilities: ComponentCapabilities::default(),
828                configurators: None,
829                operations: Vec::new(),
830                config_schema: None,
831                resources: Default::default(),
832                dev_flows: Default::default(),
833            }],
834            flows: Vec::new(),
835            dependencies: Vec::new(),
836            capabilities: Vec::new(),
837            signatures: Default::default(),
838            bootstrap: None,
839            extensions: None,
840        };
841        let bytes = encode_pack_manifest(&manifest).expect("encode manifest");
842        std::fs::write(dir.path().join("manifest.cbor"), bytes).expect("write manifest");
843        dir.into_path()
844    }
845
846    #[allow(deprecated)]
847    fn write_test_deployer_pack(
848        pack_id: &str,
849        flow_id: &str,
850        capability: DeployerCapability,
851    ) -> PathBuf {
852        let path = write_test_pack_with_id(pack_id);
853        let mut manifest = read_manifest_from_directory(&path).expect("read pack");
854        let flow_id_typed = FlowId::try_from(flow_id).expect("flow id");
855        manifest.flows = vec![PackFlowEntry {
856            id: flow_id_typed.clone(),
857            kind: FlowKind::Messaging,
858            flow: Flow {
859                schema_version: "flowir-v1".to_string(),
860                id: flow_id_typed.clone(),
861                kind: FlowKind::Messaging,
862                entrypoints: Default::default(),
863                nodes: IndexMap::<_, _, FlowHasher>::default(),
864                metadata: FlowMetadata::default(),
865            },
866            tags: Vec::new(),
867            entrypoints: Vec::new(),
868        }];
869        set_deployer_contract_v1(
870            &mut manifest,
871            DeployerContractV1 {
872                schema_version: 1,
873                planner: PlannerSpecV1 {
874                    flow_id: "plan_pack".to_string(),
875                    input_schema_ref: None,
876                    output_schema_ref: None,
877                    qa_spec_ref: None,
878                },
879                capabilities: vec![
880                    CapabilitySpecV1 {
881                        capability: DeployerCapability::Plan,
882                        flow_id: "plan_pack".to_string(),
883                        input_schema_ref: None,
884                        output_schema_ref: None,
885                        execution_output_schema_ref: None,
886                        qa_spec_ref: None,
887                        example_refs: Vec::new(),
888                    },
889                    CapabilitySpecV1 {
890                        capability,
891                        flow_id: flow_id.to_string(),
892                        input_schema_ref: None,
893                        output_schema_ref: None,
894                        execution_output_schema_ref: None,
895                        qa_spec_ref: None,
896                        example_refs: Vec::new(),
897                    },
898                ],
899            },
900        )
901        .expect("set contract");
902        let bytes = encode_pack_manifest(&manifest).expect("encode manifest");
903        std::fs::write(path.join("manifest.cbor"), bytes).expect("rewrite manifest");
904        path
905    }
906
907    #[test]
908    fn resolve_direct_pack_path_prefers_provider_specific_filename() {
909        let providers_dir = tempfile::tempdir().expect("tempdir");
910        let aws_pack = providers_dir.path().join("aws.gtpack");
911        std::fs::rename(
912            write_test_deployer_pack(
913                "greentic.deploy.aws",
914                "apply_pack",
915                DeployerCapability::Apply,
916            ),
917            &aws_pack,
918        )
919        .expect("move aws fixture");
920
921        let config = DeployerConfig {
922            capability: DeployerCapability::Apply,
923            provider: Provider::Aws,
924            strategy: "iac-only".into(),
925            tenant: "acme".into(),
926            environment: "staging".into(),
927            pack_path: write_test_pack(),
928            bundle_root: None,
929            providers_dir: providers_dir.path().to_path_buf(),
930            packs_dir: PathBuf::from("packs"),
931            provider_pack: None,
932            pack_ref: None,
933            distributor_url: None,
934            distributor_token: None,
935            preview: false,
936            dry_run: false,
937            execute_local: false,
938            output: crate::config::OutputFormat::Text,
939            greentic: greentic_config::ConfigResolver::new()
940                .load()
941                .expect("load default config")
942                .config,
943            provenance: greentic_config::ProvenanceMap::new(),
944            config_warnings: Vec::new(),
945            deploy_pack_id_override: None,
946            deploy_flow_id_override: None,
947            bundle_source: None,
948            bundle_digest: None,
949            repo_registry_base: None,
950            store_registry_base: None,
951        };
952        let target = DeploymentTarget {
953            provider: "aws".into(),
954            strategy: "iac-only".into(),
955        };
956
957        let resolved = resolve_direct_pack_path(&config, &target).expect("direct path");
958        assert_eq!(resolved, aws_pack);
959    }
960
961    #[test]
962    fn resolve_deployment_pack_uses_provider_specific_filename_without_override() {
963        let providers_dir = tempfile::tempdir().expect("tempdir");
964        let aws_pack = providers_dir.path().join("aws.gtpack");
965        std::fs::rename(
966            write_test_deployer_pack(
967                "greentic.deploy.aws",
968                "apply_pack",
969                DeployerCapability::Apply,
970            ),
971            &aws_pack,
972        )
973        .expect("move aws fixture");
974
975        let config = DeployerConfig {
976            capability: DeployerCapability::Apply,
977            provider: Provider::Aws,
978            strategy: "iac-only".into(),
979            tenant: "acme".into(),
980            environment: "staging".into(),
981            pack_path: write_test_pack(),
982            bundle_root: None,
983            providers_dir: providers_dir.path().to_path_buf(),
984            packs_dir: PathBuf::from("packs"),
985            provider_pack: None,
986            pack_ref: None,
987            distributor_url: None,
988            distributor_token: None,
989            preview: false,
990            dry_run: false,
991            execute_local: false,
992            output: crate::config::OutputFormat::Text,
993            greentic: greentic_config::ConfigResolver::new()
994                .load()
995                .expect("load default config")
996                .config,
997            provenance: greentic_config::ProvenanceMap::new(),
998            config_warnings: Vec::new(),
999            deploy_pack_id_override: None,
1000            deploy_flow_id_override: None,
1001            bundle_source: None,
1002            bundle_digest: None,
1003            repo_registry_base: None,
1004            store_registry_base: None,
1005        };
1006        let target = DeploymentTarget {
1007            provider: "aws".into(),
1008            strategy: "iac-only".into(),
1009        };
1010
1011        let resolved =
1012            resolve_deployment_pack_for_capability(&config, &target, DeployerCapability::Apply)
1013                .expect("resolve deployment pack");
1014        assert_eq!(resolved.dispatch.pack_id, "greentic.deploy.aws");
1015        assert_eq!(resolved.dispatch.flow_id, "apply_pack");
1016        assert_eq!(resolved.pack_path, aws_pack);
1017        assert!(resolved.origin.contains("providers-dir"));
1018    }
1019
1020    #[test]
1021    fn contract_owned_capability_flow_overrides_default_flow() {
1022        let manifest = PackManifest {
1023            schema_version: "pack-v1".to_string(),
1024            pack_id: PackId::try_from("greentic.deploy.aws").unwrap(),
1025            name: None,
1026            version: Version::new(0, 1, 0),
1027            kind: PackKind::Provider,
1028            publisher: "greentic".to_string(),
1029            secret_requirements: Vec::new(),
1030            components: vec![],
1031            flows: vec![],
1032            dependencies: Vec::new(),
1033            capabilities: Vec::new(),
1034            signatures: Default::default(),
1035            bootstrap: None,
1036            extensions: None,
1037        };
1038        let mut manifest = manifest;
1039        set_deployer_contract_v1(
1040            &mut manifest,
1041            DeployerContractV1 {
1042                schema_version: 1,
1043                planner: PlannerSpecV1 {
1044                    flow_id: "plan_pack".into(),
1045                    input_schema_ref: None,
1046                    output_schema_ref: None,
1047                    qa_spec_ref: None,
1048                },
1049                capabilities: vec![
1050                    CapabilitySpecV1 {
1051                        capability: DeployerCapability::Plan,
1052                        flow_id: "plan_pack".into(),
1053                        input_schema_ref: None,
1054                        output_schema_ref: None,
1055                        execution_output_schema_ref: None,
1056                        qa_spec_ref: None,
1057                        example_refs: Vec::new(),
1058                    },
1059                    CapabilitySpecV1 {
1060                        capability: DeployerCapability::Apply,
1061                        flow_id: "apply_pack".into(),
1062                        input_schema_ref: None,
1063                        output_schema_ref: None,
1064                        execution_output_schema_ref: None,
1065                        qa_spec_ref: None,
1066                        example_refs: Vec::new(),
1067                    },
1068                    CapabilitySpecV1 {
1069                        capability: DeployerCapability::Destroy,
1070                        flow_id: "destroy_pack".into(),
1071                        input_schema_ref: None,
1072                        output_schema_ref: None,
1073                        execution_output_schema_ref: None,
1074                        qa_spec_ref: None,
1075                        example_refs: Vec::new(),
1076                    },
1077                ],
1078            },
1079        )
1080        .unwrap();
1081
1082        let fallback = DeploymentDispatch {
1083            capability: DeployerCapability::Apply,
1084            pack_id: "greentic.deploy.aws".into(),
1085            flow_id: "deploy_aws_iac".into(),
1086            handler_id: "builtin.aws".into(),
1087        };
1088        let resolved =
1089            resolve_contract_dispatch(&manifest, DeployerCapability::Destroy, &fallback).unwrap();
1090        assert_eq!(resolved.pack_id, "greentic.deploy.aws");
1091        assert_eq!(resolved.flow_id, "destroy_pack");
1092        assert_eq!(resolved.capability, DeployerCapability::Destroy);
1093    }
1094
1095    #[test]
1096    fn missing_contract_capability_errors() {
1097        let mut manifest = PackManifest {
1098            schema_version: "pack-v1".to_string(),
1099            pack_id: PackId::try_from("greentic.deploy.aws").unwrap(),
1100            name: None,
1101            version: Version::new(0, 1, 0),
1102            kind: PackKind::Provider,
1103            publisher: "greentic".to_string(),
1104            secret_requirements: Vec::new(),
1105            components: vec![],
1106            flows: vec![],
1107            dependencies: Vec::new(),
1108            capabilities: Vec::new(),
1109            signatures: Default::default(),
1110            bootstrap: None,
1111            extensions: None,
1112        };
1113        set_deployer_contract_v1(
1114            &mut manifest,
1115            DeployerContractV1 {
1116                schema_version: 1,
1117                planner: PlannerSpecV1 {
1118                    flow_id: "plan_pack".into(),
1119                    input_schema_ref: None,
1120                    output_schema_ref: None,
1121                    qa_spec_ref: None,
1122                },
1123                capabilities: vec![CapabilitySpecV1 {
1124                    capability: DeployerCapability::Plan,
1125                    flow_id: "plan_pack".into(),
1126                    input_schema_ref: None,
1127                    output_schema_ref: None,
1128                    execution_output_schema_ref: None,
1129                    qa_spec_ref: None,
1130                    example_refs: Vec::new(),
1131                }],
1132            },
1133        )
1134        .unwrap();
1135
1136        let fallback = DeploymentDispatch {
1137            capability: DeployerCapability::Apply,
1138            pack_id: "greentic.deploy.aws".into(),
1139            flow_id: "deploy_aws_iac".into(),
1140            handler_id: "builtin.aws".into(),
1141        };
1142        let err = resolve_contract_dispatch(&manifest, DeployerCapability::Rollback, &fallback)
1143            .unwrap_err();
1144        assert!(format!("{err}").contains("does not declare `rollback` capability"));
1145    }
1146}