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            agents: Default::default(),
216            schema_version: "pack-v1".to_string(),
217            pack_id: PackId::try_from("greentic.deploy.external").expect("pack id"),
218            name: None,
219            version: Version::new(0, 1, 0),
220            kind: PackKind::Provider,
221            publisher: "greentic".to_string(),
222            secret_requirements: Vec::new(),
223            components: Vec::new(),
224            flows: Vec::new(),
225            dependencies: Vec::new(),
226            capabilities: Vec::new(),
227            signatures: Default::default(),
228            bootstrap: None,
229            extensions: None,
230        };
231        set_deployer_contract_v1(&mut manifest, sample_contract()).expect("set contract");
232        let bytes = encode_pack_manifest(&manifest).expect("encode manifest");
233        std::fs::write(dir.path().join("manifest.cbor"), bytes).expect("write manifest");
234        dir
235    }
236
237    #[test]
238    fn pack_source_lists_extension_contract_from_pack_dir() {
239        let dir = write_pack_dir_with_contract();
240        let contracts =
241            list_pack_deployment_extension_contracts(&DeploymentExtensionSourceOptions {
242                pack_paths: vec![dir.path().to_path_buf()],
243            });
244        assert_eq!(contracts.len(), 1);
245        let contract = &contracts[0];
246        assert_eq!(contract.source, DeploymentExtensionSourceKind::Pack);
247        assert_eq!(contract.extension.id, "greentic.deploy.external");
248        assert_eq!(contract.provider, Some(Provider::Generic));
249        assert_eq!(
250            contract.aliases,
251            vec![
252                "greentic.deploy.external".to_string(),
253                "generic".to_string()
254            ]
255        );
256        assert_eq!(contract.handlers.len(), 1);
257        assert_eq!(contract.handlers[0].id, "pack.greentic.deploy.external");
258        assert!(
259            contract.handlers[0]
260                .supported_capabilities
261                .contains(&DeployerCapability::Plan)
262        );
263        assert!(
264            contract.handlers[0]
265                .supported_capabilities
266                .contains(&DeployerCapability::Apply)
267        );
268    }
269
270    #[test]
271    fn pack_source_ignores_paths_without_deployer_contract() {
272        let dir = tempfile::tempdir().expect("tempdir");
273        let manifest = PackManifest {
274            agents: Default::default(),
275            schema_version: "pack-v1".to_string(),
276            pack_id: PackId::try_from("greentic.no.contract").expect("pack id"),
277            name: None,
278            version: Version::new(0, 1, 0),
279            kind: PackKind::Provider,
280            publisher: "greentic".to_string(),
281            secret_requirements: Vec::new(),
282            components: Vec::new(),
283            flows: Vec::new(),
284            dependencies: Vec::new(),
285            capabilities: Vec::new(),
286            signatures: Default::default(),
287            bootstrap: None,
288            extensions: None,
289        };
290        let bytes = encode_pack_manifest(&manifest).expect("encode manifest");
291        std::fs::write(dir.path().join("manifest.cbor"), bytes).expect("write manifest");
292
293        let contracts =
294            list_pack_deployment_extension_contracts(&DeploymentExtensionSourceOptions {
295                pack_paths: vec![dir.path().to_path_buf()],
296            });
297        assert!(contracts.is_empty());
298    }
299
300    #[test]
301    fn pack_source_resolves_execution_dispatch_from_contract() {
302        let dir = write_pack_dir_with_contract();
303        let dispatch = resolve_pack_deployment_dispatch(dir.path(), DeployerCapability::Apply)
304            .expect("resolve dispatch")
305            .expect("dispatch");
306        assert_eq!(dispatch.pack_id, "greentic.deploy.external");
307        assert_eq!(dispatch.flow_id, "apply_flow");
308        assert_eq!(dispatch.handler_id, "pack.greentic.deploy.external");
309        assert_eq!(dispatch.capability, DeployerCapability::Apply);
310    }
311
312    #[test]
313    fn pack_source_infers_provider_and_target_from_pack_id() {
314        let dir = tempfile::tempdir().expect("tempdir");
315        let mut manifest = PackManifest {
316            agents: Default::default(),
317            schema_version: "pack-v1".to_string(),
318            pack_id: PackId::try_from("greentic.deploy.aws").expect("pack id"),
319            name: None,
320            version: Version::new(0, 1, 0),
321            kind: PackKind::Provider,
322            publisher: "greentic".to_string(),
323            secret_requirements: Vec::new(),
324            components: Vec::new(),
325            flows: Vec::new(),
326            dependencies: Vec::new(),
327            capabilities: Vec::new(),
328            signatures: Default::default(),
329            bootstrap: None,
330            extensions: None,
331        };
332        set_deployer_contract_v1(&mut manifest, sample_contract()).expect("set contract");
333        let bytes = encode_pack_manifest(&manifest).expect("encode manifest");
334        std::fs::write(dir.path().join("manifest.cbor"), bytes).expect("write manifest");
335
336        let contracts =
337            list_pack_deployment_extension_contracts(&DeploymentExtensionSourceOptions {
338                pack_paths: vec![dir.path().to_path_buf()],
339            });
340        let contract = &contracts[0];
341        assert_eq!(contract.provider, Some(Provider::Aws));
342        assert_eq!(
343            contract.extension.target,
344            UnifiedTargetSelection::MultiTarget(MultiTargetKind::Aws)
345        );
346        assert!(contract.aliases.iter().any(|alias| alias == "aws"));
347    }
348}