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            agents: Default::default(),
1094            schema_version: "pack-v1".to_string(),
1095            pack_id: PackId::from_str("dev.greentic.sample").unwrap(),
1096            name: None,
1097            version: Version::new(0, 1, 0),
1098            kind: PackKind::Application,
1099            publisher: "greentic".to_string(),
1100            secret_requirements: Vec::new(),
1101            components: Vec::new(),
1102            flows: Vec::new(),
1103            dependencies: Vec::new(),
1104            capabilities: Vec::new(),
1105            signatures: Default::default(),
1106            bootstrap: None,
1107            extensions: None,
1108        }
1109    }
1110
1111    fn sample_contract() -> DeployerContractV1 {
1112        DeployerContractV1 {
1113            schema_version: 1,
1114            planner: PlannerSpecV1 {
1115                flow_id: "plan_flow".into(),
1116                input_schema_ref: Some("assets/schemas/deployer-plan-input.schema.json".into()),
1117                output_schema_ref: Some("assets/schemas/deployer-plan-output.schema.json".into()),
1118                qa_spec_ref: Some("assets/qaspecs/plan.json".into()),
1119            },
1120            capabilities: vec![
1121                CapabilitySpecV1 {
1122                    capability: DeployerCapability::Plan,
1123                    flow_id: "plan_flow".into(),
1124                    input_schema_ref: Some("assets/schemas/deployer-plan-input.schema.json".into()),
1125                    output_schema_ref: Some(
1126                        "assets/schemas/deployer-plan-output.schema.json".into(),
1127                    ),
1128                    execution_output_schema_ref: None,
1129                    qa_spec_ref: None,
1130                    example_refs: vec!["assets/examples/plan.json".into()],
1131                },
1132                CapabilitySpecV1 {
1133                    capability: DeployerCapability::Apply,
1134                    flow_id: "apply_flow".into(),
1135                    input_schema_ref: None,
1136                    output_schema_ref: None,
1137                    execution_output_schema_ref: Some(
1138                        "assets/schemas/apply-execution-output.schema.json".into(),
1139                    ),
1140                    qa_spec_ref: None,
1141                    example_refs: Vec::new(),
1142                },
1143                CapabilitySpecV1 {
1144                    capability: DeployerCapability::Destroy,
1145                    flow_id: "destroy_flow".into(),
1146                    input_schema_ref: None,
1147                    output_schema_ref: None,
1148                    execution_output_schema_ref: Some(
1149                        "assets/schemas/destroy-execution-output.schema.json".into(),
1150                    ),
1151                    qa_spec_ref: None,
1152                    example_refs: Vec::new(),
1153                },
1154                CapabilitySpecV1 {
1155                    capability: DeployerCapability::Status,
1156                    flow_id: "status_flow".into(),
1157                    input_schema_ref: None,
1158                    output_schema_ref: None,
1159                    execution_output_schema_ref: Some(
1160                        "assets/schemas/status-execution-output.schema.json".into(),
1161                    ),
1162                    qa_spec_ref: None,
1163                    example_refs: Vec::new(),
1164                },
1165            ],
1166        }
1167    }
1168
1169    #[test]
1170    fn round_trips_contract_through_manifest_extension() {
1171        let mut manifest = sample_manifest();
1172        let contract = sample_contract();
1173        set_deployer_contract_v1(&mut manifest, contract.clone()).unwrap();
1174        let decoded = get_deployer_contract_v1(&manifest).unwrap().unwrap();
1175        assert_eq!(decoded, contract);
1176    }
1177
1178    #[test]
1179    fn rejects_duplicate_capabilities() {
1180        let mut contract = sample_contract();
1181        contract.capabilities.push(CapabilitySpecV1 {
1182            capability: DeployerCapability::Plan,
1183            flow_id: "other_plan".into(),
1184            input_schema_ref: None,
1185            output_schema_ref: None,
1186            execution_output_schema_ref: None,
1187            qa_spec_ref: None,
1188            example_refs: Vec::new(),
1189        });
1190        let err = contract.validate().unwrap_err();
1191        assert!(format!("{err}").contains("duplicate capability"));
1192    }
1193
1194    #[test]
1195    fn loads_pack_asset_from_dir_and_gtpack() {
1196        let base = std::env::current_dir().unwrap().join("target/tmp-tests");
1197        std::fs::create_dir_all(&base).unwrap();
1198        let dir = tempfile::tempdir_in(&base).unwrap();
1199        let relative = "assets/schemas/deployer-plan-input.schema.json";
1200        let bytes = br#"{"type":"object"}"#;
1201        let asset_path = dir.path().join(relative);
1202        std::fs::create_dir_all(asset_path.parent().unwrap()).unwrap();
1203        std::fs::write(&asset_path, bytes).unwrap();
1204        assert_eq!(read_pack_asset(dir.path(), relative).unwrap(), bytes);
1205
1206        let tar_path = dir.path().join("sample.gtpack");
1207        let mut builder = Builder::new(Vec::new());
1208        let mut header = tar::Header::new_gnu();
1209        header.set_size(bytes.len() as u64);
1210        header.set_mode(0o644);
1211        header.set_cksum();
1212        builder
1213            .append_data(&mut header, relative, &bytes[..])
1214            .expect("append asset");
1215        let tar_bytes = builder.into_inner().unwrap();
1216        let mut file = std::fs::File::create(&tar_path).unwrap();
1217        file.write_all(&tar_bytes).unwrap();
1218
1219        assert_eq!(read_pack_asset(&tar_path, relative).unwrap(), bytes);
1220    }
1221
1222    #[test]
1223    fn copies_pack_subtree_from_dir_and_gtpack() {
1224        let base = std::env::current_dir().unwrap().join("target/tmp-tests");
1225        std::fs::create_dir_all(&base).unwrap();
1226        let dir = tempfile::tempdir_in(&base).unwrap();
1227
1228        let source_root = dir.path().join("terraform");
1229        std::fs::create_dir_all(source_root.join("modules/operator")).unwrap();
1230        std::fs::write(source_root.join("main.tf"), "module \"root\" {}").unwrap();
1231        std::fs::write(
1232            source_root.join("modules/operator/main.tf"),
1233            "module \"operator\" {}",
1234        )
1235        .unwrap();
1236
1237        let copied =
1238            copy_pack_subtree(dir.path(), "terraform", &dir.path().join("out-dir")).unwrap();
1239        assert_eq!(
1240            copied,
1241            vec![
1242                "main.tf".to_string(),
1243                "modules/operator/main.tf".to_string()
1244            ]
1245        );
1246        assert!(dir.path().join("out-dir/main.tf").exists());
1247        assert!(dir.path().join("out-dir/modules/operator/main.tf").exists());
1248
1249        let tar_path = dir.path().join("sample.gtpack");
1250        let mut builder = Builder::new(Vec::new());
1251        append_tar_file(&mut builder, "terraform/main.tf", br#"module "root" {}"#);
1252        append_tar_file(
1253            &mut builder,
1254            "terraform/modules/operator/main.tf",
1255            br#"module "operator" {}"#,
1256        );
1257        let tar_bytes = builder.into_inner().unwrap();
1258        let mut file = std::fs::File::create(&tar_path).unwrap();
1259        file.write_all(&tar_bytes).unwrap();
1260
1261        let copied =
1262            copy_pack_subtree(&tar_path, "terraform", &dir.path().join("out-gtpack")).unwrap();
1263        assert_eq!(
1264            copied,
1265            vec![
1266                "main.tf".to_string(),
1267                "modules/operator/main.tf".to_string()
1268            ]
1269        );
1270        assert!(dir.path().join("out-gtpack/main.tf").exists());
1271        assert!(
1272            dir.path()
1273                .join("out-gtpack/modules/operator/main.tf")
1274                .exists()
1275        );
1276    }
1277
1278    fn append_tar_file(builder: &mut Builder<Vec<u8>>, path: &str, bytes: &[u8]) {
1279        let mut header = tar::Header::new_gnu();
1280        header.set_size(bytes.len() as u64);
1281        header.set_mode(0o644);
1282        header.set_cksum();
1283        builder.append_data(&mut header, path, bytes).unwrap();
1284    }
1285
1286    #[test]
1287    fn cloud_target_requirements_apply_operator_image_source_override() {
1288        assert_eq!(
1289            operator_image_source_for_provider_override(Provider::Gcp, Some("ghcr")),
1290            OperatorImageSource::Ghcr
1291        );
1292        assert_eq!(
1293            operator_image_source_for_provider_override(Provider::Gcp, None),
1294            OperatorImageSource::GcpArtifactRegistry
1295        );
1296    }
1297
1298    #[test]
1299    fn cloud_target_requirements_for_provider_cover_cloud_targets_only() {
1300        let aws = CloudTargetRequirementsV1::for_provider(Provider::Aws).expect("aws");
1301        assert_eq!(aws.target, "aws");
1302        assert_eq!(aws.target_label, "AWS");
1303        assert_eq!(aws.provider_pack_filename, "aws.gtpack");
1304        assert!(aws.remote_bundle_source_required);
1305        assert!(!aws.credential_requirements.is_empty());
1306        assert!(aws.variable_requirements.iter().any(|entry| entry.name
1307            == "GREENTIC_DEPLOY_TERRAFORM_VAR_REMOTE_STATE_BACKEND"
1308            && entry.required));
1309        assert!(aws.variable_requirements.iter().any(|entry| entry.name
1310            == "GREENTIC_DEPLOY_TERRAFORM_VAR_REDIS_URL"
1311            && !entry.required));
1312
1313        let azure = CloudTargetRequirementsV1::for_provider(Provider::Azure).expect("azure");
1314        assert_eq!(azure.target_label, "Azure");
1315        assert_eq!(azure.provider_pack_filename, "azure.gtpack");
1316        assert!(azure.variable_requirements.iter().any(|entry| entry.name
1317            == "GREENTIC_DEPLOY_TERRAFORM_VAR_AZURE_KEY_VAULT_ID"
1318            && entry.required));
1319
1320        let gcp = CloudTargetRequirementsV1::for_provider(Provider::Gcp).expect("gcp");
1321        assert_eq!(gcp.target_label, "GCP");
1322        assert_eq!(gcp.provider_pack_filename, "gcp.gtpack");
1323        assert!(gcp.variable_requirements.iter().any(|entry| entry.name
1324            == "GREENTIC_DEPLOY_TERRAFORM_VAR_GCP_PROJECT_ID"
1325            && entry.required));
1326        assert_eq!(
1327            gcp.variable_requirements
1328                .iter()
1329                .find(|entry| entry.name == "GREENTIC_DEPLOY_TERRAFORM_VAR_OPERATOR_IMAGE")
1330                .and_then(|entry| entry.default_value.as_deref()),
1331            Some(DEFAULT_GCP_OPERATOR_IMAGE)
1332        );
1333
1334        assert!(CloudTargetRequirementsV1::for_provider(Provider::Local).is_none());
1335        assert!(CloudTargetRequirementsV1::for_provider(Provider::K8s).is_none());
1336        assert!(CloudTargetRequirementsV1::for_provider(Provider::Generic).is_none());
1337    }
1338
1339    #[test]
1340    fn cloud_deployer_extension_descriptor_for_provider_is_canonical() {
1341        let aws = CloudDeployerExtensionDescriptorV1::for_provider(Provider::Aws).expect("aws");
1342        assert_eq!(aws.extension_id, EXT_DEPLOY_AWS);
1343        assert_eq!(aws.deployer_pack_id, "greentic.deploy.aws");
1344        assert_eq!(aws.provider_pack_filename, "aws.gtpack");
1345        assert_eq!(aws.target_id, "aws-ecs-fargate-local");
1346
1347        let azure =
1348            CloudDeployerExtensionDescriptorV1::for_provider(Provider::Azure).expect("azure");
1349        assert_eq!(azure.extension_id, EXT_DEPLOY_AZURE);
1350        assert_eq!(azure.deployer_pack_id, "greentic.deploy.azure");
1351        assert_eq!(azure.provider_pack_filename, "azure.gtpack");
1352        assert_eq!(azure.target_id, "azure-container-apps-local");
1353
1354        let gcp = CloudDeployerExtensionDescriptorV1::for_provider(Provider::Gcp).expect("gcp");
1355        assert_eq!(gcp.extension_id, EXT_DEPLOY_GCP);
1356        assert_eq!(gcp.deployer_pack_id, "greentic.deploy.gcp");
1357        assert_eq!(gcp.provider_pack_filename, "gcp.gtpack");
1358        assert_eq!(gcp.target_id, "gcp-cloud-run-local");
1359
1360        assert!(CloudDeployerExtensionDescriptorV1::for_provider(Provider::Local).is_none());
1361        assert!(CloudDeployerExtensionDescriptorV1::for_provider(Provider::K8s).is_none());
1362        assert!(CloudDeployerExtensionDescriptorV1::for_provider(Provider::Generic).is_none());
1363    }
1364
1365    #[test]
1366    fn set_cloud_deployer_extension_ref_writes_manifest_extensions() {
1367        let mut manifest = sample_manifest();
1368        set_cloud_deployer_extension_ref(&mut manifest, Provider::Aws).expect("aws ext");
1369        set_cloud_deployer_extension_ref(&mut manifest, Provider::Gcp).expect("gcp ext");
1370
1371        let extensions = manifest.extensions.expect("extensions");
1372        let aws = extensions.get(EXT_DEPLOY_AWS).expect("aws ext entry");
1373        assert_eq!(aws.kind, EXT_DEPLOY_AWS);
1374        assert_eq!(aws.version, "0.1.0");
1375        let aws_inline = aws.inline.as_ref().expect("aws inline");
1376        let ExtensionInline::Other(aws_value) = aws_inline else {
1377            panic!("expected Other inline payload for aws");
1378        };
1379        let aws_descriptor: CloudDeployerExtensionDescriptorV1 =
1380            serde_json::from_value(aws_value.clone()).expect("aws descriptor");
1381        assert_eq!(aws_descriptor.deployer_pack_id, "greentic.deploy.aws");
1382        assert_eq!(aws_descriptor.provider_pack_filename, "aws.gtpack");
1383
1384        let gcp = extensions.get(EXT_DEPLOY_GCP).expect("gcp ext entry");
1385        assert_eq!(gcp.kind, EXT_DEPLOY_GCP);
1386        assert_eq!(gcp.version, "0.1.0");
1387        let gcp_inline = gcp.inline.as_ref().expect("gcp inline");
1388        let ExtensionInline::Other(gcp_value) = gcp_inline else {
1389            panic!("expected Other inline payload for gcp");
1390        };
1391        let gcp_descriptor: CloudDeployerExtensionDescriptorV1 =
1392            serde_json::from_value(gcp_value.clone()).expect("gcp descriptor");
1393        assert_eq!(gcp_descriptor.deployer_pack_id, "greentic.deploy.gcp");
1394        assert_eq!(gcp_descriptor.provider_pack_filename, "gcp.gtpack");
1395    }
1396
1397    #[test]
1398    fn contract_validation_rejects_invalid_shapes_and_finds_capabilities() {
1399        let mut missing_plan = sample_contract();
1400        missing_plan
1401            .capabilities
1402            .retain(|entry| entry.capability != DeployerCapability::Plan);
1403        let err = missing_plan.validate().unwrap_err();
1404        assert!(
1405            err.to_string()
1406                .contains("must declare the `plan` capability")
1407        );
1408
1409        let mut bad_schema = sample_contract();
1410        bad_schema.schema_version = 2;
1411        let err = bad_schema.validate().unwrap_err();
1412        assert!(err.to_string().contains("unsupported"));
1413
1414        let mut empty_planner = sample_contract();
1415        empty_planner.planner.flow_id.clear();
1416        let err = empty_planner.validate().unwrap_err();
1417        assert!(
1418            err.to_string()
1419                .contains("planner.flow_id must not be empty")
1420        );
1421
1422        let mut empty_capability_flow = sample_contract();
1423        empty_capability_flow.capabilities[0].flow_id.clear();
1424        let err = empty_capability_flow.validate().unwrap_err();
1425        assert!(err.to_string().contains("has empty flow_id"));
1426
1427        let contract = sample_contract();
1428        assert_eq!(
1429            contract
1430                .capability(DeployerCapability::Apply)
1431                .map(|entry| entry.flow_id.as_str()),
1432            Some("apply_flow")
1433        );
1434        assert!(contract.capability(DeployerCapability::Rollback).is_none());
1435    }
1436
1437    #[test]
1438    fn get_deployer_contract_rejects_unexpected_inline_type() {
1439        let mut manifest = sample_manifest();
1440        let extensions = manifest.extensions.get_or_insert_with(Default::default);
1441        extensions.insert(
1442            EXT_DEPLOYER_V1.to_string(),
1443            ExtensionRef {
1444                kind: EXT_DEPLOYER_V1.to_string(),
1445                version: "1.0.0".to_string(),
1446                digest: None,
1447                location: None,
1448                inline: Some(ExtensionInline::Provider(ProviderExtensionInline::default())),
1449            },
1450        );
1451        let err = get_deployer_contract_v1(&manifest).unwrap_err();
1452        assert!(err.to_string().contains("unexpected type"));
1453    }
1454
1455    #[test]
1456    fn read_pack_asset_and_copy_subtree_reject_parent_refs() {
1457        let base = std::env::current_dir().unwrap().join("target/tmp-tests");
1458        std::fs::create_dir_all(&base).unwrap();
1459        let dir = tempfile::tempdir_in(&base).unwrap();
1460
1461        let err = read_pack_asset(dir.path(), "../secrets.txt").unwrap_err();
1462        assert!(
1463            err.to_string()
1464                .contains("pack asset ref must stay pack-relative")
1465        );
1466
1467        let err =
1468            copy_pack_subtree(dir.path(), "../terraform", &dir.path().join("out")).unwrap_err();
1469        assert!(
1470            err.to_string()
1471                .contains("pack subtree ref must stay pack-relative")
1472        );
1473    }
1474
1475    #[test]
1476    fn copy_pack_subtree_from_zip_gtpack() {
1477        let base = std::env::current_dir().unwrap().join("target/tmp-tests");
1478        std::fs::create_dir_all(&base).unwrap();
1479        let dir = tempfile::tempdir_in(&base).unwrap();
1480
1481        let zip_path = dir.path().join("sample.gtpack");
1482        let file = std::fs::File::create(&zip_path).unwrap();
1483        let mut zip = zip::ZipWriter::new(file);
1484        let options = SimpleFileOptions::default();
1485        zip.start_file("terraform/main.tf", options).unwrap();
1486        zip.write_all(br#"module "root" {}"#).unwrap();
1487        zip.start_file("terraform/modules/operator/main.tf", options)
1488            .unwrap();
1489        zip.write_all(br#"module "operator" {}"#).unwrap();
1490        zip.finish().unwrap();
1491
1492        let copied =
1493            copy_pack_subtree(&zip_path, "terraform", &dir.path().join("out-zip")).unwrap();
1494        assert_eq!(
1495            copied,
1496            vec![
1497                "main.tf".to_string(),
1498                "modules/operator/main.tf".to_string()
1499            ]
1500        );
1501        assert!(dir.path().join("out-zip/main.tf").exists());
1502        assert!(dir.path().join("out-zip/modules/operator/main.tf").exists());
1503    }
1504
1505    #[test]
1506    fn resolve_deployer_contract_assets_loads_referenced_files() {
1507        let base = std::env::current_dir().unwrap().join("target/tmp-tests");
1508        std::fs::create_dir_all(&base).unwrap();
1509        let dir = tempfile::tempdir_in(&base).unwrap();
1510
1511        let planner_input = dir
1512            .path()
1513            .join("assets/schemas/deployer-plan-input.schema.json");
1514        let planner_output = dir
1515            .path()
1516            .join("assets/schemas/deployer-plan-output.schema.json");
1517        let apply_output = dir
1518            .path()
1519            .join("assets/schemas/apply-execution-output.schema.json");
1520        let destroy_output = dir
1521            .path()
1522            .join("assets/schemas/destroy-execution-output.schema.json");
1523        let status_output = dir
1524            .path()
1525            .join("assets/schemas/status-execution-output.schema.json");
1526        let planner_qa = dir.path().join("assets/qaspecs/plan.json");
1527        let example = dir.path().join("assets/examples/plan.json");
1528        std::fs::create_dir_all(planner_input.parent().unwrap()).unwrap();
1529        std::fs::create_dir_all(planner_qa.parent().unwrap()).unwrap();
1530        std::fs::create_dir_all(example.parent().unwrap()).unwrap();
1531        std::fs::write(&planner_input, br#"{"type":"object"}"#).unwrap();
1532        std::fs::write(&planner_output, br#"{"type":"object","title":"plan"}"#).unwrap();
1533        std::fs::write(&apply_output, br#"{"type":"object","title":"apply"}"#).unwrap();
1534        std::fs::write(&destroy_output, br#"{"type":"object","title":"destroy"}"#).unwrap();
1535        std::fs::write(&status_output, br#"{"type":"object","title":"status"}"#).unwrap();
1536        std::fs::write(&planner_qa, br#"{"questions":[]}"#).unwrap();
1537        std::fs::write(&example, br#"{"kind":"plan"}"#).unwrap();
1538
1539        let mut manifest = sample_manifest();
1540        set_deployer_contract_v1(&mut manifest, sample_contract()).unwrap();
1541        let resolved = resolve_deployer_contract_assets(&manifest, dir.path())
1542            .unwrap()
1543            .expect("resolved");
1544
1545        assert_eq!(resolved.schema_version, 1);
1546        assert_eq!(resolved.planner.flow_id, "plan_flow");
1547        assert_eq!(
1548            resolved
1549                .planner
1550                .input_schema
1551                .as_ref()
1552                .and_then(|asset| asset.json.as_ref())
1553                .and_then(|json| json.get("type"))
1554                .and_then(|value| value.as_str()),
1555            Some("object")
1556        );
1557        assert_eq!(
1558            resolved
1559                .planner
1560                .qa_spec
1561                .as_ref()
1562                .map(|asset| asset.path.as_str()),
1563            Some("assets/qaspecs/plan.json")
1564        );
1565        let plan_capability = resolved
1566            .capabilities
1567            .iter()
1568            .find(|entry| entry.capability == DeployerCapability::Plan)
1569            .expect("plan capability");
1570        assert_eq!(plan_capability.flow_id, "plan_flow");
1571        assert_eq!(plan_capability.examples.len(), 1);
1572        assert_eq!(
1573            plan_capability.examples[0].path,
1574            "assets/examples/plan.json"
1575        );
1576        assert_eq!(
1577            plan_capability.examples[0]
1578                .json
1579                .as_ref()
1580                .and_then(|json| json.get("kind"))
1581                .and_then(|value| value.as_str()),
1582            Some("plan")
1583        );
1584    }
1585}