Skip to main content

greentic_deployer/
gcp.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 GCP adapter surface.
16#[derive(Debug, Clone)]
17pub struct GcpRequest {
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 GcpRequest {
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::Gcp,
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 gcp-cloud-run-local`.
114///
115/// Mirrors the JSON schema declared by the `deploy-gcp` 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 GcpCloudRunExtConfig {
120    pub project_id: String,
121    pub region: String,
122    pub environment: String,
123    pub operator_image_digest: String,
124    pub bundle_source: String,
125    pub bundle_digest: String,
126    pub remote_state_backend: String,
127    pub dns_name: Option<String>,
128    pub public_base_url: Option<String>,
129    pub repo_registry_base: Option<String>,
130    pub store_registry_base: Option<String>,
131    pub admin_allowed_clients: Option<String>,
132    #[serde(default = "default_ext_tenant")]
133    pub tenant: String,
134}
135
136fn default_ext_tenant() -> String {
137    "default".to_string()
138}
139
140pub fn resolve_config(request: GcpRequest) -> Result<DeployerConfig> {
141    DeployerConfig::resolve(request.into_deployer_request())
142}
143
144pub fn ensure_gcp_config(config: &DeployerConfig) -> Result<()> {
145    if config.provider != Provider::Gcp || config.strategy != "iac-only" {
146        return Err(DeployerError::Config(format!(
147            "gcp adapter requires provider=gcp strategy=iac-only, got provider={} strategy={}",
148            config.provider.as_str(),
149            config.strategy
150        )));
151    }
152    Ok(())
153}
154
155/// Build a `GcpRequest` from the extension-provided config. Used by
156/// `apply_from_ext` / `destroy_from_ext`. Fields unused by the extension
157/// path default to `None` / `false` / sensible defaults.
158fn build_gcp_request_from_ext(
159    capability: DeployerCapability,
160    cfg: &GcpCloudRunExtConfig,
161    pack_path: Option<&std::path::Path>,
162) -> GcpRequest {
163    GcpRequest {
164        capability,
165        tenant: cfg.tenant.clone(),
166        pack_path: pack_path.map(std::path::Path::to_path_buf),
167        bundle_root: None,
168        bundle_source: Some(cfg.bundle_source.clone()),
169        bundle_digest: Some(cfg.bundle_digest.clone()),
170        repo_registry_base: cfg.repo_registry_base.clone(),
171        store_registry_base: cfg.store_registry_base.clone(),
172        provider_pack: None,
173        deploy_pack_id_override: None,
174        deploy_flow_id_override: None,
175        environment: Some(cfg.environment.clone()),
176        pack_id: None,
177        pack_version: None,
178        pack_digest: None,
179        distributor_url: None,
180        distributor_token: None,
181        preview: false,
182        dry_run: false,
183        execute_local: true,
184        output: crate::config::OutputFormat::Text,
185        config_path: None,
186        allow_remote_in_offline: false,
187        providers_dir: std::path::PathBuf::from("providers/deployer"),
188        packs_dir: std::path::PathBuf::from("packs"),
189    }
190}
191
192/// Extension-driven apply entry point: parse JSON config, build request,
193/// delegate to existing `resolve_config` + `apply::run` pipeline.
194///
195/// `_creds_json` is reserved for future secret URI resolution (Phase B #2);
196/// today, GCP credentials come from the ambient Google Application Default
197/// Credentials (ADC) chain (`gcloud auth application-default login` or
198/// `GOOGLE_APPLICATION_CREDENTIALS`).
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: GcpCloudRunExtConfig =
206        serde_json::from_str(config_json).context("parse gcp cloud-run config JSON")?;
207    let request = build_gcp_request_from_ext(DeployerCapability::Apply, &cfg, pack_path);
208    let config = resolve_config(request).context("resolve GCP deployer config")?;
209    let rt = tokio::runtime::Runtime::new().context("create tokio runtime for GCP deploy")?;
210    let _outcome = rt
211        .block_on(crate::apply::run(config))
212        .context("run GCP deployment pipeline")?;
213    Ok(())
214}
215
216/// Extension-driven destroy entry point: same shape as `apply_from_ext`
217/// with `capability: Destroy`.
218pub fn destroy_from_ext(
219    config_json: &str,
220    _creds_json: &str,
221    pack_path: Option<&std::path::Path>,
222) -> anyhow::Result<()> {
223    use anyhow::Context;
224    let cfg: GcpCloudRunExtConfig =
225        serde_json::from_str(config_json).context("parse gcp cloud-run config JSON")?;
226    let request = build_gcp_request_from_ext(DeployerCapability::Destroy, &cfg, pack_path);
227    let config = resolve_config(request).context("resolve GCP deployer config")?;
228    let rt = tokio::runtime::Runtime::new().context("create tokio runtime for GCP destroy")?;
229    let _outcome = rt
230        .block_on(crate::apply::run(config))
231        .context("run GCP destroy pipeline")?;
232    Ok(())
233}
234
235pub async fn run(request: GcpRequest) -> Result<multi_target::OperationResult> {
236    let config = resolve_config(request)?;
237    run_config(config).await
238}
239
240pub async fn run_config(config: DeployerConfig) -> Result<multi_target::OperationResult> {
241    ensure_gcp_config(&config)?;
242    promote_runtime_secrets_for_apply(&config).await?;
243    multi_target::run(config).await
244}
245
246pub async fn run_with_plan(
247    request: GcpRequest,
248    plan: PlanContext,
249) -> Result<multi_target::OperationResult> {
250    let config = resolve_config(request)?;
251    run_config_with_plan(config, plan).await
252}
253
254pub async fn run_config_with_plan(
255    config: DeployerConfig,
256    plan: PlanContext,
257) -> Result<multi_target::OperationResult> {
258    ensure_gcp_config(&config)?;
259    promote_runtime_secrets_for_apply(&config).await?;
260    multi_target::run_with_plan(config, plan).await
261}
262
263async fn promote_runtime_secrets_for_apply(config: &DeployerConfig) -> Result<()> {
264    let Some(resolution) = resolve_for_cloud_apply(config).await? else {
265        return Ok(());
266    };
267    let project_id = gcp_project_id()?;
268    let prefix = default_cloud_secret_prefix(&config.environment, &config.tenant, None);
269    promote_to_gcp_secret_manager(&resolution.resolved, &project_id, &prefix).await?;
270    Ok(())
271}
272
273async fn promote_to_gcp_secret_manager(
274    resolved: &[ResolvedRuntimeSecret],
275    project_id: &str,
276    prefix: &str,
277) -> Result<PromoteRuntimeSecretsReport> {
278    let mut report = PromoteRuntimeSecretsReport::default();
279    for secret in resolved {
280        let remote_name = flat_cloud_secret_name(
281            prefix,
282            &secret.requirement.provider_id,
283            &secret.requirement.key,
284            255,
285        );
286        ensure_gcp_secret(project_id, &remote_name)?;
287        add_gcp_secret_version(project_id, &remote_name, secret.value.expose())?;
288        report
289            .promoted
290            .push(crate::runtime_secrets::PromotedRuntimeSecret {
291                uri: secret.requirement.uri.clone(),
292                remote_name,
293            });
294    }
295    Ok(report)
296}
297
298fn gcp_project_id() -> Result<String> {
299    std::env::var("GREENTIC_DEPLOY_TERRAFORM_VAR_GCP_PROJECT_ID")
300        .or_else(|_| std::env::var("GOOGLE_CLOUD_PROJECT"))
301        .or_else(|_| std::env::var("GCLOUD_PROJECT"))
302        .map(|value| value.trim().to_string())
303        .ok()
304        .filter(|value| !value.is_empty())
305        .ok_or_else(|| {
306            DeployerError::Config(
307                "GCP runtime secret promotion requires GREENTIC_DEPLOY_TERRAFORM_VAR_GCP_PROJECT_ID, GOOGLE_CLOUD_PROJECT, or GCLOUD_PROJECT"
308                    .to_string(),
309            )
310        })
311}
312
313fn ensure_gcp_secret(project_id: &str, secret_name: &str) -> Result<()> {
314    let status = ProcessCommand::new("gcloud")
315        .args([
316            "secrets",
317            "create",
318            secret_name,
319            "--project",
320            project_id,
321            "--replication-policy",
322            "automatic",
323            "--quiet",
324        ])
325        .stdout(Stdio::null())
326        .stderr(Stdio::piped())
327        .status()
328        .map_err(|err| DeployerError::Other(format!("run gcloud secrets create: {err}")))?;
329    if status.success() {
330        return Ok(());
331    }
332
333    let describe = ProcessCommand::new("gcloud")
334        .args([
335            "secrets",
336            "describe",
337            secret_name,
338            "--project",
339            project_id,
340            "--quiet",
341        ])
342        .stdout(Stdio::null())
343        .stderr(Stdio::null())
344        .status()
345        .map_err(|err| DeployerError::Other(format!("run gcloud secrets describe: {err}")))?;
346    if describe.success() {
347        Ok(())
348    } else {
349        Err(DeployerError::Other(format!(
350            "create GCP Secret Manager secret {secret_name} failed"
351        )))
352    }
353}
354
355fn add_gcp_secret_version(project_id: &str, secret_name: &str, value: &str) -> Result<()> {
356    let mut child = ProcessCommand::new("gcloud")
357        .args([
358            "secrets",
359            "versions",
360            "add",
361            secret_name,
362            "--project",
363            project_id,
364            "--data-file",
365            "-",
366            "--quiet",
367        ])
368        .stdin(Stdio::piped())
369        .stdout(Stdio::null())
370        .stderr(Stdio::piped())
371        .spawn()
372        .map_err(|err| DeployerError::Other(format!("run gcloud secrets versions add: {err}")))?;
373    if let Some(stdin) = child.stdin.as_mut() {
374        stdin.write_all(value.as_bytes())?;
375    }
376    let output = child.wait_with_output().map_err(|err| {
377        DeployerError::Other(format!("wait for gcloud secrets versions add: {err}"))
378    })?;
379    if output.status.success() {
380        Ok(())
381    } else {
382        Err(DeployerError::Other(format!(
383            "add GCP Secret Manager version for {secret_name} failed"
384        )))
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn gcp_request_defaults_to_gcp_iac_target() {
394        let request = GcpRequest::new(
395            DeployerCapability::Plan,
396            "acme",
397            Some(PathBuf::from("pack-dir")),
398        )
399        .into_deployer_request();
400
401        assert_eq!(request.provider, Provider::Gcp);
402        assert_eq!(request.strategy, "iac-only");
403        assert_eq!(request.tenant, "acme");
404    }
405
406    #[test]
407    fn gcp_request_preserves_all_passthrough_fields() {
408        let mut request = GcpRequest::new(
409            DeployerCapability::Apply,
410            "acme",
411            Some(PathBuf::from("pack-dir")),
412        );
413        request.bundle_root = Some(PathBuf::from("bundle-root"));
414        request.bundle_source = Some("gs://bucket/bundle.gtbundle".into());
415        request.bundle_digest = Some("sha256:abc".into());
416        request.repo_registry_base = Some("https://repo.example".into());
417        request.store_registry_base = Some("https://store.example".into());
418        request.provider_pack = Some(PathBuf::from("providers/deployer/gcp.gtpack"));
419        request.deploy_pack_id_override = Some("greentic.deploy.gcp".into());
420        request.deploy_flow_id_override = Some("apply_terraform".into());
421        request.environment = Some("prod".into());
422        request.pack_id = Some("pack-id".into());
423        request.pack_version = Some("1.2.3".into());
424        request.pack_digest = Some("sha256:def".into());
425        request.distributor_url = Some("https://dist.example".into());
426        request.distributor_token = Some("token".into());
427        request.preview = true;
428        request.dry_run = true;
429        request.execute_local = true;
430        request.output = OutputFormat::Yaml;
431        request.config_path = Some(PathBuf::from("greentic.toml"));
432        request.allow_remote_in_offline = true;
433        request.providers_dir = PathBuf::from("providers");
434        request.packs_dir = PathBuf::from("packs-dir");
435
436        let deployer = request.into_deployer_request();
437
438        assert_eq!(deployer.capability, DeployerCapability::Apply);
439        assert_eq!(deployer.provider, Provider::Gcp);
440        assert_eq!(
441            deployer.bundle_root.as_deref(),
442            Some(std::path::Path::new("bundle-root"))
443        );
444        assert_eq!(
445            deployer.bundle_source.as_deref(),
446            Some("gs://bucket/bundle.gtbundle")
447        );
448        assert_eq!(deployer.bundle_digest.as_deref(), Some("sha256:abc"));
449        assert_eq!(
450            deployer.repo_registry_base.as_deref(),
451            Some("https://repo.example")
452        );
453        assert_eq!(
454            deployer.store_registry_base.as_deref(),
455            Some("https://store.example")
456        );
457        assert_eq!(
458            deployer.provider_pack.as_deref(),
459            Some(std::path::Path::new("providers/deployer/gcp.gtpack"))
460        );
461        assert_eq!(
462            deployer.deploy_pack_id_override.as_deref(),
463            Some("greentic.deploy.gcp")
464        );
465        assert_eq!(
466            deployer.deploy_flow_id_override.as_deref(),
467            Some("apply_terraform")
468        );
469        assert_eq!(deployer.environment.as_deref(), Some("prod"));
470        assert_eq!(deployer.pack_id.as_deref(), Some("pack-id"));
471        assert_eq!(deployer.pack_version.as_deref(), Some("1.2.3"));
472        assert_eq!(deployer.pack_digest.as_deref(), Some("sha256:def"));
473        assert_eq!(
474            deployer.distributor_url.as_deref(),
475            Some("https://dist.example")
476        );
477        assert_eq!(deployer.distributor_token.as_deref(), Some("token"));
478        assert!(deployer.preview);
479        assert!(deployer.dry_run);
480        assert!(deployer.execute_local);
481        assert_eq!(deployer.output, OutputFormat::Yaml);
482        assert_eq!(
483            deployer.config_path.as_deref(),
484            Some(std::path::Path::new("greentic.toml"))
485        );
486        assert!(deployer.allow_remote_in_offline);
487        assert_eq!(deployer.providers_dir, PathBuf::from("providers"));
488        assert_eq!(deployer.packs_dir, PathBuf::from("packs-dir"));
489    }
490
491    #[test]
492    fn ensure_gcp_config_rejects_non_gcp_provider() {
493        let tmp = tempfile::tempdir().expect("tempdir");
494        let mut request = GcpRequest::new(
495            DeployerCapability::Plan,
496            "acme",
497            Some(tmp.path().to_path_buf()),
498        )
499        .into_deployer_request();
500        request.provider = Provider::Aws;
501        let config = DeployerConfig::resolve(request).expect("resolve config");
502
503        let err = ensure_gcp_config(&config).expect_err("non-gcp config should fail");
504        assert!(
505            err.to_string().contains("provider=aws strategy=iac-only"),
506            "got: {err}"
507        );
508    }
509
510    #[test]
511    fn ensure_gcp_config_accepts_gcp_iac_config() {
512        let tmp = tempfile::tempdir().expect("tempdir");
513        let request = GcpRequest::new(
514            DeployerCapability::Plan,
515            "acme",
516            Some(tmp.path().to_path_buf()),
517        )
518        .into_deployer_request();
519        let config = DeployerConfig::resolve(request).expect("resolve config");
520
521        ensure_gcp_config(&config).expect("gcp config");
522    }
523
524    #[test]
525    fn gcp_secret_names_are_flat_and_bounded() {
526        let name = flat_cloud_secret_name(
527            "greentic/dev/demo/_",
528            "messaging-telegram",
529            "TELEGRAM_BOT_TOKEN",
530            255,
531        );
532        assert_eq!(
533            name,
534            "greentic-dev-demo-messaging-telegram-telegram-bot-token"
535        );
536    }
537
538    #[test]
539    fn ext_config_parses_minimum_fields() {
540        let json = r#"{
541            "projectId": "my-gcp-project-12345",
542            "region": "us-central1",
543            "environment": "staging",
544            "operatorImageDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
545            "bundleSource": "oci://registry.example/acme/prod-bundle@sha256:1111111111111111111111111111111111111111111111111111111111111111",
546            "bundleDigest": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
547            "remoteStateBackend": "gs://my-tf-state-bucket/greentic/staging"
548        }"#;
549        let cfg: GcpCloudRunExtConfig = serde_json::from_str(json).unwrap();
550        assert_eq!(cfg.project_id, "my-gcp-project-12345");
551        assert_eq!(cfg.region, "us-central1");
552        assert_eq!(cfg.environment, "staging");
553        assert_eq!(cfg.tenant, "default");
554        assert!(cfg.dns_name.is_none());
555        assert!(cfg.public_base_url.is_none());
556    }
557
558    #[test]
559    fn ext_config_accepts_all_optionals() {
560        let json = r#"{
561            "projectId": "my-gcp-project-12345",
562            "region": "us-central1",
563            "environment": "prod",
564            "operatorImageDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
565            "bundleSource": "oci://registry.example/acme/prod-bundle@sha256:1111111111111111111111111111111111111111111111111111111111111111",
566            "bundleDigest": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
567            "remoteStateBackend": "gs://my-tf-state-bucket/greentic/prod",
568            "dnsName": "api.example.com",
569            "publicBaseUrl": "https://api.example.com",
570            "repoRegistryBase": "https://repo.example.com",
571            "storeRegistryBase": "https://store.example.com",
572            "adminAllowedClients": "CN=admin",
573            "tenant": "acme"
574        }"#;
575        let cfg: GcpCloudRunExtConfig = serde_json::from_str(json).unwrap();
576        assert_eq!(cfg.dns_name.as_deref(), Some("api.example.com"));
577        assert_eq!(
578            cfg.public_base_url.as_deref(),
579            Some("https://api.example.com")
580        );
581        assert_eq!(cfg.tenant, "acme");
582    }
583
584    #[test]
585    fn build_gcp_request_from_ext_maps_cloud_bundle_fields() {
586        let cfg = GcpCloudRunExtConfig {
587            project_id: "project-123".to_string(),
588            region: "europe-west1".to_string(),
589            environment: "prod".to_string(),
590            operator_image_digest: "sha256:0000".to_string(),
591            bundle_source: "oci://registry.example/acme/prod".to_string(),
592            bundle_digest: "sha256:1111".to_string(),
593            remote_state_backend: "gs://state/greentic/prod".to_string(),
594            dns_name: Some("api.example.com".to_string()),
595            public_base_url: Some("https://api.example.com".to_string()),
596            repo_registry_base: Some("https://repo.example.com".to_string()),
597            store_registry_base: Some("https://store.example.com".to_string()),
598            admin_allowed_clients: Some("CN=admin".to_string()),
599            tenant: "acme".to_string(),
600        };
601
602        let request = build_gcp_request_from_ext(
603            DeployerCapability::Destroy,
604            &cfg,
605            Some(std::path::Path::new("pack")),
606        );
607
608        assert_eq!(request.capability, DeployerCapability::Destroy);
609        assert_eq!(request.tenant, "acme");
610        assert_eq!(request.pack_path, Some(PathBuf::from("pack")));
611        assert_eq!(
612            request.bundle_source.as_deref(),
613            Some("oci://registry.example/acme/prod")
614        );
615        assert_eq!(request.bundle_digest.as_deref(), Some("sha256:1111"));
616        assert_eq!(
617            request.repo_registry_base.as_deref(),
618            Some("https://repo.example.com")
619        );
620        assert_eq!(
621            request.store_registry_base.as_deref(),
622            Some("https://store.example.com")
623        );
624        assert_eq!(request.environment.as_deref(), Some("prod"));
625        assert!(request.execute_local);
626    }
627
628    #[test]
629    fn ext_config_rejects_missing_project_id() {
630        let json = r#"{
631            "region": "us-central1",
632            "environment": "staging",
633            "operatorImageDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
634            "bundleSource": "oci://...",
635            "bundleDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
636            "remoteStateBackend": "gs://..."
637        }"#;
638        let err = serde_json::from_str::<GcpCloudRunExtConfig>(json).unwrap_err();
639        let msg = format!("{err}");
640        assert!(
641            msg.contains("projectId") || msg.contains("project_id"),
642            "got: {msg}"
643        );
644    }
645
646    #[test]
647    fn apply_from_ext_rejects_invalid_json() {
648        let err = apply_from_ext("not json", "{}", None).unwrap_err();
649        assert!(format!("{err}").contains("parse"), "got: {err}");
650    }
651
652    #[test]
653    fn apply_from_ext_rejects_missing_required_field() {
654        let json = r#"{"projectId":"my-project"}"#;
655        let err = apply_from_ext(json, "{}", None).unwrap_err();
656        let msg = format!("{err:#}");
657        assert!(
658            msg.contains("missing field")
659                || msg.contains("bundleSource")
660                || msg.contains("bundle_source"),
661            "got: {msg}"
662        );
663    }
664
665    #[test]
666    fn destroy_from_ext_rejects_invalid_json() {
667        let err = destroy_from_ext("not json", "{}", None).unwrap_err();
668        assert!(format!("{err}").contains("parse"), "got: {err}");
669    }
670}