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: 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/// 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
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
193/// Extension-driven apply entry point: parse JSON config, build request,
194/// delegate to existing `resolve_config` + `apply::run` pipeline.
195///
196/// `_creds_json` is reserved for future secret URI resolution (Phase B #2);
197/// today, Azure credentials come from the ambient Azure auth chain
198/// (`az login`, `AZURE_*` env vars, or managed identity).
199pub 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
216/// Extension-driven destroy entry point.
217pub 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}