Skip to main content

greentic_setup/
provider_commands.rs

1//! `greentic-setup provider {add,list,remove}` — one-command provider wiring.
2//!
3//! Porcelain over the deployer's messaging-endpoint library API: resolves the
4//! provider pack, collects setup answers (interactive or headless), writes
5//! secrets into the env's dev store (the store the runtime reads), registers
6//! the messaging endpoint, and links it to a deployed bundle.
7//!
8//! Secrets are written via the deployer's `secrets::put` (env-level dev store
9//! at `<env_dir>/.greentic/dev/.dev.secrets.env`) — NOT the setup-native
10//! `SecretsSetup` (bundle-scoped store the runtime never reads). The
11//! endpoint's `secret_refs` carry `secret://` URIs (deployer convention, no
12//! trailing `s`) so the runtime can resolve them.
13
14use 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
32/// The `updated_by` identity stamped on every mutation this module performs.
33const UPDATED_BY: &str = "greentic-setup";
34
35/// Outcome of the provider-pack deployment step in [`register_provider_core`].
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum PackDeployOutcome {
38    /// A new revision was staged with the provider pack injected into the bundle.
39    Deployed,
40    /// The provider pack was already present in the deployed bundle.
41    AlreadyPresent,
42}
43
44/// Result of [`register_provider_core`].
45pub struct ProviderRegistrationResult {
46    pub endpoint_id: String,
47    pub pack_deploy: PackDeployOutcome,
48}
49
50/// No-op flags for direct deployer library calls (no `--schema`, no
51/// `--answers` file — we supply payloads programmatically).
52fn op_flags() -> OpFlags {
53    OpFlags {
54        schema_only: false,
55        answers: None,
56    }
57}
58
59// ---------------------------------------------------------------------------
60// Pack resolution
61// ---------------------------------------------------------------------------
62
63/// Resolve a provider pack to a local `.gtpack` path.
64///
65/// Resolution order:
66/// 1. `--pack <path>` explicit override.
67/// 2. OCI fetch from GHCR.
68/// 3. Offline fallback: pack already inside the deployed bundle in this env.
69///
70/// `pack_version` overrides the OCI tag (the `--pack-version` escape hatch).
71/// It is ignored when `explicit_pack` is `Some` (the `--pack` flag wins).
72pub 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    // 1. Explicit override.
80    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    // 2. OCI fetch.
89    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                // When the user explicitly requested a version, do not
98                // silently fall back to an arbitrary old pack — the offline
99                // fallback has no version awareness and would return
100                // whichever pack happened to be deployed previously.
101                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    // 3. Offline fallback: scan deployed revisions for the pack.
129    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
150/// Scan `<env_dir>/revisions/*/bundle/packs/` for a pack matching `pack_name`.
151fn 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
167// ---------------------------------------------------------------------------
168// Secret URI helpers
169// ---------------------------------------------------------------------------
170
171/// Normalize a provider segment for secret URIs/paths. Mirrors the runtime's
172/// `normalize_pack_segment` (greentic-runner-host/src/secrets.rs): lowercase,
173/// keep `a-z 0-9 _ -`. Unlike `canonical_secret_name` (which maps hyphens to
174/// underscores), this preserves hyphens so that `messaging-telegram` stays
175/// `messaging-telegram`.
176fn 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
194/// Build a `secret://` URI (deployer convention, no trailing `s`) from
195/// components. The provider segment is normalized with
196/// [`normalize_provider_segment`] (hyphens preserved) to match the runtime's
197/// `normalize_pack_segment`. The key segment uses `canonical_secret_name`
198/// (hyphens → underscores) to match the runtime's `canonicalize_secret_key`.
199fn 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
210/// Build the relative path portion (everything after `secret://<env>/`) for
211/// the deployer's `SecretsPutPayload.path`. Provider segment uses
212/// [`normalize_provider_segment`]; key segment uses `canonical_secret_name`.
213/// Team segment uses [`greentic_secrets_lib::normalize_team`] (canonical
214/// source of truth for the empty / `"default"` → `_` rule).
215fn 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
223// ---------------------------------------------------------------------------
224// Bundle-id auto-detection
225// ---------------------------------------------------------------------------
226
227/// Auto-detect the bundle id from the environment. Returns `Ok(id)` when
228/// exactly one bundle is deployed; returns an actionable error otherwise.
229///
230/// Reads `environment.json` directly from the store's on-disk layout rather
231/// than going through `EnvironmentReads` (which requires `EnvId`, a type
232/// from `greentic-deploy-spec` that is not in our dependency graph).
233pub 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    // Only consider active bundles (or entries without a status field, for
251    // backward compat). Archived / Paused bundles are not valid link targets.
252    //
253    // `BundleDeploymentStatus` is `#[serde(rename_all = "lowercase")]`, so the
254    // on-disk value is `"active"` — match case-insensitively rather than
255    // against a hand-guessed casing.
256    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
291// ---------------------------------------------------------------------------
292// Mutation core — reusable by `add()` and answers-driven auto-deploy
293// ---------------------------------------------------------------------------
294
295/// Payload for [`register_provider_core`].
296pub 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
310/// Register a messaging endpoint, write its secrets, optionally link a
311/// bundle, and deploy the provider pack into the environment's bundle.
312/// This is the mutation core shared by `add()` (interactive) and
313/// answers-driven auto-deploy (non-interactive).
314///
315/// Ordering invariant: bundle_id is resolved BEFORE this function is called
316/// (the caller must verify it exists). Inside, the sequence is:
317/// 1. register endpoint (fail-fast on duplicate)
318/// 2. write secrets
319/// 3. link bundle
320/// 4. deploy provider pack (when `link_bundle` is true and the pack is not
321///    already in the deployed bundle)
322///
323/// When `idempotency_key` is `Some`, the deployer's replay logic makes
324/// same-identity re-runs no-ops. When `None`, the deployer mints a fresh
325/// UUID (legacy interactive path).
326pub 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
341/// Testable inner implementation of [`register_provider_core`].
342///
343/// Accepts a `deploy_fn` closure so tests can substitute a lightweight stub for
344/// the real [`crate::env_deploy::deploy_bundle_to_env`] call and assert that the
345/// pack-deploy step is actually reached from this function.
346fn 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    // ── Build secret entries ─────────────────────────────────────────
353    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    // ── Register the messaging endpoint ──────────────────────────────
388    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    // ── Write secrets ────────────────────────────────────────────────
415    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    // ── Link bundle ──────────────────────────────────────────────────
443    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    // ── Deploy provider pack into the environment's bundle ──────────
460    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
479/// Derive a deterministic idempotency key from (env_id, provider_type,
480/// provider_id). Re-running the same answers.json against the same
481/// environment naturally replays as a no-op in the deployer's endpoint
482/// engine, which is idempotent on same-key same-identity replays.
483pub 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
501// ---------------------------------------------------------------------------
502// provider add
503// ---------------------------------------------------------------------------
504
505/// `greentic-setup provider add <KIND>`.
506pub 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    // ── Build the deployer store ───────────────────────────────────────
525    let root = LocalFsStore::default_root()
526        .context("cannot locate the environment store: HOME / USERPROFILE not set")?;
527    let store = LocalFsStore::new(root);
528
529    // Ensure / validate the target environment exists.
530    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    // ── Resolve the provider pack ─────────────────────────────────────
547    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    // ── Resolve provider id (used by the device-code check and all
557    //    downstream steps) ────────────────────────────────────────────
558    let provider_id = args
559        .provider_id
560        .as_deref()
561        .unwrap_or(info.default_provider_id);
562
563    // ── Check for teams device-code flow (before collecting answers) ──
564    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    // ── Resolve the link target BEFORE any mutation ───────────────────
610    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    // ── Closing message ───────────────────────────────────────────────
646    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    // Warn about webhook registration if no public base URL is resolvable.
668    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
683// ---------------------------------------------------------------------------
684// provider list
685// ---------------------------------------------------------------------------
686
687/// `greentic-setup provider list`.
688pub 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
699// ---------------------------------------------------------------------------
700// provider remove
701// ---------------------------------------------------------------------------
702
703/// `greentic-setup provider remove <ID>`.
704pub 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
732// ---------------------------------------------------------------------------
733// Helpers
734// ---------------------------------------------------------------------------
735
736/// Check whether the pack's `setup.yaml` declares an `oauth_device_code` setup
737/// action (used by teams-graph).
738fn 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
751/// Collect the set of question names marked `secret: true` in the pack's
752/// `setup.yaml`.
753fn 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
766/// Check whether a public base URL is resolvable for the given environment.
767fn has_resolvable_public_url(store: &LocalFsStore, env_id: &str) -> bool {
768    // Check environment variable first.
769    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    // Check the environment's host config from environment.json.
778    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// ---------------------------------------------------------------------------
792// Provider-pack-into-bundle injection
793// ---------------------------------------------------------------------------
794
795/// Resolved deployment context from `environment.json`.
796#[derive(Debug)]
797struct DeploymentContext {
798    /// Directory containing the serving revision's bundle tree.
799    bundle_dir: PathBuf,
800    /// Billing principal from the `BundleDeployment`.
801    customer_id: Option<String>,
802}
803
804/// The subset of the deployer's `environment.json` that provider-add needs.
805///
806/// Deserialized into named fields rather than poked at with `serde_json::Value`
807/// `.get()` chains: if the deployer renames `bundles` or `bundle_id`, this fails
808/// loudly at parse time instead of silently yielding `None` and reporting a
809/// misleading "no deployment for bundle" further down.
810///
811/// This mirrors the deployer's `Environment` / `BundleDeployment`. Using those
812/// types directly would be better still, but constructing one in a test requires
813/// a trusted operator key that the deployer refuses to auto-generate, which would
814/// make every test here non-hermetic. Unknown fields are ignored on purpose —
815/// `environment.json` carries far more than this.
816#[derive(serde::Deserialize)]
817struct EnvJson {
818    bundles: Vec<EnvJsonBundle>,
819}
820
821#[derive(serde::Deserialize)]
822struct EnvJsonBundle {
823    bundle_id: String,
824    /// `#[serde(default)]` mirrors the deployer's own default on this field.
825    #[serde(default)]
826    current_revisions: Vec<String>,
827    #[serde(default)]
828    customer_id: Option<String>,
829}
830
831/// Resolve the deployment context for `bundle_id` from the store.
832///
833/// Reads `environment.json`, finds the `BundleDeployment` whose `bundle_id`
834/// matches, validates it has exactly one serving revision, and returns the
835/// revision's bundle directory and the deployment's `customer_id`.
836fn 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 = &current_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
888/// Result of checking whether a pack file already exists in a bundle.
889enum PackPresence {
890    /// No pack file at this location.
891    Absent,
892    /// File exists and has the same sha256 digest as the source.
893    MatchingDigest,
894    /// File exists but has a different sha256 digest.
895    DigestMismatch,
896}
897
898/// Compute the sha256 hex digest of a file.
899fn 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
906/// Check whether a pack is already present in a bundle directory, comparing
907/// content digests rather than just filenames. Uses the same placement logic
908/// as [`crate::engine::get_pack_target_dir`].
909fn 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
928/// Prepare a modified bundle directory with the provider pack injected.
929///
930/// Returns `Ok(Some((bundle_dir, tempdir_handle)))` when the pack was
931/// injected into a copy of the deployed bundle. Returns `Ok(None)` when the
932/// pack was already present with the same content digest (idempotent
933/// re-run). The `tempdir_handle` must be kept alive until the caller is
934/// done with the returned path.
935///
936/// When the pack file exists but has a different digest (e.g. a newer
937/// version resolved via `--pack-version` or a rotated `:stable` tag), the
938/// embedded copy is replaced and the bundle is rebuilt.
939fn 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, // `only_used_providers` — keep the deployed tree intact
956    )
957    .context("copy deployed bundle for modification")?;
958
959    // When the pack exists with a different digest, remove the stale copy
960    // before injecting. `execute_add_packs_to_bundle` overwrites anyway,
961    // but removing first keeps the lock file consistent.
962    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
988/// Inject the provider pack into the environment's deployed bundle and
989/// redeploy via the env-apply engine, creating a new revision.
990///
991/// Returns [`PackDeployOutcome::AlreadyPresent`] when the pack is already in
992/// the serving revision's bundle tree with the same content digest.
993///
994/// Resolves the target deployment from the store via `bundle_id` (not
995/// mtime), extracts `customer_id` and the single serving revision, and
996/// errors clearly on traffic splits.
997///
998/// Accepts a `deploy_fn` closure so tests can substitute a lightweight
999/// stub for the real [`crate::env_deploy::deploy_bundle_to_env`] call.
1000fn 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        // Must match the runtime's normalize_pack_segment behavior.
1035        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        // The provider segment (pack name) must preserve hyphens to match
1068        // the runtime's normalize_pack_segment.
1069        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        // No environment.json at all — the early bail says "no bundle".
1105        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            // Lowercase, exactly as `BundleDeploymentStatus`
1159            // (`#[serde(rename_all = "lowercase")]`) writes it to
1160            // environment.json. A capitalised fixture here would pass while the
1161            // real store silently matched nothing.
1162            "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        // Only the Active bundle should be auto-detected.
1174        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    // -- resolve_pack precedence tests (no network) --------------------------
1206
1207    #[test]
1208    fn resolve_pack_explicit_path_wins() {
1209        // Precedence rule: --pack (explicit path) wins over OCI and fallback.
1210        let root = tempfile::tempdir().unwrap();
1211        let store = LocalFsStore::new(root.path());
1212        let info = provider_registry::lookup("telegram").unwrap();
1213
1214        // Create a fake pack file.
1215        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        // Test the offline fallback scan directly (no network dependency).
1238        let root = tempfile::tempdir().unwrap();
1239        let env_dir = root.path().join("local");
1240
1241        // Set up the revision directory structure the fallback expects.
1242        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        // Use a tag that will never exist on the registry so OCI always fails,
1268        // regardless of whether GHCR is reachable.
1269        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        // When the user explicitly passes --pack-version, OCI failure must NOT
1295        // silently fall back to an arbitrary old pack from a deployed revision.
1296        // The function must error so the user knows the requested version could
1297        // not be fetched.
1298        let root = tempfile::tempdir().unwrap();
1299        let store = LocalFsStore::new(root.path());
1300        let info = provider_registry::lookup("telegram").unwrap();
1301
1302        // Plant a pack in a deployed revision so the offline fallback *would*
1303        // find it.
1304        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        // Verify the version override flows into the OCI reference. We cannot
1335        // observe the OCI reference directly from resolve_pack (it's internal),
1336        // so we test via the registry helper.
1337        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    // -- provider pack injection helpers ----------------------------------------
1346
1347    /// Write a minimal .gtpack ZIP archive with a `pack.manifest.json`.
1348    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    // -- resolve_deployment_context tests ----------------------------------------
1370
1371    /// Write an `environment.json` fixture into `env_dir`.
1372    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    // -- check_pack_in_bundle_dir tests -------------------------------------------
1451
1452    #[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        // Copy the exact same file into the bundle.
1476        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        // Write two packs with different content.
1491        let pack_path = root.path().join("messaging-telegram.gtpack");
1492        write_test_pack(&pack_path, "messaging-telegram");
1493        // Write a different file in the bundle location.
1494        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    // -- prepare_bundle_with_provider_pack tests ----------------------------------
1506
1507    #[test]
1508    fn prepare_bundle_injects_provider_pack() {
1509        // This test FAILS without the fix: without execute_add_packs_to_bundle,
1510        // the provider pack would not be present in the output bundle and the
1511        // assertions would fail.
1512        let root = tempfile::tempdir().unwrap();
1513
1514        // Set up a fake environment with a deployed revision.
1515        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        // Create a provider pack to inject.
1520        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        // Act: inject the pack (first arg is bundle dir, not env dir).
1526        let result =
1527            prepare_bundle_with_provider_pack(&bundle_dir, &pack_path, "messaging-telegram")
1528                .unwrap();
1529
1530        // Assert: pack was injected (not already present).
1531        assert!(
1532            result.is_some(),
1533            "pack should be injected (was not already present)"
1534        );
1535        let (output_bundle, _tempdir) = result.unwrap();
1536
1537        // Assert: provider pack file exists in the correct location.
1538        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        // Assert: bundle.yaml has the extension_providers reference.
1549        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        // Assert: bundle.lock.json has the entry with a digest.
1558        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        // Set up a revision with the provider pack already in the bundle.
1582        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        // Act: try to inject the same pack (same content).
1593        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: injection was skipped (pack already present with matching digest).
1601        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        // When a pack exists but has different content (e.g. new version),
1610        // prepare_bundle must inject anyway (not skip).
1611        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        // Plant a stale pack in the bundle.
1617        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        // Create a new pack with different content.
1626        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        // Running inject twice should produce the same bundle.yaml content.
1642        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        // First injection.
1650        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        // Simulate a second run: the pack is now in the source bundle
1659        // (because the first run deployed it). Copy it into the bundle dir to
1660        // simulate what stage_local_bundle would do.
1661        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        // Second injection should detect the pack (same digest) and skip.
1673        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        // Verify the first bundle.yaml is well-formed (only one reference).
1682        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    // -- inject_provider_pack_impl (orchestrator-level) tests -------------------
1692
1693    #[test]
1694    fn inject_provider_pack_calls_deploy_when_pack_absent() {
1695        // This test exercises the thin orchestrator that `register_provider_core`
1696        // delegates to. It FAILS if the deploy function is never called (i.e.
1697        // the integration is removed or short-circuited).
1698        let root = tempfile::tempdir().unwrap();
1699        let store = LocalFsStore::new(root.path());
1700
1701        // Set up env with a deployed revision (no provider pack yet).
1702        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 environment.json with the bundle deployment pointing at rev-001.
1707        write_env_json(
1708            &env_dir,
1709            serde_json::json!([{
1710                "bundle_id": "test-bundle",
1711                "current_revisions": ["rev-001"],
1712            }]),
1713        );
1714
1715        // Create a provider pack to inject.
1716        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        // Track whether the deploy function is called.
1722        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                // The bundle copy must contain the injected provider pack.
1733                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        // Counter-test: when the pack is already in the revision with the same
1762        // digest, the deploy function must NOT be called.
1763        let root = tempfile::tempdir().unwrap();
1764        let store = LocalFsStore::new(root.path());
1765
1766        // Set up env with a revision that already has the provider pack.
1767        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    // -- F1: injection targets bundle_id, not mtime --------------------------------
1808
1809    #[test]
1810    fn inject_targets_bundle_id_not_mtime() {
1811        // THREE bundles, with the target (bundle-a) deliberately in the MIDDLE
1812        // of the `bundles` array and owning the mtime-OLDEST revision. Every
1813        // positional shortcut therefore lands on a decoy:
1814        //   * "newest by mtime"  -> bundle-b
1815        //   * "first in array"   -> bundle-z
1816        //   * "last in array"    -> bundle-b
1817        // Both decoys already carry the pack, so any of those choices reports
1818        // "already present" and skips the deploy, failing the assertions below.
1819        // Only an actual bundle_id match reaches bundle-a, where the pack is
1820        // absent and a deploy is required.
1821        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        // Decoy + target + decoy. Decoys get the pack planted; the target does not.
1829        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); // first in array
1839        plant_pack_in("rev-a", "bundle-a", false); // the target
1840        plant_pack_in("rev-b", "bundle-b", true); // last in array, newest mtime
1841
1842        // Explicit mtimes: rev-b is newest, so the old mtime heuristic picks it.
1843        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); // oldest
1855        stamp("rev-z", 1_500_000);
1856        stamp("rev-b", 2_000_000_000); // newest
1857
1858        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", // target the MIDDLE bundle
1872            &pack_path,
1873            "messaging-telegram",
1874            |bundle_copy, _env_id, _customer_id| {
1875                deploy_called.set(true);
1876                // Prove we rebuilt bundle-a's tree, not a decoy's: a deploy
1877                // firing is not enough, it must be the RIGHT bundle.
1878                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        // Must deploy (pack absent from bundle-a), not skip (pack present in
1891        // bundle-b which would be chosen by mtime).
1892        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    // -- F2: customer_id carried into manifest ------------------------------------
1900
1901    #[test]
1902    fn inject_passes_customer_id_to_deploy_fn() {
1903        // The deploy_fn must receive the customer_id from the deployment so the
1904        // synthesized manifest includes it (required for non-local envs).
1905        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    // -- F2: traffic-split error --------------------------------------------------
1947
1948    #[test]
1949    fn inject_errors_on_traffic_split() {
1950        // When a deployment has multiple serving revisions (active traffic split),
1951        // inject must refuse rather than silently picking one.
1952        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    // -- F4: digest mismatch triggers redeploy ------------------------------------
1990
1991    #[test]
1992    fn inject_redeploys_on_digest_mismatch() {
1993        // When the embedded pack has different content (e.g. new version),
1994        // inject must replace it and redeploy, not skip.
1995        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        // Plant a stale pack with different content.
2003        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        // New pack with different content.
2020        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        // When the embedded pack has the SAME content, inject must skip.
2047        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        // Copy the exact same pack into the bundle.
2058        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    // -- register_provider_core (integration call-site) tests -------------------
2084    //
2085    // The `link_bundle = true` path cannot be exercised hermetically: it calls
2086    // `messaging::link_bundle`, which requires a bundle actually deployed in the
2087    // env, which in turn requires a trusted operator key at
2088    // `~/.greentic/operator/key.pem`. The deployer deliberately refuses to
2089    // auto-generate that key (see its own test at `cli/bundles.rs`), and faking
2090    // it would mean writing to the user's real HOME.
2091    //
2092    // That path is instead guarded at COMPILE time: `register_provider_core_impl`
2093    // takes the deploy step as a `deploy_fn` parameter, so short-circuiting the
2094    // pack-deploy call leaves `deploy_fn` unused and
2095    // `clippy --all-targets --all-features -- -D warnings` (run by
2096    // `ci/local_check.sh` and PR CI) fails the build. Verified by mutation:
2097    // removing the call site yields `error: unused variable: deploy_fn` plus four
2098    // now-dead helper functions. The deploy behaviour itself is covered by the
2099    // `inject_provider_pack_impl` tests above.
2100
2101    #[test]
2102    fn register_provider_core_skips_deploy_when_not_linking_bundle() {
2103        // With link_bundle = false there is no env bundle to inject into, so the
2104        // deploy must not fire and the outcome must be AlreadyPresent.
2105        let root = tempfile::tempdir().unwrap();
2106        let store = LocalFsStore::new(root.path());
2107
2108        // Endpoint registration needs a registered `local` env, not just a
2109        // directory on disk. Use production's own bootstrap.
2110        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    // -- pack-driven helpers --------------------------------------------------
2139
2140    /// Build a minimal `.gtpack` (zip) containing `assets/setup.yaml`.
2141    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(&not_a_pack, b"not a zip archive").unwrap();
2183        assert!(!has_oauth_device_code_action(&not_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        // The canonical form (hyphens -> underscores) is inserted alongside.
2197        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(&not_a_pack, b"not a zip archive").unwrap();
2206        assert!(collect_secret_keys_from_pack(&not_a_pack).is_empty());
2207    }
2208
2209    // -- deterministic_idempotency_key ----------------------------------------
2210
2211    #[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        // 16 digest bytes -> 32 hex chars.
2218        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    // -- has_resolvable_public_url ---------------------------------------------
2234
2235    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        // The PUBLIC_BASE_URL env var short-circuits everything; skip when the
2262        // outer environment (a developer shell) has it set — mirrors the exact
2263        // non-empty filter the production code applies.
2264        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        // No environment.json at all.
2270        assert!(!has_resolvable_public_url(&store, "local"));
2271        // environment.json without host_config.
2272        write_host_env_json(root.path(), "local", &serde_json::json!({"bundles": []}));
2273        assert!(!has_resolvable_public_url(&store, "local"));
2274        // host_config present but the URL is empty.
2275        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    // -- register_provider_core ------------------------------------------------
2284
2285    #[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        // Empty and non-string secret values must be skipped, not written.
2305        answers.insert("webhook_secret".into(), serde_json::json!(""));
2306        answers.insert("numeric_secret".into(), serde_json::json!(42));
2307        // Non-secret answers never become secret entries.
2308        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        // Same-key same-identity re-run replays as a no-op on the same endpoint.
2333        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        // The endpoint is visible through the deployer's list verb and carries
2339        // exactly one secret ref — the non-empty string secret.
2340        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}