#![cfg(feature = "docker-tests")]
use std::process::Command;
use std::time::Duration;
use consortium::dag::*;
fn ssh_opts() -> &'static str {
"-oStrictHostKeyChecking=no -oUserKnownHostsFile=/dev/null -oPasswordAuthentication=no -oConnectTimeout=10"
}
fn ssh_key() -> String {
let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
let key = std::path::PathBuf::from(manifest)
.parent()
.unwrap()
.parent()
.unwrap()
.join("tests/docker/ssh/id_ed25519");
key.to_string_lossy().to_string()
}
fn docker_dir() -> std::path::PathBuf {
let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
std::path::PathBuf::from(manifest)
.parent()
.unwrap()
.parent()
.unwrap()
.join("tests/docker")
}
fn start_container(name: &str, image: &str, port: u16) -> bool {
let _ = Command::new("docker").args(["rm", "-f", name]).output();
let auth_keys = docker_dir().join("ssh/authorized_keys");
let output = Command::new("docker")
.args([
"run",
"-d",
"--name",
name,
"-p",
&format!("{}:22", port),
"-v",
&format!("{}:/root/.ssh/authorized_keys:ro", auth_keys.display()),
image,
])
.output();
match output {
Ok(o) if o.status.success() => true,
Ok(o) => {
eprintln!(
"Failed to start {}: {}",
name,
String::from_utf8_lossy(&o.stderr)
);
false
}
Err(e) => {
eprintln!("Failed to start {}: {}", name, e);
false
}
}
}
fn wait_ssh(port: u16, timeout_secs: u64) -> bool {
let key = ssh_key();
let start = std::time::Instant::now();
while start.elapsed() < Duration::from_secs(timeout_secs) {
let result = Command::new("ssh")
.args([
"-oStrictHostKeyChecking=no",
"-oUserKnownHostsFile=/dev/null",
"-oPasswordAuthentication=no",
"-oConnectTimeout=1",
"-oBatchMode=yes",
"-i",
&key,
"-p",
&port.to_string(),
"root@127.0.0.1",
"true",
])
.output();
if let Ok(o) = result {
if o.status.success() {
return true;
}
}
std::thread::sleep(Duration::from_millis(500));
}
false
}
fn ssh_run(port: u16, cmd: &str) -> (bool, String, String) {
let key = ssh_key();
let output = Command::new("ssh")
.args([
"-oStrictHostKeyChecking=no",
"-oUserKnownHostsFile=/dev/null",
"-oPasswordAuthentication=no",
"-oConnectTimeout=10",
"-i",
&key,
"-p",
&port.to_string(),
"root@127.0.0.1",
cmd,
])
.output()
.expect("ssh failed to execute");
(
output.status.success(),
String::from_utf8_lossy(&output.stdout).trim().to_string(),
String::from_utf8_lossy(&output.stderr).trim().to_string(),
)
}
fn stop_container(name: &str) {
let _ = Command::new("docker").args(["rm", "-f", name]).output();
}
#[test]
fn test_nix_build_real_derivation() {
let port = 2501;
assert!(start_container(
"nix-int-build",
"consortium-nix-node",
port
));
assert!(wait_ssh(port, 30), "nix container SSH not ready");
let (ok, stdout, stderr) = ssh_run(
port,
"cd /test-flake && nix build .#test-derivation --no-link --print-out-paths",
);
assert!(ok, "nix build failed: {}", stderr);
assert!(
stdout.contains("/nix/store/"),
"expected store path, got: {}",
stdout
);
let (ok2, content, _) = ssh_run(port, &format!("cat {}", stdout));
assert!(ok2);
assert_eq!(content, "built-by-consortium");
stop_container("nix-int-build");
}
#[test]
fn test_nix_eval_flake_attr() {
let port = 2502;
assert!(start_container("nix-int-eval", "consortium-nix-node", port));
assert!(wait_ssh(port, 30));
let (ok, stdout, stderr) = ssh_run(
port,
"cd /test-flake && nix eval .#packages.x86_64-linux.test-derivation.outPath --raw",
);
assert!(ok, "nix eval failed: {}", stderr);
assert!(
stdout.contains("/nix/store/"),
"expected store path, got: {}",
stdout
);
stop_container("nix-int-eval");
}
#[test]
fn test_nix_build_via_dag_executor() {
let port = 2503;
assert!(start_container("nix-int-dag", "consortium-nix-node", port));
assert!(wait_ssh(port, 30));
let key = ssh_key();
let mut dag = DagBuilder::new();
let _k1 = key.clone();
dag.add_task(
"eval",
FnTask::new("eval flake", move |ctx| {
let (ok, stdout, stderr) = ssh_run(
2503,
"cd /test-flake && nix eval .#packages.x86_64-linux.test-derivation.outPath --raw",
);
if !ok {
return TaskOutcome::Failed(format!("eval failed: {}", stderr));
}
ctx.set_output(TaskId::from("eval"), stdout);
TaskOutcome::Success
}),
);
dag.add_task(
"build",
FnTask::new("build derivation", move |ctx| {
let eval_path: String = match ctx.get_output(&TaskId::from("eval")) {
Some(p) => p,
None => return TaskOutcome::Failed("no eval output".into()),
};
let (ok, stdout, stderr) = ssh_run(
2503,
"cd /test-flake && nix build .#test-derivation --no-link --print-out-paths",
);
if !ok {
return TaskOutcome::Failed(format!("build failed: {}", stderr));
}
assert_eq!(
stdout.trim(),
eval_path,
"eval and build paths should match"
);
ctx.set_output(TaskId::from("build"), stdout);
TaskOutcome::Success
}),
);
dag.add_task(
"verify",
FnTask::new("verify output", move |ctx| {
let build_path: String = match ctx.get_output(&TaskId::from("build")) {
Some(p) => p,
None => return TaskOutcome::Failed("no build output".into()),
};
let (ok, content, _) = ssh_run(2503, &format!("cat {}", build_path));
if !ok || content != "built-by-consortium" {
return TaskOutcome::Failed(format!("verify failed: got '{}'", content));
}
TaskOutcome::Success
}),
);
dag.add_dep("build", "eval");
dag.add_dep("verify", "build");
let report = dag.build().unwrap().run().unwrap();
assert!(report.is_success(), "nix DAG failed: {:?}", report.failed);
assert_eq!(report.completed.len(), 3);
stop_container("nix-int-dag");
}
#[test]
fn test_nix_cache_hit_skips_rebuild() {
let port = 2504;
assert!(start_container(
"nix-int-cache",
"consortium-nix-node",
port
));
assert!(wait_ssh(port, 30));
let (ok, path1, _) = ssh_run(
port,
"cd /test-flake && nix build .#test-derivation --no-link --print-out-paths",
);
assert!(ok);
let start = std::time::Instant::now();
let (ok2, path2, _) = ssh_run(
port,
"cd /test-flake && nix build .#test-derivation --no-link --print-out-paths",
);
let elapsed = start.elapsed();
assert!(ok2);
assert_eq!(path1, path2, "cached build should produce same path");
assert!(
elapsed < Duration::from_secs(5),
"cached build took {:?}, expected near-instant",
elapsed
);
stop_container("nix-int-cache");
}
#[test]
fn test_ansible_ping_module() {
let ctrl_port = 2510;
let target_port = 2511;
assert!(start_container(
"ansible-int-ctrl",
"consortium-ansible-node",
ctrl_port
));
assert!(start_container(
"ansible-int-target",
"consortium-ssh-node",
target_port
));
assert!(wait_ssh(ctrl_port, 30));
assert!(wait_ssh(target_port, 30));
let _key = ssh_key();
let (_ok, _stdout, _stderr) = ssh_run(
ctrl_port,
&format!(
"ansible -i '127.0.0.1:{},' -m ping all \
--ssh-extra-args='-oStrictHostKeyChecking=no -oUserKnownHostsFile=/dev/null' \
-u root 2>&1 || true",
target_port
),
);
let (ok2, stdout2, stderr2) = ssh_run(ctrl_port, "ansible --version");
assert!(ok2, "ansible not available: {}", stderr2);
assert!(
stdout2.contains("ansible"),
"expected ansible version, got: {}",
stdout2
);
let (ok3, stdout3, stderr3) =
ssh_run(ctrl_port, "ansible -i 'localhost,' -c local -m ping all");
assert!(
ok3,
"ansible ping localhost failed: {} {}",
stdout3, stderr3
);
assert!(
stdout3.contains("SUCCESS") || stdout3.contains("pong"),
"expected SUCCESS/pong, got: {}",
stdout3
);
stop_container("ansible-int-ctrl");
stop_container("ansible-int-target");
}
#[test]
fn test_ansible_playbook_via_dag() {
let port = 2512;
assert!(start_container(
"ansible-int-playbook",
"consortium-ansible-node",
port
));
assert!(wait_ssh(port, 30));
let (ok, _, stderr) = ssh_run(
port,
r#"cat > /tmp/test-playbook.yml << 'YAML'
---
- hosts: localhost
connection: local
gather_facts: no
tasks:
- name: Create test file
copy:
content: "deployed-by-consortium"
dest: /tmp/consortium-test-output
- name: Verify file
command: cat /tmp/consortium-test-output
register: result
- name: Assert content
assert:
that: result.stdout == "deployed-by-consortium"
YAML"#,
);
assert!(ok, "failed to create playbook: {}", stderr);
let mut dag = DagBuilder::new();
dag.add_task(
"run-playbook",
FnTask::new("run ansible playbook", |_ctx| {
let (ok, stdout, stderr) =
ssh_run(2512, "ansible-playbook /tmp/test-playbook.yml 2>&1");
if ok {
TaskOutcome::Success
} else {
TaskOutcome::Failed(format!("playbook failed: {} {}", stdout, stderr))
}
}),
);
dag.add_task(
"verify",
FnTask::new("verify playbook result", |_ctx| {
let (ok, content, _) = ssh_run(2512, "cat /tmp/consortium-test-output");
if ok && content == "deployed-by-consortium" {
TaskOutcome::Success
} else {
TaskOutcome::Failed(format!("verify failed: got '{}'", content))
}
}),
);
dag.add_dep("verify", "run-playbook");
let report = dag.build().unwrap().run().unwrap();
assert!(
report.is_success(),
"ansible DAG failed: {:?}",
report.failed
);
stop_container("ansible-int-playbook");
}
#[test]
fn test_ansible_playbook_failure_propagation() {
let port = 2513;
assert!(start_container(
"ansible-int-fail",
"consortium-ansible-node",
port
));
assert!(wait_ssh(port, 30));
let (ok, _, _) = ssh_run(
port,
r#"cat > /tmp/bad-playbook.yml << 'YAML'
---
- hosts: localhost
connection: local
gather_facts: no
tasks:
- name: This will fail
command: /nonexistent/command
YAML"#,
);
assert!(ok);
let mut dag = DagBuilder::new();
dag.add_task(
"bad-playbook",
FnTask::new("run bad playbook", |_ctx| {
let (ok, stdout, _stderr) =
ssh_run(2513, "ansible-playbook /tmp/bad-playbook.yml 2>&1");
if ok {
TaskOutcome::Success
} else {
TaskOutcome::Failed(format!("playbook failed as expected: {}", stdout))
}
}),
);
dag.add_task(
"should-be-cancelled",
FnTask::new("dependent task", |_ctx| TaskOutcome::Success),
);
dag.add_dep("should-be-cancelled", "bad-playbook");
dag.error_policy(ErrorPolicy::ContinueIndependent);
let report = dag.build().unwrap().run().unwrap();
assert!(!report.is_success());
assert!(report.failed.contains_key(&TaskId::from("bad-playbook")));
assert!(report
.cancelled
.contains(&TaskId::from("should-be-cancelled")));
stop_container("ansible-int-fail");
}
#[test]
fn test_parallel_nix_builds_across_containers() {
let ports = [2520, 2521, 2522];
let names: Vec<String> = (0..3).map(|i| format!("nix-int-parallel-{}", i)).collect();
for (i, name) in names.iter().enumerate() {
assert!(
start_container(name, "consortium-nix-node", ports[i]),
"failed to start {}",
name
);
}
for port in &ports {
assert!(wait_ssh(*port, 30), "SSH not ready on port {}", port);
}
let start = std::time::Instant::now();
let mut dag = DagBuilder::new();
for (i, port) in ports.iter().enumerate() {
let p = *port;
dag.add_task(
format!("build-{}", i),
FnTask::new(format!("build on container {}", i), move |ctx| {
let (ok, stdout, stderr) = ssh_run(
p,
"cd /test-flake && nix build .#test-derivation --no-link --print-out-paths",
);
if ok {
ctx.set_output(TaskId(format!("build-{}", p)), stdout);
TaskOutcome::Success
} else {
TaskOutcome::Failed(format!("build failed: {}", stderr))
}
}),
);
}
let report = dag.build().unwrap().run().unwrap();
let elapsed = start.elapsed();
assert!(
report.is_success(),
"parallel builds failed: {:?}",
report.failed
);
assert_eq!(report.completed.len(), 3);
eprintln!("3 parallel nix builds completed in {:?}", elapsed);
for name in &names {
stop_container(name);
}
}
#[test]
fn test_dag_executor_nix_pipeline_with_cache() {
let port = 2525;
assert!(start_container(
"nix-int-pipeline",
"consortium-nix-node",
port
));
assert!(wait_ssh(port, 30));
struct NixPathExistsCache {
port: u16,
}
impl CacheStrategy for NixPathExistsCache {
fn check(
&self,
task_id: &TaskId,
ctx: &DagContext,
) -> Option<Box<dyn std::any::Any + Send>> {
if !task_id.0.starts_with("build:") {
return None;
}
let eval_id = TaskId(task_id.0.replace("build:", "eval:"));
let store_path: String = ctx.get_output(&eval_id)?;
let (ok, _, _) = ssh_run(self.port, &format!("test -e {}", store_path));
if ok {
Some(Box::new(store_path))
} else {
None
}
}
}
let ctx = DagContext::new();
let mut dag = DagBuilder::new();
dag.add_task(
"eval:test",
FnTask::new("eval", |ctx| {
let (ok, stdout, _) = ssh_run(
2525,
"cd /test-flake && nix eval .#packages.x86_64-linux.test-derivation.outPath --raw",
);
if ok {
ctx.set_output(TaskId::from("eval:test"), stdout);
TaskOutcome::Success
} else {
TaskOutcome::Failed("eval failed".into())
}
}),
);
dag.add_task(
"build:test",
FnTask::new("build", |ctx| {
let (ok, stdout, _) = ssh_run(
2525,
"cd /test-flake && nix build .#test-derivation --no-link --print-out-paths",
);
if ok {
ctx.set_output(TaskId::from("build:test"), stdout);
TaskOutcome::Success
} else {
TaskOutcome::Failed("build failed".into())
}
}),
);
dag.add_dep("build:test", "eval:test");
dag.cache(NixPathExistsCache { port });
dag.context(ctx);
let report = dag.build().unwrap().run().unwrap();
assert!(report.is_success());
assert!(report.completed.contains(&TaskId::from("build:test")));
assert_eq!(
report.skipped.len(),
0,
"first run should not skip anything"
);
stop_container("nix-int-pipeline");
}