Skip to main content

greentic_deployer/
azure.rs

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/// Library-facing request for the explicit Azure adapter surface.
16#[derive(Debug, Clone)]
17pub struct AzureRequest {
18    pub capability: DeployerCapability,
19    pub tenant: String,
20    pub pack_path: Option<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: Option<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/// Configuration shape consumed by `ext apply --target azure-container-apps-local`.
114///
115/// Mirrors the JSON schema declared by the `deploy-azure` reference extension.
116/// Keys use camelCase on the wire; Rust field names use snake_case with serde rename.
117#[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
156/// Build an `AzureRequest` from the extension-provided config.
157fn 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.map(std::path::Path::to_path_buf),
166        bundle_root: None,
167        bundle_source: Some(cfg.bundle_source.clone()),
168        bundle_digest: Some(cfg.bundle_digest.clone()),
169        repo_registry_base: cfg.repo_registry_base.clone(),
170        store_registry_base: cfg.store_registry_base.clone(),
171        provider_pack: None,
172        deploy_pack_id_override: None,
173        deploy_flow_id_override: None,
174        environment: Some(cfg.environment.clone()),
175        pack_id: None,
176        pack_version: None,
177        pack_digest: None,
178        distributor_url: None,
179        distributor_token: None,
180        preview: false,
181        dry_run: false,
182        execute_local: true,
183        output: crate::config::OutputFormat::Text,
184        config_path: None,
185        allow_remote_in_offline: false,
186        providers_dir: std::path::PathBuf::from("providers/deployer"),
187        packs_dir: std::path::PathBuf::from("packs"),
188    }
189}
190
191/// Extension-driven apply entry point: parse JSON config, build request,
192/// delegate to existing `resolve_config` + `apply::run` pipeline.
193///
194/// `_creds_json` is reserved for future secret URI resolution (Phase B #2);
195/// today, Azure credentials come from the ambient Azure auth chain
196/// (`az login`, `AZURE_*` env vars, or managed identity).
197pub fn apply_from_ext(
198    config_json: &str,
199    _creds_json: &str,
200    pack_path: Option<&std::path::Path>,
201) -> anyhow::Result<()> {
202    use anyhow::Context;
203    let cfg: AzureContainerAppsExtConfig =
204        serde_json::from_str(config_json).context("parse azure container-apps config JSON")?;
205    let request = build_azure_request_from_ext(DeployerCapability::Apply, &cfg, pack_path);
206    let config = resolve_config(request).context("resolve Azure deployer config")?;
207    let rt = tokio::runtime::Runtime::new().context("create tokio runtime for Azure deploy")?;
208    let _outcome = rt
209        .block_on(crate::apply::run(config))
210        .context("run Azure deployment pipeline")?;
211    Ok(())
212}
213
214/// Extension-driven destroy entry point.
215pub fn destroy_from_ext(
216    config_json: &str,
217    _creds_json: &str,
218    pack_path: Option<&std::path::Path>,
219) -> anyhow::Result<()> {
220    use anyhow::Context;
221    let cfg: AzureContainerAppsExtConfig =
222        serde_json::from_str(config_json).context("parse azure container-apps config JSON")?;
223    let request = build_azure_request_from_ext(DeployerCapability::Destroy, &cfg, pack_path);
224    let config = resolve_config(request).context("resolve Azure deployer config")?;
225    let rt = tokio::runtime::Runtime::new().context("create tokio runtime for Azure destroy")?;
226    let _outcome = rt
227        .block_on(crate::apply::run(config))
228        .context("run Azure destroy pipeline")?;
229    Ok(())
230}
231
232pub async fn run(request: AzureRequest) -> Result<multi_target::OperationResult> {
233    let config = resolve_config(request)?;
234    run_config(config).await
235}
236
237pub async fn run_config(config: DeployerConfig) -> Result<multi_target::OperationResult> {
238    ensure_azure_config(&config)?;
239    promote_runtime_secrets_for_apply(&config).await?;
240    multi_target::run(config).await
241}
242
243pub async fn run_with_plan(
244    request: AzureRequest,
245    plan: PlanContext,
246) -> Result<multi_target::OperationResult> {
247    let config = resolve_config(request)?;
248    run_config_with_plan(config, plan).await
249}
250
251pub async fn run_config_with_plan(
252    config: DeployerConfig,
253    plan: PlanContext,
254) -> Result<multi_target::OperationResult> {
255    ensure_azure_config(&config)?;
256    promote_runtime_secrets_for_apply(&config).await?;
257    multi_target::run_with_plan(config, plan).await
258}
259
260async fn promote_runtime_secrets_for_apply(config: &DeployerConfig) -> Result<()> {
261    let Some(resolution) = resolve_for_cloud_apply(config).await? else {
262        return Ok(());
263    };
264    let vault_name = azure_key_vault_name()?;
265    let prefix = default_cloud_secret_prefix(&config.environment, &config.tenant, None);
266    promote_to_azure_key_vault(&resolution.resolved, &vault_name, &prefix).await?;
267    Ok(())
268}
269
270async fn promote_to_azure_key_vault(
271    resolved: &[ResolvedRuntimeSecret],
272    vault_name: &str,
273    prefix: &str,
274) -> Result<PromoteRuntimeSecretsReport> {
275    let mut report = PromoteRuntimeSecretsReport::default();
276    for secret in resolved {
277        let remote_name = flat_cloud_secret_name(
278            prefix,
279            &secret.requirement.provider_id,
280            &secret.requirement.key,
281            127,
282        );
283        set_azure_key_vault_secret(vault_name, &remote_name, secret.value.expose())?;
284        report
285            .promoted
286            .push(crate::runtime_secrets::PromotedRuntimeSecret {
287                uri: secret.requirement.uri.clone(),
288                remote_name,
289            });
290    }
291    Ok(report)
292}
293
294fn azure_key_vault_name() -> Result<String> {
295    if let Some(value) = std::env::var("GREENTIC_DEPLOY_TERRAFORM_VAR_AZURE_KEY_VAULT_NAME")
296        .ok()
297        .map(|value| value.trim().to_string())
298        .filter(|value| !value.is_empty())
299    {
300        return Ok(value);
301    }
302    if let Some(value) = std::env::var("GREENTIC_DEPLOY_TERRAFORM_VAR_AZURE_KEY_VAULT_URI")
303        .ok()
304        .and_then(|value| key_vault_name_from_uri(&value))
305    {
306        return Ok(value);
307    }
308    if let Some(value) = std::env::var("GREENTIC_DEPLOY_TERRAFORM_VAR_AZURE_KEY_VAULT_ID")
309        .ok()
310        .and_then(|value| key_vault_name_from_id(&value))
311    {
312        return Ok(value);
313    }
314    Err(DeployerError::Config(
315        "Azure runtime secret promotion requires GREENTIC_DEPLOY_TERRAFORM_VAR_AZURE_KEY_VAULT_NAME, _URI, or _ID"
316            .to_string(),
317    ))
318}
319
320fn key_vault_name_from_uri(uri: &str) -> Option<String> {
321    let host = uri
322        .trim()
323        .trim_end_matches('/')
324        .strip_prefix("https://")
325        .unwrap_or(uri.trim())
326        .split('/')
327        .next()?;
328    host.split('.')
329        .next()
330        .map(str::trim)
331        .filter(|value| !value.is_empty())
332        .map(ToOwned::to_owned)
333}
334
335fn key_vault_name_from_id(id: &str) -> Option<String> {
336    id.trim()
337        .trim_end_matches('/')
338        .rsplit('/')
339        .next()
340        .map(str::trim)
341        .filter(|value| !value.is_empty())
342        .map(ToOwned::to_owned)
343}
344
345fn set_azure_key_vault_secret(vault_name: &str, secret_name: &str, value: &str) -> Result<()> {
346    let mut temp = tempfile::NamedTempFile::new()
347        .map_err(|err| DeployerError::Other(format!("create temporary secret file: {err}")))?;
348    temp.write_all(value.as_bytes())?;
349    temp.flush()?;
350
351    let status = ProcessCommand::new("az")
352        .args([
353            "keyvault",
354            "secret",
355            "set",
356            "--vault-name",
357            vault_name,
358            "--name",
359            secret_name,
360            "--file",
361            temp.path().to_str().ok_or_else(|| {
362                DeployerError::Other("temporary secret path is not UTF-8".to_string())
363            })?,
364            "--only-show-errors",
365            "--output",
366            "none",
367        ])
368        .stdout(Stdio::null())
369        .stderr(Stdio::piped())
370        .status()
371        .map_err(|err| DeployerError::Other(format!("run az keyvault secret set: {err}")))?;
372    if status.success() {
373        Ok(())
374    } else {
375        Err(DeployerError::Other(format!(
376            "set Azure Key Vault secret {secret_name} in vault {vault_name} failed"
377        )))
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn azure_request_defaults_to_azure_iac_target() {
387        let request = AzureRequest::new(
388            DeployerCapability::Plan,
389            "acme",
390            Some(PathBuf::from("pack-dir")),
391        )
392        .into_deployer_request();
393
394        assert_eq!(request.provider, Provider::Azure);
395        assert_eq!(request.strategy, "iac-only");
396        assert_eq!(request.tenant, "acme");
397    }
398
399    #[test]
400    fn azure_request_preserves_all_passthrough_fields() {
401        let mut request = AzureRequest::new(
402            DeployerCapability::Apply,
403            "acme",
404            Some(PathBuf::from("pack-dir")),
405        );
406        request.bundle_root = Some(PathBuf::from("bundle-root"));
407        request.bundle_source = Some("azblob://container/bundle.gtbundle".into());
408        request.bundle_digest = Some("sha256:abc".into());
409        request.repo_registry_base = Some("https://repo.example".into());
410        request.store_registry_base = Some("https://store.example".into());
411        request.provider_pack = Some(PathBuf::from("providers/deployer/azure.gtpack"));
412        request.deploy_pack_id_override = Some("greentic.deploy.azure".into());
413        request.deploy_flow_id_override = Some("apply_terraform".into());
414        request.environment = Some("prod".into());
415        request.pack_id = Some("pack-id".into());
416        request.pack_version = Some("1.2.3".into());
417        request.pack_digest = Some("sha256:def".into());
418        request.distributor_url = Some("https://dist.example".into());
419        request.distributor_token = Some("token".into());
420        request.preview = true;
421        request.dry_run = true;
422        request.execute_local = true;
423        request.output = OutputFormat::Json;
424        request.config_path = Some(PathBuf::from("greentic.toml"));
425        request.allow_remote_in_offline = true;
426        request.providers_dir = PathBuf::from("providers");
427        request.packs_dir = PathBuf::from("packs-dir");
428
429        let deployer = request.into_deployer_request();
430
431        assert_eq!(deployer.capability, DeployerCapability::Apply);
432        assert_eq!(deployer.provider, Provider::Azure);
433        assert_eq!(
434            deployer.bundle_root.as_deref(),
435            Some(std::path::Path::new("bundle-root"))
436        );
437        assert_eq!(
438            deployer.bundle_source.as_deref(),
439            Some("azblob://container/bundle.gtbundle")
440        );
441        assert_eq!(deployer.bundle_digest.as_deref(), Some("sha256:abc"));
442        assert_eq!(
443            deployer.repo_registry_base.as_deref(),
444            Some("https://repo.example")
445        );
446        assert_eq!(
447            deployer.store_registry_base.as_deref(),
448            Some("https://store.example")
449        );
450        assert_eq!(
451            deployer.provider_pack.as_deref(),
452            Some(std::path::Path::new("providers/deployer/azure.gtpack"))
453        );
454        assert_eq!(
455            deployer.deploy_pack_id_override.as_deref(),
456            Some("greentic.deploy.azure")
457        );
458        assert_eq!(
459            deployer.deploy_flow_id_override.as_deref(),
460            Some("apply_terraform")
461        );
462        assert_eq!(deployer.environment.as_deref(), Some("prod"));
463        assert_eq!(deployer.pack_id.as_deref(), Some("pack-id"));
464        assert_eq!(deployer.pack_version.as_deref(), Some("1.2.3"));
465        assert_eq!(deployer.pack_digest.as_deref(), Some("sha256:def"));
466        assert_eq!(
467            deployer.distributor_url.as_deref(),
468            Some("https://dist.example")
469        );
470        assert_eq!(deployer.distributor_token.as_deref(), Some("token"));
471        assert!(deployer.preview);
472        assert!(deployer.dry_run);
473        assert!(deployer.execute_local);
474        assert_eq!(deployer.output, OutputFormat::Json);
475        assert_eq!(
476            deployer.config_path.as_deref(),
477            Some(std::path::Path::new("greentic.toml"))
478        );
479        assert!(deployer.allow_remote_in_offline);
480        assert_eq!(deployer.providers_dir, PathBuf::from("providers"));
481        assert_eq!(deployer.packs_dir, PathBuf::from("packs-dir"));
482    }
483
484    #[test]
485    fn ensure_azure_config_rejects_non_azure_provider() {
486        let tmp = tempfile::tempdir().expect("tempdir");
487        let mut request = AzureRequest::new(
488            DeployerCapability::Plan,
489            "acme",
490            Some(tmp.path().to_path_buf()),
491        )
492        .into_deployer_request();
493        request.provider = Provider::Gcp;
494        let config = DeployerConfig::resolve(request).expect("resolve config");
495
496        let err = ensure_azure_config(&config).expect_err("non-azure config should fail");
497        assert!(
498            err.to_string().contains("provider=gcp strategy=iac-only"),
499            "got: {err}"
500        );
501    }
502
503    #[test]
504    fn ensure_azure_config_accepts_azure_iac_config() {
505        let tmp = tempfile::tempdir().expect("tempdir");
506        let request = AzureRequest::new(
507            DeployerCapability::Plan,
508            "acme",
509            Some(tmp.path().to_path_buf()),
510        )
511        .into_deployer_request();
512        let config = DeployerConfig::resolve(request).expect("resolve config");
513
514        ensure_azure_config(&config).expect("azure config");
515    }
516
517    #[test]
518    fn parses_key_vault_name_from_uri_and_id() {
519        assert_eq!(
520            key_vault_name_from_uri("https://my-vault.vault.azure.net/").as_deref(),
521            Some("my-vault")
522        );
523        assert_eq!(
524            key_vault_name_from_id(
525                "/subscriptions/aaa/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/my-vault"
526            )
527            .as_deref(),
528            Some("my-vault")
529        );
530    }
531
532    #[test]
533    fn ext_config_parses_minimum_fields() {
534        let json = r#"{
535            "location": "eastus",
536            "keyVaultUri": "https://my-vault.vault.azure.net/",
537            "keyVaultId": "/subscriptions/aaa/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/my-vault",
538            "environment": "staging",
539            "operatorImageDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
540            "bundleSource": "oci://registry.example/acme/prod-bundle@sha256:1111111111111111111111111111111111111111111111111111111111111111",
541            "bundleDigest": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
542            "remoteStateBackend": "azurerm://storage/container/key"
543        }"#;
544        let cfg: AzureContainerAppsExtConfig = serde_json::from_str(json).unwrap();
545        assert_eq!(cfg.location, "eastus");
546        assert_eq!(cfg.key_vault_uri, "https://my-vault.vault.azure.net/");
547        assert_eq!(cfg.tenant, "default");
548        assert!(cfg.dns_name.is_none());
549    }
550
551    #[test]
552    fn ext_config_accepts_all_optionals() {
553        let json = r#"{
554            "location": "eastus",
555            "keyVaultUri": "https://my-vault.vault.azure.net/",
556            "keyVaultId": "/subscriptions/aaa/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/my-vault",
557            "environment": "prod",
558            "operatorImageDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
559            "bundleSource": "oci://...",
560            "bundleDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
561            "remoteStateBackend": "azurerm://...",
562            "dnsName": "api.example.com",
563            "publicBaseUrl": "https://api.example.com",
564            "repoRegistryBase": "https://repo.example.com",
565            "storeRegistryBase": "https://store.example.com",
566            "adminAllowedClients": "CN=admin",
567            "tenant": "acme"
568        }"#;
569        let cfg: AzureContainerAppsExtConfig = serde_json::from_str(json).unwrap();
570        assert_eq!(cfg.dns_name.as_deref(), Some("api.example.com"));
571        assert_eq!(cfg.tenant, "acme");
572    }
573
574    #[test]
575    fn build_azure_request_from_ext_maps_cloud_bundle_fields() {
576        let cfg = AzureContainerAppsExtConfig {
577            location: "eastus".to_string(),
578            key_vault_uri: "https://my-vault.vault.azure.net/".to_string(),
579            key_vault_id:
580                "/subscriptions/aaa/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/my-vault"
581                    .to_string(),
582            environment: "prod".to_string(),
583            operator_image_digest: "sha256:0000".to_string(),
584            bundle_source: "oci://registry.example/acme/prod".to_string(),
585            bundle_digest: "sha256:1111".to_string(),
586            remote_state_backend: "azurerm://state/prod".to_string(),
587            dns_name: Some("api.example.com".to_string()),
588            public_base_url: Some("https://api.example.com".to_string()),
589            repo_registry_base: Some("https://repo.example.com".to_string()),
590            store_registry_base: Some("https://store.example.com".to_string()),
591            admin_allowed_clients: Some("CN=admin".to_string()),
592            tenant: "acme".to_string(),
593        };
594
595        let request = build_azure_request_from_ext(
596            DeployerCapability::Destroy,
597            &cfg,
598            Some(std::path::Path::new("pack")),
599        );
600
601        assert_eq!(request.capability, DeployerCapability::Destroy);
602        assert_eq!(request.tenant, "acme");
603        assert_eq!(request.pack_path, Some(PathBuf::from("pack")));
604        assert_eq!(
605            request.bundle_source.as_deref(),
606            Some("oci://registry.example/acme/prod")
607        );
608        assert_eq!(request.bundle_digest.as_deref(), Some("sha256:1111"));
609        assert_eq!(
610            request.repo_registry_base.as_deref(),
611            Some("https://repo.example.com")
612        );
613        assert_eq!(
614            request.store_registry_base.as_deref(),
615            Some("https://store.example.com")
616        );
617        assert_eq!(request.environment.as_deref(), Some("prod"));
618        assert!(request.execute_local);
619    }
620
621    #[test]
622    fn ext_config_rejects_missing_location() {
623        let json = r#"{
624            "keyVaultUri": "https://my-vault.vault.azure.net/",
625            "keyVaultId": "/subscriptions/aaa/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/my-vault",
626            "environment": "staging",
627            "operatorImageDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
628            "bundleSource": "oci://...",
629            "bundleDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
630            "remoteStateBackend": "azurerm://..."
631        }"#;
632        let err = serde_json::from_str::<AzureContainerAppsExtConfig>(json).unwrap_err();
633        let msg = format!("{err}");
634        assert!(msg.contains("location"), "got: {msg}");
635    }
636
637    #[test]
638    fn apply_from_ext_rejects_invalid_json() {
639        let err = apply_from_ext("not json", "{}", None).unwrap_err();
640        assert!(format!("{err}").contains("parse"), "got: {err}");
641    }
642
643    #[test]
644    fn apply_from_ext_rejects_missing_required_field() {
645        let json = r#"{"location":"eastus"}"#;
646        let err = apply_from_ext(json, "{}", None).unwrap_err();
647        let msg = format!("{err:#}");
648        assert!(
649            msg.contains("missing field")
650                || msg.contains("keyVaultUri")
651                || msg.contains("key_vault_uri"),
652            "got: {msg}"
653        );
654    }
655
656    #[test]
657    fn destroy_from_ext_rejects_invalid_json() {
658        let err = destroy_from_ext("not json", "{}", None).unwrap_err();
659        assert!(format!("{err}").contains("parse"), "got: {err}");
660    }
661}