Skip to main content

greentic_deployer/
apply.rs

1//! Legacy/provider-oriented multi-target deployment orchestration.
2//!
3//! This module still contains the older generic deployment-pack execution path
4//! used for non-single-vm targets. The stable OSS single-VM path lives in
5//! `crate::single_vm`.
6
7use std::collections::BTreeMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use std::thread::sleep;
12use std::time::Duration;
13
14use tracing::{info, info_span};
15
16use serde::{Deserialize, Serialize};
17use serde_json::Value as JsonValue;
18
19use crate::Provider;
20use crate::config::{DeployerConfig, OutputFormat};
21use crate::contract::{
22    DeployerCapability, ResolvedCapabilityContract, ResolvedDeployerContract, copy_pack_subtree,
23    read_pack_asset, resolve_deployer_contract_assets,
24};
25use crate::deployment::{
26    DeploymentPackSelection, DeploymentTarget, ExecutionOutcome, ExecutionOutcomePayload,
27    execute_deployment_pack, resolve_deployment_pack,
28};
29use crate::error::{DeployerError, Result};
30use crate::pack_introspect;
31use crate::plan::PlanContext;
32use crate::telemetry;
33use greentic_telemetry::{TelemetryCtx, set_current_telemetry_ctx};
34use serde_json;
35use serde_yaml_bw as serde_yaml;
36
37const SECRETS_PROVIDER_BINDING_RELATIVE_PATH: &str = "state/config/platform/secrets-provider.json";
38const SECRETS_PROVIDER_BINDING_SCHEMA_VERSION: &str = "greentic.secrets.binding.v1";
39
40#[derive(Debug, Clone, Serialize)]
41#[serde(tag = "kind", rename_all = "snake_case")]
42pub enum OperationPayload {
43    Plan(Box<PlanPayload>),
44    Generate(Box<GeneratePayload>),
45    Apply(Box<ApplyPayload>),
46    Destroy(Box<DestroyPayload>),
47    Status(Box<StatusPayload>),
48    Rollback(Box<RollbackPayload>),
49}
50
51#[derive(Debug, Clone, Serialize)]
52pub struct PlanPayload {
53    pub plan: PlanContext,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub rendered_output: Option<String>,
56}
57
58#[derive(Debug, Clone, Serialize)]
59pub struct CapabilityPayload {
60    pub capability: String,
61    pub provider: String,
62    pub strategy: String,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub rendered_output: Option<String>,
65}
66
67#[derive(Debug, Clone, Serialize)]
68pub struct GeneratePayload {
69    pub capability: String,
70    pub provider: String,
71    pub strategy: String,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub input_schema_path: Option<String>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub output_schema_path: Option<String>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub qa_spec_path: Option<String>,
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub example_paths: Vec<String>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub rendered_output: Option<String>,
82}
83
84#[derive(Debug, Clone, Serialize)]
85pub struct ApplyPayload {
86    pub capability: String,
87    pub provider: String,
88    pub strategy: String,
89    pub pack_id: String,
90    pub flow_id: String,
91    pub output_dir: String,
92    pub plan_path: String,
93    pub invoke_path: String,
94    pub runner_cmd: Vec<String>,
95    pub runner_env: Vec<(String, String)>,
96}
97
98#[derive(Debug, Clone, Serialize)]
99pub struct DestroyPayload {
100    pub capability: String,
101    pub provider: String,
102    pub strategy: String,
103    pub pack_id: String,
104    pub flow_id: String,
105    pub output_dir: String,
106    pub plan_path: String,
107    pub invoke_path: String,
108    pub runner_cmd: Vec<String>,
109    pub runner_env: Vec<(String, String)>,
110}
111
112#[derive(Debug, Clone, Serialize)]
113pub struct StatusPayload {
114    pub capability: String,
115    pub provider: String,
116    pub strategy: String,
117    pub pack_id: String,
118    pub flow_id: String,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub rendered_output: Option<String>,
121}
122
123#[derive(Debug, Clone, Serialize)]
124pub struct RollbackPayload {
125    pub capability: String,
126    pub provider: String,
127    pub strategy: String,
128    pub pack_id: String,
129    pub flow_id: String,
130    pub target_capability: String,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub rendered_output: Option<String>,
133}
134
135#[derive(Debug, Clone, Serialize)]
136pub struct OutputValidation {
137    pub schema_path: String,
138    pub valid: bool,
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub errors: Vec<String>,
141}
142
143#[derive(Debug, Clone, Serialize)]
144pub struct ExecutionReport {
145    pub output_dir: String,
146    pub plan_path: String,
147    pub invoke_path: String,
148    pub handoff_path: String,
149    pub runner_command_path: String,
150    pub handler_id: String,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub status: Option<String>,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub message: Option<String>,
155    #[serde(default, skip_serializing_if = "Vec::is_empty")]
156    pub output_files: Vec<String>,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub outcome_payload: Option<ExecutionOutcomePayload>,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub outcome_validation: Option<OutputValidation>,
161}
162
163#[derive(Debug, Clone, Serialize)]
164pub struct OperationResult {
165    pub capability: String,
166    pub executed: bool,
167    pub preview: bool,
168    pub output_dir: String,
169    pub plan_path: String,
170    pub invoke_path: String,
171    pub pack_id: String,
172    pub flow_id: String,
173    pub handler_id: String,
174    pub pack_path: String,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub contract: Option<ResolvedDeployerContract>,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub capability_contract: Option<ResolvedCapabilityContract>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub payload: Option<OperationPayload>,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub output_validation: Option<OutputValidation>,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub execution: Option<ExecutionReport>,
185}
186
187pub fn render_operation_result(value: &OperationResult, format: OutputFormat) -> Result<String> {
188    match format {
189        OutputFormat::Text => Ok(render_operation_result_text(value)),
190        OutputFormat::Json => match apply_success_webchat_url(value) {
191            Some(webchat_url) => serde_json::to_string_pretty(&serde_json::json!({
192                "webchat_url": webchat_url,
193            }))
194            .map_err(|err| DeployerError::Other(err.to_string())),
195            None => serde_json::to_string_pretty(value)
196                .map_err(|err| DeployerError::Other(err.to_string())),
197        },
198        OutputFormat::Yaml => match apply_success_webchat_url(value) {
199            Some(webchat_url) => serde_yaml::to_string(&serde_json::json!({
200                "webchat_url": webchat_url,
201            }))
202            .map_err(|err| DeployerError::Other(err.to_string())),
203            None => {
204                serde_yaml::to_string(value).map_err(|err| DeployerError::Other(err.to_string()))
205            }
206        },
207    }
208}
209
210fn render_operation_result_text(value: &OperationResult) -> String {
211    if let Some(summary) = render_apply_success_summary(value) {
212        return summary;
213    }
214
215    let mut out = String::new();
216    out.push_str(&format!(
217        "capability={} executed={} preview={}\n",
218        value.capability, value.executed, value.preview
219    ));
220    out.push_str(&format!("pack_id={}\n", value.pack_id));
221    out.push_str(&format!("flow_id={}\n", value.flow_id));
222    out.push_str(&format!("handler_id={}\n", value.handler_id));
223    out.push_str(&format!("pack_path={}\n", value.pack_path));
224    out.push_str(&format!("output_dir={}\n", value.output_dir));
225    out.push_str(&format!("plan_path={}\n", value.plan_path));
226    out.push_str(&format!("invoke_path={}\n", value.invoke_path));
227
228    if let Some(payload) = value.payload.as_ref() {
229        render_operation_payload_text(payload, &mut out);
230    }
231    if let Some(validation) = value.output_validation.as_ref() {
232        render_output_validation_text("output_validation", validation, &mut out);
233    }
234    if let Some(execution) = value.execution.as_ref() {
235        render_execution_report_text(execution, &mut out);
236    }
237    append_terraform_runtime_text(value, &mut out);
238
239    out
240}
241
242fn render_apply_success_summary(value: &OperationResult) -> Option<String> {
243    apply_success_webchat_url(value).map(|webchat_url| format!("{webchat_url}\n"))
244}
245
246fn apply_success_webchat_url(value: &OperationResult) -> Option<String> {
247    if value.capability != "apply" || !value.executed || value.preview {
248        return None;
249    }
250    let execution = value.execution.as_ref()?;
251    let ExecutionOutcomePayload::Apply(payload) = execution.outcome_payload.as_ref()? else {
252        return None;
253    };
254    if payload.state != "applied" {
255        return None;
256    }
257
258    let endpoint = payload
259        .output_refs
260        .get("operator_endpoint")
261        .or_else(|| payload.endpoints.first())?;
262    let tenant = operation_result_tenant(value).unwrap_or("demo");
263    Some(webchat_gui_url(endpoint, tenant))
264}
265
266fn operation_result_tenant(value: &OperationResult) -> Option<&str> {
267    let OperationPayload::Apply(payload) = value.payload.as_ref()? else {
268        return None;
269    };
270    payload
271        .runner_env
272        .iter()
273        .find_map(|(key, value)| (key == "GREENTIC_TENANT").then_some(value.as_str()))
274}
275
276fn webchat_gui_url(endpoint: &str, tenant: &str) -> String {
277    format!(
278        "{}/v1/web/webchat/{}/",
279        endpoint.trim_end_matches('/'),
280        tenant.trim_matches('/')
281    )
282}
283
284fn render_operation_payload_text(payload: &OperationPayload, out: &mut String) {
285    match payload {
286        OperationPayload::Plan(payload) => {
287            out.push_str("payload_kind=plan\n");
288            out.push_str(&format!("target={}\n", payload.plan.target.as_str()));
289            out.push_str(&format!("components={}\n", payload.plan.components.len()));
290        }
291        OperationPayload::Generate(payload) => {
292            out.push_str("payload_kind=generate\n");
293            out.push_str(&format!("provider={}\n", payload.provider));
294            out.push_str(&format!("strategy={}\n", payload.strategy));
295            if let Some(path) = payload.input_schema_path.as_ref() {
296                out.push_str(&format!("input_schema={path}\n"));
297            }
298            if let Some(path) = payload.output_schema_path.as_ref() {
299                out.push_str(&format!("output_schema={path}\n"));
300            }
301            if let Some(path) = payload.qa_spec_path.as_ref() {
302                out.push_str(&format!("qa_spec={path}\n"));
303            }
304            if !payload.example_paths.is_empty() {
305                out.push_str(&format!("examples={}\n", payload.example_paths.join(", ")));
306            }
307        }
308        OperationPayload::Apply(payload) => {
309            out.push_str("payload_kind=apply\n");
310            out.push_str(&format!("provider={}\n", payload.provider));
311            out.push_str(&format!("strategy={}\n", payload.strategy));
312            out.push_str(&format!("runner_cmd={}\n", payload.runner_cmd.join(" ")));
313        }
314        OperationPayload::Destroy(payload) => {
315            out.push_str("payload_kind=destroy\n");
316            out.push_str(&format!("provider={}\n", payload.provider));
317            out.push_str(&format!("strategy={}\n", payload.strategy));
318            out.push_str(&format!("runner_cmd={}\n", payload.runner_cmd.join(" ")));
319        }
320        OperationPayload::Status(payload) => {
321            out.push_str("payload_kind=status\n");
322            out.push_str(&format!("provider={}\n", payload.provider));
323            out.push_str(&format!("strategy={}\n", payload.strategy));
324        }
325        OperationPayload::Rollback(payload) => {
326            out.push_str("payload_kind=rollback\n");
327            out.push_str(&format!("provider={}\n", payload.provider));
328            out.push_str(&format!("strategy={}\n", payload.strategy));
329            out.push_str(&format!(
330                "target_capability={}\n",
331                payload.target_capability
332            ));
333        }
334    }
335}
336
337fn render_output_validation_text(label: &str, validation: &OutputValidation, out: &mut String) {
338    out.push_str(&format!("{label}.schema={}\n", validation.schema_path));
339    out.push_str(&format!("{label}.valid={}\n", validation.valid));
340    if !validation.errors.is_empty() {
341        out.push_str(&format!(
342            "{label}.errors={}\n",
343            validation.errors.join(" | ")
344        ));
345    }
346}
347
348fn render_execution_report_text(execution: &ExecutionReport, out: &mut String) {
349    out.push_str("execution.present=true\n");
350    out.push_str(&format!("execution.output_dir={}\n", execution.output_dir));
351    out.push_str(&format!(
352        "execution.handoff_path={}\n",
353        execution.handoff_path
354    ));
355    out.push_str(&format!(
356        "execution.runner_command_path={}\n",
357        execution.runner_command_path
358    ));
359    if let Some(status) = execution.status.as_ref() {
360        out.push_str(&format!("execution.status={status}\n"));
361    }
362    if let Some(message) = execution.message.as_ref() {
363        out.push_str(&format!("execution.message={message}\n"));
364    }
365    if !execution.output_files.is_empty() {
366        out.push_str(&format!(
367            "execution.output_files={}\n",
368            execution.output_files.join(", ")
369        ));
370    }
371    if let Some(validation) = execution.outcome_validation.as_ref() {
372        render_output_validation_text("execution.validation", validation, out);
373    }
374}
375
376fn append_terraform_runtime_text(value: &OperationResult, out: &mut String) {
377    let runtime_path = Path::new(&value.output_dir).join("terraform-runtime.json");
378    let Ok(bytes) = fs::read(&runtime_path) else {
379        return;
380    };
381    let Ok(metadata) = serde_json::from_slice::<TerraformRuntimeMetadata>(&bytes) else {
382        return;
383    };
384
385    out.push_str("terraform_runtime.present=true\n");
386    out.push_str(&format!(
387        "terraform_runtime.root={}\n",
388        metadata.terraform_root
389    ));
390    out.push_str(&format!(
391        "terraform_runtime.copied_files={}\n",
392        metadata.copied_files.join(", ")
393    ));
394    out.push_str(&format!(
395        "terraform_runtime.status_command={}\n",
396        metadata.status_command
397    ));
398}
399
400pub async fn run(config: DeployerConfig) -> Result<OperationResult> {
401    telemetry::init(&config)?;
402    let plan = {
403        let span = stage_span("plan", &config);
404        let _enter = span.enter();
405        install_telemetry_context("plan", &config);
406        pack_introspect::build_plan(&config)?
407    };
408    run_with_plan(config, plan).await
409}
410
411/// Executes a deployment given an already constructed [`PlanContext`].
412///
413/// This is the entry point greentic-runner/control planes should invoke after producing the plan.
414/// Callers are expected to have initialised telemetry already (e.g. via `telemetry::init`).
415pub async fn run_with_plan(config: DeployerConfig, plan: PlanContext) -> Result<OperationResult> {
416    let plan_summary = plan.summary();
417    info!("built deployment plan: {}", plan_summary);
418
419    let plan_target = DeploymentTarget {
420        provider: plan.deployment.provider.clone(),
421        strategy: plan.deployment.strategy.clone(),
422    };
423    if plan_target.provider != config.provider.as_str() || plan_target.strategy != config.strategy {
424        info!(
425            "deployment plan target provider={} strategy={} (requested {}::{})",
426            plan_target.provider,
427            plan_target.strategy,
428            config.provider.as_str(),
429            config.strategy
430        );
431    }
432    let selection = resolve_deployment_pack(&config, &plan_target)?;
433    info!(
434        capability = %selection.dispatch.capability.as_str(),
435        provider = %plan_target.provider,
436        strategy = %plan_target.strategy,
437        pack_id = %selection.dispatch.pack_id,
438        flow_id = %selection.dispatch.flow_id,
439        pack_path = %selection.pack_path.display(),
440        origin = %selection.origin,
441        candidates = ?selection.candidates,
442        "resolved deployment pack"
443    );
444    let dispatch = &selection.dispatch;
445
446    let deploy_dir = config.provider_output_dir();
447    fs::create_dir_all(&deploy_dir)?;
448    let runtime_artifacts = persist_runtime_artifacts(&config, &plan, &selection, &deploy_dir)?;
449    let contract = resolve_deployer_contract_assets(&selection.manifest, &selection.pack_path)?;
450    let capability_contract = contract
451        .as_ref()
452        .and_then(|contract| {
453            contract
454                .capabilities
455                .iter()
456                .find(|entry| entry.capability == selection.dispatch.capability)
457        })
458        .cloned();
459    info!(
460        plan_path = %runtime_artifacts.plan.display(),
461        invoke_path = %runtime_artifacts.invoke.display(),
462        "persisted runtime invocation metadata"
463    );
464
465    let executed_payload = operation_payload(
466        config.capability,
467        &plan,
468        &plan_target,
469        &runtime_artifacts,
470        capability_contract.as_ref(),
471        None,
472    );
473    let executed_output_validation = match executed_payload.as_ref() {
474        Some(payload) => validation_for_payload(
475            output_schema_for_operation(
476                config.capability,
477                contract.as_ref(),
478                capability_contract.as_ref(),
479            ),
480            payload,
481        )?,
482        None => None,
483    };
484
485    if let Some(execution_outcome) = execute_deployment_pack(&config, &plan, dispatch).await? {
486        info!("deployment plan executed via deployment pack");
487        return Ok(build_operation_result(
488            &config,
489            &selection,
490            &runtime_artifacts,
491            OperationResultData {
492                contract,
493                capability_contract,
494                payload: executed_payload,
495                output_validation: executed_output_validation,
496                execution_outcome: Some(execution_outcome),
497                executed: true,
498            },
499        ));
500    }
501
502    if let Some(execution_outcome) =
503        synthesize_local_execution_outcome(&config, &runtime_artifacts)?
504    {
505        info!("deployment status synthesized from local runtime artifacts");
506        return Ok(build_operation_result(
507            &config,
508            &selection,
509            &runtime_artifacts,
510            OperationResultData {
511                contract,
512                capability_contract,
513                payload: executed_payload,
514                output_validation: executed_output_validation,
515                execution_outcome: Some(execution_outcome),
516                executed: true,
517            },
518        ));
519    }
520
521    let render_text = config.capability != DeployerCapability::Plan
522        || matches!(config.output, OutputFormat::Text);
523    if render_text {
524        println!("{}", plan.summary());
525        println!(
526            "Deployment executor not registered; runtime metadata stored under {}",
527            deploy_dir.display()
528        );
529    }
530
531    match config.capability {
532        DeployerCapability::Plan => {
533            let rendered_output = render_plan_output(&config, &plan)?;
534            let payload = operation_payload(
535                config.capability,
536                &plan,
537                &plan_target,
538                &runtime_artifacts,
539                capability_contract.as_ref(),
540                rendered_output,
541            )
542            .expect("plan payload");
543            let output_validation = validation_for_payload(
544                output_schema_for_operation(
545                    config.capability,
546                    contract.as_ref(),
547                    capability_contract.as_ref(),
548                ),
549                &payload,
550            )?;
551            if config.preview {
552                println!("Preview mode: nothing was applied.");
553            }
554            Ok(build_operation_result(
555                &config,
556                &selection,
557                &runtime_artifacts,
558                OperationResultData {
559                    contract,
560                    capability_contract: capability_contract.clone(),
561                    payload: Some(payload),
562                    output_validation,
563                    execution_outcome: None,
564                    executed: false,
565                },
566            ))
567        }
568        DeployerCapability::Generate
569        | DeployerCapability::Status
570        | DeployerCapability::Rollback => {
571            let rendered_output =
572                render_contract_summary(&config, &plan, capability_contract.as_ref())?;
573            let payload = operation_payload(
574                config.capability,
575                &plan,
576                &plan_target,
577                &runtime_artifacts,
578                capability_contract.as_ref(),
579                rendered_output,
580            )
581            .expect("capability payload");
582            let output_validation = validation_for_payload(
583                output_schema_for_operation(
584                    config.capability,
585                    contract.as_ref(),
586                    capability_contract.as_ref(),
587                ),
588                &payload,
589            )?;
590            if config.preview {
591                println!("Preview mode: skipping {}.", config.capability.as_str());
592            }
593            Ok(build_operation_result(
594                &config,
595                &selection,
596                &runtime_artifacts,
597                OperationResultData {
598                    contract,
599                    capability_contract: capability_contract.clone(),
600                    payload: Some(payload),
601                    output_validation,
602                    execution_outcome: None,
603                    executed: false,
604                },
605            ))
606        }
607        DeployerCapability::Apply => {
608            if config.preview {
609                println!("Preview mode: skipping apply.");
610                let payload = operation_payload(
611                    config.capability,
612                    &plan,
613                    &plan_target,
614                    &runtime_artifacts,
615                    capability_contract.as_ref(),
616                    None,
617                )
618                .expect("apply payload");
619                let output_validation = validation_for_payload(
620                    output_schema_for_operation(
621                        config.capability,
622                        contract.as_ref(),
623                        capability_contract.as_ref(),
624                    ),
625                    &payload,
626                )?;
627                return Ok(build_operation_result(
628                    &config,
629                    &selection,
630                    &runtime_artifacts,
631                    OperationResultData {
632                        contract,
633                        capability_contract: capability_contract.clone(),
634                        payload: Some(payload),
635                        output_validation,
636                        execution_outcome: None,
637                        executed: false,
638                    },
639                ));
640            }
641            Err(DeployerError::DeploymentPackUnsupported {
642                provider: config.provider.as_str().to_string(),
643                strategy: config.strategy.clone(),
644                capability: config.capability.as_str().to_string(),
645            })
646        }
647        DeployerCapability::Destroy => {
648            if config.preview {
649                println!("Preview mode: skipping destroy.");
650                let payload = operation_payload(
651                    config.capability,
652                    &plan,
653                    &plan_target,
654                    &runtime_artifacts,
655                    capability_contract.as_ref(),
656                    None,
657                )
658                .expect("destroy payload");
659                let output_validation = validation_for_payload(
660                    output_schema_for_operation(
661                        config.capability,
662                        contract.as_ref(),
663                        capability_contract.as_ref(),
664                    ),
665                    &payload,
666                )?;
667                return Ok(build_operation_result(
668                    &config,
669                    &selection,
670                    &runtime_artifacts,
671                    OperationResultData {
672                        contract,
673                        capability_contract: capability_contract.clone(),
674                        payload: Some(payload),
675                        output_validation,
676                        execution_outcome: None,
677                        executed: false,
678                    },
679                ));
680            }
681            Err(DeployerError::DeploymentPackUnsupported {
682                provider: config.provider.as_str().to_string(),
683                strategy: config.strategy.clone(),
684                capability: config.capability.as_str().to_string(),
685            })
686        }
687    }
688}
689
690fn synthesize_local_execution_outcome(
691    config: &DeployerConfig,
692    runtime_artifacts: &RuntimeArtifacts,
693) -> Result<Option<ExecutionOutcome>> {
694    if config.execute_local && uses_terraform_handoff(config) {
695        match config.capability {
696            DeployerCapability::Apply => {
697                return execute_local_terraform_operation(config, runtime_artifacts, "apply");
698            }
699            DeployerCapability::Destroy => {
700                return execute_local_terraform_operation(config, runtime_artifacts, "destroy");
701            }
702            _ => {}
703        }
704    }
705    if config.execute_local && uses_operator_handoff(config) {
706        match config.capability {
707            DeployerCapability::Apply => {
708                return execute_local_scripted_operation(
709                    config,
710                    runtime_artifacts,
711                    "operator-apply.sh",
712                    "operator-apply",
713                    "applied",
714                    ScriptedPayloadKind::Apply,
715                    "operator apply executed locally",
716                );
717            }
718            DeployerCapability::Destroy => {
719                return execute_local_scripted_operation(
720                    config,
721                    runtime_artifacts,
722                    "operator-delete.sh",
723                    "operator-destroy",
724                    "destroyed",
725                    ScriptedPayloadKind::Destroy,
726                    "operator destroy executed locally",
727                );
728            }
729            _ => {}
730        }
731    }
732    if config.execute_local && uses_k8s_raw_handoff(config) {
733        match config.capability {
734            DeployerCapability::Apply => {
735                return execute_local_scripted_operation(
736                    config,
737                    runtime_artifacts,
738                    "kubectl-apply.sh",
739                    "k8s-raw-apply",
740                    "applied",
741                    ScriptedPayloadKind::Apply,
742                    "k8s-raw apply executed locally",
743                );
744            }
745            DeployerCapability::Destroy => {
746                return execute_local_scripted_operation(
747                    config,
748                    runtime_artifacts,
749                    "kubectl-delete.sh",
750                    "k8s-raw-destroy",
751                    "destroyed",
752                    ScriptedPayloadKind::Destroy,
753                    "k8s-raw destroy executed locally",
754                );
755            }
756            _ => {}
757        }
758    }
759    if config.execute_local && uses_helm_handoff(config) {
760        match config.capability {
761            DeployerCapability::Apply => {
762                return execute_local_scripted_operation(
763                    config,
764                    runtime_artifacts,
765                    "helm-upgrade.sh",
766                    "helm-apply",
767                    "applied",
768                    ScriptedPayloadKind::Apply,
769                    "helm apply executed locally",
770                );
771            }
772            DeployerCapability::Destroy => {
773                return execute_local_scripted_operation(
774                    config,
775                    runtime_artifacts,
776                    "helm-rollback.sh",
777                    "helm-destroy",
778                    "destroyed",
779                    ScriptedPayloadKind::Destroy,
780                    "helm destroy executed locally",
781                );
782            }
783            _ => {}
784        }
785    }
786    if config.execute_local && uses_serverless_handoff(config) {
787        match config.capability {
788            DeployerCapability::Apply => {
789                return execute_local_scripted_operation(
790                    config,
791                    runtime_artifacts,
792                    "serverless-deploy.sh",
793                    "serverless-apply",
794                    "applied",
795                    ScriptedPayloadKind::Apply,
796                    "serverless apply executed locally",
797                );
798            }
799            DeployerCapability::Destroy => {
800                return execute_local_scripted_operation(
801                    config,
802                    runtime_artifacts,
803                    "serverless-destroy.sh",
804                    "serverless-destroy",
805                    "destroyed",
806                    ScriptedPayloadKind::Destroy,
807                    "serverless destroy executed locally",
808                );
809            }
810            _ => {}
811        }
812    }
813    if config.execute_local && uses_snap_handoff(config) {
814        match config.capability {
815            DeployerCapability::Apply => {
816                return execute_local_scripted_operation(
817                    config,
818                    runtime_artifacts,
819                    "snap-install.sh",
820                    "snap-apply",
821                    "applied",
822                    ScriptedPayloadKind::Apply,
823                    "snap apply executed locally",
824                );
825            }
826            DeployerCapability::Destroy => {
827                return execute_local_scripted_operation(
828                    config,
829                    runtime_artifacts,
830                    "snap-remove.sh",
831                    "snap-destroy",
832                    "destroyed",
833                    ScriptedPayloadKind::Destroy,
834                    "snap destroy executed locally",
835                );
836            }
837            _ => {}
838        }
839    }
840    if config.execute_local && uses_juju_machine_handoff(config) {
841        match config.capability {
842            DeployerCapability::Apply => {
843                return execute_local_scripted_operation(
844                    config,
845                    runtime_artifacts,
846                    "juju-machine-deploy.sh",
847                    "juju-machine-apply",
848                    "applied",
849                    ScriptedPayloadKind::Apply,
850                    "juju-machine apply executed locally",
851                );
852            }
853            DeployerCapability::Destroy => {
854                return execute_local_scripted_operation(
855                    config,
856                    runtime_artifacts,
857                    "juju-machine-remove.sh",
858                    "juju-machine-destroy",
859                    "destroyed",
860                    ScriptedPayloadKind::Destroy,
861                    "juju-machine destroy executed locally",
862                );
863            }
864            _ => {}
865        }
866    }
867    if config.execute_local && uses_juju_k8s_handoff(config) {
868        match config.capability {
869            DeployerCapability::Apply => {
870                return execute_local_scripted_operation(
871                    config,
872                    runtime_artifacts,
873                    "juju-k8s-deploy.sh",
874                    "juju-k8s-apply",
875                    "applied",
876                    ScriptedPayloadKind::Apply,
877                    "juju-k8s apply executed locally",
878                );
879            }
880            DeployerCapability::Destroy => {
881                return execute_local_scripted_operation(
882                    config,
883                    runtime_artifacts,
884                    "juju-k8s-remove.sh",
885                    "juju-k8s-destroy",
886                    "destroyed",
887                    ScriptedPayloadKind::Destroy,
888                    "juju-k8s destroy executed locally",
889                );
890            }
891            _ => {}
892        }
893    }
894    if config.capability == DeployerCapability::Status && uses_terraform_handoff(config) {
895        return synthesize_local_terraform_status(config, runtime_artifacts);
896    }
897    if config.capability == DeployerCapability::Status && uses_operator_handoff(config) {
898        return synthesize_scripted_handoff_status(
899            config,
900            runtime_artifacts,
901            "operator-handoff.txt",
902            vec![
903                ("operator_manifest", "operator/rendered-manifests.yaml"),
904                ("operator_apply_script", "operator-apply.sh"),
905                ("operator_delete_script", "operator-delete.sh"),
906                ("operator_status_script", "operator-status.sh"),
907            ],
908            "operator status synthesized from local handoff artifacts",
909        );
910    }
911    if config.capability == DeployerCapability::Status && uses_k8s_raw_handoff(config) {
912        return synthesize_scripted_handoff_status(
913            config,
914            runtime_artifacts,
915            "k8s-handoff.txt",
916            vec![
917                ("k8s_manifest", "k8s/rendered-manifests.yaml"),
918                ("k8s_apply_script", "kubectl-apply.sh"),
919                ("k8s_delete_script", "kubectl-delete.sh"),
920                ("k8s_status_script", "kubectl-status.sh"),
921            ],
922            "k8s-raw status synthesized from local handoff artifacts",
923        );
924    }
925    if config.capability == DeployerCapability::Status && uses_helm_handoff(config) {
926        return synthesize_scripted_handoff_status(
927            config,
928            runtime_artifacts,
929            "helm-handoff.txt",
930            vec![
931                ("helm_chart", "helm-chart/Chart.yaml"),
932                ("helm_upgrade_script", "helm-upgrade.sh"),
933                ("helm_rollback_script", "helm-rollback.sh"),
934                ("helm_status_script", "helm-status.sh"),
935            ],
936            "helm status synthesized from local handoff artifacts",
937        );
938    }
939    if config.capability == DeployerCapability::Status && uses_serverless_handoff(config) {
940        return synthesize_scripted_handoff_status(
941            config,
942            runtime_artifacts,
943            "serverless-handoff.txt",
944            vec![
945                (
946                    "serverless_descriptor",
947                    "serverless/deployment-descriptor.json",
948                ),
949                ("serverless_deploy_script", "serverless-deploy.sh"),
950                ("serverless_destroy_script", "serverless-destroy.sh"),
951                ("serverless_status_script", "serverless-status.sh"),
952            ],
953            "serverless status synthesized from local handoff artifacts",
954        );
955    }
956    if config.capability == DeployerCapability::Status && uses_snap_handoff(config) {
957        return synthesize_scripted_handoff_status(
958            config,
959            runtime_artifacts,
960            "snap-handoff.txt",
961            vec![
962                ("snap_fetch", "snap/fetch/snapcraft.yaml"),
963                ("snap_embedded", "snap/embedded/snapcraft.yaml"),
964                ("snap_install", "snap-install.sh"),
965                ("snap_remove", "snap-remove.sh"),
966                ("snap_status", "snap-status.sh"),
967            ],
968            "snap status synthesized from local handoff artifacts",
969        );
970    }
971    if config.capability == DeployerCapability::Status && uses_juju_machine_handoff(config) {
972        return synthesize_scripted_handoff_status(
973            config,
974            runtime_artifacts,
975            "juju-machine-handoff.txt",
976            vec![
977                ("juju_machine_charm", "juju-machine-charm/charmcraft.yaml"),
978                ("juju_machine_deploy", "juju-machine-deploy.sh"),
979                ("juju_machine_remove", "juju-machine-remove.sh"),
980                ("juju_machine_status", "juju-machine-status.sh"),
981            ],
982            "juju-machine status synthesized from local handoff artifacts",
983        );
984    }
985    if config.capability == DeployerCapability::Status && uses_juju_k8s_handoff(config) {
986        return synthesize_scripted_handoff_status(
987            config,
988            runtime_artifacts,
989            "juju-k8s-handoff.txt",
990            vec![
991                ("juju_k8s_charm", "juju-k8s-charm/charmcraft.yaml"),
992                ("juju_k8s_deploy", "juju-k8s-deploy.sh"),
993                ("juju_k8s_remove", "juju-k8s-remove.sh"),
994                ("juju_k8s_status", "juju-k8s-status.sh"),
995            ],
996            "juju-k8s status synthesized from local handoff artifacts",
997        );
998    }
999    Ok(None)
1000}
1001
1002fn uses_terraform_handoff(config: &DeployerConfig) -> bool {
1003    (config.provider == crate::config::Provider::Generic && config.strategy == "terraform")
1004        || (matches!(
1005            config.provider,
1006            crate::config::Provider::Aws
1007                | crate::config::Provider::Azure
1008                | crate::config::Provider::Gcp
1009        ) && config.strategy == "iac-only")
1010}
1011
1012fn uses_operator_handoff(config: &DeployerConfig) -> bool {
1013    config.provider == crate::config::Provider::K8s && config.strategy == "operator"
1014}
1015
1016fn uses_k8s_raw_handoff(config: &DeployerConfig) -> bool {
1017    config.provider == crate::config::Provider::K8s && config.strategy == "raw-manifests"
1018}
1019
1020fn uses_helm_handoff(config: &DeployerConfig) -> bool {
1021    config.provider == crate::config::Provider::K8s && config.strategy == "helm"
1022}
1023
1024fn uses_serverless_handoff(config: &DeployerConfig) -> bool {
1025    config.provider == crate::config::Provider::Generic && config.strategy == "serverless-container"
1026}
1027
1028fn uses_snap_handoff(config: &DeployerConfig) -> bool {
1029    config.provider == crate::config::Provider::Local && config.strategy == "snap"
1030}
1031
1032fn uses_juju_machine_handoff(config: &DeployerConfig) -> bool {
1033    config.provider == crate::config::Provider::Local && config.strategy == "juju-machine"
1034}
1035
1036fn uses_juju_k8s_handoff(config: &DeployerConfig) -> bool {
1037    config.provider == crate::config::Provider::K8s && config.strategy == "juju-k8s"
1038}
1039
1040enum ScriptedPayloadKind {
1041    Apply,
1042    Destroy,
1043}
1044
1045fn execute_local_terraform_operation(
1046    config: &DeployerConfig,
1047    runtime_artifacts: &RuntimeArtifacts,
1048    operation: &str,
1049) -> Result<Option<ExecutionOutcome>> {
1050    let script_name = match operation {
1051        "apply" => "terraform-apply.sh",
1052        "destroy" => "terraform-destroy.sh",
1053        other => {
1054            return Err(DeployerError::Config(format!(
1055                "unsupported terraform local operation {other}"
1056            )));
1057        }
1058    };
1059    let script_path = runtime_artifacts.deploy_dir.join(script_name);
1060    if !script_path.exists() {
1061        return Ok(None);
1062    }
1063
1064    let stdout_log = format!("terraform-{operation}.stdout.log");
1065    let stderr_log = format!("terraform-{operation}.stderr.log");
1066    let output = run_script_capture_logs(
1067        &script_path,
1068        &runtime_artifacts.deploy_dir,
1069        config.provider,
1070        runtime_artifacts,
1071        &stdout_log,
1072        &stderr_log,
1073    )?;
1074
1075    if !output.status.success() {
1076        if operation == "destroy" && config.provider == crate::config::Provider::Aws {
1077            let cleanup_script = runtime_artifacts
1078                .deploy_dir
1079                .join("terraform-aws-cleanup.sh");
1080            if cleanup_script.exists() {
1081                let cleanup_stdout = "terraform-destroy-cleanup.stdout.log";
1082                let cleanup_stderr = "terraform-destroy-cleanup.stderr.log";
1083                let cleanup = run_script_capture_logs(
1084                    &cleanup_script,
1085                    &runtime_artifacts.deploy_dir,
1086                    config.provider,
1087                    runtime_artifacts,
1088                    cleanup_stdout,
1089                    cleanup_stderr,
1090                )?;
1091                if cleanup.status.success() {
1092                    let retry_stdout = "terraform-destroy-retry.stdout.log";
1093                    let retry_stderr = "terraform-destroy-retry.stderr.log";
1094                    let retry = run_script_capture_logs(
1095                        &script_path,
1096                        &runtime_artifacts.deploy_dir,
1097                        config.provider,
1098                        runtime_artifacts,
1099                        retry_stdout,
1100                        retry_stderr,
1101                    )?;
1102                    if retry.status.success() {
1103                        let payload = ExecutionOutcomePayload::Destroy(
1104                            crate::deployment::DestroyExecutionOutcome {
1105                                deployment_id: runtime_artifacts.handoff.output_dir.clone(),
1106                                state: "destroyed".to_string(),
1107                                destroyed_resources: Vec::new(),
1108                            },
1109                        );
1110                        return Ok(Some(ExecutionOutcome {
1111                            status: Some("destroyed".to_string()),
1112                            message: Some(format!(
1113                                "terraform destroy executed locally via {} after AWS cleanup fallback",
1114                                script_path.display()
1115                            )),
1116                            output_files: vec![
1117                                stdout_log,
1118                                stderr_log,
1119                                cleanup_stdout.to_string(),
1120                                cleanup_stderr.to_string(),
1121                                retry_stdout.to_string(),
1122                                retry_stderr.to_string(),
1123                            ],
1124                            payload: Some(payload),
1125                        }));
1126                    }
1127                }
1128            }
1129        }
1130        let code = output
1131            .status
1132            .code()
1133            .map(|value| value.to_string())
1134            .unwrap_or_else(|| "signal".to_string());
1135        return Err(DeployerError::Other(format!(
1136            "terraform {operation} failed with exit {code}; see {stdout_log} and {stderr_log}"
1137        )));
1138    }
1139
1140    let state = if operation == "apply" {
1141        "applied"
1142    } else {
1143        "destroyed"
1144    };
1145    if operation == "apply" {
1146        let _ = capture_terraform_outputs(config.provider, runtime_artifacts);
1147        wait_for_runtime_readiness(config.provider, runtime_artifacts)?;
1148    }
1149    let endpoints = if operation == "apply" {
1150        collect_runtime_endpoints(runtime_artifacts)
1151    } else {
1152        Vec::new()
1153    };
1154    let output_refs = if operation == "apply" {
1155        collect_terraform_output_refs(runtime_artifacts)
1156    } else {
1157        BTreeMap::new()
1158    };
1159    let payload = if operation == "apply" {
1160        ExecutionOutcomePayload::Apply(crate::deployment::ApplyExecutionOutcome {
1161            deployment_id: runtime_artifacts.handoff.output_dir.clone(),
1162            state: state.to_string(),
1163            provider: Some(config.provider.as_str().to_string()),
1164            strategy: Some(config.strategy.clone()),
1165            endpoints,
1166            output_refs,
1167        })
1168    } else {
1169        ExecutionOutcomePayload::Destroy(crate::deployment::DestroyExecutionOutcome {
1170            deployment_id: runtime_artifacts.handoff.output_dir.clone(),
1171            state: state.to_string(),
1172            destroyed_resources: Vec::new(),
1173        })
1174    };
1175
1176    Ok(Some(ExecutionOutcome {
1177        status: Some(state.to_string()),
1178        message: Some(format!(
1179            "terraform {operation} executed locally via {}",
1180            script_path.display()
1181        )),
1182        output_files: vec![stdout_log, stderr_log],
1183        payload: Some(payload),
1184    }))
1185}
1186
1187fn apply_default_cloud_envs(command: &mut Command, provider: crate::config::Provider) {
1188    if provider == crate::config::Provider::Aws {
1189        if std::env::var_os("AWS_REGION").is_none() {
1190            command.env("AWS_REGION", "eu-north-1");
1191        }
1192        if std::env::var_os("AWS_DEFAULT_REGION").is_none() {
1193            command.env("AWS_DEFAULT_REGION", "eu-north-1");
1194        }
1195    }
1196}
1197
1198fn run_script_capture_logs(
1199    script_path: &Path,
1200    current_dir: &Path,
1201    provider: crate::config::Provider,
1202    runtime_artifacts: &RuntimeArtifacts,
1203    stdout_log: &str,
1204    stderr_log: &str,
1205) -> Result<std::process::Output> {
1206    // Accepted risk: script_path is a deployer-generated executable path and is invoked without a shell.
1207    // foxguard: ignore[rs/no-command-injection]
1208    let mut command = Command::new(script_path);
1209    command.current_dir(current_dir);
1210    apply_default_cloud_envs(&mut command, provider);
1211    let output = command.output().map_err(DeployerError::Io)?;
1212    fs::write(
1213        runtime_artifacts.deploy_dir.join(stdout_log),
1214        &output.stdout,
1215    )?;
1216    fs::write(
1217        runtime_artifacts.deploy_dir.join(stderr_log),
1218        &output.stderr,
1219    )?;
1220    Ok(output)
1221}
1222
1223fn execute_local_scripted_operation(
1224    config: &DeployerConfig,
1225    runtime_artifacts: &RuntimeArtifacts,
1226    script_name: &str,
1227    log_prefix: &str,
1228    state: &str,
1229    payload_kind: ScriptedPayloadKind,
1230    message: &str,
1231) -> Result<Option<ExecutionOutcome>> {
1232    let script_path = runtime_artifacts.deploy_dir.join(script_name);
1233    if !script_path.exists() {
1234        return Ok(None);
1235    }
1236
1237    let output = Command::new("bash")
1238        .current_dir(&runtime_artifacts.deploy_dir)
1239        .arg(&script_path)
1240        .output()
1241        .map_err(DeployerError::Io)?;
1242
1243    let stdout_log = format!("{log_prefix}.stdout.log");
1244    let stderr_log = format!("{log_prefix}.stderr.log");
1245    fs::write(
1246        runtime_artifacts.deploy_dir.join(&stdout_log),
1247        &output.stdout,
1248    )?;
1249    fs::write(
1250        runtime_artifacts.deploy_dir.join(&stderr_log),
1251        &output.stderr,
1252    )?;
1253
1254    if !output.status.success() {
1255        let code = output
1256            .status
1257            .code()
1258            .map(|value| value.to_string())
1259            .unwrap_or_else(|| "signal".to_string());
1260        return Err(DeployerError::Other(format!(
1261            "{script_name} failed with exit {code}; see {stdout_log} and {stderr_log}"
1262        )));
1263    }
1264
1265    let endpoints = if matches!(payload_kind, ScriptedPayloadKind::Apply) {
1266        collect_runtime_endpoints(runtime_artifacts)
1267    } else {
1268        Vec::new()
1269    };
1270    let output_refs = if matches!(payload_kind, ScriptedPayloadKind::Apply) {
1271        collect_terraform_output_refs(runtime_artifacts)
1272    } else {
1273        BTreeMap::new()
1274    };
1275    let payload = match payload_kind {
1276        ScriptedPayloadKind::Apply => {
1277            ExecutionOutcomePayload::Apply(crate::deployment::ApplyExecutionOutcome {
1278                deployment_id: runtime_artifacts.handoff.output_dir.clone(),
1279                state: state.to_string(),
1280                provider: Some(config.provider.as_str().to_string()),
1281                strategy: Some(config.strategy.clone()),
1282                endpoints,
1283                output_refs,
1284            })
1285        }
1286        ScriptedPayloadKind::Destroy => {
1287            ExecutionOutcomePayload::Destroy(crate::deployment::DestroyExecutionOutcome {
1288                deployment_id: runtime_artifacts.handoff.output_dir.clone(),
1289                state: state.to_string(),
1290                destroyed_resources: Vec::new(),
1291            })
1292        }
1293    };
1294
1295    Ok(Some(ExecutionOutcome {
1296        status: Some(state.to_string()),
1297        message: Some(format!("{message} via {}", script_path.display())),
1298        output_files: vec![stdout_log, stderr_log],
1299        payload: Some(payload),
1300    }))
1301}
1302
1303fn synthesize_local_terraform_status(
1304    config: &DeployerConfig,
1305    runtime_artifacts: &RuntimeArtifacts,
1306) -> Result<Option<ExecutionOutcome>> {
1307    let runtime_path = runtime_artifacts.deploy_dir.join("terraform-runtime.json");
1308    let bytes = match fs::read(&runtime_path) {
1309        Ok(bytes) => bytes,
1310        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1311        Err(err) => return Err(DeployerError::Io(err)),
1312    };
1313    let metadata: TerraformRuntimeMetadata =
1314        serde_json::from_slice(&bytes).map_err(|err| DeployerError::Other(err.to_string()))?;
1315
1316    let terraform_root = PathBuf::from(&metadata.terraform_root);
1317    let mut health_checks = Vec::new();
1318    health_checks.push(format!("terraform_runtime_json:{}", runtime_path.display()));
1319    health_checks.push(format!(
1320        "terraform_root:{}",
1321        if terraform_root.exists() {
1322            "present"
1323        } else {
1324            "missing"
1325        }
1326    ));
1327    for script in &metadata.scripts {
1328        let present = runtime_artifacts.deploy_dir.join(script).exists();
1329        health_checks.push(format!(
1330            "script:{}:{}",
1331            script,
1332            if present { "present" } else { "missing" }
1333        ));
1334    }
1335
1336    let ready = terraform_root.exists()
1337        && metadata
1338            .scripts
1339            .iter()
1340            .all(|script| runtime_artifacts.deploy_dir.join(script).exists());
1341    let state = if ready {
1342        "handoff_ready"
1343    } else {
1344        "handoff_incomplete"
1345    };
1346    let endpoints = collect_runtime_endpoints(runtime_artifacts);
1347    let output_refs = collect_terraform_output_refs(runtime_artifacts);
1348
1349    Ok(Some(ExecutionOutcome {
1350        status: Some(state.to_string()),
1351        message: Some("terraform status synthesized from local handoff artifacts".into()),
1352        output_files: vec![
1353            "terraform-runtime.json".into(),
1354            "terraform-handoff.txt".into(),
1355            "terraform-init.sh".into(),
1356            "terraform-plan.sh".into(),
1357            "terraform-apply.sh".into(),
1358            "terraform-destroy.sh".into(),
1359            "terraform-status.sh".into(),
1360        ],
1361        payload: Some(ExecutionOutcomePayload::Status(
1362            crate::deployment::StatusExecutionOutcome {
1363                deployment_id: runtime_artifacts.handoff.output_dir.clone(),
1364                state: state.to_string(),
1365                provider: Some(config.provider.as_str().to_string()),
1366                strategy: Some(config.strategy.clone()),
1367                status_source: Some("terraform_handoff".into()),
1368                endpoints,
1369                health_checks,
1370                output_refs,
1371            },
1372        )),
1373    }))
1374}
1375
1376fn collect_runtime_endpoints(runtime_artifacts: &RuntimeArtifacts) -> Vec<String> {
1377    let outputs_path = runtime_artifacts.deploy_dir.join("terraform-outputs.json");
1378    if let Ok(contents) = fs::read_to_string(&outputs_path) {
1379        let endpoints = parse_terraform_output_endpoints(&contents);
1380        if !endpoints.is_empty() {
1381            return endpoints;
1382        }
1383    }
1384
1385    let terraform_root = runtime_artifacts.deploy_dir.join("terraform");
1386    let Some(tfvars_path) = select_tfvars_path(&terraform_root) else {
1387        return Vec::new();
1388    };
1389    let Ok(contents) = fs::read_to_string(tfvars_path) else {
1390        return Vec::new();
1391    };
1392
1393    parse_dns_name_endpoint(&contents).into_iter().collect()
1394}
1395
1396fn collect_terraform_output_refs(runtime_artifacts: &RuntimeArtifacts) -> BTreeMap<String, String> {
1397    let outputs_path = runtime_artifacts.deploy_dir.join("terraform-outputs.json");
1398    let Ok(contents) = fs::read_to_string(outputs_path) else {
1399        return BTreeMap::new();
1400    };
1401    parse_terraform_output_refs(&contents)
1402}
1403
1404fn wait_for_runtime_readiness(
1405    provider: crate::config::Provider,
1406    runtime_artifacts: &RuntimeArtifacts,
1407) -> Result<()> {
1408    if provider != crate::config::Provider::Azure {
1409        return Ok(());
1410    }
1411    if std::env::var("GREENTIC_DEPLOY_SKIP_ENDPOINT_READY_CHECK")
1412        .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
1413        .unwrap_or(false)
1414    {
1415        return Ok(());
1416    }
1417
1418    let endpoints = collect_runtime_endpoints(runtime_artifacts);
1419    let Some(endpoint) = endpoints.first() else {
1420        return Err(DeployerError::Other(
1421            "azure apply completed without operator endpoint output".to_string(),
1422        ));
1423    };
1424    let ready_url = format!("{}/readyz", endpoint.trim_end_matches('/'));
1425    let max_attempts = std::env::var("GREENTIC_DEPLOY_ENDPOINT_READY_MAX_ATTEMPTS")
1426        .ok()
1427        .and_then(|value| value.parse::<u32>().ok())
1428        .unwrap_or(18);
1429    let retry_delay_seconds = std::env::var("GREENTIC_DEPLOY_ENDPOINT_READY_RETRY_DELAY_SECONDS")
1430        .ok()
1431        .and_then(|value| value.parse::<u64>().ok())
1432        .unwrap_or(10);
1433
1434    for attempt in 1..=max_attempts {
1435        let status = Command::new("curl")
1436            .arg("-sS")
1437            .arg("-o")
1438            .arg("/dev/null")
1439            .arg("-w")
1440            .arg("%{http_code}")
1441            .arg("--max-time")
1442            .arg("10")
1443            .arg(&ready_url)
1444            .output();
1445
1446        match status {
1447            Ok(output) if output.status.success() => {
1448                let code = String::from_utf8_lossy(&output.stdout);
1449                if code.trim() == "200" {
1450                    return Ok(());
1451                }
1452            }
1453            Ok(_) => {}
1454            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1455            Err(_) => {}
1456        }
1457
1458        if attempt < max_attempts {
1459            sleep(Duration::from_secs(retry_delay_seconds));
1460        }
1461    }
1462
1463    Err(DeployerError::Other(format!(
1464        "azure endpoint readiness check failed for {}; /readyz did not return 200",
1465        ready_url
1466    )))
1467}
1468
1469fn capture_terraform_outputs(
1470    provider: crate::config::Provider,
1471    runtime_artifacts: &RuntimeArtifacts,
1472) -> Result<()> {
1473    let terraform_root = runtime_artifacts.deploy_dir.join("terraform");
1474    if !terraform_root.exists() {
1475        return Ok(());
1476    }
1477
1478    let terraform_bin = if terraform_root.join("terraform").exists() {
1479        terraform_root.join("terraform")
1480    } else {
1481        PathBuf::from("terraform")
1482    };
1483    // Accepted risk: terraform_bin is either the generated local Terraform binary or the fixed PATH lookup "terraform"; no shell is used.
1484    // foxguard: ignore[rs/no-command-injection]
1485    let mut command = Command::new(terraform_bin);
1486    command
1487        .current_dir(&terraform_root)
1488        .arg("output")
1489        .arg("-json");
1490    apply_default_cloud_envs(&mut command, provider);
1491    let output = command.output().map_err(DeployerError::Io)?;
1492
1493    if !output.status.success() {
1494        return Ok(());
1495    }
1496
1497    fs::write(
1498        runtime_artifacts.deploy_dir.join("terraform-outputs.json"),
1499        output.stdout,
1500    )
1501    .map_err(DeployerError::Io)
1502}
1503
1504fn select_tfvars_path(terraform_root: &Path) -> Option<PathBuf> {
1505    let mut candidates = fs::read_dir(terraform_root)
1506        .ok()?
1507        .filter_map(|entry| entry.ok().map(|value| value.path()))
1508        .filter(|path| {
1509            path.is_file()
1510                && path
1511                    .file_name()
1512                    .and_then(|value| value.to_str())
1513                    .is_some_and(|value| {
1514                        value.ends_with(".tfvars") || value.ends_with(".tfvars.example")
1515                    })
1516        })
1517        .collect::<Vec<_>>();
1518    candidates.sort();
1519    candidates
1520        .iter()
1521        .find(|path| {
1522            path.file_name()
1523                .and_then(|value| value.to_str())
1524                .is_some_and(|value| {
1525                    value.ends_with(".tfvars") && !value.ends_with(".tfvars.example")
1526                })
1527        })
1528        .cloned()
1529        .or_else(|| candidates.into_iter().next())
1530}
1531
1532fn parse_dns_name_endpoint(contents: &str) -> Option<String> {
1533    for line in contents.lines() {
1534        let trimmed = line.trim();
1535        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
1536            continue;
1537        }
1538        let (key, value) = trimmed.split_once('=')?;
1539        if key.trim() != "dns_name" {
1540            continue;
1541        }
1542        let dns_name = value
1543            .split('#')
1544            .next()
1545            .and_then(|segment| segment.split("//").next())
1546            .map(str::trim)
1547            .map(|segment| segment.trim_matches('"'))
1548            .filter(|segment| !segment.is_empty())?;
1549        return Some(format!("https://{dns_name}"));
1550    }
1551    None
1552}
1553
1554fn parse_terraform_output_endpoints(contents: &str) -> Vec<String> {
1555    let Ok(value) = serde_json::from_str::<serde_json::Value>(contents) else {
1556        return Vec::new();
1557    };
1558    let Some(map) = value.as_object() else {
1559        return Vec::new();
1560    };
1561
1562    let mut endpoints = Vec::new();
1563    for (key, value) in map {
1564        let lower = key.to_ascii_lowercase();
1565        if !lower.contains("endpoint") && !lower.contains("url") && !lower.contains("dns") {
1566            continue;
1567        }
1568        let Some(output_value) = value.get("value") else {
1569            continue;
1570        };
1571        if let Some(url) = output_value.as_str() {
1572            endpoints.push(url.to_string());
1573        }
1574    }
1575    endpoints.sort();
1576    endpoints.dedup();
1577    endpoints
1578}
1579
1580fn parse_terraform_output_refs(contents: &str) -> BTreeMap<String, String> {
1581    let Ok(value) = serde_json::from_str::<serde_json::Value>(contents) else {
1582        return BTreeMap::new();
1583    };
1584    let Some(map) = value.as_object() else {
1585        return BTreeMap::new();
1586    };
1587
1588    let mut refs = BTreeMap::new();
1589    for (key, value) in map {
1590        let Some(output_value) = value.get("value") else {
1591            continue;
1592        };
1593        if let Some(text) = output_value.as_str() {
1594            refs.insert(key.clone(), text.to_string());
1595        }
1596    }
1597    refs
1598}
1599
1600fn synthesize_scripted_handoff_status(
1601    config: &DeployerConfig,
1602    runtime_artifacts: &RuntimeArtifacts,
1603    handoff_note: &str,
1604    checks: Vec<(&str, &str)>,
1605    message: &str,
1606) -> Result<Option<ExecutionOutcome>> {
1607    let note_path = runtime_artifacts.deploy_dir.join(handoff_note);
1608    if !note_path.exists() {
1609        return Ok(None);
1610    }
1611
1612    let mut health_checks = Vec::new();
1613    let mut ready = true;
1614    let mut output_files = vec![handoff_note.to_string()];
1615    for (name, relative_path) in checks {
1616        let path = runtime_artifacts.deploy_dir.join(relative_path);
1617        let present = path.exists();
1618        ready &= present;
1619        health_checks.push(format!(
1620            "{}:{}",
1621            name,
1622            if present { "present" } else { "missing" }
1623        ));
1624        if path.is_file() {
1625            output_files.push(relative_path.to_string());
1626        }
1627    }
1628    let state = if ready {
1629        "handoff_ready"
1630    } else {
1631        "handoff_incomplete"
1632    };
1633
1634    Ok(Some(ExecutionOutcome {
1635        status: Some(state.to_string()),
1636        message: Some(message.to_string()),
1637        output_files,
1638        payload: Some(ExecutionOutcomePayload::Status(
1639            crate::deployment::StatusExecutionOutcome {
1640                deployment_id: runtime_artifacts.handoff.output_dir.clone(),
1641                state: state.to_string(),
1642                provider: Some(config.provider.as_str().to_string()),
1643                strategy: Some(config.strategy.clone()),
1644                status_source: Some("scripted_handoff".into()),
1645                endpoints: Vec::new(),
1646                health_checks,
1647                output_refs: BTreeMap::new(),
1648            },
1649        )),
1650    }))
1651}
1652
1653fn operation_payload(
1654    capability: DeployerCapability,
1655    plan: &PlanContext,
1656    target: &DeploymentTarget,
1657    runtime_artifacts: &RuntimeArtifacts,
1658    capability_contract: Option<&ResolvedCapabilityContract>,
1659    rendered_output: Option<String>,
1660) -> Option<OperationPayload> {
1661    match capability {
1662        DeployerCapability::Plan => Some(OperationPayload::Plan(Box::new(PlanPayload {
1663            plan: plan.clone(),
1664            rendered_output,
1665        }))),
1666        DeployerCapability::Generate => {
1667            Some(OperationPayload::Generate(Box::new(GeneratePayload {
1668                capability: capability.as_str().to_string(),
1669                provider: target.provider.clone(),
1670                strategy: target.strategy.clone(),
1671                input_schema_path: capability_contract
1672                    .and_then(|entry| entry.input_schema.as_ref())
1673                    .map(|asset| asset.path.clone()),
1674                output_schema_path: capability_contract
1675                    .and_then(|entry| entry.output_schema.as_ref())
1676                    .map(|asset| asset.path.clone()),
1677                qa_spec_path: capability_contract
1678                    .and_then(|entry| entry.qa_spec.as_ref())
1679                    .map(|asset| asset.path.clone()),
1680                example_paths: capability_contract
1681                    .map(|entry| {
1682                        entry
1683                            .examples
1684                            .iter()
1685                            .map(|asset| asset.path.clone())
1686                            .collect::<Vec<_>>()
1687                    })
1688                    .unwrap_or_default(),
1689                rendered_output,
1690            })))
1691        }
1692        DeployerCapability::Apply => Some(OperationPayload::Apply(Box::new(ApplyPayload {
1693            capability: capability.as_str().to_string(),
1694            provider: target.provider.clone(),
1695            strategy: target.strategy.clone(),
1696            pack_id: runtime_artifacts.handoff.pack_id.clone(),
1697            flow_id: runtime_artifacts.handoff.flow_id.clone(),
1698            output_dir: runtime_artifacts.handoff.output_dir.clone(),
1699            plan_path: runtime_artifacts.plan.display().to_string(),
1700            invoke_path: runtime_artifacts.invoke.display().to_string(),
1701            runner_cmd: runtime_artifacts.handoff.runner_cmd.clone(),
1702            runner_env: runtime_artifacts.handoff.runner_env.clone(),
1703        }))),
1704        DeployerCapability::Destroy => Some(OperationPayload::Destroy(Box::new(DestroyPayload {
1705            capability: capability.as_str().to_string(),
1706            provider: target.provider.clone(),
1707            strategy: target.strategy.clone(),
1708            pack_id: runtime_artifacts.handoff.pack_id.clone(),
1709            flow_id: runtime_artifacts.handoff.flow_id.clone(),
1710            output_dir: runtime_artifacts.handoff.output_dir.clone(),
1711            plan_path: runtime_artifacts.plan.display().to_string(),
1712            invoke_path: runtime_artifacts.invoke.display().to_string(),
1713            runner_cmd: runtime_artifacts.handoff.runner_cmd.clone(),
1714            runner_env: runtime_artifacts.handoff.runner_env.clone(),
1715        }))),
1716        DeployerCapability::Status => Some(OperationPayload::Status(Box::new(StatusPayload {
1717            capability: capability.as_str().to_string(),
1718            provider: target.provider.clone(),
1719            strategy: target.strategy.clone(),
1720            pack_id: runtime_artifacts.handoff.pack_id.clone(),
1721            flow_id: runtime_artifacts.handoff.flow_id.clone(),
1722            rendered_output,
1723        }))),
1724        DeployerCapability::Rollback => {
1725            Some(OperationPayload::Rollback(Box::new(RollbackPayload {
1726                capability: capability.as_str().to_string(),
1727                provider: target.provider.clone(),
1728                strategy: target.strategy.clone(),
1729                pack_id: runtime_artifacts.handoff.pack_id.clone(),
1730                flow_id: runtime_artifacts.handoff.flow_id.clone(),
1731                target_capability: DeployerCapability::Apply.as_str().to_string(),
1732                rendered_output,
1733            })))
1734        }
1735    }
1736}
1737
1738fn output_schema_for_operation<'a>(
1739    capability: DeployerCapability,
1740    contract: Option<&'a ResolvedDeployerContract>,
1741    capability_contract: Option<&'a ResolvedCapabilityContract>,
1742) -> Option<&'a crate::contract::ContractAsset> {
1743    match capability {
1744        DeployerCapability::Plan => contract
1745            .as_ref()
1746            .and_then(|entry| entry.planner.output_schema.as_ref()),
1747        DeployerCapability::Generate
1748        | DeployerCapability::Apply
1749        | DeployerCapability::Destroy
1750        | DeployerCapability::Status
1751        | DeployerCapability::Rollback => capability_contract
1752            .as_ref()
1753            .and_then(|entry| entry.output_schema.as_ref()),
1754    }
1755}
1756
1757struct OperationResultData {
1758    contract: Option<ResolvedDeployerContract>,
1759    capability_contract: Option<ResolvedCapabilityContract>,
1760    payload: Option<OperationPayload>,
1761    output_validation: Option<OutputValidation>,
1762    execution_outcome: Option<ExecutionOutcome>,
1763    executed: bool,
1764}
1765
1766fn build_operation_result(
1767    config: &DeployerConfig,
1768    selection: &DeploymentPackSelection,
1769    runtime_artifacts: &RuntimeArtifacts,
1770    data: OperationResultData,
1771) -> OperationResult {
1772    let execution = data.executed.then(|| {
1773        build_execution_report(
1774            &selection.dispatch.handler_id,
1775            runtime_artifacts,
1776            data.capability_contract.as_ref(),
1777            data.execution_outcome,
1778        )
1779    });
1780    OperationResult {
1781        capability: config.capability.as_str().to_string(),
1782        executed: data.executed,
1783        preview: config.preview,
1784        output_dir: config.provider_output_dir().display().to_string(),
1785        plan_path: runtime_artifacts.plan.display().to_string(),
1786        invoke_path: runtime_artifacts.invoke.display().to_string(),
1787        pack_id: selection.dispatch.pack_id.clone(),
1788        flow_id: selection.dispatch.flow_id.clone(),
1789        handler_id: selection.dispatch.handler_id.clone(),
1790        pack_path: selection.pack_path.display().to_string(),
1791        contract: data.contract,
1792        capability_contract: data.capability_contract,
1793        payload: data.payload,
1794        output_validation: data.output_validation,
1795        execution,
1796    }
1797}
1798
1799fn build_execution_report(
1800    handler_id: &str,
1801    runtime_artifacts: &RuntimeArtifacts,
1802    capability_contract: Option<&ResolvedCapabilityContract>,
1803    execution_outcome: Option<ExecutionOutcome>,
1804) -> ExecutionReport {
1805    let status = execution_outcome
1806        .as_ref()
1807        .and_then(|outcome| outcome.status.clone());
1808    let message = execution_outcome
1809        .as_ref()
1810        .and_then(|outcome| outcome.message.clone());
1811    let outcome_payload = execution_outcome
1812        .as_ref()
1813        .and_then(|outcome| outcome.payload.clone());
1814    let outcome_validation = validation_for_execution_outcome(
1815        capability_contract.and_then(|contract| contract.execution_output_schema.as_ref()),
1816        outcome_payload.as_ref(),
1817    )
1818    .unwrap_or_else(|err| {
1819        Some(OutputValidation {
1820            schema_path: capability_contract
1821                .and_then(|contract| contract.execution_output_schema.as_ref())
1822                .map(|asset| asset.path.clone())
1823                .unwrap_or_default(),
1824            valid: false,
1825            errors: vec![err.to_string()],
1826        })
1827    });
1828    let mut output_files = collect_output_files(&runtime_artifacts.deploy_dir);
1829    if let Some(outcome) = execution_outcome.as_ref() {
1830        for file in &outcome.output_files {
1831            if !output_files.iter().any(|existing| existing == file) {
1832                output_files.push(file.clone());
1833            }
1834        }
1835        output_files.sort();
1836    }
1837    ExecutionReport {
1838        output_dir: runtime_artifacts.handoff.output_dir.clone(),
1839        plan_path: runtime_artifacts.plan.display().to_string(),
1840        invoke_path: runtime_artifacts.invoke.display().to_string(),
1841        handoff_path: runtime_artifacts.handoff_path.display().to_string(),
1842        runner_command_path: runtime_artifacts.runner_command_path.display().to_string(),
1843        handler_id: handler_id.to_string(),
1844        status,
1845        message,
1846        output_files,
1847        outcome_payload,
1848        outcome_validation,
1849    }
1850}
1851
1852fn collect_output_files(output_dir: &Path) -> Vec<String> {
1853    let mut files = Vec::new();
1854    let Ok(entries) = fs::read_dir(output_dir) else {
1855        return files;
1856    };
1857    for entry in entries.flatten() {
1858        let path = entry.path();
1859        if path.is_file()
1860            && let Some(name) = path.file_name().and_then(|name| name.to_str())
1861        {
1862            files.push(name.to_string());
1863        }
1864    }
1865    files.sort();
1866    files
1867}
1868
1869fn validation_for_payload(
1870    schema: Option<&crate::contract::ContractAsset>,
1871    payload: &OperationPayload,
1872) -> Result<Option<OutputValidation>> {
1873    validation_for_json_value(
1874        schema,
1875        serde_json::to_value(payload).map_err(|err| DeployerError::Other(err.to_string())),
1876    )
1877}
1878
1879fn validation_for_execution_outcome(
1880    schema: Option<&crate::contract::ContractAsset>,
1881    payload: Option<&ExecutionOutcomePayload>,
1882) -> Result<Option<OutputValidation>> {
1883    let Some(payload) = payload else {
1884        return Ok(None);
1885    };
1886    validation_for_json_value(
1887        schema,
1888        serde_json::to_value(payload).map_err(|err| DeployerError::Other(err.to_string())),
1889    )
1890}
1891
1892fn validation_for_json_value(
1893    schema: Option<&crate::contract::ContractAsset>,
1894    payload: Result<JsonValue>,
1895) -> Result<Option<OutputValidation>> {
1896    let Some(schema) = schema else {
1897        return Ok(None);
1898    };
1899    let Some(schema_json) = schema.json.as_ref() else {
1900        return Ok(Some(OutputValidation {
1901            schema_path: schema.path.clone(),
1902            valid: false,
1903            errors: vec![format!("schema asset {} is not valid JSON", schema.path)],
1904        }));
1905    };
1906
1907    let compiled = jsonschema::validator_for(schema_json).map_err(|err| {
1908        DeployerError::Contract(format!(
1909            "failed to compile output schema {}: {}",
1910            schema.path, err
1911        ))
1912    })?;
1913    let instance = payload?;
1914
1915    let errors = compiled
1916        .iter_errors(&instance)
1917        .map(|err| err.to_string())
1918        .collect::<Vec<_>>();
1919
1920    Ok(Some(OutputValidation {
1921        schema_path: schema.path.clone(),
1922        valid: errors.is_empty(),
1923        errors,
1924    }))
1925}
1926
1927struct RuntimeArtifacts {
1928    deploy_dir: PathBuf,
1929    plan: PathBuf,
1930    invoke: PathBuf,
1931    handoff: DeployerInvocation,
1932    handoff_path: PathBuf,
1933    runner_command_path: PathBuf,
1934}
1935
1936#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1937struct SecretsProviderBinding {
1938    schema_version: String,
1939    provider_id: String,
1940    pack: String,
1941    config: BTreeMap<String, String>,
1942}
1943
1944fn persist_runtime_artifacts(
1945    config: &DeployerConfig,
1946    plan: &PlanContext,
1947    selection: &DeploymentPackSelection,
1948    deploy_dir: &Path,
1949) -> Result<RuntimeArtifacts> {
1950    let runtime_dir = config.runtime_output_dir();
1951    fs::create_dir_all(&runtime_dir)?;
1952
1953    let plan_path = runtime_dir.join("plan.json");
1954    let plan_file = fs::File::create(&plan_path)?;
1955    serde_json::to_writer_pretty(plan_file, plan)?;
1956
1957    let invocation = RuntimeInvocation {
1958        capability: selection.dispatch.capability.as_str().to_string(),
1959        provider: config.provider.as_str().to_string(),
1960        strategy: config.strategy.clone(),
1961        tenant: config.tenant.clone(),
1962        environment: config.environment.clone(),
1963        output_dir: deploy_dir.display().to_string(),
1964        plan_path: plan_path.display().to_string(),
1965        pack_id: selection.dispatch.pack_id.clone(),
1966        flow_id: selection.dispatch.flow_id.clone(),
1967        handler_id: selection.dispatch.handler_id.clone(),
1968        pack_path: selection.pack_path.display().to_string(),
1969    };
1970    let invoke_path = runtime_dir.join("invoke.json");
1971    let invoke_file = fs::File::create(&invoke_path)?;
1972    serde_json::to_writer_pretty(invoke_file, &invocation)?;
1973
1974    materialize_adapter_handoff_assets(config, selection, deploy_dir)?;
1975    materialize_secrets_provider_binding(config, deploy_dir)?;
1976    let handoff = write_runner_diagnostics(config, deploy_dir, selection, &plan_path)?;
1977
1978    Ok(RuntimeArtifacts {
1979        deploy_dir: deploy_dir.to_path_buf(),
1980        plan: plan_path,
1981        invoke: invoke_path,
1982        handoff: handoff.invocation,
1983        handoff_path: handoff.handoff_path,
1984        runner_command_path: handoff.runner_command_path,
1985    })
1986}
1987
1988fn materialize_secrets_provider_binding(config: &DeployerConfig, deploy_dir: &Path) -> Result<()> {
1989    let Some(binding) = secrets_provider_binding_for_target(config) else {
1990        return Ok(());
1991    };
1992    let path = deploy_dir.join(SECRETS_PROVIDER_BINDING_RELATIVE_PATH);
1993    if let Some(parent) = path.parent() {
1994        fs::create_dir_all(parent)?;
1995    }
1996    fs::write(
1997        path,
1998        serde_json::to_vec_pretty(&binding).map_err(|err| DeployerError::Other(err.to_string()))?,
1999    )?;
2000    Ok(())
2001}
2002
2003fn secrets_provider_binding_for_target(config: &DeployerConfig) -> Option<SecretsProviderBinding> {
2004    let (provider_id, pack) = match config.provider {
2005        crate::config::Provider::Aws => {
2006            ("greentic.secrets.aws-sm", "providers/secrets/aws-sm.gtpack")
2007        }
2008        crate::config::Provider::Gcp => {
2009            ("greentic.secrets.gcp-sm", "providers/secrets/gcp-sm.gtpack")
2010        }
2011        crate::config::Provider::Azure => (
2012            "greentic.secrets.azure-kv",
2013            "providers/secrets/azure-kv.gtpack",
2014        ),
2015        crate::config::Provider::Local => ("greentic.secrets.dev", "providers/secrets/dev.gtpack"),
2016        _ => return None,
2017    };
2018    let namespace_prefix = crate::runtime_secrets::default_cloud_secret_prefix(
2019        &config.environment,
2020        &config.tenant,
2021        None,
2022    );
2023    let mut binding_config = BTreeMap::new();
2024    binding_config.insert("environment".to_string(), config.environment.clone());
2025    binding_config.insert("tenant".to_string(), config.tenant.clone());
2026    binding_config.insert("team".to_string(), "_".to_string());
2027    binding_config.insert("namespace_prefix".to_string(), namespace_prefix.clone());
2028    binding_config.insert("prefix".to_string(), namespace_prefix);
2029
2030    Some(SecretsProviderBinding {
2031        schema_version: SECRETS_PROVIDER_BINDING_SCHEMA_VERSION.to_string(),
2032        provider_id: provider_id.to_string(),
2033        pack: pack.to_string(),
2034        config: binding_config,
2035    })
2036}
2037
2038fn materialize_adapter_handoff_assets(
2039    config: &DeployerConfig,
2040    selection: &DeploymentPackSelection,
2041    deploy_dir: &Path,
2042) -> Result<()> {
2043    if uses_terraform_handoff(config) {
2044        materialize_terraform_handoff_assets(config, selection, deploy_dir)?;
2045    } else if config.provider == crate::config::Provider::K8s && config.strategy == "raw-manifests"
2046    {
2047        materialize_k8s_raw_handoff_assets(config, selection, deploy_dir)?;
2048    } else if config.provider == crate::config::Provider::K8s && config.strategy == "operator" {
2049        materialize_operator_handoff_assets(config, selection, deploy_dir)?;
2050    } else if config.provider == crate::config::Provider::K8s && config.strategy == "helm" {
2051        materialize_helm_handoff_assets(config, selection, deploy_dir)?;
2052    } else if config.provider == crate::config::Provider::Generic
2053        && config.strategy == "serverless-container"
2054    {
2055        materialize_serverless_handoff_assets(config, selection, deploy_dir)?;
2056    } else if uses_snap_handoff(config) {
2057        materialize_snap_handoff_assets(config, selection, deploy_dir)?;
2058    } else if uses_juju_machine_handoff(config) {
2059        materialize_juju_machine_handoff_assets(config, selection, deploy_dir)?;
2060    } else if uses_juju_k8s_handoff(config) {
2061        materialize_juju_k8s_handoff_assets(config, selection, deploy_dir)?;
2062    }
2063    Ok(())
2064}
2065
2066fn materialize_terraform_handoff_assets(
2067    config: &DeployerConfig,
2068    selection: &DeploymentPackSelection,
2069    deploy_dir: &Path,
2070) -> Result<()> {
2071    let terraform_root = deploy_dir.join("terraform");
2072    let copied = copy_pack_subtree(&selection.pack_path, "terraform", &terraform_root)?;
2073    if copied.is_empty() {
2074        return Ok(());
2075    }
2076    let local_terraform = terraform_root.join("terraform");
2077    if local_terraform.exists() {
2078        set_executable_if_unix(&local_terraform)?;
2079    }
2080    prune_generated_terraform_root(config, &terraform_root)?;
2081    configure_terraform_backend(config, &terraform_root, deploy_dir)?;
2082    normalize_terraform_main_tf(config, &terraform_root)?;
2083
2084    let tfvars_example = resolve_tfvars_example_name(&terraform_root, &config.environment)?;
2085    let generated_tfvars = materialize_generated_tfvars(config, &terraform_root, &tfvars_example)?;
2086    let script_tfvars = generated_tfvars.clone().or_else(|| {
2087        let env_tfvars = format!("{}.tfvars", config.environment);
2088        terraform_root
2089            .join(&env_tfvars)
2090            .exists()
2091            .then_some(env_tfvars)
2092    });
2093    let init_script = "terraform-init.sh";
2094    let plan_script = "terraform-plan.sh";
2095    let apply_script = "terraform-apply.sh";
2096    let destroy_script = "terraform-destroy.sh";
2097    let status_script = "terraform-status.sh";
2098    let aws_cleanup_script = "terraform-aws-cleanup.sh";
2099    write_executable_script(&deploy_dir.join(init_script), terraform_init_script())?;
2100    write_executable_script(
2101        &deploy_dir.join(plan_script),
2102        terraform_plan_like_script(
2103            "plan",
2104            config.provider,
2105            script_tfvars.as_deref(),
2106            &tfvars_example,
2107        ),
2108    )?;
2109    write_executable_script(
2110        &deploy_dir.join(apply_script),
2111        terraform_plan_like_script(
2112            "apply",
2113            config.provider,
2114            script_tfvars.as_deref(),
2115            &tfvars_example,
2116        ),
2117    )?;
2118    write_executable_script(
2119        &deploy_dir.join(destroy_script),
2120        terraform_plan_like_script(
2121            "destroy",
2122            config.provider,
2123            script_tfvars.as_deref(),
2124            &tfvars_example,
2125        ),
2126    )?;
2127    write_executable_script(
2128        &deploy_dir.join(status_script),
2129        terraform_script_prelude("\"$TERRAFORM_BIN\" show -json \"$@\""),
2130    )?;
2131    let mut scripts = vec![
2132        init_script.to_string(),
2133        plan_script.to_string(),
2134        apply_script.to_string(),
2135        destroy_script.to_string(),
2136        status_script.to_string(),
2137    ];
2138    if config.provider == crate::config::Provider::Aws {
2139        write_executable_script(
2140            &deploy_dir.join(aws_cleanup_script),
2141            terraform_aws_cleanup_script(script_tfvars.as_deref(), &tfvars_example),
2142        )?;
2143        scripts.push(aws_cleanup_script.to_string());
2144    }
2145
2146    let metadata = TerraformRuntimeMetadata {
2147        terraform_root: terraform_root.display().to_string(),
2148        copied_files: copied.clone(),
2149        scripts,
2150        generated_tfvars: generated_tfvars.clone(),
2151        secrets_provider_binding: secrets_provider_binding_for_target(config)
2152            .map(|_| SECRETS_PROVIDER_BINDING_RELATIVE_PATH.to_string()),
2153        init_command: format!("./{init_script}"),
2154        plan_command: format!("./{plan_script}"),
2155        apply_command: format!("./{apply_script}"),
2156        destroy_command: format!("./{destroy_script}"),
2157        status_command: format!("./{status_script}"),
2158    };
2159    fs::write(
2160        deploy_dir.join("terraform-runtime.json"),
2161        serde_json::to_vec_pretty(&metadata)
2162            .map_err(|err| DeployerError::Other(err.to_string()))?,
2163    )?;
2164
2165    let mut note = String::new();
2166    note.push_str("Terraform handoff assets were materialized from the deployment pack.\n");
2167    note.push_str(&format!("terraform_root={}\n", terraform_root.display()));
2168    note.push_str(&format!(
2169        "suggested_tfvars_example={}\n",
2170        terraform_root.join(&tfvars_example).display()
2171    ));
2172    if let Some(tfvars) = generated_tfvars.as_ref() {
2173        note.push_str(&format!(
2174            "generated_tfvars={}\n",
2175            terraform_root.join(tfvars).display()
2176        ));
2177    }
2178    if secrets_provider_binding_for_target(config).is_some() {
2179        note.push_str(&format!(
2180            "secrets_provider_binding={}\n",
2181            deploy_dir
2182                .join(SECRETS_PROVIDER_BINDING_RELATIVE_PATH)
2183                .display()
2184        ));
2185    }
2186    note.push_str("terraform_env_override_prefix=GREENTIC_DEPLOY_TERRAFORM_VAR_\n");
2187    note.push_str(
2188        "scripts=terraform-init.sh, terraform-plan.sh, terraform-apply.sh, terraform-destroy.sh, terraform-status.sh\n",
2189    );
2190    if config.provider == crate::config::Provider::Aws {
2191        note.push_str("aws_cleanup_command=./terraform-aws-cleanup.sh\n");
2192    }
2193    note.push_str(&format!("status_command={}\n", metadata.status_command));
2194    note.push_str("copied_files:\n");
2195    for path in copied {
2196        note.push_str(&format!("- {path}\n"));
2197    }
2198    fs::write(deploy_dir.join("terraform-handoff.txt"), note)?;
2199    Ok(())
2200}
2201
2202fn prune_generated_terraform_root(config: &DeployerConfig, terraform_root: &Path) -> Result<()> {
2203    let (module_name, module_source, module_inputs) = match config.provider {
2204        crate::config::Provider::Aws => (
2205            "operator",
2206            "./modules/operator",
2207            r#"  cloud                 = var.cloud
2208  tenant                = var.tenant
2209  deployment_name_prefix = var.deployment_name_prefix
2210  operator_image        = "ghcr.io/greenticai/greentic-start-distroless@${var.operator_image_digest}"
2211  bundle_source         = var.bundle_source
2212  bundle_s3_object_ref  = var.bundle_s3_object_ref
2213  bundle_s3_object_arn  = var.bundle_s3_object_arn
2214  bundle_digest         = var.bundle_digest
2215  repo_registry_base    = var.repo_registry_base
2216  store_registry_base   = var.store_registry_base
2217  admin_allowed_clients = var.admin_allowed_clients
2218  public_base_url       = var.public_base_url
2219  runtime_secret_prefix = var.runtime_secret_prefix
2220  runtime_secret_env    = var.runtime_secret_env
2221  secrets_map           = var.secrets_map"#,
2222        ),
2223        crate::config::Provider::Azure => (
2224            "operator",
2225            "./modules/operator-azure",
2226            r#"  cloud                 = var.cloud
2227  tenant                = var.tenant
2228  environment           = var.environment
2229  deployment_name_prefix = var.deployment_name_prefix
2230  bundle_digest         = var.bundle_digest
2231  bundle_source         = var.bundle_source
2232  repo_registry_base    = var.repo_registry_base
2233  store_registry_base   = var.store_registry_base
2234  operator_image        = "ghcr.io/greenticai/greentic-start-distroless@${var.operator_image_digest}"
2235  admin_allowed_clients = var.admin_allowed_clients
2236  public_base_url       = var.public_base_url
2237  azure_key_vault_uri   = var.azure_key_vault_uri
2238  azure_key_vault_id    = var.azure_key_vault_id
2239  azure_location        = var.azure_location
2240  runtime_secret_prefix = var.runtime_secret_prefix
2241  runtime_secret_env    = var.runtime_secret_env
2242  secrets_map           = var.secrets_map"#,
2243        ),
2244        crate::config::Provider::Gcp => (
2245            "operator",
2246            "./modules/operator-gcp",
2247            r#"  cloud                 = var.cloud
2248  tenant                = var.tenant
2249  environment           = var.environment
2250  deployment_name_prefix = var.deployment_name_prefix
2251  bundle_digest         = var.bundle_digest
2252  bundle_source         = var.bundle_source
2253  repo_registry_base    = var.repo_registry_base
2254  store_registry_base   = var.store_registry_base
2255  operator_image        = "ghcr.io/greenticai/greentic-start-distroless@${var.operator_image_digest}"
2256  admin_allowed_clients = var.admin_allowed_clients
2257  public_base_url       = var.public_base_url
2258  gcp_project_id        = var.gcp_project_id
2259  gcp_region            = var.gcp_region
2260  runtime_secret_prefix = var.runtime_secret_prefix
2261  runtime_secret_env    = var.runtime_secret_env
2262  secrets_map           = var.secrets_map"#,
2263        ),
2264        _ => return Ok(()),
2265    };
2266
2267    let main_tf = format!(
2268        "module \"{module_name}\" {{\n  source = \"{module_source}\"\n\n{module_inputs}\n}}\n\nmodule \"dns\" {{\n  count  = var.dns_name != \"\" ? 1 : 0\n  source = \"./modules/dns\"\n\n  dns_name = var.dns_name\n}}\n\nmodule \"registry\" {{\n  source = \"./modules/registry\"\n\n  bundle_source = var.bundle_source\n  bundle_digest = var.bundle_digest\n}}\n"
2269    );
2270    fs::write(terraform_root.join("main.tf"), main_tf)?;
2271
2272    let relay_outputs = match config.provider {
2273        crate::config::Provider::Azure | crate::config::Provider::Gcp => format!(
2274            r#"
2275output "admin_access_mode" {{
2276  value = module.{module_name}.admin_access_mode
2277}}
2278
2279output "admin_public_endpoint" {{
2280  value = module.{module_name}.admin_public_endpoint
2281}}
2282
2283output "admin_relay_token_secret_ref" {{
2284  value = module.{module_name}.admin_relay_token_secret_ref
2285}}
2286"#
2287        ),
2288        _ => String::new(),
2289    };
2290
2291    let outputs_tf = format!(
2292        r#"output "operator_endpoint" {{
2293  value = module.{module_name}.operator_endpoint
2294}}
2295
2296output "cloud_provider" {{
2297  value = var.cloud
2298}}
2299
2300output "admin_ca_secret_ref" {{
2301  value = module.{module_name}.admin_ca_secret_ref
2302}}
2303
2304output "admin_server_cert_secret_ref" {{
2305  value = module.{module_name}.admin_server_cert_secret_ref
2306}}
2307
2308output "admin_server_key_secret_ref" {{
2309  value = module.{module_name}.admin_server_key_secret_ref
2310}}
2311
2312output "admin_client_cert_secret_ref" {{
2313  value = module.{module_name}.admin_client_cert_secret_ref
2314}}
2315
2316output "admin_client_key_secret_ref" {{
2317  value = module.{module_name}.admin_client_key_secret_ref
2318}}
2319{relay_outputs}"#
2320    );
2321    fs::write(terraform_root.join("outputs.tf"), outputs_tf)?;
2322    ensure_terraform_variable_declared(
2323        &terraform_root.join("variables.tf"),
2324        "deployment_name_prefix",
2325        "string",
2326        Some(""),
2327    )?;
2328    ensure_terraform_variable_declared(
2329        &terraform_root.join("variables.tf"),
2330        "bundle_s3_object_ref",
2331        "string",
2332        Some(""),
2333    )?;
2334    ensure_terraform_variable_declared(
2335        &terraform_root.join("variables.tf"),
2336        "bundle_s3_object_arn",
2337        "string",
2338        Some(""),
2339    )?;
2340    ensure_terraform_variable_declared(
2341        &terraform_root.join("variables.tf"),
2342        "runtime_secret_prefix",
2343        "string",
2344        Some(""),
2345    )?;
2346    ensure_terraform_variable_declared(
2347        &terraform_root.join("variables.tf"),
2348        "runtime_secret_env",
2349        "map(string)",
2350        Some("{}"),
2351    )?;
2352    ensure_terraform_variable_declared(
2353        &terraform_root.join("variables.tf"),
2354        "secrets_map",
2355        "map(string)",
2356        Some("{}"),
2357    )?;
2358    let module_variables = match config.provider {
2359        crate::config::Provider::Aws => Some(terraform_root.join("modules/operator/variables.tf")),
2360        crate::config::Provider::Azure => {
2361            Some(terraform_root.join("modules/operator-azure/variables.tf"))
2362        }
2363        crate::config::Provider::Gcp => {
2364            Some(terraform_root.join("modules/operator-gcp/variables.tf"))
2365        }
2366        _ => None,
2367    };
2368    if let Some(module_variables) = module_variables {
2369        ensure_terraform_variable_declared(
2370            &module_variables,
2371            "deployment_name_prefix",
2372            "string",
2373            Some(""),
2374        )?;
2375        ensure_terraform_variable_declared(
2376            &module_variables,
2377            "bundle_s3_object_ref",
2378            "string",
2379            Some(""),
2380        )?;
2381        ensure_terraform_variable_declared(
2382            &module_variables,
2383            "bundle_s3_object_arn",
2384            "string",
2385            Some(""),
2386        )?;
2387        ensure_terraform_variable_declared(
2388            &module_variables,
2389            "runtime_secret_prefix",
2390            "string",
2391            Some(""),
2392        )?;
2393        ensure_terraform_variable_declared(
2394            &module_variables,
2395            "runtime_secret_env",
2396            "map(string)",
2397            Some("{}"),
2398        )?;
2399        ensure_terraform_variable_declared(
2400            &module_variables,
2401            "secrets_map",
2402            "map(string)",
2403            Some("{}"),
2404        )?;
2405    }
2406    if config.provider == crate::config::Provider::Aws {
2407        ensure_aws_runtime_secret_wiring(&terraform_root.join("modules/operator/main.tf"))?;
2408    }
2409
2410    Ok(())
2411}
2412
2413fn ensure_aws_runtime_secret_wiring(path: &Path) -> Result<()> {
2414    if !path.exists() {
2415        return Ok(());
2416    }
2417    let mut contents = fs::read_to_string(path)?;
2418    if contents.contains("runtime_secret_env")
2419        && contents.contains("task_runtime_secrets")
2420        && contents.contains("task_bundle_s3_object")
2421    {
2422        return Ok(());
2423    }
2424    if !contents.contains(r#"data "aws_caller_identity" "current""#) {
2425        contents = format!("data \"aws_caller_identity\" \"current\" {{}}\n\n{contents}");
2426    }
2427    if !contents.contains("task_runtime_secrets") {
2428        let marker = r#"resource "aws_ecs_task_definition" "this" {"#;
2429        let policy = r#"resource "aws_iam_role_policy" "task_runtime_secrets" {
2430  count = trimspace(var.runtime_secret_prefix) != "" ? 1 : 0
2431  name  = "${local.name_prefix}-task-runtime-secrets"
2432  role  = aws_iam_role.task_execution.id
2433
2434  policy = jsonencode({
2435    Version = "2012-10-17"
2436    Statement = [
2437      {
2438        Effect = "Allow"
2439        Action = [
2440          "secretsmanager:GetSecretValue"
2441        ]
2442        Resource = [
2443          "arn:aws:secretsmanager:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:secret:${trim(var.runtime_secret_prefix, "/")}/*"
2444        ]
2445      }
2446    ]
2447  })
2448}
2449
2450"#;
2451        contents = contents.replacen(marker, &format!("{policy}{marker}"), 1);
2452    }
2453    if !contents.contains("task_bundle_s3_object") {
2454        let marker = r#"resource "aws_ecs_task_definition" "this" {"#;
2455        let policy = r#"resource "aws_iam_role_policy" "task_bundle_s3_object" {
2456  count = trimspace(var.bundle_s3_object_arn) != "" ? 1 : 0
2457  name  = "${local.name_prefix}-task-bundle-s3-object"
2458  role  = aws_iam_role.task_execution.id
2459
2460  policy = jsonencode({
2461    Version = "2012-10-17"
2462    Statement = [
2463      {
2464        Effect = "Allow"
2465        Action = [
2466          "s3:GetObject"
2467        ]
2468        Resource = var.bundle_s3_object_arn
2469      }
2470    ]
2471  })
2472}
2473
2474"#;
2475        contents = contents.replacen(marker, &format!("{policy}{marker}"), 1);
2476    }
2477    if !contents.contains("bundle_fetcher_enabled") {
2478        if let Some(line) = contents
2479            .lines()
2480            .find(|line| line.trim_start().starts_with("admin_secret_prefix ="))
2481            .map(str::to_string)
2482        {
2483            let replacement = format!(
2484                "{line}\n  bundle_fetcher_enabled = trimspace(var.bundle_s3_object_ref) != \"\"\n  operator_bundle_source = local.bundle_fetcher_enabled ? \"/greentic-bundle/bundle.gtbundle\" : var.bundle_source"
2485            );
2486            contents = contents.replacen(&line, &replacement, 1);
2487        }
2488        contents = contents.replace(
2489            r#"  task_role_arn            = aws_iam_role.task_execution.arn
2490
2491  container_definitions = jsonencode(["#,
2492            r#"  task_role_arn            = aws_iam_role.task_execution.arn
2493
2494  volume {
2495    name = "greentic-bundle"
2496  }
2497
2498  container_definitions = jsonencode(concat(
2499    local.bundle_fetcher_enabled ? [
2500      {
2501        name      = "bundle-fetcher"
2502        image     = "public.ecr.aws/aws-cli/aws-cli:latest"
2503        essential = false
2504        command = [
2505          "s3",
2506          "cp",
2507          var.bundle_s3_object_ref,
2508          local.operator_bundle_source
2509        ]
2510        mountPoints = [
2511          {
2512            sourceVolume  = "greentic-bundle"
2513            containerPath = "/greentic-bundle"
2514            readOnly      = false
2515          }
2516        ]
2517        logConfiguration = {
2518          logDriver = "awslogs"
2519          options = {
2520            awslogs-group         = aws_cloudwatch_log_group.this.name
2521            awslogs-region        = data.aws_region.current.name
2522            awslogs-stream-prefix = "bundle-fetcher"
2523          }
2524        }
2525      }
2526    ] : [],
2527    ["#,
2528        );
2529        contents = contents.replace("var.bundle_source,", "local.operator_bundle_source,");
2530        contents = contents.replace(
2531            r#"            name  = "GREENTIC_BUNDLE_SOURCE"
2532            value = var.bundle_source"#,
2533            r#"            name  = "GREENTIC_BUNDLE_SOURCE"
2534            value = local.operator_bundle_source"#,
2535        );
2536        contents = contents.replace(
2537            r#"      portMappings = ["#,
2538            r#"      dependsOn = local.bundle_fetcher_enabled ? [
2539        {
2540          containerName = "bundle-fetcher"
2541          condition     = "SUCCESS"
2542        }
2543      ] : []
2544      mountPoints = local.bundle_fetcher_enabled ? [
2545        {
2546          sourceVolume  = "greentic-bundle"
2547          containerPath = "/greentic-bundle"
2548          readOnly      = true
2549        }
2550      ] : []
2551      portMappings = ["#,
2552        );
2553        contents = contents.replace(
2554            r#"    }
2555  ])
2556
2557  tags = local.common_tags"#,
2558            r#"    }
2559    ]
2560  ))
2561
2562  tags = local.common_tags"#,
2563        );
2564    }
2565    fs::write(path, contents)?;
2566    Ok(())
2567}
2568
2569fn ensure_terraform_variable_declared(
2570    path: &Path,
2571    name: &str,
2572    ty: &str,
2573    default: Option<&str>,
2574) -> Result<()> {
2575    let declaration = match default {
2576        Some(value) => {
2577            let rendered_default = if ty.starts_with("map(") && value == "{}" {
2578                "{}".to_string()
2579            } else {
2580                serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
2581            };
2582            format!(
2583                "variable \"{name}\" {{\n  type    = {ty}\n  default = {rendered_default}\n}}\n"
2584            )
2585        }
2586        None => format!("variable \"{name}\" {{\n  type = {ty}\n}}\n"),
2587    };
2588
2589    let mut contents = if path.exists() {
2590        fs::read_to_string(path)?
2591    } else {
2592        String::new()
2593    };
2594    if contents.contains(&format!("variable \"{name}\"")) {
2595        return Ok(());
2596    }
2597    if !contents.is_empty() && !contents.ends_with('\n') {
2598        contents.push('\n');
2599    }
2600    contents.push_str(&declaration);
2601    fs::write(path, contents)?;
2602    Ok(())
2603}
2604
2605fn aws_bundle_s3_object_arn(bundle_source: &str) -> Option<String> {
2606    let rest = bundle_source.trim().strip_prefix("s3://")?;
2607    let (bucket, key) = rest.split_once('/')?;
2608    let key = key.trim_start_matches('/');
2609    if bucket.is_empty() || key.is_empty() {
2610        return None;
2611    }
2612    Some(format!("arn:aws:s3:::{bucket}/{key}"))
2613}
2614
2615fn materialize_generated_tfvars(
2616    config: &DeployerConfig,
2617    terraform_root: &Path,
2618    tfvars_example: &str,
2619) -> Result<Option<String>> {
2620    if config.bundle_source.is_none()
2621        && config.bundle_digest.is_none()
2622        && terraform_env_overrides().is_empty()
2623    {
2624        return Ok(None);
2625    }
2626
2627    let example_path = terraform_root.join(tfvars_example);
2628    let output_name = format!("{}.tfvars", config.environment);
2629    let output_path = terraform_root.join(&output_name);
2630
2631    let mut contents = if example_path.exists() {
2632        fs::read_to_string(&example_path)?
2633    } else {
2634        String::new()
2635    };
2636
2637    replace_tfvars_assignment(&mut contents, "cloud", config.provider.as_str());
2638    replace_tfvars_assignment(&mut contents, "tenant", &config.tenant);
2639    replace_tfvars_assignment(&mut contents, "environment", &config.environment);
2640    let deployment_name_prefix = resolve_terraform_deployment_name_prefix(config, &output_path);
2641    replace_tfvars_assignment(
2642        &mut contents,
2643        "deployment_name_prefix",
2644        &deployment_name_prefix,
2645    );
2646
2647    for (key, value) in terraform_contract_default_overrides(config.provider) {
2648        replace_tfvars_assignment(&mut contents, &key, &value);
2649    }
2650
2651    if let Some(bundle_source) = config.bundle_source.as_ref() {
2652        replace_tfvars_assignment(&mut contents, "bundle_source", bundle_source);
2653        if let Some(s3_arn) = aws_bundle_s3_object_arn(bundle_source) {
2654            replace_tfvars_assignment(&mut contents, "bundle_s3_object_arn", &s3_arn);
2655        }
2656    }
2657    if let Some(bundle_digest) = config.bundle_digest.as_ref() {
2658        replace_tfvars_assignment(&mut contents, "bundle_digest", bundle_digest);
2659    }
2660    if let Some(repo_registry_base) = config.repo_registry_base.as_ref() {
2661        replace_tfvars_assignment(&mut contents, "repo_registry_base", repo_registry_base);
2662    }
2663    if let Some(store_registry_base) = config.store_registry_base.as_ref() {
2664        replace_tfvars_assignment(&mut contents, "store_registry_base", store_registry_base);
2665    }
2666    if matches!(
2667        config.provider,
2668        crate::config::Provider::Aws
2669            | crate::config::Provider::Azure
2670            | crate::config::Provider::Gcp
2671    ) {
2672        replace_tfvars_assignment(
2673            &mut contents,
2674            "runtime_secret_prefix",
2675            &crate::runtime_secrets::default_cloud_secret_prefix(
2676                &config.environment,
2677                &config.tenant,
2678                None,
2679            ),
2680        );
2681        let runtime_secret_env = crate::runtime_secrets::runtime_secret_env_map_for_cloud(config)?;
2682        replace_tfvars_map_assignment(&mut contents, "runtime_secret_env", &runtime_secret_env);
2683    }
2684    for (key, value) in terraform_env_overrides() {
2685        replace_tfvars_assignment(&mut contents, &key, &value);
2686    }
2687    apply_operator_secrets_map_tfvar(&mut contents);
2688    normalize_public_base_url_assignment(&mut contents);
2689
2690    fs::write(output_path, contents)?;
2691    Ok(Some(output_name))
2692}
2693
2694/// PR-08: lift operator secrets into the generated tfvars file so the AWS
2695/// operator module materialises them as Secrets Manager entries and injects
2696/// them into the ECS task definition's `secrets` block.
2697///
2698/// Source contract (v1): a JSON object at the path indicated by
2699/// `GREENTIC_OPERATOR_SECRETS_JSON`, mapping canonical `secrets://...` URIs
2700/// to UTF-8 string values. When the env var is unset, the file is missing,
2701/// or the JSON is empty, no `secrets_map` assignment is written and the
2702/// terraform default (`{}`) applies — no operator-secret resources are
2703/// created. The bundle artifact never carries these values.
2704///
2705/// A future `gtc secrets export --json <path>` will be the canonical
2706/// producer; meanwhile the env-var contract lets operators or CI assemble
2707/// the JSON out-of-band and hand it to `gtc deploy`.
2708///
2709/// Logging policy: only the count is printed. Values, keys, and the file
2710/// path never leave the deployer process via stdout/stderr.
2711fn apply_operator_secrets_map_tfvar(contents: &mut String) {
2712    let Some(map) = load_operator_secrets_map() else {
2713        return;
2714    };
2715    if map.is_empty() {
2716        return;
2717    }
2718    let rendered = render_terraform_map(&map);
2719    replace_tfvars_assignment_literal(contents, "secrets_map", &rendered);
2720    eprintln!(
2721        "operator secrets: applied {} entr{} to tfvars (source=GREENTIC_OPERATOR_SECRETS_JSON)",
2722        map.len(),
2723        if map.len() == 1 { "y" } else { "ies" }
2724    );
2725}
2726
2727fn load_operator_secrets_map() -> Option<std::collections::BTreeMap<String, String>> {
2728    let path = std::env::var("GREENTIC_OPERATOR_SECRETS_JSON")
2729        .ok()
2730        .map(std::path::PathBuf::from)?;
2731    if !path.is_file() {
2732        return None;
2733    }
2734    let raw = fs::read_to_string(&path).ok()?;
2735    let parsed: std::collections::BTreeMap<String, String> = serde_json::from_str(&raw).ok()?;
2736    Some(parsed)
2737}
2738
2739/// Render a Rust map as a terraform HCL map literal.
2740///
2741/// Values are JSON-quoted (the same escape contract `replace_tfvars_assignment`
2742/// uses for plain strings), so the result is `{ "k" = "v", ... }` with each
2743/// pair on its own line for readability. Terraform marks the variable
2744/// `sensitive` at the schema level, so plan/apply suppresses values.
2745fn render_terraform_map(map: &std::collections::BTreeMap<String, String>) -> String {
2746    let mut out = String::from("{\n");
2747    for (key, value) in map {
2748        let key_q = serde_json::to_string(key).unwrap_or_else(|_| format!("\"{key}\""));
2749        let value_q = serde_json::to_string(value).unwrap_or_else(|_| format!("\"{value}\""));
2750        out.push_str(&format!("  {key_q} = {value_q}\n"));
2751    }
2752    out.push('}');
2753    out
2754}
2755
2756/// Like `replace_tfvars_assignment` but treats `value` as an already-rendered
2757/// HCL literal (map/list/etc.) instead of JSON-quoting it as a string.
2758fn replace_tfvars_assignment_literal(contents: &mut String, key: &str, value_literal: &str) {
2759    let replacement = format!("{key} = {value_literal}");
2760    let mut rewritten = Vec::new();
2761    let mut replaced = false;
2762    let mut iter = contents.lines().peekable();
2763    while let Some(line) = iter.next() {
2764        if !replaced && line.trim_start().starts_with(&format!("{key} = ")) {
2765            rewritten.push(replacement.clone());
2766            // Skip continuation lines of a previous multi-line literal until
2767            // we see a top-level closing `}` (terraform map/list literal).
2768            if line.trim_end().ends_with('{') || line.trim_end().ends_with('[') {
2769                for cont in iter.by_ref() {
2770                    if cont.trim() == "}" || cont.trim() == "]" {
2771                        break;
2772                    }
2773                }
2774            }
2775            replaced = true;
2776            continue;
2777        }
2778        rewritten.push(line.to_string());
2779    }
2780    if !replaced {
2781        if !contents.is_empty() && !contents.ends_with('\n') {
2782            rewritten.push(String::new());
2783        }
2784        rewritten.push(replacement);
2785    }
2786    let mut joined = rewritten.join("\n");
2787    if !joined.ends_with('\n') {
2788        joined.push('\n');
2789    }
2790    *contents = joined;
2791}
2792
2793fn resolve_terraform_deployment_name_prefix(config: &DeployerConfig, output_path: &Path) -> String {
2794    if let Some(prefix) = explicit_deployment_name_prefix() {
2795        return prefix;
2796    }
2797
2798    if let Ok(existing) = fs::read_to_string(output_path)
2799        && let Some(prefix) = read_tfvars_assignment(&existing, "deployment_name_prefix")
2800            .filter(|value| !value.trim().is_empty())
2801        && prefix != legacy_shared_deployment_name_prefix(config)
2802    {
2803        return prefix;
2804    }
2805
2806    stable_deployment_name_prefix(config)
2807}
2808
2809fn stable_deployment_name_prefix(config: &DeployerConfig) -> String {
2810    let seed = format!(
2811        "{}\0{}\0{}\0{}",
2812        config.provider.as_str(),
2813        config.tenant,
2814        config.environment,
2815        local_deployment_identity_seed(config),
2816    );
2817    format!("greentic-{:08x}", fnv1a32(seed.as_bytes()))
2818}
2819
2820fn legacy_shared_deployment_name_prefix(config: &DeployerConfig) -> String {
2821    let seed = format!(
2822        "{}\0{}\0{}",
2823        config.provider.as_str(),
2824        config.tenant,
2825        config.environment,
2826    );
2827    format!("greentic-{:08x}", fnv1a32(seed.as_bytes()))
2828}
2829
2830fn explicit_deployment_name_prefix() -> Option<String> {
2831    std::env::var("GREENTIC_DEPLOY_TERRAFORM_VAR_DEPLOYMENT_NAME_PREFIX")
2832        .ok()
2833        .or_else(|| std::env::var("GREENTIC_DEPLOYMENT_NAME_PREFIX").ok())
2834        .and_then(|value| {
2835            let normalized = normalize_deployment_name_prefix(&value);
2836            (!normalized.is_empty()).then_some(normalized)
2837        })
2838}
2839
2840fn local_deployment_identity_seed(config: &DeployerConfig) -> String {
2841    std::env::var("GREENTIC_DEPLOYMENT_ID")
2842        .ok()
2843        .filter(|value| !value.trim().is_empty())
2844        .unwrap_or_else(|| {
2845            let owner = std::env::var("GREENTIC_DEPLOYMENT_OWNER")
2846                .ok()
2847                .or_else(|| std::env::var("USER").ok())
2848                .or_else(|| std::env::var("USERNAME").ok())
2849                .filter(|value| !value.trim().is_empty())
2850                .unwrap_or_else(|| "unknown".to_string());
2851            let workspace = config
2852                .bundle_root
2853                .as_ref()
2854                .and_then(|path| path.canonicalize().ok())
2855                .or_else(|| std::env::current_dir().ok())
2856                .map(|path| path.display().to_string())
2857                .unwrap_or_else(|| "unknown".to_string());
2858            format!("{owner}\0{workspace}")
2859        })
2860}
2861
2862fn normalize_deployment_name_prefix(value: &str) -> String {
2863    let mut normalized = value
2864        .trim()
2865        .chars()
2866        .map(|ch| {
2867            if ch.is_ascii_alphanumeric() {
2868                ch.to_ascii_lowercase()
2869            } else {
2870                '-'
2871            }
2872        })
2873        .collect::<String>();
2874    while normalized.contains("--") {
2875        normalized = normalized.replace("--", "-");
2876    }
2877    normalized = normalized.trim_matches('-').to_string();
2878    if normalized.is_empty() {
2879        return normalized;
2880    }
2881    if !normalized
2882        .chars()
2883        .next()
2884        .is_some_and(|ch| ch.is_ascii_alphabetic())
2885    {
2886        normalized = format!("greentic-{normalized}");
2887    }
2888    if normalized.len() > 24 {
2889        normalized.truncate(24);
2890        normalized = normalized.trim_end_matches('-').to_string();
2891    }
2892    normalized
2893}
2894
2895fn fnv1a32(bytes: &[u8]) -> u32 {
2896    const OFFSET: u32 = 0x811c9dc5;
2897    const PRIME: u32 = 0x01000193;
2898
2899    let mut hash = OFFSET;
2900    for byte in bytes {
2901        hash ^= u32::from(*byte);
2902        hash = hash.wrapping_mul(PRIME);
2903    }
2904    hash
2905}
2906
2907fn normalize_public_base_url_assignment(contents: &mut String) {
2908    let dns_name = read_tfvars_assignment(contents, "dns_name");
2909    let public_base_url = read_tfvars_assignment(contents, "public_base_url");
2910
2911    if let Some(dns_name) = dns_name.filter(|value| !value.trim().is_empty()) {
2912        replace_tfvars_assignment(contents, "public_base_url", &format!("https://{dns_name}"));
2913        return;
2914    }
2915
2916    if let Some(public_base_url) = public_base_url {
2917        let normalized = public_base_url
2918            .trim()
2919            .trim_end_matches('/')
2920            .to_ascii_lowercase();
2921        let is_placeholder = normalized.is_empty()
2922            || normalized.contains("example.com")
2923            || normalized.contains("localhost")
2924            || normalized.contains("127.0.0.1");
2925        if is_placeholder {
2926            replace_tfvars_assignment(contents, "public_base_url", "");
2927        }
2928    }
2929}
2930
2931fn terraform_contract_default_overrides(provider: Provider) -> Vec<(String, String)> {
2932    let Some(requirements) = crate::contract::CloudTargetRequirementsV1::for_provider(provider)
2933    else {
2934        return Vec::new();
2935    };
2936
2937    let mut overrides = requirements
2938        .variable_requirements
2939        .into_iter()
2940        .filter_map(|entry| {
2941            let key = normalize_terraform_requirement_name(&entry.name)?;
2942            let value = entry.default_value?;
2943            Some((key, value))
2944        })
2945        .collect::<Vec<_>>();
2946    overrides.sort_by(|a, b| a.0.cmp(&b.0));
2947    overrides
2948}
2949
2950fn normalize_terraform_requirement_name(name: &str) -> Option<String> {
2951    const PREFIX: &str = "GREENTIC_DEPLOY_TERRAFORM_VAR_";
2952    let suffix = name.strip_prefix(PREFIX)?;
2953    let normalized = suffix.trim();
2954    if normalized.is_empty() {
2955        return None;
2956    }
2957    Some(
2958        normalized
2959            .to_ascii_lowercase()
2960            .replace("__", "-")
2961            .replace('_', ".")
2962            .replace('.', "_"),
2963    )
2964}
2965
2966fn resolve_tfvars_example_name(terraform_root: &Path, environment: &str) -> Result<String> {
2967    let preferred = format!("{environment}.tfvars.example");
2968    if terraform_root.join(&preferred).exists() {
2969        return Ok(preferred);
2970    }
2971
2972    let mut candidates = fs::read_dir(terraform_root)?
2973        .filter_map(|entry| entry.ok())
2974        .filter_map(|entry| {
2975            let file_type = entry.file_type().ok()?;
2976            if !file_type.is_file() {
2977                return None;
2978            }
2979            let name = entry.file_name();
2980            let name = name.to_str()?;
2981            name.ends_with(".tfvars.example").then(|| name.to_string())
2982        })
2983        .collect::<Vec<_>>();
2984    candidates.sort();
2985
2986    Ok(candidates.into_iter().next().unwrap_or(preferred))
2987}
2988
2989fn terraform_env_overrides() -> Vec<(String, String)> {
2990    const PREFIX: &str = "GREENTIC_DEPLOY_TERRAFORM_VAR_";
2991    let mut overrides = std::env::vars()
2992        .filter_map(|(key, value)| {
2993            let suffix = key.strip_prefix(PREFIX)?;
2994            let normalized = suffix.trim();
2995            if normalized.is_empty() {
2996                return None;
2997            }
2998            Some((normalized.to_ascii_lowercase(), value))
2999        })
3000        .map(|(key, value)| (key.replace("__", "-").replace('_', "."), value))
3001        .map(|(key, value)| (key.replace('.', "_"), value))
3002        .collect::<Vec<_>>();
3003    overrides.sort_by(|a, b| a.0.cmp(&b.0));
3004    overrides
3005}
3006
3007fn replace_tfvars_assignment(contents: &mut String, key: &str, value: &str) {
3008    let replacement = format!(
3009        "{key} = {}",
3010        serde_json::to_string(value).unwrap_or_else(|_| format!("\"{value}\""))
3011    );
3012
3013    let mut rewritten = Vec::new();
3014    let mut replaced = false;
3015    for line in contents.lines() {
3016        let trimmed = line.trim_start();
3017        if !replaced && trimmed.starts_with(&format!("{key} =")) {
3018            rewritten.push(replacement.clone());
3019            replaced = true;
3020        } else {
3021            rewritten.push(line.to_string());
3022        }
3023    }
3024    if !replaced {
3025        rewritten.push(replacement);
3026    }
3027    *contents = rewritten.join("\n");
3028    contents.push('\n');
3029}
3030
3031fn replace_tfvars_map_assignment(
3032    contents: &mut String,
3033    key: &str,
3034    values: &BTreeMap<String, String>,
3035) {
3036    let replacement = if values.is_empty() {
3037        format!("{key} = {{}}")
3038    } else {
3039        let mut out = format!("{key} = {{\n");
3040        for (map_key, value) in values {
3041            out.push_str(&format!(
3042                "  {} = {}\n",
3043                serde_json::to_string(map_key).unwrap_or_else(|_| format!("\"{map_key}\"")),
3044                serde_json::to_string(value).unwrap_or_else(|_| format!("\"{value}\""))
3045            ));
3046        }
3047        out.push('}');
3048        out
3049    };
3050
3051    let mut rewritten = Vec::new();
3052    let mut replaced = false;
3053    let mut skipping_multiline = false;
3054    for line in contents.lines() {
3055        let trimmed = line.trim_start();
3056        if skipping_multiline {
3057            if trimmed == "}" {
3058                skipping_multiline = false;
3059            }
3060            continue;
3061        }
3062        if !replaced && trimmed.starts_with(&format!("{key} =")) {
3063            rewritten.push(replacement.clone());
3064            replaced = true;
3065            if trimmed.ends_with('{') && !trimmed.contains('}') {
3066                skipping_multiline = true;
3067            }
3068        } else {
3069            rewritten.push(line.to_string());
3070        }
3071    }
3072    if !replaced {
3073        rewritten.push(replacement);
3074    }
3075    *contents = rewritten.join("\n");
3076    contents.push('\n');
3077}
3078
3079fn read_tfvars_assignment(contents: &str, key: &str) -> Option<String> {
3080    for line in contents.lines() {
3081        let trimmed = line.trim();
3082        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
3083            continue;
3084        }
3085        let (lhs, rhs) = trimmed.split_once('=')?;
3086        if lhs.trim() != key {
3087            continue;
3088        }
3089        let value = rhs
3090            .split('#')
3091            .next()
3092            .map(str::trim)
3093            .map(|segment| segment.trim_matches('"'))
3094            .unwrap_or_default();
3095        return Some(value.to_string());
3096    }
3097    None
3098}
3099
3100fn materialize_k8s_raw_handoff_assets(
3101    _config: &DeployerConfig,
3102    selection: &DeploymentPackSelection,
3103    deploy_dir: &Path,
3104) -> Result<()> {
3105    let manifests = read_pack_asset(
3106        &selection.pack_path,
3107        "assets/examples/rendered-manifests.yaml",
3108    )?;
3109    let k8s_root = deploy_dir.join("k8s");
3110    fs::create_dir_all(&k8s_root)?;
3111    fs::write(k8s_root.join("rendered-manifests.yaml"), manifests)?;
3112    write_executable_script(
3113        &deploy_dir.join("kubectl-apply.sh"),
3114        kubectl_script("apply -f \"$K8S_ROOT/rendered-manifests.yaml\" \"$@\""),
3115    )?;
3116    write_executable_script(
3117        &deploy_dir.join("kubectl-delete.sh"),
3118        kubectl_script("delete -f \"$K8S_ROOT/rendered-manifests.yaml\" \"$@\""),
3119    )?;
3120    write_executable_script(
3121        &deploy_dir.join("kubectl-status.sh"),
3122        kubectl_script("get -f \"$K8S_ROOT/rendered-manifests.yaml\" \"$@\""),
3123    )?;
3124
3125    let mut note = String::new();
3126    note.push_str("K8s raw handoff assets were materialized from the deployment pack.\n");
3127    note.push_str(&format!(
3128        "manifest_path={}\n",
3129        k8s_root.join("rendered-manifests.yaml").display()
3130    ));
3131    note.push_str("scripts=kubectl-apply.sh, kubectl-delete.sh, kubectl-status.sh\n");
3132    fs::write(deploy_dir.join("k8s-handoff.txt"), note)?;
3133    Ok(())
3134}
3135
3136fn materialize_helm_handoff_assets(
3137    config: &DeployerConfig,
3138    selection: &DeploymentPackSelection,
3139    deploy_dir: &Path,
3140) -> Result<()> {
3141    let chart_root = deploy_dir.join("helm-chart");
3142    let copied = copy_pack_subtree(&selection.pack_path, "chart", &chart_root)?;
3143    if copied.is_empty() {
3144        return Ok(());
3145    }
3146
3147    let release_name = format!("greentic-{}", config.tenant);
3148    write_executable_script(
3149        &deploy_dir.join("helm-upgrade.sh"),
3150        helm_script(&format!(
3151            "upgrade --install {release_name} \"$CHART_ROOT\" \"$@\""
3152        )),
3153    )?;
3154    write_executable_script(
3155        &deploy_dir.join("helm-rollback.sh"),
3156        helm_script(&format!("rollback {release_name} \"$@\"")),
3157    )?;
3158    write_executable_script(
3159        &deploy_dir.join("helm-status.sh"),
3160        helm_script(&format!("status {release_name} \"$@\"")),
3161    )?;
3162
3163    let mut note = String::new();
3164    note.push_str("Helm handoff assets were materialized from the deployment pack.\n");
3165    note.push_str(&format!("chart_root={}\n", chart_root.display()));
3166    note.push_str(&format!("release_name={release_name}\n"));
3167    note.push_str("scripts=helm-upgrade.sh, helm-rollback.sh, helm-status.sh\n");
3168    note.push_str("copied_files:\n");
3169    for path in copied {
3170        note.push_str(&format!("- {path}\n"));
3171    }
3172    fs::write(deploy_dir.join("helm-handoff.txt"), note)?;
3173    Ok(())
3174}
3175
3176fn materialize_operator_handoff_assets(
3177    _config: &DeployerConfig,
3178    selection: &DeploymentPackSelection,
3179    deploy_dir: &Path,
3180) -> Result<()> {
3181    let manifests = read_pack_asset(
3182        &selection.pack_path,
3183        "assets/examples/rendered-manifests.yaml",
3184    )?;
3185    let operator_root = deploy_dir.join("operator");
3186    fs::create_dir_all(&operator_root)?;
3187    fs::write(operator_root.join("rendered-manifests.yaml"), manifests)?;
3188    write_executable_script(
3189        &deploy_dir.join("operator-apply.sh"),
3190        kubectl_root_script(
3191            "OPERATOR_ROOT",
3192            "apply -f \"$OPERATOR_ROOT/rendered-manifests.yaml\" \"$@\"",
3193        ),
3194    )?;
3195    write_executable_script(
3196        &deploy_dir.join("operator-delete.sh"),
3197        kubectl_root_script(
3198            "OPERATOR_ROOT",
3199            "delete -f \"$OPERATOR_ROOT/rendered-manifests.yaml\" \"$@\"",
3200        ),
3201    )?;
3202    write_executable_script(
3203        &deploy_dir.join("operator-status.sh"),
3204        kubectl_root_script(
3205            "OPERATOR_ROOT",
3206            "get -f \"$OPERATOR_ROOT/rendered-manifests.yaml\" \"$@\"",
3207        ),
3208    )?;
3209
3210    let mut note = String::new();
3211    note.push_str("Operator handoff assets were materialized from the deployment pack.\n");
3212    note.push_str(&format!(
3213        "manifest_path={}\n",
3214        operator_root.join("rendered-manifests.yaml").display()
3215    ));
3216    note.push_str("scripts=operator-apply.sh, operator-delete.sh, operator-status.sh\n");
3217    note.push_str("admin_api=localhost_only_https_mtls\n");
3218    fs::write(deploy_dir.join("operator-handoff.txt"), note)?;
3219    Ok(())
3220}
3221
3222fn materialize_serverless_handoff_assets(
3223    _config: &DeployerConfig,
3224    selection: &DeploymentPackSelection,
3225    deploy_dir: &Path,
3226) -> Result<()> {
3227    let descriptor = read_pack_asset(
3228        &selection.pack_path,
3229        "assets/examples/deployment-descriptor.json",
3230    )?;
3231    let serverless_root = deploy_dir.join("serverless");
3232    fs::create_dir_all(&serverless_root)?;
3233    fs::write(
3234        serverless_root.join("deployment-descriptor.json"),
3235        descriptor,
3236    )?;
3237    write_executable_script(
3238        &deploy_dir.join("serverless-deploy.sh"),
3239        generic_root_script(
3240            "SERVERLESS_ROOT",
3241            "echo \"serverless deploy descriptor: $SERVERLESS_ROOT/deployment-descriptor.json\"",
3242        ),
3243    )?;
3244    write_executable_script(
3245        &deploy_dir.join("serverless-status.sh"),
3246        generic_root_script(
3247            "SERVERLESS_ROOT",
3248            "echo \"serverless status descriptor: $SERVERLESS_ROOT/deployment-descriptor.json\"",
3249        ),
3250    )?;
3251    write_executable_script(
3252        &deploy_dir.join("serverless-destroy.sh"),
3253        generic_root_script(
3254            "SERVERLESS_ROOT",
3255            "echo \"serverless destroy descriptor: $SERVERLESS_ROOT/deployment-descriptor.json\"",
3256        ),
3257    )?;
3258
3259    let mut note = String::new();
3260    note.push_str("Serverless handoff assets were materialized from the deployment pack.\n");
3261    note.push_str(&format!(
3262        "descriptor_path={}\n",
3263        serverless_root.join("deployment-descriptor.json").display()
3264    ));
3265    note.push_str("scripts=serverless-deploy.sh, serverless-destroy.sh, serverless-status.sh\n");
3266    note.push_str("filesystem_hint=tmp_only\n");
3267    fs::write(deploy_dir.join("serverless-handoff.txt"), note)?;
3268    Ok(())
3269}
3270
3271fn materialize_snap_handoff_assets(
3272    _config: &DeployerConfig,
3273    selection: &DeploymentPackSelection,
3274    deploy_dir: &Path,
3275) -> Result<()> {
3276    let snap_root = deploy_dir.join("snap");
3277    let copied = copy_pack_subtree(&selection.pack_path, "snap", &snap_root)?;
3278    if copied.is_empty() {
3279        return Ok(());
3280    }
3281    write_executable_script(
3282        &deploy_dir.join("snap-install.sh"),
3283        generic_root_script(
3284            "SNAP_ROOT",
3285            "echo \"snap install scaffold from $SNAP_ROOT/fetch/snapcraft.yaml\"",
3286        ),
3287    )?;
3288    write_executable_script(
3289        &deploy_dir.join("snap-remove.sh"),
3290        generic_root_script(
3291            "SNAP_ROOT",
3292            "echo \"snap remove scaffold from $SNAP_ROOT/embedded/snapcraft.yaml\"",
3293        ),
3294    )?;
3295    write_executable_script(
3296        &deploy_dir.join("snap-status.sh"),
3297        generic_root_script(
3298            "SNAP_ROOT",
3299            "echo \"snap status scaffold from $SNAP_ROOT/fetch/snapcraft.yaml\"",
3300        ),
3301    )?;
3302
3303    let mut note = String::new();
3304    note.push_str("Snap handoff assets were materialized from the deployment pack.\n");
3305    note.push_str(&format!("snap_root={}\n", snap_root.display()));
3306    note.push_str("scripts=snap-install.sh, snap-remove.sh, snap-status.sh\n");
3307    note.push_str("copied_files:\n");
3308    for path in copied {
3309        note.push_str(&format!("- {path}\n"));
3310    }
3311    fs::write(deploy_dir.join("snap-handoff.txt"), note)?;
3312    Ok(())
3313}
3314
3315fn materialize_juju_machine_handoff_assets(
3316    _config: &DeployerConfig,
3317    selection: &DeploymentPackSelection,
3318    deploy_dir: &Path,
3319) -> Result<()> {
3320    let charm_root = deploy_dir.join("juju-machine-charm");
3321    let copied = copy_pack_subtree(&selection.pack_path, "charm", &charm_root)?;
3322    if copied.is_empty() {
3323        return Ok(());
3324    }
3325    write_executable_script(
3326        &deploy_dir.join("juju-machine-deploy.sh"),
3327        juju_script(
3328            "juju-machine-charm",
3329            "deploy \"$CHARM_ROOT\" greentic-operator \"$@\"",
3330        ),
3331    )?;
3332    write_executable_script(
3333        &deploy_dir.join("juju-machine-remove.sh"),
3334        juju_script(
3335            "juju-machine-charm",
3336            "remove-application greentic-operator \"$@\"",
3337        ),
3338    )?;
3339    write_executable_script(
3340        &deploy_dir.join("juju-machine-status.sh"),
3341        juju_script("juju-machine-charm", "status greentic-operator \"$@\""),
3342    )?;
3343
3344    let mut note = String::new();
3345    note.push_str("Juju machine handoff assets were materialized from the deployment pack.\n");
3346    note.push_str(&format!("charm_root={}\n", charm_root.display()));
3347    note.push_str(
3348        "scripts=juju-machine-deploy.sh, juju-machine-remove.sh, juju-machine-status.sh\n",
3349    );
3350    note.push_str("copied_files:\n");
3351    for path in copied {
3352        note.push_str(&format!("- {path}\n"));
3353    }
3354    fs::write(deploy_dir.join("juju-machine-handoff.txt"), note)?;
3355    Ok(())
3356}
3357
3358fn materialize_juju_k8s_handoff_assets(
3359    _config: &DeployerConfig,
3360    selection: &DeploymentPackSelection,
3361    deploy_dir: &Path,
3362) -> Result<()> {
3363    let charm_root = deploy_dir.join("juju-k8s-charm");
3364    let copied = copy_pack_subtree(&selection.pack_path, "charm", &charm_root)?;
3365    if copied.is_empty() {
3366        return Ok(());
3367    }
3368    write_executable_script(
3369        &deploy_dir.join("juju-k8s-deploy.sh"),
3370        juju_script(
3371            "juju-k8s-charm",
3372            "deploy \"$CHARM_ROOT\" greentic-operator-k8s \"$@\"",
3373        ),
3374    )?;
3375    write_executable_script(
3376        &deploy_dir.join("juju-k8s-remove.sh"),
3377        juju_script(
3378            "juju-k8s-charm",
3379            "remove-application greentic-operator-k8s \"$@\"",
3380        ),
3381    )?;
3382    write_executable_script(
3383        &deploy_dir.join("juju-k8s-status.sh"),
3384        juju_script("juju-k8s-charm", "status greentic-operator-k8s \"$@\""),
3385    )?;
3386
3387    let mut note = String::new();
3388    note.push_str("Juju k8s handoff assets were materialized from the deployment pack.\n");
3389    note.push_str(&format!("charm_root={}\n", charm_root.display()));
3390    note.push_str("scripts=juju-k8s-deploy.sh, juju-k8s-remove.sh, juju-k8s-status.sh\n");
3391    note.push_str("copied_files:\n");
3392    for path in copied {
3393        note.push_str(&format!("- {path}\n"));
3394    }
3395    fs::write(deploy_dir.join("juju-k8s-handoff.txt"), note)?;
3396    Ok(())
3397}
3398
3399fn terraform_script_prelude(command: &str) -> String {
3400    format!(
3401        "#!/usr/bin/env bash\nset -euo pipefail\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nTF_ROOT=\"${{SCRIPT_DIR}}/terraform\"\ncd \"$TF_ROOT\"\nTERRAFORM_BIN=\"terraform\"\nif [ -x \"$TF_ROOT/terraform\" ]; then\n  TERRAFORM_BIN=\"$TF_ROOT/terraform\"\nfi\n{command}\n"
3402    )
3403}
3404
3405fn terraform_init_script() -> String {
3406    "#!/usr/bin/env bash\nset -euo pipefail\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nTF_ROOT=\"${SCRIPT_DIR}/terraform\"\ncd \"$TF_ROOT\"\nTERRAFORM_BIN=\"terraform\"\nif [ -x \"$TF_ROOT/terraform\" ]; then\n  TERRAFORM_BIN=\"$TF_ROOT/terraform\"\nfi\nif [ -f \"${SCRIPT_DIR}/backend.hcl\" ]; then\n  \"$TERRAFORM_BIN\" init -backend-config=\"${SCRIPT_DIR}/backend.hcl\" \"$@\"\nelse\n  \"$TERRAFORM_BIN\" init \"$@\"\nfi\n"
3407        .to_string()
3408}
3409
3410fn terraform_hash_string_function() -> &'static str {
3411    r#"hash_string() {
3412  if command -v md5sum >/dev/null 2>&1; then
3413    printf '%s' "$1" | md5sum | awk '{print substr($1,1,8)}'
3414  else
3415    printf '%s' "$1" | md5 -q | awk '{print substr($1,1,8)}'
3416  fi
3417}
3418"#
3419}
3420
3421fn terraform_plan_like_script(
3422    operation: &str,
3423    provider: crate::config::Provider,
3424    generated_tfvars: Option<&str>,
3425    tfvars_example: &str,
3426) -> String {
3427    let extra_args = match operation {
3428        "apply" | "destroy" => " -auto-approve -input=false",
3429        _ => " -input=false",
3430    };
3431    let tfvars_lookup = if let Some(generated_tfvars) = generated_tfvars {
3432        format!(
3433            "if [ -f \"{generated_tfvars}\" ]; then\n  VAR_FILE=\"{generated_tfvars}\"\nelif [ -f \"{tfvars_example}\" ]; then\n  VAR_FILE=\"{tfvars_example}\"\nelse\n  for candidate in *.tfvars *.tfvars.example; do\n    if [ -f \"$candidate\" ]; then\n      VAR_FILE=\"$candidate\"\n      break\n    fi\n  done\nfi"
3434        )
3435    } else {
3436        format!(
3437            "if [ -f \"{tfvars_example}\" ]; then\n  VAR_FILE=\"{tfvars_example}\"\nelse\n  for candidate in *.tfvars *.tfvars.example; do\n    if [ -f \"$candidate\" ]; then\n      VAR_FILE=\"$candidate\"\n      break\n    fi\n  done\nfi"
3438        )
3439    };
3440    let pre_apply_hook = if operation == "apply" && provider == crate::config::Provider::Aws {
3441        r#"
3442if command -v aws >/dev/null 2>&1; then
3443  MODULE_ADDR=""
3444  if grep -q 'module "operator_aws"' main.tf; then
3445    MODULE_ADDR="module.operator_aws[0]"
3446  elif grep -q 'module "operator"' main.tf; then
3447    MODULE_ADDR="module.operator"
3448  fi
3449  if [ -n "$MODULE_ADDR" ]; then
3450    BUNDLE_DIGEST_VALUE=""
3451    DEPLOYMENT_NAME_PREFIX_VALUE=""
3452    AWS_REGION_VALUE="${AWS_REGION:-${AWS_DEFAULT_REGION:-}}"
3453    if [ -n "$VAR_FILE" ] && [ -f "$VAR_FILE" ]; then
3454      BUNDLE_DIGEST_VALUE=$(sed -n 's/^bundle_digest = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3455      DEPLOYMENT_NAME_PREFIX_VALUE=$(sed -n 's/^deployment_name_prefix = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3456    fi
3457    NAME_PREFIX="$DEPLOYMENT_NAME_PREFIX_VALUE"
3458    if [ -z "$NAME_PREFIX" ] && [ -n "$BUNDLE_DIGEST_VALUE" ]; then
3459      SHORT_ID="$(hash_string "$BUNDLE_DIGEST_VALUE")"
3460      NAME_PREFIX="greentic-${SHORT_ID}"
3461    fi
3462    if [ -n "$NAME_PREFIX" ] && [ -n "$AWS_REGION_VALUE" ]; then
3463      export AWS_REGION="$AWS_REGION_VALUE"
3464      export AWS_DEFAULT_REGION="$AWS_REGION_VALUE"
3465      import_if_missing() {
3466        local address="$1"
3467        local id="$2"
3468        if "$TERRAFORM_BIN" state show "$address" >/dev/null 2>&1; then
3469          return 0
3470        fi
3471        if [ -n "$VAR_FILE" ] && [ -f "$VAR_FILE" ]; then
3472          "$TERRAFORM_BIN" import -input=false -var-file="$VAR_FILE" "$address" "$id"
3473        else
3474          "$TERRAFORM_BIN" import -input=false "$address" "$id"
3475        fi
3476      }
3477      SECURITY_GROUP_ALB_NAME="${NAME_PREFIX}-alb"
3478      SECURITY_GROUP_SERVICE_NAME="${NAME_PREFIX}-svc"
3479      ALB_NAME="${NAME_PREFIX}-alb"
3480      CLUSTER_NAME="${NAME_PREFIX}-cluster"
3481      LOG_GROUP_NAME="/greentic/demo/${NAME_PREFIX}"
3482      ROLE_NAME="${NAME_PREFIX}-task-exec"
3483      SERVICE_NAME="${NAME_PREFIX}-service"
3484      ALB_GROUP_ID=$(aws ec2 describe-security-groups --region "$AWS_REGION_VALUE" --filters Name=group-name,Values="$SECURITY_GROUP_ALB_NAME" --query 'SecurityGroups[0].GroupId' --output text 2>/dev/null || true)
3485      if [ -n "$ALB_GROUP_ID" ] && [ "$ALB_GROUP_ID" != "None" ]; then
3486        import_if_missing "${MODULE_ADDR}.aws_security_group.alb" "$ALB_GROUP_ID"
3487      fi
3488      SERVICE_GROUP_ID=$(aws ec2 describe-security-groups --region "$AWS_REGION_VALUE" --filters Name=group-name,Values="$SECURITY_GROUP_SERVICE_NAME" --query 'SecurityGroups[0].GroupId' --output text 2>/dev/null || true)
3489      if [ -n "$SERVICE_GROUP_ID" ] && [ "$SERVICE_GROUP_ID" != "None" ]; then
3490        import_if_missing "${MODULE_ADDR}.aws_security_group.service" "$SERVICE_GROUP_ID"
3491      fi
3492      ALB_ARN=$(aws elbv2 describe-load-balancers --region "$AWS_REGION_VALUE" --names "$ALB_NAME" --query 'LoadBalancers[0].LoadBalancerArn' --output text 2>/dev/null || true)
3493      if [ -n "$ALB_ARN" ] && [ "$ALB_ARN" != "None" ]; then
3494        import_if_missing "${MODULE_ADDR}.aws_lb.this" "$ALB_ARN"
3495        LISTENER_ARN=$(aws elbv2 describe-listeners --region "$AWS_REGION_VALUE" --load-balancer-arn "$ALB_ARN" --query 'Listeners[?Port==`80` && Protocol==`HTTP`].ListenerArn | [0]' --output text 2>/dev/null || true)
3496        if [ -n "$LISTENER_ARN" ] && [ "$LISTENER_ARN" != "None" ]; then
3497          import_if_missing "${MODULE_ADDR}.aws_lb_listener.http" "$LISTENER_ARN"
3498        fi
3499      fi
3500      CLUSTER_FOUND=$(aws ecs describe-clusters --region "$AWS_REGION_VALUE" --clusters "$CLUSTER_NAME" --query 'clusters[?status==`ACTIVE`].clusterName | [0]' --output text 2>/dev/null || true)
3501      if [ -n "$CLUSTER_FOUND" ] && [ "$CLUSTER_FOUND" != "None" ] && [ "$CLUSTER_FOUND" != "MISSING" ]; then
3502        import_if_missing "${MODULE_ADDR}.aws_ecs_cluster.this" "$CLUSTER_NAME"
3503      fi
3504      LOG_GROUP_FOUND=$(aws logs describe-log-groups --region "$AWS_REGION_VALUE" --log-group-name-prefix "$LOG_GROUP_NAME" --query 'logGroups[?logGroupName==`'"$LOG_GROUP_NAME"'`].logGroupName | [0]' --output text 2>/dev/null || true)
3505      if [ -n "$LOG_GROUP_FOUND" ] && [ "$LOG_GROUP_FOUND" != "None" ]; then
3506        import_if_missing "${MODULE_ADDR}.aws_cloudwatch_log_group.this" "$LOG_GROUP_NAME"
3507      fi
3508      if aws iam get-role --role-name "$ROLE_NAME" >/dev/null 2>&1; then
3509        import_if_missing "${MODULE_ADDR}.aws_iam_role.task_execution" "$ROLE_NAME"
3510      fi
3511      SERVICE_FOUND=$(aws ecs describe-services --region "$AWS_REGION_VALUE" --cluster "$CLUSTER_NAME" --services "$SERVICE_NAME" --query 'services[?status==`ACTIVE`].serviceName | [0]' --output text 2>/dev/null || true)
3512      if [ -n "$SERVICE_FOUND" ] && [ "$SERVICE_FOUND" != "None" ] && [ "$SERVICE_FOUND" != "MISSING" ]; then
3513        import_if_missing "${MODULE_ADDR}.aws_ecs_service.this" "${CLUSTER_NAME}/${SERVICE_NAME}"
3514      fi
3515    fi
3516  fi
3517fi
3518"#
3519        .to_string()
3520    } else if operation == "apply" && provider == crate::config::Provider::Azure {
3521        r#"
3522if command -v az >/dev/null 2>&1 && [ -n "${ARM_SUBSCRIPTION_ID:-}" ]; then
3523  MODULE_ADDR=""
3524  if grep -q 'module "operator_azure"' main.tf; then
3525    MODULE_ADDR="module.operator_azure[0]"
3526  elif grep -q 'module "operator"' main.tf; then
3527    MODULE_ADDR="module.operator"
3528  fi
3529    if [ -n "$MODULE_ADDR" ]; then
3530      BUNDLE_DIGEST_VALUE=""
3531      ENVIRONMENT_VALUE="dev"
3532      CLOUD_VALUE=""
3533      OPERATOR_IMAGE_DIGEST_VALUE=""
3534      BUNDLE_SOURCE_VALUE=""
3535      REMOTE_STATE_BACKEND_VALUE=""
3536      KEY_VAULT_ID_VALUE=""
3537      DEPLOYMENT_NAME_PREFIX_VALUE=""
3538      if [ -n "$VAR_FILE" ] && [ -f "$VAR_FILE" ]; then
3539        CLOUD_VALUE=$(sed -n 's/^cloud = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3540        BUNDLE_DIGEST_VALUE=$(sed -n 's/^bundle_digest = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3541        ENVIRONMENT_VALUE=$(sed -n 's/^environment = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3542        OPERATOR_IMAGE_DIGEST_VALUE=$(sed -n 's/^operator_image_digest = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3543        BUNDLE_SOURCE_VALUE=$(sed -n 's/^bundle_source = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3544        REMOTE_STATE_BACKEND_VALUE=$(sed -n 's/^remote_state_backend = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3545        KEY_VAULT_ID_VALUE=$(sed -n 's/^azure_key_vault_id = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3546        DEPLOYMENT_NAME_PREFIX_VALUE=$(sed -n 's/^deployment_name_prefix = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3547      fi
3548      if [ -n "$BUNDLE_DIGEST_VALUE" ]; then
3549        export TF_VAR_cloud="${CLOUD_VALUE:-azure}"
3550        export TF_VAR_environment="${ENVIRONMENT_VALUE:-dev}"
3551        export TF_VAR_operator_image_digest="$OPERATOR_IMAGE_DIGEST_VALUE"
3552        export TF_VAR_bundle_source="$BUNDLE_SOURCE_VALUE"
3553        export TF_VAR_bundle_digest="$BUNDLE_DIGEST_VALUE"
3554        export TF_VAR_remote_state_backend="$REMOTE_STATE_BACKEND_VALUE"
3555        export TF_VAR_azure_key_vault_id="$KEY_VAULT_ID_VALUE"
3556        export TF_VAR_azure_location="${GREENTIC_DEPLOY_TERRAFORM_VAR_AZURE_LOCATION:-}"
3557        NAME_PREFIX="$DEPLOYMENT_NAME_PREFIX_VALUE"
3558        if [ -z "$NAME_PREFIX" ]; then
3559          SHORT_ID="$(hash_string "$BUNDLE_DIGEST_VALUE")"
3560          NAME_PREFIX="greentic-${SHORT_ID}"
3561        fi
3562        RESOURCE_GROUP_NAME="${NAME_PREFIX}-rg"
3563      LOG_ANALYTICS_NAME="${NAME_PREFIX}-logs"
3564      CONTAINER_ENV_NAME="${NAME_PREFIX}-cae"
3565      CONTAINER_APP_NAME="${NAME_PREFIX}-app"
3566        import_if_missing() {
3567          local address="$1"
3568          local id="$2"
3569          if "$TERRAFORM_BIN" state show "$address" >/dev/null 2>&1; then
3570            return 0
3571          fi
3572          if [ -n "$VAR_FILE" ] && [ -f "$VAR_FILE" ]; then
3573            "$TERRAFORM_BIN" import -input=false -var-file="$VAR_FILE" "$address" "$id"
3574          else
3575            "$TERRAFORM_BIN" import -input=false "$address" "$id"
3576          fi
3577        }
3578      if az group show --name "$RESOURCE_GROUP_NAME" >/dev/null 2>&1; then
3579        import_if_missing "${MODULE_ADDR}.azurerm_resource_group.this" "/subscriptions/${ARM_SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP_NAME}"
3580      fi
3581      LOG_ANALYTICS_ID=$(az monitor log-analytics workspace show --resource-group "$RESOURCE_GROUP_NAME" --workspace-name "$LOG_ANALYTICS_NAME" --query id -o tsv 2>/dev/null || true)
3582      if [ -n "$LOG_ANALYTICS_ID" ]; then
3583        import_if_missing "${MODULE_ADDR}.azurerm_log_analytics_workspace.this" "$LOG_ANALYTICS_ID"
3584      fi
3585      CONTAINER_ENV_ID=$(az resource show --ids "/subscriptions/${ARM_SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP_NAME}/providers/Microsoft.App/managedEnvironments/${CONTAINER_ENV_NAME}" --query id -o tsv 2>/dev/null || true)
3586      if [ -n "$CONTAINER_ENV_ID" ]; then
3587        import_if_missing "${MODULE_ADDR}.azurerm_container_app_environment.this" "$CONTAINER_ENV_ID"
3588      fi
3589      CONTAINER_APP_ID=$(az resource show --ids "/subscriptions/${ARM_SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP_NAME}/providers/Microsoft.App/containerApps/${CONTAINER_APP_NAME}" --query id -o tsv 2>/dev/null || true)
3590      if [ -n "$CONTAINER_APP_ID" ]; then
3591        import_if_missing "${MODULE_ADDR}.azurerm_container_app.this" "$CONTAINER_APP_ID"
3592      fi
3593      if [ -n "$KEY_VAULT_ID_VALUE" ]; then
3594        KEY_VAULT_NAME=$(basename "$KEY_VAULT_ID_VALUE")
3595        ADMIN_CA_SECRET_ID=$(az keyvault secret show --vault-name "$KEY_VAULT_NAME" --name "greentic-admin-ca-${ENVIRONMENT_VALUE}" --query id -o tsv 2>/dev/null || true)
3596        if [ -n "$ADMIN_CA_SECRET_ID" ]; then
3597          import_if_missing "${MODULE_ADDR}.azurerm_key_vault_secret.admin_ca[0]" "$ADMIN_CA_SECRET_ID"
3598        fi
3599        ADMIN_SERVER_CERT_SECRET_ID=$(az keyvault secret show --vault-name "$KEY_VAULT_NAME" --name "greentic-admin-server-cert-${ENVIRONMENT_VALUE}" --query id -o tsv 2>/dev/null || true)
3600        if [ -n "$ADMIN_SERVER_CERT_SECRET_ID" ]; then
3601          import_if_missing "${MODULE_ADDR}.azurerm_key_vault_secret.admin_server_cert[0]" "$ADMIN_SERVER_CERT_SECRET_ID"
3602        fi
3603        ADMIN_SERVER_KEY_SECRET_ID=$(az keyvault secret show --vault-name "$KEY_VAULT_NAME" --name "greentic-admin-server-key-${ENVIRONMENT_VALUE}" --query id -o tsv 2>/dev/null || true)
3604        if [ -n "$ADMIN_SERVER_KEY_SECRET_ID" ]; then
3605          import_if_missing "${MODULE_ADDR}.azurerm_key_vault_secret.admin_server_key[0]" "$ADMIN_SERVER_KEY_SECRET_ID"
3606        fi
3607      fi
3608    fi
3609  fi
3610fi
3611"# 
3612        .to_string()
3613    } else if operation == "apply" && provider == crate::config::Provider::Gcp {
3614        r#"
3615if command -v gcloud >/dev/null 2>&1; then
3616  MODULE_ADDR=""
3617  if grep -q 'module "operator_gcp"' main.tf; then
3618    MODULE_ADDR="module.operator_gcp[0]"
3619  elif grep -q 'module "operator"' main.tf; then
3620    MODULE_ADDR="module.operator"
3621  fi
3622  if [ -n "$MODULE_ADDR" ]; then
3623    GCP_PROJECT_ID_VALUE=""
3624    GCP_REGION_VALUE="us-central1"
3625    ENVIRONMENT_VALUE="dev"
3626    BUNDLE_DIGEST_VALUE=""
3627    DEPLOYMENT_NAME_PREFIX_VALUE=""
3628    if [ -n "$VAR_FILE" ] && [ -f "$VAR_FILE" ]; then
3629      GCP_PROJECT_ID_VALUE=$(sed -n 's/^gcp_project_id = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3630      GCP_REGION_VALUE=$(sed -n 's/^gcp_region = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3631      ENVIRONMENT_VALUE=$(sed -n 's/^environment = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3632      BUNDLE_DIGEST_VALUE=$(sed -n 's/^bundle_digest = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3633      DEPLOYMENT_NAME_PREFIX_VALUE=$(sed -n 's/^deployment_name_prefix = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3634    fi
3635    if [ -n "$GCP_PROJECT_ID_VALUE" ]; then
3636      import_if_missing() {
3637        local address="$1"
3638        local id="$2"
3639        if "$TERRAFORM_BIN" state show "$address" >/dev/null 2>&1; then
3640          return 0
3641        fi
3642        if [ -n "$VAR_FILE" ] && [ -f "$VAR_FILE" ]; then
3643          "$TERRAFORM_BIN" import -input=false -var-file="$VAR_FILE" "$address" "$id"
3644        else
3645          "$TERRAFORM_BIN" import -input=false "$address" "$id"
3646        fi
3647      }
3648      import_gcp_secret_if_exists() {
3649        local address="$1"
3650        local secret_name="$2"
3651        local secret_id
3652        secret_id=$(gcloud secrets describe "$secret_name" --project "$GCP_PROJECT_ID_VALUE" --format='value(name)' 2>/dev/null || true)
3653        if [ -n "$secret_id" ]; then
3654          import_if_missing "$address" "$secret_id"
3655        fi
3656      }
3657      import_gcp_secret_if_exists "${MODULE_ADDR}.google_secret_manager_secret.admin_ca" "greentic-admin-ca-${ENVIRONMENT_VALUE}"
3658      import_gcp_secret_if_exists "${MODULE_ADDR}.google_secret_manager_secret.admin_server_cert" "greentic-admin-server-cert-${ENVIRONMENT_VALUE}"
3659      import_gcp_secret_if_exists "${MODULE_ADDR}.google_secret_manager_secret.admin_server_key" "greentic-admin-server-key-${ENVIRONMENT_VALUE}"
3660      import_gcp_secret_if_exists "${MODULE_ADDR}.google_secret_manager_secret.admin_client_cert" "greentic-admin-client-cert-${ENVIRONMENT_VALUE}"
3661      import_gcp_secret_if_exists "${MODULE_ADDR}.google_secret_manager_secret.admin_client_key" "greentic-admin-client-key-${ENVIRONMENT_VALUE}"
3662      import_gcp_secret_if_exists "${MODULE_ADDR}.google_secret_manager_secret.admin_relay_token" "greentic-admin-relay-token-${ENVIRONMENT_VALUE}"
3663      if [ -n "$BUNDLE_DIGEST_VALUE" ]; then
3664        NAME_PREFIX="$DEPLOYMENT_NAME_PREFIX_VALUE"
3665        if [ -z "$NAME_PREFIX" ]; then
3666          SHORT_ID="$(hash_string "$BUNDLE_DIGEST_VALUE")"
3667          NAME_PREFIX="greentic-${SHORT_ID}"
3668        fi
3669        CLOUD_RUN_SERVICE_NAME="${NAME_PREFIX}-run"
3670        CLOUD_RUN_SERVICE_ID=$(gcloud run services describe "$CLOUD_RUN_SERVICE_NAME" --project "$GCP_PROJECT_ID_VALUE" --region "$GCP_REGION_VALUE" --format='value(metadata.name)' 2>/dev/null || true)
3671        if [ -n "$CLOUD_RUN_SERVICE_ID" ]; then
3672          import_if_missing "${MODULE_ADDR}.google_cloud_run_v2_service.this" "projects/${GCP_PROJECT_ID_VALUE}/locations/${GCP_REGION_VALUE}/services/${CLOUD_RUN_SERVICE_NAME}"
3673        fi
3674      fi
3675    fi
3676  fi
3677fi
3678"#
3679        .to_string()
3680    } else {
3681        String::new()
3682    };
3683    let apply_invocation = format!(
3684        "if [ -n \"$VAR_FILE\" ]; then\n  \"$TERRAFORM_BIN\" {operation}{extra_args} -var-file=\"$VAR_FILE\" \"$@\"\nelse\n  \"$TERRAFORM_BIN\" {operation}{extra_args} \"$@\"\nfi"
3685    );
3686    let apply_invocation_with_redirection = if apply_invocation.contains("-var-file=\"$VAR_FILE\"")
3687    {
3688        "  if [ -n \"$VAR_FILE\" ]; then\n    \"$TERRAFORM_BIN\" apply -auto-approve -input=false -var-file=\"$VAR_FILE\" \"$@\" >\"$stdout_file\" 2>\"$stderr_file\"\n  else\n    \"$TERRAFORM_BIN\" apply -auto-approve -input=false \"$@\" >\"$stdout_file\" 2>\"$stderr_file\"\n  fi"
3689    } else {
3690        "  \"$TERRAFORM_BIN\" apply -auto-approve -input=false \"$@\" >\"$stdout_file\" 2>\"$stderr_file\""
3691    };
3692    let operation_block = if operation == "apply" && provider == crate::config::Provider::Azure {
3693        format!(
3694            "AZURE_APPLY_MAX_ATTEMPTS=\"${{GREENTIC_AZURE_APPLY_MAX_ATTEMPTS:-6}}\"\nAZURE_APPLY_RETRY_DELAY_SECONDS=\"${{GREENTIC_AZURE_APPLY_RETRY_DELAY_SECONDS:-20}}\"\nattempt=1\nwhile true; do\n  stdout_file=\"$(mktemp)\"\n  stderr_file=\"$(mktemp)\"\n  set +e\n{apply_invocation_with_redirection}\n  status=$?\n  set -e\n  cat \"$stdout_file\"\n  cat \"$stderr_file\" >&2\n  if [ \"$status\" -eq 0 ]; then\n    rm -f \"$stdout_file\" \"$stderr_file\"\n    break\n  fi\n  retry_reason=\"\"\n  if grep -q 'ResourceGroupBeingDeleted' \"$stderr_file\"; then\n    retry_reason='resource group is still being deleted'\n  elif grep -q 'ManagedEnvironmentNotProvisioned' \"$stderr_file\"; then\n    retry_reason='container app environment is not fully provisioned yet'\n  elif grep -q 'Operation was canceled' \"$stderr_file\"; then\n    retry_reason='azure control plane canceled the previous environment operation'\n  fi\n  if [ -n \"$retry_reason\" ] && [ \"$attempt\" -lt \"$AZURE_APPLY_MAX_ATTEMPTS\" ]; then\n    echo \"Azure apply hit transient condition: $retry_reason; retrying in ${{AZURE_APPLY_RETRY_DELAY_SECONDS}}s (attempt ${{attempt}}/${{AZURE_APPLY_MAX_ATTEMPTS}})\" >&2\n    rm -f \"$stdout_file\" \"$stderr_file\" .terraform.tfstate.lock.info\n    sleep \"$AZURE_APPLY_RETRY_DELAY_SECONDS\"\n    attempt=$((attempt + 1))\n    continue\n  fi\n  rm -f \"$stdout_file\" \"$stderr_file\"\n  exit \"$status\"\ndone",
3695            apply_invocation_with_redirection = apply_invocation_with_redirection
3696        )
3697    } else if operation == "apply" && provider == crate::config::Provider::Aws {
3698        format!(
3699            "AWS_APPLY_MAX_ATTEMPTS=\"${{GREENTIC_AWS_APPLY_MAX_ATTEMPTS:-6}}\"\nAWS_APPLY_RETRY_DELAY_SECONDS=\"${{GREENTIC_AWS_APPLY_RETRY_DELAY_SECONDS:-20}}\"\nattempt=1\nwhile true; do\n  stdout_file=\"$(mktemp)\"\n  stderr_file=\"$(mktemp)\"\n  set +e\n{apply_invocation_with_redirection}\n  status=$?\n  set -e\n  cat \"$stdout_file\"\n  cat \"$stderr_file\" >&2\n  if [ \"$status\" -eq 0 ]; then\n    rm -f \"$stdout_file\" \"$stderr_file\"\n    break\n  fi\n  retry_reason=\"\"\n  if grep -q 'DuplicateLoadBalancerName' \"$stderr_file\"; then\n    retry_reason='load balancer is still being deleted or reused'\n  elif grep -q 'EntityAlreadyExists' \"$stderr_file\"; then\n    retry_reason='iam or log resource still exists while aws control plane converges'\n  elif grep -q 'already exists' \"$stderr_file\"; then\n    retry_reason='aws resource name is still reserved while the previous deployment is converging'\n  elif grep -q 'OperationAborted' \"$stderr_file\"; then\n    retry_reason='aws control plane reported an in-progress conflicting operation'\n  fi\n  if [ -n \"$retry_reason\" ] && [ \"$attempt\" -lt \"$AWS_APPLY_MAX_ATTEMPTS\" ]; then\n    echo \"AWS apply hit transient condition: $retry_reason; retrying in ${{AWS_APPLY_RETRY_DELAY_SECONDS}}s (attempt ${{attempt}}/${{AWS_APPLY_MAX_ATTEMPTS}})\" >&2\n    rm -f \"$stdout_file\" \"$stderr_file\" .terraform.tfstate.lock.info\n    sleep \"$AWS_APPLY_RETRY_DELAY_SECONDS\"\n    attempt=$((attempt + 1))\n    continue\n  fi\n  rm -f \"$stdout_file\" \"$stderr_file\"\n  exit \"$status\"\ndone",
3700            apply_invocation_with_redirection = apply_invocation_with_redirection
3701        )
3702    } else if operation == "apply" && provider == crate::config::Provider::Gcp {
3703        format!(
3704            "GCP_APPLY_MAX_ATTEMPTS=\"${{GREENTIC_GCP_APPLY_MAX_ATTEMPTS:-6}}\"\nGCP_APPLY_RETRY_DELAY_SECONDS=\"${{GREENTIC_GCP_APPLY_RETRY_DELAY_SECONDS:-20}}\"\nattempt=1\nwhile true; do\n  stdout_file=\"$(mktemp)\"\n  stderr_file=\"$(mktemp)\"\n  set +e\n{apply_invocation_with_redirection}\n  status=$?\n  set -e\n  cat \"$stdout_file\"\n  cat \"$stderr_file\" >&2\n  if [ \"$status\" -eq 0 ]; then\n    rm -f \"$stdout_file\" \"$stderr_file\"\n    break\n  fi\n  retry_reason=\"\"\n  if grep -q 'being deleted' \"$stderr_file\"; then\n    retry_reason='gcp resource is still being deleted'\n  elif grep -q 'already exists' \"$stderr_file\"; then\n    retry_reason='gcp resource name is still reserved while the previous deployment is converging'\n  elif grep -q 'operation is already in progress' \"$stderr_file\"; then\n    retry_reason='gcp control plane already has an operation in progress'\n  fi\n  if [ -n \"$retry_reason\" ] && [ \"$attempt\" -lt \"$GCP_APPLY_MAX_ATTEMPTS\" ]; then\n    echo \"GCP apply hit transient condition: $retry_reason; retrying in ${{GCP_APPLY_RETRY_DELAY_SECONDS}}s (attempt ${{attempt}}/${{GCP_APPLY_MAX_ATTEMPTS}})\" >&2\n    rm -f \"$stdout_file\" \"$stderr_file\" .terraform.tfstate.lock.info\n    sleep \"$GCP_APPLY_RETRY_DELAY_SECONDS\"\n    attempt=$((attempt + 1))\n    continue\n  fi\n  rm -f \"$stdout_file\" \"$stderr_file\"\n  exit \"$status\"\ndone",
3705            apply_invocation_with_redirection = apply_invocation_with_redirection
3706        )
3707    } else {
3708        apply_invocation
3709    };
3710    let hash_helper = terraform_hash_string_function();
3711    format!(
3712        "#!/usr/bin/env bash\nset -euo pipefail\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nTF_ROOT=\"${{SCRIPT_DIR}}/terraform\"\ncd \"$TF_ROOT\"\nTERRAFORM_BIN=\"terraform\"\nif [ -x \"$TF_ROOT/terraform\" ]; then\n  TERRAFORM_BIN=\"$TF_ROOT/terraform\"\nfi\nINIT_ARGS=(-input=false)\nif [ -f \"${{SCRIPT_DIR}}/backend.hcl\" ]; then\n  INIT_ARGS+=(\"-backend-config=${{SCRIPT_DIR}}/backend.hcl\")\nfi\n\"$TERRAFORM_BIN\" init \"${{INIT_ARGS[@]}}\"\n{hash_helper}VAR_FILE=\"\"\n{tfvars_lookup}\n{pre_apply_hook}{operation_block}\n"
3713    )
3714}
3715
3716fn terraform_aws_cleanup_script(generated_tfvars: Option<&str>, tfvars_example: &str) -> String {
3717    let tfvars_lookup = if let Some(generated_tfvars) = generated_tfvars {
3718        format!(
3719            "if [ -f \"{generated_tfvars}\" ]; then\n  VAR_FILE=\"{generated_tfvars}\"\nelif [ -f \"{tfvars_example}\" ]; then\n  VAR_FILE=\"{tfvars_example}\"\nelse\n  for candidate in *.tfvars *.tfvars.example; do\n    if [ -f \"$candidate\" ]; then\n      VAR_FILE=\"$candidate\"\n      break\n    fi\n  done\nfi"
3720        )
3721    } else {
3722        format!(
3723            "if [ -f \"{tfvars_example}\" ]; then\n  VAR_FILE=\"{tfvars_example}\"\nelse\n  for candidate in *.tfvars *.tfvars.example; do\n    if [ -f \"$candidate\" ]; then\n      VAR_FILE=\"$candidate\"\n      break\n    fi\n  done\nfi"
3724        )
3725    };
3726    let hash_helper = terraform_hash_string_function();
3727    format!(
3728        "#!/usr/bin/env bash\nset -euo pipefail\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nTF_ROOT=\"${{SCRIPT_DIR}}/terraform\"\ncd \"$TF_ROOT\"\n{hash_helper}VAR_FILE=\"\"\n{tfvars_lookup}\nif ! command -v aws >/dev/null 2>&1; then\n  echo \"aws cli not found; skipping AWS cleanup fallback\"\n  exit 0\nfi\nBUNDLE_DIGEST=\"\"\nNAME_PREFIX=\"\"\nif [ -n \"$VAR_FILE\" ] && [ -f \"$VAR_FILE\" ]; then\n  BUNDLE_DIGEST=$(sed -n 's/^bundle_digest = \"\\(.*\\)\"$/\\1/p' \"$VAR_FILE\" | head -n 1)\n  NAME_PREFIX=$(sed -n 's/^deployment_name_prefix = \"\\(.*\\)\"$/\\1/p' \"$VAR_FILE\" | head -n 1)\nfi\nif [ -z \"$NAME_PREFIX\" ]; then\n  if [ -z \"$BUNDLE_DIGEST\" ]; then\n    echo \"bundle_digest not found; skipping AWS cleanup fallback\"\n    exit 0\n  fi\n  SHORT_ID=\"$(hash_string \"$BUNDLE_DIGEST\")\"\n  NAME_PREFIX=\"greentic-${{SHORT_ID}}\"\nfi\nAWS_REGION_VALUE=\"${{AWS_REGION:-${{AWS_DEFAULT_REGION:-}}}}\"\nif [ -z \"$AWS_REGION_VALUE\" ]; then\n  echo \"AWS region not set; skipping AWS cleanup fallback\"\n  exit 0\nfi\nSECRET_PREFIX=\"greentic/admin/${{NAME_PREFIX}}/\"\nLOG_GROUP=\"/greentic/demo/${{NAME_PREFIX}}\"\nROLE_NAME=\"${{NAME_PREFIX}}-task-exec\"\nCLUSTER_NAME=\"${{NAME_PREFIX}}-cluster\"\nSERVICE_NAME=\"${{NAME_PREFIX}}-service\"\nLB_NAME=\"${{NAME_PREFIX}}-alb\"\naws logs delete-log-group --region \"$AWS_REGION_VALUE\" --log-group-name \"$LOG_GROUP\" >/dev/null 2>&1 || true\nSECRET_ARNS=$(aws secretsmanager list-secrets --region \"$AWS_REGION_VALUE\" --filters Key=name,Values=\"$SECRET_PREFIX\" --query 'SecretList[].ARN' --output text 2>/dev/null || true)\nfor secret_arn in $SECRET_ARNS; do\n  aws secretsmanager delete-secret --region \"$AWS_REGION_VALUE\" --secret-id \"$secret_arn\" --force-delete-without-recovery >/dev/null 2>&1 || true\ndone\nINLINE_POLICIES=$(aws iam list-role-policies --role-name \"$ROLE_NAME\" --query 'PolicyNames[]' --output text 2>/dev/null || true)\nfor policy_name in $INLINE_POLICIES; do\n  aws iam delete-role-policy --role-name \"$ROLE_NAME\" --policy-name \"$policy_name\" >/dev/null 2>&1 || true\ndone\nATTACHED_POLICIES=$(aws iam list-attached-role-policies --role-name \"$ROLE_NAME\" --query 'AttachedPolicies[].PolicyArn' --output text 2>/dev/null || true)\nfor policy_arn in $ATTACHED_POLICIES; do\n  aws iam detach-role-policy --role-name \"$ROLE_NAME\" --policy-arn \"$policy_arn\" >/dev/null 2>&1 || true\ndone\naws iam delete-role --role-name \"$ROLE_NAME\" >/dev/null 2>&1 || true\nLB_ARN=$(aws elbv2 describe-load-balancers --region \"$AWS_REGION_VALUE\" --names \"$LB_NAME\" --query 'LoadBalancers[0].LoadBalancerArn' --output text 2>/dev/null || true)\nif [ -n \"$LB_ARN\" ] && [ \"$LB_ARN\" != \"None\" ]; then\n  aws elbv2 delete-load-balancer --region \"$AWS_REGION_VALUE\" --load-balancer-arn \"$LB_ARN\" >/dev/null 2>&1 || true\nfi\naws ecs update-service --region \"$AWS_REGION_VALUE\" --cluster \"$CLUSTER_NAME\" --service \"$SERVICE_NAME\" --desired-count 0 >/dev/null 2>&1 || true\naws ecs delete-service --region \"$AWS_REGION_VALUE\" --cluster \"$CLUSTER_NAME\" --service \"$SERVICE_NAME\" --force >/dev/null 2>&1 || true\naws ecs delete-cluster --region \"$AWS_REGION_VALUE\" --cluster \"$CLUSTER_NAME\" >/dev/null 2>&1 || true\n"
3729    )
3730}
3731
3732fn configure_terraform_backend(
3733    config: &DeployerConfig,
3734    terraform_root: &Path,
3735    deploy_dir: &Path,
3736) -> Result<()> {
3737    let providers_path = terraform_root.join("providers.tf");
3738    if !providers_path.exists() {
3739        return Ok(());
3740    }
3741
3742    let contents = fs::read_to_string(&providers_path)?;
3743    if !contents.contains("backend \"s3\" {}") {
3744        return Ok(());
3745    }
3746
3747    if let Some(bucket) = std::env::var("GREENTIC_TERRAFORM_BACKEND_BUCKET")
3748        .ok()
3749        .filter(|value| !value.trim().is_empty())
3750    {
3751        let region = std::env::var("GREENTIC_TERRAFORM_BACKEND_REGION")
3752            .ok()
3753            .or_else(|| std::env::var("AWS_REGION").ok())
3754            .or_else(|| std::env::var("AWS_DEFAULT_REGION").ok())
3755            .unwrap_or_else(|| "eu-north-1".to_string());
3756        let key = std::env::var("GREENTIC_TERRAFORM_BACKEND_KEY")
3757            .ok()
3758            .filter(|value| !value.trim().is_empty())
3759            .unwrap_or_else(|| {
3760                let deployment_name_prefix = explicit_deployment_name_prefix()
3761                    .unwrap_or_else(|| stable_deployment_name_prefix(config));
3762                format!(
3763                    "greentic/{}/{}/{}/{}/terraform.tfstate",
3764                    config.provider.as_str(),
3765                    config.tenant,
3766                    config.environment,
3767                    deployment_name_prefix
3768                )
3769            });
3770        let backend_hcl =
3771            format!("bucket = \"{bucket}\"\nkey = \"{key}\"\nregion = \"{region}\"\n");
3772        fs::write(deploy_dir.join("backend.hcl"), backend_hcl)?;
3773        return Ok(());
3774    }
3775
3776    let rewritten = match config.provider {
3777        crate::config::Provider::Aws => {
3778            "terraform {\n  required_version = \">= 1.8.0\"\n  backend \"local\" {\n    path = \"terraform.tfstate\"\n  }\n}\n".to_string()
3779        }
3780        crate::config::Provider::Azure => {
3781            "terraform {\n  required_version = \">= 1.8.0\"\n  backend \"local\" {\n    path = \"terraform.tfstate\"\n  }\n\n  required_providers {\n    azurerm = {\n      source = \"hashicorp/azurerm\"\n    }\n  }\n}\n\nprovider \"azurerm\" {\n  features {}\n}\n".to_string()
3782        }
3783        crate::config::Provider::Gcp => {
3784            "terraform {\n  required_version = \">= 1.8.0\"\n  backend \"local\" {\n    path = \"terraform.tfstate\"\n  }\n\n  required_providers {\n    google = {\n      source = \"hashicorp/google\"\n    }\n  }\n}\n\nprovider \"google\" {\n  project = trimspace(var.gcp_project_id) != \"\" ? var.gcp_project_id : \"greentic-placeholder\"\n  region  = trimspace(var.gcp_region) != \"\" ? var.gcp_region : \"us-central1\"\n}\n".to_string()
3785        }
3786        _ => contents.replace(
3787            "backend \"s3\" {}",
3788            "backend \"local\" {\n    path = \"terraform.tfstate\"\n  }",
3789        ),
3790    };
3791    fs::write(providers_path, rewritten)?;
3792    Ok(())
3793}
3794
3795fn normalize_terraform_main_tf(config: &DeployerConfig, terraform_root: &Path) -> Result<()> {
3796    if config.provider != crate::config::Provider::Gcp {
3797        return Ok(());
3798    }
3799
3800    let main_tf_path = terraform_root.join("main.tf");
3801    if main_tf_path.exists() {
3802        let contents = fs::read_to_string(&main_tf_path)?;
3803        let old = r#"  operator_image        = "ghcr.io/greenticai/greentic-start-distroless@${var.operator_image_digest}""#;
3804        let new = r#"  operator_image        = var.operator_image != "" ? var.operator_image : "ghcr.io/greenticai/greentic-start-distroless@${var.operator_image_digest}""#;
3805        if contents.contains(old) {
3806            fs::write(&main_tf_path, contents.replace(old, new))?;
3807        }
3808    }
3809    normalize_gcp_operator_module_main_tf(terraform_root)?;
3810    Ok(())
3811}
3812
3813fn normalize_gcp_operator_module_main_tf(terraform_root: &Path) -> Result<()> {
3814    let module_main_tf_path = terraform_root.join("modules/operator-gcp/main.tf");
3815    if !module_main_tf_path.exists() {
3816        return Ok(());
3817    }
3818
3819    let mut contents = fs::read_to_string(&module_main_tf_path)?;
3820    contents = contents.replace(
3821        r#"        name  = "GREENTIC_ADMIN_CA_SECRET_REF"
3822        value = google_secret_manager_secret.admin_ca.id"#,
3823        r#"        name  = "GREENTIC_ADMIN_CA_PEM"
3824        value = tls_self_signed_cert.admin_ca.cert_pem"#,
3825    );
3826    contents = contents.replace(
3827        r#"        name  = "GREENTIC_ADMIN_SERVER_CERT_SECRET_REF"
3828        value = google_secret_manager_secret.admin_server_cert.id"#,
3829        r#"        name  = "GREENTIC_ADMIN_SERVER_CERT_PEM"
3830        value = tls_locally_signed_cert.admin_server.cert_pem"#,
3831    );
3832    contents = contents.replace(
3833        r#"        name  = "GREENTIC_ADMIN_SERVER_KEY_SECRET_REF"
3834        value = google_secret_manager_secret.admin_server_key.id"#,
3835        r#"        name  = "GREENTIC_ADMIN_SERVER_KEY_PEM"
3836        value = tls_private_key.admin_server.private_key_pem"#,
3837    );
3838    contents = contents.replace(
3839        "  ingress  = \"INGRESS_TRAFFIC_ALL\"\n\n  template {",
3840        "  ingress  = \"INGRESS_TRAFFIC_ALL\"\n  deletion_protection = false\n\n  template {",
3841    );
3842
3843    for snippet in [
3844        r#"
3845      env {
3846        name = "GREENTIC_ADMIN_CA_PEM"
3847        value_source {
3848          secret_key_ref {
3849            secret  = google_secret_manager_secret.admin_ca.secret_id
3850            version = "latest"
3851          }
3852        }
3853      }
3854"#,
3855        r#"
3856      env {
3857        name = "GREENTIC_ADMIN_SERVER_CERT_PEM"
3858        value_source {
3859          secret_key_ref {
3860            secret  = google_secret_manager_secret.admin_server_cert.secret_id
3861            version = "latest"
3862          }
3863        }
3864      }
3865"#,
3866        r#"
3867      env {
3868        name = "GREENTIC_ADMIN_SERVER_KEY_PEM"
3869        value_source {
3870          secret_key_ref {
3871            secret  = google_secret_manager_secret.admin_server_key.secret_id
3872            version = "latest"
3873          }
3874        }
3875      }
3876"#,
3877        r#"
3878resource "google_service_account" "runtime" {
3879  project      = var.gcp_project_id
3880  account_id   = "${local.name_prefix}-run"
3881  display_name = "Greentic runtime"
3882}
3883"#,
3884        r#"
3885resource "google_secret_manager_secret_iam_member" "runtime_admin_ca_accessor" {
3886  project   = var.gcp_project_id
3887  secret_id = google_secret_manager_secret.admin_ca.secret_id
3888  role      = "roles/secretmanager.secretAccessor"
3889  member    = "serviceAccount:${google_service_account.runtime.email}"
3890}
3891"#,
3892        r#"
3893resource "google_secret_manager_secret_iam_member" "runtime_admin_server_cert_accessor" {
3894  project   = var.gcp_project_id
3895  secret_id = google_secret_manager_secret.admin_server_cert.secret_id
3896  role      = "roles/secretmanager.secretAccessor"
3897  member    = "serviceAccount:${google_service_account.runtime.email}"
3898}
3899"#,
3900        r#"
3901resource "google_secret_manager_secret_iam_member" "runtime_admin_server_key_accessor" {
3902  project   = var.gcp_project_id
3903  secret_id = google_secret_manager_secret.admin_server_key.secret_id
3904  role      = "roles/secretmanager.secretAccessor"
3905  member    = "serviceAccount:${google_service_account.runtime.email}"
3906}
3907"#,
3908        r#"
3909resource "google_secret_manager_secret_iam_member" "runtime_admin_ca_accessor" {
3910  project   = var.gcp_project_id
3911  secret_id = google_secret_manager_secret.admin_ca.secret_id
3912  role      = "roles/secretmanager.secretAccessor"
3913  member    = "serviceAccount:${local.runtime_service_account_email}"
3914}
3915"#,
3916        r#"
3917resource "google_secret_manager_secret_iam_member" "runtime_admin_server_cert_accessor" {
3918  project   = var.gcp_project_id
3919  secret_id = google_secret_manager_secret.admin_server_cert.secret_id
3920  role      = "roles/secretmanager.secretAccessor"
3921  member    = "serviceAccount:${local.runtime_service_account_email}"
3922}
3923"#,
3924        r#"
3925resource "google_secret_manager_secret_iam_member" "runtime_admin_server_key_accessor" {
3926  project   = var.gcp_project_id
3927  secret_id = google_secret_manager_secret.admin_server_key.secret_id
3928  role      = "roles/secretmanager.secretAccessor"
3929  member    = "serviceAccount:${local.runtime_service_account_email}"
3930}
3931"#,
3932        r#"  project_number                = split("/", google_secret_manager_secret.admin_ca.id)[1]
3933  runtime_service_account_email = "${local.project_number}-compute@developer.gserviceaccount.com"
3934"#,
3935        r#"  depends_on = [
3936    google_secret_manager_secret_iam_member.runtime_admin_ca_accessor,
3937    google_secret_manager_secret_iam_member.runtime_admin_server_cert_accessor,
3938    google_secret_manager_secret_iam_member.runtime_admin_server_key_accessor,
3939  ]
3940"#,
3941        r#"    service_account = google_service_account.runtime.email
3942"#,
3943    ] {
3944        contents = contents.replace(snippet, "");
3945    }
3946
3947    fs::write(module_main_tf_path, contents)?;
3948    Ok(())
3949}
3950
3951fn kubectl_script(command: &str) -> String {
3952    format!(
3953        "#!/usr/bin/env bash\nset -euo pipefail\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nK8S_ROOT=\"${{SCRIPT_DIR}}/k8s\"\n{command}\n"
3954    )
3955}
3956
3957fn kubectl_root_script(root_var: &str, command: &str) -> String {
3958    format!(
3959        "#!/usr/bin/env bash\nset -euo pipefail\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n{root_var}=\"${{SCRIPT_DIR}}/{}\"\nkubectl {command}\n",
3960        root_var.to_ascii_lowercase().trim_end_matches("_root")
3961    )
3962}
3963
3964fn helm_script(command: &str) -> String {
3965    format!(
3966        "#!/usr/bin/env bash\nset -euo pipefail\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nCHART_ROOT=\"${{SCRIPT_DIR}}/helm-chart\"\nhelm {command}\n"
3967    )
3968}
3969
3970fn juju_script(charm_dir: &str, command: &str) -> String {
3971    format!(
3972        "#!/usr/bin/env bash\nset -euo pipefail\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nCHARM_ROOT=\"${{SCRIPT_DIR}}/{charm_dir}\"\njuju {command}\n"
3973    )
3974}
3975
3976fn generic_root_script(root_var: &str, command: &str) -> String {
3977    format!(
3978        "#!/usr/bin/env bash\nset -euo pipefail\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n{root_var}=\"${{SCRIPT_DIR}}/{}\"\n{command}\n",
3979        root_var.to_ascii_lowercase().trim_end_matches("_root")
3980    )
3981}
3982
3983fn write_executable_script(path: &Path, contents: String) -> Result<()> {
3984    let file_name = path
3985        .file_name()
3986        .and_then(|value| value.to_str())
3987        .ok_or_else(|| DeployerError::Other(format!("invalid script path {}", path.display())))?;
3988    let tmp_path = path.with_file_name(format!(".{file_name}.tmp"));
3989    fs::write(&tmp_path, contents)?;
3990    set_executable_if_unix(&tmp_path)?;
3991    fs::rename(&tmp_path, path)?;
3992    Ok(())
3993}
3994
3995fn set_executable_if_unix(path: &Path) -> Result<()> {
3996    #[cfg(unix)]
3997    {
3998        use std::os::unix::fs::PermissionsExt;
3999        let mut perms = fs::metadata(path)?.permissions();
4000        perms.set_mode(0o755);
4001        fs::set_permissions(path, perms)?;
4002    }
4003    Ok(())
4004}
4005
4006#[derive(Serialize)]
4007struct RuntimeInvocation {
4008    capability: String,
4009    provider: String,
4010    strategy: String,
4011    tenant: String,
4012    environment: String,
4013    output_dir: String,
4014    plan_path: String,
4015    pack_id: String,
4016    flow_id: String,
4017    handler_id: String,
4018    pack_path: String,
4019}
4020
4021#[derive(Debug, Clone, Serialize)]
4022struct DeployerInvocation {
4023    capability: String,
4024    pack_id: String,
4025    flow_id: String,
4026    handler_id: String,
4027    pack_path: String,
4028    output_dir: String,
4029    runner_cmd: Vec<String>,
4030    runner_env: Vec<(String, String)>,
4031}
4032
4033struct WrittenDiagnostics {
4034    invocation: DeployerInvocation,
4035    handoff_path: PathBuf,
4036    runner_command_path: PathBuf,
4037}
4038
4039#[derive(Debug, Clone, Serialize, Deserialize)]
4040struct TerraformRuntimeMetadata {
4041    terraform_root: String,
4042    copied_files: Vec<String>,
4043    scripts: Vec<String>,
4044    #[serde(skip_serializing_if = "Option::is_none")]
4045    generated_tfvars: Option<String>,
4046    #[serde(skip_serializing_if = "Option::is_none")]
4047    secrets_provider_binding: Option<String>,
4048    init_command: String,
4049    plan_command: String,
4050    apply_command: String,
4051    destroy_command: String,
4052    status_command: String,
4053}
4054
4055fn write_runner_diagnostics(
4056    config: &DeployerConfig,
4057    deploy_dir: &Path,
4058    selection: &DeploymentPackSelection,
4059    plan_path: &Path,
4060) -> Result<WrittenDiagnostics> {
4061    let diag = build_deployer_invocation(config, deploy_dir, selection, plan_path);
4062
4063    let runner_cmd = diag.runner_cmd.clone();
4064    let runner_env = diag.runner_env.clone();
4065
4066    let diag_path = deploy_dir.join("._deployer_invocation.json");
4067    let diag_file = fs::File::create(&diag_path)?;
4068    serde_json::to_writer_pretty(diag_file, &diag)?;
4069
4070    let mut doc = String::from("Runner command:\n");
4071    doc.push_str(&runner_cmd.join(" "));
4072    doc.push('\n');
4073    doc.push_str("Environment:\n");
4074    for (key, value) in runner_env {
4075        doc.push_str(&format!("{key}={value}\n"));
4076    }
4077    let runner_command_path = deploy_dir.join("._runner_cmd.txt");
4078    fs::write(&runner_command_path, doc)?;
4079    Ok(WrittenDiagnostics {
4080        invocation: diag,
4081        handoff_path: diag_path,
4082        runner_command_path,
4083    })
4084}
4085
4086fn build_deployer_invocation(
4087    config: &DeployerConfig,
4088    deploy_dir: &Path,
4089    selection: &DeploymentPackSelection,
4090    plan_path: &Path,
4091) -> DeployerInvocation {
4092    DeployerInvocation {
4093        capability: selection.dispatch.capability.as_str().to_string(),
4094        pack_id: selection.dispatch.pack_id.clone(),
4095        flow_id: selection.dispatch.flow_id.clone(),
4096        handler_id: selection.dispatch.handler_id.clone(),
4097        pack_path: selection.pack_path.display().to_string(),
4098        output_dir: deploy_dir.display().to_string(),
4099        runner_cmd: vec![
4100            "greentic-runner".to_string(),
4101            "--pack".to_string(),
4102            selection.pack_path.display().to_string(),
4103            "--flow".to_string(),
4104            selection.dispatch.flow_id.clone(),
4105            "--plan".to_string(),
4106            plan_path.display().to_string(),
4107            "--output".to_string(),
4108            deploy_dir.display().to_string(),
4109        ],
4110        runner_env: vec![
4111            (
4112                "GREENTIC_PROVIDER".to_string(),
4113                config.provider.as_str().to_string(),
4114            ),
4115            ("GREENTIC_STRATEGY".to_string(), config.strategy.clone()),
4116            ("GREENTIC_TENANT".to_string(), config.tenant.clone()),
4117            (
4118                "GREENTIC_ENVIRONMENT".to_string(),
4119                config.environment.clone(),
4120            ),
4121            (
4122                "GREENTIC_DEPLOYMENT_CAPABILITY".to_string(),
4123                selection.dispatch.capability.as_str().to_string(),
4124            ),
4125            (
4126                "GREENTIC_DEPLOYMENT_PACK_ID".to_string(),
4127                selection.dispatch.pack_id.clone(),
4128            ),
4129            (
4130                "GREENTIC_DEPLOYMENT_FLOW_ID".to_string(),
4131                selection.dispatch.flow_id.clone(),
4132            ),
4133        ],
4134    }
4135}
4136
4137fn stage_span(stage: &str, config: &DeployerConfig) -> tracing::Span {
4138    let span = info_span!(
4139        "deployment",
4140        stage,
4141        tenant = %config.tenant,
4142        environment = %config.environment,
4143        provider = %config.provider.as_str()
4144    );
4145    span.record("greentic.deployer.provider", config.provider.as_str());
4146    span.record("greentic.deployer.tenant", config.tenant.as_str());
4147    span.record("greentic.deployer.environment", config.environment.as_str());
4148    span
4149}
4150
4151fn install_telemetry_context(stage: &str, config: &DeployerConfig) {
4152    let session = format!("{stage}/{env}", stage = stage, env = config.environment);
4153    let ctx = TelemetryCtx::new(config.tenant.clone())
4154        .with_provider(config.provider.as_str())
4155        .with_session(session);
4156    set_current_telemetry_ctx(ctx);
4157}
4158
4159fn render_plan_output(config: &DeployerConfig, plan: &PlanContext) -> Result<Option<String>> {
4160    match config.output {
4161        OutputFormat::Text => {
4162            let rendered = render_component_summary(plan);
4163            print!("{rendered}");
4164            Ok(Some(rendered))
4165        }
4166        OutputFormat::Json => {
4167            let json = serde_json::to_string_pretty(plan)
4168                .map_err(|err| DeployerError::Other(err.to_string()))?;
4169            println!("{json}");
4170            Ok(Some(json))
4171        }
4172        OutputFormat::Yaml => {
4173            let yaml =
4174                serde_yaml::to_string(plan).map_err(|err| DeployerError::Other(err.to_string()))?;
4175            println!("{yaml}");
4176            Ok(Some(yaml))
4177        }
4178    }
4179}
4180
4181fn render_component_summary(plan: &PlanContext) -> String {
4182    if plan.components.is_empty() {
4183        return "No component role/profile mappings available.\n".to_string();
4184    }
4185
4186    let mut out = format!("Component mappings for target {}:\n", plan.target.as_str());
4187    for component in &plan.components {
4188        out.push_str(&format!(
4189            "- {}: role={} profile={} infra={}",
4190            component.id,
4191            component.role.as_str(),
4192            component.profile.as_str(),
4193            component.infra.summary
4194        ));
4195        out.push('\n');
4196        if !component.infra.resources.is_empty() {
4197            out.push_str(&format!(
4198                "  resources: {}\n",
4199                component.infra.resources.join(", ")
4200            ));
4201        }
4202        if let Some(inference) = &component.inference {
4203            if !inference.warnings.is_empty() {
4204                for warning in &inference.warnings {
4205                    out.push_str(&format!("  warning: {warning}\n"));
4206                }
4207            } else {
4208                out.push_str(&format!("  info: {}\n", inference.source));
4209            }
4210        }
4211    }
4212    out
4213}
4214
4215fn render_contract_summary(
4216    config: &DeployerConfig,
4217    plan: &PlanContext,
4218    capability_contract: Option<&ResolvedCapabilityContract>,
4219) -> Result<Option<String>> {
4220    let rendered = match config.output {
4221        OutputFormat::Text => {
4222            let mut text = format!(
4223                "{} prepared for provider={} strategy={}\n",
4224                config.capability.as_str(),
4225                plan.deployment.provider,
4226                plan.deployment.strategy
4227            );
4228            if let Some(contract) = capability_contract {
4229                text.push_str(&format!("flow_id={}\n", contract.flow_id));
4230                if let Some(schema) = &contract.input_schema {
4231                    text.push_str(&format!("input_schema={}\n", schema.path));
4232                }
4233                if let Some(schema) = &contract.output_schema {
4234                    text.push_str(&format!("output_schema={}\n", schema.path));
4235                }
4236                if let Some(qa_spec) = &contract.qa_spec {
4237                    text.push_str(&format!("qa_spec={}\n", qa_spec.path));
4238                }
4239            }
4240            text
4241        }
4242        OutputFormat::Json => serde_json::to_string_pretty(&serde_json::json!({
4243            "capability": config.capability.as_str(),
4244            "provider": plan.deployment.provider,
4245            "strategy": plan.deployment.strategy,
4246            "contract": capability_contract,
4247        }))
4248        .map_err(|err| DeployerError::Other(err.to_string()))?,
4249        OutputFormat::Yaml => serde_yaml::to_string(&serde_json::json!({
4250            "capability": config.capability.as_str(),
4251            "provider": plan.deployment.provider,
4252            "strategy": plan.deployment.strategy,
4253            "contract": capability_contract,
4254        }))
4255        .map_err(|err| DeployerError::Other(err.to_string()))?,
4256    };
4257    println!("{rendered}");
4258    Ok(Some(rendered))
4259}
4260
4261#[cfg(test)]
4262mod tests {
4263    use super::*;
4264    use crate::config::{DeployerConfig, Provider};
4265    use crate::contract::{
4266        CapabilitySpecV1, DeployerCapability, DeployerContractV1, PlannerSpecV1,
4267        set_deployer_contract_v1,
4268    };
4269    use crate::deployment::{EXECUTOR_TEST_LOCK, clear_deployment_executor};
4270    use greentic_types::cbor::encode_pack_manifest;
4271    use greentic_types::component::{ComponentCapabilities, ComponentManifest, ComponentProfiles};
4272    use greentic_types::flow::{Flow, FlowHasher, FlowKind, FlowMetadata};
4273    use greentic_types::pack_manifest::{PackFlowEntry, PackKind, PackManifest};
4274    use greentic_types::{ComponentId, FlowId, PackId};
4275    use indexmap::IndexMap;
4276    use semver::Version;
4277    use std::path::PathBuf;
4278    use std::str::FromStr;
4279    use tar::Builder;
4280
4281    fn config_for(pack_path: PathBuf, capability: DeployerCapability) -> DeployerConfig {
4282        DeployerConfig {
4283            capability,
4284            provider: Provider::Aws,
4285            strategy: "iac-only".into(),
4286            tenant: "acme".into(),
4287            environment: "staging".into(),
4288            pack_path: pack_path.clone(),
4289            bundle_root: None,
4290            providers_dir: PathBuf::from("providers/deployer"),
4291            packs_dir: PathBuf::from("packs"),
4292            provider_pack: Some(pack_path),
4293            pack_ref: None,
4294            distributor_url: None,
4295            distributor_token: None,
4296            preview: false,
4297            dry_run: false,
4298            execute_local: false,
4299            output: crate::config::OutputFormat::Json,
4300            greentic: greentic_config::ConfigResolver::new()
4301                .load()
4302                .expect("load default config")
4303                .config,
4304            provenance: greentic_config::ProvenanceMap::new(),
4305            config_warnings: Vec::new(),
4306            deploy_pack_id_override: None,
4307            deploy_flow_id_override: None,
4308            bundle_source: None,
4309            bundle_digest: None,
4310            repo_registry_base: None,
4311            store_registry_base: None,
4312        }
4313    }
4314
4315    fn write_test_pack(with_contract: bool) -> PathBuf {
4316        let base = std::env::current_dir()
4317            .expect("cwd")
4318            .join("target/tmp-tests");
4319        std::fs::create_dir_all(&base).expect("create tmp base");
4320        let dir = tempfile::tempdir_in(base).expect("temp dir");
4321        let mut manifest = PackManifest {
4322            schema_version: "pack-v1".to_string(),
4323            pack_id: PackId::from_str("greentic.deploy.aws").unwrap(),
4324            name: None,
4325            version: Version::new(0, 1, 0),
4326            kind: PackKind::Application,
4327            publisher: "greentic".to_string(),
4328            secret_requirements: Vec::new(),
4329            components: vec![ComponentManifest {
4330                id: ComponentId::from_str("dev.greentic.component").unwrap(),
4331                version: Version::new(0, 1, 0),
4332                supports: Vec::new(),
4333                world: "greentic:test/world".to_string(),
4334                profiles: ComponentProfiles::default(),
4335                capabilities: ComponentCapabilities::default(),
4336                configurators: None,
4337                operations: Vec::new(),
4338                config_schema: None,
4339                resources: Default::default(),
4340                dev_flows: Default::default(),
4341            }],
4342            flows: vec![
4343                flow_entry("deploy_aws_iac"),
4344                flow_entry("plan_pack"),
4345                flow_entry("generate_pack"),
4346                flow_entry("destroy_pack"),
4347                flow_entry("status_pack"),
4348                flow_entry("rollback_pack"),
4349            ],
4350            dependencies: Vec::new(),
4351            capabilities: Vec::new(),
4352            signatures: Default::default(),
4353            bootstrap: None,
4354            extensions: None,
4355        };
4356        if with_contract {
4357            set_deployer_contract_v1(
4358                &mut manifest,
4359                DeployerContractV1 {
4360                    schema_version: 1,
4361                    planner: PlannerSpecV1 {
4362                        flow_id: "plan_pack".into(),
4363                        input_schema_ref: None,
4364                        output_schema_ref: Some("assets/schemas/plan-output.schema.json".into()),
4365                        qa_spec_ref: None,
4366                    },
4367                    capabilities: vec![
4368                        CapabilitySpecV1 {
4369                            capability: DeployerCapability::Plan,
4370                            flow_id: "plan_pack".into(),
4371                            input_schema_ref: None,
4372                            output_schema_ref: Some(
4373                                "assets/schemas/plan-output.schema.json".into(),
4374                            ),
4375                            execution_output_schema_ref: None,
4376                            qa_spec_ref: None,
4377                            example_refs: Vec::new(),
4378                        },
4379                        CapabilitySpecV1 {
4380                            capability: DeployerCapability::Generate,
4381                            flow_id: "generate_pack".into(),
4382                            input_schema_ref: Some(
4383                                "assets/schemas/generate-input.schema.json".into(),
4384                            ),
4385                            output_schema_ref: Some(
4386                                "assets/schemas/generate-output.schema.json".into(),
4387                            ),
4388                            execution_output_schema_ref: None,
4389                            qa_spec_ref: Some("assets/qa/generate.qa.json".into()),
4390                            example_refs: vec!["assets/examples/generate.example.json".into()],
4391                        },
4392                        CapabilitySpecV1 {
4393                            capability: DeployerCapability::Apply,
4394                            flow_id: "deploy_aws_iac".into(),
4395                            input_schema_ref: None,
4396                            output_schema_ref: None,
4397                            execution_output_schema_ref: Some(
4398                                "assets/schemas/apply-execution-output.schema.json".into(),
4399                            ),
4400                            qa_spec_ref: None,
4401                            example_refs: Vec::new(),
4402                        },
4403                        CapabilitySpecV1 {
4404                            capability: DeployerCapability::Destroy,
4405                            flow_id: "destroy_pack".into(),
4406                            input_schema_ref: None,
4407                            output_schema_ref: None,
4408                            execution_output_schema_ref: Some(
4409                                "assets/schemas/destroy-execution-output.schema.json".into(),
4410                            ),
4411                            qa_spec_ref: None,
4412                            example_refs: Vec::new(),
4413                        },
4414                        CapabilitySpecV1 {
4415                            capability: DeployerCapability::Status,
4416                            flow_id: "status_pack".into(),
4417                            input_schema_ref: None,
4418                            output_schema_ref: Some(
4419                                "assets/schemas/status-output.schema.json".into(),
4420                            ),
4421                            execution_output_schema_ref: Some(
4422                                "assets/schemas/status-execution-output.schema.json".into(),
4423                            ),
4424                            qa_spec_ref: None,
4425                            example_refs: Vec::new(),
4426                        },
4427                        CapabilitySpecV1 {
4428                            capability: DeployerCapability::Rollback,
4429                            flow_id: "rollback_pack".into(),
4430                            input_schema_ref: None,
4431                            output_schema_ref: Some(
4432                                "assets/schemas/rollback-output.schema.json".into(),
4433                            ),
4434                            execution_output_schema_ref: None,
4435                            qa_spec_ref: None,
4436                            example_refs: Vec::new(),
4437                        },
4438                    ],
4439                },
4440            )
4441            .unwrap();
4442        }
4443        let encoded = encode_pack_manifest(&manifest).expect("encode manifest");
4444        let mut builder = Builder::new(Vec::new());
4445        let mut header = tar::Header::new_gnu();
4446        header.set_size(encoded.len() as u64);
4447        header.set_mode(0o644);
4448        header.set_cksum();
4449        builder
4450            .append_data(&mut header, "manifest.cbor", encoded.as_slice())
4451            .expect("append manifest");
4452        if with_contract {
4453            append_tar_entry(
4454                &mut builder,
4455                "assets/schemas/plan-output.schema.json",
4456                br#"{"type":"object","required":["kind","plan"],"properties":{"kind":{"const":"plan"},"plan":{"type":"object"}}}"#,
4457            );
4458            append_tar_entry(
4459                &mut builder,
4460                "assets/schemas/generate-input.schema.json",
4461                br#"{"type":"object","properties":{"provider":{"type":"string"}}}"#,
4462            );
4463            append_tar_entry(
4464                &mut builder,
4465                "assets/schemas/generate-output.schema.json",
4466                br#"{"type":"object","required":["kind","capability","provider","strategy","input_schema_path","output_schema_path","qa_spec_path","example_paths"],"properties":{"kind":{"const":"generate"},"capability":{"const":"generate"},"provider":{"type":"string"},"strategy":{"type":"string"},"input_schema_path":{"const":"assets/schemas/generate-input.schema.json"},"output_schema_path":{"const":"assets/schemas/generate-output.schema.json"},"qa_spec_path":{"const":"assets/qa/generate.qa.json"},"example_paths":{"type":"array","items":{"type":"string"},"contains":{"const":"assets/examples/generate.example.json"}}}}"#,
4467            );
4468            append_tar_entry(
4469                &mut builder,
4470                "assets/qa/generate.qa.json",
4471                br#"{"questions":[{"id":"provider","kind":"select"}]}"#,
4472            );
4473            append_tar_entry(
4474                &mut builder,
4475                "assets/examples/generate.example.json",
4476                br#"{"provider":"aws","strategy":"iac-only"}"#,
4477            );
4478            append_tar_entry(
4479                &mut builder,
4480                "assets/examples/rendered-manifests.yaml",
4481                br#"apiVersion: v1
4482kind: Namespace
4483metadata:
4484  name: greentic
4485"#,
4486            );
4487            append_tar_entry(
4488                &mut builder,
4489                "assets/schemas/apply-execution-output.schema.json",
4490                br#"{"type":"object","required":["kind","deployment_id","state","endpoints"],"properties":{"kind":{"const":"apply"},"deployment_id":{"type":"string"},"state":{"type":"string"},"provider":{"type":"string"},"strategy":{"type":"string"},"endpoints":{"type":"array","items":{"type":"string"}},"output_refs":{"type":"object","additionalProperties":{"type":"string"}}}}"#,
4491            );
4492            append_tar_entry(
4493                &mut builder,
4494                "assets/schemas/destroy-execution-output.schema.json",
4495                br#"{"type":"object","required":["kind","deployment_id","state"],"properties":{"kind":{"const":"destroy"},"deployment_id":{"type":"string"},"state":{"type":"string"}}}"#,
4496            );
4497            append_tar_entry(
4498                &mut builder,
4499                "assets/schemas/status-output.schema.json",
4500                br#"{"type":"object","required":["kind","capability","provider","strategy","pack_id","flow_id"],"properties":{"kind":{"const":"status"},"capability":{"const":"status"},"provider":{"type":"string"},"strategy":{"type":"string"},"pack_id":{"type":"string"},"flow_id":{"type":"string"}}}"#,
4501            );
4502            append_tar_entry(
4503                &mut builder,
4504                "assets/schemas/status-execution-output.schema.json",
4505                br#"{"type":"object","required":["kind","deployment_id","state","health_checks"],"properties":{"kind":{"const":"status"},"deployment_id":{"type":"string"},"state":{"type":"string"},"provider":{"type":"string"},"strategy":{"type":"string"},"status_source":{"type":"string"},"endpoints":{"type":"array","items":{"type":"string"}},"health_checks":{"type":"array","items":{"type":"string"}},"output_refs":{"type":"object","additionalProperties":{"type":"string"}}}}"#,
4506            );
4507            append_tar_entry(
4508                &mut builder,
4509                "assets/schemas/rollback-output.schema.json",
4510                br#"{"type":"object","required":["kind","capability","provider","strategy","pack_id","flow_id","target_capability"],"properties":{"kind":{"const":"rollback"},"capability":{"const":"rollback"},"provider":{"type":"string"},"strategy":{"type":"string"},"pack_id":{"type":"string"},"flow_id":{"type":"string"},"target_capability":{"const":"apply"}}}"#,
4511            );
4512            append_tar_entry(&mut builder, "terraform/main.tf", br#"module "root" {}"#);
4513            append_tar_entry(
4514                &mut builder,
4515                "terraform/staging.tfvars.example",
4516                br#"dns_name = "acme.example.test""#,
4517            );
4518            append_tar_entry(
4519                &mut builder,
4520                "terraform/modules/operator/main.tf",
4521                br#"module "operator" {}"#,
4522            );
4523            append_tar_entry(
4524                &mut builder,
4525                "terraform/terraform",
4526                br#"#!/usr/bin/env bash
4527set -euo pipefail
4528printf '%s\n' "$*" >> terraform-invocation.args
4529if [ "${1:-}" = "output" ] && [ "${2:-}" = "-json" ]; then
4530cat <<'EOF'
4531{"operator_endpoint":{"value":"http://terraform-output.example.test"}}
4532EOF
4533fi
4534"#,
4535            );
4536            append_tar_entry(
4537                &mut builder,
4538                "chart/Chart.yaml",
4539                br#"apiVersion: v2
4540name: greentic
4541version: 0.1.0
4542"#,
4543            );
4544            append_tar_entry(
4545                &mut builder,
4546                "chart/values.yaml",
4547                br#"image:
4548  repository: ghcr.io/greentic-ai/operator-distroless
4549"#,
4550            );
4551            append_tar_entry(
4552                &mut builder,
4553                "chart/templates/deployment.yaml",
4554                br#"apiVersion: apps/v1
4555kind: Deployment
4556"#,
4557            );
4558        }
4559        let bytes = builder.into_inner().expect("tar bytes");
4560        let path = dir.path().join("sample.gtpack");
4561        std::fs::write(&path, bytes).expect("write pack");
4562        let _persisted = dir.keep();
4563        path
4564    }
4565
4566    fn flow_entry(id: &str) -> PackFlowEntry {
4567        PackFlowEntry {
4568            id: FlowId::from_str(id).unwrap(),
4569            kind: FlowKind::Messaging,
4570            flow: Flow {
4571                schema_version: "flowir-v1".to_string(),
4572                id: FlowId::from_str(id).unwrap(),
4573                kind: FlowKind::Messaging,
4574                entrypoints: Default::default(),
4575                nodes: IndexMap::<_, _, FlowHasher>::default(),
4576                metadata: FlowMetadata::default(),
4577            },
4578            tags: Vec::new(),
4579            entrypoints: Vec::new(),
4580        }
4581    }
4582
4583    fn append_tar_entry(builder: &mut Builder<Vec<u8>>, path: &str, bytes: &[u8]) {
4584        let mut header = tar::Header::new_gnu();
4585        header.set_size(bytes.len() as u64);
4586        header.set_mode(0o644);
4587        header.set_cksum();
4588        builder
4589            .append_data(&mut header, path, bytes)
4590            .expect("append tar entry");
4591    }
4592
4593    #[test]
4594    fn collect_output_files_returns_sorted_files_only() {
4595        let base = std::env::current_dir()
4596            .expect("cwd")
4597            .join("target/tmp-tests");
4598        std::fs::create_dir_all(&base).expect("create tmp base");
4599        let dir = tempfile::tempdir_in(base).expect("temp dir");
4600        std::fs::write(dir.path().join("b.txt"), "b").expect("write b");
4601        std::fs::write(dir.path().join("a.txt"), "a").expect("write a");
4602        std::fs::create_dir(dir.path().join("nested")).expect("create nested dir");
4603
4604        let files = collect_output_files(dir.path());
4605        assert_eq!(files, vec!["a.txt".to_string(), "b.txt".to_string()]);
4606    }
4607
4608    #[test]
4609    fn build_execution_report_merges_executor_payload() {
4610        let base = std::env::current_dir()
4611            .expect("cwd")
4612            .join("target/tmp-tests");
4613        std::fs::create_dir_all(&base).expect("create tmp base");
4614        let dir = tempfile::tempdir_in(base).expect("temp dir");
4615
4616        let deploy_dir = dir.path().join("deploy");
4617        std::fs::create_dir_all(&deploy_dir).expect("create deploy dir");
4618        std::fs::write(deploy_dir.join("local.json"), "{}").expect("write local file");
4619        let plan_path = dir.path().join("plan.json");
4620        let invoke_path = dir.path().join("invoke.json");
4621        let handoff_path = deploy_dir.join("._deployer_invocation.json");
4622        let runner_command_path = deploy_dir.join("._runner_cmd.txt");
4623        std::fs::write(&plan_path, "{}").expect("write plan");
4624        std::fs::write(&invoke_path, "{}").expect("write invoke");
4625        std::fs::write(&handoff_path, "{}").expect("write handoff");
4626        std::fs::write(&runner_command_path, "cmd").expect("write runner cmd");
4627
4628        let runtime_artifacts = RuntimeArtifacts {
4629            deploy_dir: deploy_dir.clone(),
4630            plan: plan_path,
4631            invoke: invoke_path,
4632            handoff: DeployerInvocation {
4633                capability: "apply".into(),
4634                pack_id: "greentic.deploy.aws".into(),
4635                handler_id: "builtin.aws".into(),
4636                flow_id: "deploy_aws_iac".into(),
4637                pack_path: "/tmp/sample.gtpack".into(),
4638                output_dir: deploy_dir.display().to_string(),
4639                runner_cmd: vec!["greentic-runner".into()],
4640                runner_env: vec![("GREENTIC_DEPLOYMENT_CAPABILITY".into(), "apply".into())],
4641            },
4642            handoff_path,
4643            runner_command_path,
4644        };
4645        let capability_contract = ResolvedCapabilityContract {
4646            capability: DeployerCapability::Apply,
4647            flow_id: "deploy_aws_iac".into(),
4648            input_schema: None,
4649            output_schema: None,
4650            execution_output_schema: Some(crate::contract::ContractAsset {
4651                path: "assets/schemas/apply-execution-output.schema.json".into(),
4652                json: Some(serde_json::json!({
4653                    "type": "object",
4654                    "required": ["kind", "deployment_id", "state", "endpoints"],
4655                    "properties": {
4656                        "kind": { "const": "apply" },
4657                        "deployment_id": { "type": "string" },
4658                        "state": { "type": "string" },
4659                        "provider": { "type": "string" },
4660                        "strategy": { "type": "string" },
4661                        "endpoints": { "type": "array", "items": { "type": "string" } },
4662                        "output_refs": {
4663                            "type": "object",
4664                            "additionalProperties": { "type": "string" }
4665                        }
4666                    }
4667                })),
4668                text: None,
4669                size_bytes: 0,
4670            }),
4671            qa_spec: None,
4672            examples: Vec::new(),
4673        };
4674
4675        let report = build_execution_report(
4676            "builtin.aws",
4677            &runtime_artifacts,
4678            Some(&capability_contract),
4679            Some(ExecutionOutcome {
4680                status: Some("applied".into()),
4681                message: Some("ok".into()),
4682                output_files: vec!["remote.json".into()],
4683                payload: Some(ExecutionOutcomePayload::Apply(
4684                    crate::deployment::ApplyExecutionOutcome {
4685                        deployment_id: "dep-42".into(),
4686                        state: "ready".into(),
4687                        provider: Some("aws".into()),
4688                        strategy: Some("iac-only".into()),
4689                        endpoints: vec!["https://ready.example.test".into()],
4690                        output_refs: BTreeMap::new(),
4691                    },
4692                )),
4693            }),
4694        );
4695
4696        assert_eq!(report.status.as_deref(), Some("applied"));
4697        assert_eq!(report.message.as_deref(), Some("ok"));
4698        assert_eq!(
4699            report.output_files,
4700            vec![
4701                "._deployer_invocation.json".to_string(),
4702                "._runner_cmd.txt".to_string(),
4703                "local.json".to_string(),
4704                "remote.json".to_string()
4705            ]
4706        );
4707        match report.outcome_payload.expect("outcome payload") {
4708            ExecutionOutcomePayload::Apply(payload) => {
4709                assert_eq!(payload.deployment_id, "dep-42");
4710                assert_eq!(payload.state, "ready");
4711            }
4712            other => panic!("unexpected outcome payload: {:?}", other),
4713        }
4714        assert!(
4715            report
4716                .outcome_validation
4717                .as_ref()
4718                .expect("validation")
4719                .valid
4720        );
4721    }
4722
4723    #[test]
4724    fn build_execution_report_validates_destroy_outcome_payload() {
4725        let base = std::env::current_dir()
4726            .expect("cwd")
4727            .join("target/tmp-tests");
4728        std::fs::create_dir_all(&base).expect("create tmp base");
4729        let dir = tempfile::tempdir_in(base).expect("temp dir");
4730
4731        let deploy_dir = dir.path().join("deploy");
4732        std::fs::create_dir_all(&deploy_dir).expect("create deploy dir");
4733        let runtime_artifacts = RuntimeArtifacts {
4734            deploy_dir: deploy_dir.clone(),
4735            plan: dir.path().join("plan.json"),
4736            invoke: dir.path().join("invoke.json"),
4737            handoff: DeployerInvocation {
4738                capability: "destroy".into(),
4739                pack_id: "greentic.deploy.aws".into(),
4740                handler_id: "builtin.aws".into(),
4741                flow_id: "destroy_pack".into(),
4742                pack_path: "/tmp/sample.gtpack".into(),
4743                output_dir: deploy_dir.display().to_string(),
4744                runner_cmd: vec!["greentic-runner".into()],
4745                runner_env: vec![("GREENTIC_DEPLOYMENT_CAPABILITY".into(), "destroy".into())],
4746            },
4747            handoff_path: deploy_dir.join("._deployer_invocation.json"),
4748            runner_command_path: deploy_dir.join("._runner_cmd.txt"),
4749        };
4750        let capability_contract = ResolvedCapabilityContract {
4751            capability: DeployerCapability::Destroy,
4752            flow_id: "destroy_pack".into(),
4753            input_schema: None,
4754            output_schema: None,
4755            execution_output_schema: Some(crate::contract::ContractAsset {
4756                path: "assets/schemas/destroy-execution-output.schema.json".into(),
4757                json: Some(serde_json::json!({
4758                    "type": "object",
4759                    "required": ["kind", "deployment_id", "state", "destroyed_resources"],
4760                    "properties": {
4761                        "kind": { "const": "destroy" },
4762                        "deployment_id": { "type": "string" },
4763                        "state": { "type": "string" },
4764                        "destroyed_resources": {
4765                            "type": "array",
4766                            "items": { "type": "string" }
4767                        }
4768                    }
4769                })),
4770                text: None,
4771                size_bytes: 0,
4772            }),
4773            qa_spec: None,
4774            examples: Vec::new(),
4775        };
4776
4777        let report = build_execution_report(
4778            "builtin.aws",
4779            &runtime_artifacts,
4780            Some(&capability_contract),
4781            Some(ExecutionOutcome {
4782                status: Some("destroyed".into()),
4783                message: None,
4784                output_files: Vec::new(),
4785                payload: Some(ExecutionOutcomePayload::Destroy(
4786                    crate::deployment::DestroyExecutionOutcome {
4787                        deployment_id: "dep-42".into(),
4788                        state: "deleted".into(),
4789                        destroyed_resources: Vec::new(),
4790                    },
4791                )),
4792            }),
4793        );
4794
4795        assert!(
4796            report
4797                .outcome_validation
4798                .as_ref()
4799                .expect("validation")
4800                .valid
4801        );
4802    }
4803
4804    #[test]
4805    fn build_execution_report_validates_status_outcome_payload() {
4806        let base = std::env::current_dir()
4807            .expect("cwd")
4808            .join("target/tmp-tests");
4809        std::fs::create_dir_all(&base).expect("create tmp base");
4810        let dir = tempfile::tempdir_in(base).expect("temp dir");
4811
4812        let deploy_dir = dir.path().join("deploy");
4813        std::fs::create_dir_all(&deploy_dir).expect("create deploy dir");
4814        let runtime_artifacts = RuntimeArtifacts {
4815            deploy_dir: deploy_dir.clone(),
4816            plan: dir.path().join("plan.json"),
4817            invoke: dir.path().join("invoke.json"),
4818            handoff: DeployerInvocation {
4819                capability: "status".into(),
4820                pack_id: "greentic.deploy.aws".into(),
4821                handler_id: "builtin.aws".into(),
4822                flow_id: "status_pack".into(),
4823                pack_path: "/tmp/sample.gtpack".into(),
4824                output_dir: deploy_dir.display().to_string(),
4825                runner_cmd: vec!["greentic-runner".into()],
4826                runner_env: vec![("GREENTIC_DEPLOYMENT_CAPABILITY".into(), "status".into())],
4827            },
4828            handoff_path: deploy_dir.join("._deployer_invocation.json"),
4829            runner_command_path: deploy_dir.join("._runner_cmd.txt"),
4830        };
4831        let capability_contract = ResolvedCapabilityContract {
4832            capability: DeployerCapability::Status,
4833            flow_id: "status_pack".into(),
4834            input_schema: None,
4835            output_schema: Some(crate::contract::ContractAsset {
4836                path: "assets/schemas/status-output.schema.json".into(),
4837                json: None,
4838                text: None,
4839                size_bytes: 0,
4840            }),
4841            execution_output_schema: Some(crate::contract::ContractAsset {
4842                path: "assets/schemas/status-execution-output.schema.json".into(),
4843                json: Some(serde_json::json!({
4844                    "type": "object",
4845                    "required": ["kind", "deployment_id", "state", "health_checks"],
4846                    "properties": {
4847                        "kind": { "const": "status" },
4848                        "deployment_id": { "type": "string" },
4849                        "state": { "type": "string" },
4850                        "provider": { "type": "string" },
4851                        "strategy": { "type": "string" },
4852                        "status_source": { "type": "string" },
4853                        "endpoints": { "type": "array", "items": { "type": "string" } },
4854                        "health_checks": { "type": "array", "items": { "type": "string" } },
4855                        "output_refs": {
4856                            "type": "object",
4857                            "additionalProperties": { "type": "string" }
4858                        }
4859                    }
4860                })),
4861                text: None,
4862                size_bytes: 0,
4863            }),
4864            qa_spec: None,
4865            examples: Vec::new(),
4866        };
4867
4868        let report = build_execution_report(
4869            "builtin.aws",
4870            &runtime_artifacts,
4871            Some(&capability_contract),
4872            Some(ExecutionOutcome {
4873                status: Some("ready".into()),
4874                message: None,
4875                output_files: Vec::new(),
4876                payload: Some(ExecutionOutcomePayload::Status(
4877                    crate::deployment::StatusExecutionOutcome {
4878                        deployment_id: "dep-42".into(),
4879                        state: "healthy".into(),
4880                        provider: Some("aws".into()),
4881                        strategy: Some("iac-only".into()),
4882                        status_source: Some("terraform_handoff".into()),
4883                        endpoints: vec!["https://ready.example.test".into()],
4884                        health_checks: vec!["http:ok".into()],
4885                        output_refs: BTreeMap::new(),
4886                    },
4887                )),
4888            }),
4889        );
4890
4891        assert!(
4892            report
4893                .outcome_validation
4894                .as_ref()
4895                .expect("validation")
4896                .valid
4897        );
4898    }
4899
4900    #[tokio::test]
4901    async fn plan_result_contains_typed_payload() {
4902        let _guard = EXECUTOR_TEST_LOCK.lock().await;
4903        clear_deployment_executor();
4904        let pack_path = write_test_pack(true);
4905        let result = run(config_for(pack_path, DeployerCapability::Plan))
4906            .await
4907            .expect("plan runs");
4908        match result.payload.expect("payload") {
4909            OperationPayload::Plan(payload) => {
4910                assert_eq!(payload.plan.plan.tenant, "acme");
4911            }
4912            other => panic!("unexpected payload: {:?}", other),
4913        }
4914        assert!(result.output_validation.as_ref().expect("validation").valid);
4915    }
4916
4917    #[tokio::test]
4918    async fn terraform_status_synthesizes_local_execution_outcome() {
4919        let _guard = EXECUTOR_TEST_LOCK.lock().await;
4920        clear_deployment_executor();
4921        let base = std::env::current_dir()
4922            .expect("cwd")
4923            .join("target/tmp-tests");
4924        std::fs::create_dir_all(&base).expect("create tmp base");
4925        let dir = tempfile::tempdir_in(base).expect("temp dir");
4926        let pack_path = write_test_pack(true);
4927
4928        let mut greentic = greentic_config::ConfigResolver::new()
4929            .load()
4930            .expect("load default config")
4931            .config;
4932        greentic.paths.state_dir = dir.path().join(".greentic-state");
4933
4934        let result = run(DeployerConfig {
4935            capability: DeployerCapability::Status,
4936            provider: Provider::Generic,
4937            strategy: "terraform".into(),
4938            tenant: "acme".into(),
4939            environment: "staging".into(),
4940            pack_path: pack_path.clone(),
4941            bundle_root: None,
4942            providers_dir: PathBuf::from("providers/deployer"),
4943            packs_dir: PathBuf::from("packs"),
4944            provider_pack: Some(pack_path),
4945            pack_ref: None,
4946            distributor_url: None,
4947            distributor_token: None,
4948            preview: false,
4949            dry_run: false,
4950            execute_local: false,
4951            output: crate::config::OutputFormat::Text,
4952            greentic,
4953            provenance: greentic_config::ProvenanceMap::new(),
4954            config_warnings: Vec::new(),
4955            deploy_pack_id_override: None,
4956            deploy_flow_id_override: None,
4957            bundle_source: None,
4958            bundle_digest: None,
4959            repo_registry_base: None,
4960            store_registry_base: None,
4961        })
4962        .await
4963        .expect("terraform status runs");
4964
4965        assert!(result.executed);
4966        assert_eq!(result.capability, "status");
4967        let execution = result.execution.expect("execution report");
4968        assert_eq!(execution.status.as_deref(), Some("handoff_ready"));
4969        match execution.outcome_payload.expect("outcome payload") {
4970            ExecutionOutcomePayload::Status(payload) => {
4971                assert_eq!(payload.state, "handoff_ready");
4972                assert!(
4973                    payload
4974                        .health_checks
4975                        .iter()
4976                        .any(|entry| entry == "terraform_root:present")
4977                );
4978                assert!(
4979                    payload
4980                        .health_checks
4981                        .iter()
4982                        .any(|entry| entry == "script:terraform-status.sh:present")
4983                );
4984            }
4985            other => panic!("unexpected outcome payload: {:?}", other),
4986        }
4987    }
4988
4989    #[tokio::test]
4990    async fn terraform_apply_execute_runs_local_script_via_fake_terraform() {
4991        let _guard = EXECUTOR_TEST_LOCK.lock().await;
4992        clear_deployment_executor();
4993        let base = std::env::current_dir()
4994            .expect("cwd")
4995            .join("target/tmp-tests");
4996        std::fs::create_dir_all(&base).expect("create tmp base");
4997        let dir = tempfile::tempdir_in(&base).expect("temp dir");
4998        let pack_path = write_test_pack(true);
4999        let mut greentic = greentic_config::ConfigResolver::new()
5000            .load()
5001            .expect("load default config")
5002            .config;
5003        greentic.paths.state_dir = dir.path().join(".greentic-state");
5004
5005        let result = run(DeployerConfig {
5006            capability: DeployerCapability::Apply,
5007            provider: Provider::Generic,
5008            strategy: "terraform".into(),
5009            tenant: "acme".into(),
5010            environment: "staging".into(),
5011            pack_path: pack_path.clone(),
5012            bundle_root: None,
5013            providers_dir: PathBuf::from("providers/deployer"),
5014            packs_dir: PathBuf::from("packs"),
5015            provider_pack: Some(pack_path),
5016            pack_ref: None,
5017            distributor_url: None,
5018            distributor_token: None,
5019            preview: false,
5020            dry_run: false,
5021            execute_local: true,
5022            output: crate::config::OutputFormat::Text,
5023            greentic,
5024            provenance: greentic_config::ProvenanceMap::new(),
5025            config_warnings: Vec::new(),
5026            deploy_pack_id_override: None,
5027            deploy_flow_id_override: None,
5028            bundle_source: Some("file:///tmp/apply-test.gtbundle".into()),
5029            bundle_digest: Some(
5030                "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
5031            ),
5032            repo_registry_base: None,
5033            store_registry_base: None,
5034        })
5035        .await
5036        .expect("terraform apply runs");
5037        assert!(result.executed);
5038        let execution = result.execution.expect("execution report");
5039        assert_eq!(execution.status.as_deref(), Some("applied"));
5040        match execution.outcome_payload.expect("outcome payload") {
5041            ExecutionOutcomePayload::Apply(payload) => {
5042                assert_eq!(
5043                    payload.endpoints,
5044                    vec!["http://terraform-output.example.test"]
5045                );
5046            }
5047            other => panic!("unexpected outcome payload: {:?}", other),
5048        }
5049        assert!(
5050            execution
5051                .output_files
5052                .iter()
5053                .any(|entry| entry == "terraform-apply.stdout.log")
5054        );
5055        let applied_args = std::fs::read_to_string(
5056            Path::new(&result.output_dir)
5057                .join("terraform")
5058                .join("terraform-invocation.args"),
5059        )
5060        .expect("read fake terraform args");
5061        assert!(applied_args.contains("apply -auto-approve -input=false"));
5062        assert!(applied_args.contains("-var-file=staging.tfvars"));
5063        assert!(applied_args.contains("output -json"));
5064    }
5065
5066    #[test]
5067    fn parse_dns_name_endpoint_extracts_https_endpoint() {
5068        let endpoint = parse_dns_name_endpoint(
5069            r#"
5070            dns_name = "acme.example.test"
5071            "#,
5072        );
5073        assert_eq!(endpoint.as_deref(), Some("https://acme.example.test"));
5074    }
5075
5076    #[test]
5077    fn persist_runtime_artifacts_materializes_terraform_handoff_assets() {
5078        let base = std::env::current_dir()
5079            .expect("cwd")
5080            .join("target/tmp-tests");
5081        std::fs::create_dir_all(&base).expect("create tmp base");
5082        let dir = tempfile::tempdir_in(base).expect("temp dir");
5083        let pack_path = write_test_pack(true);
5084
5085        let mut greentic = greentic_config::ConfigResolver::new()
5086            .load()
5087            .expect("load default config")
5088            .config;
5089        greentic.paths.state_dir = dir.path().join(".greentic-state");
5090
5091        let config = DeployerConfig {
5092            capability: DeployerCapability::Plan,
5093            provider: Provider::Generic,
5094            strategy: "terraform".into(),
5095            tenant: "acme".into(),
5096            environment: "staging".into(),
5097            pack_path: pack_path.clone(),
5098            bundle_root: None,
5099            providers_dir: PathBuf::from("providers/deployer"),
5100            packs_dir: PathBuf::from("packs"),
5101            provider_pack: Some(pack_path.clone()),
5102            pack_ref: None,
5103            distributor_url: None,
5104            distributor_token: None,
5105            preview: false,
5106            dry_run: false,
5107            execute_local: false,
5108            output: crate::config::OutputFormat::Json,
5109            greentic,
5110            provenance: greentic_config::ProvenanceMap::new(),
5111            config_warnings: Vec::new(),
5112            deploy_pack_id_override: None,
5113            deploy_flow_id_override: None,
5114            bundle_source: Some("file:///tmp/demo.gtbundle".into()),
5115            bundle_digest: Some(
5116                "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into(),
5117            ),
5118            repo_registry_base: None,
5119            store_registry_base: None,
5120        };
5121        let plan = pack_introspect::build_plan(&config).expect("build plan");
5122        let deploy_dir = dir.path().join("output");
5123        std::fs::create_dir_all(&deploy_dir).expect("create output dir");
5124        let selection = DeploymentPackSelection {
5125            dispatch: crate::deployment::DeploymentDispatch {
5126                capability: DeployerCapability::Plan,
5127                pack_id: "greentic.deploy.terraform".into(),
5128                flow_id: "plan_terraform".into(),
5129                handler_id: "builtin.terraform".into(),
5130            },
5131            pack_path,
5132            manifest: PackManifest {
5133                schema_version: "pack-v1".to_string(),
5134                pack_id: PackId::from_str("greentic.deploy.terraform").unwrap(),
5135                name: None,
5136                version: Version::new(0, 1, 0),
5137                kind: PackKind::Application,
5138                publisher: "greentic".to_string(),
5139                secret_requirements: Vec::new(),
5140                components: Vec::new(),
5141                flows: Vec::new(),
5142                dependencies: Vec::new(),
5143                capabilities: Vec::new(),
5144                signatures: Default::default(),
5145                bootstrap: None,
5146                extensions: None,
5147            },
5148            origin: "test".into(),
5149            candidates: Vec::new(),
5150        };
5151
5152        let artifacts = persist_runtime_artifacts(&config, &plan, &selection, &deploy_dir)
5153            .expect("persist runtime artifacts");
5154
5155        assert!(artifacts.deploy_dir.join("terraform/main.tf").exists());
5156        assert!(
5157            artifacts
5158                .deploy_dir
5159                .join("terraform/modules/operator/main.tf")
5160                .exists()
5161        );
5162        assert!(artifacts.deploy_dir.join("terraform-init.sh").exists());
5163        assert!(artifacts.deploy_dir.join("terraform-plan.sh").exists());
5164        assert!(artifacts.deploy_dir.join("terraform-apply.sh").exists());
5165        assert!(artifacts.deploy_dir.join("terraform-destroy.sh").exists());
5166        assert!(artifacts.deploy_dir.join("terraform-status.sh").exists());
5167        let metadata: TerraformRuntimeMetadata = serde_json::from_slice(
5168            &std::fs::read(artifacts.deploy_dir.join("terraform-runtime.json"))
5169                .expect("read terraform runtime metadata"),
5170        )
5171        .expect("parse terraform runtime metadata");
5172        assert_eq!(
5173            metadata.scripts,
5174            vec![
5175                "terraform-init.sh".to_string(),
5176                "terraform-plan.sh".to_string(),
5177                "terraform-apply.sh".to_string(),
5178                "terraform-destroy.sh".to_string(),
5179                "terraform-status.sh".to_string()
5180            ]
5181        );
5182        assert_eq!(metadata.generated_tfvars.as_deref(), Some("staging.tfvars"));
5183        assert_eq!(metadata.status_command, "./terraform-status.sh");
5184        let note = std::fs::read_to_string(artifacts.deploy_dir.join("terraform-handoff.txt"))
5185            .expect("read terraform handoff note");
5186        assert!(note.contains("terraform_root="));
5187        assert!(note.contains("generated_tfvars="));
5188        assert!(note.contains("copied_files:"));
5189        assert!(note.contains("modules/operator/main.tf"));
5190        assert!(note.contains("status_command=./terraform-status.sh"));
5191    }
5192
5193    #[test]
5194    fn secrets_provider_binding_maps_targets_to_provider_packs() {
5195        let pack_path = PathBuf::from("/tmp/provider.gtpack");
5196        let mut config = config_for(pack_path, DeployerCapability::Apply);
5197        config.tenant = "demo".into();
5198        config.environment = "dev".into();
5199
5200        let cases = [
5201            (
5202                Provider::Aws,
5203                "greentic.secrets.aws-sm",
5204                "providers/secrets/aws-sm.gtpack",
5205            ),
5206            (
5207                Provider::Gcp,
5208                "greentic.secrets.gcp-sm",
5209                "providers/secrets/gcp-sm.gtpack",
5210            ),
5211            (
5212                Provider::Azure,
5213                "greentic.secrets.azure-kv",
5214                "providers/secrets/azure-kv.gtpack",
5215            ),
5216            (
5217                Provider::Local,
5218                "greentic.secrets.dev",
5219                "providers/secrets/dev.gtpack",
5220            ),
5221        ];
5222
5223        for (provider, provider_id, pack) in cases {
5224            config.provider = provider;
5225            let binding = secrets_provider_binding_for_target(&config).expect("binding for target");
5226            assert_eq!(
5227                binding.schema_version,
5228                SECRETS_PROVIDER_BINDING_SCHEMA_VERSION
5229            );
5230            assert_eq!(binding.provider_id, provider_id);
5231            assert_eq!(binding.pack, pack);
5232            assert_eq!(
5233                binding.config.get("environment").map(String::as_str),
5234                Some("dev")
5235            );
5236            assert_eq!(
5237                binding.config.get("tenant").map(String::as_str),
5238                Some("demo")
5239            );
5240            assert_eq!(binding.config.get("team").map(String::as_str), Some("_"));
5241            assert_eq!(
5242                binding.config.get("namespace_prefix").map(String::as_str),
5243                Some("greentic/dev/demo/_")
5244            );
5245            assert_eq!(
5246                binding.config.get("prefix").map(String::as_str),
5247                Some("greentic/dev/demo/_")
5248            );
5249        }
5250
5251        config.provider = Provider::Generic;
5252        assert!(secrets_provider_binding_for_target(&config).is_none());
5253    }
5254
5255    #[test]
5256    fn persist_runtime_artifacts_materializes_aws_secrets_provider_binding() {
5257        let base = std::env::current_dir()
5258            .expect("cwd")
5259            .join("target/tmp-tests");
5260        std::fs::create_dir_all(&base).expect("create tmp base");
5261        let dir = tempfile::tempdir_in(base).expect("temp dir");
5262        let pack_path = write_test_pack(true);
5263
5264        let mut greentic = greentic_config::ConfigResolver::new()
5265            .load()
5266            .expect("load default config")
5267            .config;
5268        greentic.paths.state_dir = dir.path().join(".greentic-state");
5269
5270        let config = DeployerConfig {
5271            capability: DeployerCapability::Plan,
5272            provider: Provider::Aws,
5273            strategy: "iac-only".into(),
5274            tenant: "demo".into(),
5275            environment: "dev".into(),
5276            pack_path: pack_path.clone(),
5277            bundle_root: None,
5278            providers_dir: PathBuf::from("providers/deployer"),
5279            packs_dir: PathBuf::from("packs"),
5280            provider_pack: Some(pack_path.clone()),
5281            pack_ref: None,
5282            distributor_url: None,
5283            distributor_token: None,
5284            preview: false,
5285            dry_run: false,
5286            execute_local: false,
5287            output: crate::config::OutputFormat::Json,
5288            greentic,
5289            provenance: greentic_config::ProvenanceMap::new(),
5290            config_warnings: Vec::new(),
5291            deploy_pack_id_override: None,
5292            deploy_flow_id_override: None,
5293            bundle_source: Some("s3://bucket/bundle.gtbundle".into()),
5294            bundle_digest: Some(
5295                "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into(),
5296            ),
5297            repo_registry_base: None,
5298            store_registry_base: None,
5299        };
5300        let plan = pack_introspect::build_plan(&config).expect("build plan");
5301        let deploy_dir = dir.path().join("output");
5302        std::fs::create_dir_all(&deploy_dir).expect("create output dir");
5303        let selection = DeploymentPackSelection {
5304            dispatch: crate::deployment::DeploymentDispatch {
5305                capability: DeployerCapability::Plan,
5306                pack_id: "greentic.deploy.aws".into(),
5307                flow_id: "plan_pack".into(),
5308                handler_id: "builtin.aws".into(),
5309            },
5310            pack_path,
5311            manifest: PackManifest {
5312                schema_version: "pack-v1".to_string(),
5313                pack_id: PackId::from_str("greentic.deploy.aws").unwrap(),
5314                name: None,
5315                version: Version::new(0, 1, 0),
5316                kind: PackKind::Application,
5317                publisher: "greentic".to_string(),
5318                secret_requirements: Vec::new(),
5319                components: Vec::new(),
5320                flows: Vec::new(),
5321                dependencies: Vec::new(),
5322                capabilities: Vec::new(),
5323                signatures: Default::default(),
5324                bootstrap: None,
5325                extensions: None,
5326            },
5327            origin: "test".into(),
5328            candidates: Vec::new(),
5329        };
5330
5331        let artifacts = persist_runtime_artifacts(&config, &plan, &selection, &deploy_dir)
5332            .expect("persist runtime artifacts");
5333        let binding_path = artifacts
5334            .deploy_dir
5335            .join(SECRETS_PROVIDER_BINDING_RELATIVE_PATH);
5336        let binding: SecretsProviderBinding = serde_json::from_slice(
5337            &std::fs::read(&binding_path).expect("read secrets provider binding"),
5338        )
5339        .expect("parse secrets provider binding");
5340
5341        assert_eq!(binding.schema_version, "greentic.secrets.binding.v1");
5342        assert_eq!(binding.provider_id, "greentic.secrets.aws-sm");
5343        assert_eq!(binding.pack, "providers/secrets/aws-sm.gtpack");
5344        assert_eq!(
5345            binding.config.get("namespace_prefix").map(String::as_str),
5346            Some("greentic/dev/demo/_")
5347        );
5348        let metadata: TerraformRuntimeMetadata = serde_json::from_slice(
5349            &std::fs::read(artifacts.deploy_dir.join("terraform-runtime.json"))
5350                .expect("read terraform runtime metadata"),
5351        )
5352        .expect("parse terraform runtime metadata");
5353        assert_eq!(
5354            metadata.secrets_provider_binding.as_deref(),
5355            Some(SECRETS_PROVIDER_BINDING_RELATIVE_PATH)
5356        );
5357        let note = std::fs::read_to_string(artifacts.deploy_dir.join("terraform-handoff.txt"))
5358            .expect("read terraform handoff note");
5359        assert!(note.contains("secrets_provider_binding="));
5360    }
5361
5362    #[test]
5363    fn terraform_apply_script_for_azure_imports_existing_resources() {
5364        let rendered = terraform_plan_like_script(
5365            "apply",
5366            Provider::Azure,
5367            Some("dev.tfvars"),
5368            "staging.tfvars.example",
5369        );
5370        assert!(rendered.contains("az keyvault secret show"));
5371        assert!(rendered.contains("azurerm_container_app_environment.this"));
5372        assert!(rendered.contains("azurerm_container_app.this"));
5373        assert!(rendered.contains("module.operator_azure[0]"));
5374        assert!(rendered.contains("module.operator"));
5375        assert!(rendered.contains("import -input=false"));
5376    }
5377
5378    #[test]
5379    fn terraform_apply_script_for_gcp_imports_existing_secrets() {
5380        let rendered = terraform_plan_like_script(
5381            "apply",
5382            Provider::Gcp,
5383            Some("dev.tfvars"),
5384            "staging.tfvars.example",
5385        );
5386        assert!(rendered.contains("gcloud secrets describe"));
5387        assert!(rendered.contains("google_secret_manager_secret.admin_ca"));
5388        assert!(rendered.contains("google_secret_manager_secret.admin_server_cert"));
5389        assert!(rendered.contains("google_secret_manager_secret.admin_server_key"));
5390        assert!(rendered.contains("gcloud run services describe"));
5391        assert!(rendered.contains("google_cloud_run_v2_service.this"));
5392        assert!(rendered.contains("module.operator_gcp[0]"));
5393        assert!(rendered.contains("import -input=false"));
5394        assert!(rendered.contains("GCP apply hit transient condition"));
5395    }
5396
5397    #[test]
5398    fn normalize_terraform_main_tf_for_gcp_respects_operator_image_override() {
5399        let dir = tempfile::tempdir().expect("tempdir");
5400        let terraform_root = dir.path().join("terraform");
5401        std::fs::create_dir_all(&terraform_root).expect("terraform root");
5402        let main_tf = terraform_root.join("main.tf");
5403        std::fs::write(
5404            &main_tf,
5405            r#"
5406module "operator" {
5407  source = "./modules/operator-gcp"
5408  operator_image        = "ghcr.io/greenticai/greentic-start-distroless@${var.operator_image_digest}"
5409}
5410"#,
5411        )
5412        .expect("write main.tf");
5413
5414        let config = DeployerConfig {
5415            provider: Provider::Gcp,
5416            ..config_for(terraform_root.clone(), DeployerCapability::Apply)
5417        };
5418        normalize_terraform_main_tf(&config, &terraform_root).expect("normalize main.tf");
5419
5420        let rendered = std::fs::read_to_string(main_tf).expect("read main.tf");
5421        assert!(rendered.contains(
5422            r#"operator_image        = var.operator_image != "" ? var.operator_image : "ghcr.io/greenticai/greentic-start-distroless@${var.operator_image_digest}""#
5423        ));
5424    }
5425
5426    #[test]
5427    fn normalize_terraform_main_tf_for_gcp_switches_operator_module_to_direct_pem_envs() {
5428        let dir = tempfile::tempdir().expect("tempdir");
5429        let terraform_root = dir.path().join("terraform");
5430        let module_root = terraform_root.join("modules/operator-gcp");
5431        std::fs::create_dir_all(&module_root).expect("module root");
5432        let main_tf = terraform_root.join("main.tf");
5433        std::fs::write(
5434            &main_tf,
5435            r#"
5436module "operator" {
5437  source = "./modules/operator-gcp"
5438  operator_image        = var.operator_image != "" ? var.operator_image : "ghcr.io/greenticai/greentic-start-distroless@${var.operator_image_digest}"
5439}
5440"#,
5441        )
5442        .expect("write main.tf");
5443        let module_main_tf = module_root.join("main.tf");
5444        std::fs::write(
5445            &module_main_tf,
5446            r#"
5447resource "google_secret_manager_secret_version" "admin_server_key" {
5448  secret      = google_secret_manager_secret.admin_server_key.id
5449  secret_data = tls_private_key.admin_server.private_key_pem
5450}
5451
5452resource "google_cloud_run_v2_service" "this" {
5453  name     = local.service_name
5454  location = var.gcp_region
5455  project  = var.gcp_project_id
5456  ingress  = "INGRESS_TRAFFIC_ALL"
5457
5458  template {
5459    scaling {
5460      min_instance_count = 1
5461      max_instance_count = 1
5462    }
5463
5464    env {
5465        name  = "GREENTIC_ADMIN_CA_SECRET_REF"
5466        value = google_secret_manager_secret.admin_ca.id
5467    }
5468
5469    env {
5470        name  = "GREENTIC_ADMIN_SERVER_CERT_SECRET_REF"
5471        value = google_secret_manager_secret.admin_server_cert.id
5472    }
5473
5474    env {
5475        name  = "GREENTIC_ADMIN_SERVER_KEY_SECRET_REF"
5476        value = google_secret_manager_secret.admin_server_key.id
5477    }
5478  }
5479}
5480"#,
5481        )
5482        .expect("write module main.tf");
5483
5484        let config = DeployerConfig {
5485            provider: Provider::Gcp,
5486            ..config_for(terraform_root.clone(), DeployerCapability::Apply)
5487        };
5488        normalize_terraform_main_tf(&config, &terraform_root).expect("normalize main.tf");
5489
5490        let rendered = std::fs::read_to_string(module_main_tf).expect("read module main.tf");
5491        assert!(rendered.contains("GREENTIC_ADMIN_CA_PEM"));
5492        assert!(rendered.contains("tls_self_signed_cert.admin_ca.cert_pem"));
5493        assert!(rendered.contains("GREENTIC_ADMIN_SERVER_CERT_PEM"));
5494        assert!(rendered.contains("tls_locally_signed_cert.admin_server.cert_pem"));
5495        assert!(rendered.contains("GREENTIC_ADMIN_SERVER_KEY_PEM"));
5496        assert!(rendered.contains("tls_private_key.admin_server.private_key_pem"));
5497        assert!(!rendered.contains("GREENTIC_ADMIN_CA_SECRET_REF"));
5498        assert!(!rendered.contains("runtime_admin_ca_accessor"));
5499    }
5500
5501    #[test]
5502    fn terraform_apply_script_for_aws_imports_existing_fixed_name_resources() {
5503        let rendered = terraform_plan_like_script(
5504            "apply",
5505            Provider::Aws,
5506            Some("dev.tfvars"),
5507            "staging.tfvars.example",
5508        );
5509        assert!(rendered.contains("module.operator_aws[0]"));
5510        assert!(rendered.contains("aws ec2 describe-security-groups"));
5511        assert!(rendered.contains("aws elbv2 describe-load-balancers"));
5512        assert!(rendered.contains("aws elbv2 describe-listeners"));
5513        assert!(rendered.contains("aws_lb_listener.http"));
5514        assert!(rendered.contains("aws ecs describe-clusters"));
5515        assert!(rendered.contains("aws ecs describe-services"));
5516        assert!(rendered.contains("aws_cloudwatch_log_group.this"));
5517        assert!(rendered.contains("aws_iam_role.task_execution"));
5518        assert!(rendered.contains("AWS apply hit transient condition"));
5519        assert!(rendered.contains("INIT_ARGS=(-input=false)"));
5520        assert!(rendered.contains("\"$TERRAFORM_BIN\" init \"${INIT_ARGS[@]}\""));
5521        assert!(!rendered.contains("BACKEND_ARGS"));
5522        assert!(rendered.contains("hash_string()"));
5523        assert!(rendered.contains("command -v md5sum"));
5524        assert!(rendered.contains("md5 -q"));
5525        assert!(rendered.contains("SHORT_ID=\"$(hash_string \"$BUNDLE_DIGEST_VALUE\")\""));
5526        assert!(!rendered.contains("SHORT_ID=$(printf '%s' \"$BUNDLE_DIGEST_VALUE\" | md5sum"));
5527    }
5528
5529    #[test]
5530    fn prune_generated_terraform_root_for_aws_includes_tenant_argument() {
5531        let dir = tempfile::tempdir().expect("tempdir");
5532        let terraform_root = dir.path().join("terraform");
5533        std::fs::create_dir_all(&terraform_root).expect("terraform root");
5534        std::fs::create_dir_all(terraform_root.join("modules/operator")).expect("module root");
5535        std::fs::write(terraform_root.join("variables.tf"), "").expect("write variables.tf");
5536        std::fs::write(terraform_root.join("modules/operator/variables.tf"), "")
5537            .expect("write module variables.tf");
5538        std::fs::write(
5539            terraform_root.join("modules/operator/main.tf"),
5540            r#"resource "aws_iam_role_policy" "task_execution_ecs_exec" {
5541  name = "${local.name_prefix}-task-exec-ecs-exec"
5542  role = aws_iam_role.task_execution.id
5543}
5544
5545resource "aws_ecs_task_definition" "this" {
5546  container_definitions = jsonencode([
5547    {
5548      environment = concat(
5549        [
5550          {
5551            name  = "GREENTIC_BUNDLE_SOURCE"
5552            value = var.bundle_source
5553          }
5554        ],
5555        [
5556          {
5557            name  = "PUBLIC_BASE_URL"
5558            value = local.effective_public_base_url
5559          }
5560        ]
5561      )
5562    }
5563  ])
5564}
5565
5566data "aws_region" "current" {}
5567"#,
5568        )
5569        .expect("write module main.tf");
5570
5571        let config = DeployerConfig {
5572            provider: Provider::Aws,
5573            ..config_for(terraform_root.clone(), DeployerCapability::Apply)
5574        };
5575
5576        prune_generated_terraform_root(&config, &terraform_root).expect("prune terraform root");
5577
5578        let rendered =
5579            std::fs::read_to_string(terraform_root.join("main.tf")).expect("read main.tf");
5580        assert!(rendered.contains(r#"tenant                = var.tenant"#));
5581        let variables =
5582            std::fs::read_to_string(terraform_root.join("variables.tf")).expect("read variables");
5583        assert!(variables.contains(r#"variable "bundle_s3_object_ref""#));
5584        assert!(variables.contains(r#"variable "bundle_s3_object_arn""#));
5585        assert!(variables.contains(r#"variable "runtime_secret_prefix""#));
5586        let module_variables =
5587            std::fs::read_to_string(terraform_root.join("modules/operator/variables.tf"))
5588                .expect("read module variables");
5589        assert!(module_variables.contains(r#"variable "bundle_s3_object_ref""#));
5590        assert!(module_variables.contains(r#"variable "bundle_s3_object_arn""#));
5591        assert!(module_variables.contains(r#"variable "runtime_secret_prefix""#));
5592        let module_main = std::fs::read_to_string(terraform_root.join("modules/operator/main.tf"))
5593            .expect("read module main");
5594        assert!(!module_main.contains("GREENTIC_SECRETS_BACKEND"));
5595        assert!(!module_main.contains("GREENTIC_ALLOW_ENV_SECRETS"));
5596        assert!(!module_main.contains("for name, secret_name in var.runtime_secret_env"));
5597        assert!(module_main.contains("task_runtime_secrets"));
5598        assert!(module_main.contains("task_bundle_s3_object"));
5599    }
5600
5601    #[test]
5602    fn persist_runtime_artifacts_materializes_aws_cleanup_helper_for_aws() {
5603        let base = std::env::current_dir()
5604            .expect("cwd")
5605            .join("target/tmp-tests");
5606        std::fs::create_dir_all(&base).expect("create tmp base");
5607        let dir = tempfile::tempdir_in(base).expect("temp dir");
5608        let pack_path = write_test_pack(true);
5609
5610        let mut greentic = greentic_config::ConfigResolver::new()
5611            .load()
5612            .expect("load default config")
5613            .config;
5614        greentic.paths.state_dir = dir.path().join(".greentic-state");
5615
5616        let config = DeployerConfig {
5617            capability: DeployerCapability::Plan,
5618            provider: Provider::Aws,
5619            strategy: "iac-only".into(),
5620            tenant: "acme".into(),
5621            environment: "staging".into(),
5622            pack_path: pack_path.clone(),
5623            bundle_root: None,
5624            providers_dir: PathBuf::from("providers/deployer"),
5625            packs_dir: PathBuf::from("packs"),
5626            provider_pack: Some(pack_path.clone()),
5627            pack_ref: None,
5628            distributor_url: None,
5629            distributor_token: None,
5630            preview: false,
5631            dry_run: false,
5632            execute_local: false,
5633            output: crate::config::OutputFormat::Json,
5634            greentic,
5635            provenance: greentic_config::ProvenanceMap::new(),
5636            config_warnings: Vec::new(),
5637            deploy_pack_id_override: None,
5638            deploy_flow_id_override: None,
5639            bundle_source: Some("file:///tmp/demo.gtbundle".into()),
5640            bundle_digest: Some(
5641                "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into(),
5642            ),
5643            repo_registry_base: None,
5644            store_registry_base: None,
5645        };
5646        let plan = pack_introspect::build_plan(&config).expect("build plan");
5647        let deploy_dir = dir.path().join("output");
5648        std::fs::create_dir_all(&deploy_dir).expect("create output dir");
5649        let selection = DeploymentPackSelection {
5650            dispatch: crate::deployment::DeploymentDispatch {
5651                capability: DeployerCapability::Plan,
5652                pack_id: "greentic.deploy.terraform".into(),
5653                flow_id: "plan_terraform".into(),
5654                handler_id: "builtin.terraform".into(),
5655            },
5656            pack_path,
5657            manifest: PackManifest {
5658                schema_version: "pack-v1".to_string(),
5659                pack_id: PackId::from_str("greentic.deploy.terraform").unwrap(),
5660                name: None,
5661                version: Version::new(0, 1, 0),
5662                kind: PackKind::Application,
5663                publisher: "greentic".to_string(),
5664                secret_requirements: Vec::new(),
5665                components: Vec::new(),
5666                flows: Vec::new(),
5667                dependencies: Vec::new(),
5668                capabilities: Vec::new(),
5669                signatures: Default::default(),
5670                bootstrap: None,
5671                extensions: None,
5672            },
5673            origin: "test".into(),
5674            candidates: Vec::new(),
5675        };
5676
5677        let artifacts = persist_runtime_artifacts(&config, &plan, &selection, &deploy_dir)
5678            .expect("persist runtime artifacts");
5679        assert!(
5680            artifacts
5681                .deploy_dir
5682                .join("terraform-aws-cleanup.sh")
5683                .exists()
5684        );
5685
5686        let metadata: TerraformRuntimeMetadata = serde_json::from_slice(
5687            &std::fs::read(artifacts.deploy_dir.join("terraform-runtime.json"))
5688                .expect("read terraform runtime metadata"),
5689        )
5690        .expect("parse terraform runtime metadata");
5691        assert!(
5692            metadata
5693                .scripts
5694                .iter()
5695                .any(|entry| entry == "terraform-aws-cleanup.sh")
5696        );
5697
5698        let cleanup =
5699            std::fs::read_to_string(artifacts.deploy_dir.join("terraform-aws-cleanup.sh"))
5700                .expect("read aws cleanup script");
5701        assert!(cleanup.contains("bundle_digest not found; skipping AWS cleanup fallback"));
5702        assert!(cleanup.contains("aws secretsmanager delete-secret"));
5703        assert!(cleanup.contains("aws iam delete-role"));
5704        assert!(cleanup.contains("aws ecs delete-service"));
5705        assert!(cleanup.contains("hash_string()"));
5706        assert!(cleanup.contains("md5 -q"));
5707        assert!(cleanup.contains("SHORT_ID=\"$(hash_string \"$BUNDLE_DIGEST\")\""));
5708        assert!(!cleanup.contains("SHORT_ID=$(printf '%s' \"$BUNDLE_DIGEST\" | md5sum"));
5709    }
5710
5711    #[test]
5712    fn persist_runtime_artifacts_falls_back_to_available_tfvars_example() {
5713        let base = std::env::current_dir()
5714            .expect("cwd")
5715            .join("target/tmp-tests");
5716        std::fs::create_dir_all(&base).expect("create tmp base");
5717        let dir = tempfile::tempdir_in(base).expect("temp dir");
5718        let pack_path = write_test_pack(true);
5719
5720        let mut greentic = greentic_config::ConfigResolver::new()
5721            .load()
5722            .expect("load default config")
5723            .config;
5724        greentic.paths.state_dir = dir.path().join(".greentic-state");
5725
5726        let config = DeployerConfig {
5727            capability: DeployerCapability::Plan,
5728            provider: Provider::Aws,
5729            strategy: "iac-only".into(),
5730            tenant: "acme".into(),
5731            environment: "dev".into(),
5732            pack_path: pack_path.clone(),
5733            bundle_root: None,
5734            providers_dir: PathBuf::from("providers/deployer"),
5735            packs_dir: PathBuf::from("packs"),
5736            provider_pack: Some(pack_path.clone()),
5737            pack_ref: None,
5738            distributor_url: None,
5739            distributor_token: None,
5740            preview: false,
5741            dry_run: false,
5742            execute_local: false,
5743            output: crate::config::OutputFormat::Json,
5744            greentic,
5745            provenance: greentic_config::ProvenanceMap::new(),
5746            config_warnings: Vec::new(),
5747            deploy_pack_id_override: None,
5748            deploy_flow_id_override: None,
5749            bundle_source: Some("file:///tmp/demo.gtbundle".into()),
5750            bundle_digest: Some(
5751                "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into(),
5752            ),
5753            repo_registry_base: None,
5754            store_registry_base: None,
5755        };
5756        let plan = pack_introspect::build_plan(&config).expect("build plan");
5757        let deploy_dir = dir.path().join("output");
5758        std::fs::create_dir_all(&deploy_dir).expect("create output dir");
5759        let selection = DeploymentPackSelection {
5760            dispatch: crate::deployment::DeploymentDispatch {
5761                capability: DeployerCapability::Plan,
5762                pack_id: "greentic.deploy.terraform".into(),
5763                flow_id: "plan_terraform".into(),
5764                handler_id: "builtin.terraform".into(),
5765            },
5766            pack_path,
5767            manifest: PackManifest {
5768                schema_version: "pack-v1".to_string(),
5769                pack_id: PackId::from_str("greentic.deploy.terraform").unwrap(),
5770                name: None,
5771                version: Version::new(0, 1, 0),
5772                kind: PackKind::Application,
5773                publisher: "greentic".to_string(),
5774                secret_requirements: Vec::new(),
5775                components: Vec::new(),
5776                flows: Vec::new(),
5777                dependencies: Vec::new(),
5778                capabilities: Vec::new(),
5779                signatures: Default::default(),
5780                bootstrap: None,
5781                extensions: None,
5782            },
5783            origin: "test".into(),
5784            candidates: Vec::new(),
5785        };
5786
5787        let artifacts = persist_runtime_artifacts(&config, &plan, &selection, &deploy_dir)
5788            .expect("persist runtime artifacts");
5789        let generated = std::fs::read_to_string(artifacts.deploy_dir.join("terraform/dev.tfvars"))
5790            .expect("read generated tfvars");
5791        assert!(generated.contains("cloud = \"aws\""));
5792        assert!(generated.contains("environment = \"dev\""));
5793        assert!(generated.contains("deployment_name_prefix = \"greentic-"));
5794        assert!(generated.contains("bundle_source = \"file:///tmp/demo.gtbundle\""));
5795        assert!(generated.contains(
5796            "bundle_digest = \"sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd\""
5797        ));
5798        assert!(generated.contains(&format!(
5799            "operator_image_digest = \"{}\"",
5800            crate::contract::DEFAULT_OPERATOR_IMAGE_DIGEST
5801        )));
5802        assert!(!generated.contains(
5803            "operator_image_digest = \"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\""
5804        ));
5805
5806        let destroy_script =
5807            std::fs::read_to_string(artifacts.deploy_dir.join("terraform-destroy.sh"))
5808                .expect("read destroy script");
5809        assert!(destroy_script.contains("VAR_FILE=\"dev.tfvars\""));
5810        assert!(destroy_script.contains("elif [ -f \"staging.tfvars.example\" ]; then"));
5811    }
5812
5813    #[test]
5814    fn generated_tfvars_ignores_legacy_bundle_digest_prefix_for_new_cloud_identity() {
5815        let base = std::env::current_dir()
5816            .expect("cwd")
5817            .join("target/tmp-tests");
5818        std::fs::create_dir_all(&base).expect("create tmp base");
5819        let dir = tempfile::tempdir_in(base).expect("temp dir");
5820        let terraform_root = dir.path().join("terraform");
5821        std::fs::create_dir_all(&terraform_root).expect("create terraform dir");
5822        std::fs::write(
5823            terraform_root.join("staging.tfvars.example"),
5824            "cloud = \"aws\"\nenvironment = \"staging\"\n",
5825        )
5826        .expect("write example");
5827        std::fs::write(
5828            terraform_root.join("dev.tfvars"),
5829            "bundle_digest = \"sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd\"\n",
5830        )
5831        .expect("write existing tfvars");
5832
5833        let mut greentic = greentic_config::ConfigResolver::new()
5834            .load()
5835            .expect("load default config")
5836            .config;
5837        greentic.paths.state_dir = dir.path().join(".greentic-state");
5838        let config = DeployerConfig {
5839            capability: DeployerCapability::Plan,
5840            provider: Provider::Aws,
5841            strategy: "iac-only".into(),
5842            tenant: "acme".into(),
5843            environment: "dev".into(),
5844            pack_path: dir.path().join("bundle"),
5845            bundle_root: None,
5846            providers_dir: PathBuf::from("providers/deployer"),
5847            packs_dir: PathBuf::from("packs"),
5848            provider_pack: None,
5849            pack_ref: None,
5850            distributor_url: None,
5851            distributor_token: None,
5852            preview: false,
5853            dry_run: false,
5854            execute_local: false,
5855            output: crate::config::OutputFormat::Json,
5856            greentic,
5857            provenance: greentic_config::ProvenanceMap::new(),
5858            config_warnings: Vec::new(),
5859            deploy_pack_id_override: None,
5860            deploy_flow_id_override: None,
5861            bundle_source: Some("file:///tmp/demo-v2.gtbundle".into()),
5862            bundle_digest: Some(
5863                "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
5864            ),
5865            repo_registry_base: None,
5866            store_registry_base: None,
5867        };
5868        let legacy_shared_prefix = legacy_shared_deployment_name_prefix(&config);
5869        std::fs::write(
5870            terraform_root.join("dev.tfvars"),
5871            format!(
5872                "deployment_name_prefix = \"{legacy_shared_prefix}\"\nbundle_digest = \"sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd\"\n"
5873            ),
5874        )
5875        .expect("write existing tfvars");
5876
5877        let generated =
5878            materialize_generated_tfvars(&config, &terraform_root, "staging.tfvars.example")
5879                .expect("generate tfvars")
5880                .expect("generated filename");
5881        let contents =
5882            std::fs::read_to_string(terraform_root.join(generated)).expect("read generated tfvars");
5883        assert!(contents.contains("deployment_name_prefix = \"greentic-"));
5884        assert!(!contents.contains(&format!(
5885            "deployment_name_prefix = \"{legacy_shared_prefix}\""
5886        )));
5887    }
5888
5889    #[test]
5890    fn generated_aws_tfvars_omit_runtime_secret_env_when_provider_binding_exists() {
5891        let base = std::env::current_dir()
5892            .expect("cwd")
5893            .join("target/tmp-tests");
5894        std::fs::create_dir_all(&base).expect("create tmp base");
5895        let dir = tempfile::tempdir_in(base).expect("temp dir");
5896        let bundle_root = dir.path();
5897        let terraform_root = bundle_root.join("terraform");
5898        std::fs::create_dir_all(&terraform_root).expect("terraform root");
5899        std::fs::write(
5900            terraform_root.join("staging.tfvars.example"),
5901            r#"
5902cloud = "aws"
5903tenant = "demo"
5904environment = "dev"
5905runtime_secret_env = {}
5906"#,
5907        )
5908        .expect("write tfvars example");
5909
5910        let pack_path = bundle_root.join("packs/messaging-webchat-gui");
5911        std::fs::create_dir_all(&pack_path).expect("pack dir");
5912        std::fs::write(
5913            pack_path.join("pack.manifest.json"),
5914            r#"{
5915  "extensions": {
5916    "greentic.generated-secrets.v1": {
5917      "inline": {
5918        "secrets": [{
5919          "key": "jwt_signing_key",
5920          "required": true,
5921          "policy": "random",
5922          "length": 20,
5923          "encoding": "raw_text",
5924          "scope": {"level": "tenant", "team": "_"}
5925        }]
5926      }
5927    }
5928  }
5929}"#,
5930        )
5931        .expect("write manifest");
5932
5933        let config = DeployerConfig {
5934            provider: Provider::Aws,
5935            tenant: "demo".into(),
5936            environment: "dev".into(),
5937            pack_path: pack_path.clone(),
5938            bundle_root: Some(bundle_root.to_path_buf()),
5939            provider_pack: None,
5940            bundle_source: Some("s3://bucket/bundle.gtbundle".into()),
5941            ..config_for(pack_path, DeployerCapability::Apply)
5942        };
5943
5944        let generated =
5945            materialize_generated_tfvars(&config, &terraform_root, "staging.tfvars.example")
5946                .expect("generate tfvars")
5947                .expect("tfvars generated");
5948        let contents =
5949            std::fs::read_to_string(terraform_root.join(generated)).expect("read generated tfvars");
5950
5951        assert!(contents.contains("runtime_secret_env = {}"));
5952        assert!(!contents.contains("GREENTIC_SECRET__"));
5953        assert!(!contents.contains("\"jwt_signing_key\""));
5954        assert!(
5955            !contents.contains("\"secrets://dev/demo/_/messaging-webchat-gui/jwt_signing_key\""),
5956            "runtime_secret_env must not include runtime secret aliases when provider binding is present: {contents}"
5957        );
5958    }
5959
5960    #[test]
5961    fn deployment_name_prefix_normalization_keeps_cloud_names_bounded() {
5962        assert_eq!(
5963            normalize_deployment_name_prefix(" Maarten/Deep Research Demo!!! "),
5964            "maarten-deep-research-de"
5965        );
5966        assert_eq!(
5967            normalize_deployment_name_prefix("123-dev"),
5968            "greentic-123-dev"
5969        );
5970    }
5971
5972    #[test]
5973    fn render_operation_result_text_includes_terraform_runtime_summary() {
5974        let base = std::env::current_dir()
5975            .expect("cwd")
5976            .join("target/tmp-tests");
5977        std::fs::create_dir_all(&base).expect("create tmp base");
5978        let dir = tempfile::tempdir_in(base).expect("temp dir");
5979        let output_dir = dir.path().join("deploy");
5980        std::fs::create_dir_all(&output_dir).expect("create output dir");
5981        std::fs::write(
5982            output_dir.join("terraform-runtime.json"),
5983            serde_json::to_vec_pretty(&TerraformRuntimeMetadata {
5984                terraform_root: output_dir.join("terraform").display().to_string(),
5985                copied_files: vec!["main.tf".into(), "modules/operator/main.tf".into()],
5986                scripts: vec!["terraform-status.sh".into()],
5987                generated_tfvars: None,
5988                secrets_provider_binding: None,
5989                init_command: "./terraform-init.sh".into(),
5990                plan_command: "./terraform-plan.sh".into(),
5991                apply_command: "./terraform-apply.sh".into(),
5992                destroy_command: "./terraform-destroy.sh".into(),
5993                status_command: "./terraform-status.sh".into(),
5994            })
5995            .expect("encode terraform runtime metadata"),
5996        )
5997        .expect("write runtime metadata");
5998
5999        let rendered = render_operation_result_text(&OperationResult {
6000            capability: "status".into(),
6001            executed: false,
6002            preview: false,
6003            output_dir: output_dir.display().to_string(),
6004            plan_path: "/tmp/plan.json".into(),
6005            invoke_path: "/tmp/invoke.json".into(),
6006            pack_id: "greentic.deploy.terraform".into(),
6007            flow_id: "status_terraform".into(),
6008            handler_id: "builtin.terraform".into(),
6009            pack_path: "/tmp/provider.gtpack".into(),
6010            contract: None,
6011            capability_contract: None,
6012            payload: Some(OperationPayload::Status(Box::new(StatusPayload {
6013                capability: "status".into(),
6014                provider: "generic".into(),
6015                strategy: "terraform".into(),
6016                pack_id: "greentic.deploy.terraform".into(),
6017                flow_id: "status_terraform".into(),
6018                rendered_output: None,
6019            }))),
6020            output_validation: None,
6021            execution: None,
6022        });
6023
6024        assert!(rendered.contains("terraform_runtime.present=true"));
6025        assert!(rendered.contains("handler_id=builtin.terraform"));
6026        assert!(
6027            rendered.contains("terraform_runtime.copied_files=main.tf, modules/operator/main.tf")
6028        );
6029        assert!(rendered.contains("terraform_runtime.status_command=./terraform-status.sh"));
6030    }
6031
6032    #[test]
6033    fn render_operation_result_text_summarizes_apply_success_with_webchat_url() {
6034        let rendered = render_operation_result_text(&OperationResult {
6035            capability: "apply".into(),
6036            executed: true,
6037            preview: false,
6038            output_dir: "/tmp/deploy".into(),
6039            plan_path: "/tmp/plan.json".into(),
6040            invoke_path: "/tmp/invoke.json".into(),
6041            pack_id: "greentic.deploy.aws".into(),
6042            flow_id: "apply_terraform".into(),
6043            handler_id: "pack.greentic.deploy.aws".into(),
6044            pack_path: "/tmp/aws.gtpack".into(),
6045            contract: None,
6046            capability_contract: None,
6047            payload: Some(OperationPayload::Apply(Box::new(ApplyPayload {
6048                capability: "apply".into(),
6049                provider: "aws".into(),
6050                strategy: "iac-only".into(),
6051                pack_id: "greentic.deploy.aws".into(),
6052                flow_id: "apply_terraform".into(),
6053                output_dir: "/tmp/deploy".into(),
6054                plan_path: "/tmp/plan.json".into(),
6055                invoke_path: "/tmp/invoke.json".into(),
6056                runner_cmd: vec![],
6057                runner_env: vec![("GREENTIC_TENANT".into(), "demo".into())],
6058            }))),
6059            output_validation: None,
6060            execution: Some(ExecutionReport {
6061                output_dir: "/tmp/deploy".into(),
6062                plan_path: "/tmp/plan.json".into(),
6063                invoke_path: "/tmp/invoke.json".into(),
6064                handoff_path: "/tmp/handoff.json".into(),
6065                runner_command_path: "/tmp/runner.txt".into(),
6066                handler_id: "pack.greentic.deploy.aws".into(),
6067                status: Some("applied".into()),
6068                message: None,
6069                output_files: vec![],
6070                outcome_payload: Some(ExecutionOutcomePayload::Apply(
6071                    crate::deployment::ApplyExecutionOutcome {
6072                        deployment_id: "/tmp/deploy".into(),
6073                        state: "applied".into(),
6074                        provider: Some("aws".into()),
6075                        strategy: Some("iac-only".into()),
6076                        endpoints: vec!["http://greentic.example.elb.amazonaws.com".into()],
6077                        output_refs: BTreeMap::new(),
6078                    },
6079                )),
6080                outcome_validation: None,
6081            }),
6082        });
6083
6084        assert_eq!(
6085            rendered,
6086            "http://greentic.example.elb.amazonaws.com/v1/web/webchat/demo/\n"
6087        );
6088        assert!(!rendered.contains("capability=apply"));
6089    }
6090
6091    #[test]
6092    fn render_operation_result_json_summarizes_apply_success_with_webchat_url() {
6093        let rendered = render_operation_result(
6094            &OperationResult {
6095                capability: "apply".into(),
6096                executed: true,
6097                preview: false,
6098                output_dir: "/tmp/deploy".into(),
6099                plan_path: "/tmp/plan.json".into(),
6100                invoke_path: "/tmp/invoke.json".into(),
6101                pack_id: "greentic.deploy.aws".into(),
6102                flow_id: "apply_terraform".into(),
6103                handler_id: "pack.greentic.deploy.aws".into(),
6104                pack_path: "/tmp/aws.gtpack".into(),
6105                contract: None,
6106                capability_contract: None,
6107                payload: Some(OperationPayload::Apply(Box::new(ApplyPayload {
6108                    capability: "apply".into(),
6109                    provider: "aws".into(),
6110                    strategy: "iac-only".into(),
6111                    pack_id: "greentic.deploy.aws".into(),
6112                    flow_id: "apply_terraform".into(),
6113                    output_dir: "/tmp/deploy".into(),
6114                    plan_path: "/tmp/plan.json".into(),
6115                    invoke_path: "/tmp/invoke.json".into(),
6116                    runner_cmd: vec![],
6117                    runner_env: vec![("GREENTIC_TENANT".into(), "demo".into())],
6118                }))),
6119                output_validation: None,
6120                execution: Some(ExecutionReport {
6121                    output_dir: "/tmp/deploy".into(),
6122                    plan_path: "/tmp/plan.json".into(),
6123                    invoke_path: "/tmp/invoke.json".into(),
6124                    handoff_path: "/tmp/handoff.json".into(),
6125                    runner_command_path: "/tmp/runner.txt".into(),
6126                    handler_id: "pack.greentic.deploy.aws".into(),
6127                    status: Some("applied".into()),
6128                    message: None,
6129                    output_files: vec![],
6130                    outcome_payload: Some(ExecutionOutcomePayload::Apply(
6131                        crate::deployment::ApplyExecutionOutcome {
6132                            deployment_id: "/tmp/deploy".into(),
6133                            state: "applied".into(),
6134                            provider: Some("aws".into()),
6135                            strategy: Some("iac-only".into()),
6136                            endpoints: vec!["http://greentic.example.elb.amazonaws.com".into()],
6137                            output_refs: BTreeMap::new(),
6138                        },
6139                    )),
6140                    outcome_validation: None,
6141                }),
6142            },
6143            OutputFormat::Json,
6144        )
6145        .expect("render json");
6146
6147        assert_eq!(
6148            rendered,
6149            "{\n  \"webchat_url\": \"http://greentic.example.elb.amazonaws.com/v1/web/webchat/demo/\"\n}"
6150        );
6151        assert!(!rendered.contains("contract"));
6152    }
6153
6154    #[test]
6155    fn persist_runtime_artifacts_materializes_k8s_raw_handoff_assets() {
6156        let base = std::env::current_dir()
6157            .expect("cwd")
6158            .join("target/tmp-tests");
6159        std::fs::create_dir_all(&base).expect("create tmp base");
6160        let dir = tempfile::tempdir_in(base).expect("temp dir");
6161        let pack_path = write_test_pack(true);
6162
6163        let mut greentic = greentic_config::ConfigResolver::new()
6164            .load()
6165            .expect("load default config")
6166            .config;
6167        greentic.paths.state_dir = dir.path().join(".greentic-state");
6168
6169        let config = DeployerConfig {
6170            capability: DeployerCapability::Plan,
6171            provider: Provider::K8s,
6172            strategy: "raw-manifests".into(),
6173            tenant: "acme".into(),
6174            environment: "staging".into(),
6175            pack_path: pack_path.clone(),
6176            bundle_root: None,
6177            providers_dir: PathBuf::from("providers/deployer"),
6178            packs_dir: PathBuf::from("packs"),
6179            provider_pack: Some(pack_path.clone()),
6180            pack_ref: None,
6181            distributor_url: None,
6182            distributor_token: None,
6183            preview: false,
6184            dry_run: false,
6185            execute_local: false,
6186            output: crate::config::OutputFormat::Json,
6187            greentic,
6188            provenance: greentic_config::ProvenanceMap::new(),
6189            config_warnings: Vec::new(),
6190            deploy_pack_id_override: None,
6191            deploy_flow_id_override: None,
6192            bundle_source: None,
6193            bundle_digest: None,
6194            repo_registry_base: None,
6195            store_registry_base: None,
6196        };
6197        let plan = pack_introspect::build_plan(&config).expect("build plan");
6198        let deploy_dir = dir.path().join("output");
6199        std::fs::create_dir_all(&deploy_dir).expect("create output dir");
6200        let selection = DeploymentPackSelection {
6201            dispatch: crate::deployment::DeploymentDispatch {
6202                capability: DeployerCapability::Plan,
6203                pack_id: "greentic.deploy.k8s".into(),
6204                flow_id: "plan_k8s_raw".into(),
6205                handler_id: "builtin.k8s_raw".into(),
6206            },
6207            pack_path,
6208            manifest: PackManifest {
6209                schema_version: "pack-v1".to_string(),
6210                pack_id: PackId::from_str("greentic.deploy.k8s").unwrap(),
6211                name: None,
6212                version: Version::new(0, 1, 0),
6213                kind: PackKind::Application,
6214                publisher: "greentic".to_string(),
6215                secret_requirements: Vec::new(),
6216                components: Vec::new(),
6217                flows: Vec::new(),
6218                dependencies: Vec::new(),
6219                capabilities: Vec::new(),
6220                signatures: Default::default(),
6221                bootstrap: None,
6222                extensions: None,
6223            },
6224            origin: "test".into(),
6225            candidates: Vec::new(),
6226        };
6227
6228        let artifacts = persist_runtime_artifacts(&config, &plan, &selection, &deploy_dir)
6229            .expect("persist runtime artifacts");
6230
6231        assert!(
6232            artifacts
6233                .deploy_dir
6234                .join("k8s/rendered-manifests.yaml")
6235                .exists()
6236        );
6237        assert!(artifacts.deploy_dir.join("kubectl-apply.sh").exists());
6238        assert!(artifacts.deploy_dir.join("kubectl-delete.sh").exists());
6239        assert!(artifacts.deploy_dir.join("kubectl-status.sh").exists());
6240        let note = std::fs::read_to_string(artifacts.deploy_dir.join("k8s-handoff.txt"))
6241            .expect("read k8s handoff note");
6242        assert!(note.contains("manifest_path="));
6243        assert!(note.contains("kubectl-apply.sh"));
6244    }
6245
6246    #[test]
6247    fn persist_runtime_artifacts_materializes_helm_handoff_assets() {
6248        let base = std::env::current_dir()
6249            .expect("cwd")
6250            .join("target/tmp-tests");
6251        std::fs::create_dir_all(&base).expect("create tmp base");
6252        let dir = tempfile::tempdir_in(base).expect("temp dir");
6253        let pack_path = write_test_pack(true);
6254
6255        let mut greentic = greentic_config::ConfigResolver::new()
6256            .load()
6257            .expect("load default config")
6258            .config;
6259        greentic.paths.state_dir = dir.path().join(".greentic-state");
6260
6261        let config = DeployerConfig {
6262            capability: DeployerCapability::Plan,
6263            provider: Provider::K8s,
6264            strategy: "helm".into(),
6265            tenant: "acme".into(),
6266            environment: "staging".into(),
6267            pack_path: pack_path.clone(),
6268            bundle_root: None,
6269            providers_dir: PathBuf::from("providers/deployer"),
6270            packs_dir: PathBuf::from("packs"),
6271            provider_pack: Some(pack_path.clone()),
6272            pack_ref: None,
6273            distributor_url: None,
6274            distributor_token: None,
6275            preview: false,
6276            dry_run: false,
6277            execute_local: false,
6278            output: crate::config::OutputFormat::Json,
6279            greentic,
6280            provenance: greentic_config::ProvenanceMap::new(),
6281            config_warnings: Vec::new(),
6282            deploy_pack_id_override: None,
6283            deploy_flow_id_override: None,
6284            bundle_source: None,
6285            bundle_digest: None,
6286            repo_registry_base: None,
6287            store_registry_base: None,
6288        };
6289        let plan = pack_introspect::build_plan(&config).expect("build plan");
6290        let deploy_dir = dir.path().join("output");
6291        std::fs::create_dir_all(&deploy_dir).expect("create output dir");
6292        let selection = DeploymentPackSelection {
6293            dispatch: crate::deployment::DeploymentDispatch {
6294                capability: DeployerCapability::Plan,
6295                pack_id: "greentic.deploy.helm".into(),
6296                flow_id: "plan_helm".into(),
6297                handler_id: "builtin.helm".into(),
6298            },
6299            pack_path,
6300            manifest: PackManifest {
6301                schema_version: "pack-v1".to_string(),
6302                pack_id: PackId::from_str("greentic.deploy.helm").unwrap(),
6303                name: None,
6304                version: Version::new(0, 1, 0),
6305                kind: PackKind::Application,
6306                publisher: "greentic".to_string(),
6307                secret_requirements: Vec::new(),
6308                components: Vec::new(),
6309                flows: Vec::new(),
6310                dependencies: Vec::new(),
6311                capabilities: Vec::new(),
6312                signatures: Default::default(),
6313                bootstrap: None,
6314                extensions: None,
6315            },
6316            origin: "test".into(),
6317            candidates: Vec::new(),
6318        };
6319
6320        let artifacts = persist_runtime_artifacts(&config, &plan, &selection, &deploy_dir)
6321            .expect("persist runtime artifacts");
6322
6323        assert!(artifacts.deploy_dir.join("helm-chart/Chart.yaml").exists());
6324        assert!(
6325            artifacts
6326                .deploy_dir
6327                .join("helm-chart/templates/deployment.yaml")
6328                .exists()
6329        );
6330        assert!(artifacts.deploy_dir.join("helm-upgrade.sh").exists());
6331        assert!(artifacts.deploy_dir.join("helm-rollback.sh").exists());
6332        assert!(artifacts.deploy_dir.join("helm-status.sh").exists());
6333        let note = std::fs::read_to_string(artifacts.deploy_dir.join("helm-handoff.txt"))
6334            .expect("read helm handoff note");
6335        assert!(note.contains("chart_root="));
6336        assert!(note.contains("release_name=greentic-acme"));
6337    }
6338
6339    #[tokio::test]
6340    async fn plan_result_without_contract_schema_skips_validation() {
6341        let _guard = EXECUTOR_TEST_LOCK.lock().await;
6342        clear_deployment_executor();
6343        let pack_path = write_test_pack(false);
6344        let result = run(config_for(pack_path, DeployerCapability::Plan))
6345            .await
6346            .expect("plan runs");
6347        assert!(result.output_validation.is_none());
6348    }
6349
6350    #[tokio::test]
6351    async fn generate_result_contains_capability_payload() {
6352        let _guard = EXECUTOR_TEST_LOCK.lock().await;
6353        clear_deployment_executor();
6354        let pack_path = write_test_pack(true);
6355        let result = run(config_for(pack_path, DeployerCapability::Generate))
6356            .await
6357            .expect("generate prepares");
6358        match result.payload.expect("payload") {
6359            OperationPayload::Generate(payload) => {
6360                assert_eq!(payload.capability, "generate");
6361                assert_eq!(payload.provider, "aws");
6362                assert_eq!(
6363                    payload.input_schema_path.as_deref(),
6364                    Some("assets/schemas/generate-input.schema.json")
6365                );
6366                assert_eq!(
6367                    payload.output_schema_path.as_deref(),
6368                    Some("assets/schemas/generate-output.schema.json")
6369                );
6370                assert_eq!(
6371                    payload.qa_spec_path.as_deref(),
6372                    Some("assets/qa/generate.qa.json")
6373                );
6374                assert_eq!(
6375                    payload.example_paths,
6376                    vec!["assets/examples/generate.example.json".to_string()]
6377                );
6378            }
6379            other => panic!("unexpected payload: {:?}", other),
6380        }
6381        assert_eq!(
6382            result
6383                .capability_contract
6384                .as_ref()
6385                .expect("capability contract")
6386                .flow_id,
6387            "generate_pack"
6388        );
6389        assert!(result.output_validation.as_ref().expect("validation").valid);
6390    }
6391
6392    #[tokio::test]
6393    async fn preview_destroy_result_uses_destroy_payload_kind() {
6394        let _guard = EXECUTOR_TEST_LOCK.lock().await;
6395        clear_deployment_executor();
6396        let pack_path = write_test_pack(true);
6397        let mut config = config_for(pack_path, DeployerCapability::Destroy);
6398        config.preview = true;
6399        let result = run(config).await.expect("destroy preview prepares");
6400        match result.payload.expect("payload") {
6401            OperationPayload::Destroy(payload) => {
6402                assert_eq!(payload.capability, "destroy");
6403                assert_eq!(payload.strategy, "iac-only");
6404                assert_eq!(payload.flow_id, "destroy_pack");
6405                assert!(payload.runner_cmd.iter().any(|arg| arg == "--flow"));
6406                assert!(
6407                    payload
6408                        .runner_env
6409                        .iter()
6410                        .any(|(key, value)| key == "GREENTIC_DEPLOYMENT_CAPABILITY"
6411                            && value == "destroy")
6412                );
6413            }
6414            other => panic!("unexpected payload: {:?}", other),
6415        }
6416    }
6417
6418    #[tokio::test]
6419    async fn preview_apply_result_contains_runner_handoff() {
6420        let _guard = EXECUTOR_TEST_LOCK.lock().await;
6421        clear_deployment_executor();
6422        let pack_path = write_test_pack(true);
6423        let mut config = config_for(pack_path, DeployerCapability::Apply);
6424        config.preview = true;
6425        let result = run(config).await.expect("apply preview prepares");
6426        match result.payload.expect("payload") {
6427            OperationPayload::Apply(payload) => {
6428                assert_eq!(payload.capability, "apply");
6429                assert_eq!(payload.pack_id, "greentic.deploy.aws");
6430                assert_eq!(payload.flow_id, "deploy_aws_iac");
6431                assert!(
6432                    payload
6433                        .runner_cmd
6434                        .iter()
6435                        .any(|arg| arg == "greentic-runner")
6436                );
6437                assert!(payload.plan_path.ends_with("plan.json"));
6438                assert!(payload.invoke_path.ends_with("invoke.json"));
6439            }
6440            other => panic!("unexpected payload: {:?}", other),
6441        }
6442    }
6443
6444    #[tokio::test]
6445    async fn status_result_contains_dispatch_metadata() {
6446        let _guard = EXECUTOR_TEST_LOCK.lock().await;
6447        clear_deployment_executor();
6448        let pack_path = write_test_pack(true);
6449        let result = run(config_for(pack_path, DeployerCapability::Status))
6450            .await
6451            .expect("status prepares");
6452        match result.payload.expect("payload") {
6453            OperationPayload::Status(payload) => {
6454                assert_eq!(payload.capability, "status");
6455                assert_eq!(payload.pack_id, "greentic.deploy.aws");
6456                assert_eq!(payload.flow_id, "status_pack");
6457            }
6458            other => panic!("unexpected payload: {:?}", other),
6459        }
6460        assert!(result.output_validation.as_ref().expect("validation").valid);
6461    }
6462
6463    #[tokio::test]
6464    async fn rollback_result_contains_target_capability() {
6465        let _guard = EXECUTOR_TEST_LOCK.lock().await;
6466        clear_deployment_executor();
6467        let pack_path = write_test_pack(true);
6468        let result = run(config_for(pack_path, DeployerCapability::Rollback))
6469            .await
6470            .expect("rollback prepares");
6471        match result.payload.expect("payload") {
6472            OperationPayload::Rollback(payload) => {
6473                assert_eq!(payload.capability, "rollback");
6474                assert_eq!(payload.target_capability, "apply");
6475                assert_eq!(payload.flow_id, "rollback_pack");
6476            }
6477            other => panic!("unexpected payload: {:?}", other),
6478        }
6479        assert!(result.output_validation.as_ref().expect("validation").valid);
6480    }
6481
6482    // ------------------------------------------------------------------
6483    // PR-08 — operator secrets map tfvar emission
6484    // ------------------------------------------------------------------
6485
6486    #[test]
6487    fn render_terraform_map_emits_quoted_key_value_pairs() {
6488        let mut map = std::collections::BTreeMap::new();
6489        map.insert(
6490            "secrets://dev/demo/_/messaging-webchat/jwt_signing_key".to_string(),
6491            "secret-value".to_string(),
6492        );
6493        map.insert(
6494            "secrets://dev/demo/_/deep-research-demo/api_key_secret".to_string(),
6495            "another".to_string(),
6496        );
6497
6498        let rendered = render_terraform_map(&map);
6499
6500        assert!(rendered.starts_with("{\n"));
6501        assert!(rendered.ends_with('}'));
6502        // BTreeMap iterates sorted, so the api_key entry lands first.
6503        assert!(
6504            rendered.contains(
6505                "\"secrets://dev/demo/_/deep-research-demo/api_key_secret\" = \"another\""
6506            ),
6507            "rendered map should contain canonical URI as key: {rendered}"
6508        );
6509        assert!(rendered.contains(
6510            "\"secrets://dev/demo/_/messaging-webchat/jwt_signing_key\" = \"secret-value\""
6511        ));
6512    }
6513
6514    #[test]
6515    fn render_terraform_map_escapes_special_characters() {
6516        let mut map = std::collections::BTreeMap::new();
6517        map.insert(
6518            "k".to_string(),
6519            "value with \"quotes\" and \\backslash".to_string(),
6520        );
6521        let rendered = render_terraform_map(&map);
6522        // JSON-style escapes survive into the HCL literal.
6523        assert!(
6524            rendered.contains(r#"value with \"quotes\" and \\backslash"#),
6525            "escapes preserved: {rendered}"
6526        );
6527    }
6528
6529    #[test]
6530    fn replace_tfvars_assignment_literal_inserts_new_map_assignment() {
6531        let mut contents = String::from("cloud = \"aws\"\ntenant = \"demo\"\n");
6532        let map_literal = "{\n  \"k\" = \"v\"\n}";
6533        replace_tfvars_assignment_literal(&mut contents, "secrets_map", map_literal);
6534        assert!(contents.contains("secrets_map = {\n  \"k\" = \"v\"\n}"));
6535        assert!(contents.contains("cloud = \"aws\""));
6536        assert!(contents.contains("tenant = \"demo\""));
6537    }
6538
6539    #[test]
6540    fn replace_tfvars_assignment_literal_replaces_existing_multiline_map() {
6541        // A previous deploy emitted a 3-line map; the next emit must replace
6542        // both the assignment and the continuation lines so the file does
6543        // not accumulate duplicate entries.
6544        let mut contents = String::from(
6545            "cloud = \"aws\"\nsecrets_map = {\n  \"old\" = \"old\"\n}\ntenant = \"demo\"\n",
6546        );
6547        let new_literal = "{\n  \"new\" = \"value\"\n}";
6548        replace_tfvars_assignment_literal(&mut contents, "secrets_map", new_literal);
6549        assert!(contents.contains("secrets_map = {\n  \"new\" = \"value\"\n}"));
6550        assert!(!contents.contains("\"old\""));
6551        assert_eq!(contents.matches("secrets_map = ").count(), 1);
6552        assert!(contents.contains("tenant = \"demo\""));
6553    }
6554}