use crate::{config::Paths, state::Run};
use anyhow::{Context, Result, anyhow, ensure};
use serde_json::Value;
use std::time::Duration;
use tokio::process::Command;
pub struct Docker;
impl Docker {
async fn command(args: &[String]) -> Result<Vec<u8>> {
let output = tokio::time::timeout(
Duration::from_secs(30),
Command::new("docker")
.args(args)
.kill_on_drop(true)
.output(),
)
.await
.context("Docker command timed out")??;
ensure!(
output.status.success(),
"Docker {} failed (check daemon, image and permissions)",
args.first().map(String::as_str).unwrap_or("command")
);
Ok(output.stdout)
}
pub async fn info() -> Result<()> {
Self::command(&[
"info".into(),
"--format".into(),
"{{.ServerVersion}}".into(),
])
.await?;
Ok(())
}
pub async fn image(image: &str) -> Result<()> {
Self::command(&["image".into(), "inspect".into(), image.into()]).await?;
Ok(())
}
pub async fn image_version(image: &str) -> Result<Option<String>> {
let bytes = Self::command(&["image".into(), "inspect".into(), image.into()]).await?;
let data: Value = serde_json::from_slice(&bytes)?;
Ok(data[0]["Config"]["Labels"]["io.runnerctl.runner.version"]
.as_str()
.map(str::to_owned))
}
pub async fn pull(image: &str) -> Result<()> {
let status = tokio::time::timeout(
Duration::from_secs(600),
Command::new("docker")
.args(["pull", image])
.stdout(std::process::Stdio::null())
.kill_on_drop(true)
.status(),
)
.await
.context("Docker pull timed out")??;
ensure!(
status.success(),
"Docker pull failed; check registry access and image tag"
);
Ok(())
}
pub async fn list(manager: &str) -> Result<Vec<Value>> {
let ids = Self::command(&[
"ps".into(),
"-aq".into(),
"--filter".into(),
format!("label=io.runnerctl.manager={manager}"),
])
.await?;
let ids = String::from_utf8(ids)?;
if ids.trim().is_empty() {
return Ok(vec![]);
}
let mut args = vec!["inspect".into()];
args.extend(ids.split_whitespace().map(str::to_owned));
Ok(serde_json::from_slice(&Self::command(&args).await?)?)
}
pub fn owned(container: &Value, manager: &str, run: &Run) -> bool {
let labels = &container["Config"]["Labels"];
labels["io.runnerctl.manager"].as_str() == Some(manager)
&& labels["io.runnerctl.run"].as_str() == Some(&run.id)
&& labels["io.runnerctl.pool"].as_str() == Some(&run.pool_id)
}
fn create_args(manager: &str, run: &Run, paths: &Paths) -> Vec<String> {
let bootstrap = paths.home.join("runs").join(&run.id);
let mut args: Vec<String> = vec![
"create".into(),
"--name".into(),
run.id.clone(),
"--restart=no".into(),
"--init".into(),
"--log-opt".into(),
"max-size=10m".into(),
"--log-opt".into(),
"max-file=3".into(),
"--security-opt".into(),
"no-new-privileges:true".into(),
];
if let Some(cpus) = run.pool.cpus {
args.extend(["--cpus".into(), cpus.to_string()]);
}
if let Some(memory) = &run.pool.memory {
args.extend(["--memory".into(), memory.clone()]);
}
for (key, value) in [
("manager", manager),
("pool", run.pool_id.as_str()),
("run", run.id.as_str()),
] {
args.extend(["--label".into(), format!("io.runnerctl.{key}={value}")]);
}
let labels = run
.pool
.labels
.iter()
.cloned()
.chain(std::iter::once(format!("pool:{}", run.pool.name)))
.collect::<Vec<_>>()
.join(",");
for (key, value) in [
("GITHUB_ORG", run.pool.organization.as_str()),
("RUNNER_NAME", run.id.as_str()),
("RUNNER_LABELS", labels.as_str()),
("RUNNER_GROUP", run.pool.runner_group.as_str()),
] {
args.extend(["--env".into(), format!("{key}={value}")]);
}
args.extend(["--env".into(), format!("GITHUB_URL={}", run.pool.url())]);
if run.pool.repository.is_some() {
args.extend(["--env".into(), "RUNNER_SCOPE=repo".into()]);
}
args.extend([
"--volume".into(),
format!("{}:/run/runnerctl:Z", bootstrap.display()),
]);
if run.pool.docker_socket {
args.extend(["--network".into(), "host".into()]);
let runtime = bootstrap.join("runtime");
args.extend([
"--volume".into(),
format!("{}:{}:Z", runtime.display(), runtime.display()),
"--env".into(),
format!("RUNNER_ROOT={}", runtime.display()),
"--env".into(),
format!("RUNNER_UID={}", unsafe { libc::geteuid() }),
]);
args.extend([
"--volume".into(),
"/var/run/docker.sock:/var/run/docker.sock".into(),
]);
}
args.push(run.pool.image.clone());
args
}
pub async fn create(manager: &str, run: &Run, paths: &Paths) -> Result<()> {
if run.pool.docker_socket {
crate::config::secure_dir(&paths.home.join("runs").join(&run.id).join("runtime"))?;
}
let args = Self::create_args(manager, run, paths);
Self::command(&args).await?;
Ok(())
}
pub async fn start(id: &str) -> Result<()> {
Self::command(&["start".into(), id.into()]).await?;
Ok(())
}
pub async fn remove(id: &str, force: bool) -> Result<()> {
let mut args = vec!["rm".into()];
if force {
args.push("--force".into());
}
args.push(id.into());
Self::command(&args).await?;
Ok(())
}
pub async fn repair_runtime_permissions(run: &Run, paths: &Paths) -> Result<()> {
let directory = paths.home.join("runs").join(&run.id);
Self::command(&[
"run".into(),
"--rm".into(),
"--name".into(),
format!("{}-cleanup", run.id),
"--label".into(),
format!("io.runnerctl.cleanup={}", run.id),
"--network=none".into(),
"--user=0".into(),
"--cpus=1".into(),
"--memory=128m".into(),
"--cap-drop=ALL".into(),
"--cap-add=CHOWN".into(),
"--cap-add=DAC_OVERRIDE".into(),
"--cap-add=FOWNER".into(),
"--security-opt=no-new-privileges:true".into(),
"--volume".into(),
format!("{}:/cleanup:Z", directory.display()),
"--entrypoint=/bin/sh".into(),
run.pool.image.clone(),
"-c".into(),
"chown -hR \"$1:$2\" /cleanup && chmod -R u+rwX /cleanup".into(),
"--".into(),
unsafe { libc::geteuid() }.to_string(),
unsafe { libc::getegid() }.to_string(),
])
.await?;
Ok(())
}
pub async fn logs(id: &str) -> Result<String> {
let out = tokio::time::timeout(
Duration::from_secs(30),
Command::new("docker")
.args(["logs", "--tail", "1000", id])
.kill_on_drop(true)
.output(),
)
.await
.context("Docker logs timed out")??;
ensure!(out.status.success(), "cannot read Docker logs");
let mut bytes = out.stdout;
bytes.extend(out.stderr);
bytes.truncate(1024 * 1024);
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
pub async fn archive(id: &str, paths: &Paths) -> Result<()> {
let file = paths.home.join("logs").join(format!("{id}.tar"));
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut child = Command::new("docker")
.args([
"cp",
&format!("{id}:/home/runner/actions-runner/_diag/."),
"-",
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.kill_on_drop(true)
.spawn()?;
let result: Result<()> = tokio::time::timeout(Duration::from_secs(30), async {
let mut stdout = child
.stdout
.take()
.ok_or_else(|| anyhow!("missing Docker stdout"))?
.take(32 * 1024 * 1024 + 1);
let mut target = tokio::fs::File::create(&file).await?;
let size = tokio::io::copy(&mut stdout, &mut target).await?;
target.flush().await?;
ensure!(
size <= 32 * 1024 * 1024,
"diagnostic archive exceeds 32 MiB; export manually before cleanup"
);
ensure!(child.wait().await?.success(), "diagnostic archive failed");
Ok(())
})
.await
.context("diagnostic archive timed out")?;
result
}
}