use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::{Value, json};
const E2E_GATE: &str = "GREENTIC_AWS_E2E";
const DESCRIPTOR: &str = "greentic.deployer.aws-ecs@1.0.0";
const ENV_ID: &str = "local";
fn deployer_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_greentic-deployer"))
}
fn gate_armed(value: Option<&str>) -> bool {
value == Some("1")
}
fn armed() -> bool {
if gate_armed(std::env::var(E2E_GATE).ok().as_deref()) {
return true;
}
eprintln!(
"skipping live-account AWS-ECS E2E: set {E2E_GATE}=1 exactly (bills a real account; \
needs the GTC_AWS_E2E_* scope vars — see the module doc)"
);
false
}
fn required_var(name: &str) -> String {
std::env::var(name).unwrap_or_else(|_| {
panic!("{E2E_GATE} is set but required scope var {name} is missing — see the module doc")
})
}
fn op(store: &Path, answers: Option<&Path>, args: &[&str]) -> Value {
let mut cmd = Command::new(deployer_bin());
cmd.arg("op").arg("--store-root").arg(store);
if let Some(path) = answers {
cmd.arg("--answers").arg(path);
}
cmd.args(args);
let out = cmd.output().expect("spawn greentic-deployer");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success(),
"`op {args:?}` failed:\nstdout: {stdout}\nstderr: {}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("`op {args:?}` stdout is not json ({e}):\n{stdout}"))
}
fn payload(store: &Path, name: &str, body: Value) -> PathBuf {
let path = store.join(name);
std::fs::write(&path, serde_json::to_vec(&body).unwrap()).expect("write payload");
path
}
fn fixture_bundle() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/bundles/perf-smoke-bundle.gtbundle")
}
fn aws_answers() -> Value {
let mut answers = json!({
"region": required_var("GTC_AWS_E2E_REGION"),
"ecs_cluster_name": required_var("GTC_AWS_E2E_CLUSTER"),
"ecr_repository_prefix": required_var("GTC_AWS_E2E_ECR_PREFIX"),
"execution_role_arn": required_var("GTC_AWS_E2E_EXECUTION_ROLE_ARN"),
"subnets": required_var("GTC_AWS_E2E_SUBNETS"),
"security_groups": required_var("GTC_AWS_E2E_SECURITY_GROUPS"),
"target_group_arns": required_var("GTC_AWS_E2E_TARGET_GROUPS"),
"alb_listener_arn": required_var("GTC_AWS_E2E_ALB_LISTENER_ARN"),
});
let obj = answers.as_object_mut().expect("answers object");
for (var, key) in [
("GTC_AWS_E2E_TASK_ROLE_ARN", "task_role_arn"),
("GTC_AWS_E2E_ROUTING_HOST", "alb_routing_host"),
("GTC_AWS_E2E_ROUTING_PATH", "alb_routing_path"),
("GTC_AWS_E2E_ASSIGN_PUBLIC_IP", "assign_public_ip"),
("GTC_AWS_E2E_CONTAINER_PORT", "container_port"),
] {
if let Ok(value) = std::env::var(var) {
obj.insert(key.to_string(), json!(value));
}
}
answers
}
#[test]
fn gate_arms_only_on_exact_1() {
assert!(gate_armed(Some("1")));
assert!(!gate_armed(None));
assert!(!gate_armed(Some("0")));
assert!(!gate_armed(Some("false")));
assert!(!gate_armed(Some("true")));
assert!(!gate_armed(Some("")));
}
fn provision_warmed_revision(store: &Path, deployment_id: &str, label: &str) -> String {
let stage = payload(
store,
&format!("stage-{label}.json"),
json!({
"environment_id": ENV_ID,
"deployment_id": deployment_id,
"bundle_path": fixture_bundle().to_string_lossy(),
}),
);
let revision_id = op(store, Some(&stage), &["revisions", "stage"])["result"]["revision_id"]
.as_str()
.expect("revision_id")
.to_string();
let warm = payload(
store,
&format!("warm-{label}.json"),
json!({"environment_id": ENV_ID, "revision_id": revision_id}),
);
let warmed = op(store, Some(&warm), &["revisions", "warm"]);
assert_eq!(
warmed["result"]["lifecycle"], "ready",
"revision {label} reaches Ready (cluster presence) before the live warm"
);
let applied = op(
store,
None,
&["env", "apply-revision", ENV_ID, &revision_id],
);
assert_eq!(
applied["result"]["action"], "warmed",
"present revision {label} drives the live Fargate warm"
);
let identity = applied["result"]["identity"].as_str().expect("identity");
assert!(
identity == "ambient" || identity == "bound",
"identity is ambient (no bound session) or bound, got {identity:?}"
);
revision_id
}
fn route_all_traffic(store: &Path, deployment_id: &str, revision_id: &str) {
let traffic = payload(
store,
&format!("traffic-{revision_id}.json"),
json!({
"environment_id": ENV_ID,
"deployment_id": deployment_id,
"entries": [{"revision_id": revision_id, "weight_bps": 10000}],
"idempotency_key": format!("aws-ecs-e2e-{revision_id}"),
}),
);
op(store, Some(&traffic), &["traffic", "set"]);
let shifted = op(
store,
None,
&["env", "apply-traffic", ENV_ID, deployment_id],
);
let entries = shifted["result"]["applied_entries"]
.as_array()
.expect("applied_entries");
assert_eq!(entries.len(), 1, "single recorded entry shifted");
assert_eq!(
entries[0]["revision_id"], revision_id,
"shifted to the revision"
);
assert_eq!(entries[0]["weight_bps"], 10000, "shifted 100 %");
}
#[test]
fn aws_ecs_full_lifecycle_against_real_account() {
if !armed() {
return;
}
let tmp = tempfile::tempdir().expect("tempdir");
let store = tmp.path();
let create = payload(
store,
"create.json",
json!({"environment_id": ENV_ID, "name": ENV_ID}),
);
op(store, Some(&create), &["env", "create"]);
std::fs::write(
store.join(ENV_ID).join("deployer-answers.json"),
serde_json::to_vec(&aws_answers()).expect("answers serialize"),
)
.expect("write deployer answers");
let bind = payload(
store,
"bind.json",
json!({
"environment_id": ENV_ID,
"slot": "deployer",
"kind": DESCRIPTOR,
"pack_ref": "builtin",
"answers_ref": "deployer-answers.json",
}),
);
op(store, Some(&bind), &["env-packs", "add"]);
op(store, None, &["trust-root", "bootstrap", ENV_ID]);
let add = payload(
store,
"add.json",
json!({
"environment_id": ENV_ID,
"bundle_id": "aws-ecs-e2e",
"route_binding": {"path_prefixes": ["/e2e"]},
}),
);
let deployment_id = op(store, Some(&add), &["bundles", "add"])["result"]["deployment_id"]
.as_str()
.expect("deployment_id")
.to_string();
let rev_a = provision_warmed_revision(store, &deployment_id, "a");
route_all_traffic(store, &deployment_id, &rev_a);
let rev_b = provision_warmed_revision(store, &deployment_id, "b");
route_all_traffic(store, &deployment_id, &rev_b);
let archive = payload(
store,
"archive-a.json",
json!({"environment_id": ENV_ID, "revision_id": rev_a}),
);
let archived = op(store, Some(&archive), &["revisions", "archive"]);
assert_eq!(
archived["result"]["lifecycle"], "archived",
"blue revision archived (desired-state) once drained"
);
let torn_down = op(store, None, &["env", "apply-revision", ENV_ID, &rev_a]);
assert_eq!(
torn_down["result"]["action"], "archived",
"absent blue revision drives the live task-set teardown"
);
}