Skip to main content

greentic_deployer/
contract.rs

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}