1use std::io::Write;
2use std::path::PathBuf;
3use std::process::{Command as ProcessCommand, Stdio};
4
5use crate::config::{DeployerConfig, DeployerRequest, OutputFormat, Provider};
6use crate::contract::DeployerCapability;
7use crate::error::{DeployerError, Result};
8use crate::multi_target;
9use crate::plan::PlanContext;
10use crate::runtime_secrets::{
11 PromoteRuntimeSecretsReport, ResolvedRuntimeSecret, default_cloud_secret_prefix,
12 flat_cloud_secret_name, resolve_for_cloud_apply,
13};
14
15#[derive(Debug, Clone)]
17pub struct AzureRequest {
18 pub capability: DeployerCapability,
19 pub tenant: String,
20 pub pack_path: PathBuf,
21 pub bundle_root: Option<PathBuf>,
22 pub bundle_source: Option<String>,
23 pub bundle_digest: Option<String>,
24 pub repo_registry_base: Option<String>,
25 pub store_registry_base: Option<String>,
26 pub provider_pack: Option<PathBuf>,
27 pub deploy_pack_id_override: Option<String>,
28 pub deploy_flow_id_override: Option<String>,
29 pub environment: Option<String>,
30 pub pack_id: Option<String>,
31 pub pack_version: Option<String>,
32 pub pack_digest: Option<String>,
33 pub distributor_url: Option<String>,
34 pub distributor_token: Option<String>,
35 pub preview: bool,
36 pub dry_run: bool,
37 pub execute_local: bool,
38 pub output: OutputFormat,
39 pub config_path: Option<PathBuf>,
40 pub allow_remote_in_offline: bool,
41 pub providers_dir: PathBuf,
42 pub packs_dir: PathBuf,
43}
44
45impl AzureRequest {
46 pub fn new(
47 capability: DeployerCapability,
48 tenant: impl Into<String>,
49 pack_path: PathBuf,
50 ) -> Self {
51 Self {
52 capability,
53 tenant: tenant.into(),
54 pack_path,
55 bundle_root: None,
56 bundle_source: None,
57 bundle_digest: None,
58 repo_registry_base: None,
59 store_registry_base: None,
60 provider_pack: None,
61 deploy_pack_id_override: None,
62 deploy_flow_id_override: None,
63 environment: None,
64 pack_id: None,
65 pack_version: None,
66 pack_digest: None,
67 distributor_url: None,
68 distributor_token: None,
69 preview: false,
70 dry_run: false,
71 execute_local: false,
72 output: OutputFormat::Text,
73 config_path: None,
74 allow_remote_in_offline: false,
75 providers_dir: PathBuf::from("providers/deployer"),
76 packs_dir: PathBuf::from("packs"),
77 }
78 }
79
80 pub fn into_deployer_request(self) -> DeployerRequest {
81 DeployerRequest {
82 capability: self.capability,
83 provider: Provider::Azure,
84 strategy: "iac-only".to_string(),
85 tenant: self.tenant,
86 environment: self.environment,
87 pack_path: self.pack_path,
88 bundle_root: self.bundle_root,
89 bundle_source: self.bundle_source,
90 bundle_digest: self.bundle_digest,
91 repo_registry_base: self.repo_registry_base,
92 store_registry_base: self.store_registry_base,
93 providers_dir: self.providers_dir,
94 packs_dir: self.packs_dir,
95 provider_pack: self.provider_pack,
96 pack_id: self.pack_id,
97 pack_version: self.pack_version,
98 pack_digest: self.pack_digest,
99 distributor_url: self.distributor_url,
100 distributor_token: self.distributor_token,
101 preview: self.preview,
102 dry_run: self.dry_run,
103 execute_local: self.execute_local,
104 output: self.output,
105 config_path: self.config_path,
106 allow_remote_in_offline: self.allow_remote_in_offline,
107 deploy_pack_id_override: self.deploy_pack_id_override,
108 deploy_flow_id_override: self.deploy_flow_id_override,
109 }
110 }
111}
112
113#[derive(Debug, Clone, serde::Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct AzureContainerAppsExtConfig {
120 pub location: String,
121 pub key_vault_uri: String,
122 pub key_vault_id: String,
123 pub environment: String,
124 pub operator_image_digest: String,
125 pub bundle_source: String,
126 pub bundle_digest: String,
127 pub remote_state_backend: String,
128 pub dns_name: Option<String>,
129 pub public_base_url: Option<String>,
130 pub repo_registry_base: Option<String>,
131 pub store_registry_base: Option<String>,
132 pub admin_allowed_clients: Option<String>,
133 #[serde(default = "default_ext_tenant")]
134 pub tenant: String,
135}
136
137fn default_ext_tenant() -> String {
138 "default".to_string()
139}
140
141pub fn resolve_config(request: AzureRequest) -> Result<DeployerConfig> {
142 DeployerConfig::resolve(request.into_deployer_request())
143}
144
145pub fn ensure_azure_config(config: &DeployerConfig) -> Result<()> {
146 if config.provider != Provider::Azure || config.strategy != "iac-only" {
147 return Err(DeployerError::Config(format!(
148 "azure adapter requires provider=azure strategy=iac-only, got provider={} strategy={}",
149 config.provider.as_str(),
150 config.strategy
151 )));
152 }
153 Ok(())
154}
155
156fn build_azure_request_from_ext(
158 capability: DeployerCapability,
159 cfg: &AzureContainerAppsExtConfig,
160 pack_path: Option<&std::path::Path>,
161) -> AzureRequest {
162 AzureRequest {
163 capability,
164 tenant: cfg.tenant.clone(),
165 pack_path: pack_path
166 .map(std::path::Path::to_path_buf)
167 .unwrap_or_default(),
168 bundle_root: None,
169 bundle_source: Some(cfg.bundle_source.clone()),
170 bundle_digest: Some(cfg.bundle_digest.clone()),
171 repo_registry_base: cfg.repo_registry_base.clone(),
172 store_registry_base: cfg.store_registry_base.clone(),
173 provider_pack: None,
174 deploy_pack_id_override: None,
175 deploy_flow_id_override: None,
176 environment: Some(cfg.environment.clone()),
177 pack_id: None,
178 pack_version: None,
179 pack_digest: None,
180 distributor_url: None,
181 distributor_token: None,
182 preview: false,
183 dry_run: false,
184 execute_local: true,
185 output: crate::config::OutputFormat::Text,
186 config_path: None,
187 allow_remote_in_offline: false,
188 providers_dir: std::path::PathBuf::from("providers/deployer"),
189 packs_dir: std::path::PathBuf::from("packs"),
190 }
191}
192
193pub fn apply_from_ext(
200 config_json: &str,
201 _creds_json: &str,
202 pack_path: Option<&std::path::Path>,
203) -> anyhow::Result<()> {
204 use anyhow::Context;
205 let cfg: AzureContainerAppsExtConfig =
206 serde_json::from_str(config_json).context("parse azure container-apps config JSON")?;
207 let request = build_azure_request_from_ext(DeployerCapability::Apply, &cfg, pack_path);
208 let config = resolve_config(request).context("resolve Azure deployer config")?;
209 let rt = tokio::runtime::Runtime::new().context("create tokio runtime for Azure deploy")?;
210 let _outcome = rt
211 .block_on(crate::apply::run(config))
212 .context("run Azure deployment pipeline")?;
213 Ok(())
214}
215
216pub fn destroy_from_ext(
218 config_json: &str,
219 _creds_json: &str,
220 pack_path: Option<&std::path::Path>,
221) -> anyhow::Result<()> {
222 use anyhow::Context;
223 let cfg: AzureContainerAppsExtConfig =
224 serde_json::from_str(config_json).context("parse azure container-apps config JSON")?;
225 let request = build_azure_request_from_ext(DeployerCapability::Destroy, &cfg, pack_path);
226 let config = resolve_config(request).context("resolve Azure deployer config")?;
227 let rt = tokio::runtime::Runtime::new().context("create tokio runtime for Azure destroy")?;
228 let _outcome = rt
229 .block_on(crate::apply::run(config))
230 .context("run Azure destroy pipeline")?;
231 Ok(())
232}
233
234pub async fn run(request: AzureRequest) -> Result<multi_target::OperationResult> {
235 let config = resolve_config(request)?;
236 run_config(config).await
237}
238
239pub async fn run_config(config: DeployerConfig) -> Result<multi_target::OperationResult> {
240 ensure_azure_config(&config)?;
241 promote_runtime_secrets_for_apply(&config).await?;
242 multi_target::run(config).await
243}
244
245pub async fn run_with_plan(
246 request: AzureRequest,
247 plan: PlanContext,
248) -> Result<multi_target::OperationResult> {
249 let config = resolve_config(request)?;
250 run_config_with_plan(config, plan).await
251}
252
253pub async fn run_config_with_plan(
254 config: DeployerConfig,
255 plan: PlanContext,
256) -> Result<multi_target::OperationResult> {
257 ensure_azure_config(&config)?;
258 promote_runtime_secrets_for_apply(&config).await?;
259 multi_target::run_with_plan(config, plan).await
260}
261
262async fn promote_runtime_secrets_for_apply(config: &DeployerConfig) -> Result<()> {
263 let Some(resolution) = resolve_for_cloud_apply(config).await? else {
264 return Ok(());
265 };
266 let vault_name = azure_key_vault_name()?;
267 let prefix = default_cloud_secret_prefix(&config.environment, &config.tenant, None);
268 promote_to_azure_key_vault(&resolution.resolved, &vault_name, &prefix).await?;
269 Ok(())
270}
271
272async fn promote_to_azure_key_vault(
273 resolved: &[ResolvedRuntimeSecret],
274 vault_name: &str,
275 prefix: &str,
276) -> Result<PromoteRuntimeSecretsReport> {
277 let mut report = PromoteRuntimeSecretsReport::default();
278 for secret in resolved {
279 let remote_name = flat_cloud_secret_name(
280 prefix,
281 &secret.requirement.provider_id,
282 &secret.requirement.key,
283 127,
284 );
285 set_azure_key_vault_secret(vault_name, &remote_name, secret.value.expose())?;
286 report
287 .promoted
288 .push(crate::runtime_secrets::PromotedRuntimeSecret {
289 uri: secret.requirement.uri.clone(),
290 remote_name,
291 });
292 }
293 Ok(report)
294}
295
296fn azure_key_vault_name() -> Result<String> {
297 if let Some(value) = std::env::var("GREENTIC_DEPLOY_TERRAFORM_VAR_AZURE_KEY_VAULT_NAME")
298 .ok()
299 .map(|value| value.trim().to_string())
300 .filter(|value| !value.is_empty())
301 {
302 return Ok(value);
303 }
304 if let Some(value) = std::env::var("GREENTIC_DEPLOY_TERRAFORM_VAR_AZURE_KEY_VAULT_URI")
305 .ok()
306 .and_then(|value| key_vault_name_from_uri(&value))
307 {
308 return Ok(value);
309 }
310 if let Some(value) = std::env::var("GREENTIC_DEPLOY_TERRAFORM_VAR_AZURE_KEY_VAULT_ID")
311 .ok()
312 .and_then(|value| key_vault_name_from_id(&value))
313 {
314 return Ok(value);
315 }
316 Err(DeployerError::Config(
317 "Azure runtime secret promotion requires GREENTIC_DEPLOY_TERRAFORM_VAR_AZURE_KEY_VAULT_NAME, _URI, or _ID"
318 .to_string(),
319 ))
320}
321
322fn key_vault_name_from_uri(uri: &str) -> Option<String> {
323 let host = uri
324 .trim()
325 .trim_end_matches('/')
326 .strip_prefix("https://")
327 .unwrap_or(uri.trim())
328 .split('/')
329 .next()?;
330 host.split('.')
331 .next()
332 .map(str::trim)
333 .filter(|value| !value.is_empty())
334 .map(ToOwned::to_owned)
335}
336
337fn key_vault_name_from_id(id: &str) -> Option<String> {
338 id.trim()
339 .trim_end_matches('/')
340 .rsplit('/')
341 .next()
342 .map(str::trim)
343 .filter(|value| !value.is_empty())
344 .map(ToOwned::to_owned)
345}
346
347fn set_azure_key_vault_secret(vault_name: &str, secret_name: &str, value: &str) -> Result<()> {
348 let mut temp = tempfile::NamedTempFile::new()
349 .map_err(|err| DeployerError::Other(format!("create temporary secret file: {err}")))?;
350 temp.write_all(value.as_bytes())?;
351 temp.flush()?;
352
353 let status = ProcessCommand::new("az")
354 .args([
355 "keyvault",
356 "secret",
357 "set",
358 "--vault-name",
359 vault_name,
360 "--name",
361 secret_name,
362 "--file",
363 temp.path().to_str().ok_or_else(|| {
364 DeployerError::Other("temporary secret path is not UTF-8".to_string())
365 })?,
366 "--only-show-errors",
367 "--output",
368 "none",
369 ])
370 .stdout(Stdio::null())
371 .stderr(Stdio::piped())
372 .status()
373 .map_err(|err| DeployerError::Other(format!("run az keyvault secret set: {err}")))?;
374 if status.success() {
375 Ok(())
376 } else {
377 Err(DeployerError::Other(format!(
378 "set Azure Key Vault secret {secret_name} in vault {vault_name} failed"
379 )))
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
388 fn azure_request_defaults_to_azure_iac_target() {
389 let request =
390 AzureRequest::new(DeployerCapability::Plan, "acme", PathBuf::from("pack-dir"))
391 .into_deployer_request();
392
393 assert_eq!(request.provider, Provider::Azure);
394 assert_eq!(request.strategy, "iac-only");
395 assert_eq!(request.tenant, "acme");
396 }
397
398 #[test]
399 fn azure_request_preserves_all_passthrough_fields() {
400 let mut request =
401 AzureRequest::new(DeployerCapability::Apply, "acme", PathBuf::from("pack-dir"));
402 request.bundle_root = Some(PathBuf::from("bundle-root"));
403 request.bundle_source = Some("azblob://container/bundle.gtbundle".into());
404 request.bundle_digest = Some("sha256:abc".into());
405 request.repo_registry_base = Some("https://repo.example".into());
406 request.store_registry_base = Some("https://store.example".into());
407 request.provider_pack = Some(PathBuf::from("providers/deployer/azure.gtpack"));
408 request.deploy_pack_id_override = Some("greentic.deploy.azure".into());
409 request.deploy_flow_id_override = Some("apply_terraform".into());
410 request.environment = Some("prod".into());
411 request.pack_id = Some("pack-id".into());
412 request.pack_version = Some("1.2.3".into());
413 request.pack_digest = Some("sha256:def".into());
414 request.distributor_url = Some("https://dist.example".into());
415 request.distributor_token = Some("token".into());
416 request.preview = true;
417 request.dry_run = true;
418 request.execute_local = true;
419 request.output = OutputFormat::Json;
420 request.config_path = Some(PathBuf::from("greentic.toml"));
421 request.allow_remote_in_offline = true;
422 request.providers_dir = PathBuf::from("providers");
423 request.packs_dir = PathBuf::from("packs-dir");
424
425 let deployer = request.into_deployer_request();
426
427 assert_eq!(deployer.capability, DeployerCapability::Apply);
428 assert_eq!(deployer.provider, Provider::Azure);
429 assert_eq!(
430 deployer.bundle_root.as_deref(),
431 Some(std::path::Path::new("bundle-root"))
432 );
433 assert_eq!(
434 deployer.bundle_source.as_deref(),
435 Some("azblob://container/bundle.gtbundle")
436 );
437 assert_eq!(deployer.bundle_digest.as_deref(), Some("sha256:abc"));
438 assert_eq!(
439 deployer.repo_registry_base.as_deref(),
440 Some("https://repo.example")
441 );
442 assert_eq!(
443 deployer.store_registry_base.as_deref(),
444 Some("https://store.example")
445 );
446 assert_eq!(
447 deployer.provider_pack.as_deref(),
448 Some(std::path::Path::new("providers/deployer/azure.gtpack"))
449 );
450 assert_eq!(
451 deployer.deploy_pack_id_override.as_deref(),
452 Some("greentic.deploy.azure")
453 );
454 assert_eq!(
455 deployer.deploy_flow_id_override.as_deref(),
456 Some("apply_terraform")
457 );
458 assert_eq!(deployer.environment.as_deref(), Some("prod"));
459 assert_eq!(deployer.pack_id.as_deref(), Some("pack-id"));
460 assert_eq!(deployer.pack_version.as_deref(), Some("1.2.3"));
461 assert_eq!(deployer.pack_digest.as_deref(), Some("sha256:def"));
462 assert_eq!(
463 deployer.distributor_url.as_deref(),
464 Some("https://dist.example")
465 );
466 assert_eq!(deployer.distributor_token.as_deref(), Some("token"));
467 assert!(deployer.preview);
468 assert!(deployer.dry_run);
469 assert!(deployer.execute_local);
470 assert_eq!(deployer.output, OutputFormat::Json);
471 assert_eq!(
472 deployer.config_path.as_deref(),
473 Some(std::path::Path::new("greentic.toml"))
474 );
475 assert!(deployer.allow_remote_in_offline);
476 assert_eq!(deployer.providers_dir, PathBuf::from("providers"));
477 assert_eq!(deployer.packs_dir, PathBuf::from("packs-dir"));
478 }
479
480 #[test]
481 fn ensure_azure_config_rejects_non_azure_provider() {
482 let tmp = tempfile::tempdir().expect("tempdir");
483 let mut request = AzureRequest::new(DeployerCapability::Plan, "acme", tmp.path().into())
484 .into_deployer_request();
485 request.provider = Provider::Gcp;
486 let config = DeployerConfig::resolve(request).expect("resolve config");
487
488 let err = ensure_azure_config(&config).expect_err("non-azure config should fail");
489 assert!(
490 err.to_string().contains("provider=gcp strategy=iac-only"),
491 "got: {err}"
492 );
493 }
494
495 #[test]
496 fn ensure_azure_config_accepts_azure_iac_config() {
497 let tmp = tempfile::tempdir().expect("tempdir");
498 let request = AzureRequest::new(DeployerCapability::Plan, "acme", tmp.path().into())
499 .into_deployer_request();
500 let config = DeployerConfig::resolve(request).expect("resolve config");
501
502 ensure_azure_config(&config).expect("azure config");
503 }
504
505 #[test]
506 fn parses_key_vault_name_from_uri_and_id() {
507 assert_eq!(
508 key_vault_name_from_uri("https://my-vault.vault.azure.net/").as_deref(),
509 Some("my-vault")
510 );
511 assert_eq!(
512 key_vault_name_from_id(
513 "/subscriptions/aaa/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/my-vault"
514 )
515 .as_deref(),
516 Some("my-vault")
517 );
518 }
519
520 #[test]
521 fn ext_config_parses_minimum_fields() {
522 let json = r#"{
523 "location": "eastus",
524 "keyVaultUri": "https://my-vault.vault.azure.net/",
525 "keyVaultId": "/subscriptions/aaa/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/my-vault",
526 "environment": "staging",
527 "operatorImageDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
528 "bundleSource": "oci://registry.example/acme/prod-bundle@sha256:1111111111111111111111111111111111111111111111111111111111111111",
529 "bundleDigest": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
530 "remoteStateBackend": "azurerm://storage/container/key"
531 }"#;
532 let cfg: AzureContainerAppsExtConfig = serde_json::from_str(json).unwrap();
533 assert_eq!(cfg.location, "eastus");
534 assert_eq!(cfg.key_vault_uri, "https://my-vault.vault.azure.net/");
535 assert_eq!(cfg.tenant, "default");
536 assert!(cfg.dns_name.is_none());
537 }
538
539 #[test]
540 fn ext_config_accepts_all_optionals() {
541 let json = r#"{
542 "location": "eastus",
543 "keyVaultUri": "https://my-vault.vault.azure.net/",
544 "keyVaultId": "/subscriptions/aaa/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/my-vault",
545 "environment": "prod",
546 "operatorImageDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
547 "bundleSource": "oci://...",
548 "bundleDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
549 "remoteStateBackend": "azurerm://...",
550 "dnsName": "api.example.com",
551 "publicBaseUrl": "https://api.example.com",
552 "repoRegistryBase": "https://repo.example.com",
553 "storeRegistryBase": "https://store.example.com",
554 "adminAllowedClients": "CN=admin",
555 "tenant": "acme"
556 }"#;
557 let cfg: AzureContainerAppsExtConfig = serde_json::from_str(json).unwrap();
558 assert_eq!(cfg.dns_name.as_deref(), Some("api.example.com"));
559 assert_eq!(cfg.tenant, "acme");
560 }
561
562 #[test]
563 fn build_azure_request_from_ext_maps_cloud_bundle_fields() {
564 let cfg = AzureContainerAppsExtConfig {
565 location: "eastus".to_string(),
566 key_vault_uri: "https://my-vault.vault.azure.net/".to_string(),
567 key_vault_id:
568 "/subscriptions/aaa/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/my-vault"
569 .to_string(),
570 environment: "prod".to_string(),
571 operator_image_digest: "sha256:0000".to_string(),
572 bundle_source: "oci://registry.example/acme/prod".to_string(),
573 bundle_digest: "sha256:1111".to_string(),
574 remote_state_backend: "azurerm://state/prod".to_string(),
575 dns_name: Some("api.example.com".to_string()),
576 public_base_url: Some("https://api.example.com".to_string()),
577 repo_registry_base: Some("https://repo.example.com".to_string()),
578 store_registry_base: Some("https://store.example.com".to_string()),
579 admin_allowed_clients: Some("CN=admin".to_string()),
580 tenant: "acme".to_string(),
581 };
582
583 let request = build_azure_request_from_ext(
584 DeployerCapability::Destroy,
585 &cfg,
586 Some(std::path::Path::new("pack")),
587 );
588
589 assert_eq!(request.capability, DeployerCapability::Destroy);
590 assert_eq!(request.tenant, "acme");
591 assert_eq!(request.pack_path, PathBuf::from("pack"));
592 assert_eq!(
593 request.bundle_source.as_deref(),
594 Some("oci://registry.example/acme/prod")
595 );
596 assert_eq!(request.bundle_digest.as_deref(), Some("sha256:1111"));
597 assert_eq!(
598 request.repo_registry_base.as_deref(),
599 Some("https://repo.example.com")
600 );
601 assert_eq!(
602 request.store_registry_base.as_deref(),
603 Some("https://store.example.com")
604 );
605 assert_eq!(request.environment.as_deref(), Some("prod"));
606 assert!(request.execute_local);
607 }
608
609 #[test]
610 fn ext_config_rejects_missing_location() {
611 let json = r#"{
612 "keyVaultUri": "https://my-vault.vault.azure.net/",
613 "keyVaultId": "/subscriptions/aaa/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/my-vault",
614 "environment": "staging",
615 "operatorImageDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
616 "bundleSource": "oci://...",
617 "bundleDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
618 "remoteStateBackend": "azurerm://..."
619 }"#;
620 let err = serde_json::from_str::<AzureContainerAppsExtConfig>(json).unwrap_err();
621 let msg = format!("{err}");
622 assert!(msg.contains("location"), "got: {msg}");
623 }
624
625 #[test]
626 fn apply_from_ext_rejects_invalid_json() {
627 let err = apply_from_ext("not json", "{}", None).unwrap_err();
628 assert!(format!("{err}").contains("parse"), "got: {err}");
629 }
630
631 #[test]
632 fn apply_from_ext_rejects_missing_required_field() {
633 let json = r#"{"location":"eastus"}"#;
634 let err = apply_from_ext(json, "{}", None).unwrap_err();
635 let msg = format!("{err:#}");
636 assert!(
637 msg.contains("missing field")
638 || msg.contains("keyVaultUri")
639 || msg.contains("key_vault_uri"),
640 "got: {msg}"
641 );
642 }
643
644 #[test]
645 fn destroy_from_ext_rejects_invalid_json() {
646 let err = destroy_from_ext("not json", "{}", None).unwrap_err();
647 assert!(format!("{err}").contains("parse"), "got: {err}");
648 }
649}