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}