use std::collections::BTreeMap;
use std::path::PathBuf;
use greentic_deploy_spec::{EnvId, RouteBinding};
type ConfigOverridesMap = BTreeMap<String, BTreeMap<String, Value>>;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::environment::{EnvironmentStore, LocalFsStore};
use super::bundles::{BundleAddPayload, BundleSummary, BundleUpdatePayload, RouteBindingPayload};
use super::revisions::{RevisionStagePayload, RevisionSummary, RevisionTransitionPayload};
use super::traffic::{TrafficSetEntryPayload, TrafficSetPayload};
use super::{OpError, OpFlags, OpOutcome};
const NOUN: &str = "deploy";
const VERB: &str = "run";
const FULL_TRAFFIC_BPS: u32 = 10_000;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleDeployPayload {
#[serde(default = "default_environment_id")]
pub environment_id: String,
pub bundle_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub customer_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bundle_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub idempotency_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_overrides: Option<BTreeMap<String, BTreeMap<String, Value>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route_binding: Option<RouteBindingPayload>,
}
fn default_environment_id() -> String {
crate::defaults::LOCAL_ENV_ID.to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeploySummary {
pub environment_id: String,
pub bundle_id: String,
pub deployment_id: String,
pub revision_id: String,
pub reused_deployment: bool,
pub superseded_revisions: Vec<String>,
pub traffic: String,
pub status: String,
}
impl DeploySummary {
fn routed(
env_id: &EnvId,
bundle_id: String,
deployment_id: String,
revision_id: String,
reused_deployment: bool,
superseded_revisions: Vec<String>,
) -> Self {
Self {
environment_id: env_id.as_str().to_string(),
bundle_id,
deployment_id,
revision_id,
reused_deployment,
superseded_revisions,
traffic: format!("100% ({FULL_TRAFFIC_BPS} bps)"),
status: "routed".to_string(),
}
}
fn into_outcome(self) -> OpOutcome {
OpOutcome::new(
NOUN,
VERB,
serde_json::to_value(self).expect("DeploySummary is json-safe"),
)
}
}
pub fn deploy(
store: &LocalFsStore,
flags: &OpFlags,
payload: Option<BundleDeployPayload>,
) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return Ok(OpOutcome::new(NOUN, VERB, deploy_schema()));
}
let payload = resolve_payload(flags, payload)?;
let env_id = parse_env_id(&payload.environment_id)?;
let bundle_id = payload.bundle_id.trim().to_string();
if bundle_id.is_empty() {
return Err(OpError::InvalidArgument(
"bundle_id must not be empty".to_string(),
));
}
if let Some(rb) = payload.route_binding.as_ref() {
rb.validate()?;
}
let bundle_path = payload.bundle_path.clone().ok_or_else(|| {
OpError::InvalidArgument(
"deploy requires a local `.gtbundle`: pass `--bundle <PATH>`".to_string(),
)
})?;
if !bundle_path.is_file() {
return Err(OpError::InvalidArgument(format!(
"bundle `{}` is not a file",
bundle_path.display()
)));
}
if !store.exists(&env_id)? {
return Err(OpError::NotFound(format!(
"environment `{env_id}` not found — run `gtc op env init` \
(then `gtc op trust-root bootstrap {env_id}`) before deploying"
)));
}
let customer_id = super::bundles::resolve_customer_id(&env_id, payload.customer_id.clone())?;
let env = store.load(&env_id)?;
let existing = env
.bundles
.iter()
.find(|b| b.bundle_id.as_str() == bundle_id && b.customer_id == customer_id);
if let Some(key) = payload.idempotency_key.as_deref()
&& let Some(b) = existing
&& let Some(split) = env
.traffic_splits
.iter()
.find(|s| s.deployment_id == b.deployment_id && s.idempotency_key == key)
{
let revision_id = split
.entries
.first()
.map(|e| e.revision_id.to_string())
.unwrap_or_default();
return Ok(DeploySummary::routed(
&env_id,
bundle_id,
b.deployment_id.to_string(),
revision_id,
true,
Vec::new(),
)
.into_outcome());
}
if let (Some(b), Some(rb_payload)) = (existing, payload.route_binding.as_ref()) {
let requested: RouteBinding = super::bundles::into_route_binding(rb_payload.clone());
if requested != b.route_binding {
return Err(OpError::Conflict(format!(
"deploy: route_binding differs from the deployed binding for \
`{bundle_id}` — routing is bundle-level metadata and is not \
restored by `traffic rollback`. Run `gtc op bundles update \
--answers ...` to change routing on an existing deployment, \
then re-deploy"
)));
}
}
let (deployment_id, reused, superseded_revisions) = match existing {
Some(b) => {
let dep = b.deployment_id;
let superseded: Vec<String> = env
.traffic_splits
.iter()
.find(|s| s.deployment_id == dep)
.map(|s| {
s.entries
.iter()
.map(|e| e.revision_id.to_string())
.collect()
})
.unwrap_or_default();
(dep.to_string(), true, superseded)
}
None => {
let add_payload = BundleAddPayload {
environment_id: payload.environment_id.clone(),
bundle_id: bundle_id.clone(),
customer_id: payload.customer_id.clone(),
route_binding: payload.route_binding.clone().unwrap_or_default(),
revenue_share: super::bundles::default_revenue_share(),
authorization_ref: super::bundles::default_authorization_ref(),
config_overrides: payload.config_overrides.clone().unwrap_or_default(),
};
let outcome = super::bundles::add(store, flags, Some(add_payload))?;
let summary: BundleSummary = parse_summary(outcome, "bundle")?;
(summary.deployment_id, false, Vec::new())
}
};
drop(env);
let stage_payload = RevisionStagePayload {
environment_id: payload.environment_id.clone(),
deployment_id: deployment_id.clone(),
bundle_path: Some(bundle_path),
bundle_digest: super::revisions::default_bundle_digest(),
pack_list: Vec::new(),
pack_list_lock_ref: PathBuf::new(),
config_digest: super::revisions::default_config_digest(),
signature_sidecar_ref: super::revisions::default_signature_sidecar_ref(),
drain_seconds: super::revisions::default_drain_seconds(),
};
let stage_outcome = super::revisions::stage(store, flags, Some(stage_payload))?;
let staged: RevisionSummary = parse_summary(stage_outcome, "revision")?;
let revision_id = staged.revision_id;
super::revisions::warm(
store,
flags,
Some(RevisionTransitionPayload {
environment_id: payload.environment_id.clone(),
revision_id: revision_id.clone(),
}),
)?;
if reused && let Some(ref overrides) = payload.config_overrides {
super::bundles::update(
store,
flags,
Some(BundleUpdatePayload {
environment_id: payload.environment_id.clone(),
deployment_id: deployment_id.clone(),
status: None,
route_binding: None,
revenue_share: None,
config_overrides: Some(overrides.clone()),
}),
)?;
}
let idempotency_key = payload
.idempotency_key
.clone()
.unwrap_or_else(|| format!("deploy:{deployment_id}:{revision_id}"));
super::traffic::set(
store,
flags,
Some(TrafficSetPayload {
environment_id: payload.environment_id.clone(),
deployment_id: deployment_id.clone(),
entries: vec![TrafficSetEntryPayload {
revision_id: revision_id.clone(),
weight_bps: Some(FULL_TRAFFIC_BPS),
weight_percent: None,
}],
updated_by: super::traffic::default_updated_by(),
idempotency_key,
authorization_ref: super::traffic::default_authorization_ref(),
}),
)?;
Ok(DeploySummary::routed(
&env_id,
bundle_id,
deployment_id,
revision_id,
reused,
superseded_revisions,
)
.into_outcome())
}
pub fn payload_from_deploy_args(
args: super::dispatch::BundleDeployArgs,
) -> Result<Option<BundleDeployPayload>, OpError> {
let super::dispatch::BundleDeployArgs {
bundle,
env,
bundle_id,
customer_id,
idempotency_key,
config_override,
config_override_json,
config_overrides_from,
path_prefix,
host,
tenant,
team,
} = args;
if bundle.is_none()
&& env.is_none()
&& bundle_id.is_none()
&& customer_id.is_none()
&& idempotency_key.is_none()
&& config_override.is_empty()
&& config_override_json.is_empty()
&& config_overrides_from.is_none()
&& path_prefix.is_empty()
&& host.is_empty()
&& tenant.is_none()
&& team.is_none()
{
return Ok(None);
}
if team.is_some() && tenant.is_none() {
return Err(OpError::InvalidArgument(
"deploy: --team requires --tenant".to_string(),
));
}
let bundle_path = bundle.ok_or_else(|| {
OpError::InvalidArgument(
"deploy: missing `--bundle <PATH>` (the local .gtbundle to deploy)".to_string(),
)
})?;
let bundle_id = match bundle_id {
Some(id) => id,
None => bundle_path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.ok_or_else(|| {
OpError::InvalidArgument(format!(
"deploy: cannot derive bundle_id from `{}` — pass `--bundle-id <ID>`",
bundle_path.display()
))
})?,
};
let config_overrides = parse_config_overrides_cli(
&config_override,
&config_override_json,
config_overrides_from,
)?;
let route_binding = route_binding_from_cli(host, path_prefix, tenant, team)?;
Ok(Some(BundleDeployPayload {
environment_id: env.unwrap_or_else(default_environment_id),
bundle_id,
customer_id,
bundle_path: Some(bundle_path),
idempotency_key,
config_overrides,
route_binding,
}))
}
fn route_binding_from_cli(
hosts: Vec<String>,
path_prefixes: Vec<String>,
tenant: Option<String>,
team: Option<String>,
) -> Result<Option<RouteBindingPayload>, OpError> {
if hosts.is_empty() && path_prefixes.is_empty() && tenant.is_none() {
return Ok(None);
}
let tenant_selector = tenant.map(|t| super::bundles::TenantSelectorPayload {
tenant: t,
team: team.unwrap_or_else(|| "default".to_string()),
});
let payload = RouteBindingPayload {
hosts,
path_prefixes,
tenant_selector,
};
payload.validate()?;
Ok(Some(payload))
}
fn parse_config_overrides_cli(
string_specs: &[String],
json_specs: &[String],
from_file: Option<PathBuf>,
) -> Result<Option<ConfigOverridesMap>, OpError> {
if string_specs.is_empty() && json_specs.is_empty() && from_file.is_none() {
return Ok(None);
}
let mut overrides: BTreeMap<String, BTreeMap<String, Value>> = BTreeMap::new();
if let Some(path) = from_file {
let bytes = std::fs::read(&path).map_err(|e| {
OpError::InvalidArgument(format!(
"deploy: cannot read --config-overrides-from `{}`: {e}",
path.display()
))
})?;
let parsed: BTreeMap<String, BTreeMap<String, Value>> = serde_json::from_slice(&bytes)
.map_err(|e| {
OpError::InvalidArgument(format!(
"deploy: --config-overrides-from `{}` is not a valid \
`{{<pack_id>: {{<key>: <value>}}}}` JSON object: {e}",
path.display()
))
})?;
overrides = parsed;
}
for spec in string_specs {
let (pack_id, key, value_raw) = split_override_spec(spec, "--config-override")?;
overrides
.entry(pack_id)
.or_default()
.insert(key, Value::String(value_raw.to_string()));
}
for spec in json_specs {
let (pack_id, key, value) = parse_one_config_override_json(spec)?;
overrides.entry(pack_id).or_default().insert(key, value);
}
Ok(Some(overrides))
}
fn parse_one_config_override_json(spec: &str) -> Result<(String, String, Value), OpError> {
let (pack_id, key, value_raw) = split_override_spec(spec, "--config-override-json")?;
let value = serde_json::from_str::<Value>(value_raw).map_err(|e| {
OpError::InvalidArgument(format!(
"deploy: --config-override-json `{spec}` has invalid JSON value: {e}"
))
})?;
Ok((pack_id, key, value))
}
fn split_override_spec<'a>(
spec: &'a str,
flag_name: &str,
) -> Result<(String, String, &'a str), OpError> {
let (pack_id, rest) = spec.split_once(':').ok_or_else(|| {
OpError::InvalidArgument(format!(
"deploy: {flag_name} `{spec}` is malformed — expected `<pack_id>:<key>=<value>`"
))
})?;
let (key, value_raw) = rest.split_once('=').ok_or_else(|| {
OpError::InvalidArgument(format!(
"deploy: {flag_name} `{spec}` is malformed — expected `<pack_id>:<key>=<value>`"
))
})?;
if pack_id.is_empty() || key.is_empty() {
return Err(OpError::InvalidArgument(format!(
"deploy: {flag_name} `{spec}` has an empty pack_id or key"
)));
}
Ok((pack_id.to_string(), key.to_string(), value_raw))
}
fn parse_summary<T: serde::de::DeserializeOwned>(
outcome: OpOutcome,
what: &str,
) -> Result<T, OpError> {
serde_json::from_value(outcome.result).map_err(|e| {
OpError::InvalidArgument(format!("internal: failed to parse {what} summary: {e}"))
})
}
fn resolve_payload<T: serde::de::DeserializeOwned>(
flags: &OpFlags,
payload: Option<T>,
) -> Result<T, OpError> {
if let Some(p) = payload {
return Ok(p);
}
if let Some(path) = &flags.answers {
return super::load_answers::<T>(path);
}
Err(OpError::InvalidArgument(
"no payload provided: pass --bundle <path>, --answers <path>, or supply the payload directly"
.to_string(),
))
}
fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
}
fn deploy_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "BundleDeployPayload",
"type": "object",
"required": ["bundle_id", "bundle_path"],
"additionalProperties": false,
"properties": {
"environment_id": {"type": "string", "default": "local"},
"bundle_id": {"type": "string"},
"customer_id": {"type": "string"},
"bundle_path": {"type": "string", "description": "local .gtbundle path (required)"},
"idempotency_key": {"type": "string", "description": "supply to make retries idempotent"},
"config_overrides": {
"type": "object",
"description": "D.4: per-pack provider config overrides keyed by pack_id (object of {key: json-value})",
"additionalProperties": {"type": "object"}
},
"route_binding": {
"type": "object",
"description": "Set hosts / path_prefixes / tenant_selector at deploy time. Omit to keep the existing binding (or default empty on fresh add).",
"properties": {
"hosts": {"type": "array", "items": {"type": "string"}},
"path_prefixes": {"type": "array", "items": {"type": "string"}},
"tenant_selector": {
"type": "object",
"properties": {
"tenant": {"type": "string"},
"team": {"type": "string"}
}
}
}
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::tests_common::{bootstrap_env_trust_root, make_env};
use tempfile::tempdir;
fn seeded_store() -> (tempfile::TempDir, LocalFsStore) {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store.save(&make_env("local")).unwrap();
let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
bootstrap_env_trust_root(&env_dir);
(dir, store)
}
fn fixture() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("testdata/bundles/perf-smoke-bundle.gtbundle")
}
fn payload(bundle_id: &str) -> BundleDeployPayload {
BundleDeployPayload {
environment_id: "local".to_string(),
bundle_id: bundle_id.to_string(),
customer_id: None,
bundle_path: Some(fixture()),
idempotency_key: None,
config_overrides: None,
route_binding: None,
}
}
fn deploy_summary(outcome: OpOutcome) -> DeploySummary {
serde_json::from_value(outcome.result).expect("DeploySummary")
}
fn perf_smoke_override(url: &str) -> BTreeMap<String, BTreeMap<String, Value>> {
BTreeMap::from([(
"perf-smoke-pack".to_string(),
BTreeMap::from([("api_base_url".to_string(), Value::String(url.to_string()))]),
)])
}
#[test]
fn fresh_deploy_creates_and_routes() {
let (_dir, store) = seeded_store();
let outcome = deploy(&store, &OpFlags::default(), Some(payload("quickstart"))).unwrap();
let s = deploy_summary(outcome);
assert!(!s.reused_deployment);
assert!(!s.deployment_id.is_empty());
assert!(!s.revision_id.is_empty());
assert!(s.superseded_revisions.is_empty());
assert_eq!(s.status, "routed");
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
assert_eq!(env.bundles.len(), 1);
assert_eq!(env.traffic_splits.len(), 1);
let split = &env.traffic_splits[0];
assert_eq!(split.entries.len(), 1);
assert_eq!(split.entries[0].weight_bps, FULL_TRAFFIC_BPS);
assert_eq!(split.entries[0].revision_id.to_string(), s.revision_id);
let rev = env
.revisions
.iter()
.find(|r| r.revision_id.to_string() == s.revision_id)
.expect("revision persisted");
assert!(
rev.bundle_digest.starts_with("sha256:") && rev.bundle_digest != "sha256:00",
"deploy must stage a real artifact digest, got {}",
rev.bundle_digest
);
}
#[test]
fn deploy_without_bundle_path_rejected() {
let (_dir, store) = seeded_store();
let mut p = payload("quickstart");
p.bundle_path = None;
let err = deploy(&store, &OpFlags::default(), Some(p)).unwrap_err();
match err {
OpError::InvalidArgument(msg) => assert!(msg.contains("--bundle"), "got {msg}"),
other => panic!("expected InvalidArgument requiring --bundle, got {other:?}"),
}
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
assert!(env.bundles.is_empty());
}
#[test]
fn redeploy_with_same_idempotency_key_is_noop() {
let (_dir, store) = seeded_store();
let mut p = payload("quickstart");
p.idempotency_key = Some("rollout-1".to_string());
let first = deploy_summary(deploy(&store, &OpFlags::default(), Some(p.clone())).unwrap());
let second = deploy_summary(deploy(&store, &OpFlags::default(), Some(p)).unwrap());
assert_eq!(second.revision_id, first.revision_id);
assert_eq!(second.deployment_id, first.deployment_id);
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
assert_eq!(
env.revisions.len(),
1,
"no duplicate revision on keyed retry"
);
let split = &env.traffic_splits[0];
assert!(
split.previous_split_ref.is_none(),
"rollback target must not be disturbed by an idempotent retry"
);
}
#[test]
fn redeploy_reuses_deployment_and_blue_green_shifts_traffic() {
let (_dir, store) = seeded_store();
let first = deploy_summary(
deploy(&store, &OpFlags::default(), Some(payload("quickstart"))).unwrap(),
);
let second = deploy_summary(
deploy(&store, &OpFlags::default(), Some(payload("quickstart"))).unwrap(),
);
assert!(second.reused_deployment);
assert_eq!(second.deployment_id, first.deployment_id);
assert_ne!(second.revision_id, first.revision_id);
assert_eq!(second.superseded_revisions, vec![first.revision_id.clone()]);
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
assert_eq!(env.bundles.len(), 1);
let split = env
.traffic_splits
.iter()
.find(|s| s.deployment_id.to_string() == second.deployment_id)
.expect("split for deployment");
assert_eq!(split.entries.len(), 1);
assert_eq!(split.entries[0].revision_id.to_string(), second.revision_id);
assert!(
env.revisions
.iter()
.any(|r| r.revision_id.to_string() == first.revision_id)
);
}
#[test]
fn missing_env_errors_with_init_hint() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
let err = deploy(&store, &OpFlags::default(), Some(payload("quickstart"))).unwrap_err();
match err {
OpError::NotFound(msg) => assert!(msg.contains("env init"), "got {msg}"),
other => panic!("expected NotFound with init hint, got {other:?}"),
}
}
#[test]
fn empty_bundle_id_rejected() {
let (_dir, store) = seeded_store();
let err = deploy(&store, &OpFlags::default(), Some(payload(" "))).unwrap_err();
assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
}
#[test]
fn derives_bundle_id_from_filename_stem() {
let args = super::super::dispatch::BundleDeployArgs {
bundle: Some(PathBuf::from("/tmp/quickstart.gtbundle")),
env: None,
bundle_id: None,
customer_id: None,
idempotency_key: None,
config_override: Vec::new(),
config_override_json: Vec::new(),
config_overrides_from: None,
path_prefix: Vec::new(),
host: Vec::new(),
tenant: None,
team: None,
};
let p = payload_from_deploy_args(args).unwrap().unwrap();
assert_eq!(p.bundle_id, "quickstart");
assert_eq!(p.environment_id, "local");
}
#[test]
fn no_args_defers_to_answers() {
let args = super::super::dispatch::BundleDeployArgs {
bundle: None,
env: None,
bundle_id: None,
customer_id: None,
idempotency_key: None,
config_override: Vec::new(),
config_override_json: Vec::new(),
config_overrides_from: None,
path_prefix: Vec::new(),
host: Vec::new(),
tenant: None,
team: None,
};
assert!(payload_from_deploy_args(args).unwrap().is_none());
}
#[test]
fn missing_bundle_with_other_args_errors() {
let args = super::super::dispatch::BundleDeployArgs {
bundle: None,
env: Some("local".to_string()),
bundle_id: None,
customer_id: None,
idempotency_key: None,
config_override: Vec::new(),
config_override_json: Vec::new(),
config_overrides_from: None,
path_prefix: Vec::new(),
host: Vec::new(),
tenant: None,
team: None,
};
let err = payload_from_deploy_args(args).unwrap_err();
assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
}
fn empty_args() -> super::super::dispatch::BundleDeployArgs {
super::super::dispatch::BundleDeployArgs {
bundle: Some(PathBuf::from("/tmp/quickstart.gtbundle")),
env: None,
bundle_id: None,
customer_id: None,
idempotency_key: None,
config_override: Vec::new(),
config_override_json: Vec::new(),
config_overrides_from: None,
path_prefix: Vec::new(),
host: Vec::new(),
tenant: None,
team: None,
}
}
#[test]
fn config_override_flag_always_stores_string() {
let args = super::super::dispatch::BundleDeployArgs {
config_override: vec![
"messaging-telegram:api_base_url=https://staging.example.com".to_string(),
"messaging-telegram:retry_max=5".to_string(),
"messaging-slack:enabled=true".to_string(),
],
..empty_args()
};
let p = payload_from_deploy_args(args).unwrap().unwrap();
let overrides = p.config_overrides.as_ref().unwrap();
assert_eq!(
overrides["messaging-telegram"]["api_base_url"],
Value::String("https://staging.example.com".to_string())
);
assert_eq!(
overrides["messaging-telegram"]["retry_max"],
Value::String("5".to_string())
);
assert_eq!(
overrides["messaging-slack"]["enabled"],
Value::String("true".to_string())
);
}
#[test]
fn config_override_json_flag_parses_typed_values() {
let args = super::super::dispatch::BundleDeployArgs {
config_override_json: vec![
"messaging-telegram:retry_max=5".to_string(),
r#"messaging-slack:enabled=true"#.to_string(),
r#"messaging-telegram:tags=["a","b"]"#.to_string(),
],
..empty_args()
};
let p = payload_from_deploy_args(args).unwrap().unwrap();
let overrides = p.config_overrides.as_ref().unwrap();
assert_eq!(
overrides["messaging-telegram"]["retry_max"],
Value::Number(serde_json::Number::from(5))
);
assert_eq!(overrides["messaging-slack"]["enabled"], Value::Bool(true));
assert_eq!(
overrides["messaging-telegram"]["tags"],
serde_json::json!(["a", "b"])
);
}
#[test]
fn config_override_json_rejects_invalid_json() {
let args = super::super::dispatch::BundleDeployArgs {
config_override_json: vec!["pack:k=not-valid-json".to_string()],
..empty_args()
};
let err = payload_from_deploy_args(args).unwrap_err();
assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
let msg = format!("{err}");
assert!(msg.contains("config-override-json"), "got {msg}");
}
#[test]
fn config_override_and_json_flags_merge() {
let args = super::super::dispatch::BundleDeployArgs {
config_override: vec![
"messaging-telegram:api_base_url=https://staging.example.com".to_string(),
],
config_override_json: vec!["messaging-telegram:retry_max=3".to_string()],
..empty_args()
};
let p = payload_from_deploy_args(args).unwrap().unwrap();
let overrides = p.config_overrides.as_ref().unwrap();
assert_eq!(overrides.len(), 1);
assert_eq!(overrides["messaging-telegram"].len(), 2);
}
#[test]
fn config_override_repeating_flags_merge_per_pack() {
let args = super::super::dispatch::BundleDeployArgs {
config_override: vec![
"messaging-telegram:api_base_url=https://staging.example.com".to_string(),
"messaging-telegram:retry_max=3".to_string(),
"messaging-slack:webhook_url=https://hooks.slack/abc".to_string(),
],
..empty_args()
};
let p = payload_from_deploy_args(args).unwrap().unwrap();
let overrides = p.config_overrides.as_ref().unwrap();
assert_eq!(overrides.len(), 2);
assert_eq!(overrides["messaging-telegram"].len(), 2);
assert_eq!(overrides["messaging-slack"].len(), 1);
}
#[test]
fn config_override_rejects_missing_colon() {
let args = super::super::dispatch::BundleDeployArgs {
config_override: vec!["api_base_url=https://example.com".to_string()],
..empty_args()
};
let err = payload_from_deploy_args(args).unwrap_err();
match err {
OpError::InvalidArgument(msg) => {
assert!(msg.contains("config-override"), "got {msg}")
}
other => panic!("expected InvalidArgument, got {other:?}"),
}
}
#[test]
fn config_override_rejects_missing_equals() {
let args = super::super::dispatch::BundleDeployArgs {
config_override: vec!["pack:no-value".to_string()],
..empty_args()
};
let err = payload_from_deploy_args(args).unwrap_err();
assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
}
#[test]
fn config_override_rejects_empty_pack_or_key() {
let args = super::super::dispatch::BundleDeployArgs {
config_override: vec![":key=value".to_string()],
..empty_args()
};
assert!(matches!(
payload_from_deploy_args(args).unwrap_err(),
OpError::InvalidArgument(_)
));
let args = super::super::dispatch::BundleDeployArgs {
config_override: vec!["pack:=value".to_string()],
..empty_args()
};
assert!(matches!(
payload_from_deploy_args(args).unwrap_err(),
OpError::InvalidArgument(_)
));
}
#[test]
fn config_overrides_from_file_loads_bulk_and_flags_override_per_key() {
let dir = tempdir().unwrap();
let file = dir.path().join("overrides.json");
std::fs::write(
&file,
r#"{
"messaging-telegram": {
"api_base_url": "https://prod.example.com",
"retry_max": 10
}
}"#,
)
.unwrap();
let args = super::super::dispatch::BundleDeployArgs {
config_override: vec![
"messaging-telegram:api_base_url=https://staging.example.com".to_string(),
],
config_overrides_from: Some(file),
..empty_args()
};
let p = payload_from_deploy_args(args).unwrap().unwrap();
let overrides = p.config_overrides.as_ref().unwrap();
assert_eq!(
overrides["messaging-telegram"]["api_base_url"],
Value::String("https://staging.example.com".to_string())
);
assert_eq!(
overrides["messaging-telegram"]["retry_max"],
Value::Number(serde_json::Number::from(10))
);
}
#[test]
fn config_overrides_from_missing_file_errors() {
let args = super::super::dispatch::BundleDeployArgs {
config_overrides_from: Some(PathBuf::from("/nonexistent/path/overrides.json")),
..empty_args()
};
assert!(matches!(
payload_from_deploy_args(args).unwrap_err(),
OpError::InvalidArgument(_)
));
}
#[test]
fn no_override_input_yields_none() {
let args = super::super::dispatch::BundleDeployArgs {
config_override: Vec::new(),
config_override_json: Vec::new(),
config_overrides_from: None,
..empty_args()
};
let p = payload_from_deploy_args(args).unwrap().unwrap();
assert!(p.config_overrides.is_none());
}
#[test]
fn empty_overrides_file_yields_some_empty() {
let dir = tempdir().unwrap();
let file = dir.path().join("empty.json");
std::fs::write(&file, "{}").unwrap();
let args = super::super::dispatch::BundleDeployArgs {
config_overrides_from: Some(file),
..empty_args()
};
let p = payload_from_deploy_args(args).unwrap().unwrap();
let overrides = p.config_overrides.as_ref().unwrap();
assert!(overrides.is_empty(), "explicit {{}} → Some(empty)");
}
#[test]
fn deploy_persists_config_overrides_via_add_path() {
let (_dir, store) = seeded_store();
let mut p = payload("quickstart");
p.config_overrides = Some(perf_smoke_override("https://staging.example.com"));
let outcome = deploy(&store, &OpFlags::default(), Some(p)).unwrap();
let s = deploy_summary(outcome);
assert!(!s.reused_deployment, "fresh deploy");
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
let bundle = env
.bundles
.iter()
.find(|b| b.deployment_id.to_string() == s.deployment_id)
.unwrap();
assert_eq!(
bundle.config_overrides["perf-smoke-pack"]["api_base_url"],
Value::String("https://staging.example.com".to_string())
);
}
#[test]
fn redeploy_with_new_overrides_replaces_them_on_existing_bundle() {
let (_dir, store) = seeded_store();
let mut p = payload("quickstart");
p.config_overrides = Some(perf_smoke_override("https://v1.example.com"));
deploy(&store, &OpFlags::default(), Some(p)).unwrap();
let mut p2 = payload("quickstart");
p2.config_overrides = Some(perf_smoke_override("https://v2.example.com"));
let s = deploy_summary(deploy(&store, &OpFlags::default(), Some(p2)).unwrap());
assert!(s.reused_deployment, "blue-green re-deploy");
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
let bundle = env
.bundles
.iter()
.find(|b| b.deployment_id.to_string() == s.deployment_id)
.unwrap();
assert_eq!(
bundle.config_overrides["perf-smoke-pack"]["api_base_url"],
Value::String("https://v2.example.com".to_string())
);
}
#[test]
fn redeploy_with_none_overrides_leaves_existing_alone() {
let (_dir, store) = seeded_store();
let initial = perf_smoke_override("https://v1.example.com");
let mut p = payload("quickstart");
p.config_overrides = Some(initial.clone());
deploy(&store, &OpFlags::default(), Some(p)).unwrap();
let s = deploy_summary(
deploy(&store, &OpFlags::default(), Some(payload("quickstart"))).unwrap(),
);
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
let bundle = env
.bundles
.iter()
.find(|b| b.deployment_id.to_string() == s.deployment_id)
.unwrap();
assert_eq!(bundle.config_overrides, initial);
}
#[test]
fn redeploy_with_explicit_empty_overrides_clears_existing() {
let (_dir, store) = seeded_store();
let mut p = payload("quickstart");
p.config_overrides = Some(perf_smoke_override("https://v1.example.com"));
deploy(&store, &OpFlags::default(), Some(p)).unwrap();
let mut p2 = payload("quickstart");
p2.config_overrides = Some(BTreeMap::new());
let s = deploy_summary(deploy(&store, &OpFlags::default(), Some(p2)).unwrap());
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
let bundle = env
.bundles
.iter()
.find(|b| b.deployment_id.to_string() == s.deployment_id)
.unwrap();
assert!(
bundle.config_overrides.is_empty(),
"explicit clear must empty the map"
);
}
#[test]
fn route_flags_build_payload() {
let args = super::super::dispatch::BundleDeployArgs {
path_prefix: vec!["/legal".to_string()],
host: vec!["api.example.com".to_string()],
tenant: Some("legal".to_string()),
team: Some("legal-team".to_string()),
..empty_args()
};
let p = payload_from_deploy_args(args).unwrap().unwrap();
let rb = p.route_binding.as_ref().expect("route_binding set");
assert_eq!(rb.path_prefixes, vec!["/legal"]);
assert_eq!(rb.hosts, vec!["api.example.com"]);
let ts = rb.tenant_selector.as_ref().expect("tenant_selector");
assert_eq!(ts.tenant, "legal");
assert_eq!(ts.team, "legal-team");
}
#[test]
fn tenant_without_team_defaults_to_default() {
let args = super::super::dispatch::BundleDeployArgs {
tenant: Some("legal".to_string()),
path_prefix: vec!["/legal".to_string()],
..empty_args()
};
let p = payload_from_deploy_args(args).unwrap().unwrap();
let ts = p
.route_binding
.as_ref()
.and_then(|rb| rb.tenant_selector.as_ref())
.expect("tenant_selector");
assert_eq!(ts.tenant, "legal");
assert_eq!(ts.team, "default");
}
#[test]
fn team_without_tenant_rejected() {
let args = super::super::dispatch::BundleDeployArgs {
team: Some("billing".to_string()),
..empty_args()
};
let err = payload_from_deploy_args(args).unwrap_err();
match err {
OpError::InvalidArgument(msg) => assert!(msg.contains("--team requires --tenant")),
other => panic!("expected InvalidArgument, got {other:?}"),
}
}
#[test]
fn no_routing_flags_yields_none() {
let args = empty_args();
let p = payload_from_deploy_args(args).unwrap().unwrap();
assert!(
p.route_binding.is_none(),
"no routing flags → route_binding is None (leave existing alone)"
);
}
#[test]
fn fresh_deploy_with_route_binding_persists_it() {
let (_dir, store) = seeded_store();
let mut p = payload("quickstart");
p.route_binding = Some(RouteBindingPayload {
hosts: Vec::new(),
path_prefixes: vec!["/legal".to_string()],
tenant_selector: Some(super::super::bundles::TenantSelectorPayload {
tenant: "legal".to_string(),
team: "default".to_string(),
}),
});
let s = deploy_summary(deploy(&store, &OpFlags::default(), Some(p)).unwrap());
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
let bundle = env
.bundles
.iter()
.find(|b| b.deployment_id.to_string() == s.deployment_id)
.unwrap();
assert_eq!(
bundle.route_binding.path_prefixes,
vec!["/legal".to_string()]
);
assert_eq!(bundle.route_binding.tenant_selector.tenant, "legal");
assert_eq!(bundle.route_binding.tenant_selector.team, "default");
}
#[test]
fn redeploy_with_differing_route_binding_rejected() {
let (_dir, store) = seeded_store();
let mut p1 = payload("quickstart");
p1.route_binding = Some(RouteBindingPayload {
hosts: Vec::new(),
path_prefixes: vec!["/v1".to_string()],
tenant_selector: None,
});
deploy(&store, &OpFlags::default(), Some(p1)).unwrap();
let mut p2 = payload("quickstart");
p2.route_binding = Some(RouteBindingPayload {
hosts: Vec::new(),
path_prefixes: vec!["/v2".to_string()],
tenant_selector: Some(super::super::bundles::TenantSelectorPayload {
tenant: "legal".to_string(),
team: "default".to_string(),
}),
});
let err = deploy(&store, &OpFlags::default(), Some(p2)).unwrap_err();
match err {
OpError::Conflict(msg) => {
assert!(
msg.contains("route_binding differs"),
"expected 'route_binding differs', got {msg}"
);
assert!(
msg.contains("bundles update"),
"expected guidance to use 'bundles update', got {msg}"
);
}
other => panic!("expected Conflict, got {other:?}"),
}
}
#[test]
fn redeploy_with_matching_route_binding_is_noop() {
let (_dir, store) = seeded_store();
let rb = RouteBindingPayload {
hosts: Vec::new(),
path_prefixes: vec!["/legal".to_string()],
tenant_selector: Some(super::super::bundles::TenantSelectorPayload {
tenant: "legal".to_string(),
team: "default".to_string(),
}),
};
let mut p1 = payload("quickstart");
p1.route_binding = Some(rb.clone());
deploy(&store, &OpFlags::default(), Some(p1)).unwrap();
let mut p2 = payload("quickstart");
p2.route_binding = Some(rb);
let s = deploy_summary(deploy(&store, &OpFlags::default(), Some(p2)).unwrap());
assert!(s.reused_deployment, "blue-green re-deploy");
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
let bundle = env
.bundles
.iter()
.find(|b| b.deployment_id.to_string() == s.deployment_id)
.unwrap();
assert_eq!(
bundle.route_binding.path_prefixes,
vec!["/legal".to_string()]
);
assert_eq!(bundle.route_binding.tenant_selector.tenant, "legal");
assert_eq!(bundle.route_binding.tenant_selector.team, "default");
}
#[test]
fn tenant_without_host_or_path_rejected() {
let args = super::super::dispatch::BundleDeployArgs {
tenant: Some("legal".to_string()),
..empty_args()
};
let err = payload_from_deploy_args(args).unwrap_err();
match err {
OpError::InvalidArgument(msg) => {
assert!(
msg.contains("host") && msg.contains("path_prefix"),
"expected validate() message mentioning host and path_prefix, got {msg}"
);
}
other => panic!("expected InvalidArgument, got {other:?}"),
}
}
#[test]
fn answers_payload_with_unreachable_route_binding_rejected() {
let (_dir, store) = seeded_store();
let mut p = payload("quickstart");
p.route_binding = Some(RouteBindingPayload {
hosts: Vec::new(),
path_prefixes: Vec::new(),
tenant_selector: Some(super::super::bundles::TenantSelectorPayload {
tenant: "legal".to_string(),
team: "default".to_string(),
}),
});
let err = deploy(&store, &OpFlags::default(), Some(p)).unwrap_err();
match err {
OpError::InvalidArgument(msg) => assert!(
msg.contains("host") && msg.contains("path_prefix"),
"got {msg}"
),
other => panic!("expected InvalidArgument, got {other:?}"),
}
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
assert!(env.bundles.is_empty(), "no deployment created");
}
#[test]
fn redeploy_without_route_binding_leaves_existing_alone() {
let (_dir, store) = seeded_store();
let mut p1 = payload("quickstart");
p1.route_binding = Some(RouteBindingPayload {
hosts: Vec::new(),
path_prefixes: vec!["/legal".to_string()],
tenant_selector: None,
});
deploy(&store, &OpFlags::default(), Some(p1)).unwrap();
let s = deploy_summary(
deploy(&store, &OpFlags::default(), Some(payload("quickstart"))).unwrap(),
);
let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
let bundle = env
.bundles
.iter()
.find(|b| b.deployment_id.to_string() == s.deployment_id)
.unwrap();
assert_eq!(
bundle.route_binding.path_prefixes,
vec!["/legal".to_string()],
"route_binding=None must NOT clear the existing binding"
);
}
}