use anyhow::{anyhow, bail, Context, Error, Result};
use flate2::read::GzDecoder;
use libmount::Overlay;
use sccache::dist::{
BuildResult, BuilderIncoming, CompileCommand, InputsReader, OutputData, ProcessOutput, TcCache,
Toolchain,
};
use sccache::lru_disk_cache::Error as LruError;
use std::collections::{hash_map, HashMap};
use std::fs;
use std::io;
use std::iter;
use std::path::{self, Path, PathBuf};
use std::process::{ChildStdin, Command, Output, Stdio};
use std::sync::Mutex;
use std::time::Instant;
use version_compare::Version;
trait CommandExt {
fn check_stdout_trim(&mut self) -> Result<String>;
fn check_piped(&mut self, pipe: &mut dyn FnMut(&mut ChildStdin) -> Result<()>) -> Result<()>;
fn check_run(&mut self) -> Result<()>;
}
impl CommandExt for Command {
fn check_stdout_trim(&mut self) -> Result<String> {
let output = self.output().context("Failed to start command")?;
check_output(&output)?;
let stdout =
String::from_utf8(output.stdout).context("Output from listing containers not UTF8")?;
Ok(stdout.trim().to_owned())
}
fn check_piped(&mut self, pipe: &mut dyn FnMut(&mut ChildStdin) -> Result<()>) -> Result<()> {
let mut process = self
.stdin(Stdio::piped())
.spawn()
.context("Failed to start command")?;
let mut stdin = process
.stdin
.take()
.expect("Requested piped stdin but not present");
pipe(&mut stdin).context("Failed to pipe input to process")?;
let output = process
.wait_with_output()
.context("Failed to wait for process to return")?;
check_output(&output)
}
fn check_run(&mut self) -> Result<()> {
let output = self.output().context("Failed to start command")?;
check_output(&output)
}
}
fn check_output(output: &Output) -> Result<()> {
if !output.status.success() {
warn!(
"===========\n{}\n==========\n\n\n\n=========\n{}\n===============\n\n\n",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
bail!("Command failed with status {}", output.status)
}
Ok(())
}
fn join_suffix<P: AsRef<Path>>(path: &Path, suffix: P) -> PathBuf {
let suffixpath = suffix.as_ref();
let mut components = suffixpath.components();
if suffixpath.has_root() {
assert_eq!(components.next(), Some(path::Component::RootDir));
}
path.join(components)
}
#[derive(Debug)]
struct OverlaySpec {
build_dir: PathBuf,
toolchain_dir: PathBuf,
}
#[derive(Debug, Clone)]
struct DeflatedToolchain {
path: PathBuf,
build_count: u64,
ctime: Instant,
}
pub struct OverlayBuilder {
bubblewrap: PathBuf,
dir: PathBuf,
toolchain_dir_map: Mutex<HashMap<Toolchain, DeflatedToolchain>>,
}
impl OverlayBuilder {
pub fn new(bubblewrap: PathBuf, dir: PathBuf) -> Result<Self> {
info!("Creating overlay builder");
if !nix::unistd::getuid().is_root() || !nix::unistd::geteuid().is_root() {
bail!("not running as root")
}
let out = Command::new(&bubblewrap)
.arg("--version")
.check_stdout_trim()
.context("Failed to execute bwrap for version check")?;
if let Some(s) = out.split_whitespace().nth(1) {
match (Version::from("0.3.0"), Version::from(s)) {
(Some(min), Some(seen)) => {
if seen < min {
bail!(
"bubblewrap 0.3.0 or later is required, got {:?} for {:?}",
out,
bubblewrap
);
}
}
(_, _) => {
bail!("Unexpected version format running {:?}: got {:?}, expected \"bubblewrap x.x.x\"",
bubblewrap, out);
}
}
} else {
bail!(
"Unexpected version format running {:?}: got {:?}, expected \"bubblewrap x.x.x\"",
bubblewrap,
out
);
}
let ret = Self {
bubblewrap,
dir,
toolchain_dir_map: Mutex::new(HashMap::new()),
};
ret.cleanup()?;
fs::create_dir(&ret.dir).context("Failed to create base directory for builder")?;
fs::create_dir(ret.dir.join("builds"))
.context("Failed to create builder builds directory")?;
fs::create_dir(ret.dir.join("toolchains"))
.context("Failed to create builder toolchains directory")?;
Ok(ret)
}
fn cleanup(&self) -> Result<()> {
if self.dir.exists() {
fs::remove_dir_all(&self.dir).context("Failed to clean up builder directory")?
}
Ok(())
}
fn prepare_overlay_dirs(
&self,
tc: &Toolchain,
tccache: &Mutex<TcCache>,
) -> Result<OverlaySpec> {
let DeflatedToolchain {
path: toolchain_dir,
build_count: id,
ctime: _,
} = {
let mut toolchain_dir_map = self.toolchain_dir_map.lock().unwrap();
let toolchain_dir = self.dir.join("toolchains").join(&tc.archive_id);
if toolchain_dir_map.contains_key(tc) && toolchain_dir.exists() {
let entry = toolchain_dir_map
.get_mut(tc)
.expect("Key missing after checking");
entry.build_count += 1;
entry.clone()
} else {
trace!("Creating toolchain directory for {}", tc.archive_id);
fs::create_dir(&toolchain_dir)?;
let mut tccache = tccache.lock().unwrap();
let toolchain_rdr = match tccache.get(tc) {
Ok(rdr) => rdr,
Err(LruError::FileNotInCache) => {
bail!("expected toolchain {}, but not available", tc.archive_id)
}
Err(e) => {
return Err(Error::from(e).context("failed to get toolchain from cache"))
}
};
tar::Archive::new(GzDecoder::new(toolchain_rdr))
.unpack(&toolchain_dir)
.or_else(|e| {
warn!("Failed to unpack toolchain: {:?}", e);
fs::remove_dir_all(&toolchain_dir)
.context("Failed to remove unpacked toolchain")?;
tccache
.remove(tc)
.context("Failed to remove corrupt toolchain")?;
Err(Error::from(e))
})?;
let entry = DeflatedToolchain {
path: toolchain_dir,
build_count: 1,
ctime: Instant::now(),
};
toolchain_dir_map.insert(tc.clone(), entry.clone());
if toolchain_dir_map.len() > tccache.len() {
let dir_map = toolchain_dir_map.clone();
let mut entries: Vec<_> = dir_map.iter().collect();
entries.sort_by(|a, b| (a.1).ctime.cmp(&(b.1).ctime));
entries.truncate(entries.len() / 2);
for (tc, _) in entries {
warn!("Removing old un-compressed toolchain: {:?}", tc);
assert!(toolchain_dir_map.remove(tc).is_some());
fs::remove_dir_all(&self.dir.join("toolchains").join(&tc.archive_id))
.context("Failed to remove old toolchain directory")?;
}
}
entry
}
};
trace!("Creating build directory for {}-{}", tc.archive_id, id);
let build_dir = self
.dir
.join("builds")
.join(format!("{}-{}", tc.archive_id, id));
fs::create_dir(&build_dir)?;
Ok(OverlaySpec {
build_dir,
toolchain_dir,
})
}
fn perform_build(
bubblewrap: &Path,
compile_command: CompileCommand,
inputs_rdr: InputsReader,
output_paths: Vec<String>,
overlay: &OverlaySpec,
) -> Result<BuildResult> {
trace!("Compile environment: {:?}", compile_command.env_vars);
trace!(
"Compile command: {:?} {:?}",
compile_command.executable,
compile_command.arguments
);
crossbeam_utils::thread::scope(|scope| {
scope
.spawn(|_| {
nix::sched::unshare(nix::sched::CloneFlags::CLONE_NEWNS)
.context("Failed to enter a new Linux namespace")?;
let source: Option<&str> = None;
let fstype: Option<&str> = None;
let data: Option<&str> = None;
nix::mount::mount(
source,
"/",
fstype,
nix::mount::MsFlags::MS_REC | nix::mount::MsFlags::MS_PRIVATE,
data,
)
.context("Failed to turn / into a slave")?;
let work_dir = overlay.build_dir.join("work");
let upper_dir = overlay.build_dir.join("upper");
let target_dir = overlay.build_dir.join("target");
fs::create_dir(&work_dir).context("Failed to create overlay work directory")?;
fs::create_dir(&upper_dir)
.context("Failed to create overlay upper directory")?;
fs::create_dir(&target_dir)
.context("Failed to create overlay target directory")?;
let () = Overlay::writable(
iter::once(overlay.toolchain_dir.as_path()),
upper_dir,
work_dir,
&target_dir,
)
.mount()
.map_err(|e| anyhow!("Failed to mount overlay FS: {}", e.to_string()))?;
trace!("copying in inputs");
tar::Archive::new(inputs_rdr)
.unpack(&target_dir)
.context("Failed to unpack inputs to overlay")?;
let CompileCommand {
executable,
arguments,
env_vars,
cwd,
} = compile_command;
let cwd = Path::new(&cwd);
trace!("creating output directories");
fs::create_dir_all(join_suffix(&target_dir, cwd))
.context("Failed to create cwd")?;
for path in output_paths.iter() {
let output_parent = if let Some(p) = Path::new(path).parent() {
p
} else {
continue;
};
fs::create_dir_all(join_suffix(&target_dir, cwd.join(output_parent)))
.context("Failed to create an output directory")?;
}
trace!("performing compile");
let mut cmd = Command::new(bubblewrap);
cmd.arg("--die-with-parent")
.args(&["--cap-drop", "ALL"])
.args(&[
"--unshare-user",
"--unshare-cgroup",
"--unshare-ipc",
"--unshare-pid",
"--unshare-net",
"--unshare-uts",
])
.arg("--bind")
.arg(&target_dir)
.arg("/")
.args(&["--proc", "/proc"])
.args(&["--dev", "/dev"])
.arg("--chdir")
.arg(cwd);
for (k, v) in env_vars {
if k.contains('=') {
warn!("Skipping environment variable: {:?}", k);
continue;
}
cmd.arg("--setenv").arg(k).arg(v);
}
cmd.arg("--");
cmd.arg(executable);
cmd.args(arguments);
let compile_output = cmd
.output()
.context("Failed to retrieve output from compile")?;
trace!("compile_output: {:?}", compile_output);
let mut outputs = vec![];
trace!("retrieving {:?}", output_paths);
for path in output_paths {
let abspath = join_suffix(&target_dir, cwd.join(&path)); match fs::File::open(abspath) {
Ok(file) => {
let output = OutputData::try_from_reader(file)
.context("Failed to read output file")?;
outputs.push((path, output))
}
Err(e) => {
if e.kind() == io::ErrorKind::NotFound {
debug!("Missing output path {:?}", path)
} else {
return Err(
Error::from(e).context("Failed to open output file")
);
}
}
}
}
let compile_output = ProcessOutput::try_from(compile_output)
.context("Failed to convert compilation exit status")?;
Ok(BuildResult {
output: compile_output,
outputs,
})
})
.join()
.unwrap_or_else(|_e| Err(anyhow!("Build thread exited unsuccessfully")))
})
.unwrap_or_else(|e| Err(anyhow!("Error joining build thread: {:?}", e)))
}
fn finish_overlay(&self, _tc: &Toolchain, overlay: OverlaySpec) {
let OverlaySpec {
build_dir,
toolchain_dir: _,
} = overlay;
if let Err(e) = fs::remove_dir_all(&build_dir) {
error!(
"Failed to remove build directory {}: {}",
build_dir.display(),
e
);
}
}
}
impl BuilderIncoming for OverlayBuilder {
fn run_build(
&self,
tc: Toolchain,
command: CompileCommand,
outputs: Vec<String>,
inputs_rdr: InputsReader,
tccache: &Mutex<TcCache>,
) -> Result<BuildResult> {
debug!("Preparing overlay");
let overlay = self
.prepare_overlay_dirs(&tc, tccache)
.context("failed to prepare overlay dirs")?;
debug!("Performing build in {:?}", overlay);
let res = Self::perform_build(&self.bubblewrap, command, inputs_rdr, outputs, &overlay);
debug!("Finishing with overlay");
self.finish_overlay(&tc, overlay);
debug!("Returning result");
res.context("Compilation execution failed")
}
}
const BASE_DOCKER_IMAGE: &str = "aidanhs/busybox";
const DOCKER_SHELL_INIT: &str = "while true; do /busybox sleep 365d && /busybox true; done";
fn docker_diff(cid: &str) -> Result<String> {
Command::new("docker")
.args(&["diff", cid])
.check_stdout_trim()
.context("Failed to Docker diff container")
}
fn docker_rm(cid: &str) -> Result<()> {
Command::new("docker")
.args(&["rm", "-f", cid])
.check_run()
.context("Failed to force delete container")
}
pub struct DockerBuilder {
image_map: Mutex<HashMap<Toolchain, String>>,
container_lists: Mutex<HashMap<Toolchain, Vec<String>>>,
}
impl DockerBuilder {
pub fn new() -> Result<Self> {
info!("Creating docker builder");
let ret = Self {
image_map: Mutex::new(HashMap::new()),
container_lists: Mutex::new(HashMap::new()),
};
ret.cleanup()?;
Ok(ret)
}
fn cleanup(&self) -> Result<()> {
info!("Performing initial Docker cleanup");
let containers = Command::new("docker")
.args(&["ps", "-a", "--format", "{{.ID}} {{.Image}}"])
.check_stdout_trim()
.context("Unable to list all Docker containers")?;
if !containers.is_empty() {
let mut containers_to_rm = vec![];
for line in containers.split(|c| c == '\n') {
let mut iter = line.splitn(2, ' ');
let container_id = iter
.next()
.context("Malformed container listing - no container ID")?;
let image_name = iter
.next()
.context("Malformed container listing - no image name")?;
if iter.next() != None {
bail!("Malformed container listing - third field on row")
}
if image_name.starts_with("sccache-builder-") {
containers_to_rm.push(container_id)
}
}
if !containers_to_rm.is_empty() {
Command::new("docker")
.args(&["rm", "-f"])
.args(containers_to_rm)
.check_run()
.context("Failed to start command to remove old containers")?;
}
}
let images = Command::new("docker")
.args(&["images", "--format", "{{.ID}} {{.Repository}}"])
.check_stdout_trim()
.context("Failed to list all docker images")?;
if !images.is_empty() {
let mut images_to_rm = vec![];
for line in images.split(|c| c == '\n') {
let mut iter = line.splitn(2, ' ');
let image_id = iter
.next()
.context("Malformed image listing - no image ID")?;
let image_name = iter
.next()
.context("Malformed image listing - no image name")?;
if iter.next() != None {
bail!("Malformed image listing - third field on row")
}
if image_name.starts_with("sccache-builder-") {
images_to_rm.push(image_id)
}
}
if !images_to_rm.is_empty() {
Command::new("docker")
.args(&["rmi"])
.args(images_to_rm)
.check_run()
.context("Failed to remove image")?
}
}
info!("Completed initial Docker cleanup");
Ok(())
}
fn get_container(&self, tc: &Toolchain, tccache: &Mutex<TcCache>) -> Result<String> {
let container = {
let mut map = self.container_lists.lock().unwrap();
map.entry(tc.clone()).or_insert_with(Vec::new).pop()
};
match container {
Some(cid) => Ok(cid),
None => {
let image = {
let mut map = self.image_map.lock().unwrap();
match map.entry(tc.clone()) {
hash_map::Entry::Occupied(e) => e.get().clone(),
hash_map::Entry::Vacant(e) => {
info!("Creating Docker image for {:?} (may block requests)", tc);
let image = Self::make_image(tc, tccache)?;
e.insert(image.clone());
image
}
}
};
Self::start_container(&image)
}
}
}
fn clean_container(&self, cid: &str) -> Result<()> {
Command::new("docker")
.args(&["exec", cid, "/busybox", "kill", "-9", "-1"])
.check_run()
.context("Failed to run kill on all processes in container")?;
let diff = docker_diff(cid)?;
if !diff.is_empty() {
let mut lastpath = None;
for line in diff.split(|c| c == '\n') {
let mut iter = line.splitn(2, ' ');
let changetype = iter
.next()
.context("Malformed container diff - no change type")?;
let changepath = iter
.next()
.context("Malformed container diff - no change path")?;
if iter.next() != None {
bail!("Malformed container diff - third field on row")
}
if changepath == "/tmp" {
continue;
}
if changetype != "A" {
bail!(
"Path {} had a non-A changetype of {}",
changepath,
changetype
);
}
if let Some(lastpath) = lastpath {
if Path::new(changepath).starts_with(lastpath) {
continue;
}
}
lastpath = Some(changepath);
if let Err(e) = Command::new("docker")
.args(&["exec", cid, "/busybox", "rm", "-rf", changepath])
.check_run()
{
warn!("Failed to remove added path in a container: {}", e)
}
}
let newdiff = docker_diff(cid)?;
if !newdiff.is_empty() && newdiff != "C /tmp" {
bail!(
"Attempted to delete files, but container still has a diff: {:?}",
newdiff
);
}
}
Ok(())
}
fn finish_container(&self, tc: &Toolchain, cid: String) {
if let Err(e) = self.clean_container(&cid) {
info!("Failed to clean container {}: {}", cid, e);
if let Err(e) = docker_rm(&cid) {
warn!(
"Failed to remove container {} after failed clean: {}",
cid, e
);
}
return;
}
if let Some(entry) = self.container_lists.lock().unwrap().get_mut(tc) {
debug!("Reclaimed container {}", cid);
entry.push(cid)
} else {
warn!(
"Was ready to reclaim container {} but toolchain went missing",
cid
);
if let Err(e) = docker_rm(&cid) {
warn!("Failed to remove container {}: {}", cid, e);
}
}
}
fn make_image(tc: &Toolchain, tccache: &Mutex<TcCache>) -> Result<String> {
let cid = Command::new("docker")
.args(&["create", BASE_DOCKER_IMAGE, "/busybox", "true"])
.check_stdout_trim()
.context("Failed to create docker container")?;
let mut tccache = tccache.lock().unwrap();
let mut toolchain_rdr = match tccache.get(tc) {
Ok(rdr) => rdr,
Err(LruError::FileNotInCache) => bail!(
"Expected to find toolchain {}, but not available",
tc.archive_id
),
Err(e) => {
return Err(e).with_context(|| format!("Failed to use toolchain {}", tc.archive_id))
}
};
trace!("Copying in toolchain");
Command::new("docker")
.args(&["cp", "-", &format!("{}:/", cid)])
.check_piped(&mut |stdin| {
io::copy(&mut toolchain_rdr, stdin)?;
Ok(())
})
.context("Failed to copy toolchain tar into container")?;
drop(toolchain_rdr);
let imagename = format!("sccache-builder-{}", &tc.archive_id);
Command::new("docker")
.args(&["commit", &cid, &imagename])
.check_run()
.context("Failed to commit container after build")?;
Command::new("docker")
.args(&["rm", "-f", &cid])
.check_run()
.context("Failed to remove temporary build container")?;
Ok(imagename)
}
fn start_container(image: &str) -> Result<String> {
Command::new("docker")
.args(&[
"run",
"-d",
image,
"/busybox",
"sh",
"-c",
DOCKER_SHELL_INIT,
])
.check_stdout_trim()
.context("Failed to run container")
}
fn perform_build(
compile_command: CompileCommand,
mut inputs_rdr: InputsReader,
output_paths: Vec<String>,
cid: &str,
) -> Result<BuildResult> {
trace!("Compile environment: {:?}", compile_command.env_vars);
trace!(
"Compile command: {:?} {:?}",
compile_command.executable,
compile_command.arguments
);
trace!("copying in inputs");
Command::new("docker")
.args(&["cp", "-", &format!("{}:/", cid)])
.check_piped(&mut |stdin| {
io::copy(&mut inputs_rdr, stdin)?;
Ok(())
})
.context("Failed to copy inputs tar into container")?;
drop(inputs_rdr);
let CompileCommand {
executable,
arguments,
env_vars,
cwd,
} = compile_command;
let cwd = Path::new(&cwd);
trace!("creating output directories");
assert!(!output_paths.is_empty());
let mut cmd = Command::new("docker");
cmd.args(&["exec", cid, "/busybox", "mkdir", "-p"]).arg(cwd);
for path in output_paths.iter() {
let output_parent = if let Some(p) = Path::new(path).parent() {
p
} else {
continue;
};
cmd.arg(cwd.join(output_parent));
}
cmd.check_run()
.context("Failed to create directories required for compile in container")?;
trace!("performing compile");
let mut cmd = Command::new("docker");
cmd.arg("exec");
for (k, v) in env_vars {
if k.contains('=') {
warn!("Skipping environment variable: {:?}", k);
continue;
}
let mut env = k;
env.push('=');
env.push_str(&v);
cmd.arg("-e").arg(env);
}
let shell_cmd = "cd \"$1\" && shift && exec \"$@\"";
cmd.args(&[cid, "/busybox", "sh", "-c", shell_cmd]);
cmd.arg(&executable);
cmd.arg(cwd);
cmd.arg(executable);
cmd.args(arguments);
let compile_output = cmd.output().context("Failed to start executing compile")?;
trace!("compile_output: {:?}", compile_output);
let mut outputs = vec![];
trace!("retrieving {:?}", output_paths);
for path in output_paths {
let abspath = cwd.join(&path); let output = Command::new("docker")
.args(&["exec", cid, "/busybox", "cat"])
.arg(abspath)
.output()
.context("Failed to start command to retrieve output file")?;
if output.status.success() {
let output = OutputData::try_from_reader(&*output.stdout)
.expect("Failed to read compress output stdout");
outputs.push((path, output))
} else {
debug!("Missing output path {:?}", path)
}
}
let compile_output = ProcessOutput::try_from(compile_output)
.context("Failed to convert compilation exit status")?;
Ok(BuildResult {
output: compile_output,
outputs,
})
}
}
impl BuilderIncoming for DockerBuilder {
fn run_build(
&self,
tc: Toolchain,
command: CompileCommand,
outputs: Vec<String>,
inputs_rdr: InputsReader,
tccache: &Mutex<TcCache>,
) -> Result<BuildResult> {
debug!("Finding container");
let cid = self
.get_container(&tc, tccache)
.context("Failed to get a container for build")?;
debug!("Performing build with container {}", cid);
let res = Self::perform_build(command, inputs_rdr, outputs, &cid)
.context("Failed to perform build")?;
debug!("Finishing with container {}", cid);
self.finish_container(&tc, cid);
debug!("Returning result");
Ok(res)
}
}