1use std::collections::BTreeSet;
2use std::env;
3use std::fs;
4use std::io::Read;
5use std::path::Path;
6
7use greentic_types::pack_manifest::{ExtensionInline, ExtensionRef, PackManifest};
8use serde::{Deserialize, Serialize};
9use serde_json::Value as JsonValue;
10
11use crate::Provider;
12use crate::error::{DeployerError, Result};
13use crate::pack_introspect::read_entry_from_gtpack;
14
15pub const EXT_DEPLOYER_V1: &str = "greentic.deployer.v1";
16pub const EXT_DEPLOYER_CONTRACT_V1: &str = "greentic.deployer.contract.v1";
17pub const EXT_DEPLOY_AWS: &str = "greentic.deploy-aws";
18pub const EXT_DEPLOY_AZURE: &str = "greentic.deploy-azure";
19pub const EXT_DEPLOY_GCP: &str = "greentic.deploy-gcp";
20pub const DEFAULT_GHCR_OPERATOR_IMAGE: &str = "ghcr.io/greenticai/greentic-start-distroless@sha256:91ee172e104cebbd263ee85cafdad54f453b08b9c78b4c60fd4f14a061a6ed7a";
21pub const DEFAULT_GCP_OPERATOR_IMAGE: &str = "europe-west1-docker.pkg.dev/x-plateau-483512-p6/greentic-images/greentic-start-distroless@sha256:5f7e4b70271c09b2a099e2c6d5c8641cbdb5a20698dcbba0e3b0f90a0f3e0e48";
22pub const DEFAULT_OPERATOR_IMAGE_DIGEST: &str =
23 "sha256:91ee172e104cebbd263ee85cafdad54f453b08b9c78b4c60fd4f14a061a6ed7a";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum DeployerCapability {
28 Generate,
29 Plan,
30 Apply,
31 Destroy,
32 Status,
33 Rollback,
34}
35
36impl DeployerCapability {
37 pub fn as_str(&self) -> &'static str {
38 match self {
39 Self::Generate => "generate",
40 Self::Plan => "plan",
41 Self::Apply => "apply",
42 Self::Destroy => "destroy",
43 Self::Status => "status",
44 Self::Rollback => "rollback",
45 }
46 }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum CloudCredentialKind {
52 AwsAccessKey,
53 AwsProfile,
54 AwsWebIdentity,
55 AzureClientSecret,
56 AzureOidc,
57 GcpApplicationCredentials,
58 GcpAccessToken,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum PromptFieldKindV1 {
64 Required,
65 Optional,
66 Secret,
67 OptionalSecret,
68 Static,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct PromptFieldSpecV1 {
73 pub env_name: String,
74 pub prompt: String,
75 pub kind: PromptFieldKindV1,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub static_value: Option<String>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct CredentialRequirementV1 {
82 pub kind: CloudCredentialKind,
83 pub label: String,
84 pub env_vars: Vec<String>,
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
86 pub satisfaction_env_groups: Vec<Vec<String>>,
87 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub prompt_fields: Vec<PromptFieldSpecV1>,
89 pub help: String,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct VariableRequirementV1 {
94 pub name: String,
95 #[serde(default)]
96 pub required: bool,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub prompt: Option<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub default_value: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub description: Option<String>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct CloudTargetRequirementsV1 {
107 pub target: String,
108 pub target_label: String,
109 pub provider_pack_filename: String,
110 pub remote_bundle_source_required: bool,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub remote_bundle_source_help: Option<String>,
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
114 pub informational_notes: Vec<String>,
115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub credential_requirements: Vec<CredentialRequirementV1>,
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
118 pub variable_requirements: Vec<VariableRequirementV1>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct CloudDeployerExtensionDescriptorV1 {
123 pub extension_id: String,
124 pub extension_version: String,
125 pub provider: String,
126 pub deployer_pack_id: String,
127 pub provider_pack_filename: String,
128 pub target_id: String,
129}
130
131impl CloudTargetRequirementsV1 {
132 pub fn aws() -> Self {
133 Self {
134 target: "aws".to_string(),
135 target_label: "AWS".to_string(),
136 provider_pack_filename: "aws.gtpack".to_string(),
137 remote_bundle_source_required: true,
138 remote_bundle_source_help: Some(
139 "Pass --deploy-bundle-source https://.../bundle.gtbundle or set GREENTIC_DEPLOY_BUNDLE_SOURCE"
140 .to_string(),
141 ),
142 informational_notes: vec!["Internal AWS bootstrap now handles admin TLS server secrets"
143 .to_string()],
144 credential_requirements: vec![
145 CredentialRequirementV1 {
146 kind: CloudCredentialKind::AwsAccessKey,
147 label: "Access key pair".to_string(),
148 env_vars: vec![
149 "AWS_ACCESS_KEY_ID".to_string(),
150 "AWS_SECRET_ACCESS_KEY".to_string(),
151 ],
152 satisfaction_env_groups: vec![vec![
153 "AWS_ACCESS_KEY_ID".to_string(),
154 "AWS_SECRET_ACCESS_KEY".to_string(),
155 ]],
156 prompt_fields: vec![
157 PromptFieldSpecV1 {
158 env_name: "AWS_ACCESS_KEY_ID".to_string(),
159 prompt: "AWS access key ID:".to_string(),
160 kind: PromptFieldKindV1::Required,
161 static_value: None,
162 },
163 PromptFieldSpecV1 {
164 env_name: "AWS_SECRET_ACCESS_KEY".to_string(),
165 prompt: "AWS secret access key:".to_string(),
166 kind: PromptFieldKindV1::Secret,
167 static_value: None,
168 },
169 PromptFieldSpecV1 {
170 env_name: "AWS_SESSION_TOKEN".to_string(),
171 prompt: "AWS session token (optional):".to_string(),
172 kind: PromptFieldKindV1::OptionalSecret,
173 static_value: None,
174 },
175 PromptFieldSpecV1 {
176 env_name: "AWS_DEFAULT_REGION".to_string(),
177 prompt: "AWS default region:".to_string(),
178 kind: PromptFieldKindV1::Static,
179 static_value: Some("eu-north-1".to_string()),
180 },
181 ],
182 help: "AWS access key credentials".to_string(),
183 },
184 CredentialRequirementV1 {
185 kind: CloudCredentialKind::AwsProfile,
186 env_vars: vec!["AWS_PROFILE".to_string(), "AWS_DEFAULT_PROFILE".to_string()],
187 label: "AWS profile".to_string(),
188 satisfaction_env_groups: vec![
189 vec!["AWS_PROFILE".to_string()],
190 vec!["AWS_DEFAULT_PROFILE".to_string()],
191 ],
192 prompt_fields: vec![
193 PromptFieldSpecV1 {
194 env_name: "AWS_PROFILE".to_string(),
195 prompt: "AWS profile:".to_string(),
196 kind: PromptFieldKindV1::Required,
197 static_value: None,
198 },
199 PromptFieldSpecV1 {
200 env_name: "AWS_DEFAULT_REGION".to_string(),
201 prompt: "AWS default region:".to_string(),
202 kind: PromptFieldKindV1::Static,
203 static_value: Some("eu-north-1".to_string()),
204 },
205 ],
206 help: "AWS shared profile credentials".to_string(),
207 },
208 CredentialRequirementV1 {
209 kind: CloudCredentialKind::AwsWebIdentity,
210 label: "Web identity token file".to_string(),
211 env_vars: vec!["AWS_WEB_IDENTITY_TOKEN_FILE".to_string()],
212 satisfaction_env_groups: vec![vec!["AWS_WEB_IDENTITY_TOKEN_FILE".to_string()]],
213 prompt_fields: vec![
214 PromptFieldSpecV1 {
215 env_name: "AWS_WEB_IDENTITY_TOKEN_FILE".to_string(),
216 prompt: "AWS web identity token file:".to_string(),
217 kind: PromptFieldKindV1::Required,
218 static_value: None,
219 },
220 PromptFieldSpecV1 {
221 env_name: "AWS_ROLE_ARN".to_string(),
222 prompt: "AWS role ARN (optional):".to_string(),
223 kind: PromptFieldKindV1::Optional,
224 static_value: None,
225 },
226 ],
227 help: "AWS web identity credentials".to_string(),
228 },
229 ],
230 variable_requirements: vec![
231 VariableRequirementV1 {
232 name: "GREENTIC_DEPLOY_TERRAFORM_VAR_REMOTE_STATE_BACKEND".to_string(),
233 required: true,
234 prompt: Some("Terraform remote state backend:".to_string()),
235 default_value: Some("s3".to_string()),
236 description: Some("Terraform remote state backend".to_string()),
237 },
238 VariableRequirementV1 {
239 name: "GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE".to_string(),
240 required: false,
241 prompt: None,
242 default_value: Some(DEFAULT_GHCR_OPERATOR_IMAGE.to_string()),
243 description: Some("Optional operator image override".to_string()),
244 },
245 VariableRequirementV1 {
246 name: "GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE_DIGEST".to_string(),
247 required: false,
248 prompt: None,
249 default_value: Some(DEFAULT_OPERATOR_IMAGE_DIGEST.to_string()),
250 description: Some("Optional operator image digest override".to_string()),
251 },
252 VariableRequirementV1 {
253 name: "GREENTIC_DEPLOY_TERRAFORM_VAR_REDIS_URL".to_string(),
254 required: false,
255 prompt: Some(
256 "Shared Redis URL (recommended for cloud webchat/state):".to_string(),
257 ),
258 default_value: None,
259 description: Some(
260 "Optional shared Redis URL for multi-instance state (for example redis://host:6379/0)"
261 .to_string(),
262 ),
263 },
264 VariableRequirementV1 {
265 name: "GREENTIC_DEPLOY_TERRAFORM_VAR_DNS_NAME".to_string(),
266 required: false,
267 prompt: None,
268 default_value: None,
269 description: Some("Optional personalized DNS name".to_string()),
270 },
271 ],
272 }
273 }
274
275 pub fn azure() -> Self {
276 Self {
277 target: "azure".to_string(),
278 target_label: "Azure".to_string(),
279 provider_pack_filename: "azure.gtpack".to_string(),
280 remote_bundle_source_required: true,
281 remote_bundle_source_help: Some(
282 "Pass --deploy-bundle-source https://.../bundle.gtbundle or set GREENTIC_DEPLOY_BUNDLE_SOURCE"
283 .to_string(),
284 ),
285 informational_notes: Vec::new(),
286 credential_requirements: vec![
287 CredentialRequirementV1 {
288 kind: CloudCredentialKind::AzureClientSecret,
289 label: "ARM service principal".to_string(),
290 env_vars: vec![
291 "ARM_CLIENT_ID".to_string(),
292 "ARM_TENANT_ID".to_string(),
293 "ARM_SUBSCRIPTION_ID".to_string(),
294 ],
295 satisfaction_env_groups: vec![vec![
296 "ARM_CLIENT_ID".to_string(),
297 "ARM_TENANT_ID".to_string(),
298 "ARM_SUBSCRIPTION_ID".to_string(),
299 "ARM_CLIENT_SECRET".to_string(),
300 ]],
301 prompt_fields: vec![
302 PromptFieldSpecV1 {
303 env_name: "ARM_SUBSCRIPTION_ID".to_string(),
304 prompt: "Azure subscription ID:".to_string(),
305 kind: PromptFieldKindV1::Required,
306 static_value: None,
307 },
308 PromptFieldSpecV1 {
309 env_name: "ARM_TENANT_ID".to_string(),
310 prompt: "Azure tenant ID:".to_string(),
311 kind: PromptFieldKindV1::Required,
312 static_value: None,
313 },
314 PromptFieldSpecV1 {
315 env_name: "ARM_CLIENT_ID".to_string(),
316 prompt: "Azure client ID:".to_string(),
317 kind: PromptFieldKindV1::Required,
318 static_value: None,
319 },
320 PromptFieldSpecV1 {
321 env_name: "ARM_CLIENT_SECRET".to_string(),
322 prompt: "Azure client secret:".to_string(),
323 kind: PromptFieldKindV1::Secret,
324 static_value: None,
325 },
326 ],
327 help: "Azure ARM client-secret style credentials".to_string(),
328 },
329 CredentialRequirementV1 {
330 kind: CloudCredentialKind::AzureOidc,
331 label: "Azure OIDC".to_string(),
332 env_vars: vec![
333 "ARM_USE_OIDC".to_string(),
334 "AZURE_CLIENT_ID".to_string(),
335 "AZURE_TENANT_ID".to_string(),
336 "AZURE_SUBSCRIPTION_ID".to_string(),
337 ],
338 satisfaction_env_groups: vec![
339 vec![
340 "ARM_CLIENT_ID".to_string(),
341 "ARM_TENANT_ID".to_string(),
342 "ARM_SUBSCRIPTION_ID".to_string(),
343 "ARM_USE_OIDC".to_string(),
344 ],
345 vec![
346 "AZURE_CLIENT_ID".to_string(),
347 "AZURE_TENANT_ID".to_string(),
348 "AZURE_SUBSCRIPTION_ID".to_string(),
349 ],
350 ],
351 prompt_fields: vec![
352 PromptFieldSpecV1 {
353 env_name: "ARM_SUBSCRIPTION_ID".to_string(),
354 prompt: "Azure subscription ID:".to_string(),
355 kind: PromptFieldKindV1::Required,
356 static_value: None,
357 },
358 PromptFieldSpecV1 {
359 env_name: "ARM_TENANT_ID".to_string(),
360 prompt: "Azure tenant ID:".to_string(),
361 kind: PromptFieldKindV1::Required,
362 static_value: None,
363 },
364 PromptFieldSpecV1 {
365 env_name: "ARM_CLIENT_ID".to_string(),
366 prompt: "Azure client ID:".to_string(),
367 kind: PromptFieldKindV1::Required,
368 static_value: None,
369 },
370 PromptFieldSpecV1 {
371 env_name: "ARM_USE_OIDC".to_string(),
372 prompt: String::new(),
373 kind: PromptFieldKindV1::Static,
374 static_value: Some("true".to_string()),
375 },
376 ],
377 help: "Azure OIDC credentials".to_string(),
378 },
379 ],
380 variable_requirements: vec![
381 VariableRequirementV1 {
382 name: "GREENTIC_DEPLOY_TERRAFORM_VAR_REMOTE_STATE_BACKEND".to_string(),
383 required: true,
384 prompt: Some("Terraform remote state backend:".to_string()),
385 default_value: Some("azurerm".to_string()),
386 description: Some("Terraform remote state backend".to_string()),
387 },
388 VariableRequirementV1 {
389 name: "GREENTIC_DEPLOY_TERRAFORM_VAR_AZURE_KEY_VAULT_ID".to_string(),
390 required: true,
391 prompt: Some("Azure Key Vault resource ID:".to_string()),
392 default_value: None,
393 description: Some("Azure Key Vault resource ID".to_string()),
394 },
395 VariableRequirementV1 {
396 name: "GREENTIC_DEPLOY_TERRAFORM_VAR_AZURE_LOCATION".to_string(),
397 required: true,
398 prompt: Some("Azure location:".to_string()),
399 default_value: Some("westeurope".to_string()),
400 description: Some("Azure location".to_string()),
401 },
402 VariableRequirementV1 {
403 name: "GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE".to_string(),
404 required: false,
405 prompt: None,
406 default_value: Some(DEFAULT_GHCR_OPERATOR_IMAGE.to_string()),
407 description: Some("Optional operator image override".to_string()),
408 },
409 VariableRequirementV1 {
410 name: "GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE_DIGEST".to_string(),
411 required: false,
412 prompt: None,
413 default_value: Some(DEFAULT_OPERATOR_IMAGE_DIGEST.to_string()),
414 description: Some("Optional operator image digest override".to_string()),
415 },
416 ],
417 }
418 }
419
420 pub fn gcp() -> Self {
421 Self {
422 target: "gcp".to_string(),
423 target_label: "GCP".to_string(),
424 provider_pack_filename: "gcp.gtpack".to_string(),
425 remote_bundle_source_required: true,
426 remote_bundle_source_help: Some(
427 "Pass --deploy-bundle-source https://.../bundle.gtbundle or set GREENTIC_DEPLOY_BUNDLE_SOURCE"
428 .to_string(),
429 ),
430 informational_notes: Vec::new(),
431 credential_requirements: vec![
432 CredentialRequirementV1 {
433 kind: CloudCredentialKind::GcpApplicationCredentials,
434 label: "Service account credentials file".to_string(),
435 env_vars: vec!["GOOGLE_APPLICATION_CREDENTIALS".to_string()],
436 satisfaction_env_groups: vec![vec![
437 "GOOGLE_APPLICATION_CREDENTIALS".to_string(),
438 ]],
439 prompt_fields: vec![PromptFieldSpecV1 {
440 env_name: "GOOGLE_APPLICATION_CREDENTIALS".to_string(),
441 prompt: "GOOGLE_APPLICATION_CREDENTIALS path:".to_string(),
442 kind: PromptFieldKindV1::Required,
443 static_value: None,
444 }],
445 help: "GCP application credentials JSON".to_string(),
446 },
447 CredentialRequirementV1 {
448 kind: CloudCredentialKind::GcpAccessToken,
449 label: "Access token".to_string(),
450 env_vars: vec![
451 "GOOGLE_OAUTH_ACCESS_TOKEN".to_string(),
452 "CLOUDSDK_AUTH_ACCESS_TOKEN".to_string(),
453 ],
454 satisfaction_env_groups: vec![
455 vec!["GOOGLE_OAUTH_ACCESS_TOKEN".to_string()],
456 vec!["CLOUDSDK_AUTH_ACCESS_TOKEN".to_string()],
457 ],
458 prompt_fields: vec![PromptFieldSpecV1 {
459 env_name: "CLOUDSDK_AUTH_ACCESS_TOKEN".to_string(),
460 prompt: "GCP access token:".to_string(),
461 kind: PromptFieldKindV1::Secret,
462 static_value: None,
463 }],
464 help: "GCP access token credentials".to_string(),
465 },
466 ],
467 variable_requirements: vec![
468 VariableRequirementV1 {
469 name: "GREENTIC_DEPLOY_TERRAFORM_VAR_REMOTE_STATE_BACKEND".to_string(),
470 required: true,
471 prompt: Some("Terraform remote state backend:".to_string()),
472 default_value: Some("gcs".to_string()),
473 description: Some("Terraform remote state backend".to_string()),
474 },
475 VariableRequirementV1 {
476 name: "GREENTIC_DEPLOY_TERRAFORM_VAR_GCP_PROJECT_ID".to_string(),
477 required: true,
478 prompt: Some("GCP project ID:".to_string()),
479 default_value: None,
480 description: Some("GCP project ID".to_string()),
481 },
482 VariableRequirementV1 {
483 name: "GREENTIC_DEPLOY_TERRAFORM_VAR_GCP_REGION".to_string(),
484 required: true,
485 prompt: Some("GCP region:".to_string()),
486 default_value: Some("us-central1".to_string()),
487 description: Some("GCP region".to_string()),
488 },
489 VariableRequirementV1 {
490 name: "GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE".to_string(),
491 required: false,
492 prompt: None,
493 default_value: Some(DEFAULT_GCP_OPERATOR_IMAGE.to_string()),
494 description: Some("Optional operator image override".to_string()),
495 },
496 VariableRequirementV1 {
497 name: "GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE_DIGEST".to_string(),
498 required: false,
499 prompt: None,
500 default_value: Some(DEFAULT_OPERATOR_IMAGE_DIGEST.to_string()),
501 description: Some("Optional operator image digest override".to_string()),
502 },
503 ],
504 }
505 }
506
507 pub fn for_provider(provider: Provider) -> Option<Self> {
508 let mut requirements = match provider {
509 Provider::Aws => Some(Self::aws()),
510 Provider::Azure => Some(Self::azure()),
511 Provider::Gcp => Some(Self::gcp()),
512 Provider::Local | Provider::K8s | Provider::Generic => None,
513 }?;
514 apply_operator_image_defaults_for_provider(&mut requirements, provider);
515 Some(requirements)
516 }
517}
518
519impl CloudDeployerExtensionDescriptorV1 {
520 pub fn for_provider(provider: Provider) -> Option<Self> {
521 let (extension_id, target_id, deployer_pack_id) = match provider {
522 Provider::Aws => (
523 EXT_DEPLOY_AWS,
524 "aws-ecs-fargate-local",
525 "greentic.deploy.aws",
526 ),
527 Provider::Azure => (
528 EXT_DEPLOY_AZURE,
529 "azure-container-apps-local",
530 "greentic.deploy.azure",
531 ),
532 Provider::Gcp => (EXT_DEPLOY_GCP, "gcp-cloud-run-local", "greentic.deploy.gcp"),
533 Provider::Local | Provider::K8s | Provider::Generic => return None,
534 };
535 let requirements = CloudTargetRequirementsV1::for_provider(provider)?;
536 Some(Self {
537 extension_id: extension_id.to_string(),
538 extension_version: "0.1.0".to_string(),
539 provider: provider.as_str().to_string(),
540 deployer_pack_id: deployer_pack_id.to_string(),
541 provider_pack_filename: requirements.provider_pack_filename,
542 target_id: target_id.to_string(),
543 })
544 }
545}
546
547fn apply_operator_image_defaults_for_provider(
548 requirements: &mut CloudTargetRequirementsV1,
549 provider: Provider,
550) {
551 let operator_image_default = operator_image_default_for_provider(provider);
552 for requirement in &mut requirements.variable_requirements {
553 match requirement.name.as_str() {
554 "GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE" => {
555 requirement.default_value = Some(operator_image_default.to_string());
556 }
557 "GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE_DIGEST" => {
558 requirement.default_value = Some(DEFAULT_OPERATOR_IMAGE_DIGEST.to_string());
559 }
560 _ => {}
561 }
562 }
563}
564
565fn operator_image_default_for_provider(provider: Provider) -> &'static str {
566 match operator_image_source_for_provider(provider) {
567 OperatorImageSource::Ghcr => DEFAULT_GHCR_OPERATOR_IMAGE,
568 OperatorImageSource::GcpArtifactRegistry => DEFAULT_GCP_OPERATOR_IMAGE,
569 }
570}
571
572fn operator_image_source_for_provider(provider: Provider) -> OperatorImageSource {
573 let env_name = format!(
574 "GREENTIC_DEPLOY_DEFAULT_OPERATOR_IMAGE_SOURCE_{}",
575 provider.as_str().to_ascii_uppercase().replace('-', "_")
576 );
577 operator_image_source_for_provider_override(provider, non_empty_env_var(&env_name).as_deref())
578}
579
580fn operator_image_source_for_provider_override(
581 provider: Provider,
582 override_value: Option<&str>,
583) -> OperatorImageSource {
584 match override_value {
585 Some("gcp-artifact-registry") => OperatorImageSource::GcpArtifactRegistry,
586 Some("ghcr") => OperatorImageSource::Ghcr,
587 _ if provider == Provider::Gcp => OperatorImageSource::GcpArtifactRegistry,
588 _ => OperatorImageSource::Ghcr,
589 }
590}
591
592fn non_empty_env_var(name: &str) -> Option<String> {
593 env::var(name)
594 .ok()
595 .map(|value| value.trim().to_string())
596 .filter(|value| !value.is_empty())
597}
598
599#[derive(Debug, Clone, Copy, PartialEq, Eq)]
600enum OperatorImageSource {
601 Ghcr,
602 GcpArtifactRegistry,
603}
604
605#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
606pub struct DeployerContractV1 {
607 pub schema_version: u32,
608 pub planner: PlannerSpecV1,
609 pub capabilities: Vec<CapabilitySpecV1>,
610}
611
612impl DeployerContractV1 {
613 pub fn validate(&self) -> Result<()> {
614 if self.schema_version != 1 {
615 return Err(DeployerError::Contract(format!(
616 "unsupported {} schema_version {}",
617 EXT_DEPLOYER_V1, self.schema_version
618 )));
619 }
620 self.planner.validate()?;
621
622 let mut seen = BTreeSet::new();
623 for capability in &self.capabilities {
624 capability.validate()?;
625 if !seen.insert(capability.capability) {
626 return Err(DeployerError::Contract(format!(
627 "duplicate capability `{}` in {}",
628 capability.capability.as_str(),
629 EXT_DEPLOYER_V1
630 )));
631 }
632 }
633
634 if !seen.contains(&DeployerCapability::Plan) {
635 return Err(DeployerError::Contract(format!(
636 "{} must declare the `plan` capability",
637 EXT_DEPLOYER_V1
638 )));
639 }
640
641 Ok(())
642 }
643
644 pub fn capability(&self, capability: DeployerCapability) -> Option<&CapabilitySpecV1> {
645 self.capabilities
646 .iter()
647 .find(|entry| entry.capability == capability)
648 }
649}
650
651#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
652pub struct PlannerSpecV1 {
653 pub flow_id: String,
654 #[serde(default, skip_serializing_if = "Option::is_none")]
655 pub input_schema_ref: Option<String>,
656 #[serde(default, skip_serializing_if = "Option::is_none")]
657 pub output_schema_ref: Option<String>,
658 #[serde(default, skip_serializing_if = "Option::is_none")]
659 pub qa_spec_ref: Option<String>,
660}
661
662impl PlannerSpecV1 {
663 fn validate(&self) -> Result<()> {
664 if self.flow_id.trim().is_empty() {
665 return Err(DeployerError::Contract(format!(
666 "{} planner.flow_id must not be empty",
667 EXT_DEPLOYER_V1
668 )));
669 }
670 Ok(())
671 }
672}
673
674#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
675pub struct CapabilitySpecV1 {
676 pub capability: DeployerCapability,
677 pub flow_id: String,
678 #[serde(default, skip_serializing_if = "Option::is_none")]
679 pub input_schema_ref: Option<String>,
680 #[serde(default, skip_serializing_if = "Option::is_none")]
681 pub output_schema_ref: Option<String>,
682 #[serde(default, skip_serializing_if = "Option::is_none")]
683 pub execution_output_schema_ref: Option<String>,
684 #[serde(default, skip_serializing_if = "Option::is_none")]
685 pub qa_spec_ref: Option<String>,
686 #[serde(default, skip_serializing_if = "Vec::is_empty")]
687 pub example_refs: Vec<String>,
688}
689
690impl CapabilitySpecV1 {
691 fn validate(&self) -> Result<()> {
692 if self.flow_id.trim().is_empty() {
693 return Err(DeployerError::Contract(format!(
694 "{} capability `{}` has empty flow_id",
695 EXT_DEPLOYER_V1,
696 self.capability.as_str()
697 )));
698 }
699 Ok(())
700 }
701}
702
703#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
704pub struct ContractAsset {
705 pub path: String,
706 #[serde(default, skip_serializing_if = "Option::is_none")]
707 pub json: Option<JsonValue>,
708 #[serde(default, skip_serializing_if = "Option::is_none")]
709 pub text: Option<String>,
710 pub size_bytes: usize,
711}
712
713#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
714pub struct ResolvedCapabilityContract {
715 pub capability: DeployerCapability,
716 pub flow_id: String,
717 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub input_schema: Option<ContractAsset>,
719 #[serde(default, skip_serializing_if = "Option::is_none")]
720 pub output_schema: Option<ContractAsset>,
721 #[serde(default, skip_serializing_if = "Option::is_none")]
722 pub execution_output_schema: Option<ContractAsset>,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
724 pub qa_spec: Option<ContractAsset>,
725 #[serde(default, skip_serializing_if = "Vec::is_empty")]
726 pub examples: Vec<ContractAsset>,
727}
728
729#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
730pub struct ResolvedPlannerContract {
731 pub flow_id: String,
732 #[serde(default, skip_serializing_if = "Option::is_none")]
733 pub input_schema: Option<ContractAsset>,
734 #[serde(default, skip_serializing_if = "Option::is_none")]
735 pub output_schema: Option<ContractAsset>,
736 #[serde(default, skip_serializing_if = "Option::is_none")]
737 pub qa_spec: Option<ContractAsset>,
738}
739
740#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
741pub struct ResolvedDeployerContract {
742 pub schema_version: u32,
743 pub planner: ResolvedPlannerContract,
744 #[serde(default, skip_serializing_if = "Vec::is_empty")]
745 pub capabilities: Vec<ResolvedCapabilityContract>,
746}
747
748pub fn get_deployer_contract_v1(manifest: &PackManifest) -> Result<Option<DeployerContractV1>> {
749 let extension = manifest.extensions.as_ref().and_then(|extensions| {
750 extensions
751 .get(EXT_DEPLOYER_CONTRACT_V1)
752 .or_else(|| extensions.get(EXT_DEPLOYER_V1))
753 });
754 let inline = match extension.and_then(|entry| entry.inline.as_ref()) {
755 Some(ExtensionInline::Other(value)) => value,
756 Some(_) => {
757 return Err(DeployerError::Contract(format!(
758 "{} inline payload has unexpected type",
759 EXT_DEPLOYER_V1
760 )));
761 }
762 None => return Ok(None),
763 };
764
765 let payload: DeployerContractV1 = serde_json::from_value(inline.clone()).map_err(|err| {
766 DeployerError::Contract(format!("{} deserialize failed: {}", EXT_DEPLOYER_V1, err))
767 })?;
768 payload.validate()?;
769 Ok(Some(payload))
770}
771
772pub fn set_deployer_contract_v1(
773 manifest: &mut PackManifest,
774 contract: DeployerContractV1,
775) -> Result<()> {
776 contract.validate()?;
777 let inline = serde_json::to_value(&contract).map_err(|err| {
778 DeployerError::Contract(format!("{} serialize failed: {}", EXT_DEPLOYER_V1, err))
779 })?;
780 let extensions = manifest.extensions.get_or_insert_with(Default::default);
781 extensions.insert(
782 EXT_DEPLOYER_CONTRACT_V1.to_string(),
783 ExtensionRef {
784 kind: EXT_DEPLOYER_CONTRACT_V1.to_string(),
785 version: "1.0.0".to_string(),
786 digest: None,
787 location: None,
788 inline: Some(ExtensionInline::Other(inline)),
789 },
790 );
791 Ok(())
792}
793
794pub fn set_cloud_deployer_extension_ref(
795 manifest: &mut PackManifest,
796 provider: Provider,
797) -> Result<()> {
798 let descriptor =
799 CloudDeployerExtensionDescriptorV1::for_provider(provider).ok_or_else(|| {
800 DeployerError::Contract(format!(
801 "cloud deployer extension is not defined for provider {}",
802 provider.as_str()
803 ))
804 })?;
805 let inline = serde_json::to_value(&descriptor).map_err(|err| {
806 DeployerError::Contract(format!(
807 "{} serialize failed: {}",
808 descriptor.extension_id, err
809 ))
810 })?;
811 let extensions = manifest.extensions.get_or_insert_with(Default::default);
812 extensions.insert(
813 descriptor.extension_id.clone(),
814 ExtensionRef {
815 kind: descriptor.extension_id,
816 version: descriptor.extension_version,
817 digest: None,
818 location: None,
819 inline: Some(ExtensionInline::Other(inline)),
820 },
821 );
822 Ok(())
823}
824
825pub fn read_pack_asset(pack_path: &Path, asset_ref: &str) -> Result<Vec<u8>> {
826 let relative = Path::new(asset_ref);
827 if relative.is_absolute() || asset_ref.contains("..") {
828 return Err(DeployerError::Contract(format!(
829 "pack asset ref must stay pack-relative: {}",
830 asset_ref
831 )));
832 }
833
834 if pack_path.is_dir() {
835 return fs::read(pack_path.join(relative)).map_err(DeployerError::Io);
836 }
837
838 read_entry_from_gtpack(pack_path, relative)
839}
840
841pub fn copy_pack_subtree(
842 pack_path: &Path,
843 subtree_ref: &str,
844 destination_root: &Path,
845) -> Result<Vec<String>> {
846 let subtree = Path::new(subtree_ref);
847 if subtree.is_absolute() || subtree_ref.contains("..") {
848 return Err(DeployerError::Contract(format!(
849 "pack subtree ref must stay pack-relative: {}",
850 subtree_ref
851 )));
852 }
853
854 if pack_path.is_dir() {
855 return copy_pack_subtree_from_dir(pack_path, subtree, destination_root);
856 }
857
858 copy_pack_subtree_from_gtpack(pack_path, subtree, destination_root)
859}
860
861fn copy_pack_subtree_from_dir(
862 pack_root: &Path,
863 subtree: &Path,
864 destination_root: &Path,
865) -> Result<Vec<String>> {
866 let source_root = pack_root.join(subtree);
867 if !source_root.exists() {
868 return Ok(Vec::new());
869 }
870
871 let mut copied = Vec::new();
872 copy_dir_recursive(&source_root, &source_root, destination_root, &mut copied)?;
873 copied.sort();
874 Ok(copied)
875}
876
877fn copy_dir_recursive(
878 current: &Path,
879 source_root: &Path,
880 destination_root: &Path,
881 copied: &mut Vec<String>,
882) -> Result<()> {
883 for entry in fs::read_dir(current).map_err(DeployerError::Io)? {
884 let entry = entry.map_err(DeployerError::Io)?;
885 let path = entry.path();
886 if path.is_dir() {
887 copy_dir_recursive(&path, source_root, destination_root, copied)?;
888 continue;
889 }
890
891 let relative = path.strip_prefix(source_root).map_err(|err| {
892 DeployerError::Contract(format!(
893 "failed to relativize {} under {}: {}",
894 path.display(),
895 source_root.display(),
896 err
897 ))
898 })?;
899 let destination = destination_root.join(relative);
900 if let Some(parent) = destination.parent() {
901 fs::create_dir_all(parent).map_err(DeployerError::Io)?;
902 }
903 fs::copy(&path, &destination).map_err(DeployerError::Io)?;
904 copied.push(relative.display().to_string());
905 }
906 Ok(())
907}
908
909fn copy_pack_subtree_from_gtpack(
910 pack_path: &Path,
911 subtree: &Path,
912 destination_root: &Path,
913) -> Result<Vec<String>> {
914 match copy_pack_subtree_from_tar_gtpack(pack_path, subtree, destination_root) {
915 Ok(copied) => Ok(copied),
916 Err(DeployerError::Io(err)) if err.kind() == std::io::ErrorKind::InvalidData => {
917 copy_pack_subtree_from_zip_gtpack(pack_path, subtree, destination_root)
918 }
919 Err(DeployerError::Io(err)) if err.kind() == std::io::ErrorKind::Other => {
920 copy_pack_subtree_from_zip_gtpack(pack_path, subtree, destination_root)
921 }
922 Err(err) => Err(err),
923 }
924}
925
926fn copy_pack_subtree_from_tar_gtpack(
927 pack_path: &Path,
928 subtree: &Path,
929 destination_root: &Path,
930) -> Result<Vec<String>> {
931 let file = fs::File::open(pack_path).map_err(DeployerError::Io)?;
932 let mut archive = tar::Archive::new(file);
933 let mut copied = Vec::new();
934
935 for entry in archive.entries().map_err(DeployerError::Io)? {
936 let mut entry = entry.map_err(DeployerError::Io)?;
937 let entry_path = entry.path().map_err(DeployerError::Io)?.into_owned();
938 if !entry_path.starts_with(subtree) || entry.header().entry_type().is_dir() {
939 continue;
940 }
941
942 let relative = entry_path.strip_prefix(subtree).map_err(|err| {
943 DeployerError::Contract(format!(
944 "failed to relativize {} under {}: {}",
945 entry_path.display(),
946 subtree.display(),
947 err
948 ))
949 })?;
950 let destination = destination_root.join(relative);
951 if let Some(parent) = destination.parent() {
952 fs::create_dir_all(parent).map_err(DeployerError::Io)?;
953 }
954 let mut bytes = Vec::new();
955 entry.read_to_end(&mut bytes).map_err(DeployerError::Io)?;
956 fs::write(&destination, bytes).map_err(DeployerError::Io)?;
957 copied.push(relative.display().to_string());
958 }
959
960 copied.sort();
961 Ok(copied)
962}
963
964fn copy_pack_subtree_from_zip_gtpack(
965 pack_path: &Path,
966 subtree: &Path,
967 destination_root: &Path,
968) -> Result<Vec<String>> {
969 let file = fs::File::open(pack_path).map_err(DeployerError::Io)?;
970 let mut archive = zip::ZipArchive::new(file).map_err(|err| {
971 DeployerError::Contract(format!(
972 "failed to open zip pack {}: {err}",
973 pack_path.display()
974 ))
975 })?;
976 let mut copied = Vec::new();
977
978 for idx in 0..archive.len() {
979 let mut entry = archive.by_index(idx).map_err(|err| {
980 DeployerError::Contract(format!(
981 "failed to read zip entry {idx} in {}: {err}",
982 pack_path.display()
983 ))
984 })?;
985 let Some(entry_name) = entry.enclosed_name().map(|path| path.to_path_buf()) else {
986 continue;
987 };
988 if !entry_name.starts_with(subtree) || entry.is_dir() {
989 continue;
990 }
991
992 let relative = entry_name.strip_prefix(subtree).map_err(|err| {
993 DeployerError::Contract(format!(
994 "failed to relativize {} under {}: {}",
995 entry_name.display(),
996 subtree.display(),
997 err
998 ))
999 })?;
1000 let destination = destination_root.join(relative);
1001 if let Some(parent) = destination.parent() {
1002 fs::create_dir_all(parent).map_err(DeployerError::Io)?;
1003 }
1004 let mut bytes = Vec::new();
1005 entry.read_to_end(&mut bytes).map_err(DeployerError::Io)?;
1006 fs::write(&destination, bytes).map_err(DeployerError::Io)?;
1007 copied.push(relative.display().to_string());
1008 }
1009
1010 copied.sort();
1011 Ok(copied)
1012}
1013
1014pub fn resolve_deployer_contract_assets(
1015 manifest: &PackManifest,
1016 pack_path: &Path,
1017) -> Result<Option<ResolvedDeployerContract>> {
1018 let Some(contract) = get_deployer_contract_v1(manifest)? else {
1019 return Ok(None);
1020 };
1021
1022 let planner = ResolvedPlannerContract {
1023 flow_id: contract.planner.flow_id.clone(),
1024 input_schema: load_optional_asset(pack_path, contract.planner.input_schema_ref.as_deref())?,
1025 output_schema: load_optional_asset(
1026 pack_path,
1027 contract.planner.output_schema_ref.as_deref(),
1028 )?,
1029 qa_spec: load_optional_asset(pack_path, contract.planner.qa_spec_ref.as_deref())?,
1030 };
1031
1032 let mut capabilities = Vec::new();
1033 for capability in &contract.capabilities {
1034 capabilities.push(ResolvedCapabilityContract {
1035 capability: capability.capability,
1036 flow_id: capability.flow_id.clone(),
1037 input_schema: load_optional_asset(pack_path, capability.input_schema_ref.as_deref())?,
1038 output_schema: load_optional_asset(pack_path, capability.output_schema_ref.as_deref())?,
1039 execution_output_schema: load_optional_asset(
1040 pack_path,
1041 capability.execution_output_schema_ref.as_deref(),
1042 )?,
1043 qa_spec: load_optional_asset(pack_path, capability.qa_spec_ref.as_deref())?,
1044 examples: capability
1045 .example_refs
1046 .iter()
1047 .map(|path| load_contract_asset(pack_path, path))
1048 .collect::<Result<Vec<_>>>()?,
1049 });
1050 }
1051
1052 Ok(Some(ResolvedDeployerContract {
1053 schema_version: contract.schema_version,
1054 planner,
1055 capabilities,
1056 }))
1057}
1058
1059fn load_optional_asset(pack_path: &Path, asset_ref: Option<&str>) -> Result<Option<ContractAsset>> {
1060 asset_ref
1061 .map(|asset_ref| load_contract_asset(pack_path, asset_ref))
1062 .transpose()
1063}
1064
1065fn load_contract_asset(pack_path: &Path, asset_ref: &str) -> Result<ContractAsset> {
1066 let bytes = read_pack_asset(pack_path, asset_ref)?;
1067 let text = String::from_utf8(bytes.clone()).ok();
1068 let json = text
1069 .as_ref()
1070 .and_then(|text| serde_json::from_str::<JsonValue>(text).ok());
1071 Ok(ContractAsset {
1072 path: asset_ref.to_string(),
1073 json,
1074 text,
1075 size_bytes: bytes.len(),
1076 })
1077}
1078
1079#[cfg(test)]
1080mod tests {
1081 use super::*;
1082 use greentic_types::PackId;
1083 use greentic_types::pack_manifest::{ExtensionInline, ExtensionRef, PackKind, PackManifest};
1084 use greentic_types::provider::ProviderExtensionInline;
1085 use semver::Version;
1086 use std::io::Write;
1087 use std::str::FromStr;
1088 use tar::Builder;
1089 use zip::write::SimpleFileOptions;
1090
1091 fn sample_manifest() -> PackManifest {
1092 PackManifest {
1093 schema_version: "pack-v1".to_string(),
1094 pack_id: PackId::from_str("dev.greentic.sample").unwrap(),
1095 name: None,
1096 version: Version::new(0, 1, 0),
1097 kind: PackKind::Application,
1098 publisher: "greentic".to_string(),
1099 secret_requirements: Vec::new(),
1100 components: Vec::new(),
1101 flows: Vec::new(),
1102 dependencies: Vec::new(),
1103 capabilities: Vec::new(),
1104 signatures: Default::default(),
1105 bootstrap: None,
1106 extensions: None,
1107 }
1108 }
1109
1110 fn sample_contract() -> DeployerContractV1 {
1111 DeployerContractV1 {
1112 schema_version: 1,
1113 planner: PlannerSpecV1 {
1114 flow_id: "plan_flow".into(),
1115 input_schema_ref: Some("assets/schemas/deployer-plan-input.schema.json".into()),
1116 output_schema_ref: Some("assets/schemas/deployer-plan-output.schema.json".into()),
1117 qa_spec_ref: Some("assets/qaspecs/plan.json".into()),
1118 },
1119 capabilities: vec![
1120 CapabilitySpecV1 {
1121 capability: DeployerCapability::Plan,
1122 flow_id: "plan_flow".into(),
1123 input_schema_ref: Some("assets/schemas/deployer-plan-input.schema.json".into()),
1124 output_schema_ref: Some(
1125 "assets/schemas/deployer-plan-output.schema.json".into(),
1126 ),
1127 execution_output_schema_ref: None,
1128 qa_spec_ref: None,
1129 example_refs: vec!["assets/examples/plan.json".into()],
1130 },
1131 CapabilitySpecV1 {
1132 capability: DeployerCapability::Apply,
1133 flow_id: "apply_flow".into(),
1134 input_schema_ref: None,
1135 output_schema_ref: None,
1136 execution_output_schema_ref: Some(
1137 "assets/schemas/apply-execution-output.schema.json".into(),
1138 ),
1139 qa_spec_ref: None,
1140 example_refs: Vec::new(),
1141 },
1142 CapabilitySpecV1 {
1143 capability: DeployerCapability::Destroy,
1144 flow_id: "destroy_flow".into(),
1145 input_schema_ref: None,
1146 output_schema_ref: None,
1147 execution_output_schema_ref: Some(
1148 "assets/schemas/destroy-execution-output.schema.json".into(),
1149 ),
1150 qa_spec_ref: None,
1151 example_refs: Vec::new(),
1152 },
1153 CapabilitySpecV1 {
1154 capability: DeployerCapability::Status,
1155 flow_id: "status_flow".into(),
1156 input_schema_ref: None,
1157 output_schema_ref: None,
1158 execution_output_schema_ref: Some(
1159 "assets/schemas/status-execution-output.schema.json".into(),
1160 ),
1161 qa_spec_ref: None,
1162 example_refs: Vec::new(),
1163 },
1164 ],
1165 }
1166 }
1167
1168 #[test]
1169 fn round_trips_contract_through_manifest_extension() {
1170 let mut manifest = sample_manifest();
1171 let contract = sample_contract();
1172 set_deployer_contract_v1(&mut manifest, contract.clone()).unwrap();
1173 let decoded = get_deployer_contract_v1(&manifest).unwrap().unwrap();
1174 assert_eq!(decoded, contract);
1175 }
1176
1177 #[test]
1178 fn rejects_duplicate_capabilities() {
1179 let mut contract = sample_contract();
1180 contract.capabilities.push(CapabilitySpecV1 {
1181 capability: DeployerCapability::Plan,
1182 flow_id: "other_plan".into(),
1183 input_schema_ref: None,
1184 output_schema_ref: None,
1185 execution_output_schema_ref: None,
1186 qa_spec_ref: None,
1187 example_refs: Vec::new(),
1188 });
1189 let err = contract.validate().unwrap_err();
1190 assert!(format!("{err}").contains("duplicate capability"));
1191 }
1192
1193 #[test]
1194 fn loads_pack_asset_from_dir_and_gtpack() {
1195 let base = std::env::current_dir().unwrap().join("target/tmp-tests");
1196 std::fs::create_dir_all(&base).unwrap();
1197 let dir = tempfile::tempdir_in(&base).unwrap();
1198 let relative = "assets/schemas/deployer-plan-input.schema.json";
1199 let bytes = br#"{"type":"object"}"#;
1200 let asset_path = dir.path().join(relative);
1201 std::fs::create_dir_all(asset_path.parent().unwrap()).unwrap();
1202 std::fs::write(&asset_path, bytes).unwrap();
1203 assert_eq!(read_pack_asset(dir.path(), relative).unwrap(), bytes);
1204
1205 let tar_path = dir.path().join("sample.gtpack");
1206 let mut builder = Builder::new(Vec::new());
1207 let mut header = tar::Header::new_gnu();
1208 header.set_size(bytes.len() as u64);
1209 header.set_mode(0o644);
1210 header.set_cksum();
1211 builder
1212 .append_data(&mut header, relative, &bytes[..])
1213 .expect("append asset");
1214 let tar_bytes = builder.into_inner().unwrap();
1215 let mut file = std::fs::File::create(&tar_path).unwrap();
1216 file.write_all(&tar_bytes).unwrap();
1217
1218 assert_eq!(read_pack_asset(&tar_path, relative).unwrap(), bytes);
1219 }
1220
1221 #[test]
1222 fn copies_pack_subtree_from_dir_and_gtpack() {
1223 let base = std::env::current_dir().unwrap().join("target/tmp-tests");
1224 std::fs::create_dir_all(&base).unwrap();
1225 let dir = tempfile::tempdir_in(&base).unwrap();
1226
1227 let source_root = dir.path().join("terraform");
1228 std::fs::create_dir_all(source_root.join("modules/operator")).unwrap();
1229 std::fs::write(source_root.join("main.tf"), "module \"root\" {}").unwrap();
1230 std::fs::write(
1231 source_root.join("modules/operator/main.tf"),
1232 "module \"operator\" {}",
1233 )
1234 .unwrap();
1235
1236 let copied =
1237 copy_pack_subtree(dir.path(), "terraform", &dir.path().join("out-dir")).unwrap();
1238 assert_eq!(
1239 copied,
1240 vec![
1241 "main.tf".to_string(),
1242 "modules/operator/main.tf".to_string()
1243 ]
1244 );
1245 assert!(dir.path().join("out-dir/main.tf").exists());
1246 assert!(dir.path().join("out-dir/modules/operator/main.tf").exists());
1247
1248 let tar_path = dir.path().join("sample.gtpack");
1249 let mut builder = Builder::new(Vec::new());
1250 append_tar_file(&mut builder, "terraform/main.tf", br#"module "root" {}"#);
1251 append_tar_file(
1252 &mut builder,
1253 "terraform/modules/operator/main.tf",
1254 br#"module "operator" {}"#,
1255 );
1256 let tar_bytes = builder.into_inner().unwrap();
1257 let mut file = std::fs::File::create(&tar_path).unwrap();
1258 file.write_all(&tar_bytes).unwrap();
1259
1260 let copied =
1261 copy_pack_subtree(&tar_path, "terraform", &dir.path().join("out-gtpack")).unwrap();
1262 assert_eq!(
1263 copied,
1264 vec![
1265 "main.tf".to_string(),
1266 "modules/operator/main.tf".to_string()
1267 ]
1268 );
1269 assert!(dir.path().join("out-gtpack/main.tf").exists());
1270 assert!(
1271 dir.path()
1272 .join("out-gtpack/modules/operator/main.tf")
1273 .exists()
1274 );
1275 }
1276
1277 fn append_tar_file(builder: &mut Builder<Vec<u8>>, path: &str, bytes: &[u8]) {
1278 let mut header = tar::Header::new_gnu();
1279 header.set_size(bytes.len() as u64);
1280 header.set_mode(0o644);
1281 header.set_cksum();
1282 builder.append_data(&mut header, path, bytes).unwrap();
1283 }
1284
1285 #[test]
1286 fn cloud_target_requirements_apply_operator_image_source_override() {
1287 assert_eq!(
1288 operator_image_source_for_provider_override(Provider::Gcp, Some("ghcr")),
1289 OperatorImageSource::Ghcr
1290 );
1291 assert_eq!(
1292 operator_image_source_for_provider_override(Provider::Gcp, None),
1293 OperatorImageSource::GcpArtifactRegistry
1294 );
1295 }
1296
1297 #[test]
1298 fn cloud_target_requirements_for_provider_cover_cloud_targets_only() {
1299 let aws = CloudTargetRequirementsV1::for_provider(Provider::Aws).expect("aws");
1300 assert_eq!(aws.target, "aws");
1301 assert_eq!(aws.target_label, "AWS");
1302 assert_eq!(aws.provider_pack_filename, "aws.gtpack");
1303 assert!(aws.remote_bundle_source_required);
1304 assert!(!aws.credential_requirements.is_empty());
1305 assert!(aws.variable_requirements.iter().any(|entry| entry.name
1306 == "GREENTIC_DEPLOY_TERRAFORM_VAR_REMOTE_STATE_BACKEND"
1307 && entry.required));
1308 assert!(aws.variable_requirements.iter().any(|entry| entry.name
1309 == "GREENTIC_DEPLOY_TERRAFORM_VAR_REDIS_URL"
1310 && !entry.required));
1311
1312 let azure = CloudTargetRequirementsV1::for_provider(Provider::Azure).expect("azure");
1313 assert_eq!(azure.target_label, "Azure");
1314 assert_eq!(azure.provider_pack_filename, "azure.gtpack");
1315 assert!(azure.variable_requirements.iter().any(|entry| entry.name
1316 == "GREENTIC_DEPLOY_TERRAFORM_VAR_AZURE_KEY_VAULT_ID"
1317 && entry.required));
1318
1319 let gcp = CloudTargetRequirementsV1::for_provider(Provider::Gcp).expect("gcp");
1320 assert_eq!(gcp.target_label, "GCP");
1321 assert_eq!(gcp.provider_pack_filename, "gcp.gtpack");
1322 assert!(gcp.variable_requirements.iter().any(|entry| entry.name
1323 == "GREENTIC_DEPLOY_TERRAFORM_VAR_GCP_PROJECT_ID"
1324 && entry.required));
1325 assert_eq!(
1326 gcp.variable_requirements
1327 .iter()
1328 .find(|entry| entry.name == "GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE")
1329 .and_then(|entry| entry.default_value.as_deref()),
1330 Some(DEFAULT_GCP_OPERATOR_IMAGE)
1331 );
1332
1333 assert!(CloudTargetRequirementsV1::for_provider(Provider::Local).is_none());
1334 assert!(CloudTargetRequirementsV1::for_provider(Provider::K8s).is_none());
1335 assert!(CloudTargetRequirementsV1::for_provider(Provider::Generic).is_none());
1336 }
1337
1338 #[test]
1339 fn cloud_deployer_extension_descriptor_for_provider_is_canonical() {
1340 let aws = CloudDeployerExtensionDescriptorV1::for_provider(Provider::Aws).expect("aws");
1341 assert_eq!(aws.extension_id, EXT_DEPLOY_AWS);
1342 assert_eq!(aws.deployer_pack_id, "greentic.deploy.aws");
1343 assert_eq!(aws.provider_pack_filename, "aws.gtpack");
1344 assert_eq!(aws.target_id, "aws-ecs-fargate-local");
1345
1346 let azure =
1347 CloudDeployerExtensionDescriptorV1::for_provider(Provider::Azure).expect("azure");
1348 assert_eq!(azure.extension_id, EXT_DEPLOY_AZURE);
1349 assert_eq!(azure.deployer_pack_id, "greentic.deploy.azure");
1350 assert_eq!(azure.provider_pack_filename, "azure.gtpack");
1351 assert_eq!(azure.target_id, "azure-container-apps-local");
1352
1353 let gcp = CloudDeployerExtensionDescriptorV1::for_provider(Provider::Gcp).expect("gcp");
1354 assert_eq!(gcp.extension_id, EXT_DEPLOY_GCP);
1355 assert_eq!(gcp.deployer_pack_id, "greentic.deploy.gcp");
1356 assert_eq!(gcp.provider_pack_filename, "gcp.gtpack");
1357 assert_eq!(gcp.target_id, "gcp-cloud-run-local");
1358
1359 assert!(CloudDeployerExtensionDescriptorV1::for_provider(Provider::Local).is_none());
1360 assert!(CloudDeployerExtensionDescriptorV1::for_provider(Provider::K8s).is_none());
1361 assert!(CloudDeployerExtensionDescriptorV1::for_provider(Provider::Generic).is_none());
1362 }
1363
1364 #[test]
1365 fn set_cloud_deployer_extension_ref_writes_manifest_extensions() {
1366 let mut manifest = sample_manifest();
1367 set_cloud_deployer_extension_ref(&mut manifest, Provider::Aws).expect("aws ext");
1368 set_cloud_deployer_extension_ref(&mut manifest, Provider::Gcp).expect("gcp ext");
1369
1370 let extensions = manifest.extensions.expect("extensions");
1371 let aws = extensions.get(EXT_DEPLOY_AWS).expect("aws ext entry");
1372 assert_eq!(aws.kind, EXT_DEPLOY_AWS);
1373 assert_eq!(aws.version, "0.1.0");
1374 let aws_inline = aws.inline.as_ref().expect("aws inline");
1375 let ExtensionInline::Other(aws_value) = aws_inline else {
1376 panic!("expected Other inline payload for aws");
1377 };
1378 let aws_descriptor: CloudDeployerExtensionDescriptorV1 =
1379 serde_json::from_value(aws_value.clone()).expect("aws descriptor");
1380 assert_eq!(aws_descriptor.deployer_pack_id, "greentic.deploy.aws");
1381 assert_eq!(aws_descriptor.provider_pack_filename, "aws.gtpack");
1382
1383 let gcp = extensions.get(EXT_DEPLOY_GCP).expect("gcp ext entry");
1384 assert_eq!(gcp.kind, EXT_DEPLOY_GCP);
1385 assert_eq!(gcp.version, "0.1.0");
1386 let gcp_inline = gcp.inline.as_ref().expect("gcp inline");
1387 let ExtensionInline::Other(gcp_value) = gcp_inline else {
1388 panic!("expected Other inline payload for gcp");
1389 };
1390 let gcp_descriptor: CloudDeployerExtensionDescriptorV1 =
1391 serde_json::from_value(gcp_value.clone()).expect("gcp descriptor");
1392 assert_eq!(gcp_descriptor.deployer_pack_id, "greentic.deploy.gcp");
1393 assert_eq!(gcp_descriptor.provider_pack_filename, "gcp.gtpack");
1394 }
1395
1396 #[test]
1397 fn contract_validation_rejects_invalid_shapes_and_finds_capabilities() {
1398 let mut missing_plan = sample_contract();
1399 missing_plan
1400 .capabilities
1401 .retain(|entry| entry.capability != DeployerCapability::Plan);
1402 let err = missing_plan.validate().unwrap_err();
1403 assert!(
1404 err.to_string()
1405 .contains("must declare the `plan` capability")
1406 );
1407
1408 let mut bad_schema = sample_contract();
1409 bad_schema.schema_version = 2;
1410 let err = bad_schema.validate().unwrap_err();
1411 assert!(err.to_string().contains("unsupported"));
1412
1413 let mut empty_planner = sample_contract();
1414 empty_planner.planner.flow_id.clear();
1415 let err = empty_planner.validate().unwrap_err();
1416 assert!(
1417 err.to_string()
1418 .contains("planner.flow_id must not be empty")
1419 );
1420
1421 let mut empty_capability_flow = sample_contract();
1422 empty_capability_flow.capabilities[0].flow_id.clear();
1423 let err = empty_capability_flow.validate().unwrap_err();
1424 assert!(err.to_string().contains("has empty flow_id"));
1425
1426 let contract = sample_contract();
1427 assert_eq!(
1428 contract
1429 .capability(DeployerCapability::Apply)
1430 .map(|entry| entry.flow_id.as_str()),
1431 Some("apply_flow")
1432 );
1433 assert!(contract.capability(DeployerCapability::Rollback).is_none());
1434 }
1435
1436 #[test]
1437 fn get_deployer_contract_rejects_unexpected_inline_type() {
1438 let mut manifest = sample_manifest();
1439 let extensions = manifest.extensions.get_or_insert_with(Default::default);
1440 extensions.insert(
1441 EXT_DEPLOYER_V1.to_string(),
1442 ExtensionRef {
1443 kind: EXT_DEPLOYER_V1.to_string(),
1444 version: "1.0.0".to_string(),
1445 digest: None,
1446 location: None,
1447 inline: Some(ExtensionInline::Provider(ProviderExtensionInline::default())),
1448 },
1449 );
1450 let err = get_deployer_contract_v1(&manifest).unwrap_err();
1451 assert!(err.to_string().contains("unexpected type"));
1452 }
1453
1454 #[test]
1455 fn read_pack_asset_and_copy_subtree_reject_parent_refs() {
1456 let base = std::env::current_dir().unwrap().join("target/tmp-tests");
1457 std::fs::create_dir_all(&base).unwrap();
1458 let dir = tempfile::tempdir_in(&base).unwrap();
1459
1460 let err = read_pack_asset(dir.path(), "../secrets.txt").unwrap_err();
1461 assert!(
1462 err.to_string()
1463 .contains("pack asset ref must stay pack-relative")
1464 );
1465
1466 let err =
1467 copy_pack_subtree(dir.path(), "../terraform", &dir.path().join("out")).unwrap_err();
1468 assert!(
1469 err.to_string()
1470 .contains("pack subtree ref must stay pack-relative")
1471 );
1472 }
1473
1474 #[test]
1475 fn copy_pack_subtree_from_zip_gtpack() {
1476 let base = std::env::current_dir().unwrap().join("target/tmp-tests");
1477 std::fs::create_dir_all(&base).unwrap();
1478 let dir = tempfile::tempdir_in(&base).unwrap();
1479
1480 let zip_path = dir.path().join("sample.gtpack");
1481 let file = std::fs::File::create(&zip_path).unwrap();
1482 let mut zip = zip::ZipWriter::new(file);
1483 let options = SimpleFileOptions::default();
1484 zip.start_file("terraform/main.tf", options).unwrap();
1485 zip.write_all(br#"module "root" {}"#).unwrap();
1486 zip.start_file("terraform/modules/operator/main.tf", options)
1487 .unwrap();
1488 zip.write_all(br#"module "operator" {}"#).unwrap();
1489 zip.finish().unwrap();
1490
1491 let copied =
1492 copy_pack_subtree(&zip_path, "terraform", &dir.path().join("out-zip")).unwrap();
1493 assert_eq!(
1494 copied,
1495 vec![
1496 "main.tf".to_string(),
1497 "modules/operator/main.tf".to_string()
1498 ]
1499 );
1500 assert!(dir.path().join("out-zip/main.tf").exists());
1501 assert!(dir.path().join("out-zip/modules/operator/main.tf").exists());
1502 }
1503
1504 #[test]
1505 fn resolve_deployer_contract_assets_loads_referenced_files() {
1506 let base = std::env::current_dir().unwrap().join("target/tmp-tests");
1507 std::fs::create_dir_all(&base).unwrap();
1508 let dir = tempfile::tempdir_in(&base).unwrap();
1509
1510 let planner_input = dir
1511 .path()
1512 .join("assets/schemas/deployer-plan-input.schema.json");
1513 let planner_output = dir
1514 .path()
1515 .join("assets/schemas/deployer-plan-output.schema.json");
1516 let apply_output = dir
1517 .path()
1518 .join("assets/schemas/apply-execution-output.schema.json");
1519 let destroy_output = dir
1520 .path()
1521 .join("assets/schemas/destroy-execution-output.schema.json");
1522 let status_output = dir
1523 .path()
1524 .join("assets/schemas/status-execution-output.schema.json");
1525 let planner_qa = dir.path().join("assets/qaspecs/plan.json");
1526 let example = dir.path().join("assets/examples/plan.json");
1527 std::fs::create_dir_all(planner_input.parent().unwrap()).unwrap();
1528 std::fs::create_dir_all(planner_qa.parent().unwrap()).unwrap();
1529 std::fs::create_dir_all(example.parent().unwrap()).unwrap();
1530 std::fs::write(&planner_input, br#"{"type":"object"}"#).unwrap();
1531 std::fs::write(&planner_output, br#"{"type":"object","title":"plan"}"#).unwrap();
1532 std::fs::write(&apply_output, br#"{"type":"object","title":"apply"}"#).unwrap();
1533 std::fs::write(&destroy_output, br#"{"type":"object","title":"destroy"}"#).unwrap();
1534 std::fs::write(&status_output, br#"{"type":"object","title":"status"}"#).unwrap();
1535 std::fs::write(&planner_qa, br#"{"questions":[]}"#).unwrap();
1536 std::fs::write(&example, br#"{"kind":"plan"}"#).unwrap();
1537
1538 let mut manifest = sample_manifest();
1539 set_deployer_contract_v1(&mut manifest, sample_contract()).unwrap();
1540 let resolved = resolve_deployer_contract_assets(&manifest, dir.path())
1541 .unwrap()
1542 .expect("resolved");
1543
1544 assert_eq!(resolved.schema_version, 1);
1545 assert_eq!(resolved.planner.flow_id, "plan_flow");
1546 assert_eq!(
1547 resolved
1548 .planner
1549 .input_schema
1550 .as_ref()
1551 .and_then(|asset| asset.json.as_ref())
1552 .and_then(|json| json.get("type"))
1553 .and_then(|value| value.as_str()),
1554 Some("object")
1555 );
1556 assert_eq!(
1557 resolved
1558 .planner
1559 .qa_spec
1560 .as_ref()
1561 .map(|asset| asset.path.as_str()),
1562 Some("assets/qaspecs/plan.json")
1563 );
1564 let plan_capability = resolved
1565 .capabilities
1566 .iter()
1567 .find(|entry| entry.capability == DeployerCapability::Plan)
1568 .expect("plan capability");
1569 assert_eq!(plan_capability.flow_id, "plan_flow");
1570 assert_eq!(plan_capability.examples.len(), 1);
1571 assert_eq!(
1572 plan_capability.examples[0].path,
1573 "assets/examples/plan.json"
1574 );
1575 assert_eq!(
1576 plan_capability.examples[0]
1577 .json
1578 .as_ref()
1579 .and_then(|json| json.get("kind"))
1580 .and_then(|value| value.as_str()),
1581 Some("plan")
1582 );
1583 }
1584}