Skip to main content

greentic_deployer/
extension_sources.rs

1use std::path::{Path, PathBuf};
2
3use crate::adapter::{MultiTargetKind, UnifiedTargetSelection};
4use crate::config::Provider;
5use crate::contract::{DeployerCapability, get_deployer_contract_v1};
6use crate::error::Result;
7use crate::extension::{
8    BuiltinBackendExecutionKind, DeploymentExtensionContract, DeploymentExtensionDescriptor,
9    DeploymentExtensionKind, DeploymentExtensionSourceKind, DeploymentHandlerDescriptor,
10};
11use crate::pack_introspect::{read_manifest_from_directory, read_manifest_from_gtpack};
12
13#[derive(Debug, Clone, Default)]
14pub struct DeploymentExtensionSourceOptions {
15    pub pack_paths: Vec<PathBuf>,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct PackDeploymentDispatch {
20    pub capability: DeployerCapability,
21    pub pack_id: String,
22    pub flow_id: String,
23    pub handler_id: String,
24}
25
26fn read_manifest(path: &Path) -> Result<greentic_types::pack_manifest::PackManifest> {
27    if path.is_dir() {
28        read_manifest_from_directory(path)
29    } else {
30        read_manifest_from_gtpack(path)
31    }
32}
33
34fn default_target_for_pack_contract() -> UnifiedTargetSelection {
35    UnifiedTargetSelection::MultiTarget(MultiTargetKind::Generic)
36}
37
38fn infer_provider_from_pack_id(pack_id: &str) -> Provider {
39    let normalized = pack_id.trim().to_ascii_lowercase();
40    if normalized.contains(".aws") || normalized.ends_with("aws") {
41        Provider::Aws
42    } else if normalized.contains(".azure") || normalized.ends_with("azure") {
43        Provider::Azure
44    } else if normalized.contains(".gcp") || normalized.ends_with("gcp") {
45        Provider::Gcp
46    } else if normalized.contains(".k8s") || normalized.ends_with("k8s") {
47        Provider::K8s
48    } else if normalized.contains(".local") || normalized.ends_with("local") {
49        Provider::Local
50    } else {
51        Provider::Generic
52    }
53}
54
55fn infer_target_for_provider(provider: Provider) -> UnifiedTargetSelection {
56    match provider {
57        Provider::Local => UnifiedTargetSelection::MultiTarget(MultiTargetKind::Local),
58        Provider::Aws => UnifiedTargetSelection::MultiTarget(MultiTargetKind::Aws),
59        Provider::Azure => UnifiedTargetSelection::MultiTarget(MultiTargetKind::Azure),
60        Provider::Gcp => UnifiedTargetSelection::MultiTarget(MultiTargetKind::Gcp),
61        Provider::K8s => UnifiedTargetSelection::MultiTarget(MultiTargetKind::K8s),
62        Provider::Generic => default_target_for_pack_contract(),
63    }
64}
65
66fn capabilities_from_contract(
67    contract: &crate::contract::DeployerContractV1,
68) -> Vec<DeployerCapability> {
69    let mut capabilities: Vec<DeployerCapability> = contract
70        .capabilities
71        .iter()
72        .map(|entry| entry.capability)
73        .collect();
74    if !capabilities.contains(&DeployerCapability::Plan) {
75        capabilities.push(DeployerCapability::Plan);
76    }
77    capabilities
78}
79
80pub fn resolve_pack_deployment_dispatch(
81    path: &Path,
82    capability: DeployerCapability,
83) -> Result<Option<PackDeploymentDispatch>> {
84    let manifest = read_manifest(path)?;
85    let pack_id = manifest.pack_id.to_string();
86    let handler_id = format!("pack.{pack_id}");
87
88    if let Some(contract) = get_deployer_contract_v1(&manifest)? {
89        let flow_id = match capability {
90            DeployerCapability::Plan => contract.planner.flow_id,
91            _ => contract
92                .capability(capability)
93                .map(|spec| spec.flow_id.clone())
94                .or_else(|| manifest.flows.first().map(|flow| flow.id.to_string()))
95                .ok_or_else(|| {
96                    crate::error::DeployerError::Config(format!(
97                        "deployment pack {} does not declare `{}` and has no fallback flows",
98                        pack_id,
99                        capability.as_str()
100                    ))
101                })?,
102        };
103        return Ok(Some(PackDeploymentDispatch {
104            capability,
105            pack_id,
106            flow_id,
107            handler_id,
108        }));
109    }
110
111    let Some(first_flow) = manifest.flows.first() else {
112        return Err(crate::error::DeployerError::Config(format!(
113            "deployment pack {} has no contract and no flows",
114            pack_id
115        )));
116    };
117
118    Ok(Some(PackDeploymentDispatch {
119        capability,
120        pack_id,
121        flow_id: first_flow.id.to_string(),
122        handler_id,
123    }))
124}
125
126fn load_pack_deployment_extension_contract(
127    path: &Path,
128) -> Result<Option<DeploymentExtensionContract>> {
129    let manifest = read_manifest(path)?;
130    let Some(contract) = get_deployer_contract_v1(&manifest)? else {
131        return Ok(None);
132    };
133    let pack_id = manifest.pack_id.to_string();
134    let handler_id = format!("pack.{pack_id}");
135    let capabilities = capabilities_from_contract(&contract);
136    let provider = infer_provider_from_pack_id(&pack_id);
137    Ok(Some(DeploymentExtensionContract {
138        source: DeploymentExtensionSourceKind::Pack,
139        extension: DeploymentExtensionDescriptor {
140            id: pack_id.clone(),
141            kind: DeploymentExtensionKind::Pack,
142            target: infer_target_for_provider(provider),
143            summary: format!(
144                "Deployment extension contract loaded from {}",
145                path.display()
146            ),
147        },
148        provider: Some(provider),
149        aliases: vec![pack_id.clone(), provider.as_str().to_string()],
150        handlers: vec![DeploymentHandlerDescriptor {
151            id: handler_id,
152            execution_kind: BuiltinBackendExecutionKind::Executable,
153            supported_capabilities: capabilities,
154        }],
155    }))
156}
157
158pub fn list_pack_deployment_extension_contracts(
159    options: &DeploymentExtensionSourceOptions,
160) -> Vec<DeploymentExtensionContract> {
161    options
162        .pack_paths
163        .iter()
164        .filter_map(|path| load_pack_deployment_extension_contract(path).ok().flatten())
165        .collect()
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use greentic_distributor_client::PackId;
172    use greentic_types::cbor::encode_pack_manifest;
173    use greentic_types::pack_manifest::{PackKind, PackManifest};
174    use semver::Version;
175
176    use crate::contract::{
177        CapabilitySpecV1, DeployerContractV1, PlannerSpecV1, set_deployer_contract_v1,
178    };
179
180    fn sample_contract() -> DeployerContractV1 {
181        DeployerContractV1 {
182            schema_version: 1,
183            planner: PlannerSpecV1 {
184                flow_id: "plan_flow".into(),
185                input_schema_ref: None,
186                output_schema_ref: None,
187                qa_spec_ref: None,
188            },
189            capabilities: vec![
190                CapabilitySpecV1 {
191                    capability: DeployerCapability::Plan,
192                    flow_id: "plan_flow".into(),
193                    input_schema_ref: None,
194                    output_schema_ref: None,
195                    execution_output_schema_ref: None,
196                    qa_spec_ref: None,
197                    example_refs: Vec::new(),
198                },
199                CapabilitySpecV1 {
200                    capability: DeployerCapability::Apply,
201                    flow_id: "apply_flow".into(),
202                    input_schema_ref: None,
203                    output_schema_ref: None,
204                    execution_output_schema_ref: None,
205                    qa_spec_ref: None,
206                    example_refs: Vec::new(),
207                },
208            ],
209        }
210    }
211
212    fn write_pack_dir_with_contract() -> tempfile::TempDir {
213        let dir = tempfile::tempdir().expect("tempdir");
214        let mut manifest = PackManifest {
215            schema_version: "pack-v1".to_string(),
216            pack_id: PackId::try_from("greentic.deploy.external").expect("pack id"),
217            name: None,
218            version: Version::new(0, 1, 0),
219            kind: PackKind::Provider,
220            publisher: "greentic".to_string(),
221            secret_requirements: Vec::new(),
222            components: Vec::new(),
223            flows: Vec::new(),
224            dependencies: Vec::new(),
225            capabilities: Vec::new(),
226            signatures: Default::default(),
227            bootstrap: None,
228            extensions: None,
229        };
230        set_deployer_contract_v1(&mut manifest, sample_contract()).expect("set contract");
231        let bytes = encode_pack_manifest(&manifest).expect("encode manifest");
232        std::fs::write(dir.path().join("manifest.cbor"), bytes).expect("write manifest");
233        dir
234    }
235
236    #[test]
237    fn pack_source_lists_extension_contract_from_pack_dir() {
238        let dir = write_pack_dir_with_contract();
239        let contracts =
240            list_pack_deployment_extension_contracts(&DeploymentExtensionSourceOptions {
241                pack_paths: vec![dir.path().to_path_buf()],
242            });
243        assert_eq!(contracts.len(), 1);
244        let contract = &contracts[0];
245        assert_eq!(contract.source, DeploymentExtensionSourceKind::Pack);
246        assert_eq!(contract.extension.id, "greentic.deploy.external");
247        assert_eq!(contract.provider, Some(Provider::Generic));
248        assert_eq!(
249            contract.aliases,
250            vec![
251                "greentic.deploy.external".to_string(),
252                "generic".to_string()
253            ]
254        );
255        assert_eq!(contract.handlers.len(), 1);
256        assert_eq!(contract.handlers[0].id, "pack.greentic.deploy.external");
257        assert!(
258            contract.handlers[0]
259                .supported_capabilities
260                .contains(&DeployerCapability::Plan)
261        );
262        assert!(
263            contract.handlers[0]
264                .supported_capabilities
265                .contains(&DeployerCapability::Apply)
266        );
267    }
268
269    #[test]
270    fn pack_source_ignores_paths_without_deployer_contract() {
271        let dir = tempfile::tempdir().expect("tempdir");
272        let manifest = PackManifest {
273            schema_version: "pack-v1".to_string(),
274            pack_id: PackId::try_from("greentic.no.contract").expect("pack id"),
275            name: None,
276            version: Version::new(0, 1, 0),
277            kind: PackKind::Provider,
278            publisher: "greentic".to_string(),
279            secret_requirements: Vec::new(),
280            components: Vec::new(),
281            flows: Vec::new(),
282            dependencies: Vec::new(),
283            capabilities: Vec::new(),
284            signatures: Default::default(),
285            bootstrap: None,
286            extensions: None,
287        };
288        let bytes = encode_pack_manifest(&manifest).expect("encode manifest");
289        std::fs::write(dir.path().join("manifest.cbor"), bytes).expect("write manifest");
290
291        let contracts =
292            list_pack_deployment_extension_contracts(&DeploymentExtensionSourceOptions {
293                pack_paths: vec![dir.path().to_path_buf()],
294            });
295        assert!(contracts.is_empty());
296    }
297
298    #[test]
299    fn pack_source_resolves_execution_dispatch_from_contract() {
300        let dir = write_pack_dir_with_contract();
301        let dispatch = resolve_pack_deployment_dispatch(dir.path(), DeployerCapability::Apply)
302            .expect("resolve dispatch")
303            .expect("dispatch");
304        assert_eq!(dispatch.pack_id, "greentic.deploy.external");
305        assert_eq!(dispatch.flow_id, "apply_flow");
306        assert_eq!(dispatch.handler_id, "pack.greentic.deploy.external");
307        assert_eq!(dispatch.capability, DeployerCapability::Apply);
308    }
309
310    #[test]
311    fn pack_source_infers_provider_and_target_from_pack_id() {
312        let dir = tempfile::tempdir().expect("tempdir");
313        let mut manifest = PackManifest {
314            schema_version: "pack-v1".to_string(),
315            pack_id: PackId::try_from("greentic.deploy.aws").expect("pack id"),
316            name: None,
317            version: Version::new(0, 1, 0),
318            kind: PackKind::Provider,
319            publisher: "greentic".to_string(),
320            secret_requirements: Vec::new(),
321            components: Vec::new(),
322            flows: Vec::new(),
323            dependencies: Vec::new(),
324            capabilities: Vec::new(),
325            signatures: Default::default(),
326            bootstrap: None,
327            extensions: None,
328        };
329        set_deployer_contract_v1(&mut manifest, sample_contract()).expect("set contract");
330        let bytes = encode_pack_manifest(&manifest).expect("encode manifest");
331        std::fs::write(dir.path().join("manifest.cbor"), bytes).expect("write manifest");
332
333        let contracts =
334            list_pack_deployment_extension_contracts(&DeploymentExtensionSourceOptions {
335                pack_paths: vec![dir.path().to_path_buf()],
336            });
337        let contract = &contracts[0];
338        assert_eq!(contract.provider, Some(Provider::Aws));
339        assert_eq!(
340            contract.extension.target,
341            UnifiedTargetSelection::MultiTarget(MultiTargetKind::Aws)
342        );
343        assert!(contract.aliases.iter().any(|alias| alias == "aws"));
344    }
345}