ref:54545c135e3d6eedc3c13d240fa08dea27f86d42

feat: integrate CI runner into single binary

Ports the full anvil-runner functionality from Elixir/Burrito into Rust: Runner execution: - anvil runner configure — register with server via registration token - anvil runner start — polling loop with exponential backoff (5s-30s) - anvil runner unconfigure — deregister and remove config - anvil runner status — show runner config Job execution: - Docker container execution with workspace mounting - Bare execution fallback (host shell) - Streaming log reporter with buffering (100 lines) and 1s flush timer - Log upload retry (2 retries, 500ms backoff) - Job timeout support (default 600s) Infrastructure: - Service sidecar lifecycle (Docker network, health checks, cleanup) - Health checks for postgres, redis, mysql, mongo - Well-known port detection for 8 service types - Stateful workspaces (no git clean — preserves build caches) - Heartbeat loop (30s interval) - Graceful shutdown with 30s grace period (ctrl+c) - --once and --ephemeral modes - Workspace cleanup policies (always/never/on-success) OS service integration: - macOS: launchd plist (~/Library/LaunchAgents/) - Linux: systemd unit (user or system) - install/uninstall/start/stop/status commands Admin commands (PAT auth) preserved: list/view/update/remove/token Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SHA: 54545c135e3d6eedc3c13d240fa08dea27f86d42
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-03-11 02:26
Parents: c62e1fb
13 files changed +1728 -12
Type
Cargo.lock +69 −0
@@ -70,6 +70,9 @@
"colored",
"dialoguer",
"dirs",
"futures",
"hostname",
"libc",
"reqwest",
"serde",
"serde_json",
@@ -335,6 +338,21 @@
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
@@ -344,6 +362,7 @@
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -353,6 +372,40 @@
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -364,8 +417,13 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
@@ -436,6 +494,17 @@
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hostname"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
dependencies = [
"cfg-if",
"libc",
"windows-link",
]
[[package]]
name = "http"
Cargo.toml +4 −1
@@ -2,7 +2,7 @@
name = "anvil-cli"
version = "0.1.0"
edition = "2021"
description = "CLI for Anvil — a self-hosted code forge"
description = "CLI and CI runner for Anvil — a self-hosted code forge"
license = "MIT"
[[bin]]
@@ -22,3 +22,6 @@
tabled = "0.17"
url = "2"
thiserror = "2"
hostname = "0.4"
libc = "0.2"
futures = "0.3"
src/commands/mod.rs +3 −1
@@ -57,6 +57,8 @@
Command::Release(args) => release::run(args).await,
Command::Deploy(args) => deploy::run(args).await,
Command::Agent(args) => agent::run(args).await,
Command::Runner(args) => runner::run(args).await,
Command::Runner(args) => runner::run(args)
.await
.map_err(|e| -> Box<dyn std::error::Error> { e }),
}
}
src/commands/runner.rs +509 −10
@@ -1,6 +1,8 @@
use crate::client::Client;
use crate::output;
use crate::runner::{self, RunnerConfig};
use clap::{Args, Subcommand};
use reqwest::header::{HeaderValue, AUTHORIZATION, CONTENT_TYPE};
#[derive(Args)]
pub struct RunnerArgs {
@@ -10,6 +12,62 @@
#[derive(Subcommand)]
pub enum RunnerCommand {
// === Runner execution commands ===
/// Register this machine as a CI runner
Configure {
/// Anvil server URL
#[arg(long)]
url: String,
/// Registration token (or set ANVIL_RUNNER_TOKEN env var)
#[arg(long)]
token: Option<String>,
/// Runner name (defaults to hostname)
#[arg(long)]
name: Option<String>,
/// Labels (comma-separated)
#[arg(long, default_value = "self-hosted")]
labels: String,
/// Working directory for job workspaces
#[arg(long)]
work_dir: Option<String>,
/// Max concurrent jobs
#[arg(long, default_value = "1")]
parallel: u32,
/// Config file path
#[arg(long)]
config: Option<String>,
},
/// Start polling for and executing CI jobs
Start {
/// Run one job then exit
#[arg(long)]
once: bool,
/// Deregister after one job
#[arg(long)]
ephemeral: bool,
/// Cleanup strategy: always, never, on-success
#[arg(long, default_value = "never")]
cleanup: String,
/// Config file path
#[arg(long)]
config: Option<String>,
},
/// Show runner configuration
Status {
/// Config file path
#[arg(long)]
config: Option<String>,
},
/// Deregister from server and remove config
Unconfigure {
/// Config file path
#[arg(long)]
config: Option<String>,
},
/// Manage OS service (systemd/launchd)
Service(ServiceArgs),
// === Admin commands (use PAT auth) ===
/// List runners for an org or repo
List {
/// Organization ID
@@ -19,12 +77,12 @@
#[arg(long)]
repo: Option<String>,
},
/// View runner details
/// View runner details (admin)
View {
/// Runner ID
id: String,
},
/// Update a runner
/// Update a runner (admin)
Update {
/// Runner ID
id: String,
@@ -35,7 +93,7 @@
#[arg(long)]
name: Option<String>,
},
/// Remove a runner
/// Remove a runner (admin)
Remove {
/// Runner ID
id: String,
@@ -51,8 +109,67 @@
},
}
pub async fn run(args: RunnerArgs) -> Result<(), Box<dyn std::error::Error>> {
#[derive(Args)]
pub struct ServiceArgs {
#[command(subcommand)]
pub command: ServiceCommand,
}
#[derive(Subcommand)]
pub enum ServiceCommand {
/// Install as a system service
Install {
/// Config file path
#[arg(long)]
config: Option<String>,
},
/// Uninstall the system service
Uninstall,
/// Start the service
#[command(name = "svc-start")]
Start,
/// Stop the service
#[command(name = "svc-stop")]
Stop,
/// Show service status
#[command(name = "svc-status")]
SvcStatus,
}
pub async fn run(args: RunnerArgs) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match args.command {
// Runner execution
RunnerCommand::Configure {
url,
token,
name,
labels,
work_dir,
parallel,
config,
} => {
configure(
&url,
token.as_deref(),
name.as_deref(),
&labels,
work_dir.as_deref(),
parallel,
config.as_deref(),
)
.await
}
RunnerCommand::Start {
once,
ephemeral,
cleanup,
config,
} => start(once, ephemeral, &cleanup, config.as_deref()).await,
RunnerCommand::Status { config } => status(config.as_deref()).await,
RunnerCommand::Unconfigure { config } => unconfigure(config.as_deref()).await,
RunnerCommand::Service(svc) => service(svc).await,
// Admin
RunnerCommand::List { org, repo } => list(org.as_deref(), repo.as_deref()).await,
RunnerCommand::View { id } => view(&id).await,
RunnerCommand::Update { id, labels, name } => {
@@ -61,9 +178,388 @@
RunnerCommand::Remove { id } => remove(&id).await,
RunnerCommand::Token { org, repo } => token(org.as_deref(), repo.as_deref()).await,
}
}
// === Runner execution commands ===
async fn configure(
url: &str,
token: Option<&str>,
name: Option<&str>,
labels: &str,
work_dir: Option<&str>,
parallel: u32,
config_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let token = match token {
Some(t) => t.to_string(),
None => std::env::var("ANVIL_RUNNER_TOKEN")
.map_err(|_| "no token provided — use --token or set ANVIL_RUNNER_TOKEN")?,
};
let runner_name = match name {
Some(n) => n.to_string(),
None => hostname::get()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_else(|_| "unnamed".to_string()),
};
let work_dir = work_dir.map(|s| s.to_string()).unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".anvil-runner")
.join("_work")
.to_string_lossy()
.to_string()
});
let os = std::env::consts::OS.to_string();
let arch = std::env::consts::ARCH.to_string();
// Register with server
let register_url = format!("{}/api/v1/runners/register", url.trim_end_matches('/'));
let client = reqwest::Client::new();
let resp = client
.post(&register_url)
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.json(&serde_json::json!({
"token": token,
"name": runner_name,
"labels": labels,
"os": os,
"arch": arch,
}))
.send()
.await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("registration failed: {text}").into());
}
let body: serde_json::Value = resp.json().await?;
let data = body.get("data").unwrap_or(&body);
let runner_id = data
.get("runner_id")
.or_else(|| data.get("id"))
.and_then(|v| v.as_str())
.ok_or("missing runner_id in response")?;
let runner_token = data
.get("runner_token")
.or_else(|| data.get("token"))
.and_then(|v| v.as_str())
.ok_or("missing runner_token in response")?;
let config = RunnerConfig {
server_url: url.to_string(),
runner_id: runner_id.to_string(),
runner_token: runner_token.to_string(),
name: runner_name.clone(),
labels: labels.split(',').map(|s| s.trim().to_string()).collect(),
work_dir,
parallel,
poll_interval_ms: 5000,
heartbeat_interval_ms: 30000,
once: false,
ephemeral: false,
cleanup: "never".to_string(),
};
config.save(config_path)?;
output::success(&format!("Registered runner '{runner_name}'"));
output::detail("ID", runner_id);
output::detail(
"Config",
&RunnerConfig::path(config_path).display().to_string(),
);
output::info("Start with: anvil runner start");
Ok(())
}
async fn start(
once: bool,
ephemeral: bool,
cleanup: &str,
config_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut config = RunnerConfig::load(config_path)?;
config.once = once;
config.ephemeral = ephemeral;
config.cleanup = cleanup.to_string();
runner::loop_runner::start(config).await
}
async fn status(config_path: Option<&str>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let config = RunnerConfig::load(config_path)?;
output::header("Runner Configuration");
output::detail("Server", &config.server_url);
output::detail("Runner ID", &config.runner_id);
output::detail("Name", &config.name);
output::detail("Labels", &config.labels.join(", "));
output::detail("Work dir", &config.work_dir);
output::detail("Parallel", &config.parallel.to_string());
output::detail("Poll interval", &format!("{}ms", config.poll_interval_ms));
output::detail(
"Heartbeat interval",
&format!("{}ms", config.heartbeat_interval_ms),
);
output::detail(
"Config",
&RunnerConfig::path(config_path).display().to_string(),
);
Ok(())
}
async fn unconfigure(
config_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let config = RunnerConfig::load(config_path)?;
// Deregister from server
let client = reqwest::Client::new();
let url = config.api_url(&format!("/runners/{}", config.runner_id));
let resp = client
.delete(&url)
.header(
AUTHORIZATION,
HeaderValue::from_str(&config.auth_header()).unwrap(),
)
.send()
.await;
match resp {
Ok(r) if r.status().is_success() => {
output::success("Deregistered from server");
}
Ok(r) => {
output::warn(&format!("Deregistration returned {}", r.status()));
}
Err(e) => {
output::warn(&format!("Could not reach server: {e}"));
}
}
// Remove config file
RunnerConfig::delete(config_path)?;
output::success("Config removed");
Ok(())
}
async fn service(args: ServiceArgs) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match args.command {
ServiceCommand::Install { config } => service_install(config.as_deref()),
ServiceCommand::Uninstall => service_uninstall(),
ServiceCommand::Start => service_cmd("start"),
ServiceCommand::Stop => service_cmd("stop"),
ServiceCommand::SvcStatus => service_cmd("status"),
}
}
fn service_install(
config_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let exe = std::env::current_exe()?;
let exe_str = exe.to_str().unwrap();
let config_file = RunnerConfig::path(config_path);
let config_str = config_file.to_str().unwrap();
if cfg!(target_os = "macos") {
// launchd plist
let plist_dir = dirs::home_dir().unwrap().join("Library/LaunchAgents");
std::fs::create_dir_all(&plist_dir)?;
let plist_path = plist_dir.join("com.anvil.runner.plist");
let log_dir = dirs::home_dir().unwrap().join(".anvil-runner");
std::fs::create_dir_all(&log_dir)?;
let plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.anvil.runner</string>
<key>ProgramArguments</key>
<array>
<string>{exe_str}</string>
<string>runner</string>
<string>start</string>
<string>--config</string>
<string>{config_str}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>{log_out}</string>
<key>StandardErrorPath</key>
<string>{log_err}</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>{home}</string>
</dict>
</dict>
</plist>"#,
log_out = log_dir.join("runner.log").display(),
log_err = log_dir.join("runner.err.log").display(),
home = dirs::home_dir().unwrap().display(),
);
std::fs::write(&plist_path, plist)?;
output::success(&format!(
"Installed launchd service: {}",
plist_path.display()
));
output::info("Start with: anvil runner service svc-start");
} else {
// systemd service
let is_root = unsafe { libc::getuid() } == 0;
let (unit_dir, user_flag) = if is_root {
("/etc/systemd/system".to_string(), "")
} else {
let dir = dirs::home_dir().unwrap().join(".config/systemd/user");
std::fs::create_dir_all(&dir)?;
(dir.to_string_lossy().to_string(), " --user")
};
let unit_path = format!("{unit_dir}/anvil-runner.service");
let unit = format!(
r#"[Unit]
Description=Anvil CI Runner
After=network.target
[Service]
Type=simple
ExecStart={exe_str} runner start --config {config_str}
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
"#,
);
std::fs::write(&unit_path, unit)?;
// Enable
let _ = std::process::Command::new("systemctl")
.args(if user_flag.is_empty() {
vec!["enable", "anvil-runner"]
} else {
vec!["--user", "enable", "anvil-runner"]
})
.output();
output::success(&format!("Installed systemd service: {unit_path}"));
output::info("Start with: anvil runner service svc-start");
}
Ok(())
}
fn service_uninstall() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if cfg!(target_os = "macos") {
let plist = dirs::home_dir()
.unwrap()
.join("Library/LaunchAgents/com.anvil.runner.plist");
let _ = std::process::Command::new("launchctl")
.args(["unload", plist.to_str().unwrap()])
.output();
if plist.exists() {
std::fs::remove_file(&plist)?;
}
output::success("Uninstalled launchd service");
} else {
let is_root = unsafe { libc::getuid() } == 0;
let args: Vec<&str> = if is_root {
vec!["disable", "anvil-runner"]
} else {
vec!["--user", "disable", "anvil-runner"]
};
let _ = std::process::Command::new("systemctl").args(&args).output();
let unit = if is_root {
"/etc/systemd/system/anvil-runner.service".to_string()
} else {
dirs::home_dir()
.unwrap()
.join(".config/systemd/user/anvil-runner.service")
.to_string_lossy()
.to_string()
};
if std::path::Path::new(&unit).exists() {
std::fs::remove_file(&unit)?;
}
output::success("Uninstalled systemd service");
}
Ok(())
}
fn service_cmd(action: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if cfg!(target_os = "macos") {
let plist = dirs::home_dir()
.unwrap()
.join("Library/LaunchAgents/com.anvil.runner.plist");
let launchctl_action = match action {
"start" => "load",
async fn list(org: Option<&str>, repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
"stop" => "unload",
"status" => {
let output = std::process::Command::new("launchctl")
.args(["list", "com.anvil.runner"])
.output()?;
println!("{}", String::from_utf8_lossy(&output.stdout));
return Ok(());
}
_ => return Err(format!("unknown action: {action}").into()),
};
let output = std::process::Command::new("launchctl")
.args([launchctl_action, plist.to_str().unwrap()])
.output()?;
if output.status.success() {
output::success(&format!("Service {action}ed"));
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("launchctl {launchctl_action} failed: {stderr}").into());
}
} else {
let is_root = unsafe { libc::getuid() } == 0;
let mut args: Vec<&str> = Vec::new();
if !is_root {
args.push("--user");
}
args.push(action);
args.push("anvil-runner");
let output = std::process::Command::new("systemctl")
.args(&args)
.output()?;
if action == "status" {
println!("{}", String::from_utf8_lossy(&output.stdout));
} else if output.status.success() {
output::success(&format!("Service {action}ed"));
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("systemctl {action} failed: {stderr}").into());
}
}
Ok(())
}
// === Admin commands (PAT auth) ===
async fn list(
org: Option<&str>,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = Client::from_config()?;
let path = match (org, repo) {
@@ -118,7 +614,7 @@
Ok(())
}
async fn view(id: &str) -> Result<(), Box<dyn std::error::Error>> {
async fn view(id: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = Client::from_config()?;
let resp: serde_json::Value = client.get(&format!("/runners/{id}")).await?;
@@ -156,7 +652,7 @@
id: &str,
labels: Option<&str>,
name: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = Client::from_config()?;
let mut body = serde_json::Map::new();
@@ -176,7 +672,7 @@
Ok(())
}
async fn remove(id: &str) -> Result<(), Box<dyn std::error::Error>> {
async fn remove(id: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = Client::from_config()?;
client.delete_empty(&format!("/runners/{id}")).await?;
@@ -186,7 +682,10 @@
Ok(())
}
async fn token(org: Option<&str>, repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
async fn token(
org: Option<&str>,
repo: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = Client::from_config()?;
let path = match (org, repo) {
@@ -205,7 +704,7 @@
output::success("Generated registration token:");
println!("\n {token}\n");
output::info("Use with: anvil-runner configure --token <token>");
output::info("Use with: anvil runner configure --url <URL> --token <token>");
Ok(())
}
src/main.rs +1 −0
@@ -2,6 +2,7 @@
mod commands;
mod config;
mod output;
mod runner;
use clap::Parser;
use commands::Cli;
src/runner/config.rs +89 −0
@@ -1,0 +1,89 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunnerConfig {
pub server_url: String,
pub runner_id: String,
pub runner_token: String,
pub name: String,
pub labels: Vec<String>,
pub work_dir: String,
pub parallel: u32,
#[serde(default = "default_poll_interval")]
pub poll_interval_ms: u64,
#[serde(default = "default_heartbeat_interval")]
pub heartbeat_interval_ms: u64,
#[serde(default)]
pub once: bool,
#[serde(default)]
pub ephemeral: bool,
#[serde(default = "default_cleanup")]
pub cleanup: String,
}
fn default_poll_interval() -> u64 {
5000
}
fn default_heartbeat_interval() -> u64 {
30000
}
fn default_cleanup() -> String {
"never".to_string()
}
impl RunnerConfig {
pub fn path(custom: Option<&str>) -> PathBuf {
if let Some(p) = custom {
return PathBuf::from(p);
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".anvil-runner")
.join("config.json")
}
pub fn load(path: Option<&str>) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let p = Self::path(path);
if !p.exists() {
return Err(format!(
"config not found at {} — run `anvil runner configure` first",
p.display()
)
.into());
}
let contents = std::fs::read_to_string(&p)?;
Ok(serde_json::from_str(&contents)?)
}
pub fn save(&self, path: Option<&str>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let p = Self::path(path);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(self)?;
std::fs::write(&p, contents)?;
Ok(())
}
pub fn delete(path: Option<&str>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let p = Self::path(path);
if p.exists() {
std::fs::remove_file(&p)?;
}
Ok(())
}
pub fn api_url(&self, path: &str) -> String {
let base = self.server_url.trim_end_matches('/');
format!("{base}/api/v1{path}")
}
pub fn auth_header(&self) -> String {
format!("Bearer {}", self.runner_token)
}
pub fn work_dir_path(&self) -> PathBuf {
PathBuf::from(&self.work_dir)
}
}
src/runner/executor.rs +186 −0
@@ -1,0 +1,186 @@
use crate::runner::log_reporter::LogReporter;
use std::collections::HashMap;
use std::path::Path;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
const DEFAULT_TIMEOUT_SECS: u64 = 600;
pub struct ExecResult {
pub exit_code: i32,
}
/// Execute a job command, streaming output to the log reporter.
pub async fn execute(
command: &str,
image: Option<&str>,
workspace: &Path,
env: &HashMap<String, String>,
network: Option<&str>,
timeout_seconds: Option<u64>,
log_reporter: &LogReporter,
) -> Result<ExecResult, Box<dyn std::error::Error + Send + Sync>> {
let timeout = timeout_seconds.unwrap_or(DEFAULT_TIMEOUT_SECS);
match image {
Some(img) => {
execute_docker(command, img, workspace, env, network, timeout, log_reporter).await
}
None => execute_bare(command, workspace, env, timeout, log_reporter).await,
}
}
async fn execute_docker(
command: &str,
image: &str,
workspace: &Path,
env: &HashMap<String, String>,
network: Option<&str>,
timeout: u64,
log_reporter: &LogReporter,
) -> Result<ExecResult, Box<dyn std::error::Error + Send + Sync>> {
let workspace_str = workspace.to_str().unwrap_or(".");
let mut args = vec![
"run".to_string(),
"--rm".into(),
"--add-host=host.docker.internal:host-gateway".into(),
];
if let Some(net) = network {
args.push("--network".into());
args.push(net.to_string());
}
// Environment variables
for (k, v) in env {
args.push("-e".into());
args.push(format!("{k}={v}"));
}
// Standard CI env vars
args.push("-e".into());
args.push("CI=true".into());
args.push("-e".into());
args.push("ANVIL_CI=true".into());
args.push("-e".into());
args.push("ANVIL_WORKSPACE=/workspace".into());
// Mount workspace
args.push("-v".into());
args.push(format!("{workspace_str}:/workspace"));
args.push("-w".into());
args.push("/workspace".into());
// Image and command
args.push(image.to_string());
args.push("/bin/sh".into());
args.push("-c".into());
args.push(command.to_string());
run_and_stream(
"docker",
&args,
workspace,
&HashMap::new(),
timeout,
log_reporter,
)
.await
}
async fn execute_bare(
command: &str,
workspace: &Path,
env: &HashMap<String, String>,
timeout: u64,
log_reporter: &LogReporter,
) -> Result<ExecResult, Box<dyn std::error::Error + Send + Sync>> {
let mut full_env = env.clone();
full_env.insert(
"ANVIL_WORKSPACE".into(),
workspace.to_str().unwrap_or(".").into(),
);
full_env.insert("CI".into(), "true".into());
full_env.insert("ANVIL_CI".into(), "true".into());
run_and_stream(
"/bin/sh",
&["-c".to_string(), command.to_string()],
workspace,
&full_env,
timeout,
log_reporter,
)
.await
}
async fn run_and_stream(
program: &str,
args: &[String],
cwd: &Path,
env: &HashMap<String, String>,
timeout: u64,
log_reporter: &LogReporter,
) -> Result<ExecResult, Box<dyn std::error::Error + Send + Sync>> {
let mut cmd = Command::new(program);
cmd.args(args)
.current_dir(cwd)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
for (k, v) in env {
cmd.env(k, v);
}
let mut child = cmd.spawn()?;
// Stream stdout and stderr concurrently via separate tasks
let reporter_out = log_reporter.clone();
let stdout = child.stdout.take();
let stdout_task = tokio::spawn(async move {
if let Some(stdout) = stdout {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
reporter_out.append(&line).await;
}
}
});
let reporter_err = log_reporter.clone();
let stderr = child.stderr.take();
let stderr_task = tokio::spawn(async move {
if let Some(stderr) = stderr {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
reporter_err.append(&line).await;
}
}
});
// Wait with timeout
let result = tokio::time::timeout(std::time::Duration::from_secs(timeout), async {
// Wait for output tasks to complete (they finish when the process closes its pipes)
let _ = stdout_task.await;
let _ = stderr_task.await;
child.wait().await
})
.await;
// Final flush
log_reporter.flush().await;
match result {
Ok(Ok(status)) => Ok(ExecResult {
exit_code: status.code().unwrap_or(1),
}),
Ok(Err(e)) => Err(format!("process error: {e}").into()),
Err(_) => {
// Timeout — kill the child
let _ = child.kill().await;
log_reporter.append("Job timed out, killed").await;
log_reporter.flush().await;
Ok(ExecResult { exit_code: 1 })
}
}
}
src/runner/heartbeat.rs +57 −0
@@ -1,0 +1,57 @@
use crate::runner::RunnerConfig;
use reqwest::header::{HeaderValue, AUTHORIZATION};
/// Start a heartbeat loop that sends periodic pings to the server.
/// Returns a JoinHandle — abort it to stop heartbeating.
pub fn start(config: &RunnerConfig) -> tokio::task::JoinHandle<()> {
let url = config.api_url(&format!("/runners/{}/heartbeat", config.runner_id));
let auth = config.auth_header();
let interval_ms = config.heartbeat_interval_ms;
let client = reqwest::Client::new();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_millis(interval_ms));
loop {
interval.tick().await;
match client
.post(&url)
.header(AUTHORIZATION, HeaderValue::from_str(&auth).unwrap())
.send()
.await
{
Ok(resp) if resp.status().is_success() => {}
Ok(resp) => {
eprintln!("heartbeat warning: server returned {}", resp.status());
}
Err(e) => {
eprintln!("heartbeat error: {e}");
}
}
}
})
}
/// Send a single heartbeat (used for connection validation and final heartbeat).
pub async fn send_once(
config: &RunnerConfig,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let url = config.api_url(&format!("/runners/{}/heartbeat", config.runner_id));
let client = reqwest::Client::new();
let resp = client
.post(&url)
.header(
AUTHORIZATION,
HeaderValue::from_str(&config.auth_header()).unwrap(),
)
.send()
.await?;
if resp.status().is_success() {
Ok(())
} else {
Err(format!("heartbeat failed: {}", resp.status()).into())
}
}
src/runner/log_reporter.rs +100 −0
@@ -1,0 +1,100 @@
use reqwest::header::{HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use std::sync::Arc;
use tokio::sync::Mutex;
const MAX_BUFFER_LINES: usize = 100;
const MAX_RETRIES: usize = 2;
#[derive(Clone)]
pub struct LogReporter {
client: reqwest::Client,
url: String,
auth: String,
buffer: Arc<Mutex<Vec<String>>>,
}
impl LogReporter {
pub fn new(server_url: &str, job_id: &str, runner_token: &str) -> Self {
let base = server_url.trim_end_matches('/');
Self {
client: reqwest::Client::new(),
url: format!("{base}/api/v1/runners/jobs/{job_id}/logs"),
auth: format!("Bearer {runner_token}"),
buffer: Arc::new(Mutex::new(Vec::new())),
}
}
/// Append raw output data (will be split on newlines).
pub async fn append(&self, data: &str) {
let lines: Vec<String> = data.lines().map(|l| l.to_string()).collect();
let should_flush = {
let mut buf = self.buffer.lock().await;
buf.extend(lines);
buf.len() >= MAX_BUFFER_LINES
};
if should_flush {
self.flush().await;
}
}
/// Flush buffered lines to the server.
pub async fn flush(&self) {
let lines = {
let mut buf = self.buffer.lock().await;
if buf.is_empty() {
return;
}
std::mem::take(&mut *buf)
};
self.send_lines(&lines).await;
}
async fn send_lines(&self, lines: &[String]) {
for attempt in 0..=MAX_RETRIES {
let result = self
.client
.post(&self.url)
.header(AUTHORIZATION, HeaderValue::from_str(&self.auth).unwrap())
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.json(&serde_json::json!({ "lines": lines }))
.send()
.await;
match result {
Ok(resp) if resp.status().is_success() => return,
Ok(resp) => {
if attempt < MAX_RETRIES {
eprintln!("log upload failed (status {}), retrying...", resp.status());
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
} else {
eprintln!(
"log upload failed after {} retries, dropping lines",
MAX_RETRIES
);
}
}
Err(e) => {
if attempt < MAX_RETRIES {
eprintln!("log upload error: {e}, retrying...");
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
} else {
eprintln!("log upload error after {} retries: {e}", MAX_RETRIES);
}
}
}
}
}
/// Start a background flush timer. Cancel by aborting the returned handle.
pub fn start_flush_timer(&self) -> tokio::task::JoinHandle<()> {
let reporter = self.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000));
loop {
interval.tick().await;
reporter.flush().await;
}
})
}
}
src/runner/loop_runner.rs +349 −0
@@ -1,0 +1,349 @@
use crate::runner::executor::{self, ExecResult};
use crate::runner::heartbeat;
use crate::runner::log_reporter::LogReporter;
use crate::runner::service_manager;
use crate::runner::workspace;
use crate::runner::RunnerConfig;
use reqwest::header::{HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Notify;
const MIN_BACKOFF_MS: u64 = 5_000;
const MAX_BACKOFF_MS: u64 = 30_000;
/// Start the runner main loop.
pub async fn start(config: RunnerConfig) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Validate connection
eprintln!("Connecting to {}...", config.server_url);
heartbeat::send_once(&config).await?;
eprintln!("Connected. Runner '{}' ready.", config.name);
// Start heartbeat
let heartbeat_handle = heartbeat::start(&config);
// Shutdown signal
let shutdown = Arc::new(Notify::new());
let shutdown_clone = shutdown.clone();
tokio::spawn(async move {
tokio::signal::ctrl_c()
.await
.expect("failed to listen for ctrl+c");
eprintln!("\nShutting down...");
shutdown_clone.notify_waiters();
});
// Run polling slots
let mut handles = Vec::new();
for slot in 1..=config.parallel {
let cfg = config.clone();
let stop = shutdown.clone();
handles.push(tokio::spawn(async move {
poller_loop(cfg, slot, stop).await;
}));
}
// Wait for shutdown signal
shutdown.notified().await;
// Grace period: wait for active jobs (up to 30s)
eprintln!("Waiting for active jobs to finish (30s grace period)...");
let grace = tokio::time::timeout(
std::time::Duration::from_secs(30),
futures::future::join_all(handles),
)
.await;
if grace.is_err() {
eprintln!("Grace period expired, forcing shutdown.");
}
// Stop heartbeat
heartbeat_handle.abort();
// Final heartbeat
let _ = heartbeat::send_once(&config).await;
eprintln!("Runner stopped.");
Ok(())
}
async fn poller_loop(config: RunnerConfig, slot: u32, shutdown: Arc<Notify>) {
let client = reqwest::Client::new();
let mut backoff_ms = MIN_BACKOFF_MS;
loop {
// Check shutdown
let poll_result = tokio::select! {
_ = shutdown.notified() => return,
result = claim_job(&client, &config) => result,
};
match poll_result {
Ok(Some(job)) => {
backoff_ms = MIN_BACKOFF_MS; // Reset backoff
let job_id = job
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
eprintln!(
"[slot {slot}] Claimed job {}",
&job_id[..8.min(job_id.len())]
);
let result = execute_job(&config, &job, slot).await;
report_result(&client, &config, &job_id, result).await;
// Handle --once / --ephemeral
if config.once || config.ephemeral {
if config.ephemeral {
let _ = deregister(&client, &config).await;
}
eprintln!(
"[slot {slot}] Exiting ({})",
if config.ephemeral {
"ephemeral"
} else {
"once"
}
);
std::process::exit(0);
}
}
Ok(None) => {
// No jobs available — backoff
tokio::select! {
_ = shutdown.notified() => return,
_ = tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)) => {},
}
backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS);
}
Err(e) => {
eprintln!("[slot {slot}] Poll error: {e}");
drop(e); // Drop before await to satisfy Send bound
tokio::select! {
_ = shutdown.notified() => return,
_ = tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)) => {},
}
backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS);
}
}
}
}
async fn claim_job(
client: &reqwest::Client,
config: &RunnerConfig,
) -> Result<Option<serde_json::Value>, Box<dyn std::error::Error + Send + Sync>> {
let url = config.api_url(&format!("/runners/{}/jobs/claim", config.runner_id));
let resp = client
.post(&url)
.header(
AUTHORIZATION,
HeaderValue::from_str(&config.auth_header()).unwrap(),
)
.send()
.await?;
match resp.status().as_u16() {
200 => {
let body: serde_json::Value = resp.json().await?;
let job = body.get("job").cloned().or(Some(body));
Ok(job)
}
204 => Ok(None),
status => {
let text = resp.text().await.unwrap_or_default();
Err(format!("claim failed ({status}): {text}").into())
}
}
}
async fn execute_job(
config: &RunnerConfig,
job: &serde_json::Value,
slot: u32,
) -> Result<ExecResult, Box<dyn std::error::Error + Send + Sync>> {
let job_id = job.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
let command = job
.get("command")
.and_then(|v| v.as_str())
.ok_or("job missing 'command'")?;
let image = job.get("image").and_then(|v| v.as_str());
let repo_url = job.get("repo_clone_url").and_then(|v| v.as_str());
let commit_sha = job.get("commit_sha").and_then(|v| v.as_str());
let timeout_seconds = job.get("timeout_seconds").and_then(|v| v.as_u64());
// Update status to running
let client = reqwest::Client::new();
update_job_status(&client, config, job_id, "running", None).await;
// Set up log reporter
let log_reporter = LogReporter::new(&config.server_url, job_id, &config.runner_token);
let flush_handle = log_reporter.start_flush_timer();
// Prepare workspace
let workspace_path = if let (Some(url), Some(sha)) = (repo_url, commit_sha) {
match workspace::prepare(&config.work_dir_path(), url, sha, slot) {
Ok(ws) => Some(ws),
Err(e) => {
log_reporter
.append(&format!("Workspace preparation failed: {e}"))
.await;
log_reporter.flush().await;
flush_handle.abort();
return Err(e);
}
}
} else {
None
};
let ws = workspace_path
.as_deref()
.unwrap_or_else(|| std::path::Path::new("."));
// Start services
let services = job.get("services");
let svc_context = if let Some(svcs) = services {
if svcs.is_object() && !svcs.as_object().unwrap().is_empty() {
match service_manager::start_services(job_id, svcs).await {
Ok(ctx) => Some(ctx),
Err(e) => {
log_reporter
.append(&format!("Service startup failed: {e}"))
.await;
log_reporter.flush().await;
flush_handle.abort();
return Err(e);
}
}
} else {
None
}
} else {
None
};
// Build env vars from job + services
let mut env = HashMap::new();
if let Some(job_env) = job.get("env").and_then(|v| v.as_object()) {
for (k, v) in job_env {
env.insert(k.clone(), v.as_str().unwrap_or("").to_string());
}
}
if let Some(ref ctx) = svc_context {
env.extend(ctx.env_vars.clone());
}
let network = svc_context.as_ref().and_then(|ctx| {
if ctx.network.is_empty() {
None
} else {
Some(ctx.network.as_str())
}
});
// Execute
let result = executor::execute(
command,
image,
ws,
&env,
network,
timeout_seconds,
&log_reporter,
)
.await;
// Cleanup services
if let Some(ctx) = &svc_context {
service_manager::cleanup_services(&ctx.containers, &ctx.network);
}
// Cleanup workspace
if let Some(ref ws_path) = workspace_path {
match config.cleanup.as_str() {
"always" => workspace::cleanup(ws_path),
"on-success" => {
if let Ok(ref r) = result {
if r.exit_code == 0 {
workspace::cleanup(ws_path);
}
}
}
_ => {} // "never" — keep for caching
}
}
// Stop flush timer
flush_handle.abort();
result
}
async fn report_result(
client: &reqwest::Client,
config: &RunnerConfig,
job_id: &str,
result: Result<ExecResult, Box<dyn std::error::Error + Send + Sync>>,
) {
let (status, exit_code) = match result {
Ok(r) => {
let s = if r.exit_code == 0 { "passed" } else { "failed" };
(s, Some(r.exit_code))
}
Err(e) => {
eprintln!("Job execution error: {e}");
("failed", Some(1))
}
};
update_job_status(client, config, job_id, status, exit_code).await;
}
async fn update_job_status(
client: &reqwest::Client,
config: &RunnerConfig,
job_id: &str,
status: &str,
exit_code: Option<i32>,
) {
let url = config.api_url(&format!("/runners/jobs/{job_id}/status"));
let mut body = serde_json::json!({ "status": status });
if let Some(code) = exit_code {
body["exit_code"] = serde_json::json!(code);
}
let result = client
.patch(&url)
.header(
AUTHORIZATION,
HeaderValue::from_str(&config.auth_header()).unwrap(),
)
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.json(&body)
.send()
.await;
if let Err(e) = result {
eprintln!("failed to update job status: {e}");
}
}
async fn deregister(
client: &reqwest::Client,
config: &RunnerConfig,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let url = config.api_url(&format!("/runners/{}", config.runner_id));
client
.delete(&url)
.header(
AUTHORIZATION,
HeaderValue::from_str(&config.auth_header()).unwrap(),
)
.send()
.await?;
Ok(())
}
src/runner/mod.rs +9 −0
@@ -1,0 +1,9 @@
pub mod config;
pub mod executor;
pub mod heartbeat;
pub mod log_reporter;
pub mod loop_runner;
pub mod service_manager;
pub mod workspace;
pub use config::RunnerConfig;
src/runner/service_manager.rs +232 −0
@@ -1,0 +1,232 @@
use std::collections::HashMap;
use std::process::Command;
/// Well-known default ports for common services.
fn well_known_port(image: &str) -> Option<u16> {
let lower = image.to_lowercase();
if lower.contains("postgres") {
Some(5432)
} else if lower.contains("mysql") || lower.contains("mariadb") {
Some(3306)
} else if lower.contains("redis") {
Some(6379)
} else if lower.contains("memcached") {
Some(11211)
} else if lower.contains("mongo") {
Some(27017)
} else if lower.contains("elasticsearch") {
Some(9200)
} else if lower.contains("rabbitmq") {
Some(5672)
} else if lower.contains("minio") {
Some(9000)
} else {
None
}
}
/// Health check command for known service images.
fn health_check_cmd(image: &str, container: &str) -> Option<Vec<String>> {
let lower = image.to_lowercase();
if lower.contains("postgres") {
Some(vec![
"docker".into(),
"exec".into(),
container.into(),
"pg_isready".into(),
"-q".into(),
])
} else if lower.contains("redis") {
Some(vec![
"docker".into(),
"exec".into(),
container.into(),
"redis-cli".into(),
"ping".into(),
])
} else if lower.contains("mysql") || lower.contains("mariadb") {
Some(vec![
"docker".into(),
"exec".into(),
container.into(),
"mysqladmin".into(),
"ping".into(),
"--silent".into(),
])
} else if lower.contains("mongo") {
Some(vec![
"docker".into(),
"exec".into(),
container.into(),
"mongosh".into(),
"--eval".into(),
"db.runCommand('ping')".into(),
])
} else {
None
}
}
pub struct ServiceContext {
pub containers: Vec<String>,
pub network: String,
pub env_vars: HashMap<String, String>,
}
/// Start service containers for a job.
/// `services` is a map of service_name → {image, env, port/ports}.
pub async fn start_services(
job_id: &str,
services: &serde_json::Value,
) -> Result<ServiceContext, Box<dyn std::error::Error + Send + Sync>> {
let services_map = services
.as_object()
.ok_or("services must be a JSON object")?;
if services_map.is_empty() {
return Ok(ServiceContext {
containers: vec![],
network: String::new(),
env_vars: HashMap::new(),
});
}
let network = format!("anvil-ci-{job_id}");
// Create Docker network
let output = Command::new("docker")
.args(["network", "create", &network])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("failed to create Docker network: {stderr}").into());
}
let mut containers = Vec::new();
let mut env_vars = HashMap::new();
for (svc_name, svc_config) in services_map {
let image = svc_config
.get("image")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("service '{svc_name}' missing 'image'"))?;
let container_name = format!("anvil-ci-svc-{job_id}-{svc_name}");
let mut args = vec![
"run".to_string(),
"--detach".into(),
"--name".into(),
container_name.clone(),
"--network".into(),
network.clone(),
"--network-alias".into(),
svc_name.clone(),
];
// Add environment variables
if let Some(env_map) = svc_config.get("env").and_then(|v| v.as_object()) {
for (k, v) in env_map {
let val = v.as_str().unwrap_or("");
args.push("-e".into());
args.push(format!("{k}={val}"));
}
}
// Add port mappings
if let Some(ports) = svc_config.get("ports").and_then(|v| v.as_array()) {
for port in ports {
if let Some(p) = port.as_str() {
args.push("-p".into());
args.push(p.to_string());
}
}
}
args.push(image.to_string());
let output = Command::new("docker").args(&args).output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// Clean up on failure
cleanup_services(&containers, &network);
return Err(format!("failed to start service '{svc_name}': {stderr}").into());
}
containers.push(container_name.clone());
// Wait for health check (up to 30s)
if let Some(check_cmd) = health_check_cmd(image, &container_name) {
wait_for_health(&check_cmd, 30).await;
} else {
// Brief pause for unknown services
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
// Determine port
let port = svc_config
.get("port")
.and_then(|v| v.as_u64())
.map(|p| p as u16)
.or_else(|| {
svc_config
.get("ports")
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(|p| p.as_str())
.and_then(|s| s.split(':').next())
.and_then(|s| s.parse::<u16>().ok())
})
.or_else(|| well_known_port(image));
// Set env vars for the job
let svc_upper = svc_name.to_uppercase();
env_vars.insert(format!("{svc_upper}_HOST"), svc_name.clone());
if let Some(p) = port {
env_vars.insert(format!("{svc_upper}_PORT"), p.to_string());
}
// Forward service env vars to the job
if let Some(env_map) = svc_config.get("env").and_then(|v| v.as_object()) {
for (k, v) in env_map {
let val = v.as_str().unwrap_or("");
env_vars.insert(k.clone(), val.to_string());
}
}
}
Ok(ServiceContext {
containers,
network,
env_vars,
})
}
/// Wait for a health check command to succeed.
async fn wait_for_health(cmd: &[String], timeout_secs: u64) {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
while tokio::time::Instant::now() < deadline {
if let Ok(output) = Command::new(&cmd[0]).args(&cmd[1..]).output() {
if output.status.success() {
return;
}
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
eprintln!("warning: health check timed out after {timeout_secs}s");
}
/// Stop and remove all service containers and the network.
pub fn cleanup_services(containers: &[String], network: &str) {
for container in containers {
let _ = Command::new("docker")
.args(["rm", "-f", container])
.output();
}
if !network.is_empty() {
let _ = Command::new("docker")
.args(["network", "rm", network])
.output();
}
}
src/runner/workspace.rs +120 −0
@@ -1,0 +1,120 @@
use std::path::{Path, PathBuf};
use std::process::Command;
/// Prepare workspace for a job: clone or fetch, then checkout the target SHA.
/// Returns the workspace directory path.
pub fn prepare(
work_dir: &Path,
repo_clone_url: &str,
commit_sha: &str,
slot: u32,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let (owner, repo) = parse_repo_url(repo_clone_url)?;
// Localize URL for Docker: host.docker.internal → 127.0.0.1
let local_url = repo_clone_url.replace("host.docker.internal", "127.0.0.1");
let workspace = work_dir.join(&owner).join(&repo).join(slot.to_string());
std::fs::create_dir_all(&workspace)?;
let git_dir = workspace.join(".git");
if git_dir.exists() {
// Existing checkout — update remote and fetch
run_git(
&workspace,
&["remote", "set-url", "origin", &local_url],
"set-url",
)?;
run_git(&workspace, &["fetch", "origin", "--prune"], "fetch")?;
} else {
// Fresh clone
run_git_in(
work_dir,
&["clone", &local_url, workspace.to_str().unwrap()],
"clone",
)?;
}
// Checkout target commit — force to discard any local changes
run_git(&workspace, &["checkout", "--force", commit_sha], "checkout")?;
// NOTE: no `git clean -fdx` — preserves build caches (deps, _build, node_modules, target/)
Ok(workspace)
}
/// Remove workspace directory.
pub fn cleanup(workspace: &Path) {
if workspace.exists() {
if let Err(e) = std::fs::remove_dir_all(workspace) {
eprintln!(
"warning: failed to clean workspace {}: {e}",
workspace.display()
);
}
}
}
/// Parse owner/repo from a clone URL.
fn parse_repo_url(url: &str) -> Result<(String, String), Box<dyn std::error::Error + Send + Sync>> {
// Handle both https://host/owner/repo.git and git@host:owner/repo.git
let path = if url.contains("://") {
url::Url::parse(url)?
.path()
.trim_start_matches('/')
.to_string()
} else if let Some(colon_idx) = url.find(':') {
url[colon_idx + 1..].to_string()
} else {
url.to_string()
};
let path = path.trim_end_matches(".git");
let parts: Vec<&str> = path.rsplitn(3, '/').collect();
if parts.len() < 2 {
return Err(format!("cannot parse repo URL: {url}").into());
}
Ok((parts[1].to_string(), parts[0].to_string()))
}
fn run_git(
cwd: &Path,
args: &[&str],
label: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let output = Command::new("git").current_dir(cwd).args(args).output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("warning: git {label} failed: {stderr}");
}
Ok(())
}
fn run_git_in(
cwd: &Path,
args: &[&str],
label: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
run_git(cwd, args, label)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_repo_url() {
assert_eq!(
parse_repo_url("https://anvil.fangorn.io/fangorn/myrepo.git").unwrap(),
("fangorn".into(), "myrepo".into())
);
assert_eq!(
parse_repo_url("git@anvil.fangorn.io:fangorn/myrepo.git").unwrap(),
("fangorn".into(), "myrepo".into())
);
assert_eq!(
parse_repo_url("https://anvil.example.com/org/repo").unwrap(),
("org".into(), "repo".into())
);
}
}