use std::collections::BTreeMap;
use std::fmt;
use std::path::PathBuf;
use std::process::{Command, Output};
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();
}
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 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>,
}
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,
}
}
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 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 logs: String,
pub cached: bool,
}
pub struct Base<'a> {
snapshot_name: String,
snapshot_id: String,
cache: Option<&'a FileCache>,
n: u32,
}
impl Base<'_> {
pub fn step(&mut 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,
exit_code: 0,
logs: String::new(),
cached: true,
});
}
}
self.n += 1;
let box_name = format!("{}-job{}-{}", self.snapshot_name, self.n, slug(&step.name));
box_cleanup(&["rm", "-f", &box_name]);
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 out = Command::new(box_bin())
.args(["exec", &box_name, "--", "sh", "-c", &full])
.output()?;
box_cleanup(&["rm", "-f", &box_name]);
let code = out.status.code().unwrap_or(-1);
let mut logs = String::from_utf8_lossy(&out.stdout).into_owned();
logs.push_str(&String::from_utf8_lossy(&out.stderr));
if code == 0 {
if let Some(c) = self.cache {
c.put(&k);
}
}
if code != 0 && !step.allow_failure {
return Err(PipelineError::StepFailed {
name: step.name,
code,
logs,
});
}
Ok(StepResult {
name: step.name,
exit_code: code,
logs,
cached: false,
})
}
pub fn dispose(&self) {
box_cleanup(&["snapshot", "rm", &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
}
pub fn warm_base(spec: WarmBase<'_>) -> Result<Base<'_>> {
let base_box = format!("ci-base-{}", key(&[&spec.image, &spec.setup]));
let snap = format!("{base_box}-snap");
box_cleanup(&["rm", "-f", &base_box]); if let Ok(old) = snapshot_id(&snap) {
box_cleanup(&["snapshot", "rm", &old]);
}
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(&["snapshot", "create", &base_box, "--name", &snap])?;
snapshot_id(&snap)
})();
box_cleanup(&["rm", "-f", &base_box]);
Ok(Base {
snapshot_name: snap,
snapshot_id: prepared?,
cache: spec.cache,
n: 0,
})
}
#[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 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 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 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|rm) exit 0 ;;
exec) last=""; for a in "$@"; do last="$a"; done
case "$last" in *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 ;;
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 mut 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, .. })
));
assert!(matches!(
box_run(&["exec", "x", "--", "boom"]),
Err(PipelineError::Cli { code: Some(7), .. })
));
let allowed = base
.step(Step::new("maybe", "boom").allow_failure())
.expect("allow_failure step");
assert_eq!(allowed.exit_code, 7);
assert!(!allowed.cached);
let _ = warm_base(WarmBase::new("img", "echo hi").cache(&cache)).expect("warm_base #2");
base.dispose();
let mut 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);
let _ = Step::new("n", "cmd")
.input("x")
.env("K", "V")
.allow_failure();
}
}