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