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