use std::path::PathBuf;
use greentic_deploy_spec::EnvId;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::environment::{EnvironmentStore, LocalFsStore};
use super::bundles::{BundleAddPayload, BundleSummary, 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>,
}
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(),
));
}
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());
}
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: RouteBindingPayload {
hosts: Vec::new(),
path_prefixes: Vec::new(),
tenant_selector: None,
},
revenue_share: super::bundles::default_revenue_share(),
authorization_ref: super::bundles::default_authorization_ref(),
};
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(),
}),
)?;
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,
} = args;
if bundle.is_none()
&& env.is_none()
&& bundle_id.is_none()
&& customer_id.is_none()
&& idempotency_key.is_none()
{
return Ok(None);
}
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()
))
})?,
};
Ok(Some(BundleDeployPayload {
environment_id: env.unwrap_or_else(default_environment_id),
bundle_id,
customer_id,
bundle_path: Some(bundle_path),
idempotency_key,
}))
}
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"}
}
})
}
#[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,
}
}
fn deploy_summary(outcome: OpOutcome) -> DeploySummary {
serde_json::from_value(outcome.result).expect("DeploySummary")
}
#[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,
};
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,
};
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,
};
let err = payload_from_deploy_args(args).unwrap_err();
assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
}
}