use std::collections::{BTreeMap, VecDeque};
use std::fmt;
use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
const DEFAULT_MAX_OUTPUT: usize = 1 << 20;
const DEFAULT_INFRA_RETRIES: usize = 2;
pub const INFRA_FAILURE: i32 = i32::MIN;
static INSTANCE_SEQ: AtomicU32 = AtomicU32::new(0);
fn instance_token() -> String {
format!(
"{}-{}",
std::process::id(),
INSTANCE_SEQ.fetch_add(1, Ordering::Relaxed)
)
}
fn box_bin() -> String {
std::env::var("A3S_BOX").unwrap_or_else(|_| "a3s-box".to_string())
}
#[derive(Debug)]
pub enum PipelineError {
Io(std::io::Error),
Cli {
args: String,
code: Option<i32>,
stderr: String,
},
StepFailed {
name: String,
code: i32,
logs: String,
},
NotReady(String),
}
impl fmt::Display for PipelineError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PipelineError::Io(e) => write!(f, "a3s-box invocation failed: {e}"),
PipelineError::Cli { args, code, stderr } => {
write!(
f,
"`a3s-box {args}` failed (exit {code:?}): {}",
stderr.trim()
)
}
PipelineError::StepFailed { name, code, logs } => {
write!(f, "step '{name}' failed (exit {code})\n{}", logs.trim())
}
PipelineError::NotReady(b) => write!(f, "box '{b}' never became ready"),
}
}
}
impl std::error::Error for PipelineError {}
impl From<std::io::Error> for PipelineError {
fn from(e: std::io::Error) -> Self {
PipelineError::Io(e)
}
}
type Result<T> = std::result::Result<T, PipelineError>;
fn box_run(args: &[&str]) -> Result<Output> {
let out = Command::new(box_bin()).args(args).output()?;
if !out.status.success() {
return Err(PipelineError::Cli {
args: args.join(" "),
code: out.status.code(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
});
}
Ok(out)
}
fn box_cleanup(args: &[&str]) {
let _ = Command::new(box_bin()).args(args).output();
}
struct BoxGuard(String);
impl Drop for BoxGuard {
fn drop(&mut self) {
box_cleanup(&["rm", "-f", &self.0]);
}
}
fn key(parts: &[&str]) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for p in parts {
for b in p.bytes().chain(std::iter::once(0u8)) {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
}
format!("{h:016x}")
}
fn with_env(cmd: &str, env: &BTreeMap<String, String>) -> String {
if env.is_empty() {
return cmd.to_string();
}
let mut s = String::new();
for (k, v) in env {
s.push_str("export ");
s.push_str(k);
s.push_str("='");
s.push_str(&v.replace('\'', "'\\''"));
s.push_str("'; ");
}
s.push_str(cmd);
s
}
fn slug(s: &str) -> String {
s.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.take(40)
.collect()
}
fn cap_output(mut s: String, cap: usize) -> String {
if s.len() <= cap {
return s;
}
let mut end = cap;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
let dropped = s.len() - end;
s.truncate(end);
s.push_str(&format!("\n[..truncated {dropped} bytes]"));
s
}
fn parse_metrics(stdout: &str) -> BTreeMap<String, f64> {
let mut m = BTreeMap::new();
for line in stdout.lines() {
if let Some(rest) = line.trim().strip_prefix("::metric ") {
if let Some((k, v)) = rest.split_once('=') {
if let Ok(val) = v.trim().parse::<f64>() {
if val.is_finite() {
m.insert(k.trim().to_string(), val);
}
}
}
}
}
m
}
fn json_str(s: &str) -> String {
let mut o = String::with_capacity(s.len() + 2);
o.push('"');
for c in s.chars() {
match c {
'"' => o.push_str("\\\""),
'\\' => o.push_str("\\\\"),
'\n' => o.push_str("\\n"),
'\r' => o.push_str("\\r"),
'\t' => o.push_str("\\t"),
c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)),
c => o.push(c),
}
}
o.push('"');
o
}
fn json_num(v: f64) -> String {
if v.is_finite() {
format!("{v}")
} else {
"null".to_string()
}
}
fn wait_ready(box_name: &str) -> Result<()> {
wait_ready_inner(box_name, std::time::Duration::from_secs(30))
}
fn wait_ready_inner(box_name: &str, budget: std::time::Duration) -> Result<()> {
let start = std::time::Instant::now();
let mut delay = std::time::Duration::from_millis(25);
let cap = std::time::Duration::from_millis(500);
loop {
let ready = Command::new(box_bin())
.args(["exec", box_name, "--", "true"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if ready {
return Ok(());
}
if start.elapsed() >= budget {
return Err(PipelineError::NotReady(box_name.to_string()));
}
std::thread::sleep(delay);
delay = (delay * 2).min(cap);
}
}
pub struct FileCache {
dir: PathBuf,
}
impl FileCache {
pub fn new(dir: impl Into<PathBuf>) -> std::io::Result<Self> {
let dir = dir.into();
std::fs::create_dir_all(&dir)?;
Ok(Self { dir })
}
fn has(&self, k: &str) -> bool {
self.dir.join(k).exists()
}
fn put(&self, k: &str) {
let _ = std::fs::create_dir_all(&self.dir);
let _ = std::fs::File::create(self.dir.join(k));
}
}
pub struct WarmBase<'a> {
image: String,
setup: String,
env: BTreeMap<String, String>,
cache: Option<&'a FileCache>,
max_output: usize,
infra_retries: usize,
}
impl<'a> WarmBase<'a> {
pub fn new(image: impl Into<String>, setup: impl Into<String>) -> Self {
Self {
image: image.into(),
setup: setup.into(),
env: BTreeMap::new(),
cache: None,
max_output: DEFAULT_MAX_OUTPUT,
infra_retries: DEFAULT_INFRA_RETRIES,
}
}
pub fn env(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
self.env.insert(k.into(), v.into());
self
}
pub fn cache(mut self, cache: &'a FileCache) -> Self {
self.cache = Some(cache);
self
}
pub fn max_output(mut self, bytes: usize) -> Self {
self.max_output = bytes;
self
}
pub fn infra_retries(mut self, n: usize) -> Self {
self.infra_retries = n;
self
}
}
pub struct Step {
name: String,
cmd: String,
inputs: Vec<String>,
env: BTreeMap<String, String>,
allow_failure: bool,
}
impl Step {
pub fn new(name: impl Into<String>, cmd: impl Into<String>) -> Self {
Self {
name: name.into(),
cmd: cmd.into(),
inputs: Vec::new(),
env: BTreeMap::new(),
allow_failure: false,
}
}
pub fn input(mut self, s: impl Into<String>) -> Self {
self.inputs.push(s.into());
self
}
pub fn env(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
self.env.insert(k.into(), v.into());
self
}
pub fn allow_failure(mut self) -> Self {
self.allow_failure = true;
self
}
}
#[derive(Debug, Clone)]
pub struct StepResult {
pub name: String,
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub cached: bool,
pub allow_failure: bool,
pub duration_ms: u128,
pub metrics: BTreeMap<String, f64>,
}
impl StepResult {
pub fn ok(&self) -> bool {
self.exit_code == 0 || self.allow_failure
}
pub fn combined(&self) -> String {
let mut s = self.stdout.clone();
s.push_str(&self.stderr);
s
}
pub fn to_json(&self) -> String {
let mut metrics = String::from("{");
for (i, (k, v)) in self.metrics.iter().enumerate() {
if i > 0 {
metrics.push(',');
}
metrics.push_str(&json_str(k));
metrics.push(':');
metrics.push_str(&json_num(*v));
}
metrics.push('}');
format!(
"{{\"name\":{},\"exit_code\":{},\"cached\":{},\"allow_failure\":{},\"duration_ms\":{},\"metrics\":{},\"stdout\":{},\"stderr\":{}}}",
json_str(&self.name),
self.exit_code,
self.cached,
self.allow_failure,
self.duration_ms,
metrics,
json_str(&self.stdout),
json_str(&self.stderr),
)
}
}
#[derive(Debug, Clone)]
pub struct Report {
pub steps: Vec<StepResult>,
pub total_ms: u128,
pub passed: bool,
}
impl Report {
pub fn failures(&self) -> Vec<&StepResult> {
self.steps.iter().filter(|r| !r.ok()).collect()
}
pub fn to_json(&self) -> String {
let steps: Vec<String> = self.steps.iter().map(|s| s.to_json()).collect();
format!(
"{{\"passed\":{},\"total_ms\":{},\"steps\":[{}]}}",
self.passed,
self.total_ms,
steps.join(",")
)
}
}
pub struct Base<'a> {
snapshot_name: String,
snapshot_id: String,
cache: Option<&'a FileCache>,
max_output: usize,
infra_retries: usize,
n: AtomicU32,
disposed: AtomicBool,
}
impl Base<'_> {
fn exec_step(&self, step: &Step) -> Result<StepResult> {
let mut parts: Vec<&str> = vec![&step.name, &step.cmd];
parts.extend(step.inputs.iter().map(|s| s.as_str()));
let k = key(&parts);
if let Some(c) = self.cache {
if c.has(&k) {
return Ok(StepResult {
name: step.name.clone(),
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
cached: true,
allow_failure: step.allow_failure,
duration_ms: 0,
metrics: BTreeMap::new(),
});
}
}
let i = self.n.fetch_add(1, Ordering::Relaxed) + 1;
let box_name = format!("{}-job{}-{}", self.snapshot_name, i, slug(&step.name));
let _guard = BoxGuard(box_name.clone());
box_run(&[
"snapshot",
"restore",
&self.snapshot_name,
"--name",
&box_name,
])?;
box_run(&["start", &box_name])?;
wait_ready(&box_name)?;
let full = with_env(&step.cmd, &step.env);
let t = Instant::now();
let out = Command::new(box_bin())
.args(["exec", &box_name, "--", "sh", "-c", &full])
.output()?;
let duration_ms = t.elapsed().as_millis();
let code = out.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
let metrics = parse_metrics(&stdout);
if code == 0 {
if let Some(c) = self.cache {
c.put(&k);
}
}
Ok(StepResult {
name: step.name.clone(),
exit_code: code,
stdout: cap_output(stdout, self.max_output),
stderr: cap_output(stderr, self.max_output),
cached: false,
allow_failure: step.allow_failure,
duration_ms,
metrics,
})
}
fn exec_step_retrying(&self, step: &Step) -> Result<StepResult> {
retry_infra(self.infra_retries, || self.exec_step(step))
}
pub fn step(&self, step: Step) -> Result<StepResult> {
let r = self.exec_step_retrying(&step)?;
if r.exit_code != 0 && !step.allow_failure {
return Err(PipelineError::StepFailed {
name: r.name.clone(),
code: r.exit_code,
logs: r.combined(),
});
}
Ok(r)
}
fn run_one(&self, step: Step) -> StepResult {
match self.exec_step_retrying(&step) {
Ok(r) => r,
Err(e) => StepResult {
name: step.name.clone(),
exit_code: INFRA_FAILURE,
stdout: String::new(),
stderr: e.to_string(),
cached: false,
allow_failure: step.allow_failure,
duration_ms: 0,
metrics: BTreeMap::new(),
},
}
}
pub fn run_parallel(&self, steps: Vec<Step>, max_concurrency: usize) -> Report {
let start = Instant::now();
let n = steps.len();
if n == 0 {
return Report {
steps: Vec::new(),
total_ms: 0,
passed: true,
};
}
let conc = max_concurrency.max(1).min(n);
let queue: Mutex<VecDeque<(usize, Step)>> =
Mutex::new(steps.into_iter().enumerate().collect());
let results: Mutex<Vec<(usize, StepResult)>> = Mutex::new(Vec::with_capacity(n));
std::thread::scope(|s| {
for _ in 0..conc {
s.spawn(|| loop {
let next = { queue.lock().unwrap().pop_front() };
let Some((idx, step)) = next else { break };
let r = self.run_one(step);
results.lock().unwrap().push((idx, r));
});
}
});
let mut v = results.into_inner().unwrap();
v.sort_by_key(|(i, _)| *i);
let steps: Vec<StepResult> = v.into_iter().map(|(_, r)| r).collect();
let passed = steps.iter().all(|r| r.ok());
Report {
steps,
total_ms: start.elapsed().as_millis(),
passed,
}
}
pub fn dispose(&self) {
if !self.disposed.swap(true, Ordering::SeqCst) {
box_cleanup(&["snapshot", "rm", "--force", &self.snapshot_id]);
}
}
}
impl Drop for Base<'_> {
fn drop(&mut self) {
if !self.disposed.swap(true, Ordering::SeqCst) {
box_cleanup(&["snapshot", "rm", "--force", &self.snapshot_id]);
}
}
}
fn snapshot_id(name: &str) -> Result<String> {
let out = box_run(&["snapshot", "ls"])?;
let text = String::from_utf8_lossy(&out.stdout);
parse_snapshot_id(&text, name).ok_or_else(|| PipelineError::Cli {
args: format!("snapshot ls (locate '{name}')"),
code: None,
stderr: "snapshot not found after create".into(),
})
}
fn parse_snapshot_id(ls_output: &str, name: &str) -> Option<String> {
for line in ls_output.lines() {
let mut cols = line.split_whitespace();
if let (Some(id), Some(nm)) = (cols.next(), cols.next()) {
if nm == name {
return Some(id.to_string());
}
}
}
None
}
fn retry_infra<T>(retries: usize, mut f: impl FnMut() -> Result<T>) -> Result<T> {
let mut last: Option<PipelineError> = None;
for attempt in 0..=retries {
match f() {
Ok(v) => return Ok(v),
Err(e) => {
last = Some(e);
if attempt < retries {
std::thread::sleep(Duration::from_millis(150 * (attempt as u64 + 1)));
}
}
}
}
Err(last.expect("loop runs at least once"))
}
pub fn warm_base(spec: WarmBase<'_>) -> Result<Base<'_>> {
retry_infra(spec.infra_retries, || warm_once(&spec))
}
fn warm_once<'a>(spec: &WarmBase<'a>) -> Result<Base<'a>> {
let base_box = format!(
"ci-base-{}-{}",
key(&[&spec.image, &spec.setup]),
instance_token()
);
let snap = format!("{base_box}-snap");
box_run(&[
"run",
"-d",
"--name",
&base_box,
&spec.image,
"--",
"sleep",
"86400",
])?;
let prepared = (|| -> Result<String> {
wait_ready(&base_box)?;
let setup = with_env(&spec.setup, &spec.env);
box_run(&["exec", &base_box, "--", "sh", "-c", &setup])?; box_run(&["stop", &base_box])?;
box_run(&["snapshot", "create", &base_box, "--name", &snap])?;
snapshot_id(&snap)
})();
box_cleanup(&["rm", "-f", &base_box]);
if prepared.is_err() {
if let Ok(id) = snapshot_id(&snap) {
box_cleanup(&["snapshot", "rm", "--force", &id]);
}
}
Ok(Base {
snapshot_name: snap,
snapshot_id: prepared?,
cache: spec.cache,
max_output: spec.max_output,
infra_retries: spec.infra_retries,
n: AtomicU32::new(0),
disposed: AtomicBool::new(false),
})
}
fn parse_owner_pid(name: &str) -> Option<u32> {
let rest = name.strip_prefix("ci-base-")?;
let mut it = rest.split('-');
it.next()?; it.next()?.parse().ok()
}
fn pid_alive(pid: u32) -> Option<bool> {
if !std::path::Path::new("/proc").is_dir() {
return None;
}
Some(std::path::Path::new(&format!("/proc/{pid}")).exists())
}
fn orphan_pid(name: &str, me: u32) -> Option<u32> {
let pid = parse_owner_pid(name)?;
if pid == me {
return None; }
match pid_alive(pid) {
Some(false) => Some(pid), _ => None, }
}
pub fn sweep_orphans() -> Vec<String> {
let me = std::process::id();
let mut removed = Vec::new();
if let Ok(out) = box_run(&["ps", "-a", "--format", "{{.Names}}"]) {
for name in String::from_utf8_lossy(&out.stdout).lines() {
let name = name.trim();
if orphan_pid(name, me).is_some() {
box_cleanup(&["rm", "-f", name]);
removed.push(name.to_string());
}
}
}
if let Ok(out) = box_run(&["snapshot", "ls"]) {
let text = String::from_utf8_lossy(&out.stdout);
for line in text.lines() {
let mut cols = line.split_whitespace();
if let (Some(id), Some(name)) = (cols.next(), cols.next()) {
if orphan_pid(name, me).is_some() {
box_cleanup(&["snapshot", "rm", "--force", id]);
removed.push(name.to_string());
}
}
}
}
removed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_is_deterministic_and_order_sensitive() {
assert_eq!(key(&["a", "b"]), key(&["a", "b"]));
assert_ne!(key(&["a", "b"]), key(&["b", "a"]));
assert_ne!(key(&["ab", "c"]), key(&["a", "bc"]));
}
#[test]
fn instance_token_is_unique_per_call() {
assert_ne!(instance_token(), instance_token());
}
#[test]
fn with_env_escapes_and_prefixes() {
let mut e = BTreeMap::new();
assert_eq!(with_env("run", &e), "run");
e.insert("A".to_string(), "x y".to_string());
assert_eq!(with_env("run", &e), "export A='x y'; run");
let mut q = BTreeMap::new();
q.insert("Q".to_string(), "a'b".to_string());
assert_eq!(with_env("run", &q), "export Q='a'\\''b'; run");
}
#[test]
fn slug_sanitizes() {
assert_eq!(slug("a/b c"), "a-b-c");
}
#[test]
fn cap_output_truncates_on_char_boundary() {
assert_eq!(cap_output("hello".into(), 10), "hello"); let capped = cap_output("a".repeat(100), 10);
assert!(capped.starts_with("aaaaaaaaaa"));
assert!(capped.contains("[..truncated 90 bytes]"));
let c = cap_output("é".repeat(10), 5); assert!(std::str::from_utf8(c.as_bytes()).is_ok());
assert!(c.contains("truncated"));
}
#[test]
fn parse_metrics_extracts_finite_numeric_only() {
let m = parse_metrics(
"noise\n::metric perf_ms=84.2\n ::metric tests=412 \n::metric bad=xyz\n\
::metric inf_m=inf\n::metric nan_m=NaN\ntrailing",
);
assert_eq!(m.get("perf_ms"), Some(&84.2));
assert_eq!(m.get("tests"), Some(&412.0));
assert!(!m.contains_key("bad")); assert!(!m.contains_key("inf_m")); assert!(!m.contains_key("nan_m"));
assert_eq!(m.len(), 2);
}
#[test]
fn json_str_escapes_quotes_backslash_control() {
assert_eq!(json_str("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\"");
assert_eq!(json_str("\u{0001}"), "\"\\u0001\"");
assert_eq!(json_num(1.5), "1.5");
assert_eq!(json_num(f64::NAN), "null");
}
#[test]
fn report_and_step_json_shape() {
let mut metrics = BTreeMap::new();
metrics.insert("k".to_string(), 2.0);
let rep = Report {
passed: false,
total_ms: 5,
steps: vec![StepResult {
name: "t".into(),
exit_code: 1,
stdout: "o".into(),
stderr: "e\"x".into(),
cached: false,
allow_failure: false,
duration_ms: 3,
metrics,
}],
};
let j = rep.to_json();
assert!(j.contains("\"passed\":false"));
assert!(j.contains("\"total_ms\":5"));
assert!(j.contains("\"name\":\"t\""));
assert!(j.contains("\"exit_code\":1"));
assert!(j.contains("\"metrics\":{\"k\":2}"));
assert!(j.contains("\"stderr\":\"e\\\"x\"")); assert_eq!(rep.failures().len(), 1);
}
#[test]
fn step_result_ok_respects_allow_failure() {
let mk = |code: i32, allow: bool| StepResult {
name: "s".into(),
exit_code: code,
stdout: String::new(),
stderr: String::new(),
cached: false,
allow_failure: allow,
duration_ms: 0,
metrics: BTreeMap::new(),
};
assert!(mk(0, false).ok());
assert!(!mk(1, false).ok());
assert!(mk(1, true).ok()); assert!(!mk(INFRA_FAILURE, false).ok());
}
#[test]
fn file_cache_roundtrip() {
let dir = std::env::temp_dir().join(format!("a3s-ci-test-{}", key(&["cache"])));
let _ = std::fs::remove_dir_all(&dir);
let cache = FileCache::new(&dir).unwrap();
let k = key(&["x"]);
assert!(!cache.has(&k));
cache.put(&k);
assert!(cache.has(&k));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn file_cache_put_recreates_missing_dir() {
let dir = std::env::temp_dir().join(format!("a3s-ci-test-{}", key(&["recreate"])));
let cache = FileCache::new(&dir).unwrap();
let k = key(&["y"]);
let _ = std::fs::remove_dir_all(&dir);
assert!(!cache.has(&k));
cache.put(&k);
assert!(
cache.has(&k),
"put must recreate the dir and write the marker"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn parse_snapshot_id_finds_by_name() {
let ls = "SNAPSHOT ID NAME SOURCE BOX\n\
snap-111 ci-base-aaa-snap box-1\n\
snap-222 ci-base-bbb-snap box-2\n";
assert_eq!(
parse_snapshot_id(ls, "ci-base-bbb-snap").as_deref(),
Some("snap-222")
);
assert_eq!(parse_snapshot_id(ls, "nope"), None);
assert_eq!(parse_snapshot_id("", "x"), None);
assert_eq!(
parse_snapshot_id("lonely\n\nsnap-9 found x\n", "found").as_deref(),
Some("snap-9")
);
}
#[test]
fn parse_owner_pid_and_orphan_detection() {
assert_eq!(parse_owner_pid("ci-base-deadbeef-4242-0-snap"), Some(4242));
assert_eq!(
parse_owner_pid("ci-base-deadbeef-4242-7-snap-job3-make"),
Some(4242)
);
assert_eq!(parse_owner_pid("other-box"), None);
assert_eq!(parse_owner_pid("ci-base-onlykey"), None);
let me = std::process::id();
let mine = format!("ci-base-deadbeef-{me}-0-snap");
assert_eq!(orphan_pid(&mine, me), None);
if pid_alive(4_000_000_000).is_some() {
assert_eq!(
orphan_pid("ci-base-deadbeef-4000000000-0-snap", me),
Some(4_000_000_000)
);
} else {
assert_eq!(orphan_pid("ci-base-deadbeef-4000000000-0-snap", me), None);
}
}
#[test]
fn ci_error_display_covers_each_variant() {
let io = PipelineError::from(std::io::Error::other("boom"));
assert!(format!("{io}").contains("boom"));
let cli = PipelineError::Cli {
args: "snapshot rm s".into(),
code: Some(1),
stderr: "still used".into(),
};
assert!(format!("{cli}").contains("still used"));
let step = PipelineError::StepFailed {
name: "test".into(),
code: 7,
logs: "oops".into(),
};
let s = format!("{step}");
assert!(s.contains("test") && s.contains("exit 7"));
assert!(format!("{}", PipelineError::NotReady("b1".into())).contains("b1"));
}
#[cfg(unix)]
const FAKE_BOX: &str = r#"#!/bin/sh
state="$(dirname "$0")/.snaps"
case "$1" in
run|start|stop|rm) exit 0 ;;
exec) last=""; for a in "$@"; do last="$a"; done
case "$last" in
*emitmetric*) printf '::metric score=9\n'; exit 0 ;;
*boom*) exit 7 ;;
*) exit 0 ;;
esac ;;
snapshot)
case "$2" in
create) name=""; while [ "$#" -gt 0 ]; do [ "$1" = "--name" ] && name="$2"; shift; done
printf '%s %s\n' "snap-fake1" "$name" >> "$state"; printf 'snap-fake1\n'; exit 0 ;;
restore)
for a in "$@"; do
case "$a" in
*infrafail*) exit 1 ;;
*flaky*) c="$(dirname "$0")/.flaky"; n=$(cat "$c" 2>/dev/null || echo 0); n=$((n+1)); echo "$n" > "$c"; [ "$n" -le 2 ] && exit 1 || exit 0 ;;
esac
done
exit 0 ;;
ls) printf 'ID NAME SRC\n'; cat "$state" 2>/dev/null; exit 0 ;;
*) exit 0 ;;
esac ;;
*) exit 0 ;;
esac
"#;
#[cfg(unix)]
#[test]
fn orchestration_drives_the_cli() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("a3s-ci-fakebox-{}", key(&["fakebox"])));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let fake = dir.join("a3s-box");
std::fs::write(&fake, FAKE_BOX).unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
std::env::set_var("A3S_BOX", &fake);
let cache = FileCache::new(dir.join("cache")).unwrap();
let base = warm_base(WarmBase::new("img", "echo hi").cache(&cache)).expect("warm_base");
let r = base.step(Step::new("build", "make")).expect("step");
assert_eq!(r.exit_code, 0);
assert!(!r.cached);
assert!(base.step(Step::new("build", "make")).expect("step2").cached); assert!(matches!(
base.step(Step::new("fail", "boom")),
Err(PipelineError::StepFailed { code: 7, .. })
));
let allowed = base
.step(Step::new("maybe", "boom").allow_failure())
.expect("allow_failure step");
assert_eq!(allowed.exit_code, 7);
let rep = base.run_parallel(
vec![
Step::new("a", "make"),
Step::new("b", "boom"), Step::new("m", "emitmetric"), ],
2,
);
assert_eq!(rep.steps.len(), 3);
assert_eq!(rep.steps[0].name, "a"); assert_eq!(rep.steps[1].exit_code, 7);
assert_eq!(rep.steps[2].metrics.get("score"), Some(&9.0));
assert!(!rep.passed);
assert_eq!(rep.failures().len(), 1);
assert!(rep.to_json().contains("\"score\":9"));
let rep2 = base.run_parallel(
vec![
Step::new("ok", "make"),
Step::new("soft", "boom").allow_failure(),
],
2,
);
assert!(rep2.passed, "allow_failure step must not fail the batch");
let rep3 = base.run_parallel(vec![Step::new("infrafail", "make")], 1);
assert_eq!(rep3.steps[0].exit_code, INFRA_FAILURE);
assert!(!rep3.steps[0].stderr.is_empty());
assert!(!rep3.passed);
let _ = std::fs::remove_file(dir.join(".flaky"));
let okr = base.run_parallel(vec![Step::new("flaky", "make")], 1);
assert_eq!(
okr.steps[0].exit_code, 0,
"transient infra failure should be retried to success"
);
assert!(okr.passed);
assert!(base.run_parallel(Vec::new(), 4).passed);
let b1 = warm_base(WarmBase::new("u", "echo").cache(&cache)).expect("b1");
let b2 = warm_base(WarmBase::new("u", "echo").cache(&cache)).expect("b2");
assert_ne!(
b1.snapshot_name, b2.snapshot_name,
"same spec must yield disjoint snapshot names"
);
b1.dispose();
b2.dispose();
base.dispose();
base.dispose();
let nocache = warm_base(WarmBase::new("img", "echo hi")).expect("warm_base nocache");
assert!(
!nocache
.step(Step::new("x", "make"))
.expect("nocache step")
.cached
);
nocache.dispose();
std::env::set_var("A3S_BOX", "/nonexistent/a3s-box-xyzzy");
assert!(matches!(box_run(&["version"]), Err(PipelineError::Io(_))));
box_cleanup(&["rm", "-f", "x"]); assert!(snapshot_id("any").is_err());
assert!(warm_base(WarmBase::new("img", "echo hi")).is_err());
assert!(matches!(
wait_ready_inner("b", std::time::Duration::from_millis(0)),
Err(PipelineError::NotReady(_))
));
std::env::remove_var("A3S_BOX");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn builders_chain_all_options() {
let c = FileCache::new(std::env::temp_dir().join(format!("a3s-ci-bld-{}", key(&["bld"]))))
.unwrap();
let _ = WarmBase::new("img", "setup")
.env("A", "1")
.cache(&c)
.max_output(4096)
.infra_retries(1);
let _ = Step::new("n", "cmd")
.input("x")
.env("K", "V")
.allow_failure();
}
}