mod bakefile;
mod cache;
mod config;
mod format;
mod runner;
mod schedule;
mod tar;
use clap::{App, AppSettings, Arg};
use env_logger::{fmt::Color, Builder, Env};
use log::Level;
use std::{
cell::{Cell, RefCell},
collections::{HashMap, HashSet},
convert::AsRef,
env::current_dir,
fs,
io::{stdout, Seek, SeekFrom, Write},
path::Path,
path::PathBuf,
process::exit,
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
};
use tempfile::tempfile;
use textwrap::Wrapper;
#[macro_use]
extern crate log;
#[macro_use]
extern crate scopeguard;
const VERSION: &str = "0.3.0";
const BAKEFILE_DEFAULT_NAME: &str = "bake.yml";
const CONFIG_FILE_XDG_PATH: &str = "bake/bake.yml";
const BAKEFILE_ARG: &str = "file";
const CONFIG_FILE_ARG: &str = "config-file";
const READ_LOCAL_CACHE_ARG: &str = "read-local-cache";
const WRITE_LOCAL_CACHE_ARG: &str = "write-local-cache";
const READ_REMOTE_CACHE_ARG: &str = "read-remote-cache";
const WRITE_REMOTE_CACHE_ARG: &str = "write-remote-cache";
const REPO_ARG: &str = "repo";
const SHELL_ARG: &str = "shell";
const TASKS_ARG: &str = "tasks";
fn set_up_logging() {
Builder::from_env(
Env::default()
.filter_or("LOG_LEVEL", "info")
.write_style("LOG_STYLE"),
)
.format(|buf, record| {
let mut style = buf.style();
style.set_bold(true);
match record.level() {
Level::Error => {
style.set_color(Color::Red);
}
Level::Warn => {
style.set_color(Color::Yellow);
}
Level::Info => {
style.set_color(Color::Green);
}
Level::Debug | Level::Trace => {
style.set_color(Color::Blue);
}
}
let indent_size = record.level().to_string().len() + 3;
let indent = &" ".repeat(indent_size);
writeln!(
buf,
"{} {}",
style.value(format!("[{}]", record.level())),
&Wrapper::with_termwidth()
.initial_indent(indent)
.subsequent_indent(indent)
.fill(&record.args().to_string())[indent_size..]
)
})
.init();
}
fn set_up_signal_handlers(
running: Arc<AtomicBool>,
active_containers: Arc<Mutex<HashSet<String>>>,
) -> Result<(), String> {
ctrlc::set_handler(move || {
if running.swap(false, Ordering::SeqCst) {
let _ = stdout().write(b"\n");
info!("Terminating...");
for container in &*active_containers.lock().unwrap() {
if let Err(e) = runner::stop_container(&container) {
error!("{}", e);
}
}
let _ = stdout().write(b"\n");
}
})
.map_err(|e| format!("Error installing signal handler. Details: {}", e))
}
fn parse_bool(s: &str) -> Result<bool, String> {
let normalized = s.trim().to_lowercase();
match normalized.as_ref() {
"true" | "yes" => Ok(true),
"false" | "no" => Ok(false),
_ => Err(format!("`{}` is not a Boolean.", s)),
}
}
struct Settings {
bakefile_path: PathBuf,
docker_repo: String,
read_local_cache: bool,
write_local_cache: bool,
read_remote_cache: bool,
write_remote_cache: bool,
spawn_shell: bool,
tasks: Option<Vec<String>>,
}
fn settings() -> Result<Settings, String> {
let matches = App::new("Bake")
.version(VERSION)
.version_short("v")
.author("Stephan Boyer <stephan@stephanboyer.com>")
.about("Bake is a containerized build system.")
.setting(AppSettings::ColoredHelp)
.setting(AppSettings::NextLineHelp)
.setting(AppSettings::UnifiedHelpMessage)
.arg(
Arg::with_name(BAKEFILE_ARG)
.short("f")
.long(BAKEFILE_ARG)
.value_name("PATH")
.help("Sets the path to the bakefile")
.takes_value(true),
)
.arg(
Arg::with_name(CONFIG_FILE_ARG)
.short("c")
.long(CONFIG_FILE_ARG)
.value_name("PATH")
.help("Sets the path of the config file")
.takes_value(true),
)
.arg(
Arg::with_name(READ_LOCAL_CACHE_ARG)
.long(READ_LOCAL_CACHE_ARG)
.value_name("BOOL")
.help("Sets whether local cache reading is enabled")
.takes_value(true),
)
.arg(
Arg::with_name(WRITE_LOCAL_CACHE_ARG)
.long(WRITE_LOCAL_CACHE_ARG)
.value_name("BOOL")
.help("Sets whether local cache writing is enabled")
.takes_value(true),
)
.arg(
Arg::with_name(READ_REMOTE_CACHE_ARG)
.long(READ_REMOTE_CACHE_ARG)
.value_name("BOOL")
.help("Sets whether remote cache reading is enabled")
.takes_value(true),
)
.arg(
Arg::with_name(WRITE_REMOTE_CACHE_ARG)
.long(WRITE_REMOTE_CACHE_ARG)
.value_name("BOOL")
.help("Sets whether remote cache writing is enabled")
.takes_value(true),
)
.arg(
Arg::with_name(REPO_ARG)
.short("r")
.long(REPO_ARG)
.value_name("REPO")
.help("Sets the Docker repository")
.takes_value(true),
)
.arg(
Arg::with_name(SHELL_ARG)
.short("s")
.long(SHELL_ARG)
.help("Drops you into a shell after the tasks are finished"),
)
.arg(
Arg::with_name(TASKS_ARG)
.value_name("TASKS")
.multiple(true)
.help("Sets the tasks to run"),
)
.get_matches();
let bakefile_path = matches.value_of(BAKEFILE_ARG).map_or_else(
|| {
let mut candidate_dir = current_dir().map_err(|e| {
format!(
"Unable to determine current working directory. Details: {}",
e
)
})?;
loop {
let candidate_path = candidate_dir.join(BAKEFILE_DEFAULT_NAME);
if let Ok(metadata) = fs::metadata(&candidate_path) {
if metadata.file_type().is_file() {
return Ok(candidate_path);
}
}
if !candidate_dir.pop() {
return Err(format!(
"Unable to locate file `{}`.",
BAKEFILE_DEFAULT_NAME
));
}
}
},
|x| Ok(Path::new(x).to_owned()),
)?;
let default_config_file_path =
dirs::config_dir().map(|path| path.join(CONFIG_FILE_XDG_PATH));
let config_file_path = matches.value_of(CONFIG_FILE_ARG).map_or_else(
|| default_config_file_path,
|path| Some(PathBuf::from(path)),
);
let config_data = config_file_path
.as_ref()
.and_then(|path| {
debug!("Loading configuration file `{}`...", path.to_string_lossy());
fs::read_to_string(path).ok()
})
.map_or_else(
|| {
debug!(
"Configuration file not found. Using the default configuration."
);
config::EMPTY_CONFIG.to_owned()
},
|data| {
debug!("Found it.");
data
},
);
let config = config::parse(&config_data).map_err(|e| {
format!(
"Unable to parse file `{}`. Details: {}.",
config_file_path.as_ref().unwrap().to_string_lossy(), e
)
})?;
let read_local_cache = matches
.value_of(READ_LOCAL_CACHE_ARG)
.map_or(Ok(config.read_local_cache), |s| parse_bool(s))?;
let write_local_cache = matches
.value_of(WRITE_LOCAL_CACHE_ARG)
.map_or(Ok(config.write_local_cache), |s| parse_bool(s))?;
let read_remote_cache = matches
.value_of(READ_REMOTE_CACHE_ARG)
.map_or(Ok(config.read_remote_cache), |s| parse_bool(s))?;
let write_remote_cache = matches
.value_of(WRITE_REMOTE_CACHE_ARG)
.map_or(Ok(config.write_remote_cache), |s| parse_bool(s))?;
let docker_repo = matches
.value_of(REPO_ARG)
.unwrap_or(&config.docker_repo)
.to_owned();
let spawn_shell = matches.is_present(SHELL_ARG);
let tasks = matches.values_of(TASKS_ARG).map(|tasks| {
tasks
.map(std::borrow::ToOwned::to_owned)
.collect::<Vec<_>>()
});
Ok(Settings {
bakefile_path,
read_local_cache,
write_local_cache,
read_remote_cache,
write_remote_cache,
docker_repo,
spawn_shell,
tasks,
})
}
fn parse_bakefile(bakefile_path: &Path) -> Result<bakefile::Bakefile, String> {
let bakefile_data = fs::read_to_string(bakefile_path).map_err(|e| {
format!(
"Unable to read file `{}`. Details: {}",
bakefile_path.to_string_lossy(),
e
)
})?;
bakefile::parse(&bakefile_data).map_err(|e| {
format!(
"Unable to parse file `{}`. Details: {}",
bakefile_path.to_string_lossy(),
e
)
})
}
fn get_roots<'a>(
settings: &'a Settings,
bakefile: &'a bakefile::Bakefile,
) -> Result<Vec<&'a str>, String> {
settings.tasks.as_ref().map_or_else(
|| {
if let Some(default) = &bakefile.default {
Ok(vec![default.as_ref()])
} else {
Ok(bakefile.tasks.keys().map(AsRef::as_ref).collect::<Vec<_>>())
}
},
|tasks| {
for task in tasks {
if !bakefile.tasks.contains_key(task) {
return Err(format!(
"No task named `{}` in `{}`.",
task,
settings.bakefile_path.to_string_lossy()
));
}
}
Ok(tasks.iter().map(AsRef::as_ref).collect())
},
)
}
fn fetch_env(
schedule: &[&str],
tasks: &HashMap<String, bakefile::Task>,
) -> Result<HashMap<String, String>, String> {
let mut env = HashMap::new();
let mut violations = HashMap::new();
for task in schedule {
match bakefile::environment(&tasks[*task]) {
Ok(env_for_task) => {
env.extend(env_for_task);
}
Err(vars) => {
violations.insert((*task).to_owned(), vars);
}
}
}
if !violations.is_empty() {
return Err(format!(
"The following tasks use variables which are missing from the environment: {}.",
format::series(
violations
.iter()
.map(|(task, vars)| format!(
"`{}` ({})",
task,
format::series(
vars
.iter()
.map(|var| format!("`{}`", var))
.collect::<Vec<_>>().as_ref()
)
))
.collect::<Vec<_>>().as_ref()
)
));
}
Ok(env)
}
fn run_tasks<'a>(
schedule: &[&'a str],
settings: &Settings,
bakefile: &bakefile::Bakefile,
env: &HashMap<String, String>,
running: &Arc<AtomicBool>,
active_containers: &Arc<Mutex<HashSet<String>>>,
) -> Result<(), String> {
let base_image_already_existed = runner::image_exists(&bakefile.image);
if !base_image_already_existed {
info!("Pulling image `{}`...", bakefile.image);
runner::pull_image(&bakefile.image)?;
}
let mut cache_key = cache::hash_str(&bakefile.image);
let mut first_task = true;
let from_image = RefCell::new(bakefile.image.clone());
let from_image_cacheable = Cell::new(true);
for task in schedule {
let task_data = &bakefile.tasks[*task];
let image_to_delete = if (settings.write_local_cache
&& from_image_cacheable.get())
|| (first_task && base_image_already_existed)
{
None
} else {
Some(from_image.borrow().to_owned())
};
defer! {{
if let Some(image) = image_to_delete {
if let Err(e) = runner::delete_image(&image) {
error!("{}", e);
}
}
}}
first_task = false;
if !running.load(Ordering::SeqCst) {
return Err("Interrupted.".to_owned());
}
let tar_file = tempfile().map_err(|e| {
format!("Unable to create temporary file. Details: {}", e)
})?;
let mut bakefile_dir = PathBuf::from(&settings.bakefile_path);
bakefile_dir.pop();
let (mut tar_file, files_hash) = tar::create(
tar_file,
&task_data.paths,
&bakefile_dir.to_string_lossy().to_string(),
&task_data.location,
)?;
tar_file
.seek(SeekFrom::Start(0))
.map_err(|e| format!("Unable to seek temporary file. Details: {}", e))?;
cache_key = cache::key(&cache_key, &task_data, &files_hash, &env);
let to_image =
RefCell::new(format!("{}:{}", settings.docker_repo, cache_key));
let this_task_cacheable = task_data.cache && from_image_cacheable.get();
defer! {{
from_image.replace(to_image.borrow().clone());
from_image_cacheable.set(this_task_cacheable);
}}
if this_task_cacheable {
if settings.read_local_cache && runner::image_exists(&to_image.borrow())
{
info!("Task `{}` found in local cache.", task);
continue;
}
if settings.read_remote_cache {
info!("Attempting to fetch task `{}` from remote cache...", task);
if runner::pull_image(&to_image.borrow()).is_ok() {
info!("Task `{}` fetched from remote cache.", task);
continue;
}
info!("Task `{}` not found in remote cache.", task);
}
}
if !running.load(Ordering::SeqCst) {
return Err("Interrupted.".to_owned());
}
info!("Running task `{}`...", task);
runner::run(
task_data,
&from_image.borrow(),
&to_image.borrow(),
&env,
tar_file,
&running,
&active_containers,
)?;
if !running.load(Ordering::SeqCst) {
return Err("Interrupted.".to_owned());
}
if settings.write_remote_cache && this_task_cacheable {
info!("Writing to remote cache...");
match runner::push_image(&to_image.borrow()) {
Ok(()) => info!("Task `{}` pushed to remote cache.", task),
Err(e) => warn!("{}", e),
};
}
}
defer! {{
if !settings.write_local_cache || !from_image_cacheable.get() {
if let Err(e) = runner::delete_image(&from_image.borrow()) {
error!("{}", e);
}
}
}}
info!("{} finished.", format::number(schedule.len(), "task"));
if settings.spawn_shell {
info!("Here's a shell in the context of the tasks that were executed:");
runner::spawn_shell(&from_image.borrow())?;
}
Ok(())
}
fn entry() -> Result<(), String> {
set_up_logging();
let running = Arc::new(AtomicBool::new(true));
let active_containers = Arc::new(Mutex::new(HashSet::<String>::new()));
set_up_signal_handlers(running.clone(), active_containers.clone())?;
let settings = settings()?;
let bakefile = parse_bakefile(&settings.bakefile_path)?;
let root_tasks = get_roots(&settings, &bakefile)?;
let schedule = schedule::compute(&bakefile, &root_tasks);
info!(
"The following tasks will be executed in the order given: {}.",
format::series(
schedule
.iter()
.map(|task| format!("`{}`", task))
.collect::<Vec<_>>()
.as_ref()
)
);
let env = fetch_env(&schedule, &bakefile.tasks)?;
run_tasks(
&schedule,
&settings,
&bakefile,
&env,
&running,
&active_containers,
)?;
Ok(())
}
fn main() {
if let Err(e) = entry() {
error!("{}", e);
exit(1);
}
}