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: 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#[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.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
191pub 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
214pub 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}