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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20pub struct DeploymentTarget {
21 pub provider: String,
22 pub strategy: String,
23}
24
25#[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#[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
44pub 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
123pub 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
495pub 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#[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}