1use std::path::{Path, PathBuf};
15
16use anyhow::{Context, Result, bail};
17use greentic_deployer::cli::bootstrap::{LocalEnvOutcome, ensure_local_environment};
18use greentic_deployer::cli::dispatch::print_outcome;
19use greentic_deployer::cli::messaging::{
20 EndpointAddPayload, EndpointLinkBundlePayload, EndpointRemovePayload,
21};
22use greentic_deployer::cli::secrets::SecretsPutPayload;
23use greentic_deployer::cli::{OpFlags, messaging, secrets};
24use greentic_deployer::environment::LocalFsStore;
25
26use crate::bundle_source::BundleSource;
27use crate::cli_args::ProviderAddArgs;
28use crate::provider_registry::{self, ProviderPackInfo};
29use crate::secrets::load_secret_requirements_from_pack;
30use crate::setup_input::{self, SetupInputAnswers};
31
32const UPDATED_BY: &str = "greentic-setup";
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum PackDeployOutcome {
38 Deployed,
40 AlreadyPresent,
42}
43
44pub struct ProviderRegistrationResult {
46 pub endpoint_id: String,
47 pub pack_deploy: PackDeployOutcome,
48}
49
50fn op_flags() -> OpFlags {
53 OpFlags {
54 schema_only: false,
55 answers: None,
56 }
57}
58
59pub fn resolve_pack(
73 explicit_pack: Option<&Path>,
74 info: &ProviderPackInfo,
75 store: &LocalFsStore,
76 env_id: &str,
77 pack_version: Option<&str>,
78) -> Result<PathBuf> {
79 if let Some(pack) = explicit_pack {
81 if !pack.exists() {
82 bail!("pack path does not exist: {}", pack.display());
83 }
84 eprintln!("Resolved pack: {} (local override)", pack.display());
85 return Ok(pack.to_path_buf());
86 }
87
88 let oci_ref = provider_registry::oci_reference(info, pack_version);
90 match BundleSource::parse(&oci_ref) {
91 Ok(source) => match source.resolve() {
92 Ok(path) => {
93 eprintln!("Resolved pack: {oci_ref} -> {}", path.display());
94 return Ok(path);
95 }
96 Err(err) => {
97 if pack_version.is_some() {
102 bail!(
103 "OCI fetch failed for {oci_ref}: {err:#}\n\
104 The --pack-version override requires a successful \
105 OCI fetch. Supply --pack <path> to use a local file."
106 );
107 }
108 eprintln!(
109 "Warning: OCI fetch failed for {oci_ref}: {err:#}\n\
110 Falling back to bundle-embedded pack."
111 );
112 }
113 },
114 Err(err) => {
115 if pack_version.is_some() {
116 bail!(
117 "OCI reference could not be parsed ({oci_ref}): {err:#}\n\
118 The --pack-version override requires a valid OCI reference."
119 );
120 }
121 eprintln!(
122 "Warning: OCI reference could not be parsed ({oci_ref}): {err:#}\n\
123 Falling back to bundle-embedded pack."
124 );
125 }
126 }
127
128 let env_dir = store.root().join(env_id);
130 if env_dir.is_dir()
131 && let Some(path) = find_pack_in_revisions(&env_dir, info.pack_name)
132 {
133 eprintln!(
134 "Resolved pack: {} (offline fallback from deployed revision)",
135 path.display()
136 );
137 return Ok(path);
138 }
139
140 bail!(
141 "could not resolve provider pack `{}`.\n\
142 Tried:\n \
143 1. OCI: {oci_ref}\n \
144 2. Offline: pack not found in any deployed revision.\n\n\
145 Supply --pack <path> to point at a local .gtpack file.",
146 info.pack_name,
147 )
148}
149
150fn find_pack_in_revisions(env_dir: &Path, pack_name: &str) -> Option<PathBuf> {
152 let revisions_dir = env_dir.join("revisions");
153 let entries = std::fs::read_dir(&revisions_dir).ok()?;
154 for entry in entries.flatten() {
155 let candidate = entry
156 .path()
157 .join("bundle")
158 .join("packs")
159 .join(format!("{pack_name}.gtpack"));
160 if candidate.is_file() {
161 return Some(candidate);
162 }
163 }
164 None
165}
166
167fn normalize_provider_segment(raw: &str) -> String {
177 let s: String = raw
178 .chars()
179 .map(|ch| {
180 let ch = ch.to_ascii_lowercase();
181 match ch {
182 'a'..='z' | '0'..='9' | '_' | '-' => ch,
183 _ => '_',
184 }
185 })
186 .collect();
187 if s.is_empty() {
188 "provider".to_string()
189 } else {
190 s
191 }
192}
193
194fn deployer_secret_uri(
200 env: &str,
201 tenant: &str,
202 team: Option<&str>,
203 provider: &str,
204 key: &str,
205) -> String {
206 let rel_path = deployer_secret_path(tenant, team, provider, key);
207 format!("secret://{env}/{rel_path}")
208}
209
210fn deployer_secret_path(tenant: &str, team: Option<&str>, provider: &str, key: &str) -> String {
216 let team_segment = greentic_secrets_lib::normalize_team(team)
217 .unwrap_or_else(|| greentic_secrets_lib::TEAM_PLACEHOLDER.to_string());
218 let normalized_provider = normalize_provider_segment(provider);
219 let normalized_key = crate::secret_name::canonical_secret_name(key);
220 format!("{tenant}/{team_segment}/{normalized_provider}/{normalized_key}")
221}
222
223pub fn auto_detect_bundle_id(store: &LocalFsStore, env_id_str: &str) -> Result<String> {
234 let env_json_path = store.root().join(env_id_str).join("environment.json");
235 if !env_json_path.is_file() {
236 bail!(
237 "no bundle is deployed in environment `{env_id_str}`.\n\
238 Deploy a bundle first: gtc start <bundle>"
239 );
240 }
241 let raw = std::fs::read_to_string(&env_json_path)
242 .with_context(|| format!("read {}", env_json_path.display()))?;
243 let doc: serde_json::Value =
244 serde_json::from_str(&raw).with_context(|| format!("parse {}", env_json_path.display()))?;
245 let all_bundles = doc
246 .get("bundles")
247 .and_then(|v| v.as_array())
248 .cloned()
249 .unwrap_or_default();
250 let bundles: Vec<_> = all_bundles
257 .into_iter()
258 .filter(|b| {
259 b.get("status")
260 .and_then(|v| v.as_str())
261 .is_none_or(|s| s.eq_ignore_ascii_case("active"))
262 })
263 .collect();
264 match bundles.len() {
265 0 => bail!(
266 "no active bundle is deployed in environment `{env_id_str}`.\n\
267 Deploy a bundle first: gtc start <bundle>"
268 ),
269 1 => {
270 let id = bundles[0]
271 .get("bundle_id")
272 .and_then(|v| v.as_str())
273 .context("bundle entry missing bundle_id")?;
274 Ok(id.to_string())
275 }
276 n => {
277 let ids: Vec<String> = bundles
278 .iter()
279 .filter_map(|b| b.get("bundle_id").and_then(|v| v.as_str()))
280 .map(|id| format!(" - {id}"))
281 .collect();
282 bail!(
283 "{n} active bundles deployed in environment `{env_id_str}`. \
284 Pass --bundle-id to choose one:\n{}",
285 ids.join("\n"),
286 )
287 }
288 }
289}
290
291pub struct RegisterProviderPayload<'a> {
297 pub env_id: &'a str,
298 pub tenant: &'a str,
299 pub team: Option<&'a str>,
300 pub provider_type: &'a str,
301 pub provider_id: &'a str,
302 pub pack_name: &'a str,
303 pub display_name: String,
304 pub bundle_id: &'a str,
305 pub link_bundle: bool,
306 pub answers: &'a serde_json::Map<String, serde_json::Value>,
307 pub pack_path: &'a Path,
308}
309
310pub fn register_provider_core(
327 store: &LocalFsStore,
328 payload: &RegisterProviderPayload<'_>,
329 idempotency_key: Option<String>,
330) -> Result<ProviderRegistrationResult> {
331 register_provider_core_impl(
332 store,
333 payload,
334 idempotency_key,
335 |bundle_copy, env_id, customer_id| {
336 crate::env_deploy::deploy_bundle_to_env(bundle_copy, env_id, false, true, customer_id)
337 },
338 )
339}
340
341fn register_provider_core_impl(
347 store: &LocalFsStore,
348 payload: &RegisterProviderPayload<'_>,
349 idempotency_key: Option<String>,
350 deploy_fn: impl FnOnce(&Path, &str, Option<&str>) -> Result<()>,
351) -> Result<ProviderRegistrationResult> {
352 let mut secret_keys = collect_secret_keys_from_pack(payload.pack_path);
354 for req in load_secret_requirements_from_pack(payload.pack_path).unwrap_or_default() {
355 secret_keys.insert(req.key.clone());
356 secret_keys.insert(crate::secret_name::canonical_secret_name(&req.key));
357 }
358
359 let mut secret_entries: Vec<(String, String, String, String)> = Vec::new();
360
361 for (key, value) in payload.answers {
362 let is_secret = secret_keys.contains(key)
363 || secret_keys.contains(&crate::secret_name::canonical_secret_name(key));
364 if !is_secret {
365 continue;
366 }
367 let secret_value = match value.as_str() {
368 Some(s) if !s.is_empty() => s.to_string(),
369 _ => continue,
370 };
371 let rel_path = deployer_secret_path(payload.tenant, payload.team, payload.pack_name, key);
372 let uri = deployer_secret_uri(
373 payload.env_id,
374 payload.tenant,
375 payload.team,
376 payload.pack_name,
377 key,
378 );
379 secret_entries.push((key.clone(), secret_value, rel_path, uri));
380 }
381
382 let secret_refs: Vec<String> = secret_entries
383 .iter()
384 .map(|(_, _, _, uri)| uri.clone())
385 .collect();
386
387 let add_result = messaging::add(
389 store,
390 &op_flags(),
391 Some(EndpointAddPayload {
392 environment_id: payload.env_id.to_string(),
393 provider_id: payload.provider_id.to_string(),
394 provider_type: payload.provider_type.to_string(),
395 display_name: payload.display_name.clone(),
396 secret_refs,
397 webhook_secret_ref: None,
398 idempotency_key,
399 updated_by: UPDATED_BY.to_string(),
400 }),
401 )
402 .map_err(|e| anyhow::anyhow!("add messaging endpoint: {e}"))?;
403
404 let endpoint_id = add_result
405 .result
406 .get("endpoint_id")
407 .and_then(|v| v.as_str())
408 .map(|s| s.to_string())
409 .context("add outcome missing endpoint_id")?;
410
411 eprintln!("Endpoint registered: {endpoint_id}");
412 print_outcome(&add_result).ok();
413
414 for (key, value, rel_path, uri) in &secret_entries {
416 let put_result = secrets::put(
417 store,
418 &op_flags(),
419 Some(SecretsPutPayload {
420 environment_id: payload.env_id.to_string(),
421 path: rel_path.clone(),
422 value: value.clone(),
423 idempotency_key: None,
424 }),
425 );
426 match put_result {
427 Ok(_outcome) => {
428 eprintln!(" Secret written: {uri}");
429 }
430 Err(e) => {
431 bail!(
432 "failed to write secret `{key}` to env store: {e}\n\n\
433 The endpoint `{endpoint_id}` was already registered. \
434 To clean up, run:\n \
435 greentic-setup provider remove {endpoint_id} --env {payload_env}",
436 payload_env = payload.env_id,
437 );
438 }
439 }
440 }
441
442 if payload.link_bundle {
444 let link_result = messaging::link_bundle(
445 store,
446 &op_flags(),
447 Some(EndpointLinkBundlePayload {
448 environment_id: payload.env_id.to_string(),
449 endpoint_id: endpoint_id.clone(),
450 bundle_id: payload.bundle_id.to_string(),
451 idempotency_key: None,
452 updated_by: UPDATED_BY.to_string(),
453 }),
454 )
455 .map_err(|e| anyhow::anyhow!("link bundle `{}` to endpoint: {e}", payload.bundle_id))?;
456 print_outcome(&link_result).ok();
457 }
458
459 let pack_deploy = if payload.link_bundle {
461 inject_provider_pack_impl(
462 store,
463 payload.env_id,
464 payload.bundle_id,
465 payload.pack_path,
466 payload.pack_name,
467 deploy_fn,
468 )?
469 } else {
470 PackDeployOutcome::AlreadyPresent
471 };
472
473 Ok(ProviderRegistrationResult {
474 endpoint_id,
475 pack_deploy,
476 })
477}
478
479pub fn deterministic_idempotency_key(
484 env_id: &str,
485 provider_type: &str,
486 provider_id: &str,
487) -> String {
488 use sha2::{Digest, Sha256};
489 let mut hasher = Sha256::new();
490 hasher.update(b"greentic-setup:provider:");
491 hasher.update(env_id.as_bytes());
492 hasher.update(b":");
493 hasher.update(provider_type.as_bytes());
494 hasher.update(b":");
495 hasher.update(provider_id.as_bytes());
496 let digest = hasher.finalize();
497 let hex: String = digest[..16].iter().map(|b| format!("{b:02x}")).collect();
498 format!("setup-provider-{hex}")
499}
500
501pub fn add(
507 args: &ProviderAddArgs,
508 env_id: &str,
509 tenant: &str,
510 team: Option<&str>,
511 dry_run: bool,
512 non_interactive: bool,
513 answers_path: Option<&Path>,
514) -> Result<()> {
515 let kind = args.kind.to_ascii_lowercase();
516 let info = provider_registry::lookup(&kind).ok_or_else(|| {
517 anyhow::anyhow!(
518 "unknown provider kind `{}`. Known kinds: {}",
519 kind,
520 provider_registry::known_kinds().join(", "),
521 )
522 })?;
523
524 let root = LocalFsStore::default_root()
526 .context("cannot locate the environment store: HOME / USERPROFILE not set")?;
527 let store = LocalFsStore::new(root);
528
529 if env_id == "local" {
531 let (_env, outcome) = ensure_local_environment(&store, None)
532 .map_err(|e| anyhow::anyhow!("ensure local environment: {e}"))?;
533 if matches!(outcome, LocalEnvOutcome::Created) {
534 eprintln!("Created environment `local`.");
535 }
536 } else {
537 let env_json = store.root().join(env_id).join("environment.json");
538 if !env_json.is_file() {
539 bail!(
540 "environment `{env_id}` does not exist.\n\
541 Create it first, or use --env local."
542 );
543 }
544 }
545
546 let pack_path = resolve_pack(
548 args.pack.as_deref(),
549 info,
550 &store,
551 env_id,
552 args.pack_version.as_deref(),
553 )?;
554 eprintln!("Using provider pack: {}", pack_path.display());
555
556 let provider_id = args
559 .provider_id
560 .as_deref()
561 .unwrap_or(info.default_provider_id);
562
563 if has_oauth_device_code_action(&pack_path) {
565 bail!(
566 "provider `teams` requires an OAuth device-code flow that is currently only \
567 available through the full bundle-setup engine.\n\n\
568 Use the existing two-step path instead:\n \
569 1. greentic-setup bundle add {pack} --bundle <bundle-dir>\n \
570 2. greentic-setup bundle setup teams --bundle <bundle-dir>\n\n\
571 Then register the endpoint manually:\n \
572 gtc op messaging endpoint add --env {env} --provider-type teams \
573 --provider-id {pid} --display-name \"Teams\" --updated-by greentic-setup\n\n\
574 `provider add teams` will be supported once the device-code flow is \
575 callable outside the bundle-setup engine.",
576 pack = pack_path.display(),
577 env = env_id,
578 pid = provider_id,
579 );
580 }
581
582 let setup_input = if let Some(path) = answers_path {
583 let raw = setup_input::load_setup_input(path)
584 .with_context(|| format!("load answers from {}", path.display()))?;
585 let keys = std::collections::BTreeSet::new();
586 Some(SetupInputAnswers::new(raw, keys)?)
587 } else {
588 None
589 };
590
591 let answers = setup_input::collect_setup_answers(
592 &pack_path,
593 provider_id,
594 setup_input.as_ref(),
595 !non_interactive,
596 )
597 .context("collect setup answers")?;
598
599 if dry_run {
600 eprintln!(
601 "Dry run: would register provider `{provider_id}` (type: {}) in env `{env_id}`.",
602 info.provider_type
603 );
604 let json = serde_json::to_string_pretty(&answers).context("serialize dry-run answers")?;
605 println!("{json}");
606 return Ok(());
607 }
608
609 let bundle_id = if let Some(id) = &args.bundle_id {
611 id.clone()
612 } else {
613 auto_detect_bundle_id(&store, env_id)?
614 };
615
616 let display_name = args
617 .display_name
618 .clone()
619 .unwrap_or_else(|| crate::setup_to_formspec::capitalize(info.kind));
620
621 let answers_map = answers.as_object().cloned().unwrap_or_default();
622
623 let result = register_provider_core(
624 &store,
625 &RegisterProviderPayload {
626 env_id,
627 tenant,
628 team,
629 provider_type: info.provider_type,
630 provider_id,
631 pack_name: info.pack_name,
632 display_name,
633 bundle_id: &bundle_id,
634 link_bundle: true,
635 answers: &answers_map,
636 pack_path: &pack_path,
637 },
638 Some(deterministic_idempotency_key(
639 env_id,
640 info.provider_type,
641 provider_id,
642 )),
643 )?;
644
645 eprintln!();
647 eprintln!(
648 "Provider `{provider_id}` (type: {ptype}) is registered in environment `{env_id}` \
649 and linked to bundle `{bundle_id}`.",
650 ptype = info.provider_type,
651 );
652 match result.pack_deploy {
653 PackDeployOutcome::Deployed => {
654 eprintln!(
655 "Provider pack deployed and new revision staged. \
656 Restart the runtime (`gtc start`) to activate."
657 );
658 }
659 PackDeployOutcome::AlreadyPresent => {
660 eprintln!(
661 "The provider pack is already in the deployed bundle. \
662 A running runtime will pick up the endpoint on its next reload."
663 );
664 }
665 }
666
667 if !has_resolvable_public_url(&store, env_id) {
669 eprintln!();
670 eprintln!(
671 "Note: no public base URL is configured for environment `{env_id}`.\n\
672 Webhook registration will be skipped until one is set.\n\
673 Options:\n \
674 - Start a tunnel (the runtime auto-detects it)\n \
675 - Set PUBLIC_BASE_URL in the environment\n \
676 - Run: gtc op env set-public-url {env_id} <url>"
677 );
678 }
679
680 Ok(())
681}
682
683pub fn list(env_id: &str) -> Result<()> {
689 let root = LocalFsStore::default_root()
690 .context("cannot locate the environment store: HOME / USERPROFILE not set")?;
691 let store = LocalFsStore::new(root);
692
693 let outcome = messaging::list(&store, &op_flags(), env_id)
694 .map_err(|e| anyhow::anyhow!("list messaging endpoints: {e}"))?;
695 print_outcome(&outcome)?;
696 Ok(())
697}
698
699pub fn remove(endpoint_id: &str, env_id: &str) -> Result<()> {
705 let root = LocalFsStore::default_root()
706 .context("cannot locate the environment store: HOME / USERPROFILE not set")?;
707 let store = LocalFsStore::new(root);
708
709 let outcome = messaging::remove(
710 &store,
711 &op_flags(),
712 Some(EndpointRemovePayload {
713 environment_id: env_id.to_string(),
714 endpoint_id: endpoint_id.to_string(),
715 idempotency_key: None,
716 updated_by: UPDATED_BY.to_string(),
717 }),
718 )
719 .map_err(|e| anyhow::anyhow!("remove messaging endpoint: {e}"))?;
720 print_outcome(&outcome)?;
721
722 eprintln!(
723 "Endpoint `{endpoint_id}` removed from environment `{env_id}`.\n\
724 Note: secrets associated with this endpoint were NOT deleted. \
725 Remove them manually if no longer needed:\n \
726 gtc op secrets list --answers '{{\"environment_id\":\"{env_id}\"}}'"
727 );
728
729 Ok(())
730}
731
732fn has_oauth_device_code_action(pack_path: &Path) -> bool {
739 let spec = match setup_input::load_setup_spec(pack_path) {
740 Ok(Some(spec)) => spec,
741 _ => return false,
742 };
743 spec.setup_actions.iter().any(|action| {
744 action
745 .get("kind")
746 .and_then(|v| v.as_str())
747 .is_some_and(|k| k == "oauth_device_code")
748 })
749}
750
751fn collect_secret_keys_from_pack(pack_path: &Path) -> std::collections::HashSet<String> {
754 let mut keys = std::collections::HashSet::new();
755 if let Ok(Some(spec)) = setup_input::load_setup_spec(pack_path) {
756 for q in &spec.questions {
757 if q.secret {
758 keys.insert(q.name.clone());
759 keys.insert(crate::secret_name::canonical_secret_name(&q.name));
760 }
761 }
762 }
763 keys
764}
765
766fn has_resolvable_public_url(store: &LocalFsStore, env_id: &str) -> bool {
768 if std::env::var("PUBLIC_BASE_URL")
770 .ok()
771 .filter(|v| !v.is_empty())
772 .is_some()
773 {
774 return true;
775 }
776
777 let env_json_path = store.root().join(env_id).join("environment.json");
779 let Ok(raw) = std::fs::read_to_string(&env_json_path) else {
780 return false;
781 };
782 let Ok(doc) = serde_json::from_str::<serde_json::Value>(&raw) else {
783 return false;
784 };
785 doc.get("host_config")
786 .and_then(|hc| hc.get("public_base_url"))
787 .and_then(|v| v.as_str())
788 .is_some_and(|url| !url.is_empty())
789}
790
791#[derive(Debug)]
797struct DeploymentContext {
798 bundle_dir: PathBuf,
800 customer_id: Option<String>,
802}
803
804#[derive(serde::Deserialize)]
817struct EnvJson {
818 bundles: Vec<EnvJsonBundle>,
819}
820
821#[derive(serde::Deserialize)]
822struct EnvJsonBundle {
823 bundle_id: String,
824 #[serde(default)]
826 current_revisions: Vec<String>,
827 #[serde(default)]
828 customer_id: Option<String>,
829}
830
831fn resolve_deployment_context(env_dir: &Path, bundle_id: &str) -> Result<DeploymentContext> {
837 let env_json_path = env_dir.join("environment.json");
838 let raw = std::fs::read_to_string(&env_json_path)
839 .with_context(|| format!("read {}", env_json_path.display()))?;
840 let doc: EnvJson =
841 serde_json::from_str(&raw).with_context(|| format!("parse {}", env_json_path.display()))?;
842
843 let deployment = doc
844 .bundles
845 .iter()
846 .find(|b| b.bundle_id == bundle_id)
847 .with_context(|| {
848 format!(
849 "no deployment for bundle `{bundle_id}` in environment at {}",
850 env_dir.display(),
851 )
852 })?;
853
854 let current_revisions = &deployment.current_revisions;
855
856 match current_revisions.len() {
857 0 => bail!(
858 "deployment for bundle `{bundle_id}` has no serving revisions.\n\
859 Deploy the bundle first: gtc start <bundle>"
860 ),
861 1 => {}
862 n => bail!(
863 "deployment for bundle `{bundle_id}` has {n} serving revisions \
864 (active traffic split).\n\
865 `provider add` cannot safely rebuild a bundle mid-split because \
866 silently picking one revision would shift traffic.\n\
867 Resolve the traffic split first, then retry."
868 ),
869 }
870
871 let revision_id = ¤t_revisions[0];
872 let bundle_dir = env_dir.join("revisions").join(revision_id).join("bundle");
873
874 if !bundle_dir.is_dir() {
875 bail!(
876 "revision `{revision_id}` for bundle `{bundle_id}` has no bundle \
877 directory at {}",
878 bundle_dir.display(),
879 );
880 }
881
882 Ok(DeploymentContext {
883 bundle_dir,
884 customer_id: deployment.customer_id.clone(),
885 })
886}
887
888enum PackPresence {
890 Absent,
892 MatchingDigest,
894 DigestMismatch,
896}
897
898fn sha256_file(path: &Path) -> Result<String> {
900 use sha2::{Digest, Sha256};
901 let bytes = std::fs::read(path).with_context(|| format!("read file {}", path.display()))?;
902 let hash = Sha256::digest(bytes);
903 Ok(hash.iter().map(|b| format!("{b:02x}")).collect())
904}
905
906fn check_pack_in_bundle_dir(
910 bundle_dir: &Path,
911 pack_id: &str,
912 source_pack_path: &Path,
913) -> Result<PackPresence> {
914 let target_dir = crate::engine::get_pack_target_dir(bundle_dir, pack_id);
915 let embedded_path = target_dir.join(format!("{pack_id}.gtpack"));
916 if !embedded_path.is_file() {
917 return Ok(PackPresence::Absent);
918 }
919 let source_digest = sha256_file(source_pack_path)?;
920 let embedded_digest = sha256_file(&embedded_path)?;
921 if source_digest == embedded_digest {
922 Ok(PackPresence::MatchingDigest)
923 } else {
924 Ok(PackPresence::DigestMismatch)
925 }
926}
927
928fn prepare_bundle_with_provider_pack(
940 source_bundle_dir: &Path,
941 pack_path: &Path,
942 pack_name: &str,
943) -> Result<Option<(PathBuf, tempfile::TempDir)>> {
944 match check_pack_in_bundle_dir(source_bundle_dir, pack_name, pack_path)? {
945 PackPresence::MatchingDigest => return Ok(None),
946 PackPresence::Absent | PackPresence::DigestMismatch => {}
947 }
948
949 let temp_dir =
950 tempfile::tempdir().context("create temporary directory for bundle modification")?;
951 let bundle_copy = temp_dir.path().join("bundle");
952 crate::cli_helpers::copy_dir_recursive(
953 &source_bundle_dir.to_path_buf(),
954 &bundle_copy,
955 false, )
957 .context("copy deployed bundle for modification")?;
958
959 let target_dir = crate::engine::get_pack_target_dir(&bundle_copy, pack_name);
963 let stale = target_dir.join(format!("{pack_name}.gtpack"));
964 if stale.is_file() {
965 std::fs::remove_file(&stale)
966 .with_context(|| format!("remove stale pack {}", stale.display()))?;
967 }
968
969 let digest = format!("sha256:{}", sha256_file(pack_path)?);
970
971 crate::engine::execute_add_packs_to_bundle(
972 &bundle_copy,
973 &[crate::plan::ResolvedPackInfo {
974 source_ref: pack_path.display().to_string(),
975 mapped_ref: pack_path.display().to_string(),
976 resolved_digest: digest,
977 pack_id: pack_name.to_string(),
978 entry_flows: Vec::new(),
979 cached_path: pack_path.to_path_buf(),
980 output_path: pack_path.to_path_buf(),
981 }],
982 )
983 .context("inject provider pack into bundle")?;
984
985 Ok(Some((bundle_copy, temp_dir)))
986}
987
988fn inject_provider_pack_impl(
1001 store: &LocalFsStore,
1002 env_id: &str,
1003 bundle_id: &str,
1004 pack_path: &Path,
1005 pack_name: &str,
1006 deploy_fn: impl FnOnce(&Path, &str, Option<&str>) -> Result<()>,
1007) -> Result<PackDeployOutcome> {
1008 let env_dir = store.root().join(env_id);
1009 let ctx = resolve_deployment_context(&env_dir, bundle_id)?;
1010
1011 match prepare_bundle_with_provider_pack(&ctx.bundle_dir, pack_path, pack_name)? {
1012 None => {
1013 eprintln!(
1014 "Provider pack `{pack_name}` is already present in the deployed bundle; \
1015 skipping bundle rebuild."
1016 );
1017 Ok(PackDeployOutcome::AlreadyPresent)
1018 }
1019 Some((bundle_copy, _temp_dir)) => {
1020 eprintln!("Deploying bundle with provider pack `{pack_name}`...");
1021 deploy_fn(&bundle_copy, env_id, ctx.customer_id.as_deref())
1022 .context("redeploy bundle with injected provider pack")?;
1023 Ok(PackDeployOutcome::Deployed)
1024 }
1025 }
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031
1032 #[test]
1033 fn normalize_provider_segment_preserves_hyphens() {
1034 assert_eq!(
1036 normalize_provider_segment("messaging-telegram"),
1037 "messaging-telegram"
1038 );
1039 assert_eq!(
1040 normalize_provider_segment("MESSAGING-SLACK"),
1041 "messaging-slack"
1042 );
1043 assert_eq!(normalize_provider_segment("telegram"), "telegram");
1044 }
1045
1046 #[test]
1047 fn normalize_provider_segment_maps_dots_and_spaces() {
1048 assert_eq!(
1049 normalize_provider_segment("my.provider name"),
1050 "my_provider_name"
1051 );
1052 }
1053
1054 #[test]
1055 fn normalize_provider_segment_empty() {
1056 assert_eq!(normalize_provider_segment(""), "provider");
1057 }
1058
1059 #[test]
1060 fn deployer_secret_uri_basic() {
1061 let uri = deployer_secret_uri("local", "demo", None, "telegram", "bot_token");
1062 assert_eq!(uri, "secret://local/demo/_/telegram/bot_token");
1063 }
1064
1065 #[test]
1066 fn deployer_secret_uri_preserves_hyphens_in_provider() {
1067 let uri = deployer_secret_uri("local", "demo", None, "messaging-telegram", "bot_token");
1070 assert_eq!(uri, "secret://local/demo/_/messaging-telegram/bot_token");
1071 }
1072
1073 #[test]
1074 fn deployer_secret_uri_with_team() {
1075 let uri = deployer_secret_uri("local", "acme", Some("ops"), "slack", "token");
1076 assert_eq!(uri, "secret://local/acme/ops/slack/token");
1077 }
1078
1079 #[test]
1080 fn deployer_secret_uri_default_team() {
1081 let uri = deployer_secret_uri("local", "demo", Some("default"), "webex", "bot_token");
1082 assert_eq!(uri, "secret://local/demo/_/webex/bot_token");
1083 }
1084
1085 #[test]
1086 fn deployer_secret_path_basic() {
1087 let path = deployer_secret_path("demo", None, "telegram", "bot_token");
1088 assert_eq!(path, "demo/_/telegram/bot_token");
1089 }
1090
1091 #[test]
1092 fn deployer_secret_path_preserves_hyphens_in_provider() {
1093 let path = deployer_secret_path("demo", None, "messaging-telegram", "bot_token");
1094 assert_eq!(path, "demo/_/messaging-telegram/bot_token");
1095 }
1096
1097 #[test]
1098 fn auto_detect_bundle_id_missing_env() {
1099 let root = tempfile::tempdir().unwrap();
1100 let store = LocalFsStore::new(root.path());
1101 let result = auto_detect_bundle_id(&store, "nonexistent");
1102 assert!(result.is_err());
1103 let msg = format!("{:#}", result.unwrap_err());
1104 assert!(msg.contains("no bundle"), "got: {msg}");
1106 }
1107
1108 #[test]
1109 fn auto_detect_bundle_id_empty_bundles() {
1110 let root = tempfile::tempdir().unwrap();
1111 let env_dir = root.path().join("local");
1112 std::fs::create_dir_all(&env_dir).unwrap();
1113 let env_json = serde_json::json!({
1114 "schema": "greentic.environment.v1",
1115 "environment_id": "local",
1116 "bundles": [],
1117 });
1118 std::fs::write(
1119 env_dir.join("environment.json"),
1120 serde_json::to_string_pretty(&env_json).unwrap(),
1121 )
1122 .unwrap();
1123 let store = LocalFsStore::new(root.path());
1124 let result = auto_detect_bundle_id(&store, "local");
1125 assert!(result.is_err());
1126 let msg = format!("{:#}", result.unwrap_err());
1127 assert!(msg.contains("no active bundle"), "got: {msg}");
1128 }
1129
1130 #[test]
1131 fn auto_detect_bundle_id_one_bundle() {
1132 let root = tempfile::tempdir().unwrap();
1133 let env_dir = root.path().join("local");
1134 std::fs::create_dir_all(&env_dir).unwrap();
1135 let env_json = serde_json::json!({
1136 "schema": "greentic.environment.v1",
1137 "environment_id": "local",
1138 "bundles": [{"bundle_id": "my-bundle"}],
1139 });
1140 std::fs::write(
1141 env_dir.join("environment.json"),
1142 serde_json::to_string_pretty(&env_json).unwrap(),
1143 )
1144 .unwrap();
1145 let store = LocalFsStore::new(root.path());
1146 let result = auto_detect_bundle_id(&store, "local").unwrap();
1147 assert_eq!(result, "my-bundle");
1148 }
1149
1150 #[test]
1151 fn auto_detect_bundle_id_skips_archived() {
1152 let root = tempfile::tempdir().unwrap();
1153 let env_dir = root.path().join("local");
1154 std::fs::create_dir_all(&env_dir).unwrap();
1155 let env_json = serde_json::json!({
1156 "schema": "greentic.environment.v1",
1157 "environment_id": "local",
1158 "bundles": [
1163 {"bundle_id": "active-bundle", "status": "active"},
1164 {"bundle_id": "old-bundle", "status": "archived"},
1165 ],
1166 });
1167 std::fs::write(
1168 env_dir.join("environment.json"),
1169 serde_json::to_string_pretty(&env_json).unwrap(),
1170 )
1171 .unwrap();
1172 let store = LocalFsStore::new(root.path());
1173 let result = auto_detect_bundle_id(&store, "local").unwrap();
1175 assert_eq!(result, "active-bundle");
1176 }
1177
1178 #[test]
1179 fn auto_detect_bundle_id_multiple_bundles() {
1180 let root = tempfile::tempdir().unwrap();
1181 let env_dir = root.path().join("local");
1182 std::fs::create_dir_all(&env_dir).unwrap();
1183 let env_json = serde_json::json!({
1184 "schema": "greentic.environment.v1",
1185 "environment_id": "local",
1186 "bundles": [
1187 {"bundle_id": "bundle-a"},
1188 {"bundle_id": "bundle-b"},
1189 ],
1190 });
1191 std::fs::write(
1192 env_dir.join("environment.json"),
1193 serde_json::to_string_pretty(&env_json).unwrap(),
1194 )
1195 .unwrap();
1196 let store = LocalFsStore::new(root.path());
1197 let result = auto_detect_bundle_id(&store, "local");
1198 assert!(result.is_err());
1199 let msg = format!("{:#}", result.unwrap_err());
1200 assert!(msg.contains("--bundle-id"), "got: {msg}");
1201 assert!(msg.contains("bundle-a"), "got: {msg}");
1202 assert!(msg.contains("bundle-b"), "got: {msg}");
1203 }
1204
1205 #[test]
1208 fn resolve_pack_explicit_path_wins() {
1209 let root = tempfile::tempdir().unwrap();
1211 let store = LocalFsStore::new(root.path());
1212 let info = provider_registry::lookup("telegram").unwrap();
1213
1214 let pack_file = root.path().join("my.gtpack");
1216 std::fs::write(&pack_file, b"fake").unwrap();
1217
1218 let result = resolve_pack(Some(&pack_file), info, &store, "local", None);
1219 assert_eq!(result.unwrap(), pack_file);
1220 }
1221
1222 #[test]
1223 fn resolve_pack_explicit_path_missing_errors() {
1224 let root = tempfile::tempdir().unwrap();
1225 let store = LocalFsStore::new(root.path());
1226 let info = provider_registry::lookup("telegram").unwrap();
1227
1228 let missing = root.path().join("nonexistent.gtpack");
1229 let result = resolve_pack(Some(&missing), info, &store, "local", None);
1230 assert!(result.is_err());
1231 let msg = format!("{:#}", result.unwrap_err());
1232 assert!(msg.contains("does not exist"), "got: {msg}");
1233 }
1234
1235 #[test]
1236 fn find_pack_in_revisions_finds_pack() {
1237 let root = tempfile::tempdir().unwrap();
1239 let env_dir = root.path().join("local");
1240
1241 let pack_dir = env_dir
1243 .join("revisions")
1244 .join("rev-001")
1245 .join("bundle")
1246 .join("packs");
1247 std::fs::create_dir_all(&pack_dir).unwrap();
1248 let pack_file = pack_dir.join("messaging-telegram.gtpack");
1249 std::fs::write(&pack_file, b"fake-pack").unwrap();
1250
1251 let found = find_pack_in_revisions(&env_dir, "messaging-telegram");
1252 assert_eq!(found.unwrap(), pack_file);
1253 }
1254
1255 #[test]
1256 fn find_pack_in_revisions_returns_none_when_missing() {
1257 let root = tempfile::tempdir().unwrap();
1258 let env_dir = root.path().join("local");
1259 std::fs::create_dir_all(env_dir.join("revisions")).unwrap();
1260
1261 let found = find_pack_in_revisions(&env_dir, "messaging-telegram");
1262 assert!(found.is_none());
1263 }
1264
1265 #[test]
1266 fn resolve_pack_no_source_available_errors() {
1267 let root = tempfile::tempdir().unwrap();
1270 let store = LocalFsStore::new(root.path());
1271 let info = provider_registry::lookup("telegram").unwrap();
1272
1273 let result = resolve_pack(
1274 None,
1275 info,
1276 &store,
1277 "local",
1278 Some("nonexistent-tag-for-testing"),
1279 );
1280 assert!(result.is_err(), "expected error, got: {result:?}");
1281 let msg = format!("{:#}", result.unwrap_err());
1282 assert!(
1283 msg.contains("OCI"),
1284 "error should mention OCI attempt: {msg}"
1285 );
1286 assert!(
1287 msg.contains("--pack"),
1288 "error should suggest --pack escape hatch: {msg}"
1289 );
1290 }
1291
1292 #[test]
1293 fn resolve_pack_version_override_rejects_offline_fallback() {
1294 let root = tempfile::tempdir().unwrap();
1299 let store = LocalFsStore::new(root.path());
1300 let info = provider_registry::lookup("telegram").unwrap();
1301
1302 let pack_dir = root
1305 .path()
1306 .join("local")
1307 .join("revisions")
1308 .join("rev-old")
1309 .join("bundle")
1310 .join("packs");
1311 std::fs::create_dir_all(&pack_dir).unwrap();
1312 std::fs::write(
1313 pack_dir.join(format!("{}.gtpack", info.pack_name)),
1314 b"old-pack",
1315 )
1316 .unwrap();
1317
1318 let result = resolve_pack(
1319 None,
1320 info,
1321 &store,
1322 "local",
1323 Some("nonexistent-tag-for-testing"),
1324 );
1325 assert!(
1326 result.is_err(),
1327 "should error when --pack-version is set and OCI fails, \
1328 not silently use an offline fallback; got: {result:?}"
1329 );
1330 }
1331
1332 #[test]
1333 fn resolve_pack_version_override_reflected_in_oci_ref() {
1334 let info = provider_registry::lookup("telegram").unwrap();
1338 let ref_default = provider_registry::oci_reference(info, None);
1339 let ref_pinned = provider_registry::oci_reference(info, Some("0.5.17"));
1340
1341 assert!(ref_default.ends_with(":stable"));
1342 assert!(ref_pinned.ends_with(":0.5.17"));
1343 }
1344
1345 fn write_test_pack(path: &Path, pack_id: &str) {
1349 use std::io::Write;
1350 use zip::write::{FileOptions, ZipWriter};
1351 let file = std::fs::File::create(path).unwrap();
1352 let mut writer = ZipWriter::new(file);
1353 let options: FileOptions<'_, ()> =
1354 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
1355 writer.start_file("pack.manifest.json", options).unwrap();
1356 writer
1357 .write_all(
1358 serde_json::json!({
1359 "pack_id": pack_id,
1360 "display_name": pack_id,
1361 })
1362 .to_string()
1363 .as_bytes(),
1364 )
1365 .unwrap();
1366 writer.finish().unwrap();
1367 }
1368
1369 fn write_env_json(env_dir: &Path, bundles_json: serde_json::Value) {
1373 std::fs::create_dir_all(env_dir).unwrap();
1374 let env_json = serde_json::json!({
1375 "schema": "greentic.environment.v1",
1376 "environment_id": env_dir.file_name().unwrap().to_str().unwrap(),
1377 "bundles": bundles_json,
1378 });
1379 std::fs::write(
1380 env_dir.join("environment.json"),
1381 serde_json::to_string_pretty(&env_json).unwrap(),
1382 )
1383 .unwrap();
1384 }
1385
1386 #[test]
1387 fn resolve_deployment_context_finds_single_revision() {
1388 let root = tempfile::tempdir().unwrap();
1389 let env_dir = root.path().join("local");
1390 let bundle_dir = env_dir.join("revisions").join("rev-001").join("bundle");
1391 std::fs::create_dir_all(&bundle_dir).unwrap();
1392
1393 write_env_json(
1394 &env_dir,
1395 serde_json::json!([{
1396 "bundle_id": "my-bundle",
1397 "customer_id": "acme-corp",
1398 "current_revisions": ["rev-001"],
1399 }]),
1400 );
1401
1402 let ctx = resolve_deployment_context(&env_dir, "my-bundle").unwrap();
1403 assert_eq!(ctx.bundle_dir, bundle_dir);
1404 assert_eq!(ctx.customer_id.as_deref(), Some("acme-corp"));
1405 }
1406
1407 #[test]
1408 fn resolve_deployment_context_errors_on_traffic_split() {
1409 let root = tempfile::tempdir().unwrap();
1410 let env_dir = root.path().join("local");
1411 for rev in &["rev-a", "rev-b"] {
1412 std::fs::create_dir_all(env_dir.join("revisions").join(rev).join("bundle")).unwrap();
1413 }
1414 write_env_json(
1415 &env_dir,
1416 serde_json::json!([{
1417 "bundle_id": "split-bundle",
1418 "current_revisions": ["rev-a", "rev-b"],
1419 }]),
1420 );
1421
1422 let err = resolve_deployment_context(&env_dir, "split-bundle").unwrap_err();
1423 let msg = format!("{err:#}");
1424 assert!(
1425 msg.contains("traffic split"),
1426 "expected traffic-split error, got: {msg}"
1427 );
1428 }
1429
1430 #[test]
1431 fn resolve_deployment_context_errors_on_no_revisions() {
1432 let root = tempfile::tempdir().unwrap();
1433 let env_dir = root.path().join("local");
1434 write_env_json(
1435 &env_dir,
1436 serde_json::json!([{
1437 "bundle_id": "empty-bundle",
1438 "current_revisions": [],
1439 }]),
1440 );
1441
1442 let err = resolve_deployment_context(&env_dir, "empty-bundle").unwrap_err();
1443 let msg = format!("{err:#}");
1444 assert!(
1445 msg.contains("no serving revisions"),
1446 "expected no-revisions error, got: {msg}"
1447 );
1448 }
1449
1450 #[test]
1453 fn check_pack_absent_when_not_present() {
1454 let root = tempfile::tempdir().unwrap();
1455 let bundle_dir = root.path().join("bundle");
1456 std::fs::create_dir_all(bundle_dir.join("packs")).unwrap();
1457
1458 let pack_path = root.path().join("messaging-telegram.gtpack");
1459 write_test_pack(&pack_path, "messaging-telegram");
1460
1461 let result =
1462 check_pack_in_bundle_dir(&bundle_dir, "messaging-telegram", &pack_path).unwrap();
1463 assert!(matches!(result, PackPresence::Absent));
1464 }
1465
1466 #[test]
1467 fn check_pack_matching_digest() {
1468 let root = tempfile::tempdir().unwrap();
1469 let bundle_dir = root.path().join("bundle");
1470 let provider_dir = bundle_dir.join("providers").join("messaging");
1471 std::fs::create_dir_all(&provider_dir).unwrap();
1472
1473 let pack_path = root.path().join("messaging-telegram.gtpack");
1474 write_test_pack(&pack_path, "messaging-telegram");
1475 std::fs::copy(&pack_path, provider_dir.join("messaging-telegram.gtpack")).unwrap();
1477
1478 let result =
1479 check_pack_in_bundle_dir(&bundle_dir, "messaging-telegram", &pack_path).unwrap();
1480 assert!(matches!(result, PackPresence::MatchingDigest));
1481 }
1482
1483 #[test]
1484 fn check_pack_digest_mismatch() {
1485 let root = tempfile::tempdir().unwrap();
1486 let bundle_dir = root.path().join("bundle");
1487 let provider_dir = bundle_dir.join("providers").join("messaging");
1488 std::fs::create_dir_all(&provider_dir).unwrap();
1489
1490 let pack_path = root.path().join("messaging-telegram.gtpack");
1492 write_test_pack(&pack_path, "messaging-telegram");
1493 std::fs::write(
1495 provider_dir.join("messaging-telegram.gtpack"),
1496 b"old-version-different-content",
1497 )
1498 .unwrap();
1499
1500 let result =
1501 check_pack_in_bundle_dir(&bundle_dir, "messaging-telegram", &pack_path).unwrap();
1502 assert!(matches!(result, PackPresence::DigestMismatch));
1503 }
1504
1505 #[test]
1508 fn prepare_bundle_injects_provider_pack() {
1509 let root = tempfile::tempdir().unwrap();
1513
1514 let env_dir = root.path().join("local");
1516 let bundle_dir = env_dir.join("revisions").join("rev-001").join("bundle");
1517 crate::bundle::create_demo_bundle_structure(&bundle_dir, Some("test-bundle")).unwrap();
1518
1519 let pack_dir = root.path().join("packs");
1521 std::fs::create_dir_all(&pack_dir).unwrap();
1522 let pack_path = pack_dir.join("messaging-telegram.gtpack");
1523 write_test_pack(&pack_path, "messaging-telegram");
1524
1525 let result =
1527 prepare_bundle_with_provider_pack(&bundle_dir, &pack_path, "messaging-telegram")
1528 .unwrap();
1529
1530 assert!(
1532 result.is_some(),
1533 "pack should be injected (was not already present)"
1534 );
1535 let (output_bundle, _tempdir) = result.unwrap();
1536
1537 let target_pack = output_bundle
1539 .join("providers")
1540 .join("messaging")
1541 .join("messaging-telegram.gtpack");
1542 assert!(
1543 target_pack.is_file(),
1544 "provider pack must be present at {}",
1545 target_pack.display(),
1546 );
1547
1548 let bundle_yaml =
1550 std::fs::read_to_string(output_bundle.join(crate::bundle::BUNDLE_WORKSPACE_MARKER))
1551 .unwrap();
1552 assert!(
1553 bundle_yaml.contains("providers/messaging/messaging-telegram.gtpack"),
1554 "bundle.yaml must contain the provider pack reference.\nGot:\n{bundle_yaml}",
1555 );
1556
1557 let lock_raw =
1559 std::fs::read_to_string(output_bundle.join(crate::bundle::BUNDLE_LOCK_FILE)).unwrap();
1560 let lock: serde_json::Value = serde_json::from_str(&lock_raw).unwrap();
1561 let ext_providers = lock
1562 .get("extension_providers")
1563 .and_then(|v| v.as_array())
1564 .expect("extension_providers array in lock");
1565 let has_telegram = ext_providers.iter().any(|entry| {
1566 entry
1567 .get("reference")
1568 .and_then(|v| v.as_str())
1569 .is_some_and(|r| r == "providers/messaging/messaging-telegram.gtpack")
1570 });
1571 assert!(
1572 has_telegram,
1573 "bundle.lock.json must contain the provider pack reference.\nGot:\n{lock_raw}",
1574 );
1575 }
1576
1577 #[test]
1578 fn prepare_bundle_skips_when_pack_already_present() {
1579 let root = tempfile::tempdir().unwrap();
1580
1581 let env_dir = root.path().join("local");
1583 let bundle_dir = env_dir.join("revisions").join("rev-001").join("bundle");
1584 crate::bundle::create_demo_bundle_structure(&bundle_dir, Some("test-bundle")).unwrap();
1585 let provider_dir = bundle_dir.join("providers").join("messaging");
1586 std::fs::create_dir_all(&provider_dir).unwrap();
1587 write_test_pack(
1588 &provider_dir.join("messaging-telegram.gtpack"),
1589 "messaging-telegram",
1590 );
1591
1592 let pack_path = root.path().join("messaging-telegram.gtpack");
1594 write_test_pack(&pack_path, "messaging-telegram");
1595
1596 let result =
1597 prepare_bundle_with_provider_pack(&bundle_dir, &pack_path, "messaging-telegram")
1598 .unwrap();
1599
1600 assert!(
1602 result.is_none(),
1603 "should skip injection when pack is already in the bundle with same digest",
1604 );
1605 }
1606
1607 #[test]
1608 fn prepare_bundle_replaces_when_digest_mismatch() {
1609 let root = tempfile::tempdir().unwrap();
1612
1613 let bundle_dir = root.path().join("bundle");
1614 crate::bundle::create_demo_bundle_structure(&bundle_dir, Some("test-bundle")).unwrap();
1615
1616 let provider_dir = bundle_dir.join("providers").join("messaging");
1618 std::fs::create_dir_all(&provider_dir).unwrap();
1619 std::fs::write(
1620 provider_dir.join("messaging-telegram.gtpack"),
1621 b"old-stale-content",
1622 )
1623 .unwrap();
1624
1625 let pack_path = root.path().join("messaging-telegram.gtpack");
1627 write_test_pack(&pack_path, "messaging-telegram");
1628
1629 let result =
1630 prepare_bundle_with_provider_pack(&bundle_dir, &pack_path, "messaging-telegram")
1631 .unwrap();
1632
1633 assert!(
1634 result.is_some(),
1635 "must inject when digest differs (not skip)"
1636 );
1637 }
1638
1639 #[test]
1640 fn prepare_bundle_is_idempotent_on_rerun() {
1641 let root = tempfile::tempdir().unwrap();
1643 let bundle_dir = root.path().join("bundle");
1644 crate::bundle::create_demo_bundle_structure(&bundle_dir, Some("test-bundle")).unwrap();
1645
1646 let pack_path = root.path().join("messaging-telegram.gtpack");
1647 write_test_pack(&pack_path, "messaging-telegram");
1648
1649 let result1 =
1651 prepare_bundle_with_provider_pack(&bundle_dir, &pack_path, "messaging-telegram")
1652 .unwrap();
1653 assert!(result1.is_some(), "first injection should proceed");
1654 let (bundle1, _td1) = result1.unwrap();
1655 let yaml1 =
1656 std::fs::read_to_string(bundle1.join(crate::bundle::BUNDLE_WORKSPACE_MARKER)).unwrap();
1657
1658 let provider_dir = bundle_dir.join("providers").join("messaging");
1662 std::fs::create_dir_all(&provider_dir).unwrap();
1663 std::fs::copy(
1664 bundle1
1665 .join("providers")
1666 .join("messaging")
1667 .join("messaging-telegram.gtpack"),
1668 provider_dir.join("messaging-telegram.gtpack"),
1669 )
1670 .unwrap();
1671
1672 let result2 =
1674 prepare_bundle_with_provider_pack(&bundle_dir, &pack_path, "messaging-telegram")
1675 .unwrap();
1676 assert!(
1677 result2.is_none(),
1678 "second injection should be skipped (pack already present with same digest)"
1679 );
1680
1681 let count = yaml1
1683 .matches("providers/messaging/messaging-telegram.gtpack")
1684 .count();
1685 assert_eq!(
1686 count, 1,
1687 "bundle.yaml should contain exactly one reference to the pack, found {count}"
1688 );
1689 }
1690
1691 #[test]
1694 fn inject_provider_pack_calls_deploy_when_pack_absent() {
1695 let root = tempfile::tempdir().unwrap();
1699 let store = LocalFsStore::new(root.path());
1700
1701 let env_dir = root.path().join("local");
1703 let bundle_dir = env_dir.join("revisions").join("rev-001").join("bundle");
1704 crate::bundle::create_demo_bundle_structure(&bundle_dir, Some("test-bundle")).unwrap();
1705
1706 write_env_json(
1708 &env_dir,
1709 serde_json::json!([{
1710 "bundle_id": "test-bundle",
1711 "current_revisions": ["rev-001"],
1712 }]),
1713 );
1714
1715 let pack_dir = root.path().join("packs");
1717 std::fs::create_dir_all(&pack_dir).unwrap();
1718 let pack_path = pack_dir.join("messaging-telegram.gtpack");
1719 write_test_pack(&pack_path, "messaging-telegram");
1720
1721 let deploy_called = std::cell::Cell::new(false);
1723
1724 let result = inject_provider_pack_impl(
1725 &store,
1726 "local",
1727 "test-bundle",
1728 &pack_path,
1729 "messaging-telegram",
1730 |bundle_copy, env_id, _customer_id| {
1731 deploy_called.set(true);
1732 let target = bundle_copy
1734 .join("providers")
1735 .join("messaging")
1736 .join("messaging-telegram.gtpack");
1737 assert!(
1738 target.is_file(),
1739 "deploy_fn must receive a bundle containing the injected pack at {}",
1740 target.display(),
1741 );
1742 assert_eq!(env_id, "local");
1743 Ok(())
1744 },
1745 )
1746 .unwrap();
1747
1748 assert!(
1749 deploy_called.get(),
1750 "deploy function must be called when the provider pack is absent from the bundle"
1751 );
1752 assert_eq!(
1753 result,
1754 PackDeployOutcome::Deployed,
1755 "outcome must be Deployed when pack was absent and deploy succeeded"
1756 );
1757 }
1758
1759 #[test]
1760 fn inject_provider_pack_skips_deploy_when_pack_present() {
1761 let root = tempfile::tempdir().unwrap();
1764 let store = LocalFsStore::new(root.path());
1765
1766 let env_dir = root.path().join("local");
1768 let bundle_dir = env_dir.join("revisions").join("rev-001").join("bundle");
1769 crate::bundle::create_demo_bundle_structure(&bundle_dir, Some("test-bundle")).unwrap();
1770 let provider_dir = bundle_dir.join("providers").join("messaging");
1771 std::fs::create_dir_all(&provider_dir).unwrap();
1772 write_test_pack(
1773 &provider_dir.join("messaging-telegram.gtpack"),
1774 "messaging-telegram",
1775 );
1776
1777 write_env_json(
1778 &env_dir,
1779 serde_json::json!([{
1780 "bundle_id": "test-bundle",
1781 "current_revisions": ["rev-001"],
1782 }]),
1783 );
1784
1785 let pack_path = root.path().join("messaging-telegram.gtpack");
1786 write_test_pack(&pack_path, "messaging-telegram");
1787
1788 let result = inject_provider_pack_impl(
1789 &store,
1790 "local",
1791 "test-bundle",
1792 &pack_path,
1793 "messaging-telegram",
1794 |_, _, _| {
1795 panic!("deploy function must NOT be called when the pack is already present");
1796 },
1797 )
1798 .unwrap();
1799
1800 assert_eq!(
1801 result,
1802 PackDeployOutcome::AlreadyPresent,
1803 "outcome must be AlreadyPresent when pack is already in the bundle"
1804 );
1805 }
1806
1807 #[test]
1810 fn inject_targets_bundle_id_not_mtime() {
1811 let root = tempfile::tempdir().unwrap();
1822 let store = LocalFsStore::new(root.path());
1823 let env_dir = root.path().join("local");
1824
1825 let pack_path = root.path().join("messaging-telegram.gtpack");
1826 write_test_pack(&pack_path, "messaging-telegram");
1827
1828 let plant_pack_in = |rev: &str, bundle_id: &str, with_pack: bool| {
1830 let dir = env_dir.join("revisions").join(rev).join("bundle");
1831 crate::bundle::create_demo_bundle_structure(&dir, Some(bundle_id)).unwrap();
1832 if with_pack {
1833 let pd = dir.join("providers").join("messaging");
1834 std::fs::create_dir_all(&pd).unwrap();
1835 std::fs::copy(&pack_path, pd.join("messaging-telegram.gtpack")).unwrap();
1836 }
1837 };
1838 plant_pack_in("rev-z", "bundle-z", true); plant_pack_in("rev-a", "bundle-a", false); plant_pack_in("rev-b", "bundle-b", true); use std::fs::FileTimes;
1844 use std::time::{Duration, SystemTime};
1845 let stamp = |rev: &str, secs: u64| {
1846 std::fs::File::open(env_dir.join("revisions").join(rev))
1847 .unwrap()
1848 .set_times(
1849 FileTimes::new()
1850 .set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(secs)),
1851 )
1852 .unwrap();
1853 };
1854 stamp("rev-a", 1_000_000); stamp("rev-z", 1_500_000);
1856 stamp("rev-b", 2_000_000_000); write_env_json(
1859 &env_dir,
1860 serde_json::json!([
1861 { "bundle_id": "bundle-z", "current_revisions": ["rev-z"] },
1862 { "bundle_id": "bundle-a", "current_revisions": ["rev-a"] },
1863 { "bundle_id": "bundle-b", "current_revisions": ["rev-b"] },
1864 ]),
1865 );
1866
1867 let deploy_called = std::cell::Cell::new(false);
1868 let result = inject_provider_pack_impl(
1869 &store,
1870 "local",
1871 "bundle-a", &pack_path,
1873 "messaging-telegram",
1874 |bundle_copy, _env_id, _customer_id| {
1875 deploy_called.set(true);
1876 let marker =
1879 std::fs::read_to_string(bundle_copy.join(crate::bundle::LEGACY_BUNDLE_MARKER))
1880 .expect("bundle copy must carry the bundle marker");
1881 assert!(
1882 marker.contains("bundle-a"),
1883 "deploy must receive bundle-a's tree, got marker: {marker}"
1884 );
1885 Ok(())
1886 },
1887 )
1888 .unwrap();
1889
1890 assert!(
1893 deploy_called.get(),
1894 "deploy must target bundle-a (absent), not bundle-b (present but wrong bundle_id)"
1895 );
1896 assert_eq!(result, PackDeployOutcome::Deployed);
1897 }
1898
1899 #[test]
1902 fn inject_passes_customer_id_to_deploy_fn() {
1903 let root = tempfile::tempdir().unwrap();
1906 let store = LocalFsStore::new(root.path());
1907 let env_dir = root.path().join("staging");
1908
1909 let bundle_dir = env_dir.join("revisions").join("rev-001").join("bundle");
1910 crate::bundle::create_demo_bundle_structure(&bundle_dir, Some("my-bundle")).unwrap();
1911
1912 write_env_json(
1913 &env_dir,
1914 serde_json::json!([{
1915 "bundle_id": "my-bundle",
1916 "customer_id": "billing-corp-42",
1917 "current_revisions": ["rev-001"],
1918 }]),
1919 );
1920
1921 let pack_path = root.path().join("messaging-telegram.gtpack");
1922 write_test_pack(&pack_path, "messaging-telegram");
1923
1924 let observed_customer_id = std::cell::RefCell::new(None::<Option<String>>);
1925 let _result = inject_provider_pack_impl(
1926 &store,
1927 "staging",
1928 "my-bundle",
1929 &pack_path,
1930 "messaging-telegram",
1931 |_bundle_copy, _env_id, customer_id| {
1932 *observed_customer_id.borrow_mut() = Some(customer_id.map(String::from));
1933 Ok(())
1934 },
1935 )
1936 .unwrap();
1937
1938 let cid = observed_customer_id.borrow();
1939 assert_eq!(
1940 cid.as_ref().unwrap().as_deref(),
1941 Some("billing-corp-42"),
1942 "deploy_fn must receive customer_id from the deployment"
1943 );
1944 }
1945
1946 #[test]
1949 fn inject_errors_on_traffic_split() {
1950 let root = tempfile::tempdir().unwrap();
1953 let store = LocalFsStore::new(root.path());
1954 let env_dir = root.path().join("local");
1955
1956 for rev in &["rev-a", "rev-b"] {
1957 let bd = env_dir.join("revisions").join(rev).join("bundle");
1958 crate::bundle::create_demo_bundle_structure(&bd, Some("split-bundle")).unwrap();
1959 }
1960
1961 write_env_json(
1962 &env_dir,
1963 serde_json::json!([{
1964 "bundle_id": "split-bundle",
1965 "current_revisions": ["rev-a", "rev-b"],
1966 }]),
1967 );
1968
1969 let pack_path = root.path().join("messaging-telegram.gtpack");
1970 write_test_pack(&pack_path, "messaging-telegram");
1971
1972 let err = inject_provider_pack_impl(
1973 &store,
1974 "local",
1975 "split-bundle",
1976 &pack_path,
1977 "messaging-telegram",
1978 |_, _, _| panic!("deploy must not be called during a traffic split"),
1979 )
1980 .unwrap_err();
1981
1982 let msg = format!("{err:#}");
1983 assert!(
1984 msg.contains("traffic split"),
1985 "expected traffic-split error, got: {msg}"
1986 );
1987 }
1988
1989 #[test]
1992 fn inject_redeploys_on_digest_mismatch() {
1993 let root = tempfile::tempdir().unwrap();
1996 let store = LocalFsStore::new(root.path());
1997 let env_dir = root.path().join("local");
1998
1999 let bundle_dir = env_dir.join("revisions").join("rev-001").join("bundle");
2000 crate::bundle::create_demo_bundle_structure(&bundle_dir, Some("test-bundle")).unwrap();
2001
2002 let provider_dir = bundle_dir.join("providers").join("messaging");
2004 std::fs::create_dir_all(&provider_dir).unwrap();
2005 std::fs::write(
2006 provider_dir.join("messaging-telegram.gtpack"),
2007 b"stale-old-pack-bytes",
2008 )
2009 .unwrap();
2010
2011 write_env_json(
2012 &env_dir,
2013 serde_json::json!([{
2014 "bundle_id": "test-bundle",
2015 "current_revisions": ["rev-001"],
2016 }]),
2017 );
2018
2019 let pack_path = root.path().join("messaging-telegram.gtpack");
2021 write_test_pack(&pack_path, "messaging-telegram");
2022
2023 let deploy_called = std::cell::Cell::new(false);
2024 let result = inject_provider_pack_impl(
2025 &store,
2026 "local",
2027 "test-bundle",
2028 &pack_path,
2029 "messaging-telegram",
2030 |_bundle_copy, _env_id, _customer_id| {
2031 deploy_called.set(true);
2032 Ok(())
2033 },
2034 )
2035 .unwrap();
2036
2037 assert!(
2038 deploy_called.get(),
2039 "deploy must be called when digest differs (not skipped)"
2040 );
2041 assert_eq!(result, PackDeployOutcome::Deployed);
2042 }
2043
2044 #[test]
2045 fn inject_skips_on_matching_digest() {
2046 let root = tempfile::tempdir().unwrap();
2048 let store = LocalFsStore::new(root.path());
2049 let env_dir = root.path().join("local");
2050
2051 let bundle_dir = env_dir.join("revisions").join("rev-001").join("bundle");
2052 crate::bundle::create_demo_bundle_structure(&bundle_dir, Some("test-bundle")).unwrap();
2053
2054 let pack_path = root.path().join("messaging-telegram.gtpack");
2055 write_test_pack(&pack_path, "messaging-telegram");
2056
2057 let provider_dir = bundle_dir.join("providers").join("messaging");
2059 std::fs::create_dir_all(&provider_dir).unwrap();
2060 std::fs::copy(&pack_path, provider_dir.join("messaging-telegram.gtpack")).unwrap();
2061
2062 write_env_json(
2063 &env_dir,
2064 serde_json::json!([{
2065 "bundle_id": "test-bundle",
2066 "current_revisions": ["rev-001"],
2067 }]),
2068 );
2069
2070 let result = inject_provider_pack_impl(
2071 &store,
2072 "local",
2073 "test-bundle",
2074 &pack_path,
2075 "messaging-telegram",
2076 |_, _, _| panic!("deploy must NOT be called when digest matches"),
2077 )
2078 .unwrap();
2079
2080 assert_eq!(result, PackDeployOutcome::AlreadyPresent);
2081 }
2082
2083 #[test]
2102 fn register_provider_core_skips_deploy_when_not_linking_bundle() {
2103 let root = tempfile::tempdir().unwrap();
2106 let store = LocalFsStore::new(root.path());
2107
2108 ensure_local_environment(&store, None).expect("bootstrap local env");
2111
2112 let pack_path = root.path().join("messaging-telegram.gtpack");
2113 write_test_pack(&pack_path, "messaging-telegram");
2114 let answers = serde_json::Map::new();
2115
2116 let result = register_provider_core_impl(
2117 &store,
2118 &RegisterProviderPayload {
2119 env_id: "local",
2120 tenant: "acme",
2121 team: None,
2122 provider_type: "messaging.telegram.bot",
2123 provider_id: "telegram",
2124 pack_name: "messaging-telegram",
2125 display_name: "Telegram".to_string(),
2126 bundle_id: "test-bundle",
2127 link_bundle: false,
2128 answers: &answers,
2129 pack_path: &pack_path,
2130 },
2131 None,
2132 |_, _, _| panic!("deploy must NOT be called when link_bundle is false"),
2133 )
2134 .expect("register_provider_core_impl must succeed");
2135
2136 assert_eq!(result.pack_deploy, PackDeployOutcome::AlreadyPresent);
2137 }
2138 fn create_test_pack(yaml: &str) -> (tempfile::TempDir, PathBuf) {
2142 use std::io::Write as _;
2143 use zip::write::{FileOptions, ZipWriter};
2144 let temp_dir = tempfile::tempdir().unwrap();
2145 let pack_path = temp_dir.path().join("messaging-test.gtpack");
2146 let file = std::fs::File::create(&pack_path).unwrap();
2147 let mut writer = ZipWriter::new(file);
2148 let options: FileOptions<'_, ()> =
2149 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
2150 writer.start_file("assets/setup.yaml", options).unwrap();
2151 writer.write_all(yaml.as_bytes()).unwrap();
2152 writer.finish().unwrap();
2153 (temp_dir, pack_path)
2154 }
2155
2156 #[test]
2157 fn has_oauth_device_code_action_detects_device_code_kind() {
2158 let yaml = "provider_id: teams\n\
2159 questions: []\n\
2160 setup_actions:\n \
2161 - id: device_login\n \
2162 kind: oauth_device_code\n";
2163 let (_dir, pack) = create_test_pack(yaml);
2164 assert!(has_oauth_device_code_action(&pack));
2165 }
2166
2167 #[test]
2168 fn has_oauth_device_code_action_false_for_other_actions() {
2169 let yaml = "provider_id: slack\n\
2170 questions: []\n\
2171 setup_actions:\n \
2172 - id: add_to_slack\n \
2173 kind: oauth_install_button\n";
2174 let (_dir, pack) = create_test_pack(yaml);
2175 assert!(!has_oauth_device_code_action(&pack));
2176 }
2177
2178 #[test]
2179 fn has_oauth_device_code_action_false_for_non_pack_file() {
2180 let dir = tempfile::tempdir().unwrap();
2181 let not_a_pack = dir.path().join("garbage.gtpack");
2182 std::fs::write(¬_a_pack, b"not a zip archive").unwrap();
2183 assert!(!has_oauth_device_code_action(¬_a_pack));
2184 }
2185
2186 #[test]
2187 fn collect_secret_keys_from_pack_collects_only_secret_questions() {
2188 let yaml = "provider_id: telegram\n\
2189 questions:\n \
2190 - name: bot-token\n \
2191 secret: true\n \
2192 - name: public_base_url\n";
2193 let (_dir, pack) = create_test_pack(yaml);
2194 let keys = collect_secret_keys_from_pack(&pack);
2195 assert!(keys.contains("bot-token"), "got: {keys:?}");
2196 assert!(keys.contains("bot_token"), "got: {keys:?}");
2198 assert!(!keys.contains("public_base_url"), "got: {keys:?}");
2199 }
2200
2201 #[test]
2202 fn collect_secret_keys_from_pack_empty_for_non_pack_file() {
2203 let dir = tempfile::tempdir().unwrap();
2204 let not_a_pack = dir.path().join("garbage.gtpack");
2205 std::fs::write(¬_a_pack, b"not a zip archive").unwrap();
2206 assert!(collect_secret_keys_from_pack(¬_a_pack).is_empty());
2207 }
2208
2209 #[test]
2212 fn deterministic_idempotency_key_is_stable_and_input_sensitive() {
2213 let a = deterministic_idempotency_key("local", "telegram", "tg-main");
2214 let b = deterministic_idempotency_key("local", "telegram", "tg-main");
2215 assert_eq!(a, b);
2216 assert!(a.starts_with("setup-provider-"), "got: {a}");
2217 assert_eq!(a.len(), "setup-provider-".len() + 32);
2219 assert_ne!(
2220 a,
2221 deterministic_idempotency_key("local", "telegram", "tg-2")
2222 );
2223 assert_ne!(
2224 a,
2225 deterministic_idempotency_key("prod", "telegram", "tg-main")
2226 );
2227 assert_ne!(
2228 a,
2229 deterministic_idempotency_key("local", "slack", "tg-main")
2230 );
2231 }
2232
2233 fn write_host_env_json(root: &Path, env_id: &str, doc: &serde_json::Value) {
2236 let env_dir = root.join(env_id);
2237 std::fs::create_dir_all(&env_dir).unwrap();
2238 std::fs::write(
2239 env_dir.join("environment.json"),
2240 serde_json::to_string_pretty(doc).unwrap(),
2241 )
2242 .unwrap();
2243 }
2244
2245 #[test]
2246 fn has_resolvable_public_url_reads_host_config() {
2247 let root = tempfile::tempdir().unwrap();
2248 let store = LocalFsStore::new(root.path());
2249 write_host_env_json(
2250 root.path(),
2251 "local",
2252 &serde_json::json!({
2253 "host_config": {"public_base_url": "https://example.com"}
2254 }),
2255 );
2256 assert!(has_resolvable_public_url(&store, "local"));
2257 }
2258
2259 #[test]
2260 fn has_resolvable_public_url_false_without_config() {
2261 if std::env::var("PUBLIC_BASE_URL").is_ok_and(|v| !v.is_empty()) {
2265 return;
2266 }
2267 let root = tempfile::tempdir().unwrap();
2268 let store = LocalFsStore::new(root.path());
2269 assert!(!has_resolvable_public_url(&store, "local"));
2271 write_host_env_json(root.path(), "local", &serde_json::json!({"bundles": []}));
2273 assert!(!has_resolvable_public_url(&store, "local"));
2274 write_host_env_json(
2276 root.path(),
2277 "local",
2278 &serde_json::json!({"host_config": {"public_base_url": ""}}),
2279 );
2280 assert!(!has_resolvable_public_url(&store, "local"));
2281 }
2282
2283 #[test]
2286 fn register_provider_core_registers_endpoint_and_writes_secrets() {
2287 let root = tempfile::tempdir().unwrap();
2288 let store = LocalFsStore::new(root.path());
2289 ensure_local_environment(&store, None).expect("bootstrap local env");
2290
2291 let yaml = "provider_id: telegram\n\
2292 questions:\n \
2293 - name: bot_token\n \
2294 secret: true\n \
2295 - name: webhook_secret\n \
2296 secret: true\n \
2297 - name: numeric_secret\n \
2298 secret: true\n \
2299 - name: public_base_url\n";
2300 let (_pack_dir, pack_path) = create_test_pack(yaml);
2301
2302 let mut answers = serde_json::Map::new();
2303 answers.insert("bot_token".into(), serde_json::json!("123456:test-token"));
2304 answers.insert("webhook_secret".into(), serde_json::json!(""));
2306 answers.insert("numeric_secret".into(), serde_json::json!(42));
2307 answers.insert(
2309 "public_base_url".into(),
2310 serde_json::json!("https://example.com"),
2311 );
2312
2313 let payload = RegisterProviderPayload {
2314 env_id: "local",
2315 tenant: "demo",
2316 team: None,
2317 provider_type: "telegram",
2318 provider_id: "tg-main",
2319 pack_name: "messaging-telegram",
2320 display_name: "Telegram".to_string(),
2321 bundle_id: "unused",
2322 link_bundle: false,
2323 answers: &answers,
2324 pack_path: &pack_path,
2325 };
2326 let key = deterministic_idempotency_key("local", "telegram", "tg-main");
2327 let endpoint_id = register_provider_core(&store, &payload, Some(key.clone()))
2328 .expect("register")
2329 .endpoint_id;
2330 assert!(!endpoint_id.is_empty());
2331
2332 let replay_id = register_provider_core(&store, &payload, Some(key))
2334 .expect("replay")
2335 .endpoint_id;
2336 assert_eq!(replay_id, endpoint_id);
2337
2338 let outcome = messaging::list(&store, &op_flags(), "local").expect("list");
2341 let endpoints = outcome
2342 .result
2343 .get("endpoints")
2344 .and_then(|v| v.as_array())
2345 .cloned()
2346 .unwrap_or_default();
2347 assert_eq!(endpoints.len(), 1, "got: {endpoints:?}");
2348 let ep = &endpoints[0];
2349 assert_eq!(
2350 ep.get("endpoint_id").and_then(|v| v.as_str()),
2351 Some(endpoint_id.as_str())
2352 );
2353 assert_eq!(
2354 ep.get("provider_type").and_then(|v| v.as_str()),
2355 Some("telegram")
2356 );
2357 let refs: Vec<&str> = ep
2358 .get("secret_refs")
2359 .and_then(|v| v.as_array())
2360 .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
2361 .unwrap_or_default();
2362 assert_eq!(
2363 refs,
2364 vec!["secret://local/demo/_/messaging-telegram/bot_token"]
2365 );
2366 }
2367
2368 #[test]
2369 fn register_provider_core_link_bundle_reports_missing_bundle() {
2370 let root = tempfile::tempdir().unwrap();
2371 let store = LocalFsStore::new(root.path());
2372 ensure_local_environment(&store, None).expect("bootstrap local env");
2373
2374 let (_pack_dir, pack_path) = create_test_pack("provider_id: telegram\nquestions: []\n");
2375 let answers = serde_json::Map::new();
2376
2377 let payload = RegisterProviderPayload {
2378 env_id: "local",
2379 tenant: "demo",
2380 team: None,
2381 provider_type: "telegram",
2382 provider_id: "tg-main",
2383 pack_name: "messaging-telegram",
2384 display_name: "Telegram".to_string(),
2385 bundle_id: "no-such-bundle",
2386 link_bundle: true,
2387 answers: &answers,
2388 pack_path: &pack_path,
2389 };
2390 let Err(err) = register_provider_core(&store, &payload, None) else {
2391 panic!("linking a bundle that is not deployed must fail");
2392 };
2393 let msg = format!("{err:#}");
2394 assert!(msg.contains("link bundle"), "got: {msg}");
2395 assert!(msg.contains("no-such-bundle"), "got: {msg}");
2396 }
2397}