use std::path::{Path, PathBuf};
use std::process::Output;
use std::time::{SystemTime, UNIX_EPOCH};
use assert_cmd::Command;
use serde_json::Value;
struct LiveEnv {
url: String,
token: String,
}
fn live_url() -> Option<String> {
std::env::var("IGNITION_LIVE_URL")
.ok()
.filter(|value| !value.trim().is_empty())
}
fn live_token() -> Option<String> {
std::env::var("IGNITION_LIVE_TOKEN")
.ok()
.filter(|value| !value.trim().is_empty())
}
fn live_mutations() -> bool {
std::env::var("IGNITION_LIVE_MUTATIONS").is_ok_and(|value| value == "1")
}
fn live_env() -> Option<LiveEnv> {
if !live_mutations() {
return None;
}
match (live_url(), live_token()) {
(Some(url), Some(token)) => Some(LiveEnv { url, token }),
_ => None,
}
}
fn skip(message: &str) {
eprintln!("skipping: {message}");
}
fn isolated_config() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let config = dir.path().join("config.toml");
(dir, config)
}
fn ign_rig(config: &Path, env: &LiveEnv, args: &[&str]) -> Output {
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command
.env("IGNITION_CLI_CONFIG", config)
.env("IGNITION_TOKEN", &env.token);
command.args(args).output().expect("spawn ign")
}
fn ign_profile(workdir: &Path, env: &LiveEnv, args: &[&str]) -> Output {
let config = workdir.join("marker-config.toml");
std::fs::write(
&config,
format!(
"active = \"dev\"\n\n[profiles.dev]\nurl = \"{}\"\nauth = {{ token_env = \"IGNITION_TOKEN\" }}\n",
env.url
),
)
.expect("write marker config");
let mut command = Command::cargo_bin("ign").expect("binary 'ign' not found");
command
.env("IGNITION_CLI_CONFIG", &config)
.env("IGNITION_TOKEN", &env.token)
.env("IGNITION_URL", &env.url);
command.args(args).output().expect("spawn ign")
}
fn timestamped_name(suffix: &str) -> String {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock after the unix epoch")
.as_secs();
format!("ign-e2e-rig-{ts}{suffix}")
}
fn expect_ok(what: &str, out: &Output) {
assert!(
out.status.success(),
"{what} failed (exit {:?}):\nstdout: {}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
fn data_envelope(out: &Output) -> Value {
let stdout = String::from_utf8_lossy(&out.stdout);
serde_json::from_str(stdout.trim())
.unwrap_or_else(|err| panic!("stdout envelope parses ({err}): {stdout}"))
}
#[allow(dead_code)]
fn expect_exit(out: &Output, code: i32, slug: &str, what: &str) -> Value {
assert_eq!(
out.status.code(),
Some(code),
"{what} must exit {code} (got {:?}):\nstdout: {}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
let envelope: Value = serde_json::from_str(stderr[stderr.find('{').unwrap_or(0)..].trim())
.unwrap_or_else(|err| panic!("stderr envelope parses ({err}): {stderr}"));
assert_eq!(
envelope["error"]["code"], slug,
"{what} must be {slug}: {envelope}"
);
envelope
}
const SCRATCH_PATH: &str = "ignition/script-python/e2e/snapshot";
#[test]
#[ignore = "opt-in e2e: set IGNITION_LIVE_URL + IGNITION_LIVE_TOKEN + IGNITION_LIVE_MUTATIONS=1 (the rig must be discoverable — see module docs)"]
fn snapshot_mutate_restore_round_trip() {
let Some(env) = live_env() else {
skip(
"IGNITION_LIVE_MUTATIONS=1 (with URL+TOKEN) not set — refusing to restore over a live rig",
);
return;
};
let work = tempfile::tempdir().expect("workdir");
let (_config_dir, rig_config) = isolated_config();
let snap_dir = work.path().join("snap");
let trial_before = ign_rig(&rig_config, &env, &["rig", "trial", "status", "--compact"]);
if trial_before.status.success() {
let trial = data_envelope(&trial_before)["data"].clone();
eprintln!(
"trial BEFORE: expired={}, {}s left",
trial["expired"], trial["trial_remaining_s"]
);
}
let pre_name = timestamped_name("-pre");
expect_ok(
"project new (pre-witness)",
&ign_profile(
work.path(),
&env,
&["project", "new", &pre_name, "--compact"],
),
);
let scratch_file = work.path().join("scratch.json");
std::fs::write(
&scratch_file,
r#"{"scope":"G","code":"print('survives-restore')"}"#,
)
.expect("write scratch body");
expect_ok(
"resource put (pre-witness)",
&ign_profile(
work.path(),
&env,
&[
"resource",
"put",
&pre_name,
SCRATCH_PATH,
"--file",
scratch_file.to_str().unwrap(),
"--yes",
"--compact",
],
),
);
expect_ok(
"rig snapshot",
&ign_rig(
&rig_config,
&env,
&[
"rig",
"snapshot",
"-o",
snap_dir.to_str().unwrap(),
"--compact",
],
),
);
let manifest = read_manifest(&snap_dir);
let post_name = timestamped_name("-post");
expect_ok(
"project new (post-snapshot marker)",
&ign_profile(
work.path(),
&env,
&["project", "new", &post_name, "--compact"],
),
);
let gwbk_path = snap_dir.join(manifest["gwbk"].as_str().expect("gwbk file name"));
assert!(
gwbk_path.exists(),
"the gwbk landed: {}",
gwbk_path.display()
);
let restored = ign_rig(
&rig_config,
&env,
&[
"rig",
"restore",
"--file",
gwbk_path.to_str().unwrap(),
"--yes",
"--compact",
],
);
if !restored.status.success() {
let stderr = String::from_utf8_lossy(&restored.stderr);
panic!(
"rig restore failed (exit {:?}):\nstdout: {}\nstderr: {}",
restored.status.code(),
String::from_utf8_lossy(&restored.stdout),
stderr
);
}
let restore_data = data_envelope(&restored)["data"].clone();
assert_eq!(
restore_data["state"],
Value::String("running".into()),
"success is a WITNESSED RUNNING, never a bare 2xx: {restore_data}"
);
let warnings = restore_data["warnings"]
.as_array()
.expect("warnings array (all keys always)");
assert!(
warnings.iter().any(|warning| warning
.as_str()
.is_some_and(|warning| warning.contains("API tokens"))),
"the Pitfall-5 token warning rides data: {restore_data}"
);
let survived = ign_profile(
work.path(),
&env,
&["resource", "get", &pre_name, SCRATCH_PATH, "--compact"],
);
if !survived.status.success() && survived.status.code() == Some(5) {
panic!(
"post-restore reads 401'd — the restore CLOBBERED the API token (Pitfall 5 \
observed live): re-provision the token via the gateway UI, then re-run. \
stderr: {}",
String::from_utf8_lossy(&survived.stderr)
);
}
expect_ok("pre-witness resource survived", &survived);
let got = data_envelope(&survived)["data"].clone();
assert_eq!(got["content_kind"], Value::String("json".into()));
let listed = ign_profile(work.path(), &env, &["project", "list", "--compact"]);
expect_ok("project list post-restore", &listed);
let names = data_envelope(&listed)["data"]["projects"]
.as_array()
.expect("projects array")
.iter()
.map(|project| project["name"].as_str().expect("name").to_string())
.collect::<Vec<_>>();
assert!(
!names.contains(&post_name),
"the post-snapshot project must be GONE after the restore — found: {names:?}"
);
assert!(
names.contains(&pre_name),
"the pre-witness project itself also survived: {names:?}"
);
let trial_after = ign_rig(&rig_config, &env, &["rig", "trial", "status", "--compact"]);
if trial_after.status.success() {
let trial = data_envelope(&trial_after)["data"].clone();
eprintln!(
"trial AFTER: expired={}, {}s left (restore's clock behavior is observational)",
trial["expired"], trial["trial_remaining_s"]
);
}
let doctor = ign_profile(work.path(), &env, &["doctor", "--compact"]);
expect_ok("doctor post-restore", &doctor);
let _ = ign_profile(
work.path(),
&env,
&["project", "delete", &pre_name, "--yes", "--compact"],
)
.status
.success();
}
fn read_manifest(snap_dir: &Path) -> Value {
let manifest_path = snap_dir.join("manifest.json");
let manifest: Value = serde_json::from_str(
&std::fs::read_to_string(&manifest_path)
.unwrap_or_else(|err| panic!("read {}: {err}", manifest_path.display())),
)
.expect("manifest parses");
assert!(
manifest["gwbk"]
.as_str()
.is_some_and(|name| !name.is_empty()),
"manifest carries the gwbk file name: {manifest}"
);
assert!(
manifest["projects"].as_array().is_some(),
"manifest carries the projects array: {manifest}"
);
assert!(
manifest["notes"]
.as_array()
.is_some_and(|notes| notes.len() == 2),
"manifest carries BOTH composition notes: {manifest}"
);
manifest
}