1use std::collections::BTreeMap;
40use std::path::PathBuf;
41
42use greentic_deploy_spec::{
43 BundleDeployment, CustomerId, DeploymentId, EnvId, Environment, RouteBinding,
44};
45
46type 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
64pub(crate) const FULL_TRAFFIC_BPS: u32 = 10_000;
66
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
78pub struct RemoteBundlePins {
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub bundle_digest: Option<String>,
82 #[serde(default)]
84 pub pack_list: Vec<super::revisions::PackListEntryPayload>,
85 #[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub customer_id: Option<String>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub bundle_path: Option<PathBuf>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub bundle_source_uri: Option<String>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub remote_pins: Option<RemoteBundlePins>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub idempotency_key: Option<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub config_overrides: Option<BTreeMap<String, BTreeMap<String, Value>>>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub route_binding: Option<RouteBindingPayload>,
151 #[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#[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 pub reused_deployment: bool,
173 pub superseded_revisions: Vec<String>,
176 pub traffic: String,
177 pub status: String,
178}
179
180impl DeploySummary {
181 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 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
234pub(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
253pub(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
277pub(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
300pub(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
316pub 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 if let Some(rb) = payload.route_binding.as_ref() {
336 rb.validate()?;
337 }
338
339 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 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 let customer_id = super::bundles::resolve_customer_id(&env_id, payload.customer_id.clone())?;
370
371 let env = store.load(&env_id)?;
378 let existing = find_existing_deployment(&env, &bundle_id, &customer_id);
379
380 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 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 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(env);
431
432 let stage_payload = RevisionStagePayload {
438 environment_id: payload.environment_id.clone(),
439 deployment_id: deployment_id.clone(),
440 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 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 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 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 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
535pub 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 revenue_share: None,
613 }))
614}
615
616fn 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
647fn parse_config_overrides_cli(
669 string_specs: &[String],
670 json_specs: &[String],
671 from_file: Option<PathBuf>,
672) -> Result<Option<ConfigOverridesMap>, OpError> {
673 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
709fn 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
721fn 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
744fn 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 #[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 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 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 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 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 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 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 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 assert_eq!(second.superseded_revisions, vec![first.revision_id.clone()]);
1010
1011 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 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 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 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 #[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 assert_eq!(
1146 overrides["messaging-telegram"]["retry_max"],
1147 Value::String("5".to_string())
1148 );
1149 assert_eq!(
1151 overrides["messaging-slack"]["enabled"],
1152 Value::String("true".to_string())
1153 );
1154 }
1155
1156 #[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 #[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 #[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 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 assert_eq!(
1297 overrides["messaging-telegram"]["api_base_url"],
1298 Value::String("https://staging.example.com".to_string())
1299 );
1300 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 #[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 #[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 #[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 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 #[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 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 #[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 assert_eq!(bundle.route_binding.tenant_selector.tenant, "legal");
1520 assert_eq!(bundle.route_binding.tenant_selector.team, "default");
1521 }
1522
1523 #[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 #[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 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 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 #[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 #[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 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 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}