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, plan, 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 bytes =
1993        serde_json::to_vec_pretty(&binding).map_err(|err| DeployerError::Other(err.to_string()))?;
1994    // Write to the deploy output (the deployer's emitted artifacts) AND, when a
1995    // bundle root is known, into it too. The runtime `.gtbundle` the worker
1996    // loads is (re)built from the bundle root, so writing only to the deploy
1997    // dir meant a cloud-deployed worker never saw the secrets-provider binding.
1998    write_secrets_provider_binding(deploy_dir, &bytes)?;
1999    if let Some(bundle_root) = config.bundle_root.as_deref() {
2000        write_secrets_provider_binding(bundle_root, &bytes)?;
2001    }
2002    Ok(())
2003}
2004
2005/// Write the secrets-provider binding under `root` at the canonical relative
2006/// path, creating the parent directory. Used for both the deploy output and the
2007/// bundle root.
2008fn write_secrets_provider_binding(root: &Path, bytes: &[u8]) -> Result<()> {
2009    let path = root.join(SECRETS_PROVIDER_BINDING_RELATIVE_PATH);
2010    if let Some(parent) = path.parent() {
2011        fs::create_dir_all(parent)?;
2012    }
2013    fs::write(path, bytes)?;
2014    Ok(())
2015}
2016
2017fn secrets_provider_binding_for_target(config: &DeployerConfig) -> Option<SecretsProviderBinding> {
2018    let (provider_id, pack) = match config.provider {
2019        crate::config::Provider::Aws => {
2020            ("greentic.secrets.aws-sm", "providers/secrets/aws-sm.gtpack")
2021        }
2022        crate::config::Provider::Gcp => {
2023            ("greentic.secrets.gcp-sm", "providers/secrets/gcp-sm.gtpack")
2024        }
2025        crate::config::Provider::Azure => (
2026            "greentic.secrets.azure-kv",
2027            "providers/secrets/azure-kv.gtpack",
2028        ),
2029        crate::config::Provider::Local => ("greentic.secrets.dev", "providers/secrets/dev.gtpack"),
2030        _ => return None,
2031    };
2032    let namespace_prefix = crate::runtime_secrets::default_cloud_secret_prefix(
2033        &config.environment,
2034        &config.tenant,
2035        None,
2036    );
2037    let mut binding_config = BTreeMap::new();
2038    binding_config.insert("environment".to_string(), config.environment.clone());
2039    binding_config.insert("tenant".to_string(), config.tenant.clone());
2040    binding_config.insert("team".to_string(), "_".to_string());
2041    binding_config.insert("namespace_prefix".to_string(), namespace_prefix.clone());
2042    binding_config.insert("prefix".to_string(), namespace_prefix);
2043
2044    Some(SecretsProviderBinding {
2045        schema_version: SECRETS_PROVIDER_BINDING_SCHEMA_VERSION.to_string(),
2046        provider_id: provider_id.to_string(),
2047        pack: pack.to_string(),
2048        config: binding_config,
2049    })
2050}
2051
2052fn materialize_adapter_handoff_assets(
2053    config: &DeployerConfig,
2054    plan: &PlanContext,
2055    selection: &DeploymentPackSelection,
2056    deploy_dir: &Path,
2057) -> Result<()> {
2058    if uses_terraform_handoff(config) {
2059        materialize_terraform_handoff_assets(config, plan, selection, deploy_dir)?;
2060    } else if config.provider == crate::config::Provider::K8s && config.strategy == "raw-manifests"
2061    {
2062        materialize_k8s_raw_handoff_assets(config, selection, deploy_dir)?;
2063    } else if config.provider == crate::config::Provider::K8s && config.strategy == "operator" {
2064        materialize_operator_handoff_assets(config, selection, deploy_dir)?;
2065    } else if config.provider == crate::config::Provider::K8s && config.strategy == "helm" {
2066        materialize_helm_handoff_assets(config, selection, deploy_dir)?;
2067    } else if config.provider == crate::config::Provider::Generic
2068        && config.strategy == "serverless-container"
2069    {
2070        materialize_serverless_handoff_assets(config, selection, deploy_dir)?;
2071    } else if uses_snap_handoff(config) {
2072        materialize_snap_handoff_assets(config, selection, deploy_dir)?;
2073    } else if uses_juju_machine_handoff(config) {
2074        materialize_juju_machine_handoff_assets(config, selection, deploy_dir)?;
2075    } else if uses_juju_k8s_handoff(config) {
2076        materialize_juju_k8s_handoff_assets(config, selection, deploy_dir)?;
2077    }
2078    Ok(())
2079}
2080
2081fn materialize_terraform_handoff_assets(
2082    config: &DeployerConfig,
2083    plan: &PlanContext,
2084    selection: &DeploymentPackSelection,
2085    deploy_dir: &Path,
2086) -> Result<()> {
2087    let terraform_root = deploy_dir.join("terraform");
2088    let copied = copy_pack_subtree(&selection.pack_path, "terraform", &terraform_root)?;
2089    if copied.is_empty() {
2090        return Ok(());
2091    }
2092    let local_terraform = terraform_root.join("terraform");
2093    if local_terraform.exists() {
2094        set_executable_if_unix(&local_terraform)?;
2095    }
2096    // The operator-module rewriting (`prune_generated_terraform_root` /
2097    // `normalize_terraform_main_tf`) wires a Greentic app bundle into the
2098    // standard `operator` module. A self-contained service pack ships its own
2099    // complete terraform (its own `main.tf` plus bespoke modules) and has no
2100    // `operator` module, so rewriting would both clobber the pack's terraform
2101    // and fail writing into a non-existent module directory. Serviceless plans
2102    // (no app bundle) treat the pack's terraform as authoritative and skip the
2103    // operator rewrite; only the generic backend rewrite applies.
2104    if !plan.serviceless {
2105        prune_generated_terraform_root(config, &terraform_root)?;
2106        configure_terraform_backend(config, &terraform_root, deploy_dir)?;
2107        normalize_terraform_main_tf(config, &terraform_root)?;
2108    } else {
2109        configure_terraform_backend(config, &terraform_root, deploy_dir)?;
2110    }
2111
2112    let tfvars_example = resolve_tfvars_example_name(&terraform_root, &config.environment)?;
2113    let generated_tfvars = materialize_generated_tfvars(config, &terraform_root, &tfvars_example)?;
2114    let script_tfvars = generated_tfvars.clone().or_else(|| {
2115        let env_tfvars = format!("{}.tfvars", config.environment);
2116        terraform_root
2117            .join(&env_tfvars)
2118            .exists()
2119            .then_some(env_tfvars)
2120    });
2121    let init_script = "terraform-init.sh";
2122    let plan_script = "terraform-plan.sh";
2123    let apply_script = "terraform-apply.sh";
2124    let destroy_script = "terraform-destroy.sh";
2125    let status_script = "terraform-status.sh";
2126    let aws_cleanup_script = "terraform-aws-cleanup.sh";
2127    write_executable_script(&deploy_dir.join(init_script), terraform_init_script())?;
2128    write_executable_script(
2129        &deploy_dir.join(plan_script),
2130        terraform_plan_like_script(
2131            "plan",
2132            config.provider,
2133            script_tfvars.as_deref(),
2134            &tfvars_example,
2135        ),
2136    )?;
2137    write_executable_script(
2138        &deploy_dir.join(apply_script),
2139        terraform_plan_like_script(
2140            "apply",
2141            config.provider,
2142            script_tfvars.as_deref(),
2143            &tfvars_example,
2144        ),
2145    )?;
2146    write_executable_script(
2147        &deploy_dir.join(destroy_script),
2148        terraform_plan_like_script(
2149            "destroy",
2150            config.provider,
2151            script_tfvars.as_deref(),
2152            &tfvars_example,
2153        ),
2154    )?;
2155    write_executable_script(
2156        &deploy_dir.join(status_script),
2157        terraform_script_prelude("\"$TERRAFORM_BIN\" show -json \"$@\""),
2158    )?;
2159    let mut scripts = vec![
2160        init_script.to_string(),
2161        plan_script.to_string(),
2162        apply_script.to_string(),
2163        destroy_script.to_string(),
2164        status_script.to_string(),
2165    ];
2166    if config.provider == crate::config::Provider::Aws {
2167        write_executable_script(
2168            &deploy_dir.join(aws_cleanup_script),
2169            terraform_aws_cleanup_script(script_tfvars.as_deref(), &tfvars_example),
2170        )?;
2171        scripts.push(aws_cleanup_script.to_string());
2172    }
2173
2174    let metadata = TerraformRuntimeMetadata {
2175        terraform_root: terraform_root.display().to_string(),
2176        copied_files: copied.clone(),
2177        scripts,
2178        generated_tfvars: generated_tfvars.clone(),
2179        secrets_provider_binding: secrets_provider_binding_for_target(config)
2180            .map(|_| SECRETS_PROVIDER_BINDING_RELATIVE_PATH.to_string()),
2181        init_command: format!("./{init_script}"),
2182        plan_command: format!("./{plan_script}"),
2183        apply_command: format!("./{apply_script}"),
2184        destroy_command: format!("./{destroy_script}"),
2185        status_command: format!("./{status_script}"),
2186    };
2187    fs::write(
2188        deploy_dir.join("terraform-runtime.json"),
2189        serde_json::to_vec_pretty(&metadata)
2190            .map_err(|err| DeployerError::Other(err.to_string()))?,
2191    )?;
2192
2193    let mut note = String::new();
2194    note.push_str("Terraform handoff assets were materialized from the deployment pack.\n");
2195    note.push_str(&format!("terraform_root={}\n", terraform_root.display()));
2196    note.push_str(&format!(
2197        "suggested_tfvars_example={}\n",
2198        terraform_root.join(&tfvars_example).display()
2199    ));
2200    if let Some(tfvars) = generated_tfvars.as_ref() {
2201        note.push_str(&format!(
2202            "generated_tfvars={}\n",
2203            terraform_root.join(tfvars).display()
2204        ));
2205    }
2206    if secrets_provider_binding_for_target(config).is_some() {
2207        note.push_str(&format!(
2208            "secrets_provider_binding={}\n",
2209            deploy_dir
2210                .join(SECRETS_PROVIDER_BINDING_RELATIVE_PATH)
2211                .display()
2212        ));
2213    }
2214    note.push_str("terraform_env_override_prefix=GREENTIC_DEPLOY_TERRAFORM_VAR_\n");
2215    note.push_str(
2216        "scripts=terraform-init.sh, terraform-plan.sh, terraform-apply.sh, terraform-destroy.sh, terraform-status.sh\n",
2217    );
2218    if config.provider == crate::config::Provider::Aws {
2219        note.push_str("aws_cleanup_command=./terraform-aws-cleanup.sh\n");
2220    }
2221    note.push_str(&format!("status_command={}\n", metadata.status_command));
2222    note.push_str("copied_files:\n");
2223    for path in copied {
2224        note.push_str(&format!("- {path}\n"));
2225    }
2226    fs::write(deploy_dir.join("terraform-handoff.txt"), note)?;
2227    Ok(())
2228}
2229
2230fn prune_generated_terraform_root(config: &DeployerConfig, terraform_root: &Path) -> Result<()> {
2231    let (module_name, module_source, module_inputs) = match config.provider {
2232        crate::config::Provider::Aws => (
2233            "operator",
2234            "./modules/operator",
2235            r#"  cloud                 = var.cloud
2236  tenant                = var.tenant
2237  deployment_name_prefix = var.deployment_name_prefix
2238  operator_image        = "ghcr.io/greenticai/greentic-start-distroless@${var.operator_image_digest}"
2239  bundle_source         = var.bundle_source
2240  bundle_s3_object_ref  = var.bundle_s3_object_ref
2241  bundle_s3_object_arn  = var.bundle_s3_object_arn
2242  bundle_digest         = var.bundle_digest
2243  repo_registry_base    = var.repo_registry_base
2244  store_registry_base   = var.store_registry_base
2245  admin_allowed_clients = var.admin_allowed_clients
2246  public_base_url       = var.public_base_url
2247  runtime_secret_prefix = var.runtime_secret_prefix
2248  runtime_secret_env    = var.runtime_secret_env
2249  secrets_map           = var.secrets_map"#,
2250        ),
2251        crate::config::Provider::Azure => (
2252            "operator",
2253            "./modules/operator-azure",
2254            r#"  cloud                 = var.cloud
2255  tenant                = var.tenant
2256  environment           = var.environment
2257  deployment_name_prefix = var.deployment_name_prefix
2258  bundle_digest         = var.bundle_digest
2259  bundle_source         = var.bundle_source
2260  repo_registry_base    = var.repo_registry_base
2261  store_registry_base   = var.store_registry_base
2262  operator_image        = "ghcr.io/greenticai/greentic-start-distroless@${var.operator_image_digest}"
2263  admin_allowed_clients = var.admin_allowed_clients
2264  public_base_url       = var.public_base_url
2265  azure_key_vault_uri   = var.azure_key_vault_uri
2266  azure_key_vault_id    = var.azure_key_vault_id
2267  azure_location        = var.azure_location
2268  runtime_secret_prefix = var.runtime_secret_prefix
2269  runtime_secret_env    = var.runtime_secret_env
2270  secrets_map           = var.secrets_map"#,
2271        ),
2272        crate::config::Provider::Gcp => (
2273            "operator",
2274            "./modules/operator-gcp",
2275            r#"  cloud                 = var.cloud
2276  tenant                = var.tenant
2277  environment           = var.environment
2278  deployment_name_prefix = var.deployment_name_prefix
2279  bundle_digest         = var.bundle_digest
2280  bundle_source         = var.bundle_source
2281  repo_registry_base    = var.repo_registry_base
2282  store_registry_base   = var.store_registry_base
2283  operator_image        = "ghcr.io/greenticai/greentic-start-distroless@${var.operator_image_digest}"
2284  admin_allowed_clients = var.admin_allowed_clients
2285  public_base_url       = var.public_base_url
2286  gcp_project_id        = var.gcp_project_id
2287  gcp_region            = var.gcp_region
2288  runtime_secret_prefix = var.runtime_secret_prefix
2289  runtime_secret_env    = var.runtime_secret_env
2290  secrets_map           = var.secrets_map"#,
2291        ),
2292        _ => return Ok(()),
2293    };
2294
2295    let main_tf = format!(
2296        "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"
2297    );
2298    fs::write(terraform_root.join("main.tf"), main_tf)?;
2299
2300    let relay_outputs = match config.provider {
2301        crate::config::Provider::Azure | crate::config::Provider::Gcp => format!(
2302            r#"
2303output "admin_access_mode" {{
2304  value = module.{module_name}.admin_access_mode
2305}}
2306
2307output "admin_public_endpoint" {{
2308  value = module.{module_name}.admin_public_endpoint
2309}}
2310
2311output "admin_relay_token_secret_ref" {{
2312  value = module.{module_name}.admin_relay_token_secret_ref
2313}}
2314"#
2315        ),
2316        _ => String::new(),
2317    };
2318
2319    let outputs_tf = format!(
2320        r#"output "operator_endpoint" {{
2321  value = module.{module_name}.operator_endpoint
2322}}
2323
2324output "cloud_provider" {{
2325  value = var.cloud
2326}}
2327
2328output "admin_ca_secret_ref" {{
2329  value = module.{module_name}.admin_ca_secret_ref
2330}}
2331
2332output "admin_server_cert_secret_ref" {{
2333  value = module.{module_name}.admin_server_cert_secret_ref
2334}}
2335
2336output "admin_server_key_secret_ref" {{
2337  value = module.{module_name}.admin_server_key_secret_ref
2338}}
2339
2340output "admin_client_cert_secret_ref" {{
2341  value = module.{module_name}.admin_client_cert_secret_ref
2342}}
2343
2344output "admin_client_key_secret_ref" {{
2345  value = module.{module_name}.admin_client_key_secret_ref
2346}}
2347{relay_outputs}"#
2348    );
2349    fs::write(terraform_root.join("outputs.tf"), outputs_tf)?;
2350    ensure_terraform_variable_declared(
2351        &terraform_root.join("variables.tf"),
2352        "deployment_name_prefix",
2353        "string",
2354        Some(""),
2355    )?;
2356    ensure_terraform_variable_declared(
2357        &terraform_root.join("variables.tf"),
2358        "bundle_s3_object_ref",
2359        "string",
2360        Some(""),
2361    )?;
2362    ensure_terraform_variable_declared(
2363        &terraform_root.join("variables.tf"),
2364        "bundle_s3_object_arn",
2365        "string",
2366        Some(""),
2367    )?;
2368    ensure_terraform_variable_declared(
2369        &terraform_root.join("variables.tf"),
2370        "runtime_secret_prefix",
2371        "string",
2372        Some(""),
2373    )?;
2374    ensure_terraform_variable_declared(
2375        &terraform_root.join("variables.tf"),
2376        "runtime_secret_env",
2377        "map(string)",
2378        Some("{}"),
2379    )?;
2380    ensure_terraform_variable_declared(
2381        &terraform_root.join("variables.tf"),
2382        "secrets_map",
2383        "map(string)",
2384        Some("{}"),
2385    )?;
2386    let module_variables = match config.provider {
2387        crate::config::Provider::Aws => Some(terraform_root.join("modules/operator/variables.tf")),
2388        crate::config::Provider::Azure => {
2389            Some(terraform_root.join("modules/operator-azure/variables.tf"))
2390        }
2391        crate::config::Provider::Gcp => {
2392            Some(terraform_root.join("modules/operator-gcp/variables.tf"))
2393        }
2394        _ => None,
2395    };
2396    if let Some(module_variables) = module_variables {
2397        ensure_terraform_variable_declared(
2398            &module_variables,
2399            "deployment_name_prefix",
2400            "string",
2401            Some(""),
2402        )?;
2403        ensure_terraform_variable_declared(
2404            &module_variables,
2405            "bundle_s3_object_ref",
2406            "string",
2407            Some(""),
2408        )?;
2409        ensure_terraform_variable_declared(
2410            &module_variables,
2411            "bundle_s3_object_arn",
2412            "string",
2413            Some(""),
2414        )?;
2415        ensure_terraform_variable_declared(
2416            &module_variables,
2417            "runtime_secret_prefix",
2418            "string",
2419            Some(""),
2420        )?;
2421        ensure_terraform_variable_declared(
2422            &module_variables,
2423            "runtime_secret_env",
2424            "map(string)",
2425            Some("{}"),
2426        )?;
2427        ensure_terraform_variable_declared(
2428            &module_variables,
2429            "secrets_map",
2430            "map(string)",
2431            Some("{}"),
2432        )?;
2433    }
2434    if config.provider == crate::config::Provider::Aws {
2435        ensure_aws_runtime_secret_wiring(&terraform_root.join("modules/operator/main.tf"))?;
2436    }
2437
2438    Ok(())
2439}
2440
2441fn ensure_aws_runtime_secret_wiring(path: &Path) -> Result<()> {
2442    if !path.exists() {
2443        return Ok(());
2444    }
2445    let mut contents = fs::read_to_string(path)?;
2446    if contents.contains("runtime_secret_env")
2447        && contents.contains("task_runtime_secrets")
2448        && contents.contains("task_bundle_s3_object")
2449    {
2450        return Ok(());
2451    }
2452    if !contents.contains(r#"data "aws_caller_identity" "current""#) {
2453        contents = format!("data \"aws_caller_identity\" \"current\" {{}}\n\n{contents}");
2454    }
2455    if !contents.contains("task_runtime_secrets") {
2456        let marker = r#"resource "aws_ecs_task_definition" "this" {"#;
2457        let policy = r#"resource "aws_iam_role_policy" "task_runtime_secrets" {
2458  count = trimspace(var.runtime_secret_prefix) != "" ? 1 : 0
2459  name  = "${local.name_prefix}-task-runtime-secrets"
2460  role  = aws_iam_role.task_execution.id
2461
2462  policy = jsonencode({
2463    Version = "2012-10-17"
2464    Statement = [
2465      {
2466        Effect = "Allow"
2467        Action = [
2468          "secretsmanager:GetSecretValue"
2469        ]
2470        Resource = [
2471          "arn:aws:secretsmanager:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:secret:${trim(var.runtime_secret_prefix, "/")}/*"
2472        ]
2473      }
2474    ]
2475  })
2476}
2477
2478"#;
2479        contents = contents.replacen(marker, &format!("{policy}{marker}"), 1);
2480    }
2481    if !contents.contains("task_bundle_s3_object") {
2482        let marker = r#"resource "aws_ecs_task_definition" "this" {"#;
2483        let policy = r#"resource "aws_iam_role_policy" "task_bundle_s3_object" {
2484  count = trimspace(var.bundle_s3_object_arn) != "" ? 1 : 0
2485  name  = "${local.name_prefix}-task-bundle-s3-object"
2486  role  = aws_iam_role.task_execution.id
2487
2488  policy = jsonencode({
2489    Version = "2012-10-17"
2490    Statement = [
2491      {
2492        Effect = "Allow"
2493        Action = [
2494          "s3:GetObject"
2495        ]
2496        Resource = var.bundle_s3_object_arn
2497      }
2498    ]
2499  })
2500}
2501
2502"#;
2503        contents = contents.replacen(marker, &format!("{policy}{marker}"), 1);
2504    }
2505    if !contents.contains("bundle_fetcher_enabled") {
2506        if let Some(line) = contents
2507            .lines()
2508            .find(|line| line.trim_start().starts_with("admin_secret_prefix ="))
2509            .map(str::to_string)
2510        {
2511            let replacement = format!(
2512                "{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"
2513            );
2514            contents = contents.replacen(&line, &replacement, 1);
2515        }
2516        contents = contents.replace(
2517            r#"  task_role_arn            = aws_iam_role.task_execution.arn
2518
2519  container_definitions = jsonencode(["#,
2520            r#"  task_role_arn            = aws_iam_role.task_execution.arn
2521
2522  volume {
2523    name = "greentic-bundle"
2524  }
2525
2526  container_definitions = jsonencode(concat(
2527    local.bundle_fetcher_enabled ? [
2528      {
2529        name      = "bundle-fetcher"
2530        image     = "public.ecr.aws/aws-cli/aws-cli:latest"
2531        essential = false
2532        command = [
2533          "s3",
2534          "cp",
2535          var.bundle_s3_object_ref,
2536          local.operator_bundle_source
2537        ]
2538        mountPoints = [
2539          {
2540            sourceVolume  = "greentic-bundle"
2541            containerPath = "/greentic-bundle"
2542            readOnly      = false
2543          }
2544        ]
2545        logConfiguration = {
2546          logDriver = "awslogs"
2547          options = {
2548            awslogs-group         = aws_cloudwatch_log_group.this.name
2549            awslogs-region        = data.aws_region.current.name
2550            awslogs-stream-prefix = "bundle-fetcher"
2551          }
2552        }
2553      }
2554    ] : [],
2555    ["#,
2556        );
2557        contents = contents.replace("var.bundle_source,", "local.operator_bundle_source,");
2558        contents = contents.replace(
2559            r#"            name  = "GREENTIC_BUNDLE_SOURCE"
2560            value = var.bundle_source"#,
2561            r#"            name  = "GREENTIC_BUNDLE_SOURCE"
2562            value = local.operator_bundle_source"#,
2563        );
2564        contents = contents.replace(
2565            r#"      portMappings = ["#,
2566            r#"      dependsOn = local.bundle_fetcher_enabled ? [
2567        {
2568          containerName = "bundle-fetcher"
2569          condition     = "SUCCESS"
2570        }
2571      ] : []
2572      mountPoints = local.bundle_fetcher_enabled ? [
2573        {
2574          sourceVolume  = "greentic-bundle"
2575          containerPath = "/greentic-bundle"
2576          readOnly      = true
2577        }
2578      ] : []
2579      portMappings = ["#,
2580        );
2581        contents = contents.replace(
2582            r#"    }
2583  ])
2584
2585  tags = local.common_tags"#,
2586            r#"    }
2587    ]
2588  ))
2589
2590  tags = local.common_tags"#,
2591        );
2592    }
2593    fs::write(path, contents)?;
2594    Ok(())
2595}
2596
2597fn ensure_terraform_variable_declared(
2598    path: &Path,
2599    name: &str,
2600    ty: &str,
2601    default: Option<&str>,
2602) -> Result<()> {
2603    let declaration = match default {
2604        Some(value) => {
2605            let rendered_default = if ty.starts_with("map(") && value == "{}" {
2606                "{}".to_string()
2607            } else {
2608                serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
2609            };
2610            format!(
2611                "variable \"{name}\" {{\n  type    = {ty}\n  default = {rendered_default}\n}}\n"
2612            )
2613        }
2614        None => format!("variable \"{name}\" {{\n  type = {ty}\n}}\n"),
2615    };
2616
2617    let mut contents = if path.exists() {
2618        fs::read_to_string(path)?
2619    } else {
2620        String::new()
2621    };
2622    if contents.contains(&format!("variable \"{name}\"")) {
2623        return Ok(());
2624    }
2625    if !contents.is_empty() && !contents.ends_with('\n') {
2626        contents.push('\n');
2627    }
2628    contents.push_str(&declaration);
2629    fs::write(path, contents)?;
2630    Ok(())
2631}
2632
2633fn aws_bundle_s3_object_arn(bundle_source: &str) -> Option<String> {
2634    let rest = bundle_source.trim().strip_prefix("s3://")?;
2635    let (bucket, key) = rest.split_once('/')?;
2636    let key = key.trim_start_matches('/');
2637    if bucket.is_empty() || key.is_empty() {
2638        return None;
2639    }
2640    Some(format!("arn:aws:s3:::{bucket}/{key}"))
2641}
2642
2643fn materialize_generated_tfvars(
2644    config: &DeployerConfig,
2645    terraform_root: &Path,
2646    tfvars_example: &str,
2647) -> Result<Option<String>> {
2648    if config.bundle_source.is_none()
2649        && config.bundle_digest.is_none()
2650        && terraform_env_overrides().is_empty()
2651    {
2652        return Ok(None);
2653    }
2654
2655    let example_path = terraform_root.join(tfvars_example);
2656    let output_name = format!("{}.tfvars", config.environment);
2657    let output_path = terraform_root.join(&output_name);
2658
2659    let mut contents = if example_path.exists() {
2660        fs::read_to_string(&example_path)?
2661    } else {
2662        String::new()
2663    };
2664
2665    replace_tfvars_assignment(&mut contents, "cloud", config.provider.as_str());
2666    replace_tfvars_assignment(&mut contents, "tenant", &config.tenant);
2667    replace_tfvars_assignment(&mut contents, "environment", &config.environment);
2668    let deployment_name_prefix = resolve_terraform_deployment_name_prefix(config, &output_path);
2669    replace_tfvars_assignment(
2670        &mut contents,
2671        "deployment_name_prefix",
2672        &deployment_name_prefix,
2673    );
2674
2675    for (key, value) in terraform_contract_default_overrides(config.provider) {
2676        replace_tfvars_assignment(&mut contents, &key, &value);
2677    }
2678
2679    if let Some(bundle_source) = config.bundle_source.as_ref() {
2680        replace_tfvars_assignment(&mut contents, "bundle_source", bundle_source);
2681        if let Some(s3_arn) = aws_bundle_s3_object_arn(bundle_source) {
2682            replace_tfvars_assignment(&mut contents, "bundle_s3_object_arn", &s3_arn);
2683        }
2684    }
2685    if let Some(bundle_digest) = config.bundle_digest.as_ref() {
2686        replace_tfvars_assignment(&mut contents, "bundle_digest", bundle_digest);
2687    }
2688    if let Some(repo_registry_base) = config.repo_registry_base.as_ref() {
2689        replace_tfvars_assignment(&mut contents, "repo_registry_base", repo_registry_base);
2690    }
2691    if let Some(store_registry_base) = config.store_registry_base.as_ref() {
2692        replace_tfvars_assignment(&mut contents, "store_registry_base", store_registry_base);
2693    }
2694    if matches!(
2695        config.provider,
2696        crate::config::Provider::Aws
2697            | crate::config::Provider::Azure
2698            | crate::config::Provider::Gcp
2699    ) {
2700        replace_tfvars_assignment(
2701            &mut contents,
2702            "runtime_secret_prefix",
2703            &crate::runtime_secrets::default_cloud_secret_prefix(
2704                &config.environment,
2705                &config.tenant,
2706                None,
2707            ),
2708        );
2709        let runtime_secret_env = crate::runtime_secrets::runtime_secret_env_map_for_cloud(config)?;
2710        replace_tfvars_map_assignment(&mut contents, "runtime_secret_env", &runtime_secret_env);
2711    }
2712    for (key, value) in terraform_env_overrides() {
2713        replace_tfvars_assignment(&mut contents, &key, &value);
2714    }
2715    apply_operator_secrets_map_tfvar(&mut contents);
2716    normalize_public_base_url_assignment(&mut contents);
2717
2718    fs::write(output_path, contents)?;
2719    Ok(Some(output_name))
2720}
2721
2722/// PR-08: lift operator secrets into the generated tfvars file so the AWS
2723/// operator module materialises them as Secrets Manager entries and injects
2724/// them into the ECS task definition's `secrets` block.
2725///
2726/// Source contract (v1): a JSON object at the path indicated by
2727/// `GREENTIC_OPERATOR_SECRETS_JSON`, mapping canonical `secrets://...` URIs
2728/// to UTF-8 string values. When the env var is unset, the file is missing,
2729/// or the JSON is empty, no `secrets_map` assignment is written and the
2730/// terraform default (`{}`) applies — no operator-secret resources are
2731/// created. The bundle artifact never carries these values.
2732///
2733/// A future `gtc secrets export --json <path>` will be the canonical
2734/// producer; meanwhile the env-var contract lets operators or CI assemble
2735/// the JSON out-of-band and hand it to `gtc deploy`.
2736///
2737/// Logging policy: only the count is printed. Values, keys, and the file
2738/// path never leave the deployer process via stdout/stderr.
2739fn apply_operator_secrets_map_tfvar(contents: &mut String) {
2740    let Some(map) = load_operator_secrets_map() else {
2741        return;
2742    };
2743    if map.is_empty() {
2744        return;
2745    }
2746    let rendered = render_terraform_map(&map);
2747    replace_tfvars_assignment_literal(contents, "secrets_map", &rendered);
2748    eprintln!(
2749        "operator secrets: applied {} entr{} to tfvars (source=GREENTIC_OPERATOR_SECRETS_JSON)",
2750        map.len(),
2751        if map.len() == 1 { "y" } else { "ies" }
2752    );
2753}
2754
2755fn load_operator_secrets_map() -> Option<std::collections::BTreeMap<String, String>> {
2756    let path = std::env::var("GREENTIC_OPERATOR_SECRETS_JSON")
2757        .ok()
2758        .map(std::path::PathBuf::from)?;
2759    if !path.is_file() {
2760        return None;
2761    }
2762    let raw = fs::read_to_string(&path).ok()?;
2763    let parsed: std::collections::BTreeMap<String, String> = serde_json::from_str(&raw).ok()?;
2764    Some(parsed)
2765}
2766
2767/// Render a Rust map as a terraform HCL map literal.
2768///
2769/// Values are JSON-quoted (the same escape contract `replace_tfvars_assignment`
2770/// uses for plain strings), so the result is `{ "k" = "v", ... }` with each
2771/// pair on its own line for readability. Terraform marks the variable
2772/// `sensitive` at the schema level, so plan/apply suppresses values.
2773fn render_terraform_map(map: &std::collections::BTreeMap<String, String>) -> String {
2774    let mut out = String::from("{\n");
2775    for (key, value) in map {
2776        let key_q = serde_json::to_string(key).unwrap_or_else(|_| format!("\"{key}\""));
2777        let value_q = serde_json::to_string(value).unwrap_or_else(|_| format!("\"{value}\""));
2778        out.push_str(&format!("  {key_q} = {value_q}\n"));
2779    }
2780    out.push('}');
2781    out
2782}
2783
2784/// Like `replace_tfvars_assignment` but treats `value` as an already-rendered
2785/// HCL literal (map/list/etc.) instead of JSON-quoting it as a string.
2786fn replace_tfvars_assignment_literal(contents: &mut String, key: &str, value_literal: &str) {
2787    let replacement = format!("{key} = {value_literal}");
2788    let mut rewritten = Vec::new();
2789    let mut replaced = false;
2790    let mut iter = contents.lines().peekable();
2791    while let Some(line) = iter.next() {
2792        if !replaced && line.trim_start().starts_with(&format!("{key} = ")) {
2793            rewritten.push(replacement.clone());
2794            // Skip continuation lines of a previous multi-line literal until
2795            // we see a top-level closing `}` (terraform map/list literal).
2796            if line.trim_end().ends_with('{') || line.trim_end().ends_with('[') {
2797                for cont in iter.by_ref() {
2798                    if cont.trim() == "}" || cont.trim() == "]" {
2799                        break;
2800                    }
2801                }
2802            }
2803            replaced = true;
2804            continue;
2805        }
2806        rewritten.push(line.to_string());
2807    }
2808    if !replaced {
2809        if !contents.is_empty() && !contents.ends_with('\n') {
2810            rewritten.push(String::new());
2811        }
2812        rewritten.push(replacement);
2813    }
2814    let mut joined = rewritten.join("\n");
2815    if !joined.ends_with('\n') {
2816        joined.push('\n');
2817    }
2818    *contents = joined;
2819}
2820
2821fn resolve_terraform_deployment_name_prefix(config: &DeployerConfig, output_path: &Path) -> String {
2822    if let Some(prefix) = explicit_deployment_name_prefix() {
2823        return prefix;
2824    }
2825
2826    if let Ok(existing) = fs::read_to_string(output_path)
2827        && let Some(prefix) = read_tfvars_assignment(&existing, "deployment_name_prefix")
2828            .filter(|value| !value.trim().is_empty())
2829        && prefix != legacy_shared_deployment_name_prefix(config)
2830    {
2831        return prefix;
2832    }
2833
2834    stable_deployment_name_prefix(config)
2835}
2836
2837fn stable_deployment_name_prefix(config: &DeployerConfig) -> String {
2838    let seed = format!(
2839        "{}\0{}\0{}\0{}",
2840        config.provider.as_str(),
2841        config.tenant,
2842        config.environment,
2843        local_deployment_identity_seed(config),
2844    );
2845    format!("greentic-{:08x}", fnv1a32(seed.as_bytes()))
2846}
2847
2848fn legacy_shared_deployment_name_prefix(config: &DeployerConfig) -> String {
2849    let seed = format!(
2850        "{}\0{}\0{}",
2851        config.provider.as_str(),
2852        config.tenant,
2853        config.environment,
2854    );
2855    format!("greentic-{:08x}", fnv1a32(seed.as_bytes()))
2856}
2857
2858fn explicit_deployment_name_prefix() -> Option<String> {
2859    std::env::var("GREENTIC_DEPLOY_TERRAFORM_VAR_DEPLOYMENT_NAME_PREFIX")
2860        .ok()
2861        .or_else(|| std::env::var("GREENTIC_DEPLOYMENT_NAME_PREFIX").ok())
2862        .and_then(|value| {
2863            let normalized = normalize_deployment_name_prefix(&value);
2864            (!normalized.is_empty()).then_some(normalized)
2865        })
2866}
2867
2868fn local_deployment_identity_seed(config: &DeployerConfig) -> String {
2869    std::env::var("GREENTIC_DEPLOYMENT_ID")
2870        .ok()
2871        .filter(|value| !value.trim().is_empty())
2872        .unwrap_or_else(|| {
2873            let owner = std::env::var("GREENTIC_DEPLOYMENT_OWNER")
2874                .ok()
2875                .or_else(|| std::env::var("USER").ok())
2876                .or_else(|| std::env::var("USERNAME").ok())
2877                .filter(|value| !value.trim().is_empty())
2878                .unwrap_or_else(|| "unknown".to_string());
2879            let workspace = config
2880                .bundle_root
2881                .as_ref()
2882                .and_then(|path| path.canonicalize().ok())
2883                .or_else(|| std::env::current_dir().ok())
2884                .map(|path| path.display().to_string())
2885                .unwrap_or_else(|| "unknown".to_string());
2886            format!("{owner}\0{workspace}")
2887        })
2888}
2889
2890fn normalize_deployment_name_prefix(value: &str) -> String {
2891    let mut normalized = value
2892        .trim()
2893        .chars()
2894        .map(|ch| {
2895            if ch.is_ascii_alphanumeric() {
2896                ch.to_ascii_lowercase()
2897            } else {
2898                '-'
2899            }
2900        })
2901        .collect::<String>();
2902    while normalized.contains("--") {
2903        normalized = normalized.replace("--", "-");
2904    }
2905    normalized = normalized.trim_matches('-').to_string();
2906    if normalized.is_empty() {
2907        return normalized;
2908    }
2909    if !normalized
2910        .chars()
2911        .next()
2912        .is_some_and(|ch| ch.is_ascii_alphabetic())
2913    {
2914        normalized = format!("greentic-{normalized}");
2915    }
2916    if normalized.len() > 24 {
2917        normalized.truncate(24);
2918        normalized = normalized.trim_end_matches('-').to_string();
2919    }
2920    normalized
2921}
2922
2923fn fnv1a32(bytes: &[u8]) -> u32 {
2924    const OFFSET: u32 = 0x811c9dc5;
2925    const PRIME: u32 = 0x01000193;
2926
2927    let mut hash = OFFSET;
2928    for byte in bytes {
2929        hash ^= u32::from(*byte);
2930        hash = hash.wrapping_mul(PRIME);
2931    }
2932    hash
2933}
2934
2935fn normalize_public_base_url_assignment(contents: &mut String) {
2936    let dns_name = read_tfvars_assignment(contents, "dns_name");
2937    let public_base_url = read_tfvars_assignment(contents, "public_base_url");
2938
2939    if let Some(dns_name) = dns_name.filter(|value| !value.trim().is_empty()) {
2940        replace_tfvars_assignment(contents, "public_base_url", &format!("https://{dns_name}"));
2941        return;
2942    }
2943
2944    if let Some(public_base_url) = public_base_url {
2945        let normalized = public_base_url
2946            .trim()
2947            .trim_end_matches('/')
2948            .to_ascii_lowercase();
2949        let is_placeholder = normalized.is_empty()
2950            || normalized.contains("example.com")
2951            || normalized.contains("localhost")
2952            || normalized.contains("127.0.0.1");
2953        if is_placeholder {
2954            replace_tfvars_assignment(contents, "public_base_url", "");
2955        }
2956    }
2957}
2958
2959fn terraform_contract_default_overrides(provider: Provider) -> Vec<(String, String)> {
2960    let Some(requirements) = crate::contract::CloudTargetRequirementsV1::for_provider(provider)
2961    else {
2962        return Vec::new();
2963    };
2964
2965    let mut overrides = requirements
2966        .variable_requirements
2967        .into_iter()
2968        .filter_map(|entry| {
2969            let key = normalize_terraform_requirement_name(&entry.name)?;
2970            let value = entry.default_value?;
2971            Some((key, value))
2972        })
2973        .collect::<Vec<_>>();
2974    overrides.sort_by(|a, b| a.0.cmp(&b.0));
2975    overrides
2976}
2977
2978fn normalize_terraform_requirement_name(name: &str) -> Option<String> {
2979    const PREFIX: &str = "GREENTIC_DEPLOY_TERRAFORM_VAR_";
2980    let suffix = name.strip_prefix(PREFIX)?;
2981    let normalized = suffix.trim();
2982    if normalized.is_empty() {
2983        return None;
2984    }
2985    Some(
2986        normalized
2987            .to_ascii_lowercase()
2988            .replace("__", "-")
2989            .replace('_', ".")
2990            .replace('.', "_"),
2991    )
2992}
2993
2994fn resolve_tfvars_example_name(terraform_root: &Path, environment: &str) -> Result<String> {
2995    let preferred = format!("{environment}.tfvars.example");
2996    if terraform_root.join(&preferred).exists() {
2997        return Ok(preferred);
2998    }
2999
3000    let mut candidates = fs::read_dir(terraform_root)?
3001        .filter_map(|entry| entry.ok())
3002        .filter_map(|entry| {
3003            let file_type = entry.file_type().ok()?;
3004            if !file_type.is_file() {
3005                return None;
3006            }
3007            let name = entry.file_name();
3008            let name = name.to_str()?;
3009            name.ends_with(".tfvars.example").then(|| name.to_string())
3010        })
3011        .collect::<Vec<_>>();
3012    candidates.sort();
3013
3014    Ok(candidates.into_iter().next().unwrap_or(preferred))
3015}
3016
3017fn terraform_env_overrides() -> Vec<(String, String)> {
3018    const PREFIX: &str = "GREENTIC_DEPLOY_TERRAFORM_VAR_";
3019    let mut overrides = std::env::vars()
3020        .filter_map(|(key, value)| {
3021            let suffix = key.strip_prefix(PREFIX)?;
3022            let normalized = suffix.trim();
3023            if normalized.is_empty() {
3024                return None;
3025            }
3026            Some((normalized.to_ascii_lowercase(), value))
3027        })
3028        .map(|(key, value)| (key.replace("__", "-").replace('_', "."), value))
3029        .map(|(key, value)| (key.replace('.', "_"), value))
3030        .collect::<Vec<_>>();
3031    overrides.sort_by(|a, b| a.0.cmp(&b.0));
3032    overrides
3033}
3034
3035fn replace_tfvars_assignment(contents: &mut String, key: &str, value: &str) {
3036    let replacement = format!(
3037        "{key} = {}",
3038        serde_json::to_string(value).unwrap_or_else(|_| format!("\"{value}\""))
3039    );
3040
3041    let mut rewritten = Vec::new();
3042    let mut replaced = false;
3043    for line in contents.lines() {
3044        let trimmed = line.trim_start();
3045        if !replaced && trimmed.starts_with(&format!("{key} =")) {
3046            rewritten.push(replacement.clone());
3047            replaced = true;
3048        } else {
3049            rewritten.push(line.to_string());
3050        }
3051    }
3052    if !replaced {
3053        rewritten.push(replacement);
3054    }
3055    *contents = rewritten.join("\n");
3056    contents.push('\n');
3057}
3058
3059fn replace_tfvars_map_assignment(
3060    contents: &mut String,
3061    key: &str,
3062    values: &BTreeMap<String, String>,
3063) {
3064    let replacement = if values.is_empty() {
3065        format!("{key} = {{}}")
3066    } else {
3067        let mut out = format!("{key} = {{\n");
3068        for (map_key, value) in values {
3069            out.push_str(&format!(
3070                "  {} = {}\n",
3071                serde_json::to_string(map_key).unwrap_or_else(|_| format!("\"{map_key}\"")),
3072                serde_json::to_string(value).unwrap_or_else(|_| format!("\"{value}\""))
3073            ));
3074        }
3075        out.push('}');
3076        out
3077    };
3078
3079    let mut rewritten = Vec::new();
3080    let mut replaced = false;
3081    let mut skipping_multiline = false;
3082    for line in contents.lines() {
3083        let trimmed = line.trim_start();
3084        if skipping_multiline {
3085            if trimmed == "}" {
3086                skipping_multiline = false;
3087            }
3088            continue;
3089        }
3090        if !replaced && trimmed.starts_with(&format!("{key} =")) {
3091            rewritten.push(replacement.clone());
3092            replaced = true;
3093            if trimmed.ends_with('{') && !trimmed.contains('}') {
3094                skipping_multiline = true;
3095            }
3096        } else {
3097            rewritten.push(line.to_string());
3098        }
3099    }
3100    if !replaced {
3101        rewritten.push(replacement);
3102    }
3103    *contents = rewritten.join("\n");
3104    contents.push('\n');
3105}
3106
3107fn read_tfvars_assignment(contents: &str, key: &str) -> Option<String> {
3108    for line in contents.lines() {
3109        let trimmed = line.trim();
3110        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
3111            continue;
3112        }
3113        let (lhs, rhs) = trimmed.split_once('=')?;
3114        if lhs.trim() != key {
3115            continue;
3116        }
3117        let value = rhs
3118            .split('#')
3119            .next()
3120            .map(str::trim)
3121            .map(|segment| segment.trim_matches('"'))
3122            .unwrap_or_default();
3123        return Some(value.to_string());
3124    }
3125    None
3126}
3127
3128fn materialize_k8s_raw_handoff_assets(
3129    _config: &DeployerConfig,
3130    selection: &DeploymentPackSelection,
3131    deploy_dir: &Path,
3132) -> Result<()> {
3133    let manifests = read_pack_asset(
3134        &selection.pack_path,
3135        "assets/examples/rendered-manifests.yaml",
3136    )?;
3137    let k8s_root = deploy_dir.join("k8s");
3138    fs::create_dir_all(&k8s_root)?;
3139    fs::write(k8s_root.join("rendered-manifests.yaml"), manifests)?;
3140    write_executable_script(
3141        &deploy_dir.join("kubectl-apply.sh"),
3142        kubectl_script("apply -f \"$K8S_ROOT/rendered-manifests.yaml\" \"$@\""),
3143    )?;
3144    write_executable_script(
3145        &deploy_dir.join("kubectl-delete.sh"),
3146        kubectl_script("delete -f \"$K8S_ROOT/rendered-manifests.yaml\" \"$@\""),
3147    )?;
3148    write_executable_script(
3149        &deploy_dir.join("kubectl-status.sh"),
3150        kubectl_script("get -f \"$K8S_ROOT/rendered-manifests.yaml\" \"$@\""),
3151    )?;
3152
3153    let mut note = String::new();
3154    note.push_str("K8s raw handoff assets were materialized from the deployment pack.\n");
3155    note.push_str(&format!(
3156        "manifest_path={}\n",
3157        k8s_root.join("rendered-manifests.yaml").display()
3158    ));
3159    note.push_str("scripts=kubectl-apply.sh, kubectl-delete.sh, kubectl-status.sh\n");
3160    fs::write(deploy_dir.join("k8s-handoff.txt"), note)?;
3161    Ok(())
3162}
3163
3164fn materialize_helm_handoff_assets(
3165    config: &DeployerConfig,
3166    selection: &DeploymentPackSelection,
3167    deploy_dir: &Path,
3168) -> Result<()> {
3169    let chart_root = deploy_dir.join("helm-chart");
3170    let copied = copy_pack_subtree(&selection.pack_path, "chart", &chart_root)?;
3171    if copied.is_empty() {
3172        return Ok(());
3173    }
3174
3175    let release_name = format!("greentic-{}", config.tenant);
3176    write_executable_script(
3177        &deploy_dir.join("helm-upgrade.sh"),
3178        helm_script(&format!(
3179            "upgrade --install {release_name} \"$CHART_ROOT\" \"$@\""
3180        )),
3181    )?;
3182    write_executable_script(
3183        &deploy_dir.join("helm-rollback.sh"),
3184        helm_script(&format!("rollback {release_name} \"$@\"")),
3185    )?;
3186    write_executable_script(
3187        &deploy_dir.join("helm-status.sh"),
3188        helm_script(&format!("status {release_name} \"$@\"")),
3189    )?;
3190
3191    let mut note = String::new();
3192    note.push_str("Helm handoff assets were materialized from the deployment pack.\n");
3193    note.push_str(&format!("chart_root={}\n", chart_root.display()));
3194    note.push_str(&format!("release_name={release_name}\n"));
3195    note.push_str("scripts=helm-upgrade.sh, helm-rollback.sh, helm-status.sh\n");
3196    note.push_str("copied_files:\n");
3197    for path in copied {
3198        note.push_str(&format!("- {path}\n"));
3199    }
3200    fs::write(deploy_dir.join("helm-handoff.txt"), note)?;
3201    Ok(())
3202}
3203
3204fn materialize_operator_handoff_assets(
3205    _config: &DeployerConfig,
3206    selection: &DeploymentPackSelection,
3207    deploy_dir: &Path,
3208) -> Result<()> {
3209    let manifests = read_pack_asset(
3210        &selection.pack_path,
3211        "assets/examples/rendered-manifests.yaml",
3212    )?;
3213    let operator_root = deploy_dir.join("operator");
3214    fs::create_dir_all(&operator_root)?;
3215    fs::write(operator_root.join("rendered-manifests.yaml"), manifests)?;
3216    write_executable_script(
3217        &deploy_dir.join("operator-apply.sh"),
3218        kubectl_root_script(
3219            "OPERATOR_ROOT",
3220            "apply -f \"$OPERATOR_ROOT/rendered-manifests.yaml\" \"$@\"",
3221        ),
3222    )?;
3223    write_executable_script(
3224        &deploy_dir.join("operator-delete.sh"),
3225        kubectl_root_script(
3226            "OPERATOR_ROOT",
3227            "delete -f \"$OPERATOR_ROOT/rendered-manifests.yaml\" \"$@\"",
3228        ),
3229    )?;
3230    write_executable_script(
3231        &deploy_dir.join("operator-status.sh"),
3232        kubectl_root_script(
3233            "OPERATOR_ROOT",
3234            "get -f \"$OPERATOR_ROOT/rendered-manifests.yaml\" \"$@\"",
3235        ),
3236    )?;
3237
3238    let mut note = String::new();
3239    note.push_str("Operator handoff assets were materialized from the deployment pack.\n");
3240    note.push_str(&format!(
3241        "manifest_path={}\n",
3242        operator_root.join("rendered-manifests.yaml").display()
3243    ));
3244    note.push_str("scripts=operator-apply.sh, operator-delete.sh, operator-status.sh\n");
3245    note.push_str("admin_api=localhost_only_https_mtls\n");
3246    fs::write(deploy_dir.join("operator-handoff.txt"), note)?;
3247    Ok(())
3248}
3249
3250fn materialize_serverless_handoff_assets(
3251    _config: &DeployerConfig,
3252    selection: &DeploymentPackSelection,
3253    deploy_dir: &Path,
3254) -> Result<()> {
3255    let descriptor = read_pack_asset(
3256        &selection.pack_path,
3257        "assets/examples/deployment-descriptor.json",
3258    )?;
3259    let serverless_root = deploy_dir.join("serverless");
3260    fs::create_dir_all(&serverless_root)?;
3261    fs::write(
3262        serverless_root.join("deployment-descriptor.json"),
3263        descriptor,
3264    )?;
3265    write_executable_script(
3266        &deploy_dir.join("serverless-deploy.sh"),
3267        generic_root_script(
3268            "SERVERLESS_ROOT",
3269            "echo \"serverless deploy descriptor: $SERVERLESS_ROOT/deployment-descriptor.json\"",
3270        ),
3271    )?;
3272    write_executable_script(
3273        &deploy_dir.join("serverless-status.sh"),
3274        generic_root_script(
3275            "SERVERLESS_ROOT",
3276            "echo \"serverless status descriptor: $SERVERLESS_ROOT/deployment-descriptor.json\"",
3277        ),
3278    )?;
3279    write_executable_script(
3280        &deploy_dir.join("serverless-destroy.sh"),
3281        generic_root_script(
3282            "SERVERLESS_ROOT",
3283            "echo \"serverless destroy descriptor: $SERVERLESS_ROOT/deployment-descriptor.json\"",
3284        ),
3285    )?;
3286
3287    let mut note = String::new();
3288    note.push_str("Serverless handoff assets were materialized from the deployment pack.\n");
3289    note.push_str(&format!(
3290        "descriptor_path={}\n",
3291        serverless_root.join("deployment-descriptor.json").display()
3292    ));
3293    note.push_str("scripts=serverless-deploy.sh, serverless-destroy.sh, serverless-status.sh\n");
3294    note.push_str("filesystem_hint=tmp_only\n");
3295    fs::write(deploy_dir.join("serverless-handoff.txt"), note)?;
3296    Ok(())
3297}
3298
3299fn materialize_snap_handoff_assets(
3300    _config: &DeployerConfig,
3301    selection: &DeploymentPackSelection,
3302    deploy_dir: &Path,
3303) -> Result<()> {
3304    let snap_root = deploy_dir.join("snap");
3305    let copied = copy_pack_subtree(&selection.pack_path, "snap", &snap_root)?;
3306    if copied.is_empty() {
3307        return Ok(());
3308    }
3309    write_executable_script(
3310        &deploy_dir.join("snap-install.sh"),
3311        generic_root_script(
3312            "SNAP_ROOT",
3313            "echo \"snap install scaffold from $SNAP_ROOT/fetch/snapcraft.yaml\"",
3314        ),
3315    )?;
3316    write_executable_script(
3317        &deploy_dir.join("snap-remove.sh"),
3318        generic_root_script(
3319            "SNAP_ROOT",
3320            "echo \"snap remove scaffold from $SNAP_ROOT/embedded/snapcraft.yaml\"",
3321        ),
3322    )?;
3323    write_executable_script(
3324        &deploy_dir.join("snap-status.sh"),
3325        generic_root_script(
3326            "SNAP_ROOT",
3327            "echo \"snap status scaffold from $SNAP_ROOT/fetch/snapcraft.yaml\"",
3328        ),
3329    )?;
3330
3331    let mut note = String::new();
3332    note.push_str("Snap handoff assets were materialized from the deployment pack.\n");
3333    note.push_str(&format!("snap_root={}\n", snap_root.display()));
3334    note.push_str("scripts=snap-install.sh, snap-remove.sh, snap-status.sh\n");
3335    note.push_str("copied_files:\n");
3336    for path in copied {
3337        note.push_str(&format!("- {path}\n"));
3338    }
3339    fs::write(deploy_dir.join("snap-handoff.txt"), note)?;
3340    Ok(())
3341}
3342
3343fn materialize_juju_machine_handoff_assets(
3344    _config: &DeployerConfig,
3345    selection: &DeploymentPackSelection,
3346    deploy_dir: &Path,
3347) -> Result<()> {
3348    let charm_root = deploy_dir.join("juju-machine-charm");
3349    let copied = copy_pack_subtree(&selection.pack_path, "charm", &charm_root)?;
3350    if copied.is_empty() {
3351        return Ok(());
3352    }
3353    write_executable_script(
3354        &deploy_dir.join("juju-machine-deploy.sh"),
3355        juju_script(
3356            "juju-machine-charm",
3357            "deploy \"$CHARM_ROOT\" greentic-operator \"$@\"",
3358        ),
3359    )?;
3360    write_executable_script(
3361        &deploy_dir.join("juju-machine-remove.sh"),
3362        juju_script(
3363            "juju-machine-charm",
3364            "remove-application greentic-operator \"$@\"",
3365        ),
3366    )?;
3367    write_executable_script(
3368        &deploy_dir.join("juju-machine-status.sh"),
3369        juju_script("juju-machine-charm", "status greentic-operator \"$@\""),
3370    )?;
3371
3372    let mut note = String::new();
3373    note.push_str("Juju machine handoff assets were materialized from the deployment pack.\n");
3374    note.push_str(&format!("charm_root={}\n", charm_root.display()));
3375    note.push_str(
3376        "scripts=juju-machine-deploy.sh, juju-machine-remove.sh, juju-machine-status.sh\n",
3377    );
3378    note.push_str("copied_files:\n");
3379    for path in copied {
3380        note.push_str(&format!("- {path}\n"));
3381    }
3382    fs::write(deploy_dir.join("juju-machine-handoff.txt"), note)?;
3383    Ok(())
3384}
3385
3386fn materialize_juju_k8s_handoff_assets(
3387    _config: &DeployerConfig,
3388    selection: &DeploymentPackSelection,
3389    deploy_dir: &Path,
3390) -> Result<()> {
3391    let charm_root = deploy_dir.join("juju-k8s-charm");
3392    let copied = copy_pack_subtree(&selection.pack_path, "charm", &charm_root)?;
3393    if copied.is_empty() {
3394        return Ok(());
3395    }
3396    write_executable_script(
3397        &deploy_dir.join("juju-k8s-deploy.sh"),
3398        juju_script(
3399            "juju-k8s-charm",
3400            "deploy \"$CHARM_ROOT\" greentic-operator-k8s \"$@\"",
3401        ),
3402    )?;
3403    write_executable_script(
3404        &deploy_dir.join("juju-k8s-remove.sh"),
3405        juju_script(
3406            "juju-k8s-charm",
3407            "remove-application greentic-operator-k8s \"$@\"",
3408        ),
3409    )?;
3410    write_executable_script(
3411        &deploy_dir.join("juju-k8s-status.sh"),
3412        juju_script("juju-k8s-charm", "status greentic-operator-k8s \"$@\""),
3413    )?;
3414
3415    let mut note = String::new();
3416    note.push_str("Juju k8s handoff assets were materialized from the deployment pack.\n");
3417    note.push_str(&format!("charm_root={}\n", charm_root.display()));
3418    note.push_str("scripts=juju-k8s-deploy.sh, juju-k8s-remove.sh, juju-k8s-status.sh\n");
3419    note.push_str("copied_files:\n");
3420    for path in copied {
3421        note.push_str(&format!("- {path}\n"));
3422    }
3423    fs::write(deploy_dir.join("juju-k8s-handoff.txt"), note)?;
3424    Ok(())
3425}
3426
3427fn terraform_script_prelude(command: &str) -> String {
3428    format!(
3429        "#!/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"
3430    )
3431}
3432
3433fn terraform_init_script() -> String {
3434    "#!/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"
3435        .to_string()
3436}
3437
3438fn terraform_hash_string_function() -> &'static str {
3439    r#"hash_string() {
3440  if command -v md5sum >/dev/null 2>&1; then
3441    printf '%s' "$1" | md5sum | awk '{print substr($1,1,8)}'
3442  else
3443    printf '%s' "$1" | md5 -q | awk '{print substr($1,1,8)}'
3444  fi
3445}
3446"#
3447}
3448
3449fn terraform_plan_like_script(
3450    operation: &str,
3451    provider: crate::config::Provider,
3452    generated_tfvars: Option<&str>,
3453    tfvars_example: &str,
3454) -> String {
3455    let extra_args = match operation {
3456        "apply" | "destroy" => " -auto-approve -input=false",
3457        _ => " -input=false",
3458    };
3459    let tfvars_lookup = if let Some(generated_tfvars) = generated_tfvars {
3460        format!(
3461            "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"
3462        )
3463    } else {
3464        format!(
3465            "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"
3466        )
3467    };
3468    let pre_apply_hook = if operation == "apply" && provider == crate::config::Provider::Aws {
3469        r#"
3470if command -v aws >/dev/null 2>&1; then
3471  MODULE_ADDR=""
3472  if grep -q 'module "operator_aws"' main.tf; then
3473    MODULE_ADDR="module.operator_aws[0]"
3474  elif grep -q 'module "operator"' main.tf; then
3475    MODULE_ADDR="module.operator"
3476  fi
3477  if [ -n "$MODULE_ADDR" ]; then
3478    BUNDLE_DIGEST_VALUE=""
3479    DEPLOYMENT_NAME_PREFIX_VALUE=""
3480    AWS_REGION_VALUE="${AWS_REGION:-${AWS_DEFAULT_REGION:-}}"
3481    if [ -n "$VAR_FILE" ] && [ -f "$VAR_FILE" ]; then
3482      BUNDLE_DIGEST_VALUE=$(sed -n 's/^bundle_digest = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3483      DEPLOYMENT_NAME_PREFIX_VALUE=$(sed -n 's/^deployment_name_prefix = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3484    fi
3485    NAME_PREFIX="$DEPLOYMENT_NAME_PREFIX_VALUE"
3486    if [ -z "$NAME_PREFIX" ] && [ -n "$BUNDLE_DIGEST_VALUE" ]; then
3487      SHORT_ID="$(hash_string "$BUNDLE_DIGEST_VALUE")"
3488      NAME_PREFIX="greentic-${SHORT_ID}"
3489    fi
3490    if [ -n "$NAME_PREFIX" ] && [ -n "$AWS_REGION_VALUE" ]; then
3491      export AWS_REGION="$AWS_REGION_VALUE"
3492      export AWS_DEFAULT_REGION="$AWS_REGION_VALUE"
3493      import_if_missing() {
3494        local address="$1"
3495        local id="$2"
3496        if "$TERRAFORM_BIN" state show "$address" >/dev/null 2>&1; then
3497          return 0
3498        fi
3499        if [ -n "$VAR_FILE" ] && [ -f "$VAR_FILE" ]; then
3500          "$TERRAFORM_BIN" import -input=false -var-file="$VAR_FILE" "$address" "$id"
3501        else
3502          "$TERRAFORM_BIN" import -input=false "$address" "$id"
3503        fi
3504      }
3505      SECURITY_GROUP_ALB_NAME="${NAME_PREFIX}-alb"
3506      SECURITY_GROUP_SERVICE_NAME="${NAME_PREFIX}-svc"
3507      ALB_NAME="${NAME_PREFIX}-alb"
3508      CLUSTER_NAME="${NAME_PREFIX}-cluster"
3509      LOG_GROUP_NAME="/greentic/demo/${NAME_PREFIX}"
3510      ROLE_NAME="${NAME_PREFIX}-task-exec"
3511      SERVICE_NAME="${NAME_PREFIX}-service"
3512      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)
3513      if [ -n "$ALB_GROUP_ID" ] && [ "$ALB_GROUP_ID" != "None" ]; then
3514        import_if_missing "${MODULE_ADDR}.aws_security_group.alb" "$ALB_GROUP_ID"
3515      fi
3516      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)
3517      if [ -n "$SERVICE_GROUP_ID" ] && [ "$SERVICE_GROUP_ID" != "None" ]; then
3518        import_if_missing "${MODULE_ADDR}.aws_security_group.service" "$SERVICE_GROUP_ID"
3519      fi
3520      ALB_ARN=$(aws elbv2 describe-load-balancers --region "$AWS_REGION_VALUE" --names "$ALB_NAME" --query 'LoadBalancers[0].LoadBalancerArn' --output text 2>/dev/null || true)
3521      if [ -n "$ALB_ARN" ] && [ "$ALB_ARN" != "None" ]; then
3522        import_if_missing "${MODULE_ADDR}.aws_lb.this" "$ALB_ARN"
3523        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)
3524        if [ -n "$LISTENER_ARN" ] && [ "$LISTENER_ARN" != "None" ]; then
3525          import_if_missing "${MODULE_ADDR}.aws_lb_listener.http" "$LISTENER_ARN"
3526        fi
3527      fi
3528      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)
3529      if [ -n "$CLUSTER_FOUND" ] && [ "$CLUSTER_FOUND" != "None" ] && [ "$CLUSTER_FOUND" != "MISSING" ]; then
3530        import_if_missing "${MODULE_ADDR}.aws_ecs_cluster.this" "$CLUSTER_NAME"
3531      fi
3532      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)
3533      if [ -n "$LOG_GROUP_FOUND" ] && [ "$LOG_GROUP_FOUND" != "None" ]; then
3534        import_if_missing "${MODULE_ADDR}.aws_cloudwatch_log_group.this" "$LOG_GROUP_NAME"
3535      fi
3536      if aws iam get-role --role-name "$ROLE_NAME" >/dev/null 2>&1; then
3537        import_if_missing "${MODULE_ADDR}.aws_iam_role.task_execution" "$ROLE_NAME"
3538      fi
3539      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)
3540      if [ -n "$SERVICE_FOUND" ] && [ "$SERVICE_FOUND" != "None" ] && [ "$SERVICE_FOUND" != "MISSING" ]; then
3541        import_if_missing "${MODULE_ADDR}.aws_ecs_service.this" "${CLUSTER_NAME}/${SERVICE_NAME}"
3542      fi
3543    fi
3544  fi
3545fi
3546"#
3547        .to_string()
3548    } else if operation == "apply" && provider == crate::config::Provider::Azure {
3549        r#"
3550if command -v az >/dev/null 2>&1 && [ -n "${ARM_SUBSCRIPTION_ID:-}" ]; then
3551  MODULE_ADDR=""
3552  if grep -q 'module "operator_azure"' main.tf; then
3553    MODULE_ADDR="module.operator_azure[0]"
3554  elif grep -q 'module "operator"' main.tf; then
3555    MODULE_ADDR="module.operator"
3556  fi
3557    if [ -n "$MODULE_ADDR" ]; then
3558      BUNDLE_DIGEST_VALUE=""
3559      ENVIRONMENT_VALUE="dev"
3560      CLOUD_VALUE=""
3561      OPERATOR_IMAGE_DIGEST_VALUE=""
3562      BUNDLE_SOURCE_VALUE=""
3563      REMOTE_STATE_BACKEND_VALUE=""
3564      KEY_VAULT_ID_VALUE=""
3565      DEPLOYMENT_NAME_PREFIX_VALUE=""
3566      if [ -n "$VAR_FILE" ] && [ -f "$VAR_FILE" ]; then
3567        CLOUD_VALUE=$(sed -n 's/^cloud = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3568        BUNDLE_DIGEST_VALUE=$(sed -n 's/^bundle_digest = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3569        ENVIRONMENT_VALUE=$(sed -n 's/^environment = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3570        OPERATOR_IMAGE_DIGEST_VALUE=$(sed -n 's/^operator_image_digest = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3571        BUNDLE_SOURCE_VALUE=$(sed -n 's/^bundle_source = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3572        REMOTE_STATE_BACKEND_VALUE=$(sed -n 's/^remote_state_backend = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3573        KEY_VAULT_ID_VALUE=$(sed -n 's/^azure_key_vault_id = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3574        DEPLOYMENT_NAME_PREFIX_VALUE=$(sed -n 's/^deployment_name_prefix = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3575      fi
3576      if [ -n "$BUNDLE_DIGEST_VALUE" ]; then
3577        export TF_VAR_cloud="${CLOUD_VALUE:-azure}"
3578        export TF_VAR_environment="${ENVIRONMENT_VALUE:-dev}"
3579        export TF_VAR_operator_image_digest="$OPERATOR_IMAGE_DIGEST_VALUE"
3580        export TF_VAR_bundle_source="$BUNDLE_SOURCE_VALUE"
3581        export TF_VAR_bundle_digest="$BUNDLE_DIGEST_VALUE"
3582        export TF_VAR_remote_state_backend="$REMOTE_STATE_BACKEND_VALUE"
3583        export TF_VAR_azure_key_vault_id="$KEY_VAULT_ID_VALUE"
3584        export TF_VAR_azure_location="${GREENTIC_DEPLOY_TERRAFORM_VAR_AZURE_LOCATION:-}"
3585        NAME_PREFIX="$DEPLOYMENT_NAME_PREFIX_VALUE"
3586        if [ -z "$NAME_PREFIX" ]; then
3587          SHORT_ID="$(hash_string "$BUNDLE_DIGEST_VALUE")"
3588          NAME_PREFIX="greentic-${SHORT_ID}"
3589        fi
3590        RESOURCE_GROUP_NAME="${NAME_PREFIX}-rg"
3591      LOG_ANALYTICS_NAME="${NAME_PREFIX}-logs"
3592      CONTAINER_ENV_NAME="${NAME_PREFIX}-cae"
3593      CONTAINER_APP_NAME="${NAME_PREFIX}-app"
3594        import_if_missing() {
3595          local address="$1"
3596          local id="$2"
3597          if "$TERRAFORM_BIN" state show "$address" >/dev/null 2>&1; then
3598            return 0
3599          fi
3600          if [ -n "$VAR_FILE" ] && [ -f "$VAR_FILE" ]; then
3601            "$TERRAFORM_BIN" import -input=false -var-file="$VAR_FILE" "$address" "$id"
3602          else
3603            "$TERRAFORM_BIN" import -input=false "$address" "$id"
3604          fi
3605        }
3606      if az group show --name "$RESOURCE_GROUP_NAME" >/dev/null 2>&1; then
3607        import_if_missing "${MODULE_ADDR}.azurerm_resource_group.this" "/subscriptions/${ARM_SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP_NAME}"
3608      fi
3609      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)
3610      if [ -n "$LOG_ANALYTICS_ID" ]; then
3611        import_if_missing "${MODULE_ADDR}.azurerm_log_analytics_workspace.this" "$LOG_ANALYTICS_ID"
3612      fi
3613      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)
3614      if [ -n "$CONTAINER_ENV_ID" ]; then
3615        import_if_missing "${MODULE_ADDR}.azurerm_container_app_environment.this" "$CONTAINER_ENV_ID"
3616      fi
3617      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)
3618      if [ -n "$CONTAINER_APP_ID" ]; then
3619        import_if_missing "${MODULE_ADDR}.azurerm_container_app.this" "$CONTAINER_APP_ID"
3620      fi
3621      if [ -n "$KEY_VAULT_ID_VALUE" ]; then
3622        KEY_VAULT_NAME=$(basename "$KEY_VAULT_ID_VALUE")
3623        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)
3624        if [ -n "$ADMIN_CA_SECRET_ID" ]; then
3625          import_if_missing "${MODULE_ADDR}.azurerm_key_vault_secret.admin_ca[0]" "$ADMIN_CA_SECRET_ID"
3626        fi
3627        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)
3628        if [ -n "$ADMIN_SERVER_CERT_SECRET_ID" ]; then
3629          import_if_missing "${MODULE_ADDR}.azurerm_key_vault_secret.admin_server_cert[0]" "$ADMIN_SERVER_CERT_SECRET_ID"
3630        fi
3631        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)
3632        if [ -n "$ADMIN_SERVER_KEY_SECRET_ID" ]; then
3633          import_if_missing "${MODULE_ADDR}.azurerm_key_vault_secret.admin_server_key[0]" "$ADMIN_SERVER_KEY_SECRET_ID"
3634        fi
3635      fi
3636    fi
3637  fi
3638fi
3639"# 
3640        .to_string()
3641    } else if operation == "apply" && provider == crate::config::Provider::Gcp {
3642        r#"
3643if command -v gcloud >/dev/null 2>&1; then
3644  MODULE_ADDR=""
3645  if grep -q 'module "operator_gcp"' main.tf; then
3646    MODULE_ADDR="module.operator_gcp[0]"
3647  elif grep -q 'module "operator"' main.tf; then
3648    MODULE_ADDR="module.operator"
3649  fi
3650  if [ -n "$MODULE_ADDR" ]; then
3651    GCP_PROJECT_ID_VALUE=""
3652    GCP_REGION_VALUE="us-central1"
3653    ENVIRONMENT_VALUE="dev"
3654    BUNDLE_DIGEST_VALUE=""
3655    DEPLOYMENT_NAME_PREFIX_VALUE=""
3656    if [ -n "$VAR_FILE" ] && [ -f "$VAR_FILE" ]; then
3657      GCP_PROJECT_ID_VALUE=$(sed -n 's/^gcp_project_id = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3658      GCP_REGION_VALUE=$(sed -n 's/^gcp_region = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3659      ENVIRONMENT_VALUE=$(sed -n 's/^environment = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3660      BUNDLE_DIGEST_VALUE=$(sed -n 's/^bundle_digest = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3661      DEPLOYMENT_NAME_PREFIX_VALUE=$(sed -n 's/^deployment_name_prefix = "\(.*\)"$/\1/p' "$VAR_FILE" | head -n 1)
3662    fi
3663    if [ -n "$GCP_PROJECT_ID_VALUE" ]; then
3664      import_if_missing() {
3665        local address="$1"
3666        local id="$2"
3667        if "$TERRAFORM_BIN" state show "$address" >/dev/null 2>&1; then
3668          return 0
3669        fi
3670        if [ -n "$VAR_FILE" ] && [ -f "$VAR_FILE" ]; then
3671          "$TERRAFORM_BIN" import -input=false -var-file="$VAR_FILE" "$address" "$id"
3672        else
3673          "$TERRAFORM_BIN" import -input=false "$address" "$id"
3674        fi
3675      }
3676      import_gcp_secret_if_exists() {
3677        local address="$1"
3678        local secret_name="$2"
3679        local secret_id
3680        secret_id=$(gcloud secrets describe "$secret_name" --project "$GCP_PROJECT_ID_VALUE" --format='value(name)' 2>/dev/null || true)
3681        if [ -n "$secret_id" ]; then
3682          import_if_missing "$address" "$secret_id"
3683        fi
3684      }
3685      import_gcp_secret_if_exists "${MODULE_ADDR}.google_secret_manager_secret.admin_ca" "greentic-admin-ca-${ENVIRONMENT_VALUE}"
3686      import_gcp_secret_if_exists "${MODULE_ADDR}.google_secret_manager_secret.admin_server_cert" "greentic-admin-server-cert-${ENVIRONMENT_VALUE}"
3687      import_gcp_secret_if_exists "${MODULE_ADDR}.google_secret_manager_secret.admin_server_key" "greentic-admin-server-key-${ENVIRONMENT_VALUE}"
3688      import_gcp_secret_if_exists "${MODULE_ADDR}.google_secret_manager_secret.admin_client_cert" "greentic-admin-client-cert-${ENVIRONMENT_VALUE}"
3689      import_gcp_secret_if_exists "${MODULE_ADDR}.google_secret_manager_secret.admin_client_key" "greentic-admin-client-key-${ENVIRONMENT_VALUE}"
3690      import_gcp_secret_if_exists "${MODULE_ADDR}.google_secret_manager_secret.admin_relay_token" "greentic-admin-relay-token-${ENVIRONMENT_VALUE}"
3691      if [ -n "$BUNDLE_DIGEST_VALUE" ]; then
3692        NAME_PREFIX="$DEPLOYMENT_NAME_PREFIX_VALUE"
3693        if [ -z "$NAME_PREFIX" ]; then
3694          SHORT_ID="$(hash_string "$BUNDLE_DIGEST_VALUE")"
3695          NAME_PREFIX="greentic-${SHORT_ID}"
3696        fi
3697        CLOUD_RUN_SERVICE_NAME="${NAME_PREFIX}-run"
3698        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)
3699        if [ -n "$CLOUD_RUN_SERVICE_ID" ]; then
3700          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}"
3701        fi
3702      fi
3703    fi
3704  fi
3705fi
3706"#
3707        .to_string()
3708    } else {
3709        String::new()
3710    };
3711    let apply_invocation = format!(
3712        "if [ -n \"$VAR_FILE\" ]; then\n  \"$TERRAFORM_BIN\" {operation}{extra_args} -var-file=\"$VAR_FILE\" \"$@\"\nelse\n  \"$TERRAFORM_BIN\" {operation}{extra_args} \"$@\"\nfi"
3713    );
3714    let apply_invocation_with_redirection = if apply_invocation.contains("-var-file=\"$VAR_FILE\"")
3715    {
3716        "  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"
3717    } else {
3718        "  \"$TERRAFORM_BIN\" apply -auto-approve -input=false \"$@\" >\"$stdout_file\" 2>\"$stderr_file\""
3719    };
3720    let operation_block = if operation == "apply" && provider == crate::config::Provider::Azure {
3721        format!(
3722            "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",
3723            apply_invocation_with_redirection = apply_invocation_with_redirection
3724        )
3725    } else if operation == "apply" && provider == crate::config::Provider::Aws {
3726        format!(
3727            "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",
3728            apply_invocation_with_redirection = apply_invocation_with_redirection
3729        )
3730    } else if operation == "apply" && provider == crate::config::Provider::Gcp {
3731        format!(
3732            "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",
3733            apply_invocation_with_redirection = apply_invocation_with_redirection
3734        )
3735    } else {
3736        apply_invocation
3737    };
3738    let hash_helper = terraform_hash_string_function();
3739    format!(
3740        "#!/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"
3741    )
3742}
3743
3744fn terraform_aws_cleanup_script(generated_tfvars: Option<&str>, tfvars_example: &str) -> String {
3745    let tfvars_lookup = if let Some(generated_tfvars) = generated_tfvars {
3746        format!(
3747            "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"
3748        )
3749    } else {
3750        format!(
3751            "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"
3752        )
3753    };
3754    let hash_helper = terraform_hash_string_function();
3755    format!(
3756        "#!/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"
3757    )
3758}
3759
3760fn configure_terraform_backend(
3761    config: &DeployerConfig,
3762    terraform_root: &Path,
3763    deploy_dir: &Path,
3764) -> Result<()> {
3765    let providers_path = terraform_root.join("providers.tf");
3766    if !providers_path.exists() {
3767        return Ok(());
3768    }
3769
3770    let contents = fs::read_to_string(&providers_path)?;
3771    if !contents.contains("backend \"s3\" {}") {
3772        return Ok(());
3773    }
3774
3775    if let Some(bucket) = std::env::var("GREENTIC_TERRAFORM_BACKEND_BUCKET")
3776        .ok()
3777        .filter(|value| !value.trim().is_empty())
3778    {
3779        let region = std::env::var("GREENTIC_TERRAFORM_BACKEND_REGION")
3780            .ok()
3781            .or_else(|| std::env::var("AWS_REGION").ok())
3782            .or_else(|| std::env::var("AWS_DEFAULT_REGION").ok())
3783            .unwrap_or_else(|| "eu-north-1".to_string());
3784        let key = std::env::var("GREENTIC_TERRAFORM_BACKEND_KEY")
3785            .ok()
3786            .filter(|value| !value.trim().is_empty())
3787            .unwrap_or_else(|| {
3788                let deployment_name_prefix = explicit_deployment_name_prefix()
3789                    .unwrap_or_else(|| stable_deployment_name_prefix(config));
3790                format!(
3791                    "greentic/{}/{}/{}/{}/terraform.tfstate",
3792                    config.provider.as_str(),
3793                    config.tenant,
3794                    config.environment,
3795                    deployment_name_prefix
3796                )
3797            });
3798        let backend_hcl =
3799            format!("bucket = \"{bucket}\"\nkey = \"{key}\"\nregion = \"{region}\"\n");
3800        fs::write(deploy_dir.join("backend.hcl"), backend_hcl)?;
3801        return Ok(());
3802    }
3803
3804    let rewritten = match config.provider {
3805        crate::config::Provider::Aws => {
3806            "terraform {\n  required_version = \">= 1.8.0\"\n  backend \"local\" {\n    path = \"terraform.tfstate\"\n  }\n}\n".to_string()
3807        }
3808        crate::config::Provider::Azure => {
3809            "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()
3810        }
3811        crate::config::Provider::Gcp => {
3812            "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()
3813        }
3814        _ => contents.replace(
3815            "backend \"s3\" {}",
3816            "backend \"local\" {\n    path = \"terraform.tfstate\"\n  }",
3817        ),
3818    };
3819    fs::write(providers_path, rewritten)?;
3820    Ok(())
3821}
3822
3823fn normalize_terraform_main_tf(config: &DeployerConfig, terraform_root: &Path) -> Result<()> {
3824    if config.provider != crate::config::Provider::Gcp {
3825        return Ok(());
3826    }
3827
3828    let main_tf_path = terraform_root.join("main.tf");
3829    if main_tf_path.exists() {
3830        let contents = fs::read_to_string(&main_tf_path)?;
3831        let old = r#"  operator_image        = "ghcr.io/greenticai/greentic-start-distroless@${var.operator_image_digest}""#;
3832        let new = r#"  operator_image        = var.operator_image != "" ? var.operator_image : "ghcr.io/greenticai/greentic-start-distroless@${var.operator_image_digest}""#;
3833        if contents.contains(old) {
3834            fs::write(&main_tf_path, contents.replace(old, new))?;
3835        }
3836    }
3837    normalize_gcp_operator_module_main_tf(terraform_root)?;
3838    Ok(())
3839}
3840
3841fn normalize_gcp_operator_module_main_tf(terraform_root: &Path) -> Result<()> {
3842    let module_main_tf_path = terraform_root.join("modules/operator-gcp/main.tf");
3843    if !module_main_tf_path.exists() {
3844        return Ok(());
3845    }
3846
3847    let mut contents = fs::read_to_string(&module_main_tf_path)?;
3848    contents = contents.replace(
3849        r#"        name  = "GREENTIC_ADMIN_CA_SECRET_REF"
3850        value = google_secret_manager_secret.admin_ca.id"#,
3851        r#"        name  = "GREENTIC_ADMIN_CA_PEM"
3852        value = tls_self_signed_cert.admin_ca.cert_pem"#,
3853    );
3854    contents = contents.replace(
3855        r#"        name  = "GREENTIC_ADMIN_SERVER_CERT_SECRET_REF"
3856        value = google_secret_manager_secret.admin_server_cert.id"#,
3857        r#"        name  = "GREENTIC_ADMIN_SERVER_CERT_PEM"
3858        value = tls_locally_signed_cert.admin_server.cert_pem"#,
3859    );
3860    contents = contents.replace(
3861        r#"        name  = "GREENTIC_ADMIN_SERVER_KEY_SECRET_REF"
3862        value = google_secret_manager_secret.admin_server_key.id"#,
3863        r#"        name  = "GREENTIC_ADMIN_SERVER_KEY_PEM"
3864        value = tls_private_key.admin_server.private_key_pem"#,
3865    );
3866    contents = contents.replace(
3867        "  ingress  = \"INGRESS_TRAFFIC_ALL\"\n\n  template {",
3868        "  ingress  = \"INGRESS_TRAFFIC_ALL\"\n  deletion_protection = false\n\n  template {",
3869    );
3870
3871    for snippet in [
3872        r#"
3873      env {
3874        name = "GREENTIC_ADMIN_CA_PEM"
3875        value_source {
3876          secret_key_ref {
3877            secret  = google_secret_manager_secret.admin_ca.secret_id
3878            version = "latest"
3879          }
3880        }
3881      }
3882"#,
3883        r#"
3884      env {
3885        name = "GREENTIC_ADMIN_SERVER_CERT_PEM"
3886        value_source {
3887          secret_key_ref {
3888            secret  = google_secret_manager_secret.admin_server_cert.secret_id
3889            version = "latest"
3890          }
3891        }
3892      }
3893"#,
3894        r#"
3895      env {
3896        name = "GREENTIC_ADMIN_SERVER_KEY_PEM"
3897        value_source {
3898          secret_key_ref {
3899            secret  = google_secret_manager_secret.admin_server_key.secret_id
3900            version = "latest"
3901          }
3902        }
3903      }
3904"#,
3905        r#"
3906resource "google_service_account" "runtime" {
3907  project      = var.gcp_project_id
3908  account_id   = "${local.name_prefix}-run"
3909  display_name = "Greentic runtime"
3910}
3911"#,
3912        r#"
3913resource "google_secret_manager_secret_iam_member" "runtime_admin_ca_accessor" {
3914  project   = var.gcp_project_id
3915  secret_id = google_secret_manager_secret.admin_ca.secret_id
3916  role      = "roles/secretmanager.secretAccessor"
3917  member    = "serviceAccount:${google_service_account.runtime.email}"
3918}
3919"#,
3920        r#"
3921resource "google_secret_manager_secret_iam_member" "runtime_admin_server_cert_accessor" {
3922  project   = var.gcp_project_id
3923  secret_id = google_secret_manager_secret.admin_server_cert.secret_id
3924  role      = "roles/secretmanager.secretAccessor"
3925  member    = "serviceAccount:${google_service_account.runtime.email}"
3926}
3927"#,
3928        r#"
3929resource "google_secret_manager_secret_iam_member" "runtime_admin_server_key_accessor" {
3930  project   = var.gcp_project_id
3931  secret_id = google_secret_manager_secret.admin_server_key.secret_id
3932  role      = "roles/secretmanager.secretAccessor"
3933  member    = "serviceAccount:${google_service_account.runtime.email}"
3934}
3935"#,
3936        r#"
3937resource "google_secret_manager_secret_iam_member" "runtime_admin_ca_accessor" {
3938  project   = var.gcp_project_id
3939  secret_id = google_secret_manager_secret.admin_ca.secret_id
3940  role      = "roles/secretmanager.secretAccessor"
3941  member    = "serviceAccount:${local.runtime_service_account_email}"
3942}
3943"#,
3944        r#"
3945resource "google_secret_manager_secret_iam_member" "runtime_admin_server_cert_accessor" {
3946  project   = var.gcp_project_id
3947  secret_id = google_secret_manager_secret.admin_server_cert.secret_id
3948  role      = "roles/secretmanager.secretAccessor"
3949  member    = "serviceAccount:${local.runtime_service_account_email}"
3950}
3951"#,
3952        r#"
3953resource "google_secret_manager_secret_iam_member" "runtime_admin_server_key_accessor" {
3954  project   = var.gcp_project_id
3955  secret_id = google_secret_manager_secret.admin_server_key.secret_id
3956  role      = "roles/secretmanager.secretAccessor"
3957  member    = "serviceAccount:${local.runtime_service_account_email}"
3958}
3959"#,
3960        r#"  project_number                = split("/", google_secret_manager_secret.admin_ca.id)[1]
3961  runtime_service_account_email = "${local.project_number}-compute@developer.gserviceaccount.com"
3962"#,
3963        r#"  depends_on = [
3964    google_secret_manager_secret_iam_member.runtime_admin_ca_accessor,
3965    google_secret_manager_secret_iam_member.runtime_admin_server_cert_accessor,
3966    google_secret_manager_secret_iam_member.runtime_admin_server_key_accessor,
3967  ]
3968"#,
3969        r#"    service_account = google_service_account.runtime.email
3970"#,
3971    ] {
3972        contents = contents.replace(snippet, "");
3973    }
3974
3975    fs::write(module_main_tf_path, contents)?;
3976    Ok(())
3977}
3978
3979fn kubectl_script(command: &str) -> String {
3980    format!(
3981        "#!/usr/bin/env bash\nset -euo pipefail\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nK8S_ROOT=\"${{SCRIPT_DIR}}/k8s\"\n{command}\n"
3982    )
3983}
3984
3985fn kubectl_root_script(root_var: &str, command: &str) -> String {
3986    format!(
3987        "#!/usr/bin/env bash\nset -euo pipefail\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n{root_var}=\"${{SCRIPT_DIR}}/{}\"\nkubectl {command}\n",
3988        root_var.to_ascii_lowercase().trim_end_matches("_root")
3989    )
3990}
3991
3992fn helm_script(command: &str) -> String {
3993    format!(
3994        "#!/usr/bin/env bash\nset -euo pipefail\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nCHART_ROOT=\"${{SCRIPT_DIR}}/helm-chart\"\nhelm {command}\n"
3995    )
3996}
3997
3998fn juju_script(charm_dir: &str, command: &str) -> String {
3999    format!(
4000        "#!/usr/bin/env bash\nset -euo pipefail\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nCHARM_ROOT=\"${{SCRIPT_DIR}}/{charm_dir}\"\njuju {command}\n"
4001    )
4002}
4003
4004fn generic_root_script(root_var: &str, command: &str) -> String {
4005    format!(
4006        "#!/usr/bin/env bash\nset -euo pipefail\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n{root_var}=\"${{SCRIPT_DIR}}/{}\"\n{command}\n",
4007        root_var.to_ascii_lowercase().trim_end_matches("_root")
4008    )
4009}
4010
4011fn write_executable_script(path: &Path, contents: String) -> Result<()> {
4012    let file_name = path
4013        .file_name()
4014        .and_then(|value| value.to_str())
4015        .ok_or_else(|| DeployerError::Other(format!("invalid script path {}", path.display())))?;
4016    let tmp_path = path.with_file_name(format!(".{file_name}.tmp"));
4017    fs::write(&tmp_path, contents)?;
4018    set_executable_if_unix(&tmp_path)?;
4019    fs::rename(&tmp_path, path)?;
4020    Ok(())
4021}
4022
4023fn set_executable_if_unix(path: &Path) -> Result<()> {
4024    #[cfg(unix)]
4025    {
4026        use std::os::unix::fs::PermissionsExt;
4027        let mut perms = fs::metadata(path)?.permissions();
4028        perms.set_mode(0o755);
4029        fs::set_permissions(path, perms)?;
4030    }
4031    Ok(())
4032}
4033
4034#[derive(Serialize)]
4035struct RuntimeInvocation {
4036    capability: String,
4037    provider: String,
4038    strategy: String,
4039    tenant: String,
4040    environment: String,
4041    output_dir: String,
4042    plan_path: String,
4043    pack_id: String,
4044    flow_id: String,
4045    handler_id: String,
4046    pack_path: String,
4047}
4048
4049#[derive(Debug, Clone, Serialize)]
4050struct DeployerInvocation {
4051    capability: String,
4052    pack_id: String,
4053    flow_id: String,
4054    handler_id: String,
4055    pack_path: String,
4056    output_dir: String,
4057    runner_cmd: Vec<String>,
4058    runner_env: Vec<(String, String)>,
4059}
4060
4061struct WrittenDiagnostics {
4062    invocation: DeployerInvocation,
4063    handoff_path: PathBuf,
4064    runner_command_path: PathBuf,
4065}
4066
4067#[derive(Debug, Clone, Serialize, Deserialize)]
4068struct TerraformRuntimeMetadata {
4069    terraform_root: String,
4070    copied_files: Vec<String>,
4071    scripts: Vec<String>,
4072    #[serde(skip_serializing_if = "Option::is_none")]
4073    generated_tfvars: Option<String>,
4074    #[serde(skip_serializing_if = "Option::is_none")]
4075    secrets_provider_binding: Option<String>,
4076    init_command: String,
4077    plan_command: String,
4078    apply_command: String,
4079    destroy_command: String,
4080    status_command: String,
4081}
4082
4083fn write_runner_diagnostics(
4084    config: &DeployerConfig,
4085    deploy_dir: &Path,
4086    selection: &DeploymentPackSelection,
4087    plan_path: &Path,
4088) -> Result<WrittenDiagnostics> {
4089    let diag = build_deployer_invocation(config, deploy_dir, selection, plan_path);
4090
4091    let runner_cmd = diag.runner_cmd.clone();
4092    let runner_env = diag.runner_env.clone();
4093
4094    let diag_path = deploy_dir.join("._deployer_invocation.json");
4095    let diag_file = fs::File::create(&diag_path)?;
4096    serde_json::to_writer_pretty(diag_file, &diag)?;
4097
4098    let mut doc = String::from("Runner command:\n");
4099    doc.push_str(&runner_cmd.join(" "));
4100    doc.push('\n');
4101    doc.push_str("Environment:\n");
4102    for (key, value) in runner_env {
4103        doc.push_str(&format!("{key}={value}\n"));
4104    }
4105    let runner_command_path = deploy_dir.join("._runner_cmd.txt");
4106    fs::write(&runner_command_path, doc)?;
4107    Ok(WrittenDiagnostics {
4108        invocation: diag,
4109        handoff_path: diag_path,
4110        runner_command_path,
4111    })
4112}
4113
4114fn build_deployer_invocation(
4115    config: &DeployerConfig,
4116    deploy_dir: &Path,
4117    selection: &DeploymentPackSelection,
4118    plan_path: &Path,
4119) -> DeployerInvocation {
4120    DeployerInvocation {
4121        capability: selection.dispatch.capability.as_str().to_string(),
4122        pack_id: selection.dispatch.pack_id.clone(),
4123        flow_id: selection.dispatch.flow_id.clone(),
4124        handler_id: selection.dispatch.handler_id.clone(),
4125        pack_path: selection.pack_path.display().to_string(),
4126        output_dir: deploy_dir.display().to_string(),
4127        runner_cmd: vec![
4128            "greentic-runner".to_string(),
4129            "--pack".to_string(),
4130            selection.pack_path.display().to_string(),
4131            "--flow".to_string(),
4132            selection.dispatch.flow_id.clone(),
4133            "--plan".to_string(),
4134            plan_path.display().to_string(),
4135            "--output".to_string(),
4136            deploy_dir.display().to_string(),
4137        ],
4138        runner_env: vec![
4139            (
4140                "GREENTIC_PROVIDER".to_string(),
4141                config.provider.as_str().to_string(),
4142            ),
4143            ("GREENTIC_STRATEGY".to_string(), config.strategy.clone()),
4144            ("GREENTIC_TENANT".to_string(), config.tenant.clone()),
4145            (
4146                "GREENTIC_ENVIRONMENT".to_string(),
4147                config.environment.clone(),
4148            ),
4149            (
4150                "GREENTIC_DEPLOYMENT_CAPABILITY".to_string(),
4151                selection.dispatch.capability.as_str().to_string(),
4152            ),
4153            (
4154                "GREENTIC_DEPLOYMENT_PACK_ID".to_string(),
4155                selection.dispatch.pack_id.clone(),
4156            ),
4157            (
4158                "GREENTIC_DEPLOYMENT_FLOW_ID".to_string(),
4159                selection.dispatch.flow_id.clone(),
4160            ),
4161        ],
4162    }
4163}
4164
4165fn stage_span(stage: &str, config: &DeployerConfig) -> tracing::Span {
4166    let span = info_span!(
4167        "deployment",
4168        stage,
4169        tenant = %config.tenant,
4170        environment = %config.environment,
4171        provider = %config.provider.as_str()
4172    );
4173    span.record("greentic.deployer.provider", config.provider.as_str());
4174    span.record("greentic.deployer.tenant", config.tenant.as_str());
4175    span.record("greentic.deployer.environment", config.environment.as_str());
4176    span
4177}
4178
4179fn install_telemetry_context(stage: &str, config: &DeployerConfig) {
4180    let session = format!("{stage}/{env}", stage = stage, env = config.environment);
4181    let ctx = TelemetryCtx::new(config.tenant.clone())
4182        .with_provider(config.provider.as_str())
4183        .with_session(session);
4184    set_current_telemetry_ctx(ctx);
4185}
4186
4187fn render_plan_output(config: &DeployerConfig, plan: &PlanContext) -> Result<Option<String>> {
4188    match config.output {
4189        OutputFormat::Text => {
4190            let rendered = render_component_summary(plan);
4191            print!("{rendered}");
4192            Ok(Some(rendered))
4193        }
4194        OutputFormat::Json => {
4195            let json = serde_json::to_string_pretty(plan)
4196                .map_err(|err| DeployerError::Other(err.to_string()))?;
4197            println!("{json}");
4198            Ok(Some(json))
4199        }
4200        OutputFormat::Yaml => {
4201            let yaml =
4202                serde_yaml::to_string(plan).map_err(|err| DeployerError::Other(err.to_string()))?;
4203            println!("{yaml}");
4204            Ok(Some(yaml))
4205        }
4206    }
4207}
4208
4209fn render_component_summary(plan: &PlanContext) -> String {
4210    if plan.components.is_empty() {
4211        return "No component role/profile mappings available.\n".to_string();
4212    }
4213
4214    let mut out = format!("Component mappings for target {}:\n", plan.target.as_str());
4215    for component in &plan.components {
4216        out.push_str(&format!(
4217            "- {}: role={} profile={} infra={}",
4218            component.id,
4219            component.role.as_str(),
4220            component.profile.as_str(),
4221            component.infra.summary
4222        ));
4223        out.push('\n');
4224        if !component.infra.resources.is_empty() {
4225            out.push_str(&format!(
4226                "  resources: {}\n",
4227                component.infra.resources.join(", ")
4228            ));
4229        }
4230        if let Some(inference) = &component.inference {
4231            if !inference.warnings.is_empty() {
4232                for warning in &inference.warnings {
4233                    out.push_str(&format!("  warning: {warning}\n"));
4234                }
4235            } else {
4236                out.push_str(&format!("  info: {}\n", inference.source));
4237            }
4238        }
4239    }
4240    out
4241}
4242
4243fn render_contract_summary(
4244    config: &DeployerConfig,
4245    plan: &PlanContext,
4246    capability_contract: Option<&ResolvedCapabilityContract>,
4247) -> Result<Option<String>> {
4248    let rendered = match config.output {
4249        OutputFormat::Text => {
4250            let mut text = format!(
4251                "{} prepared for provider={} strategy={}\n",
4252                config.capability.as_str(),
4253                plan.deployment.provider,
4254                plan.deployment.strategy
4255            );
4256            if let Some(contract) = capability_contract {
4257                text.push_str(&format!("flow_id={}\n", contract.flow_id));
4258                if let Some(schema) = &contract.input_schema {
4259                    text.push_str(&format!("input_schema={}\n", schema.path));
4260                }
4261                if let Some(schema) = &contract.output_schema {
4262                    text.push_str(&format!("output_schema={}\n", schema.path));
4263                }
4264                if let Some(qa_spec) = &contract.qa_spec {
4265                    text.push_str(&format!("qa_spec={}\n", qa_spec.path));
4266                }
4267            }
4268            text
4269        }
4270        OutputFormat::Json => serde_json::to_string_pretty(&serde_json::json!({
4271            "capability": config.capability.as_str(),
4272            "provider": plan.deployment.provider,
4273            "strategy": plan.deployment.strategy,
4274            "contract": capability_contract,
4275        }))
4276        .map_err(|err| DeployerError::Other(err.to_string()))?,
4277        OutputFormat::Yaml => serde_yaml::to_string(&serde_json::json!({
4278            "capability": config.capability.as_str(),
4279            "provider": plan.deployment.provider,
4280            "strategy": plan.deployment.strategy,
4281            "contract": capability_contract,
4282        }))
4283        .map_err(|err| DeployerError::Other(err.to_string()))?,
4284    };
4285    println!("{rendered}");
4286    Ok(Some(rendered))
4287}
4288
4289#[cfg(test)]
4290mod tests {
4291    use super::*;
4292    use crate::config::{DeployerConfig, Provider};
4293    use crate::contract::{
4294        CapabilitySpecV1, DeployerCapability, DeployerContractV1, PlannerSpecV1,
4295        set_deployer_contract_v1,
4296    };
4297    use crate::deployment::{EXECUTOR_TEST_LOCK, clear_deployment_executor};
4298    use greentic_types::cbor::encode_pack_manifest;
4299    use greentic_types::component::{ComponentCapabilities, ComponentManifest, ComponentProfiles};
4300    use greentic_types::flow::{Flow, FlowHasher, FlowKind, FlowMetadata};
4301    use greentic_types::pack_manifest::{PackFlowEntry, PackKind, PackManifest};
4302    use greentic_types::{ComponentId, FlowId, PackId};
4303    use indexmap::IndexMap;
4304    use semver::Version;
4305    use std::path::PathBuf;
4306    use std::str::FromStr;
4307    use tar::Builder;
4308
4309    fn config_for(pack_path: PathBuf, capability: DeployerCapability) -> DeployerConfig {
4310        DeployerConfig {
4311            capability,
4312            provider: Provider::Aws,
4313            strategy: "iac-only".into(),
4314            tenant: "acme".into(),
4315            environment: "staging".into(),
4316            pack_path: Some(pack_path.clone()),
4317            bundle_root: None,
4318            providers_dir: PathBuf::from("providers/deployer"),
4319            packs_dir: PathBuf::from("packs"),
4320            provider_pack: Some(pack_path),
4321            pack_ref: None,
4322            distributor_url: None,
4323            distributor_token: None,
4324            preview: false,
4325            dry_run: false,
4326            execute_local: false,
4327            output: crate::config::OutputFormat::Json,
4328            greentic: greentic_config::ConfigResolver::new()
4329                .load()
4330                .expect("load default config")
4331                .config,
4332            provenance: greentic_config::ProvenanceMap::new(),
4333            config_warnings: Vec::new(),
4334            deploy_pack_id_override: None,
4335            deploy_flow_id_override: None,
4336            bundle_source: None,
4337            bundle_digest: None,
4338            repo_registry_base: None,
4339            store_registry_base: None,
4340        }
4341    }
4342
4343    fn write_test_pack(with_contract: bool) -> PathBuf {
4344        let base = std::env::current_dir()
4345            .expect("cwd")
4346            .join("target/tmp-tests");
4347        std::fs::create_dir_all(&base).expect("create tmp base");
4348        let dir = tempfile::tempdir_in(base).expect("temp dir");
4349        let mut manifest = PackManifest {
4350            agents: Default::default(),
4351            schema_version: "pack-v1".to_string(),
4352            pack_id: PackId::from_str("greentic.deploy.aws").unwrap(),
4353            name: None,
4354            version: Version::new(0, 1, 0),
4355            kind: PackKind::Application,
4356            publisher: "greentic".to_string(),
4357            secret_requirements: Vec::new(),
4358            components: vec![ComponentManifest {
4359                id: ComponentId::from_str("dev.greentic.component").unwrap(),
4360                version: Version::new(0, 1, 0),
4361                supports: Vec::new(),
4362                world: "greentic:test/world".to_string(),
4363                profiles: ComponentProfiles::default(),
4364                capabilities: ComponentCapabilities::default(),
4365                configurators: None,
4366                operations: Vec::new(),
4367                config_schema: None,
4368                resources: Default::default(),
4369                dev_flows: Default::default(),
4370            }],
4371            flows: vec![
4372                flow_entry("deploy_aws_iac"),
4373                flow_entry("plan_pack"),
4374                flow_entry("generate_pack"),
4375                flow_entry("destroy_pack"),
4376                flow_entry("status_pack"),
4377                flow_entry("rollback_pack"),
4378            ],
4379            dependencies: Vec::new(),
4380            capabilities: Vec::new(),
4381            signatures: Default::default(),
4382            bootstrap: None,
4383            extensions: None,
4384        };
4385        if with_contract {
4386            set_deployer_contract_v1(
4387                &mut manifest,
4388                DeployerContractV1 {
4389                    schema_version: 1,
4390                    planner: PlannerSpecV1 {
4391                        flow_id: "plan_pack".into(),
4392                        input_schema_ref: None,
4393                        output_schema_ref: Some("assets/schemas/plan-output.schema.json".into()),
4394                        qa_spec_ref: None,
4395                    },
4396                    capabilities: vec![
4397                        CapabilitySpecV1 {
4398                            capability: DeployerCapability::Plan,
4399                            flow_id: "plan_pack".into(),
4400                            input_schema_ref: None,
4401                            output_schema_ref: Some(
4402                                "assets/schemas/plan-output.schema.json".into(),
4403                            ),
4404                            execution_output_schema_ref: None,
4405                            qa_spec_ref: None,
4406                            example_refs: Vec::new(),
4407                        },
4408                        CapabilitySpecV1 {
4409                            capability: DeployerCapability::Generate,
4410                            flow_id: "generate_pack".into(),
4411                            input_schema_ref: Some(
4412                                "assets/schemas/generate-input.schema.json".into(),
4413                            ),
4414                            output_schema_ref: Some(
4415                                "assets/schemas/generate-output.schema.json".into(),
4416                            ),
4417                            execution_output_schema_ref: None,
4418                            qa_spec_ref: Some("assets/qa/generate.qa.json".into()),
4419                            example_refs: vec!["assets/examples/generate.example.json".into()],
4420                        },
4421                        CapabilitySpecV1 {
4422                            capability: DeployerCapability::Apply,
4423                            flow_id: "deploy_aws_iac".into(),
4424                            input_schema_ref: None,
4425                            output_schema_ref: None,
4426                            execution_output_schema_ref: Some(
4427                                "assets/schemas/apply-execution-output.schema.json".into(),
4428                            ),
4429                            qa_spec_ref: None,
4430                            example_refs: Vec::new(),
4431                        },
4432                        CapabilitySpecV1 {
4433                            capability: DeployerCapability::Destroy,
4434                            flow_id: "destroy_pack".into(),
4435                            input_schema_ref: None,
4436                            output_schema_ref: None,
4437                            execution_output_schema_ref: Some(
4438                                "assets/schemas/destroy-execution-output.schema.json".into(),
4439                            ),
4440                            qa_spec_ref: None,
4441                            example_refs: Vec::new(),
4442                        },
4443                        CapabilitySpecV1 {
4444                            capability: DeployerCapability::Status,
4445                            flow_id: "status_pack".into(),
4446                            input_schema_ref: None,
4447                            output_schema_ref: Some(
4448                                "assets/schemas/status-output.schema.json".into(),
4449                            ),
4450                            execution_output_schema_ref: Some(
4451                                "assets/schemas/status-execution-output.schema.json".into(),
4452                            ),
4453                            qa_spec_ref: None,
4454                            example_refs: Vec::new(),
4455                        },
4456                        CapabilitySpecV1 {
4457                            capability: DeployerCapability::Rollback,
4458                            flow_id: "rollback_pack".into(),
4459                            input_schema_ref: None,
4460                            output_schema_ref: Some(
4461                                "assets/schemas/rollback-output.schema.json".into(),
4462                            ),
4463                            execution_output_schema_ref: None,
4464                            qa_spec_ref: None,
4465                            example_refs: Vec::new(),
4466                        },
4467                    ],
4468                },
4469            )
4470            .unwrap();
4471        }
4472        let encoded = encode_pack_manifest(&manifest).expect("encode manifest");
4473        let mut builder = Builder::new(Vec::new());
4474        let mut header = tar::Header::new_gnu();
4475        header.set_size(encoded.len() as u64);
4476        header.set_mode(0o644);
4477        header.set_cksum();
4478        builder
4479            .append_data(&mut header, "manifest.cbor", encoded.as_slice())
4480            .expect("append manifest");
4481        if with_contract {
4482            append_tar_entry(
4483                &mut builder,
4484                "assets/schemas/plan-output.schema.json",
4485                br#"{"type":"object","required":["kind","plan"],"properties":{"kind":{"const":"plan"},"plan":{"type":"object"}}}"#,
4486            );
4487            append_tar_entry(
4488                &mut builder,
4489                "assets/schemas/generate-input.schema.json",
4490                br#"{"type":"object","properties":{"provider":{"type":"string"}}}"#,
4491            );
4492            append_tar_entry(
4493                &mut builder,
4494                "assets/schemas/generate-output.schema.json",
4495                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"}}}}"#,
4496            );
4497            append_tar_entry(
4498                &mut builder,
4499                "assets/qa/generate.qa.json",
4500                br#"{"questions":[{"id":"provider","kind":"select"}]}"#,
4501            );
4502            append_tar_entry(
4503                &mut builder,
4504                "assets/examples/generate.example.json",
4505                br#"{"provider":"aws","strategy":"iac-only"}"#,
4506            );
4507            append_tar_entry(
4508                &mut builder,
4509                "assets/examples/rendered-manifests.yaml",
4510                br#"apiVersion: v1
4511kind: Namespace
4512metadata:
4513  name: greentic
4514"#,
4515            );
4516            append_tar_entry(
4517                &mut builder,
4518                "assets/schemas/apply-execution-output.schema.json",
4519                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"}}}}"#,
4520            );
4521            append_tar_entry(
4522                &mut builder,
4523                "assets/schemas/destroy-execution-output.schema.json",
4524                br#"{"type":"object","required":["kind","deployment_id","state"],"properties":{"kind":{"const":"destroy"},"deployment_id":{"type":"string"},"state":{"type":"string"}}}"#,
4525            );
4526            append_tar_entry(
4527                &mut builder,
4528                "assets/schemas/status-output.schema.json",
4529                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"}}}"#,
4530            );
4531            append_tar_entry(
4532                &mut builder,
4533                "assets/schemas/status-execution-output.schema.json",
4534                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"}}}}"#,
4535            );
4536            append_tar_entry(
4537                &mut builder,
4538                "assets/schemas/rollback-output.schema.json",
4539                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"}}}"#,
4540            );
4541            append_tar_entry(&mut builder, "terraform/main.tf", br#"module "root" {}"#);
4542            append_tar_entry(
4543                &mut builder,
4544                "terraform/staging.tfvars.example",
4545                br#"dns_name = "acme.example.test""#,
4546            );
4547            append_tar_entry(
4548                &mut builder,
4549                "terraform/modules/operator/main.tf",
4550                br#"module "operator" {}"#,
4551            );
4552            append_tar_entry(
4553                &mut builder,
4554                "terraform/terraform",
4555                br#"#!/usr/bin/env bash
4556set -euo pipefail
4557printf '%s\n' "$*" >> terraform-invocation.args
4558if [ "${1:-}" = "output" ] && [ "${2:-}" = "-json" ]; then
4559cat <<'EOF'
4560{"operator_endpoint":{"value":"http://terraform-output.example.test"}}
4561EOF
4562fi
4563"#,
4564            );
4565            append_tar_entry(
4566                &mut builder,
4567                "chart/Chart.yaml",
4568                br#"apiVersion: v2
4569name: greentic
4570version: 0.1.0
4571"#,
4572            );
4573            append_tar_entry(
4574                &mut builder,
4575                "chart/values.yaml",
4576                br#"image:
4577  repository: ghcr.io/greentic-ai/operator-distroless
4578"#,
4579            );
4580            append_tar_entry(
4581                &mut builder,
4582                "chart/templates/deployment.yaml",
4583                br#"apiVersion: apps/v1
4584kind: Deployment
4585"#,
4586            );
4587        }
4588        let bytes = builder.into_inner().expect("tar bytes");
4589        let path = dir.path().join("sample.gtpack");
4590        std::fs::write(&path, bytes).expect("write pack");
4591        let _persisted = dir.keep();
4592        path
4593    }
4594
4595    fn flow_entry(id: &str) -> PackFlowEntry {
4596        PackFlowEntry {
4597            id: FlowId::from_str(id).unwrap(),
4598            kind: FlowKind::Messaging,
4599            flow: Flow {
4600                schema_version: "flowir-v1".to_string(),
4601                id: FlowId::from_str(id).unwrap(),
4602                kind: FlowKind::Messaging,
4603                entrypoints: Default::default(),
4604                nodes: IndexMap::<_, _, FlowHasher>::default(),
4605                metadata: FlowMetadata::default(),
4606            },
4607            tags: Vec::new(),
4608            entrypoints: Vec::new(),
4609        }
4610    }
4611
4612    fn append_tar_entry(builder: &mut Builder<Vec<u8>>, path: &str, bytes: &[u8]) {
4613        let mut header = tar::Header::new_gnu();
4614        header.set_size(bytes.len() as u64);
4615        header.set_mode(0o644);
4616        header.set_cksum();
4617        builder
4618            .append_data(&mut header, path, bytes)
4619            .expect("append tar entry");
4620    }
4621
4622    #[test]
4623    fn collect_output_files_returns_sorted_files_only() {
4624        let base = std::env::current_dir()
4625            .expect("cwd")
4626            .join("target/tmp-tests");
4627        std::fs::create_dir_all(&base).expect("create tmp base");
4628        let dir = tempfile::tempdir_in(base).expect("temp dir");
4629        std::fs::write(dir.path().join("b.txt"), "b").expect("write b");
4630        std::fs::write(dir.path().join("a.txt"), "a").expect("write a");
4631        std::fs::create_dir(dir.path().join("nested")).expect("create nested dir");
4632
4633        let files = collect_output_files(dir.path());
4634        assert_eq!(files, vec!["a.txt".to_string(), "b.txt".to_string()]);
4635    }
4636
4637    #[test]
4638    fn build_execution_report_merges_executor_payload() {
4639        let base = std::env::current_dir()
4640            .expect("cwd")
4641            .join("target/tmp-tests");
4642        std::fs::create_dir_all(&base).expect("create tmp base");
4643        let dir = tempfile::tempdir_in(base).expect("temp dir");
4644
4645        let deploy_dir = dir.path().join("deploy");
4646        std::fs::create_dir_all(&deploy_dir).expect("create deploy dir");
4647        std::fs::write(deploy_dir.join("local.json"), "{}").expect("write local file");
4648        let plan_path = dir.path().join("plan.json");
4649        let invoke_path = dir.path().join("invoke.json");
4650        let handoff_path = deploy_dir.join("._deployer_invocation.json");
4651        let runner_command_path = deploy_dir.join("._runner_cmd.txt");
4652        std::fs::write(&plan_path, "{}").expect("write plan");
4653        std::fs::write(&invoke_path, "{}").expect("write invoke");
4654        std::fs::write(&handoff_path, "{}").expect("write handoff");
4655        std::fs::write(&runner_command_path, "cmd").expect("write runner cmd");
4656
4657        let runtime_artifacts = RuntimeArtifacts {
4658            deploy_dir: deploy_dir.clone(),
4659            plan: plan_path,
4660            invoke: invoke_path,
4661            handoff: DeployerInvocation {
4662                capability: "apply".into(),
4663                pack_id: "greentic.deploy.aws".into(),
4664                handler_id: "builtin.aws".into(),
4665                flow_id: "deploy_aws_iac".into(),
4666                pack_path: "/tmp/sample.gtpack".into(),
4667                output_dir: deploy_dir.display().to_string(),
4668                runner_cmd: vec!["greentic-runner".into()],
4669                runner_env: vec![("GREENTIC_DEPLOYMENT_CAPABILITY".into(), "apply".into())],
4670            },
4671            handoff_path,
4672            runner_command_path,
4673        };
4674        let capability_contract = ResolvedCapabilityContract {
4675            capability: DeployerCapability::Apply,
4676            flow_id: "deploy_aws_iac".into(),
4677            input_schema: None,
4678            output_schema: None,
4679            execution_output_schema: Some(crate::contract::ContractAsset {
4680                path: "assets/schemas/apply-execution-output.schema.json".into(),
4681                json: Some(serde_json::json!({
4682                    "type": "object",
4683                    "required": ["kind", "deployment_id", "state", "endpoints"],
4684                    "properties": {
4685                        "kind": { "const": "apply" },
4686                        "deployment_id": { "type": "string" },
4687                        "state": { "type": "string" },
4688                        "provider": { "type": "string" },
4689                        "strategy": { "type": "string" },
4690                        "endpoints": { "type": "array", "items": { "type": "string" } },
4691                        "output_refs": {
4692                            "type": "object",
4693                            "additionalProperties": { "type": "string" }
4694                        }
4695                    }
4696                })),
4697                text: None,
4698                size_bytes: 0,
4699            }),
4700            qa_spec: None,
4701            examples: Vec::new(),
4702        };
4703
4704        let report = build_execution_report(
4705            "builtin.aws",
4706            &runtime_artifacts,
4707            Some(&capability_contract),
4708            Some(ExecutionOutcome {
4709                status: Some("applied".into()),
4710                message: Some("ok".into()),
4711                output_files: vec!["remote.json".into()],
4712                payload: Some(ExecutionOutcomePayload::Apply(
4713                    crate::deployment::ApplyExecutionOutcome {
4714                        deployment_id: "dep-42".into(),
4715                        state: "ready".into(),
4716                        provider: Some("aws".into()),
4717                        strategy: Some("iac-only".into()),
4718                        endpoints: vec!["https://ready.example.test".into()],
4719                        output_refs: BTreeMap::new(),
4720                    },
4721                )),
4722            }),
4723        );
4724
4725        assert_eq!(report.status.as_deref(), Some("applied"));
4726        assert_eq!(report.message.as_deref(), Some("ok"));
4727        assert_eq!(
4728            report.output_files,
4729            vec![
4730                "._deployer_invocation.json".to_string(),
4731                "._runner_cmd.txt".to_string(),
4732                "local.json".to_string(),
4733                "remote.json".to_string()
4734            ]
4735        );
4736        match report.outcome_payload.expect("outcome payload") {
4737            ExecutionOutcomePayload::Apply(payload) => {
4738                assert_eq!(payload.deployment_id, "dep-42");
4739                assert_eq!(payload.state, "ready");
4740            }
4741            other => panic!("unexpected outcome payload: {:?}", other),
4742        }
4743        assert!(
4744            report
4745                .outcome_validation
4746                .as_ref()
4747                .expect("validation")
4748                .valid
4749        );
4750    }
4751
4752    #[test]
4753    fn build_execution_report_validates_destroy_outcome_payload() {
4754        let base = std::env::current_dir()
4755            .expect("cwd")
4756            .join("target/tmp-tests");
4757        std::fs::create_dir_all(&base).expect("create tmp base");
4758        let dir = tempfile::tempdir_in(base).expect("temp dir");
4759
4760        let deploy_dir = dir.path().join("deploy");
4761        std::fs::create_dir_all(&deploy_dir).expect("create deploy dir");
4762        let runtime_artifacts = RuntimeArtifacts {
4763            deploy_dir: deploy_dir.clone(),
4764            plan: dir.path().join("plan.json"),
4765            invoke: dir.path().join("invoke.json"),
4766            handoff: DeployerInvocation {
4767                capability: "destroy".into(),
4768                pack_id: "greentic.deploy.aws".into(),
4769                handler_id: "builtin.aws".into(),
4770                flow_id: "destroy_pack".into(),
4771                pack_path: "/tmp/sample.gtpack".into(),
4772                output_dir: deploy_dir.display().to_string(),
4773                runner_cmd: vec!["greentic-runner".into()],
4774                runner_env: vec![("GREENTIC_DEPLOYMENT_CAPABILITY".into(), "destroy".into())],
4775            },
4776            handoff_path: deploy_dir.join("._deployer_invocation.json"),
4777            runner_command_path: deploy_dir.join("._runner_cmd.txt"),
4778        };
4779        let capability_contract = ResolvedCapabilityContract {
4780            capability: DeployerCapability::Destroy,
4781            flow_id: "destroy_pack".into(),
4782            input_schema: None,
4783            output_schema: None,
4784            execution_output_schema: Some(crate::contract::ContractAsset {
4785                path: "assets/schemas/destroy-execution-output.schema.json".into(),
4786                json: Some(serde_json::json!({
4787                    "type": "object",
4788                    "required": ["kind", "deployment_id", "state", "destroyed_resources"],
4789                    "properties": {
4790                        "kind": { "const": "destroy" },
4791                        "deployment_id": { "type": "string" },
4792                        "state": { "type": "string" },
4793                        "destroyed_resources": {
4794                            "type": "array",
4795                            "items": { "type": "string" }
4796                        }
4797                    }
4798                })),
4799                text: None,
4800                size_bytes: 0,
4801            }),
4802            qa_spec: None,
4803            examples: Vec::new(),
4804        };
4805
4806        let report = build_execution_report(
4807            "builtin.aws",
4808            &runtime_artifacts,
4809            Some(&capability_contract),
4810            Some(ExecutionOutcome {
4811                status: Some("destroyed".into()),
4812                message: None,
4813                output_files: Vec::new(),
4814                payload: Some(ExecutionOutcomePayload::Destroy(
4815                    crate::deployment::DestroyExecutionOutcome {
4816                        deployment_id: "dep-42".into(),
4817                        state: "deleted".into(),
4818                        destroyed_resources: Vec::new(),
4819                    },
4820                )),
4821            }),
4822        );
4823
4824        assert!(
4825            report
4826                .outcome_validation
4827                .as_ref()
4828                .expect("validation")
4829                .valid
4830        );
4831    }
4832
4833    #[test]
4834    fn build_execution_report_validates_status_outcome_payload() {
4835        let base = std::env::current_dir()
4836            .expect("cwd")
4837            .join("target/tmp-tests");
4838        std::fs::create_dir_all(&base).expect("create tmp base");
4839        let dir = tempfile::tempdir_in(base).expect("temp dir");
4840
4841        let deploy_dir = dir.path().join("deploy");
4842        std::fs::create_dir_all(&deploy_dir).expect("create deploy dir");
4843        let runtime_artifacts = RuntimeArtifacts {
4844            deploy_dir: deploy_dir.clone(),
4845            plan: dir.path().join("plan.json"),
4846            invoke: dir.path().join("invoke.json"),
4847            handoff: DeployerInvocation {
4848                capability: "status".into(),
4849                pack_id: "greentic.deploy.aws".into(),
4850                handler_id: "builtin.aws".into(),
4851                flow_id: "status_pack".into(),
4852                pack_path: "/tmp/sample.gtpack".into(),
4853                output_dir: deploy_dir.display().to_string(),
4854                runner_cmd: vec!["greentic-runner".into()],
4855                runner_env: vec![("GREENTIC_DEPLOYMENT_CAPABILITY".into(), "status".into())],
4856            },
4857            handoff_path: deploy_dir.join("._deployer_invocation.json"),
4858            runner_command_path: deploy_dir.join("._runner_cmd.txt"),
4859        };
4860        let capability_contract = ResolvedCapabilityContract {
4861            capability: DeployerCapability::Status,
4862            flow_id: "status_pack".into(),
4863            input_schema: None,
4864            output_schema: Some(crate::contract::ContractAsset {
4865                path: "assets/schemas/status-output.schema.json".into(),
4866                json: None,
4867                text: None,
4868                size_bytes: 0,
4869            }),
4870            execution_output_schema: Some(crate::contract::ContractAsset {
4871                path: "assets/schemas/status-execution-output.schema.json".into(),
4872                json: Some(serde_json::json!({
4873                    "type": "object",
4874                    "required": ["kind", "deployment_id", "state", "health_checks"],
4875                    "properties": {
4876                        "kind": { "const": "status" },
4877                        "deployment_id": { "type": "string" },
4878                        "state": { "type": "string" },
4879                        "provider": { "type": "string" },
4880                        "strategy": { "type": "string" },
4881                        "status_source": { "type": "string" },
4882                        "endpoints": { "type": "array", "items": { "type": "string" } },
4883                        "health_checks": { "type": "array", "items": { "type": "string" } },
4884                        "output_refs": {
4885                            "type": "object",
4886                            "additionalProperties": { "type": "string" }
4887                        }
4888                    }
4889                })),
4890                text: None,
4891                size_bytes: 0,
4892            }),
4893            qa_spec: None,
4894            examples: Vec::new(),
4895        };
4896
4897        let report = build_execution_report(
4898            "builtin.aws",
4899            &runtime_artifacts,
4900            Some(&capability_contract),
4901            Some(ExecutionOutcome {
4902                status: Some("ready".into()),
4903                message: None,
4904                output_files: Vec::new(),
4905                payload: Some(ExecutionOutcomePayload::Status(
4906                    crate::deployment::StatusExecutionOutcome {
4907                        deployment_id: "dep-42".into(),
4908                        state: "healthy".into(),
4909                        provider: Some("aws".into()),
4910                        strategy: Some("iac-only".into()),
4911                        status_source: Some("terraform_handoff".into()),
4912                        endpoints: vec!["https://ready.example.test".into()],
4913                        health_checks: vec!["http:ok".into()],
4914                        output_refs: BTreeMap::new(),
4915                    },
4916                )),
4917            }),
4918        );
4919
4920        assert!(
4921            report
4922                .outcome_validation
4923                .as_ref()
4924                .expect("validation")
4925                .valid
4926        );
4927    }
4928
4929    #[tokio::test]
4930    async fn plan_result_contains_typed_payload() {
4931        let _guard = EXECUTOR_TEST_LOCK.lock().await;
4932        clear_deployment_executor();
4933        let pack_path = write_test_pack(true);
4934        let result = run(config_for(pack_path, DeployerCapability::Plan))
4935            .await
4936            .expect("plan runs");
4937        match result.payload.expect("payload") {
4938            OperationPayload::Plan(payload) => {
4939                assert_eq!(payload.plan.plan.tenant, "acme");
4940            }
4941            other => panic!("unexpected payload: {:?}", other),
4942        }
4943        assert!(result.output_validation.as_ref().expect("validation").valid);
4944    }
4945
4946    #[tokio::test]
4947    async fn terraform_status_synthesizes_local_execution_outcome() {
4948        let _guard = EXECUTOR_TEST_LOCK.lock().await;
4949        clear_deployment_executor();
4950        let base = std::env::current_dir()
4951            .expect("cwd")
4952            .join("target/tmp-tests");
4953        std::fs::create_dir_all(&base).expect("create tmp base");
4954        let dir = tempfile::tempdir_in(base).expect("temp dir");
4955        let pack_path = write_test_pack(true);
4956
4957        let mut greentic = greentic_config::ConfigResolver::new()
4958            .load()
4959            .expect("load default config")
4960            .config;
4961        greentic.paths.state_dir = dir.path().join(".greentic-state");
4962
4963        let result = run(DeployerConfig {
4964            capability: DeployerCapability::Status,
4965            provider: Provider::Generic,
4966            strategy: "terraform".into(),
4967            tenant: "acme".into(),
4968            environment: "staging".into(),
4969            pack_path: Some(pack_path.clone()),
4970            bundle_root: None,
4971            providers_dir: PathBuf::from("providers/deployer"),
4972            packs_dir: PathBuf::from("packs"),
4973            provider_pack: Some(pack_path),
4974            pack_ref: None,
4975            distributor_url: None,
4976            distributor_token: None,
4977            preview: false,
4978            dry_run: false,
4979            execute_local: false,
4980            output: crate::config::OutputFormat::Text,
4981            greentic,
4982            provenance: greentic_config::ProvenanceMap::new(),
4983            config_warnings: Vec::new(),
4984            deploy_pack_id_override: None,
4985            deploy_flow_id_override: None,
4986            bundle_source: None,
4987            bundle_digest: None,
4988            repo_registry_base: None,
4989            store_registry_base: None,
4990        })
4991        .await
4992        .expect("terraform status runs");
4993
4994        assert!(result.executed);
4995        assert_eq!(result.capability, "status");
4996        let execution = result.execution.expect("execution report");
4997        assert_eq!(execution.status.as_deref(), Some("handoff_ready"));
4998        match execution.outcome_payload.expect("outcome payload") {
4999            ExecutionOutcomePayload::Status(payload) => {
5000                assert_eq!(payload.state, "handoff_ready");
5001                assert!(
5002                    payload
5003                        .health_checks
5004                        .iter()
5005                        .any(|entry| entry == "terraform_root:present")
5006                );
5007                assert!(
5008                    payload
5009                        .health_checks
5010                        .iter()
5011                        .any(|entry| entry == "script:terraform-status.sh:present")
5012                );
5013            }
5014            other => panic!("unexpected outcome payload: {:?}", other),
5015        }
5016    }
5017
5018    #[tokio::test]
5019    async fn terraform_apply_execute_runs_local_script_via_fake_terraform() {
5020        let _guard = EXECUTOR_TEST_LOCK.lock().await;
5021        clear_deployment_executor();
5022        let base = std::env::current_dir()
5023            .expect("cwd")
5024            .join("target/tmp-tests");
5025        std::fs::create_dir_all(&base).expect("create tmp base");
5026        let dir = tempfile::tempdir_in(&base).expect("temp dir");
5027        let pack_path = write_test_pack(true);
5028        let mut greentic = greentic_config::ConfigResolver::new()
5029            .load()
5030            .expect("load default config")
5031            .config;
5032        greentic.paths.state_dir = dir.path().join(".greentic-state");
5033
5034        let result = run(DeployerConfig {
5035            capability: DeployerCapability::Apply,
5036            provider: Provider::Generic,
5037            strategy: "terraform".into(),
5038            tenant: "acme".into(),
5039            environment: "staging".into(),
5040            pack_path: Some(pack_path.clone()),
5041            bundle_root: None,
5042            providers_dir: PathBuf::from("providers/deployer"),
5043            packs_dir: PathBuf::from("packs"),
5044            provider_pack: Some(pack_path),
5045            pack_ref: None,
5046            distributor_url: None,
5047            distributor_token: None,
5048            preview: false,
5049            dry_run: false,
5050            execute_local: true,
5051            output: crate::config::OutputFormat::Text,
5052            greentic,
5053            provenance: greentic_config::ProvenanceMap::new(),
5054            config_warnings: Vec::new(),
5055            deploy_pack_id_override: None,
5056            deploy_flow_id_override: None,
5057            bundle_source: Some("file:///tmp/apply-test.gtbundle".into()),
5058            bundle_digest: Some(
5059                "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
5060            ),
5061            repo_registry_base: None,
5062            store_registry_base: None,
5063        })
5064        .await
5065        .expect("terraform apply runs");
5066        assert!(result.executed);
5067        let execution = result.execution.expect("execution report");
5068        assert_eq!(execution.status.as_deref(), Some("applied"));
5069        match execution.outcome_payload.expect("outcome payload") {
5070            ExecutionOutcomePayload::Apply(payload) => {
5071                assert_eq!(
5072                    payload.endpoints,
5073                    vec!["http://terraform-output.example.test"]
5074                );
5075            }
5076            other => panic!("unexpected outcome payload: {:?}", other),
5077        }
5078        assert!(
5079            execution
5080                .output_files
5081                .iter()
5082                .any(|entry| entry == "terraform-apply.stdout.log")
5083        );
5084        let applied_args = std::fs::read_to_string(
5085            Path::new(&result.output_dir)
5086                .join("terraform")
5087                .join("terraform-invocation.args"),
5088        )
5089        .expect("read fake terraform args");
5090        assert!(applied_args.contains("apply -auto-approve -input=false"));
5091        assert!(applied_args.contains("-var-file=staging.tfvars"));
5092        assert!(applied_args.contains("output -json"));
5093    }
5094
5095    #[test]
5096    fn parse_dns_name_endpoint_extracts_https_endpoint() {
5097        let endpoint = parse_dns_name_endpoint(
5098            r#"
5099            dns_name = "acme.example.test"
5100            "#,
5101        );
5102        assert_eq!(endpoint.as_deref(), Some("https://acme.example.test"));
5103    }
5104
5105    #[test]
5106    fn persist_runtime_artifacts_materializes_terraform_handoff_assets() {
5107        let base = std::env::current_dir()
5108            .expect("cwd")
5109            .join("target/tmp-tests");
5110        std::fs::create_dir_all(&base).expect("create tmp base");
5111        let dir = tempfile::tempdir_in(base).expect("temp dir");
5112        let pack_path = write_test_pack(true);
5113
5114        let mut greentic = greentic_config::ConfigResolver::new()
5115            .load()
5116            .expect("load default config")
5117            .config;
5118        greentic.paths.state_dir = dir.path().join(".greentic-state");
5119
5120        let config = DeployerConfig {
5121            capability: DeployerCapability::Plan,
5122            provider: Provider::Generic,
5123            strategy: "terraform".into(),
5124            tenant: "acme".into(),
5125            environment: "staging".into(),
5126            pack_path: Some(pack_path.clone()),
5127            bundle_root: None,
5128            providers_dir: PathBuf::from("providers/deployer"),
5129            packs_dir: PathBuf::from("packs"),
5130            provider_pack: Some(pack_path.clone()),
5131            pack_ref: None,
5132            distributor_url: None,
5133            distributor_token: None,
5134            preview: false,
5135            dry_run: false,
5136            execute_local: false,
5137            output: crate::config::OutputFormat::Json,
5138            greentic,
5139            provenance: greentic_config::ProvenanceMap::new(),
5140            config_warnings: Vec::new(),
5141            deploy_pack_id_override: None,
5142            deploy_flow_id_override: None,
5143            bundle_source: Some("file:///tmp/demo.gtbundle".into()),
5144            bundle_digest: Some(
5145                "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into(),
5146            ),
5147            repo_registry_base: None,
5148            store_registry_base: None,
5149        };
5150        let plan = pack_introspect::build_plan(&config).expect("build plan");
5151        let deploy_dir = dir.path().join("output");
5152        std::fs::create_dir_all(&deploy_dir).expect("create output dir");
5153        let selection = DeploymentPackSelection {
5154            dispatch: crate::deployment::DeploymentDispatch {
5155                capability: DeployerCapability::Plan,
5156                pack_id: "greentic.deploy.terraform".into(),
5157                flow_id: "plan_terraform".into(),
5158                handler_id: "builtin.terraform".into(),
5159            },
5160            pack_path,
5161            manifest: PackManifest {
5162                agents: Default::default(),
5163                schema_version: "pack-v1".to_string(),
5164                pack_id: PackId::from_str("greentic.deploy.terraform").unwrap(),
5165                name: None,
5166                version: Version::new(0, 1, 0),
5167                kind: PackKind::Application,
5168                publisher: "greentic".to_string(),
5169                secret_requirements: Vec::new(),
5170                components: Vec::new(),
5171                flows: Vec::new(),
5172                dependencies: Vec::new(),
5173                capabilities: Vec::new(),
5174                signatures: Default::default(),
5175                bootstrap: None,
5176                extensions: None,
5177            },
5178            origin: "test".into(),
5179            candidates: Vec::new(),
5180        };
5181
5182        let artifacts = persist_runtime_artifacts(&config, &plan, &selection, &deploy_dir)
5183            .expect("persist runtime artifacts");
5184
5185        assert!(artifacts.deploy_dir.join("terraform/main.tf").exists());
5186        assert!(
5187            artifacts
5188                .deploy_dir
5189                .join("terraform/modules/operator/main.tf")
5190                .exists()
5191        );
5192        assert!(artifacts.deploy_dir.join("terraform-init.sh").exists());
5193        assert!(artifacts.deploy_dir.join("terraform-plan.sh").exists());
5194        assert!(artifacts.deploy_dir.join("terraform-apply.sh").exists());
5195        assert!(artifacts.deploy_dir.join("terraform-destroy.sh").exists());
5196        assert!(artifacts.deploy_dir.join("terraform-status.sh").exists());
5197        let metadata: TerraformRuntimeMetadata = serde_json::from_slice(
5198            &std::fs::read(artifacts.deploy_dir.join("terraform-runtime.json"))
5199                .expect("read terraform runtime metadata"),
5200        )
5201        .expect("parse terraform runtime metadata");
5202        assert_eq!(
5203            metadata.scripts,
5204            vec![
5205                "terraform-init.sh".to_string(),
5206                "terraform-plan.sh".to_string(),
5207                "terraform-apply.sh".to_string(),
5208                "terraform-destroy.sh".to_string(),
5209                "terraform-status.sh".to_string()
5210            ]
5211        );
5212        assert_eq!(metadata.generated_tfvars.as_deref(), Some("staging.tfvars"));
5213        assert_eq!(metadata.status_command, "./terraform-status.sh");
5214        let note = std::fs::read_to_string(artifacts.deploy_dir.join("terraform-handoff.txt"))
5215            .expect("read terraform handoff note");
5216        assert!(note.contains("terraform_root="));
5217        assert!(note.contains("generated_tfvars="));
5218        assert!(note.contains("copied_files:"));
5219        assert!(note.contains("modules/operator/main.tf"));
5220        assert!(note.contains("status_command=./terraform-status.sh"));
5221    }
5222
5223    #[test]
5224    fn secrets_provider_binding_maps_targets_to_provider_packs() {
5225        let pack_path = PathBuf::from("/tmp/provider.gtpack");
5226        let mut config = config_for(pack_path, DeployerCapability::Apply);
5227        config.tenant = "demo".into();
5228        config.environment = "dev".into();
5229
5230        let cases = [
5231            (
5232                Provider::Aws,
5233                "greentic.secrets.aws-sm",
5234                "providers/secrets/aws-sm.gtpack",
5235            ),
5236            (
5237                Provider::Gcp,
5238                "greentic.secrets.gcp-sm",
5239                "providers/secrets/gcp-sm.gtpack",
5240            ),
5241            (
5242                Provider::Azure,
5243                "greentic.secrets.azure-kv",
5244                "providers/secrets/azure-kv.gtpack",
5245            ),
5246            (
5247                Provider::Local,
5248                "greentic.secrets.dev",
5249                "providers/secrets/dev.gtpack",
5250            ),
5251        ];
5252
5253        for (provider, provider_id, pack) in cases {
5254            config.provider = provider;
5255            let binding = secrets_provider_binding_for_target(&config).expect("binding for target");
5256            assert_eq!(
5257                binding.schema_version,
5258                SECRETS_PROVIDER_BINDING_SCHEMA_VERSION
5259            );
5260            assert_eq!(binding.provider_id, provider_id);
5261            assert_eq!(binding.pack, pack);
5262            assert_eq!(
5263                binding.config.get("environment").map(String::as_str),
5264                Some("dev")
5265            );
5266            assert_eq!(
5267                binding.config.get("tenant").map(String::as_str),
5268                Some("demo")
5269            );
5270            assert_eq!(binding.config.get("team").map(String::as_str), Some("_"));
5271            assert_eq!(
5272                binding.config.get("namespace_prefix").map(String::as_str),
5273                Some("greentic/dev/demo/_")
5274            );
5275            assert_eq!(
5276                binding.config.get("prefix").map(String::as_str),
5277                Some("greentic/dev/demo/_")
5278            );
5279        }
5280
5281        config.provider = Provider::Generic;
5282        assert!(secrets_provider_binding_for_target(&config).is_none());
5283    }
5284
5285    #[test]
5286    fn persist_runtime_artifacts_materializes_aws_secrets_provider_binding() {
5287        let base = std::env::current_dir()
5288            .expect("cwd")
5289            .join("target/tmp-tests");
5290        std::fs::create_dir_all(&base).expect("create tmp base");
5291        let dir = tempfile::tempdir_in(base).expect("temp dir");
5292        let pack_path = write_test_pack(true);
5293
5294        let mut greentic = greentic_config::ConfigResolver::new()
5295            .load()
5296            .expect("load default config")
5297            .config;
5298        greentic.paths.state_dir = dir.path().join(".greentic-state");
5299        let bundle_root_dir = dir.path().join("bundle");
5300        std::fs::create_dir_all(&bundle_root_dir).expect("create bundle root");
5301
5302        let config = DeployerConfig {
5303            capability: DeployerCapability::Plan,
5304            provider: Provider::Aws,
5305            strategy: "iac-only".into(),
5306            tenant: "demo".into(),
5307            environment: "dev".into(),
5308            pack_path: Some(pack_path.clone()),
5309            bundle_root: Some(bundle_root_dir.clone()),
5310            providers_dir: PathBuf::from("providers/deployer"),
5311            packs_dir: PathBuf::from("packs"),
5312            provider_pack: Some(pack_path.clone()),
5313            pack_ref: None,
5314            distributor_url: None,
5315            distributor_token: None,
5316            preview: false,
5317            dry_run: false,
5318            execute_local: false,
5319            output: crate::config::OutputFormat::Json,
5320            greentic,
5321            provenance: greentic_config::ProvenanceMap::new(),
5322            config_warnings: Vec::new(),
5323            deploy_pack_id_override: None,
5324            deploy_flow_id_override: None,
5325            bundle_source: Some("s3://bucket/bundle.gtbundle".into()),
5326            bundle_digest: Some(
5327                "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into(),
5328            ),
5329            repo_registry_base: None,
5330            store_registry_base: None,
5331        };
5332        let plan = pack_introspect::build_plan(&config).expect("build plan");
5333        let deploy_dir = dir.path().join("output");
5334        std::fs::create_dir_all(&deploy_dir).expect("create output dir");
5335        let selection = DeploymentPackSelection {
5336            dispatch: crate::deployment::DeploymentDispatch {
5337                capability: DeployerCapability::Plan,
5338                pack_id: "greentic.deploy.aws".into(),
5339                flow_id: "plan_pack".into(),
5340                handler_id: "builtin.aws".into(),
5341            },
5342            pack_path,
5343            manifest: PackManifest {
5344                schema_version: "pack-v1".to_string(),
5345                pack_id: PackId::from_str("greentic.deploy.aws").unwrap(),
5346                name: None,
5347                version: Version::new(0, 1, 0),
5348                kind: PackKind::Application,
5349                publisher: "greentic".to_string(),
5350                secret_requirements: Vec::new(),
5351                components: Vec::new(),
5352                flows: Vec::new(),
5353                dependencies: Vec::new(),
5354                capabilities: Vec::new(),
5355                signatures: Default::default(),
5356                bootstrap: None,
5357                extensions: None,
5358                agents: Default::default(),
5359            },
5360            origin: "test".into(),
5361            candidates: Vec::new(),
5362        };
5363
5364        let artifacts = persist_runtime_artifacts(&config, &plan, &selection, &deploy_dir)
5365            .expect("persist runtime artifacts");
5366        let binding_path = artifacts
5367            .deploy_dir
5368            .join(SECRETS_PROVIDER_BINDING_RELATIVE_PATH);
5369        let binding: SecretsProviderBinding = serde_json::from_slice(
5370            &std::fs::read(&binding_path).expect("read secrets provider binding"),
5371        )
5372        .expect("parse secrets provider binding");
5373
5374        assert_eq!(binding.schema_version, "greentic.secrets.binding.v1");
5375        assert_eq!(binding.provider_id, "greentic.secrets.aws-sm");
5376        assert_eq!(binding.pack, "providers/secrets/aws-sm.gtpack");
5377        assert_eq!(
5378            binding.config.get("namespace_prefix").map(String::as_str),
5379            Some("greentic/dev/demo/_")
5380        );
5381        let metadata: TerraformRuntimeMetadata = serde_json::from_slice(
5382            &std::fs::read(artifacts.deploy_dir.join("terraform-runtime.json"))
5383                .expect("read terraform runtime metadata"),
5384        )
5385        .expect("parse terraform runtime metadata");
5386        assert_eq!(
5387            metadata.secrets_provider_binding.as_deref(),
5388            Some(SECRETS_PROVIDER_BINDING_RELATIVE_PATH)
5389        );
5390        let note = std::fs::read_to_string(artifacts.deploy_dir.join("terraform-handoff.txt"))
5391            .expect("read terraform handoff note");
5392        assert!(note.contains("secrets_provider_binding="));
5393
5394        // The binding must ALSO land in the bundle root, so the rebuilt
5395        // .gtbundle artifact the worker loads contains it — not only the deploy
5396        // output. Regression for the cloud secrets-binding handoff gap (#311).
5397        let bundle_binding: SecretsProviderBinding = serde_json::from_slice(
5398            &std::fs::read(bundle_root_dir.join(SECRETS_PROVIDER_BINDING_RELATIVE_PATH))
5399                .expect("read bundle-root secrets provider binding"),
5400        )
5401        .expect("parse bundle-root secrets provider binding");
5402        assert_eq!(bundle_binding.provider_id, "greentic.secrets.aws-sm");
5403        assert_eq!(bundle_binding.pack, "providers/secrets/aws-sm.gtpack");
5404    }
5405
5406    #[test]
5407    fn terraform_apply_script_for_azure_imports_existing_resources() {
5408        let rendered = terraform_plan_like_script(
5409            "apply",
5410            Provider::Azure,
5411            Some("dev.tfvars"),
5412            "staging.tfvars.example",
5413        );
5414        assert!(rendered.contains("az keyvault secret show"));
5415        assert!(rendered.contains("azurerm_container_app_environment.this"));
5416        assert!(rendered.contains("azurerm_container_app.this"));
5417        assert!(rendered.contains("module.operator_azure[0]"));
5418        assert!(rendered.contains("module.operator"));
5419        assert!(rendered.contains("import -input=false"));
5420    }
5421
5422    #[test]
5423    fn terraform_apply_script_for_gcp_imports_existing_secrets() {
5424        let rendered = terraform_plan_like_script(
5425            "apply",
5426            Provider::Gcp,
5427            Some("dev.tfvars"),
5428            "staging.tfvars.example",
5429        );
5430        assert!(rendered.contains("gcloud secrets describe"));
5431        assert!(rendered.contains("google_secret_manager_secret.admin_ca"));
5432        assert!(rendered.contains("google_secret_manager_secret.admin_server_cert"));
5433        assert!(rendered.contains("google_secret_manager_secret.admin_server_key"));
5434        assert!(rendered.contains("gcloud run services describe"));
5435        assert!(rendered.contains("google_cloud_run_v2_service.this"));
5436        assert!(rendered.contains("module.operator_gcp[0]"));
5437        assert!(rendered.contains("import -input=false"));
5438        assert!(rendered.contains("GCP apply hit transient condition"));
5439    }
5440
5441    #[test]
5442    fn normalize_terraform_main_tf_for_gcp_respects_operator_image_override() {
5443        let dir = tempfile::tempdir().expect("tempdir");
5444        let terraform_root = dir.path().join("terraform");
5445        std::fs::create_dir_all(&terraform_root).expect("terraform root");
5446        let main_tf = terraform_root.join("main.tf");
5447        std::fs::write(
5448            &main_tf,
5449            r#"
5450module "operator" {
5451  source = "./modules/operator-gcp"
5452  operator_image        = "ghcr.io/greenticai/greentic-start-distroless@${var.operator_image_digest}"
5453}
5454"#,
5455        )
5456        .expect("write main.tf");
5457
5458        let config = DeployerConfig {
5459            provider: Provider::Gcp,
5460            ..config_for(terraform_root.clone(), DeployerCapability::Apply)
5461        };
5462        normalize_terraform_main_tf(&config, &terraform_root).expect("normalize main.tf");
5463
5464        let rendered = std::fs::read_to_string(main_tf).expect("read main.tf");
5465        assert!(rendered.contains(
5466            r#"operator_image        = var.operator_image != "" ? var.operator_image : "ghcr.io/greenticai/greentic-start-distroless@${var.operator_image_digest}""#
5467        ));
5468    }
5469
5470    #[test]
5471    fn normalize_terraform_main_tf_for_gcp_switches_operator_module_to_direct_pem_envs() {
5472        let dir = tempfile::tempdir().expect("tempdir");
5473        let terraform_root = dir.path().join("terraform");
5474        let module_root = terraform_root.join("modules/operator-gcp");
5475        std::fs::create_dir_all(&module_root).expect("module root");
5476        let main_tf = terraform_root.join("main.tf");
5477        std::fs::write(
5478            &main_tf,
5479            r#"
5480module "operator" {
5481  source = "./modules/operator-gcp"
5482  operator_image        = var.operator_image != "" ? var.operator_image : "ghcr.io/greenticai/greentic-start-distroless@${var.operator_image_digest}"
5483}
5484"#,
5485        )
5486        .expect("write main.tf");
5487        let module_main_tf = module_root.join("main.tf");
5488        std::fs::write(
5489            &module_main_tf,
5490            r#"
5491resource "google_secret_manager_secret_version" "admin_server_key" {
5492  secret      = google_secret_manager_secret.admin_server_key.id
5493  secret_data = tls_private_key.admin_server.private_key_pem
5494}
5495
5496resource "google_cloud_run_v2_service" "this" {
5497  name     = local.service_name
5498  location = var.gcp_region
5499  project  = var.gcp_project_id
5500  ingress  = "INGRESS_TRAFFIC_ALL"
5501
5502  template {
5503    scaling {
5504      min_instance_count = 1
5505      max_instance_count = 1
5506    }
5507
5508    env {
5509        name  = "GREENTIC_ADMIN_CA_SECRET_REF"
5510        value = google_secret_manager_secret.admin_ca.id
5511    }
5512
5513    env {
5514        name  = "GREENTIC_ADMIN_SERVER_CERT_SECRET_REF"
5515        value = google_secret_manager_secret.admin_server_cert.id
5516    }
5517
5518    env {
5519        name  = "GREENTIC_ADMIN_SERVER_KEY_SECRET_REF"
5520        value = google_secret_manager_secret.admin_server_key.id
5521    }
5522  }
5523}
5524"#,
5525        )
5526        .expect("write module main.tf");
5527
5528        let config = DeployerConfig {
5529            provider: Provider::Gcp,
5530            ..config_for(terraform_root.clone(), DeployerCapability::Apply)
5531        };
5532        normalize_terraform_main_tf(&config, &terraform_root).expect("normalize main.tf");
5533
5534        let rendered = std::fs::read_to_string(module_main_tf).expect("read module main.tf");
5535        assert!(rendered.contains("GREENTIC_ADMIN_CA_PEM"));
5536        assert!(rendered.contains("tls_self_signed_cert.admin_ca.cert_pem"));
5537        assert!(rendered.contains("GREENTIC_ADMIN_SERVER_CERT_PEM"));
5538        assert!(rendered.contains("tls_locally_signed_cert.admin_server.cert_pem"));
5539        assert!(rendered.contains("GREENTIC_ADMIN_SERVER_KEY_PEM"));
5540        assert!(rendered.contains("tls_private_key.admin_server.private_key_pem"));
5541        assert!(!rendered.contains("GREENTIC_ADMIN_CA_SECRET_REF"));
5542        assert!(!rendered.contains("runtime_admin_ca_accessor"));
5543    }
5544
5545    #[test]
5546    fn terraform_apply_script_for_aws_imports_existing_fixed_name_resources() {
5547        let rendered = terraform_plan_like_script(
5548            "apply",
5549            Provider::Aws,
5550            Some("dev.tfvars"),
5551            "staging.tfvars.example",
5552        );
5553        assert!(rendered.contains("module.operator_aws[0]"));
5554        assert!(rendered.contains("aws ec2 describe-security-groups"));
5555        assert!(rendered.contains("aws elbv2 describe-load-balancers"));
5556        assert!(rendered.contains("aws elbv2 describe-listeners"));
5557        assert!(rendered.contains("aws_lb_listener.http"));
5558        assert!(rendered.contains("aws ecs describe-clusters"));
5559        assert!(rendered.contains("aws ecs describe-services"));
5560        assert!(rendered.contains("aws_cloudwatch_log_group.this"));
5561        assert!(rendered.contains("aws_iam_role.task_execution"));
5562        assert!(rendered.contains("AWS apply hit transient condition"));
5563        assert!(rendered.contains("INIT_ARGS=(-input=false)"));
5564        assert!(rendered.contains("\"$TERRAFORM_BIN\" init \"${INIT_ARGS[@]}\""));
5565        assert!(!rendered.contains("BACKEND_ARGS"));
5566        assert!(rendered.contains("hash_string()"));
5567        assert!(rendered.contains("command -v md5sum"));
5568        assert!(rendered.contains("md5 -q"));
5569        assert!(rendered.contains("SHORT_ID=\"$(hash_string \"$BUNDLE_DIGEST_VALUE\")\""));
5570        assert!(!rendered.contains("SHORT_ID=$(printf '%s' \"$BUNDLE_DIGEST_VALUE\" | md5sum"));
5571    }
5572
5573    #[test]
5574    fn prune_generated_terraform_root_for_aws_includes_tenant_argument() {
5575        let dir = tempfile::tempdir().expect("tempdir");
5576        let terraform_root = dir.path().join("terraform");
5577        std::fs::create_dir_all(&terraform_root).expect("terraform root");
5578        std::fs::create_dir_all(terraform_root.join("modules/operator")).expect("module root");
5579        std::fs::write(terraform_root.join("variables.tf"), "").expect("write variables.tf");
5580        std::fs::write(terraform_root.join("modules/operator/variables.tf"), "")
5581            .expect("write module variables.tf");
5582        std::fs::write(
5583            terraform_root.join("modules/operator/main.tf"),
5584            r#"resource "aws_iam_role_policy" "task_execution_ecs_exec" {
5585  name = "${local.name_prefix}-task-exec-ecs-exec"
5586  role = aws_iam_role.task_execution.id
5587}
5588
5589resource "aws_ecs_task_definition" "this" {
5590  container_definitions = jsonencode([
5591    {
5592      environment = concat(
5593        [
5594          {
5595            name  = "GREENTIC_BUNDLE_SOURCE"
5596            value = var.bundle_source
5597          }
5598        ],
5599        [
5600          {
5601            name  = "PUBLIC_BASE_URL"
5602            value = local.effective_public_base_url
5603          }
5604        ]
5605      )
5606    }
5607  ])
5608}
5609
5610data "aws_region" "current" {}
5611"#,
5612        )
5613        .expect("write module main.tf");
5614
5615        let config = DeployerConfig {
5616            provider: Provider::Aws,
5617            ..config_for(terraform_root.clone(), DeployerCapability::Apply)
5618        };
5619
5620        prune_generated_terraform_root(&config, &terraform_root).expect("prune terraform root");
5621
5622        let rendered =
5623            std::fs::read_to_string(terraform_root.join("main.tf")).expect("read main.tf");
5624        assert!(rendered.contains(r#"tenant                = var.tenant"#));
5625        let variables =
5626            std::fs::read_to_string(terraform_root.join("variables.tf")).expect("read variables");
5627        assert!(variables.contains(r#"variable "bundle_s3_object_ref""#));
5628        assert!(variables.contains(r#"variable "bundle_s3_object_arn""#));
5629        assert!(variables.contains(r#"variable "runtime_secret_prefix""#));
5630        let module_variables =
5631            std::fs::read_to_string(terraform_root.join("modules/operator/variables.tf"))
5632                .expect("read module variables");
5633        assert!(module_variables.contains(r#"variable "bundle_s3_object_ref""#));
5634        assert!(module_variables.contains(r#"variable "bundle_s3_object_arn""#));
5635        assert!(module_variables.contains(r#"variable "runtime_secret_prefix""#));
5636        let module_main = std::fs::read_to_string(terraform_root.join("modules/operator/main.tf"))
5637            .expect("read module main");
5638        assert!(!module_main.contains("GREENTIC_SECRETS_BACKEND"));
5639        assert!(!module_main.contains("GREENTIC_ALLOW_ENV_SECRETS"));
5640        assert!(!module_main.contains("for name, secret_name in var.runtime_secret_env"));
5641        assert!(module_main.contains("task_runtime_secrets"));
5642        assert!(module_main.contains("task_bundle_s3_object"));
5643    }
5644
5645    #[test]
5646    fn persist_runtime_artifacts_materializes_aws_cleanup_helper_for_aws() {
5647        let base = std::env::current_dir()
5648            .expect("cwd")
5649            .join("target/tmp-tests");
5650        std::fs::create_dir_all(&base).expect("create tmp base");
5651        let dir = tempfile::tempdir_in(base).expect("temp dir");
5652        let pack_path = write_test_pack(true);
5653
5654        let mut greentic = greentic_config::ConfigResolver::new()
5655            .load()
5656            .expect("load default config")
5657            .config;
5658        greentic.paths.state_dir = dir.path().join(".greentic-state");
5659
5660        let config = DeployerConfig {
5661            capability: DeployerCapability::Plan,
5662            provider: Provider::Aws,
5663            strategy: "iac-only".into(),
5664            tenant: "acme".into(),
5665            environment: "staging".into(),
5666            pack_path: Some(pack_path.clone()),
5667            bundle_root: None,
5668            providers_dir: PathBuf::from("providers/deployer"),
5669            packs_dir: PathBuf::from("packs"),
5670            provider_pack: Some(pack_path.clone()),
5671            pack_ref: None,
5672            distributor_url: None,
5673            distributor_token: None,
5674            preview: false,
5675            dry_run: false,
5676            execute_local: false,
5677            output: crate::config::OutputFormat::Json,
5678            greentic,
5679            provenance: greentic_config::ProvenanceMap::new(),
5680            config_warnings: Vec::new(),
5681            deploy_pack_id_override: None,
5682            deploy_flow_id_override: None,
5683            bundle_source: Some("file:///tmp/demo.gtbundle".into()),
5684            bundle_digest: Some(
5685                "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into(),
5686            ),
5687            repo_registry_base: None,
5688            store_registry_base: None,
5689        };
5690        let plan = pack_introspect::build_plan(&config).expect("build plan");
5691        let deploy_dir = dir.path().join("output");
5692        std::fs::create_dir_all(&deploy_dir).expect("create output dir");
5693        let selection = DeploymentPackSelection {
5694            dispatch: crate::deployment::DeploymentDispatch {
5695                capability: DeployerCapability::Plan,
5696                pack_id: "greentic.deploy.terraform".into(),
5697                flow_id: "plan_terraform".into(),
5698                handler_id: "builtin.terraform".into(),
5699            },
5700            pack_path,
5701            manifest: PackManifest {
5702                agents: Default::default(),
5703                schema_version: "pack-v1".to_string(),
5704                pack_id: PackId::from_str("greentic.deploy.terraform").unwrap(),
5705                name: None,
5706                version: Version::new(0, 1, 0),
5707                kind: PackKind::Application,
5708                publisher: "greentic".to_string(),
5709                secret_requirements: Vec::new(),
5710                components: Vec::new(),
5711                flows: Vec::new(),
5712                dependencies: Vec::new(),
5713                capabilities: Vec::new(),
5714                signatures: Default::default(),
5715                bootstrap: None,
5716                extensions: None,
5717            },
5718            origin: "test".into(),
5719            candidates: Vec::new(),
5720        };
5721
5722        let artifacts = persist_runtime_artifacts(&config, &plan, &selection, &deploy_dir)
5723            .expect("persist runtime artifacts");
5724        assert!(
5725            artifacts
5726                .deploy_dir
5727                .join("terraform-aws-cleanup.sh")
5728                .exists()
5729        );
5730
5731        let metadata: TerraformRuntimeMetadata = serde_json::from_slice(
5732            &std::fs::read(artifacts.deploy_dir.join("terraform-runtime.json"))
5733                .expect("read terraform runtime metadata"),
5734        )
5735        .expect("parse terraform runtime metadata");
5736        assert!(
5737            metadata
5738                .scripts
5739                .iter()
5740                .any(|entry| entry == "terraform-aws-cleanup.sh")
5741        );
5742
5743        let cleanup =
5744            std::fs::read_to_string(artifacts.deploy_dir.join("terraform-aws-cleanup.sh"))
5745                .expect("read aws cleanup script");
5746        assert!(cleanup.contains("bundle_digest not found; skipping AWS cleanup fallback"));
5747        assert!(cleanup.contains("aws secretsmanager delete-secret"));
5748        assert!(cleanup.contains("aws iam delete-role"));
5749        assert!(cleanup.contains("aws ecs delete-service"));
5750        assert!(cleanup.contains("hash_string()"));
5751        assert!(cleanup.contains("md5 -q"));
5752        assert!(cleanup.contains("SHORT_ID=\"$(hash_string \"$BUNDLE_DIGEST\")\""));
5753        assert!(!cleanup.contains("SHORT_ID=$(printf '%s' \"$BUNDLE_DIGEST\" | md5sum"));
5754    }
5755
5756    #[test]
5757    fn persist_runtime_artifacts_falls_back_to_available_tfvars_example() {
5758        let base = std::env::current_dir()
5759            .expect("cwd")
5760            .join("target/tmp-tests");
5761        std::fs::create_dir_all(&base).expect("create tmp base");
5762        let dir = tempfile::tempdir_in(base).expect("temp dir");
5763        let pack_path = write_test_pack(true);
5764
5765        let mut greentic = greentic_config::ConfigResolver::new()
5766            .load()
5767            .expect("load default config")
5768            .config;
5769        greentic.paths.state_dir = dir.path().join(".greentic-state");
5770
5771        let config = DeployerConfig {
5772            capability: DeployerCapability::Plan,
5773            provider: Provider::Aws,
5774            strategy: "iac-only".into(),
5775            tenant: "acme".into(),
5776            environment: "dev".into(),
5777            pack_path: Some(pack_path.clone()),
5778            bundle_root: None,
5779            providers_dir: PathBuf::from("providers/deployer"),
5780            packs_dir: PathBuf::from("packs"),
5781            provider_pack: Some(pack_path.clone()),
5782            pack_ref: None,
5783            distributor_url: None,
5784            distributor_token: None,
5785            preview: false,
5786            dry_run: false,
5787            execute_local: false,
5788            output: crate::config::OutputFormat::Json,
5789            greentic,
5790            provenance: greentic_config::ProvenanceMap::new(),
5791            config_warnings: Vec::new(),
5792            deploy_pack_id_override: None,
5793            deploy_flow_id_override: None,
5794            bundle_source: Some("file:///tmp/demo.gtbundle".into()),
5795            bundle_digest: Some(
5796                "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into(),
5797            ),
5798            repo_registry_base: None,
5799            store_registry_base: None,
5800        };
5801        let plan = pack_introspect::build_plan(&config).expect("build plan");
5802        let deploy_dir = dir.path().join("output");
5803        std::fs::create_dir_all(&deploy_dir).expect("create output dir");
5804        let selection = DeploymentPackSelection {
5805            dispatch: crate::deployment::DeploymentDispatch {
5806                capability: DeployerCapability::Plan,
5807                pack_id: "greentic.deploy.terraform".into(),
5808                flow_id: "plan_terraform".into(),
5809                handler_id: "builtin.terraform".into(),
5810            },
5811            pack_path,
5812            manifest: PackManifest {
5813                agents: Default::default(),
5814                schema_version: "pack-v1".to_string(),
5815                pack_id: PackId::from_str("greentic.deploy.terraform").unwrap(),
5816                name: None,
5817                version: Version::new(0, 1, 0),
5818                kind: PackKind::Application,
5819                publisher: "greentic".to_string(),
5820                secret_requirements: Vec::new(),
5821                components: Vec::new(),
5822                flows: Vec::new(),
5823                dependencies: Vec::new(),
5824                capabilities: Vec::new(),
5825                signatures: Default::default(),
5826                bootstrap: None,
5827                extensions: None,
5828            },
5829            origin: "test".into(),
5830            candidates: Vec::new(),
5831        };
5832
5833        let artifacts = persist_runtime_artifacts(&config, &plan, &selection, &deploy_dir)
5834            .expect("persist runtime artifacts");
5835        let generated = std::fs::read_to_string(artifacts.deploy_dir.join("terraform/dev.tfvars"))
5836            .expect("read generated tfvars");
5837        assert!(generated.contains("cloud = \"aws\""));
5838        assert!(generated.contains("environment = \"dev\""));
5839        assert!(generated.contains("deployment_name_prefix = \"greentic-"));
5840        assert!(generated.contains("bundle_source = \"file:///tmp/demo.gtbundle\""));
5841        assert!(generated.contains(
5842            "bundle_digest = \"sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd\""
5843        ));
5844        assert!(generated.contains(&format!(
5845            "operator_image_digest = \"{}\"",
5846            crate::contract::DEFAULT_OPERATOR_IMAGE_DIGEST
5847        )));
5848        assert!(!generated.contains(
5849            "operator_image_digest = \"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\""
5850        ));
5851
5852        let destroy_script =
5853            std::fs::read_to_string(artifacts.deploy_dir.join("terraform-destroy.sh"))
5854                .expect("read destroy script");
5855        assert!(destroy_script.contains("VAR_FILE=\"dev.tfvars\""));
5856        assert!(destroy_script.contains("elif [ -f \"staging.tfvars.example\" ]; then"));
5857    }
5858
5859    #[test]
5860    fn generated_tfvars_ignores_legacy_bundle_digest_prefix_for_new_cloud_identity() {
5861        let base = std::env::current_dir()
5862            .expect("cwd")
5863            .join("target/tmp-tests");
5864        std::fs::create_dir_all(&base).expect("create tmp base");
5865        let dir = tempfile::tempdir_in(base).expect("temp dir");
5866        let terraform_root = dir.path().join("terraform");
5867        std::fs::create_dir_all(&terraform_root).expect("create terraform dir");
5868        std::fs::write(
5869            terraform_root.join("staging.tfvars.example"),
5870            "cloud = \"aws\"\nenvironment = \"staging\"\n",
5871        )
5872        .expect("write example");
5873        std::fs::write(
5874            terraform_root.join("dev.tfvars"),
5875            "bundle_digest = \"sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd\"\n",
5876        )
5877        .expect("write existing tfvars");
5878
5879        let mut greentic = greentic_config::ConfigResolver::new()
5880            .load()
5881            .expect("load default config")
5882            .config;
5883        greentic.paths.state_dir = dir.path().join(".greentic-state");
5884        let config = DeployerConfig {
5885            capability: DeployerCapability::Plan,
5886            provider: Provider::Aws,
5887            strategy: "iac-only".into(),
5888            tenant: "acme".into(),
5889            environment: "dev".into(),
5890            pack_path: Some(dir.path().join("bundle")),
5891            bundle_root: None,
5892            providers_dir: PathBuf::from("providers/deployer"),
5893            packs_dir: PathBuf::from("packs"),
5894            provider_pack: None,
5895            pack_ref: None,
5896            distributor_url: None,
5897            distributor_token: None,
5898            preview: false,
5899            dry_run: false,
5900            execute_local: false,
5901            output: crate::config::OutputFormat::Json,
5902            greentic,
5903            provenance: greentic_config::ProvenanceMap::new(),
5904            config_warnings: Vec::new(),
5905            deploy_pack_id_override: None,
5906            deploy_flow_id_override: None,
5907            bundle_source: Some("file:///tmp/demo-v2.gtbundle".into()),
5908            bundle_digest: Some(
5909                "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
5910            ),
5911            repo_registry_base: None,
5912            store_registry_base: None,
5913        };
5914        let legacy_shared_prefix = legacy_shared_deployment_name_prefix(&config);
5915        std::fs::write(
5916            terraform_root.join("dev.tfvars"),
5917            format!(
5918                "deployment_name_prefix = \"{legacy_shared_prefix}\"\nbundle_digest = \"sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd\"\n"
5919            ),
5920        )
5921        .expect("write existing tfvars");
5922
5923        let generated =
5924            materialize_generated_tfvars(&config, &terraform_root, "staging.tfvars.example")
5925                .expect("generate tfvars")
5926                .expect("generated filename");
5927        let contents =
5928            std::fs::read_to_string(terraform_root.join(generated)).expect("read generated tfvars");
5929        assert!(contents.contains("deployment_name_prefix = \"greentic-"));
5930        assert!(!contents.contains(&format!(
5931            "deployment_name_prefix = \"{legacy_shared_prefix}\""
5932        )));
5933    }
5934
5935    #[test]
5936    fn generated_aws_tfvars_omit_runtime_secret_env_when_provider_binding_exists() {
5937        let base = std::env::current_dir()
5938            .expect("cwd")
5939            .join("target/tmp-tests");
5940        std::fs::create_dir_all(&base).expect("create tmp base");
5941        let dir = tempfile::tempdir_in(base).expect("temp dir");
5942        let bundle_root = dir.path();
5943        let terraform_root = bundle_root.join("terraform");
5944        std::fs::create_dir_all(&terraform_root).expect("terraform root");
5945        std::fs::write(
5946            terraform_root.join("staging.tfvars.example"),
5947            r#"
5948cloud = "aws"
5949tenant = "demo"
5950environment = "dev"
5951runtime_secret_env = {}
5952"#,
5953        )
5954        .expect("write tfvars example");
5955
5956        let pack_path = bundle_root.join("packs/messaging-webchat-gui");
5957        std::fs::create_dir_all(&pack_path).expect("pack dir");
5958        std::fs::write(
5959            pack_path.join("pack.manifest.json"),
5960            r#"{
5961  "extensions": {
5962    "greentic.generated-secrets.v1": {
5963      "inline": {
5964        "secrets": [{
5965          "key": "jwt_signing_key",
5966          "required": true,
5967          "policy": "random",
5968          "length": 20,
5969          "encoding": "raw_text",
5970          "scope": {"level": "tenant", "team": "_"}
5971        }]
5972      }
5973    }
5974  }
5975}"#,
5976        )
5977        .expect("write manifest");
5978
5979        let config = DeployerConfig {
5980            provider: Provider::Aws,
5981            tenant: "demo".into(),
5982            environment: "dev".into(),
5983            pack_path: Some(pack_path.clone()),
5984            bundle_root: Some(bundle_root.to_path_buf()),
5985            provider_pack: None,
5986            bundle_source: Some("s3://bucket/bundle.gtbundle".into()),
5987            ..config_for(pack_path, DeployerCapability::Apply)
5988        };
5989
5990        let generated =
5991            materialize_generated_tfvars(&config, &terraform_root, "staging.tfvars.example")
5992                .expect("generate tfvars")
5993                .expect("tfvars generated");
5994        let contents =
5995            std::fs::read_to_string(terraform_root.join(generated)).expect("read generated tfvars");
5996
5997        assert!(contents.contains("runtime_secret_env = {}"));
5998        assert!(!contents.contains("GREENTIC_SECRET__"));
5999        assert!(!contents.contains("\"jwt_signing_key\""));
6000        assert!(
6001            !contents.contains("\"secrets://dev/demo/_/messaging-webchat-gui/jwt_signing_key\""),
6002            "runtime_secret_env must not include runtime secret aliases when provider binding is present: {contents}"
6003        );
6004    }
6005
6006    #[test]
6007    fn deployment_name_prefix_normalization_keeps_cloud_names_bounded() {
6008        assert_eq!(
6009            normalize_deployment_name_prefix(" Maarten/Deep Research Demo!!! "),
6010            "maarten-deep-research-de"
6011        );
6012        assert_eq!(
6013            normalize_deployment_name_prefix("123-dev"),
6014            "greentic-123-dev"
6015        );
6016    }
6017
6018    #[test]
6019    fn render_operation_result_text_includes_terraform_runtime_summary() {
6020        let base = std::env::current_dir()
6021            .expect("cwd")
6022            .join("target/tmp-tests");
6023        std::fs::create_dir_all(&base).expect("create tmp base");
6024        let dir = tempfile::tempdir_in(base).expect("temp dir");
6025        let output_dir = dir.path().join("deploy");
6026        std::fs::create_dir_all(&output_dir).expect("create output dir");
6027        std::fs::write(
6028            output_dir.join("terraform-runtime.json"),
6029            serde_json::to_vec_pretty(&TerraformRuntimeMetadata {
6030                terraform_root: output_dir.join("terraform").display().to_string(),
6031                copied_files: vec!["main.tf".into(), "modules/operator/main.tf".into()],
6032                scripts: vec!["terraform-status.sh".into()],
6033                generated_tfvars: None,
6034                secrets_provider_binding: None,
6035                init_command: "./terraform-init.sh".into(),
6036                plan_command: "./terraform-plan.sh".into(),
6037                apply_command: "./terraform-apply.sh".into(),
6038                destroy_command: "./terraform-destroy.sh".into(),
6039                status_command: "./terraform-status.sh".into(),
6040            })
6041            .expect("encode terraform runtime metadata"),
6042        )
6043        .expect("write runtime metadata");
6044
6045        let rendered = render_operation_result_text(&OperationResult {
6046            capability: "status".into(),
6047            executed: false,
6048            preview: false,
6049            output_dir: output_dir.display().to_string(),
6050            plan_path: "/tmp/plan.json".into(),
6051            invoke_path: "/tmp/invoke.json".into(),
6052            pack_id: "greentic.deploy.terraform".into(),
6053            flow_id: "status_terraform".into(),
6054            handler_id: "builtin.terraform".into(),
6055            pack_path: "/tmp/provider.gtpack".into(),
6056            contract: None,
6057            capability_contract: None,
6058            payload: Some(OperationPayload::Status(Box::new(StatusPayload {
6059                capability: "status".into(),
6060                provider: "generic".into(),
6061                strategy: "terraform".into(),
6062                pack_id: "greentic.deploy.terraform".into(),
6063                flow_id: "status_terraform".into(),
6064                rendered_output: None,
6065            }))),
6066            output_validation: None,
6067            execution: None,
6068        });
6069
6070        assert!(rendered.contains("terraform_runtime.present=true"));
6071        assert!(rendered.contains("handler_id=builtin.terraform"));
6072        assert!(
6073            rendered.contains("terraform_runtime.copied_files=main.tf, modules/operator/main.tf")
6074        );
6075        assert!(rendered.contains("terraform_runtime.status_command=./terraform-status.sh"));
6076    }
6077
6078    #[test]
6079    fn render_operation_result_text_summarizes_apply_success_with_webchat_url() {
6080        let rendered = render_operation_result_text(&OperationResult {
6081            capability: "apply".into(),
6082            executed: true,
6083            preview: false,
6084            output_dir: "/tmp/deploy".into(),
6085            plan_path: "/tmp/plan.json".into(),
6086            invoke_path: "/tmp/invoke.json".into(),
6087            pack_id: "greentic.deploy.aws".into(),
6088            flow_id: "apply_terraform".into(),
6089            handler_id: "pack.greentic.deploy.aws".into(),
6090            pack_path: "/tmp/aws.gtpack".into(),
6091            contract: None,
6092            capability_contract: None,
6093            payload: Some(OperationPayload::Apply(Box::new(ApplyPayload {
6094                capability: "apply".into(),
6095                provider: "aws".into(),
6096                strategy: "iac-only".into(),
6097                pack_id: "greentic.deploy.aws".into(),
6098                flow_id: "apply_terraform".into(),
6099                output_dir: "/tmp/deploy".into(),
6100                plan_path: "/tmp/plan.json".into(),
6101                invoke_path: "/tmp/invoke.json".into(),
6102                runner_cmd: vec![],
6103                runner_env: vec![("GREENTIC_TENANT".into(), "demo".into())],
6104            }))),
6105            output_validation: None,
6106            execution: Some(ExecutionReport {
6107                output_dir: "/tmp/deploy".into(),
6108                plan_path: "/tmp/plan.json".into(),
6109                invoke_path: "/tmp/invoke.json".into(),
6110                handoff_path: "/tmp/handoff.json".into(),
6111                runner_command_path: "/tmp/runner.txt".into(),
6112                handler_id: "pack.greentic.deploy.aws".into(),
6113                status: Some("applied".into()),
6114                message: None,
6115                output_files: vec![],
6116                outcome_payload: Some(ExecutionOutcomePayload::Apply(
6117                    crate::deployment::ApplyExecutionOutcome {
6118                        deployment_id: "/tmp/deploy".into(),
6119                        state: "applied".into(),
6120                        provider: Some("aws".into()),
6121                        strategy: Some("iac-only".into()),
6122                        endpoints: vec!["http://greentic.example.elb.amazonaws.com".into()],
6123                        output_refs: BTreeMap::new(),
6124                    },
6125                )),
6126                outcome_validation: None,
6127            }),
6128        });
6129
6130        assert_eq!(
6131            rendered,
6132            "http://greentic.example.elb.amazonaws.com/v1/web/webchat/demo/\n"
6133        );
6134        assert!(!rendered.contains("capability=apply"));
6135    }
6136
6137    #[test]
6138    fn render_operation_result_json_summarizes_apply_success_with_webchat_url() {
6139        let rendered = render_operation_result(
6140            &OperationResult {
6141                capability: "apply".into(),
6142                executed: true,
6143                preview: false,
6144                output_dir: "/tmp/deploy".into(),
6145                plan_path: "/tmp/plan.json".into(),
6146                invoke_path: "/tmp/invoke.json".into(),
6147                pack_id: "greentic.deploy.aws".into(),
6148                flow_id: "apply_terraform".into(),
6149                handler_id: "pack.greentic.deploy.aws".into(),
6150                pack_path: "/tmp/aws.gtpack".into(),
6151                contract: None,
6152                capability_contract: None,
6153                payload: Some(OperationPayload::Apply(Box::new(ApplyPayload {
6154                    capability: "apply".into(),
6155                    provider: "aws".into(),
6156                    strategy: "iac-only".into(),
6157                    pack_id: "greentic.deploy.aws".into(),
6158                    flow_id: "apply_terraform".into(),
6159                    output_dir: "/tmp/deploy".into(),
6160                    plan_path: "/tmp/plan.json".into(),
6161                    invoke_path: "/tmp/invoke.json".into(),
6162                    runner_cmd: vec![],
6163                    runner_env: vec![("GREENTIC_TENANT".into(), "demo".into())],
6164                }))),
6165                output_validation: None,
6166                execution: Some(ExecutionReport {
6167                    output_dir: "/tmp/deploy".into(),
6168                    plan_path: "/tmp/plan.json".into(),
6169                    invoke_path: "/tmp/invoke.json".into(),
6170                    handoff_path: "/tmp/handoff.json".into(),
6171                    runner_command_path: "/tmp/runner.txt".into(),
6172                    handler_id: "pack.greentic.deploy.aws".into(),
6173                    status: Some("applied".into()),
6174                    message: None,
6175                    output_files: vec![],
6176                    outcome_payload: Some(ExecutionOutcomePayload::Apply(
6177                        crate::deployment::ApplyExecutionOutcome {
6178                            deployment_id: "/tmp/deploy".into(),
6179                            state: "applied".into(),
6180                            provider: Some("aws".into()),
6181                            strategy: Some("iac-only".into()),
6182                            endpoints: vec!["http://greentic.example.elb.amazonaws.com".into()],
6183                            output_refs: BTreeMap::new(),
6184                        },
6185                    )),
6186                    outcome_validation: None,
6187                }),
6188            },
6189            OutputFormat::Json,
6190        )
6191        .expect("render json");
6192
6193        assert_eq!(
6194            rendered,
6195            "{\n  \"webchat_url\": \"http://greentic.example.elb.amazonaws.com/v1/web/webchat/demo/\"\n}"
6196        );
6197        assert!(!rendered.contains("contract"));
6198    }
6199
6200    #[test]
6201    fn persist_runtime_artifacts_materializes_k8s_raw_handoff_assets() {
6202        let base = std::env::current_dir()
6203            .expect("cwd")
6204            .join("target/tmp-tests");
6205        std::fs::create_dir_all(&base).expect("create tmp base");
6206        let dir = tempfile::tempdir_in(base).expect("temp dir");
6207        let pack_path = write_test_pack(true);
6208
6209        let mut greentic = greentic_config::ConfigResolver::new()
6210            .load()
6211            .expect("load default config")
6212            .config;
6213        greentic.paths.state_dir = dir.path().join(".greentic-state");
6214
6215        let config = DeployerConfig {
6216            capability: DeployerCapability::Plan,
6217            provider: Provider::K8s,
6218            strategy: "raw-manifests".into(),
6219            tenant: "acme".into(),
6220            environment: "staging".into(),
6221            pack_path: Some(pack_path.clone()),
6222            bundle_root: None,
6223            providers_dir: PathBuf::from("providers/deployer"),
6224            packs_dir: PathBuf::from("packs"),
6225            provider_pack: Some(pack_path.clone()),
6226            pack_ref: None,
6227            distributor_url: None,
6228            distributor_token: None,
6229            preview: false,
6230            dry_run: false,
6231            execute_local: false,
6232            output: crate::config::OutputFormat::Json,
6233            greentic,
6234            provenance: greentic_config::ProvenanceMap::new(),
6235            config_warnings: Vec::new(),
6236            deploy_pack_id_override: None,
6237            deploy_flow_id_override: None,
6238            bundle_source: None,
6239            bundle_digest: None,
6240            repo_registry_base: None,
6241            store_registry_base: None,
6242        };
6243        let plan = pack_introspect::build_plan(&config).expect("build plan");
6244        let deploy_dir = dir.path().join("output");
6245        std::fs::create_dir_all(&deploy_dir).expect("create output dir");
6246        let selection = DeploymentPackSelection {
6247            dispatch: crate::deployment::DeploymentDispatch {
6248                capability: DeployerCapability::Plan,
6249                pack_id: "greentic.deploy.k8s".into(),
6250                flow_id: "plan_k8s_raw".into(),
6251                handler_id: "builtin.k8s_raw".into(),
6252            },
6253            pack_path,
6254            manifest: PackManifest {
6255                agents: Default::default(),
6256                schema_version: "pack-v1".to_string(),
6257                pack_id: PackId::from_str("greentic.deploy.k8s").unwrap(),
6258                name: None,
6259                version: Version::new(0, 1, 0),
6260                kind: PackKind::Application,
6261                publisher: "greentic".to_string(),
6262                secret_requirements: Vec::new(),
6263                components: Vec::new(),
6264                flows: Vec::new(),
6265                dependencies: Vec::new(),
6266                capabilities: Vec::new(),
6267                signatures: Default::default(),
6268                bootstrap: None,
6269                extensions: None,
6270            },
6271            origin: "test".into(),
6272            candidates: Vec::new(),
6273        };
6274
6275        let artifacts = persist_runtime_artifacts(&config, &plan, &selection, &deploy_dir)
6276            .expect("persist runtime artifacts");
6277
6278        assert!(
6279            artifacts
6280                .deploy_dir
6281                .join("k8s/rendered-manifests.yaml")
6282                .exists()
6283        );
6284        assert!(artifacts.deploy_dir.join("kubectl-apply.sh").exists());
6285        assert!(artifacts.deploy_dir.join("kubectl-delete.sh").exists());
6286        assert!(artifacts.deploy_dir.join("kubectl-status.sh").exists());
6287        let note = std::fs::read_to_string(artifacts.deploy_dir.join("k8s-handoff.txt"))
6288            .expect("read k8s handoff note");
6289        assert!(note.contains("manifest_path="));
6290        assert!(note.contains("kubectl-apply.sh"));
6291    }
6292
6293    #[test]
6294    fn persist_runtime_artifacts_materializes_helm_handoff_assets() {
6295        let base = std::env::current_dir()
6296            .expect("cwd")
6297            .join("target/tmp-tests");
6298        std::fs::create_dir_all(&base).expect("create tmp base");
6299        let dir = tempfile::tempdir_in(base).expect("temp dir");
6300        let pack_path = write_test_pack(true);
6301
6302        let mut greentic = greentic_config::ConfigResolver::new()
6303            .load()
6304            .expect("load default config")
6305            .config;
6306        greentic.paths.state_dir = dir.path().join(".greentic-state");
6307
6308        let config = DeployerConfig {
6309            capability: DeployerCapability::Plan,
6310            provider: Provider::K8s,
6311            strategy: "helm".into(),
6312            tenant: "acme".into(),
6313            environment: "staging".into(),
6314            pack_path: Some(pack_path.clone()),
6315            bundle_root: None,
6316            providers_dir: PathBuf::from("providers/deployer"),
6317            packs_dir: PathBuf::from("packs"),
6318            provider_pack: Some(pack_path.clone()),
6319            pack_ref: None,
6320            distributor_url: None,
6321            distributor_token: None,
6322            preview: false,
6323            dry_run: false,
6324            execute_local: false,
6325            output: crate::config::OutputFormat::Json,
6326            greentic,
6327            provenance: greentic_config::ProvenanceMap::new(),
6328            config_warnings: Vec::new(),
6329            deploy_pack_id_override: None,
6330            deploy_flow_id_override: None,
6331            bundle_source: None,
6332            bundle_digest: None,
6333            repo_registry_base: None,
6334            store_registry_base: None,
6335        };
6336        let plan = pack_introspect::build_plan(&config).expect("build plan");
6337        let deploy_dir = dir.path().join("output");
6338        std::fs::create_dir_all(&deploy_dir).expect("create output dir");
6339        let selection = DeploymentPackSelection {
6340            dispatch: crate::deployment::DeploymentDispatch {
6341                capability: DeployerCapability::Plan,
6342                pack_id: "greentic.deploy.helm".into(),
6343                flow_id: "plan_helm".into(),
6344                handler_id: "builtin.helm".into(),
6345            },
6346            pack_path,
6347            manifest: PackManifest {
6348                agents: Default::default(),
6349                schema_version: "pack-v1".to_string(),
6350                pack_id: PackId::from_str("greentic.deploy.helm").unwrap(),
6351                name: None,
6352                version: Version::new(0, 1, 0),
6353                kind: PackKind::Application,
6354                publisher: "greentic".to_string(),
6355                secret_requirements: Vec::new(),
6356                components: Vec::new(),
6357                flows: Vec::new(),
6358                dependencies: Vec::new(),
6359                capabilities: Vec::new(),
6360                signatures: Default::default(),
6361                bootstrap: None,
6362                extensions: None,
6363            },
6364            origin: "test".into(),
6365            candidates: Vec::new(),
6366        };
6367
6368        let artifacts = persist_runtime_artifacts(&config, &plan, &selection, &deploy_dir)
6369            .expect("persist runtime artifacts");
6370
6371        assert!(artifacts.deploy_dir.join("helm-chart/Chart.yaml").exists());
6372        assert!(
6373            artifacts
6374                .deploy_dir
6375                .join("helm-chart/templates/deployment.yaml")
6376                .exists()
6377        );
6378        assert!(artifacts.deploy_dir.join("helm-upgrade.sh").exists());
6379        assert!(artifacts.deploy_dir.join("helm-rollback.sh").exists());
6380        assert!(artifacts.deploy_dir.join("helm-status.sh").exists());
6381        let note = std::fs::read_to_string(artifacts.deploy_dir.join("helm-handoff.txt"))
6382            .expect("read helm handoff note");
6383        assert!(note.contains("chart_root="));
6384        assert!(note.contains("release_name=greentic-acme"));
6385    }
6386
6387    #[tokio::test]
6388    async fn plan_result_without_contract_schema_skips_validation() {
6389        let _guard = EXECUTOR_TEST_LOCK.lock().await;
6390        clear_deployment_executor();
6391        let pack_path = write_test_pack(false);
6392        let result = run(config_for(pack_path, DeployerCapability::Plan))
6393            .await
6394            .expect("plan runs");
6395        assert!(result.output_validation.is_none());
6396    }
6397
6398    #[tokio::test]
6399    async fn generate_result_contains_capability_payload() {
6400        let _guard = EXECUTOR_TEST_LOCK.lock().await;
6401        clear_deployment_executor();
6402        let pack_path = write_test_pack(true);
6403        let result = run(config_for(pack_path, DeployerCapability::Generate))
6404            .await
6405            .expect("generate prepares");
6406        match result.payload.expect("payload") {
6407            OperationPayload::Generate(payload) => {
6408                assert_eq!(payload.capability, "generate");
6409                assert_eq!(payload.provider, "aws");
6410                assert_eq!(
6411                    payload.input_schema_path.as_deref(),
6412                    Some("assets/schemas/generate-input.schema.json")
6413                );
6414                assert_eq!(
6415                    payload.output_schema_path.as_deref(),
6416                    Some("assets/schemas/generate-output.schema.json")
6417                );
6418                assert_eq!(
6419                    payload.qa_spec_path.as_deref(),
6420                    Some("assets/qa/generate.qa.json")
6421                );
6422                assert_eq!(
6423                    payload.example_paths,
6424                    vec!["assets/examples/generate.example.json".to_string()]
6425                );
6426            }
6427            other => panic!("unexpected payload: {:?}", other),
6428        }
6429        assert_eq!(
6430            result
6431                .capability_contract
6432                .as_ref()
6433                .expect("capability contract")
6434                .flow_id,
6435            "generate_pack"
6436        );
6437        assert!(result.output_validation.as_ref().expect("validation").valid);
6438    }
6439
6440    #[tokio::test]
6441    async fn preview_destroy_result_uses_destroy_payload_kind() {
6442        let _guard = EXECUTOR_TEST_LOCK.lock().await;
6443        clear_deployment_executor();
6444        let pack_path = write_test_pack(true);
6445        let mut config = config_for(pack_path, DeployerCapability::Destroy);
6446        config.preview = true;
6447        let result = run(config).await.expect("destroy preview prepares");
6448        match result.payload.expect("payload") {
6449            OperationPayload::Destroy(payload) => {
6450                assert_eq!(payload.capability, "destroy");
6451                assert_eq!(payload.strategy, "iac-only");
6452                assert_eq!(payload.flow_id, "destroy_pack");
6453                assert!(payload.runner_cmd.iter().any(|arg| arg == "--flow"));
6454                assert!(
6455                    payload
6456                        .runner_env
6457                        .iter()
6458                        .any(|(key, value)| key == "GREENTIC_DEPLOYMENT_CAPABILITY"
6459                            && value == "destroy")
6460                );
6461            }
6462            other => panic!("unexpected payload: {:?}", other),
6463        }
6464    }
6465
6466    #[tokio::test]
6467    async fn preview_apply_result_contains_runner_handoff() {
6468        let _guard = EXECUTOR_TEST_LOCK.lock().await;
6469        clear_deployment_executor();
6470        let pack_path = write_test_pack(true);
6471        let mut config = config_for(pack_path, DeployerCapability::Apply);
6472        config.preview = true;
6473        let result = run(config).await.expect("apply preview prepares");
6474        match result.payload.expect("payload") {
6475            OperationPayload::Apply(payload) => {
6476                assert_eq!(payload.capability, "apply");
6477                assert_eq!(payload.pack_id, "greentic.deploy.aws");
6478                assert_eq!(payload.flow_id, "deploy_aws_iac");
6479                assert!(
6480                    payload
6481                        .runner_cmd
6482                        .iter()
6483                        .any(|arg| arg == "greentic-runner")
6484                );
6485                assert!(payload.plan_path.ends_with("plan.json"));
6486                assert!(payload.invoke_path.ends_with("invoke.json"));
6487            }
6488            other => panic!("unexpected payload: {:?}", other),
6489        }
6490    }
6491
6492    #[tokio::test]
6493    async fn status_result_contains_dispatch_metadata() {
6494        let _guard = EXECUTOR_TEST_LOCK.lock().await;
6495        clear_deployment_executor();
6496        let pack_path = write_test_pack(true);
6497        let result = run(config_for(pack_path, DeployerCapability::Status))
6498            .await
6499            .expect("status prepares");
6500        match result.payload.expect("payload") {
6501            OperationPayload::Status(payload) => {
6502                assert_eq!(payload.capability, "status");
6503                assert_eq!(payload.pack_id, "greentic.deploy.aws");
6504                assert_eq!(payload.flow_id, "status_pack");
6505            }
6506            other => panic!("unexpected payload: {:?}", other),
6507        }
6508        assert!(result.output_validation.as_ref().expect("validation").valid);
6509    }
6510
6511    #[tokio::test]
6512    async fn rollback_result_contains_target_capability() {
6513        let _guard = EXECUTOR_TEST_LOCK.lock().await;
6514        clear_deployment_executor();
6515        let pack_path = write_test_pack(true);
6516        let result = run(config_for(pack_path, DeployerCapability::Rollback))
6517            .await
6518            .expect("rollback prepares");
6519        match result.payload.expect("payload") {
6520            OperationPayload::Rollback(payload) => {
6521                assert_eq!(payload.capability, "rollback");
6522                assert_eq!(payload.target_capability, "apply");
6523                assert_eq!(payload.flow_id, "rollback_pack");
6524            }
6525            other => panic!("unexpected payload: {:?}", other),
6526        }
6527        assert!(result.output_validation.as_ref().expect("validation").valid);
6528    }
6529
6530    // ------------------------------------------------------------------
6531    // PR-08 — operator secrets map tfvar emission
6532    // ------------------------------------------------------------------
6533
6534    #[test]
6535    fn render_terraform_map_emits_quoted_key_value_pairs() {
6536        let mut map = std::collections::BTreeMap::new();
6537        map.insert(
6538            "secrets://dev/demo/_/messaging-webchat/jwt_signing_key".to_string(),
6539            "secret-value".to_string(),
6540        );
6541        map.insert(
6542            "secrets://dev/demo/_/deep-research-demo/api_key_secret".to_string(),
6543            "another".to_string(),
6544        );
6545
6546        let rendered = render_terraform_map(&map);
6547
6548        assert!(rendered.starts_with("{\n"));
6549        assert!(rendered.ends_with('}'));
6550        // BTreeMap iterates sorted, so the api_key entry lands first.
6551        assert!(
6552            rendered.contains(
6553                "\"secrets://dev/demo/_/deep-research-demo/api_key_secret\" = \"another\""
6554            ),
6555            "rendered map should contain canonical URI as key: {rendered}"
6556        );
6557        assert!(rendered.contains(
6558            "\"secrets://dev/demo/_/messaging-webchat/jwt_signing_key\" = \"secret-value\""
6559        ));
6560    }
6561
6562    #[test]
6563    fn render_terraform_map_escapes_special_characters() {
6564        let mut map = std::collections::BTreeMap::new();
6565        map.insert(
6566            "k".to_string(),
6567            "value with \"quotes\" and \\backslash".to_string(),
6568        );
6569        let rendered = render_terraform_map(&map);
6570        // JSON-style escapes survive into the HCL literal.
6571        assert!(
6572            rendered.contains(r#"value with \"quotes\" and \\backslash"#),
6573            "escapes preserved: {rendered}"
6574        );
6575    }
6576
6577    #[test]
6578    fn replace_tfvars_assignment_literal_inserts_new_map_assignment() {
6579        let mut contents = String::from("cloud = \"aws\"\ntenant = \"demo\"\n");
6580        let map_literal = "{\n  \"k\" = \"v\"\n}";
6581        replace_tfvars_assignment_literal(&mut contents, "secrets_map", map_literal);
6582        assert!(contents.contains("secrets_map = {\n  \"k\" = \"v\"\n}"));
6583        assert!(contents.contains("cloud = \"aws\""));
6584        assert!(contents.contains("tenant = \"demo\""));
6585    }
6586
6587    #[test]
6588    fn replace_tfvars_assignment_literal_replaces_existing_multiline_map() {
6589        // A previous deploy emitted a 3-line map; the next emit must replace
6590        // both the assignment and the continuation lines so the file does
6591        // not accumulate duplicate entries.
6592        let mut contents = String::from(
6593            "cloud = \"aws\"\nsecrets_map = {\n  \"old\" = \"old\"\n}\ntenant = \"demo\"\n",
6594        );
6595        let new_literal = "{\n  \"new\" = \"value\"\n}";
6596        replace_tfvars_assignment_literal(&mut contents, "secrets_map", new_literal);
6597        assert!(contents.contains("secrets_map = {\n  \"new\" = \"value\"\n}"));
6598        assert!(!contents.contains("\"old\""));
6599        assert_eq!(contents.matches("secrets_map = ").count(), 1);
6600        assert!(contents.contains("tenant = \"demo\""));
6601    }
6602}