Skip to main content

greentic_deployer/cli/
deploy.rs

1//! `gtc op deploy` — the one-shot bundle deployment orchestrator.
2//!
3//! The default, "just works" path: add the bundle deployment (when new),
4//! stage a revision from the local `.gtbundle`, warm it, and route 100 % of
5//! traffic to it. It reuses the four single-purpose verbs — `bundles add`,
6//! `revisions stage`, `revisions warm`, `traffic set` — so all of the
7//! audit / signing / revenue-policy logic stays single-sourced; this module
8//! only threads the minted ids between them and fills in sensible defaults.
9//!
10//! A real local `.gtbundle` is required on every path — `deploy` refuses to
11//! publish an artifact-less revision (placeholder digests + an empty pack-list
12//! lock would be admissible by traffic yet broken at boot).
13//!
14//! Re-deploying a bundle that is already deployed in the env stages a NEW
15//! revision and shifts 100 % traffic onto it (blue-green): because
16//! `traffic set` replaces the whole split, the previously-live revision
17//! leaves the routing table and drains at runtime. The superseded revision
18//! is retained (not archived) so `gtc op traffic rollback` still works.
19//!
20//! Each invocation is its own rollout: without a caller-supplied
21//! `--idempotency-key`, the cut-over key is derived from the freshly-minted
22//! revision, so a re-run stages another revision rather than deduplicating.
23//! Supply a stable `--idempotency-key` to make retries idempotent — a repeat
24//! with a key that already routed returns the existing outcome without minting
25//! a new revision or disturbing the rollback target.
26//!
27//! The deployer records desired state only; it carries no health-check
28//! producers (B9), so `warm` runs a no-op gate and the result reports
29//! `routed`, not a runtime liveness claim. greentic-start's watcher reloads
30//! and begins serving once the split is written.
31//!
32//! Prerequisites are required, never auto-created: the env must already exist
33//! (`gtc op env init`) and its trust root must carry the operator key
34//! (`gtc op trust-root bootstrap`). The deploy path never seeds signing keys
35//! — that would grant signing rights as a side effect of a deploy (C2).
36//!
37//! The four verbs remain the advanced / fine-tune surface, untouched.
38
39use std::collections::BTreeMap;
40use std::path::PathBuf;
41
42use greentic_deploy_spec::{
43    BundleDeployment, CustomerId, DeploymentId, EnvId, Environment, RouteBinding,
44};
45
46/// Per-pack config overrides: `<pack_id> -> <key> -> <json value>`.
47type ConfigOverridesMap = BTreeMap<String, BTreeMap<String, Value>>;
48use serde::{Deserialize, Serialize};
49use serde_json::{Value, json};
50
51use crate::environment::{EnvironmentStore, LocalFsStore};
52
53use super::bundles::{
54    BundleAddPayload, BundleSummary, BundleUpdatePayload, RevenueShareEntryPayload,
55    RouteBindingPayload,
56};
57use super::revisions::{RevisionStagePayload, RevisionSummary, RevisionTransitionPayload};
58use super::traffic::{TrafficSetEntryPayload, TrafficSetPayload};
59use super::{OpError, OpFlags, OpOutcome};
60
61const NOUN: &str = "deploy";
62const VERB: &str = "run";
63
64/// 100 % of traffic, in basis points.
65pub(crate) const FULL_TRAFFIC_BPS: u32 = 10_000;
66
67/// Caller-pinned artifact pointers for a remote (`--store-url`) deploy.
68///
69/// The local `op deploy --bundle <file>` path extracts the artifact and
70/// derives `bundle_digest` / `pack_list` / `pack_list_lock_ref` from it, but a
71/// remote store keeps no bundle bytes — so a remote deploy requires the bundle
72/// already pushed to a registry and its pointers supplied here (exactly the
73/// pins remote `revisions stage` demands). Optional fields default to the same
74/// canonical stage defaults; `bundle_digest` is the only one validated as
75/// non-placeholder by the remote path (a remote worker must be able to verify
76/// what it pulls). Ignored entirely on the local path.
77#[derive(Debug, Clone, Default, Serialize, Deserialize)]
78pub struct RemoteBundlePins {
79    /// Real (non-placeholder) content digest the worker verifies after pull.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub bundle_digest: Option<String>,
82    /// Pinned pack-list the worker materializes the revision from.
83    #[serde(default)]
84    pub pack_list: Vec<super::revisions::PackListEntryPayload>,
85    /// Env-relative pack-list lockfile pointer.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub pack_list_lock_ref: Option<PathBuf>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub config_digest: Option<String>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub signature_sidecar_ref: Option<PathBuf>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub drain_seconds: Option<u32>,
94}
95
96/// Input to [`deploy`]. Everything but `bundle_id` and `bundle_path` has a
97/// sensible default; the CLI requires `--bundle` and derives `bundle_id` from
98/// its filename stem.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct BundleDeployPayload {
101    #[serde(default = "default_environment_id")]
102    pub environment_id: String,
103    pub bundle_id: String,
104    /// Billing principal (P6). Defaults to `local-dev` on the `local` env;
105    /// required for every other env. Forwarded verbatim to `bundles add`.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub customer_id: Option<String>,
108    /// Local `.gtbundle` to stage. Required (on every path): `deploy` refuses
109    /// to publish an artifact-less revision. Optional in the struct only so an
110    /// `--answers` payload that omits it fails with a clear error rather than a
111    /// deserialization error.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub bundle_path: Option<PathBuf>,
114    /// Registry reference the bundle was resolved from, recorded on the staged
115    /// revision so a remote worker can pull it at boot. `None` (default) keeps
116    /// the revision local-serve only. `op deploy` still stages from the local
117    /// `--bundle` file; this only records where the worker can re-fetch it.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub bundle_source_uri: Option<String>,
120    /// Caller-pinned artifact pointers, required only on the remote
121    /// (`--store-url`) path (the local `--bundle` path derives them from the
122    /// artifact and ignores this). See [`RemoteBundlePins`].
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub remote_pins: Option<RemoteBundlePins>,
125    /// Idempotency key for the traffic cut-over. Defaults to a value derived
126    /// from the freshly-minted revision id, so each deploy is a distinct
127    /// (non-replay) cut-over.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub idempotency_key: Option<String>,
130    /// D.4: per-pack provider config overrides applied at egress time
131    /// (`<pack_id> -> <key> -> <json value>`). Forwarded into the new
132    /// `BundleAddPayload.config_overrides` on a fresh deploy, or applied via
133    /// `bundles update` to the existing deployment on a re-deploy (blue-green
134    /// version bump).
135    ///
136    /// Three-valued semantics (Codex finding 3):
137    /// - `None` (default, no CLI input) = leave existing overrides untouched
138    /// - `Some(empty)` = explicit clear (e.g. `--config-overrides-from` with `{}`)
139    /// - `Some(non-empty)` = replace
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub config_overrides: Option<BTreeMap<String, BTreeMap<String, Value>>>,
142    /// Route binding (hosts, path prefixes, tenant selector). Set at deploy
143    /// time so a fresh add doesn't need a follow-up `bundles update`.
144    ///
145    /// `None` (the default) means: on fresh add, use the default empty
146    /// binding; on re-deploy, leave the existing binding untouched.
147    /// `Some(...)` replaces the whole binding (same all-or-nothing shape as
148    /// `bundles update --route-binding`).
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub route_binding: Option<RouteBindingPayload>,
151    /// Revenue-share split applied on a FRESH deploy (forwarded to
152    /// `bundles add`). `None` = the `greentic@10000` default. Ignored on a
153    /// re-deploy (a blue-green version bump leaves the existing split
154    /// untouched — change it via `bundles update`).
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub revenue_share: Option<Vec<RevenueShareEntryPayload>>,
157}
158
159fn default_environment_id() -> String {
160    crate::defaults::LOCAL_ENV_ID.to_string()
161}
162
163/// Combined summary of an orchestrated deploy.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct DeploySummary {
166    pub environment_id: String,
167    pub bundle_id: String,
168    pub deployment_id: String,
169    pub revision_id: String,
170    /// `true` when the bundle was already deployed and this call reused the
171    /// existing deployment (a blue-green version bump).
172    pub reused_deployment: bool,
173    /// Revisions that were live before this deploy and have now left the
174    /// routing table (they drain at runtime; retained for rollback).
175    pub superseded_revisions: Vec<String>,
176    pub traffic: String,
177    pub status: String,
178}
179
180impl DeploySummary {
181    /// A routed deploy: the 100 %-traffic split is written. `status` is
182    /// `routed` (desired state), not a runtime liveness claim — see module docs.
183    fn routed(
184        env_id: &EnvId,
185        bundle_id: String,
186        deployment_id: String,
187        revision_id: String,
188        reused_deployment: bool,
189        superseded_revisions: Vec<String>,
190    ) -> Self {
191        Self {
192            environment_id: env_id.as_str().to_string(),
193            bundle_id,
194            deployment_id,
195            revision_id,
196            reused_deployment,
197            superseded_revisions,
198            traffic: format!("100% ({FULL_TRAFFIC_BPS} bps)"),
199            status: "routed".to_string(),
200        }
201    }
202
203    fn into_outcome(self) -> OpOutcome {
204        OpOutcome::new(
205            NOUN,
206            VERB,
207            serde_json::to_value(self).expect("DeploySummary is json-safe"),
208        )
209    }
210
211    /// Build the routed-deploy outcome directly. Used by the remote
212    /// (`--store-url`) path, which composes the typed store verbs and so never
213    /// holds a `DeploySummary` value to call [`Self::into_outcome`] on.
214    pub(crate) fn routed_outcome(
215        env_id: &EnvId,
216        bundle_id: String,
217        deployment_id: String,
218        revision_id: String,
219        reused_deployment: bool,
220        superseded_revisions: Vec<String>,
221    ) -> OpOutcome {
222        Self::routed(
223            env_id,
224            bundle_id,
225            deployment_id,
226            revision_id,
227            reused_deployment,
228            superseded_revisions,
229        )
230        .into_outcome()
231    }
232}
233
234// --- pure rollout decisions ---------------------------------------------------
235//
236// Shared by the local `deploy` (above) and the remote `--store-url`
237// `remote_deploy`. They operate on an already-loaded `Environment` + the deploy
238// payload so the blue-green rollout *decisions* (idempotent replay, deployment
239// reuse, route immutability, which revisions get superseded) are single-sourced;
240// only the *execution* — local CLI verbs vs. typed HTTP-store calls — differs.
241
242/// The deployment for `(bundle_id, customer_id)` in `env`, if already deployed.
243pub(crate) fn find_existing_deployment<'a>(
244    env: &'a Environment,
245    bundle_id: &str,
246    customer_id: &CustomerId,
247) -> Option<&'a BundleDeployment> {
248    env.bundles
249        .iter()
250        .find(|b| b.bundle_id.as_str() == bundle_id && b.customer_id == *customer_id)
251}
252
253/// Operation-level idempotency: when the caller supplied a key and `existing`
254/// already has a traffic split under it, the deploy already ran — return its
255/// `(deployment_id, revision_id)` so the caller can echo the original outcome
256/// without minting a duplicate revision or moving the rollback target. `None`
257/// ⇒ proceed with a fresh rollout.
258pub(crate) fn idempotent_deploy_replay(
259    env: &Environment,
260    existing: Option<&BundleDeployment>,
261    idempotency_key: Option<&str>,
262) -> Option<(String, String)> {
263    let key = idempotency_key?;
264    let b = existing?;
265    let split = env
266        .traffic_splits
267        .iter()
268        .find(|s| s.deployment_id == b.deployment_id && s.idempotency_key == key)?;
269    let revision_id = split
270        .entries
271        .first()
272        .map(|e| e.revision_id.to_string())
273        .unwrap_or_default();
274    Some((b.deployment_id.to_string(), revision_id))
275}
276
277/// On re-deploy with a `route_binding`, reject a binding that differs from the
278/// deployment's existing one: routing is bundle-level metadata that `traffic
279/// rollback` does NOT restore, so a silent change here would leave the prior
280/// revision mis-routed after a rollback. Equal ⇒ no-op; `None` ⇒ no change.
281pub(crate) fn ensure_route_binding_unchanged(
282    existing: Option<&BundleDeployment>,
283    requested: Option<&RouteBindingPayload>,
284) -> Result<(), OpError> {
285    if let (Some(b), Some(rb_payload)) = (existing, requested) {
286        let want: RouteBinding = super::bundles::into_route_binding(rb_payload.clone());
287        if want != b.route_binding {
288            return Err(OpError::Conflict(format!(
289                "deploy: route_binding differs from the deployed binding for \
290                 `{}` — routing is bundle-level metadata and is not restored by \
291                 `traffic rollback`. Run `gtc op bundles update --answers ...` to \
292                 change routing on an existing deployment, then re-deploy",
293                b.bundle_id.as_str()
294            )));
295        }
296    }
297    Ok(())
298}
299
300/// Revisions live in `deployment_id`'s current split — they leave the routing
301/// table on the new 100 % cut-over (blue-green) and drain at runtime. Retained
302/// (not archived) so `traffic rollback` still works.
303pub(crate) fn superseded_revisions(env: &Environment, deployment_id: DeploymentId) -> Vec<String> {
304    env.traffic_splits
305        .iter()
306        .find(|s| s.deployment_id == deployment_id)
307        .map(|s| {
308            s.entries
309                .iter()
310                .map(|e| e.revision_id.to_string())
311                .collect()
312        })
313        .unwrap_or_default()
314}
315
316/// Orchestrate add → stage → warm → traffic-set with defaults.
317pub fn deploy(
318    store: &LocalFsStore,
319    flags: &OpFlags,
320    payload: Option<BundleDeployPayload>,
321) -> Result<OpOutcome, OpError> {
322    if flags.schema_only {
323        return Ok(OpOutcome::new(NOUN, VERB, deploy_schema()));
324    }
325    let payload = resolve_payload(flags, payload)?;
326    let env_id = parse_env_id(&payload.environment_id)?;
327    let bundle_id = payload.bundle_id.trim().to_string();
328    if bundle_id.is_empty() {
329        return Err(OpError::InvalidArgument(
330            "bundle_id must not be empty".to_string(),
331        ));
332    }
333    // Payload-level routing validation. Catches the `--answers` JSON path
334    // (the CLI flag path also pre-validates via `payload_from_deploy_args`).
335    if let Some(rb) = payload.route_binding.as_ref() {
336        rb.validate()?;
337    }
338
339    // `op deploy` always stages from a real `.gtbundle`: an artifact-less
340    // stage would record placeholder digests and an empty pack-list lock —
341    // warmable by the no-op gate, admissible by traffic, and broken at boot.
342    // Reject it here, before any mutation, so a bad call never creates a
343    // deployment it then can't fill. (The legacy verbatim stage path stays
344    // reachable only through the explicit `revisions stage --answers` verb.)
345    let bundle_path = payload.bundle_path.clone().ok_or_else(|| {
346        OpError::InvalidArgument(
347            "deploy requires a local `.gtbundle`: pass `--bundle <PATH>`".to_string(),
348        )
349    })?;
350    if !bundle_path.is_file() {
351        return Err(OpError::InvalidArgument(format!(
352            "bundle `{}` is not a file",
353            bundle_path.display()
354        )));
355    }
356
357    // Preflight: the env must already exist. We never auto-create it, because
358    // `env init` is the only path that legitimately seeds the trust root (C2),
359    // and a deploy must not grant signing rights as a side effect.
360    if !store.exists(&env_id)? {
361        return Err(OpError::NotFound(format!(
362            "environment `{env_id}` not found — run `gtc op env init` \
363             (then `gtc op trust-root bootstrap {env_id}`) before deploying"
364        )));
365    }
366
367    // Resolve the billing principal the same way `bundles add` does so the
368    // reuse scan keys on the real (env_id, bundle_id, customer_id) anchor.
369    let customer_id = super::bundles::resolve_customer_id(&env_id, payload.customer_id.clone())?;
370
371    // Operation-level idempotency: if the caller supplied a key and the bundle
372    // already has a traffic split under that key, this deploy already ran —
373    // return the existing outcome without minting a duplicate revision or
374    // moving the rollback target. (`traffic set` alone keys only the cut-over,
375    // so a keyed retry would otherwise stage a fresh revision and then conflict
376    // at the split, orphaning a Ready revision.)
377    let env = store.load(&env_id)?;
378    let existing = find_existing_deployment(&env, &bundle_id, &customer_id);
379
380    // Operation-level idempotency: a keyed deploy whose split already exists
381    // returns the prior outcome without minting a duplicate revision or moving
382    // the rollback target. (`traffic set` alone keys only the cut-over, so a
383    // keyed retry would otherwise stage a fresh revision and then conflict at
384    // the split, orphaning a Ready revision.)
385    if let Some((deployment_id, revision_id)) =
386        idempotent_deploy_replay(&env, existing, payload.idempotency_key.as_deref())
387    {
388        return Ok(DeploySummary::routed_outcome(
389            &env_id,
390            bundle_id,
391            deployment_id,
392            revision_id,
393            true,
394            Vec::new(),
395        ));
396    }
397
398    // On re-deploy with a route_binding, reject a binding that differs from the
399    // deployment's existing one BEFORE staging a revision (routing is not
400    // restored by `traffic rollback`).
401    ensure_route_binding_unchanged(existing, payload.route_binding.as_ref())?;
402
403    let (deployment_id, reused, superseded_revisions) = match existing {
404        Some(b) => {
405            let dep = b.deployment_id;
406            (dep.to_string(), true, superseded_revisions(&env, dep))
407        }
408        None => {
409            let add_payload = BundleAddPayload {
410                environment_id: payload.environment_id.clone(),
411                bundle_id: bundle_id.clone(),
412                customer_id: payload.customer_id.clone(),
413                route_binding: payload.route_binding.clone().unwrap_or_default(),
414                revenue_share: payload
415                    .revenue_share
416                    .clone()
417                    .unwrap_or_else(super::bundles::default_revenue_share),
418                authorization_ref: super::bundles::default_authorization_ref(),
419                // Fresh deploy: BundleAddPayload takes a plain BTreeMap
420                // (no prior state to clear). Unwrap the Option; None → empty.
421                config_overrides: payload.config_overrides.clone().unwrap_or_default(),
422                idempotency_key: None,
423            };
424            let outcome = super::bundles::add(store, flags, Some(add_payload))?;
425            let summary: BundleSummary = parse_summary(outcome, "bundle")?;
426            (summary.deployment_id, false, Vec::new())
427        }
428    };
429    // Drop the borrow on `env` before the mutating steps below.
430    drop(env);
431
432    // Stage a fresh revision from the bundle. With `bundle_path` set, stage
433    // derives the real bundle_digest / pack_list / lock ref from the artifact;
434    // config_digest / signature_sidecar_ref / drain_seconds are still recorded
435    // verbatim, so use the same canonical defaults the `stage --answers` path
436    // applies rather than re-spelling the literals here.
437    let stage_payload = RevisionStagePayload {
438        environment_id: payload.environment_id.clone(),
439        deployment_id: deployment_id.clone(),
440        // Local `--bundle` stage: the store mints the id + key.
441        revision_id: None,
442        idempotency_key: None,
443        bundle_path: Some(bundle_path),
444        bundle_digest: super::revisions::default_bundle_digest(),
445        bundle_source_uri: payload.bundle_source_uri.clone(),
446        pack_list: Vec::new(),
447        pack_list_lock_ref: PathBuf::new(),
448        config_digest: super::revisions::default_config_digest(),
449        signature_sidecar_ref: super::revisions::default_signature_sidecar_ref(),
450        drain_seconds: super::revisions::default_drain_seconds(),
451    };
452    let stage_outcome = super::revisions::stage(store, flags, Some(stage_payload))?;
453    let staged: RevisionSummary = parse_summary(stage_outcome, "revision")?;
454    let revision_id = staged.revision_id;
455
456    // Warm it to Ready.
457    super::revisions::warm(
458        store,
459        flags,
460        Some(RevisionTransitionPayload {
461            environment_id: payload.environment_id.clone(),
462            revision_id: revision_id.clone(),
463            idempotency_key: None,
464        }),
465    )?;
466
467    // On re-deploy of an existing bundle, replace the deployment's
468    // config_overrides AFTER stage+warm succeed but BEFORE the traffic
469    // cut-over. This ordering ensures that if stage or warm fails (corrupt
470    // bundle, unpack error, health gate rejection), the override map is
471    // NOT replaced — the old deployment keeps its prior overrides intact
472    // (Codex finding 2: override replacement outside the rollout
473    // transaction).
474    //
475    // Note: bundle-level overrides mean rollback (`traffic rollback`)
476    // restores the traffic split only, not the prior override map. This is
477    // an accepted limitation of the bundle-level altitude; revision-scoped
478    // overrides are an explicit non-goal (architectural decision).
479    if reused && let Some(ref overrides) = payload.config_overrides {
480        super::bundles::update(
481            store,
482            flags,
483            Some(BundleUpdatePayload {
484                environment_id: payload.environment_id.clone(),
485                deployment_id: deployment_id.clone(),
486                status: None,
487                route_binding: None,
488                revenue_share: None,
489                config_overrides: Some(overrides.clone()),
490                idempotency_key: None,
491            }),
492        )?;
493    }
494
495    // Route 100 % of traffic to the new revision. `traffic set` is a full
496    // replacement, so any previously-live revision drops out of the split
497    // (blue-green). Without a caller-supplied key, each deploy is its own
498    // rollout: the revision-derived key guarantees a distinct cut-over. Supply
499    // `--idempotency-key` to make retries idempotent (handled above).
500    let idempotency_key = payload
501        .idempotency_key
502        .clone()
503        .unwrap_or_else(|| format!("deploy:{deployment_id}:{revision_id}"));
504    super::traffic::set(
505        store,
506        flags,
507        Some(TrafficSetPayload {
508            environment_id: payload.environment_id.clone(),
509            deployment_id: deployment_id.clone(),
510            entries: vec![TrafficSetEntryPayload {
511                revision_id: revision_id.clone(),
512                weight_bps: Some(FULL_TRAFFIC_BPS),
513                weight_percent: None,
514            }],
515            updated_by: super::traffic::default_updated_by(),
516            idempotency_key,
517            authorization_ref: super::traffic::default_authorization_ref(),
518        }),
519    )?;
520
521    // `status` is "routed" (desired-state split written), not a runtime
522    // liveness claim — the deployer has no health-check producers (B9); see
523    // `DeploySummary::routed` and the module docs.
524    Ok(DeploySummary::routed(
525        &env_id,
526        bundle_id,
527        deployment_id,
528        revision_id,
529        reused,
530        superseded_revisions,
531    )
532    .into_outcome())
533}
534
535/// Build a [`BundleDeployPayload`] from direct CLI args, or `None` when no
536/// args were supplied (deferring to `--answers` / `--schema`). Mirrors
537/// `revisions::payload_from_stage_args`: all clap fields are optional so the
538/// answers / schema paths keep working unchanged.
539pub fn payload_from_deploy_args(
540    args: super::dispatch::BundleDeployArgs,
541) -> Result<Option<BundleDeployPayload>, OpError> {
542    let super::dispatch::BundleDeployArgs {
543        bundle,
544        env,
545        bundle_id,
546        customer_id,
547        idempotency_key,
548        config_override,
549        config_override_json,
550        config_overrides_from,
551        path_prefix,
552        host,
553        tenant,
554        team,
555    } = args;
556    if bundle.is_none()
557        && env.is_none()
558        && bundle_id.is_none()
559        && customer_id.is_none()
560        && idempotency_key.is_none()
561        && config_override.is_empty()
562        && config_override_json.is_empty()
563        && config_overrides_from.is_none()
564        && path_prefix.is_empty()
565        && host.is_empty()
566        && tenant.is_none()
567        && team.is_none()
568    {
569        return Ok(None);
570    }
571    if team.is_some() && tenant.is_none() {
572        return Err(OpError::InvalidArgument(
573            "deploy: --team requires --tenant".to_string(),
574        ));
575    }
576    let bundle_path = bundle.ok_or_else(|| {
577        OpError::InvalidArgument(
578            "deploy: missing `--bundle <PATH>` (the local .gtbundle to deploy)".to_string(),
579        )
580    })?;
581    let bundle_id = match bundle_id {
582        Some(id) => id,
583        None => bundle_path
584            .file_stem()
585            .and_then(|s| s.to_str())
586            .map(|s| s.to_string())
587            .ok_or_else(|| {
588                OpError::InvalidArgument(format!(
589                    "deploy: cannot derive bundle_id from `{}` — pass `--bundle-id <ID>`",
590                    bundle_path.display()
591                ))
592            })?,
593    };
594    let config_overrides = parse_config_overrides_cli(
595        &config_override,
596        &config_override_json,
597        config_overrides_from,
598    )?;
599    let route_binding = route_binding_from_cli(host, path_prefix, tenant, team)?;
600    Ok(Some(BundleDeployPayload {
601        environment_id: env.unwrap_or_else(default_environment_id),
602        bundle_id,
603        customer_id,
604        bundle_path: Some(bundle_path),
605        bundle_source_uri: None,
606        remote_pins: None,
607        idempotency_key,
608        config_overrides,
609        route_binding,
610        // `op deploy` CLI has no revenue-share flag; defaults stay in
611        // `bundles add`. The env-manifest apply path sets this directly.
612        revenue_share: None,
613    }))
614}
615
616/// Build a `Some(RouteBindingPayload)` when ANY routing flag was supplied,
617/// `None` otherwise (caller leaves existing binding alone on re-deploy).
618///
619/// `team` defaults to `default` when `--tenant` is supplied without `--team`
620/// (matches the bundles-update payload shape the demo emits by hand). The
621/// reverse — `--team` without `--tenant` — is rejected in the caller before
622/// we get here. Calls `RouteBindingPayload::validate()` so the same
623/// unreachable-binding check covers both flag-built and `--answers` JSON
624/// payloads.
625fn route_binding_from_cli(
626    hosts: Vec<String>,
627    path_prefixes: Vec<String>,
628    tenant: Option<String>,
629    team: Option<String>,
630) -> Result<Option<RouteBindingPayload>, OpError> {
631    if hosts.is_empty() && path_prefixes.is_empty() && tenant.is_none() {
632        return Ok(None);
633    }
634    let tenant_selector = tenant.map(|t| super::bundles::TenantSelectorPayload {
635        tenant: t,
636        team: team.unwrap_or_else(|| "default".to_string()),
637    });
638    let payload = RouteBindingPayload {
639        hosts,
640        path_prefixes,
641        tenant_selector,
642    };
643    payload.validate()?;
644    Ok(Some(payload))
645}
646
647/// Parse `--config-override` / `--config-override-json` / `--config-overrides-from`
648/// CLI args into the `Option<BTreeMap<pack_id, BTreeMap<key, json>>>` shape
649/// that `BundleDeployPayload.config_overrides` expects.
650///
651/// Returns `None` when no override input was supplied at all (leave existing
652/// alone). Returns `Some(empty)` when the caller explicitly passed `{}`
653/// (clear existing overrides — Codex finding 3).
654///
655/// **String flag** (repeating): `--config-override <pack_id>:<key>=<value>`.
656/// The value is ALWAYS stored as `Value::String` — no JSON coercion
657/// (Codex finding 4). Use `--config-override-json` for typed values.
658///
659/// **JSON flag** (repeating): `--config-override-json <pack_id>:<key>=<json>`.
660/// The value is parsed as JSON; a parse error is an `InvalidArgument`.
661///
662/// **File form**: `--config-overrides-from <FILE>` — the file is read as a
663/// JSON object matching the on-the-wire `config_overrides` shape. Repeating
664/// flag entries are merged ON TOP of the file (per-pack, per-key): the flag
665/// wins on conflict, so a `--config-overrides-from base.json` plus a
666/// `--config-override messaging-telegram:api_base_url=https://staging` lets
667/// staging override the file's default.
668fn parse_config_overrides_cli(
669    string_specs: &[String],
670    json_specs: &[String],
671    from_file: Option<PathBuf>,
672) -> Result<Option<ConfigOverridesMap>, OpError> {
673    // No input at all → None (leave existing untouched).
674    if string_specs.is_empty() && json_specs.is_empty() && from_file.is_none() {
675        return Ok(None);
676    }
677    let mut overrides: BTreeMap<String, BTreeMap<String, Value>> = BTreeMap::new();
678    if let Some(path) = from_file {
679        let bytes = std::fs::read(&path).map_err(|e| {
680            OpError::InvalidArgument(format!(
681                "deploy: cannot read --config-overrides-from `{}`: {e}",
682                path.display()
683            ))
684        })?;
685        let parsed: BTreeMap<String, BTreeMap<String, Value>> = serde_json::from_slice(&bytes)
686            .map_err(|e| {
687                OpError::InvalidArgument(format!(
688                    "deploy: --config-overrides-from `{}` is not a valid \
689                     `{{<pack_id>: {{<key>: <value>}}}}` JSON object: {e}",
690                    path.display()
691                ))
692            })?;
693        overrides = parsed;
694    }
695    for spec in string_specs {
696        let (pack_id, key, value_raw) = split_override_spec(spec, "--config-override")?;
697        overrides
698            .entry(pack_id)
699            .or_default()
700            .insert(key, Value::String(value_raw.to_string()));
701    }
702    for spec in json_specs {
703        let (pack_id, key, value) = parse_one_config_override_json(spec)?;
704        overrides.entry(pack_id).or_default().insert(key, value);
705    }
706    Ok(Some(overrides))
707}
708
709/// Parse one `<pack_id>:<key>=<json>` spec where the value is parsed as
710/// typed JSON. A parse error is `InvalidArgument`.
711fn parse_one_config_override_json(spec: &str) -> Result<(String, String, Value), OpError> {
712    let (pack_id, key, value_raw) = split_override_spec(spec, "--config-override-json")?;
713    let value = serde_json::from_str::<Value>(value_raw).map_err(|e| {
714        OpError::InvalidArgument(format!(
715            "deploy: --config-override-json `{spec}` has invalid JSON value: {e}"
716        ))
717    })?;
718    Ok((pack_id, key, value))
719}
720
721/// Shared `<pack_id>:<key>=<value>` splitting logic for both flag variants.
722fn split_override_spec<'a>(
723    spec: &'a str,
724    flag_name: &str,
725) -> Result<(String, String, &'a str), OpError> {
726    let (pack_id, rest) = spec.split_once(':').ok_or_else(|| {
727        OpError::InvalidArgument(format!(
728            "deploy: {flag_name} `{spec}` is malformed — expected `<pack_id>:<key>=<value>`"
729        ))
730    })?;
731    let (key, value_raw) = rest.split_once('=').ok_or_else(|| {
732        OpError::InvalidArgument(format!(
733            "deploy: {flag_name} `{spec}` is malformed — expected `<pack_id>:<key>=<value>`"
734        ))
735    })?;
736    if pack_id.is_empty() || key.is_empty() {
737        return Err(OpError::InvalidArgument(format!(
738            "deploy: {flag_name} `{spec}` has an empty pack_id or key"
739        )));
740    }
741    Ok((pack_id.to_string(), key.to_string(), value_raw))
742}
743
744/// Deserialize an [`OpOutcome`]'s `result` into a step summary, mapping any
745/// failure to an internal-error `OpError` (the sub-verbs are typed, so this
746/// should never fire in practice).
747fn parse_summary<T: serde::de::DeserializeOwned>(
748    outcome: OpOutcome,
749    what: &str,
750) -> Result<T, OpError> {
751    serde_json::from_value(outcome.result).map_err(|e| {
752        OpError::InvalidArgument(format!("internal: failed to parse {what} summary: {e}"))
753    })
754}
755
756fn resolve_payload<T: serde::de::DeserializeOwned>(
757    flags: &OpFlags,
758    payload: Option<T>,
759) -> Result<T, OpError> {
760    if let Some(p) = payload {
761        return Ok(p);
762    }
763    if let Some(path) = &flags.answers {
764        return super::load_answers::<T>(path);
765    }
766    Err(OpError::InvalidArgument(
767        "no payload provided: pass --bundle <path>, --answers <path>, or supply the payload directly"
768            .to_string(),
769    ))
770}
771
772fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
773    EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
774}
775
776fn deploy_schema() -> Value {
777    json!({
778        "$schema": "https://json-schema.org/draft/2020-12/schema",
779        "title": "BundleDeployPayload",
780        "type": "object",
781        "required": ["bundle_id"],
782        "additionalProperties": false,
783        "properties": {
784            "environment_id": {"type": "string", "default": "local"},
785            "bundle_id": {"type": "string"},
786            "customer_id": {"type": "string"},
787            "bundle_path": {"type": "string", "description": "local .gtbundle path. REQUIRED for a local deploy; OMIT for a remote --store-url deploy (the artifact can't be extracted server-side)"},
788            "bundle_source_uri": {"type": "string", "description": "oci:// / repo:// / store:// ref the bundle was resolved from; makes the staged revision pullable by a remote worker. REQUIRED for a remote --store-url deploy; omit for local-serve-only"},
789            "remote_pins": {
790                "type": "object",
791                "description": "caller-pinned artifact pointers for a remote (--store-url) deploy (the bundle is pre-pushed to a registry). Ignored on the local path.",
792                "additionalProperties": false,
793                "properties": {
794                    "bundle_digest": {"type": "string", "description": "real (non-placeholder) content digest the worker verifies after pull. REQUIRED for a remote deploy"},
795                    "pack_list": {"type": "array", "items": {"type": "object"}},
796                    "pack_list_lock_ref": {"type": "string"},
797                    "config_digest": {"type": "string"},
798                    "signature_sidecar_ref": {"type": "string"},
799                    "drain_seconds": {"type": "integer"}
800                }
801            },
802            "idempotency_key": {"type": "string", "description": "supply to make retries idempotent; on a remote --store-url deploy it also derives a stable revision id + per-step keys so a lost-response retry replays instead of duplicating the revision"},
803            "config_overrides": {
804                "type": "object",
805                "description": "D.4: per-pack provider config overrides keyed by pack_id (object of {key: json-value})",
806                "additionalProperties": {"type": "object"}
807            },
808            "route_binding": {
809                "type": "object",
810                "description": "Set hosts / path_prefixes / tenant_selector at deploy time. Omit to keep the existing binding (or default empty on fresh add).",
811                "properties": {
812                    "hosts": {"type": "array", "items": {"type": "string"}},
813                    "path_prefixes": {"type": "array", "items": {"type": "string"}},
814                    "tenant_selector": {
815                        "type": "object",
816                        "properties": {
817                            "tenant": {"type": "string"},
818                            "team": {"type": "string"}
819                        }
820                    }
821                }
822            }
823        }
824    })
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830    use crate::cli::tests_common::{bootstrap_env_trust_root, make_env};
831    use tempfile::tempdir;
832
833    /// Schema-drift regression: `deploy_schema()` declares
834    /// `additionalProperties: false`, so a `--schema`-driven `--answers` caller
835    /// that supplies `bundle_source_uri` (the remote-pull coordinate) would be
836    /// rejected unless the schema advertises the field.
837    #[test]
838    fn deploy_schema_lists_bundle_source_uri() {
839        let schema = deploy_schema();
840        assert!(
841            schema.pointer("/properties/bundle_source_uri").is_some(),
842            "deploy_schema must list `bundle_source_uri` so --schema-driven \
843             callers can record the bundle's registry source (schema: {schema:#})"
844        );
845    }
846
847    #[test]
848    fn deploy_schema_models_remote_mode() {
849        // A remote --store-url deploy supplies bundle_source_uri + remote_pins and
850        // OMITS bundle_path. The schema must declare remote_pins and must not
851        // require bundle_path, else a --schema-driven remote answers file is
852        // rejected against its own contract (Codex finding 3).
853        let schema = deploy_schema();
854        assert!(
855            schema.pointer("/properties/remote_pins").is_some(),
856            "deploy_schema must declare `remote_pins`: {schema:#}"
857        );
858        assert!(
859            schema
860                .pointer("/properties/remote_pins/properties/bundle_digest")
861                .is_some(),
862            "remote_pins must declare `bundle_digest`: {schema:#}"
863        );
864        let required: Vec<&str> = schema["required"]
865            .as_array()
866            .expect("required is an array")
867            .iter()
868            .filter_map(|v| v.as_str())
869            .collect();
870        assert!(
871            !required.contains(&"bundle_path"),
872            "bundle_path must not be globally required (it's local-mode only): {required:?}"
873        );
874        assert!(required.contains(&"bundle_id"), "bundle_id stays required");
875    }
876
877    fn seeded_store() -> (tempfile::TempDir, LocalFsStore) {
878        let dir = tempdir().unwrap();
879        let store = LocalFsStore::new(dir.path());
880        store.save(&make_env("local")).unwrap();
881        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
882        bootstrap_env_trust_root(&env_dir);
883        (dir, store)
884    }
885
886    /// The real `.gtbundle` test fixture — extracted (pure-Rust squashfs) and
887    /// pinned into a pack-list lock by the stage step, so the orchestration is
888    /// exercised against an artifact-backed revision, not a placeholder.
889    fn fixture() -> PathBuf {
890        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
891            .join("testdata/bundles/perf-smoke-bundle.gtbundle")
892    }
893
894    fn payload(bundle_id: &str) -> BundleDeployPayload {
895        BundleDeployPayload {
896            environment_id: "local".to_string(),
897            bundle_id: bundle_id.to_string(),
898            customer_id: None,
899            bundle_path: Some(fixture()),
900            bundle_source_uri: None,
901            remote_pins: None,
902            idempotency_key: None,
903            config_overrides: None,
904            route_binding: None,
905            revenue_share: None,
906        }
907    }
908
909    fn deploy_summary(outcome: OpOutcome) -> DeploySummary {
910        serde_json::from_value(outcome.result).expect("DeploySummary")
911    }
912
913    /// Build a single-pack override map matching the perf-smoke fixture.
914    fn perf_smoke_override(url: &str) -> BTreeMap<String, BTreeMap<String, Value>> {
915        BTreeMap::from([(
916            "perf-smoke-pack".to_string(),
917            BTreeMap::from([("api_base_url".to_string(), Value::String(url.to_string()))]),
918        )])
919    }
920
921    #[test]
922    fn fresh_deploy_creates_and_routes() {
923        let (_dir, store) = seeded_store();
924        let outcome = deploy(&store, &OpFlags::default(), Some(payload("quickstart"))).unwrap();
925        let s = deploy_summary(outcome);
926        assert!(!s.reused_deployment);
927        assert!(!s.deployment_id.is_empty());
928        assert!(!s.revision_id.is_empty());
929        assert!(s.superseded_revisions.is_empty());
930        assert_eq!(s.status, "routed");
931
932        // One deployment, one live split at 100 % on the new revision, and the
933        // revision is artifact-backed (real digest derived from the .gtbundle).
934        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
935        assert_eq!(env.bundles.len(), 1);
936        assert_eq!(env.traffic_splits.len(), 1);
937        let split = &env.traffic_splits[0];
938        assert_eq!(split.entries.len(), 1);
939        assert_eq!(split.entries[0].weight_bps, FULL_TRAFFIC_BPS);
940        assert_eq!(split.entries[0].revision_id.to_string(), s.revision_id);
941        let rev = env
942            .revisions
943            .iter()
944            .find(|r| r.revision_id.to_string() == s.revision_id)
945            .expect("revision persisted");
946        assert!(
947            rev.bundle_digest.starts_with("sha256:") && rev.bundle_digest != "sha256:00",
948            "deploy must stage a real artifact digest, got {}",
949            rev.bundle_digest
950        );
951    }
952
953    #[test]
954    fn deploy_without_bundle_path_rejected() {
955        // The artifact is required on every path, including `--answers` payloads
956        // that omit it: deploy must never publish a placeholder-digest revision.
957        let (_dir, store) = seeded_store();
958        let mut p = payload("quickstart");
959        p.bundle_path = None;
960        let err = deploy(&store, &OpFlags::default(), Some(p)).unwrap_err();
961        match err {
962            OpError::InvalidArgument(msg) => assert!(msg.contains("--bundle"), "got {msg}"),
963            other => panic!("expected InvalidArgument requiring --bundle, got {other:?}"),
964        }
965        // No partial state: nothing was added before the rejection.
966        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
967        assert!(env.bundles.is_empty());
968    }
969
970    #[test]
971    fn redeploy_with_same_idempotency_key_is_noop() {
972        let (_dir, store) = seeded_store();
973        let mut p = payload("quickstart");
974        p.idempotency_key = Some("rollout-1".to_string());
975        let first = deploy_summary(deploy(&store, &OpFlags::default(), Some(p.clone())).unwrap());
976        let second = deploy_summary(deploy(&store, &OpFlags::default(), Some(p)).unwrap());
977
978        // The keyed retry returns the existing rollout, mints no new revision,
979        // and leaves the rollback target untouched.
980        assert_eq!(second.revision_id, first.revision_id);
981        assert_eq!(second.deployment_id, first.deployment_id);
982        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
983        assert_eq!(
984            env.revisions.len(),
985            1,
986            "no duplicate revision on keyed retry"
987        );
988        let split = &env.traffic_splits[0];
989        assert!(
990            split.previous_split_ref.is_none(),
991            "rollback target must not be disturbed by an idempotent retry"
992        );
993    }
994
995    #[test]
996    fn redeploy_reuses_deployment_and_blue_green_shifts_traffic() {
997        let (_dir, store) = seeded_store();
998        let first = deploy_summary(
999            deploy(&store, &OpFlags::default(), Some(payload("quickstart"))).unwrap(),
1000        );
1001        let second = deploy_summary(
1002            deploy(&store, &OpFlags::default(), Some(payload("quickstart"))).unwrap(),
1003        );
1004
1005        assert!(second.reused_deployment);
1006        assert_eq!(second.deployment_id, first.deployment_id);
1007        assert_ne!(second.revision_id, first.revision_id);
1008        // The first revision was live before; it is now superseded.
1009        assert_eq!(second.superseded_revisions, vec![first.revision_id.clone()]);
1010
1011        // Still a single deployment; the live split now points 100 % at rev2.
1012        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
1013        assert_eq!(env.bundles.len(), 1);
1014        let split = env
1015            .traffic_splits
1016            .iter()
1017            .find(|s| s.deployment_id.to_string() == second.deployment_id)
1018            .expect("split for deployment");
1019        assert_eq!(split.entries.len(), 1);
1020        assert_eq!(split.entries[0].revision_id.to_string(), second.revision_id);
1021        // The superseded revision is retained (not archived) for rollback.
1022        assert!(
1023            env.revisions
1024                .iter()
1025                .any(|r| r.revision_id.to_string() == first.revision_id)
1026        );
1027    }
1028
1029    #[test]
1030    fn missing_env_errors_with_init_hint() {
1031        let dir = tempdir().unwrap();
1032        let store = LocalFsStore::new(dir.path());
1033        // No env saved.
1034        let err = deploy(&store, &OpFlags::default(), Some(payload("quickstart"))).unwrap_err();
1035        match err {
1036            OpError::NotFound(msg) => assert!(msg.contains("env init"), "got {msg}"),
1037            other => panic!("expected NotFound with init hint, got {other:?}"),
1038        }
1039    }
1040
1041    #[test]
1042    fn empty_bundle_id_rejected() {
1043        let (_dir, store) = seeded_store();
1044        let err = deploy(&store, &OpFlags::default(), Some(payload("  "))).unwrap_err();
1045        assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
1046    }
1047
1048    #[test]
1049    fn derives_bundle_id_from_filename_stem() {
1050        let args = super::super::dispatch::BundleDeployArgs {
1051            bundle: Some(PathBuf::from("/tmp/quickstart.gtbundle")),
1052            env: None,
1053            bundle_id: None,
1054            customer_id: None,
1055            idempotency_key: None,
1056            config_override: Vec::new(),
1057            config_override_json: Vec::new(),
1058            config_overrides_from: None,
1059            path_prefix: Vec::new(),
1060            host: Vec::new(),
1061            tenant: None,
1062            team: None,
1063        };
1064        let p = payload_from_deploy_args(args).unwrap().unwrap();
1065        assert_eq!(p.bundle_id, "quickstart");
1066        assert_eq!(p.environment_id, "local");
1067    }
1068
1069    #[test]
1070    fn no_args_defers_to_answers() {
1071        let args = super::super::dispatch::BundleDeployArgs {
1072            bundle: None,
1073            env: None,
1074            bundle_id: None,
1075            customer_id: None,
1076            idempotency_key: None,
1077            config_override: Vec::new(),
1078            config_override_json: Vec::new(),
1079            config_overrides_from: None,
1080            path_prefix: Vec::new(),
1081            host: Vec::new(),
1082            tenant: None,
1083            team: None,
1084        };
1085        assert!(payload_from_deploy_args(args).unwrap().is_none());
1086    }
1087
1088    #[test]
1089    fn missing_bundle_with_other_args_errors() {
1090        let args = super::super::dispatch::BundleDeployArgs {
1091            bundle: None,
1092            env: Some("local".to_string()),
1093            bundle_id: None,
1094            customer_id: None,
1095            idempotency_key: None,
1096            config_override: Vec::new(),
1097            config_override_json: Vec::new(),
1098            config_overrides_from: None,
1099            path_prefix: Vec::new(),
1100            host: Vec::new(),
1101            tenant: None,
1102            team: None,
1103        };
1104        let err = payload_from_deploy_args(args).unwrap_err();
1105        assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
1106    }
1107
1108    // ---- D.4 train-2 ----------------------------------------------------
1109
1110    fn empty_args() -> super::super::dispatch::BundleDeployArgs {
1111        super::super::dispatch::BundleDeployArgs {
1112            bundle: Some(PathBuf::from("/tmp/quickstart.gtbundle")),
1113            env: None,
1114            bundle_id: None,
1115            customer_id: None,
1116            idempotency_key: None,
1117            config_override: Vec::new(),
1118            config_override_json: Vec::new(),
1119            config_overrides_from: None,
1120            path_prefix: Vec::new(),
1121            host: Vec::new(),
1122            tenant: None,
1123            team: None,
1124        }
1125    }
1126
1127    /// `--config-override` always stores values as strings (Codex finding 4).
1128    #[test]
1129    fn config_override_flag_always_stores_string() {
1130        let args = super::super::dispatch::BundleDeployArgs {
1131            config_override: vec![
1132                "messaging-telegram:api_base_url=https://staging.example.com".to_string(),
1133                "messaging-telegram:retry_max=5".to_string(),
1134                "messaging-slack:enabled=true".to_string(),
1135            ],
1136            ..empty_args()
1137        };
1138        let p = payload_from_deploy_args(args).unwrap().unwrap();
1139        let overrides = p.config_overrides.as_ref().unwrap();
1140        assert_eq!(
1141            overrides["messaging-telegram"]["api_base_url"],
1142            Value::String("https://staging.example.com".to_string())
1143        );
1144        // Not Number(5) — string flag never coerces.
1145        assert_eq!(
1146            overrides["messaging-telegram"]["retry_max"],
1147            Value::String("5".to_string())
1148        );
1149        // Not Bool(true) — string flag never coerces.
1150        assert_eq!(
1151            overrides["messaging-slack"]["enabled"],
1152            Value::String("true".to_string())
1153        );
1154    }
1155
1156    /// `--config-override-json` parses typed JSON values.
1157    #[test]
1158    fn config_override_json_flag_parses_typed_values() {
1159        let args = super::super::dispatch::BundleDeployArgs {
1160            config_override_json: vec![
1161                "messaging-telegram:retry_max=5".to_string(),
1162                r#"messaging-slack:enabled=true"#.to_string(),
1163                r#"messaging-telegram:tags=["a","b"]"#.to_string(),
1164            ],
1165            ..empty_args()
1166        };
1167        let p = payload_from_deploy_args(args).unwrap().unwrap();
1168        let overrides = p.config_overrides.as_ref().unwrap();
1169        assert_eq!(
1170            overrides["messaging-telegram"]["retry_max"],
1171            Value::Number(serde_json::Number::from(5))
1172        );
1173        assert_eq!(overrides["messaging-slack"]["enabled"], Value::Bool(true));
1174        assert_eq!(
1175            overrides["messaging-telegram"]["tags"],
1176            serde_json::json!(["a", "b"])
1177        );
1178    }
1179
1180    /// `--config-override-json` with invalid JSON is an error.
1181    #[test]
1182    fn config_override_json_rejects_invalid_json() {
1183        let args = super::super::dispatch::BundleDeployArgs {
1184            config_override_json: vec!["pack:k=not-valid-json".to_string()],
1185            ..empty_args()
1186        };
1187        let err = payload_from_deploy_args(args).unwrap_err();
1188        assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
1189        let msg = format!("{err}");
1190        assert!(msg.contains("config-override-json"), "got {msg}");
1191    }
1192
1193    /// Both flag forms merge into the same map (later flags win per-(pack,key)).
1194    #[test]
1195    fn config_override_and_json_flags_merge() {
1196        let args = super::super::dispatch::BundleDeployArgs {
1197            config_override: vec![
1198                "messaging-telegram:api_base_url=https://staging.example.com".to_string(),
1199            ],
1200            config_override_json: vec!["messaging-telegram:retry_max=3".to_string()],
1201            ..empty_args()
1202        };
1203        let p = payload_from_deploy_args(args).unwrap().unwrap();
1204        let overrides = p.config_overrides.as_ref().unwrap();
1205        assert_eq!(overrides.len(), 1);
1206        assert_eq!(overrides["messaging-telegram"].len(), 2);
1207    }
1208
1209    #[test]
1210    fn config_override_repeating_flags_merge_per_pack() {
1211        let args = super::super::dispatch::BundleDeployArgs {
1212            config_override: vec![
1213                "messaging-telegram:api_base_url=https://staging.example.com".to_string(),
1214                "messaging-telegram:retry_max=3".to_string(),
1215                "messaging-slack:webhook_url=https://hooks.slack/abc".to_string(),
1216            ],
1217            ..empty_args()
1218        };
1219        let p = payload_from_deploy_args(args).unwrap().unwrap();
1220        let overrides = p.config_overrides.as_ref().unwrap();
1221        assert_eq!(overrides.len(), 2);
1222        assert_eq!(overrides["messaging-telegram"].len(), 2);
1223        assert_eq!(overrides["messaging-slack"].len(), 1);
1224    }
1225
1226    #[test]
1227    fn config_override_rejects_missing_colon() {
1228        let args = super::super::dispatch::BundleDeployArgs {
1229            config_override: vec!["api_base_url=https://example.com".to_string()],
1230            ..empty_args()
1231        };
1232        let err = payload_from_deploy_args(args).unwrap_err();
1233        match err {
1234            OpError::InvalidArgument(msg) => {
1235                assert!(msg.contains("config-override"), "got {msg}")
1236            }
1237            other => panic!("expected InvalidArgument, got {other:?}"),
1238        }
1239    }
1240
1241    #[test]
1242    fn config_override_rejects_missing_equals() {
1243        let args = super::super::dispatch::BundleDeployArgs {
1244            config_override: vec!["pack:no-value".to_string()],
1245            ..empty_args()
1246        };
1247        let err = payload_from_deploy_args(args).unwrap_err();
1248        assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
1249    }
1250
1251    #[test]
1252    fn config_override_rejects_empty_pack_or_key() {
1253        let args = super::super::dispatch::BundleDeployArgs {
1254            config_override: vec![":key=value".to_string()],
1255            ..empty_args()
1256        };
1257        assert!(matches!(
1258            payload_from_deploy_args(args).unwrap_err(),
1259            OpError::InvalidArgument(_)
1260        ));
1261        let args = super::super::dispatch::BundleDeployArgs {
1262            config_override: vec!["pack:=value".to_string()],
1263            ..empty_args()
1264        };
1265        assert!(matches!(
1266            payload_from_deploy_args(args).unwrap_err(),
1267            OpError::InvalidArgument(_)
1268        ));
1269    }
1270
1271    #[test]
1272    fn config_overrides_from_file_loads_bulk_and_flags_override_per_key() {
1273        // File supplies a baseline; a `--config-override` flag wins per (pack, key).
1274        let dir = tempdir().unwrap();
1275        let file = dir.path().join("overrides.json");
1276        std::fs::write(
1277            &file,
1278            r#"{
1279                "messaging-telegram": {
1280                    "api_base_url": "https://prod.example.com",
1281                    "retry_max": 10
1282                }
1283            }"#,
1284        )
1285        .unwrap();
1286        let args = super::super::dispatch::BundleDeployArgs {
1287            config_override: vec![
1288                "messaging-telegram:api_base_url=https://staging.example.com".to_string(),
1289            ],
1290            config_overrides_from: Some(file),
1291            ..empty_args()
1292        };
1293        let p = payload_from_deploy_args(args).unwrap().unwrap();
1294        let overrides = p.config_overrides.as_ref().unwrap();
1295        // Flag wins the api_base_url key
1296        assert_eq!(
1297            overrides["messaging-telegram"]["api_base_url"],
1298            Value::String("https://staging.example.com".to_string())
1299        );
1300        // File's retry_max survives (flag didn't override it)
1301        assert_eq!(
1302            overrides["messaging-telegram"]["retry_max"],
1303            Value::Number(serde_json::Number::from(10))
1304        );
1305    }
1306
1307    #[test]
1308    fn config_overrides_from_missing_file_errors() {
1309        let args = super::super::dispatch::BundleDeployArgs {
1310            config_overrides_from: Some(PathBuf::from("/nonexistent/path/overrides.json")),
1311            ..empty_args()
1312        };
1313        assert!(matches!(
1314            payload_from_deploy_args(args).unwrap_err(),
1315            OpError::InvalidArgument(_)
1316        ));
1317    }
1318
1319    /// No override input at all → `config_overrides: None` (Codex finding 3).
1320    #[test]
1321    fn no_override_input_yields_none() {
1322        let args = super::super::dispatch::BundleDeployArgs {
1323            config_override: Vec::new(),
1324            config_override_json: Vec::new(),
1325            config_overrides_from: None,
1326            ..empty_args()
1327        };
1328        let p = payload_from_deploy_args(args).unwrap().unwrap();
1329        assert!(p.config_overrides.is_none());
1330    }
1331
1332    /// Explicit empty `{}` file → `config_overrides: Some(empty)` (Codex finding 3).
1333    #[test]
1334    fn empty_overrides_file_yields_some_empty() {
1335        let dir = tempdir().unwrap();
1336        let file = dir.path().join("empty.json");
1337        std::fs::write(&file, "{}").unwrap();
1338        let args = super::super::dispatch::BundleDeployArgs {
1339            config_overrides_from: Some(file),
1340            ..empty_args()
1341        };
1342        let p = payload_from_deploy_args(args).unwrap().unwrap();
1343        let overrides = p.config_overrides.as_ref().unwrap();
1344        assert!(overrides.is_empty(), "explicit {{}} → Some(empty)");
1345    }
1346
1347    #[test]
1348    fn deploy_persists_config_overrides_via_add_path() {
1349        let (_dir, store) = seeded_store();
1350        let mut p = payload("quickstart");
1351        p.config_overrides = Some(perf_smoke_override("https://staging.example.com"));
1352        let outcome = deploy(&store, &OpFlags::default(), Some(p)).unwrap();
1353        let s = deploy_summary(outcome);
1354        assert!(!s.reused_deployment, "fresh deploy");
1355        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
1356        let bundle = env
1357            .bundles
1358            .iter()
1359            .find(|b| b.deployment_id.to_string() == s.deployment_id)
1360            .unwrap();
1361        assert_eq!(
1362            bundle.config_overrides["perf-smoke-pack"]["api_base_url"],
1363            Value::String("https://staging.example.com".to_string())
1364        );
1365    }
1366
1367    #[test]
1368    fn redeploy_with_new_overrides_replaces_them_on_existing_bundle() {
1369        let (_dir, store) = seeded_store();
1370        let mut p = payload("quickstart");
1371        p.config_overrides = Some(perf_smoke_override("https://v1.example.com"));
1372        deploy(&store, &OpFlags::default(), Some(p)).unwrap();
1373        let mut p2 = payload("quickstart");
1374        p2.config_overrides = Some(perf_smoke_override("https://v2.example.com"));
1375        let s = deploy_summary(deploy(&store, &OpFlags::default(), Some(p2)).unwrap());
1376        assert!(s.reused_deployment, "blue-green re-deploy");
1377        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
1378        let bundle = env
1379            .bundles
1380            .iter()
1381            .find(|b| b.deployment_id.to_string() == s.deployment_id)
1382            .unwrap();
1383        assert_eq!(
1384            bundle.config_overrides["perf-smoke-pack"]["api_base_url"],
1385            Value::String("https://v2.example.com".to_string())
1386        );
1387    }
1388
1389    /// `None` overrides on re-deploy leaves existing alone (Codex finding 3).
1390    #[test]
1391    fn redeploy_with_none_overrides_leaves_existing_alone() {
1392        let (_dir, store) = seeded_store();
1393        let initial = perf_smoke_override("https://v1.example.com");
1394        let mut p = payload("quickstart");
1395        p.config_overrides = Some(initial.clone());
1396        deploy(&store, &OpFlags::default(), Some(p)).unwrap();
1397        // Re-deploy with None (no override input) — existing must survive.
1398        let s = deploy_summary(
1399            deploy(&store, &OpFlags::default(), Some(payload("quickstart"))).unwrap(),
1400        );
1401        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
1402        let bundle = env
1403            .bundles
1404            .iter()
1405            .find(|b| b.deployment_id.to_string() == s.deployment_id)
1406            .unwrap();
1407        assert_eq!(bundle.config_overrides, initial);
1408    }
1409
1410    /// `Some(empty)` overrides on re-deploy clears existing (Codex finding 3).
1411    #[test]
1412    fn redeploy_with_explicit_empty_overrides_clears_existing() {
1413        let (_dir, store) = seeded_store();
1414        let mut p = payload("quickstart");
1415        p.config_overrides = Some(perf_smoke_override("https://v1.example.com"));
1416        deploy(&store, &OpFlags::default(), Some(p)).unwrap();
1417        // Re-deploy with Some(empty) — explicit clear.
1418        let mut p2 = payload("quickstart");
1419        p2.config_overrides = Some(BTreeMap::new());
1420        let s = deploy_summary(deploy(&store, &OpFlags::default(), Some(p2)).unwrap());
1421        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
1422        let bundle = env
1423            .bundles
1424            .iter()
1425            .find(|b| b.deployment_id.to_string() == s.deployment_id)
1426            .unwrap();
1427        assert!(
1428            bundle.config_overrides.is_empty(),
1429            "explicit clear must empty the map"
1430        );
1431    }
1432
1433    // ---- routing flags (Change A) ---------------------------------------
1434
1435    #[test]
1436    fn route_flags_build_payload() {
1437        let args = super::super::dispatch::BundleDeployArgs {
1438            path_prefix: vec!["/legal".to_string()],
1439            host: vec!["api.example.com".to_string()],
1440            tenant: Some("legal".to_string()),
1441            team: Some("legal-team".to_string()),
1442            ..empty_args()
1443        };
1444        let p = payload_from_deploy_args(args).unwrap().unwrap();
1445        let rb = p.route_binding.as_ref().expect("route_binding set");
1446        assert_eq!(rb.path_prefixes, vec!["/legal"]);
1447        assert_eq!(rb.hosts, vec!["api.example.com"]);
1448        let ts = rb.tenant_selector.as_ref().expect("tenant_selector");
1449        assert_eq!(ts.tenant, "legal");
1450        assert_eq!(ts.team, "legal-team");
1451    }
1452
1453    #[test]
1454    fn tenant_without_team_defaults_to_default() {
1455        let args = super::super::dispatch::BundleDeployArgs {
1456            tenant: Some("legal".to_string()),
1457            path_prefix: vec!["/legal".to_string()],
1458            ..empty_args()
1459        };
1460        let p = payload_from_deploy_args(args).unwrap().unwrap();
1461        let ts = p
1462            .route_binding
1463            .as_ref()
1464            .and_then(|rb| rb.tenant_selector.as_ref())
1465            .expect("tenant_selector");
1466        assert_eq!(ts.tenant, "legal");
1467        assert_eq!(ts.team, "default");
1468    }
1469
1470    #[test]
1471    fn team_without_tenant_rejected() {
1472        let args = super::super::dispatch::BundleDeployArgs {
1473            team: Some("billing".to_string()),
1474            ..empty_args()
1475        };
1476        let err = payload_from_deploy_args(args).unwrap_err();
1477        match err {
1478            OpError::InvalidArgument(msg) => assert!(msg.contains("--team requires --tenant")),
1479            other => panic!("expected InvalidArgument, got {other:?}"),
1480        }
1481    }
1482
1483    #[test]
1484    fn no_routing_flags_yields_none() {
1485        let args = empty_args();
1486        let p = payload_from_deploy_args(args).unwrap().unwrap();
1487        assert!(
1488            p.route_binding.is_none(),
1489            "no routing flags → route_binding is None (leave existing alone)"
1490        );
1491    }
1492
1493    #[test]
1494    fn fresh_deploy_with_route_binding_persists_it() {
1495        let (_dir, store) = seeded_store();
1496        let mut p = payload("quickstart");
1497        p.route_binding = Some(RouteBindingPayload {
1498            hosts: Vec::new(),
1499            path_prefixes: vec!["/legal".to_string()],
1500            tenant_selector: Some(super::super::bundles::TenantSelectorPayload {
1501                tenant: "legal".to_string(),
1502                team: "default".to_string(),
1503            }),
1504        });
1505        let s = deploy_summary(deploy(&store, &OpFlags::default(), Some(p)).unwrap());
1506        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
1507        let bundle = env
1508            .bundles
1509            .iter()
1510            .find(|b| b.deployment_id.to_string() == s.deployment_id)
1511            .unwrap();
1512        assert_eq!(
1513            bundle.route_binding.path_prefixes,
1514            vec!["/legal".to_string()]
1515        );
1516        // RouteBinding.tenant_selector is not Option in the persisted form;
1517        // `into_route_binding` populates a literal "default"/"default" when
1518        // the payload's Option is None. Here we supplied a real selector.
1519        assert_eq!(bundle.route_binding.tenant_selector.tenant, "legal");
1520        assert_eq!(bundle.route_binding.tenant_selector.team, "default");
1521    }
1522
1523    /// A re-deploy with a DIFFERENT route_binding is rejected (rollback-safety:
1524    /// `traffic rollback` only restores the TrafficSplit, not the binding).
1525    #[test]
1526    fn redeploy_with_differing_route_binding_rejected() {
1527        let (_dir, store) = seeded_store();
1528        let mut p1 = payload("quickstart");
1529        p1.route_binding = Some(RouteBindingPayload {
1530            hosts: Vec::new(),
1531            path_prefixes: vec!["/v1".to_string()],
1532            tenant_selector: None,
1533        });
1534        deploy(&store, &OpFlags::default(), Some(p1)).unwrap();
1535        let mut p2 = payload("quickstart");
1536        p2.route_binding = Some(RouteBindingPayload {
1537            hosts: Vec::new(),
1538            path_prefixes: vec!["/v2".to_string()],
1539            tenant_selector: Some(super::super::bundles::TenantSelectorPayload {
1540                tenant: "legal".to_string(),
1541                team: "default".to_string(),
1542            }),
1543        });
1544        let err = deploy(&store, &OpFlags::default(), Some(p2)).unwrap_err();
1545        match err {
1546            OpError::Conflict(msg) => {
1547                assert!(
1548                    msg.contains("route_binding differs"),
1549                    "expected 'route_binding differs', got {msg}"
1550                );
1551                assert!(
1552                    msg.contains("bundles update"),
1553                    "expected guidance to use 'bundles update', got {msg}"
1554                );
1555            }
1556            other => panic!("expected Conflict, got {other:?}"),
1557        }
1558    }
1559
1560    /// A re-deploy with the SAME route_binding is a no-op (skip bundles::update).
1561    #[test]
1562    fn redeploy_with_matching_route_binding_is_noop() {
1563        let (_dir, store) = seeded_store();
1564        let rb = RouteBindingPayload {
1565            hosts: Vec::new(),
1566            path_prefixes: vec!["/legal".to_string()],
1567            tenant_selector: Some(super::super::bundles::TenantSelectorPayload {
1568                tenant: "legal".to_string(),
1569                team: "default".to_string(),
1570            }),
1571        };
1572        let mut p1 = payload("quickstart");
1573        p1.route_binding = Some(rb.clone());
1574        deploy(&store, &OpFlags::default(), Some(p1)).unwrap();
1575        // Re-deploy with the exact same route_binding.
1576        let mut p2 = payload("quickstart");
1577        p2.route_binding = Some(rb);
1578        let s = deploy_summary(deploy(&store, &OpFlags::default(), Some(p2)).unwrap());
1579        assert!(s.reused_deployment, "blue-green re-deploy");
1580        // Binding is unchanged.
1581        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
1582        let bundle = env
1583            .bundles
1584            .iter()
1585            .find(|b| b.deployment_id.to_string() == s.deployment_id)
1586            .unwrap();
1587        assert_eq!(
1588            bundle.route_binding.path_prefixes,
1589            vec!["/legal".to_string()]
1590        );
1591        assert_eq!(bundle.route_binding.tenant_selector.tenant, "legal");
1592        assert_eq!(bundle.route_binding.tenant_selector.team, "default");
1593    }
1594
1595    /// `--tenant` without `--host` or `--path-prefix` is rejected (the binding
1596    /// would have no matchers and be unreachable).
1597    #[test]
1598    fn tenant_without_host_or_path_rejected() {
1599        let args = super::super::dispatch::BundleDeployArgs {
1600            tenant: Some("legal".to_string()),
1601            ..empty_args()
1602        };
1603        let err = payload_from_deploy_args(args).unwrap_err();
1604        match err {
1605            OpError::InvalidArgument(msg) => {
1606                assert!(
1607                    msg.contains("host") && msg.contains("path_prefix"),
1608                    "expected validate() message mentioning host and path_prefix, got {msg}"
1609                );
1610            }
1611            other => panic!("expected InvalidArgument, got {other:?}"),
1612        }
1613    }
1614
1615    /// `--answers` JSON with the same unreachable shape must be rejected too,
1616    /// because validate() runs on the payload at `deploy()` entry — not just
1617    /// at the CLI flag layer (altitude fix).
1618    #[test]
1619    fn answers_payload_with_unreachable_route_binding_rejected() {
1620        let (_dir, store) = seeded_store();
1621        let mut p = payload("quickstart");
1622        p.route_binding = Some(RouteBindingPayload {
1623            hosts: Vec::new(),
1624            path_prefixes: Vec::new(),
1625            tenant_selector: Some(super::super::bundles::TenantSelectorPayload {
1626                tenant: "legal".to_string(),
1627                team: "default".to_string(),
1628            }),
1629        });
1630        let err = deploy(&store, &OpFlags::default(), Some(p)).unwrap_err();
1631        match err {
1632            OpError::InvalidArgument(msg) => assert!(
1633                msg.contains("host") && msg.contains("path_prefix"),
1634                "got {msg}"
1635            ),
1636            other => panic!("expected InvalidArgument, got {other:?}"),
1637        }
1638        // No partial state: validate() ran before any add/stage.
1639        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
1640        assert!(env.bundles.is_empty(), "no deployment created");
1641    }
1642
1643    #[test]
1644    fn redeploy_without_route_binding_leaves_existing_alone() {
1645        let (_dir, store) = seeded_store();
1646        let mut p1 = payload("quickstart");
1647        p1.route_binding = Some(RouteBindingPayload {
1648            hosts: Vec::new(),
1649            path_prefixes: vec!["/legal".to_string()],
1650            tenant_selector: None,
1651        });
1652        deploy(&store, &OpFlags::default(), Some(p1)).unwrap();
1653        // Re-deploy with route_binding = None — existing must survive.
1654        let s = deploy_summary(
1655            deploy(&store, &OpFlags::default(), Some(payload("quickstart"))).unwrap(),
1656        );
1657        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
1658        let bundle = env
1659            .bundles
1660            .iter()
1661            .find(|b| b.deployment_id.to_string() == s.deployment_id)
1662            .unwrap();
1663        assert_eq!(
1664            bundle.route_binding.path_prefixes,
1665            vec!["/legal".to_string()],
1666            "route_binding=None must NOT clear the existing binding"
1667        );
1668    }
1669}