#![allow(clippy::result_large_err)]
use std::{
collections::HashSet,
path::{Path, PathBuf},
};
use tempfile::{tempdir_in, TempDir};
use crate::{
action::Context,
cloud_init::{CloudInitError, LocalDataStore, LocalDataStoreBuilder},
config::Config,
git::{git_head, git_is_clean, is_git, GitError},
plan::{construct_all_plans, PlanError, RunnablePlan},
project::{Project, ProjectError, Projects, State},
qemu::{QemuError, QemuRunner},
runlog::{RunLog, RunLogError, RunLogSource},
util::{mkdir, mkdir_child, recreate_dir, UtilError},
vdrive::{create_tar, create_tar_with_size, VirtualDrive, VirtualDriveError},
};
#[allow(clippy::too_many_arguments)]
pub fn cmd_run(
config: &Config,
runlog: &mut RunLog,
projects: &Projects,
chosen_projects: Option<&[String]>,
dry_run: bool,
force: bool,
uefi: bool,
) -> Result<(), RunError> {
let executor = config.executor().ok_or(RunError::NoRunCi)?;
eprintln!("executor from config: {}", executor.display());
let abs_executor = find_on_path(executor)?;
eprintln!("executor from PATH: {}", abs_executor.display());
let statedir = config.state();
if !statedir.exists() {
mkdir(statedir).map_err(|e| RunError::MkdirState(statedir.into(), e))?;
}
for (name, project) in chosen(projects, chosen_projects) {
let (do_run, mut state) = should_run(dry_run, force, statedir, name, project)?;
if do_run {
state.remove_run_log()?;
let log = state.create_run_log()?;
runlog.to_named_file(&log).map_err(RunError::RunLogCreate)?;
state.remove_raw_log()?;
let raw_log = state.create_raw_log()?;
let console_log = state.create_console_log()?;
runlog.run_ci(RunLogSource::Prelude, name);
let (pre_plan, mut plan, post_plan) =
construct_all_plans(config, name, project, &state)?;
let mut pre_plan_context = Context::new(runlog);
pre_plan.execute(RunLogSource::PrePlan, &mut pre_plan_context)?;
plan.carry_over_from_context(&pre_plan_context);
let tmp = tempdir_in(config.tmpdir()).map_err(RunError::TempDir)?;
let source_drive = create_source_vdrive(&tmp, project.source())?;
let deps_drive = create_dependencies_vdrive(&tmp, &state)?;
let executor_drive = create_executor_vdrive(&tmp, &plan, &abs_executor)?;
let artifactsdir = state.artifactsdir();
let artifact_drive = create_artifacts_vdrive(&tmp, config, project, &artifactsdir)?;
let cachedir = state.cachedir();
let cache_drive = create_cache_vdrive(&tmp, config, project, &cachedir)?;
let ds = create_cloud_init_iso(false)?;
let exit = QemuRunner::default()
.config(config)
.base_image(project.image())
.executor(&executor_drive)
.source(&source_drive)
.cache(&cache_drive)
.dependencies(&deps_drive)
.artifacts(&artifact_drive)
.raw_log(&raw_log)
.console_log(&console_log)
.cloud_init(&ds)
.uefi(uefi)
.run(RunLogSource::Plan, runlog)?;
if exit == 0 {
artifact_drive.extract_to(&artifactsdir)?;
if cachedir.exists() {
recreate_dir(&cachedir)?;
}
cache_drive
.extract_to(&cachedir)
.map_err(|e| QemuError::ExtractCache(cachedir.clone(), Box::new(e)))?;
post_plan.execute(RunLogSource::PostPlan, &mut Context::new(runlog))?;
}
if is_git(project.source()) {
let head = git_head(project.source())?;
state.set_latest_commot(Some(&head));
} else {
state.set_latest_commot(None);
};
state.write_to_file()?;
if exit != 0 {
return Err(RunError::RunFailed);
}
} else {
runlog.skip_ci(RunLogSource::Prelude, name);
}
}
Ok(())
}
fn find_on_path(bin: &Path) -> Result<PathBuf, RunError> {
let path = std::env::var_os("PATH").ok_or(RunError::NoPath)?;
for dir in std::env::split_paths(&path) {
let full = dir.join(bin);
if full.exists() {
return Ok(full);
}
}
Err(RunError::NotOnPath(bin.to_path_buf()))
}
pub fn create_cloud_init_iso(network: bool) -> Result<LocalDataStore, RunError> {
const BOOTSTRAP: &str = r#"
(set -xeu
env
dir="$(mktemp -d)"
cd "$dir"
tar -xvf /dev/vdb
find -ls || true
ldd ./run-ci || true
echo ================================ BEGIN ================================
export RUST_BACKTRACE=1
if ./run-ci; then
echo "EXIT CODE: 0"
else
echo "EXIT CODE: $?"
fi) > /dev/ttyS1 2>&1
"#;
LocalDataStoreBuilder::default()
.with_hostname("ambient")
.with_runcmd("echo xyzzy > /dev/ttyS1")
.with_runcmd(BOOTSTRAP)
.with_runcmd("poweroff")
.with_network(network)
.build()
.map_err(RunError::CloudInit)
}
pub fn create_source_vdrive(tmp: &TempDir, source_dir: &Path) -> Result<VirtualDrive, RunError> {
Ok(create_tar(tmp.path().join("src.tar"), source_dir)?)
}
fn create_artifacts_vdrive(
tmp: &TempDir,
config: &Config,
project: &Project,
artifactsdir: &Path,
) -> Result<VirtualDrive, RunError> {
recreate_dir(artifactsdir)?;
Ok(create_tar_with_size(
tmp.path().join("artifacts.tar"),
artifactsdir,
project
.artifact_max_size()
.unwrap_or(config.artifacts_max_size()),
)?)
}
fn create_dependencies_vdrive(tmp: &TempDir, state: &State) -> Result<VirtualDrive, RunError> {
let dependencies = state.dependenciesdir();
if !dependencies.exists() {
mkdir(&dependencies)
.map_err(|e| RunError::MkdirProjectSubState(dependencies.clone(), e))?;
}
Ok(create_tar(tmp.path().join("deps.tar"), &dependencies)?)
}
fn create_cache_vdrive(
tmp: &TempDir,
config: &Config,
project: &Project,
cachedir: &Path,
) -> Result<VirtualDrive, RunError> {
if !cachedir.exists() {
mkdir(cachedir).map_err(|e| RunError::MkdirProjectSubState(cachedir.into(), e))?;
}
Ok(create_tar_with_size(
tmp.path().join("cache.tar"),
cachedir,
project.cache_max_size().unwrap_or(config.cache_max_size()),
)?)
}
pub fn create_executor_vdrive(
tmp: &TempDir,
plan: &RunnablePlan,
executor: &Path,
) -> Result<VirtualDrive, RunError> {
assert!(executor.exists());
let dirname =
mkdir_child(tmp.path(), "ambient-execute-plan").map_err(RunError::MkdirProjectRunCi)?;
let bin2 = dirname.join("run-ci");
std::fs::copy(executor, &bin2).map_err(|e| RunError::Copy(executor.into(), bin2.clone(), e))?;
let plan_filename = dirname.join("plan.yaml");
plan.to_file(&plan_filename)?;
Ok(create_tar(tmp.path().join("executor.tar"), &dirname)?)
}
fn should_run(
dry_run: bool,
force: bool,
statedir: &Path,
name: &str,
project: &Project,
) -> Result<(bool, State), RunError> {
let mut decision = Decision::default();
let state = State::from_file(statedir, name)?;
if let Some(latest_commit) = state.latest_commit() {
decision.latest_commit(latest_commit);
} else {
}
let is_git = is_git(project.source());
if is_git {
decision.is_git();
} else {
decision.is_not_git();
}
if git_is_clean(project.source()) {
decision.is_clean();
} else {
decision.is_dirty();
}
if is_git {
let head = git_head(project.source())?;
decision.current_commit(&head);
} else {
}
if dry_run {
decision.dry_run();
} else {
decision.no_dry_run();
}
if force {
decision.force();
} else {
decision.no_force();
}
let do_run = decision.should_run() == ShouldRun::Run;
Ok((do_run, state))
}
fn chosen<'a>(projects: &'a Projects, chosen: Option<&[String]>) -> Vec<(&'a str, &'a Project)> {
let set: HashSet<&str> = match chosen {
Some(v) if !v.is_empty() => v.iter().map(|s| s.as_str()).collect(),
_ => projects.iter().map(|(k, _)| k).collect(),
};
let mut projects: Vec<(&'a str, &'a Project)> =
projects.iter().filter(|(k, _)| set.contains(k)).collect();
projects.sort_by(|(a_name, _), (b_name, _)| a_name.cmp(b_name));
projects
}
#[derive(Debug, thiserror::Error)]
pub enum RunError {
#[error(transparent)]
Project(#[from] ProjectError),
#[error(transparent)]
Util(#[from] UtilError),
#[error("failed to create a context for executing actions")]
Context(#[source] crate::action::ActionError),
#[error("failed to create temporary directory for running CI build")]
TempDir(#[source] std::io::Error),
#[error("failed to create a general state directory")]
MkdirState(PathBuf, #[source] UtilError),
#[error("failed to create a project state sub-directory")]
MkdirProjectSubState(PathBuf, #[source] UtilError),
#[error("failed to create a temporary directory for action runner")]
MkdirProjectRunCi(#[source] UtilError),
#[error("failed to create a cloud-init ISO file")]
CloudInit(#[source] CloudInitError),
#[error("virtual drive is too big: {0} > {1}")]
DriveTooBig(u64, u64),
#[error(transparent)]
Qemu(#[from] QemuError),
#[error("CI run failed inside QEMU")]
RunFailed,
#[error("failed to copy {0} to {1}")]
Copy(PathBuf, PathBuf, #[source] std::io::Error),
#[error(transparent)]
Plan(#[from] PlanError),
#[error(transparent)]
Git(#[from] GitError),
#[error(transparent)]
VDrive(#[from] VirtualDriveError),
#[error("you must set path to ambient-execute-plan program with option or in configuration")]
NoRunCi,
#[error("failed to open run log")]
RunLogCreate(#[source] RunLogError),
#[error("failed to find PATH in the environment")]
NoPath,
#[error("failed to find {0} on PATH")]
NotOnPath(PathBuf),
}
#[derive(Debug, Default)]
struct Decision {
dry_run: Option<bool>,
force_run: Option<bool>,
is_git: Option<bool>,
latest_commit: Option<String>,
current_commit: Option<String>,
source_is_dirty: Option<bool>,
}
impl Decision {
fn dry_run(&mut self) {
self.dry_run = Some(true);
}
fn no_dry_run(&mut self) {
self.dry_run = Some(false);
}
fn force(&mut self) {
self.force_run = Some(true);
}
fn no_force(&mut self) {
self.force_run = Some(false);
}
fn is_git(&mut self) {
self.is_git = Some(true);
}
fn is_not_git(&mut self) {
self.is_git = Some(false);
}
fn latest_commit(&mut self, commit: &str) {
self.latest_commit = Some(commit.into());
}
fn current_commit(&mut self, commit: &str) {
self.current_commit = Some(commit.into());
}
fn is_clean(&mut self) {
self.source_is_dirty = Some(false);
}
fn is_dirty(&mut self) {
self.source_is_dirty = Some(true);
}
fn should_run(&self) -> ShouldRun {
let dry_run = self.dry_run == Some(true);
let force = self.force_run == Some(true);
let is_git = self.is_git == Some(true);
let dirty = self.source_is_dirty == Some(true);
if dry_run {
Self::log("dry run");
ShouldRun::DontRun
} else if force {
Self::log("force");
ShouldRun::Run
} else if !is_git {
Self::log("not git");
ShouldRun::Run
} else if dirty {
Self::log("dirty");
ShouldRun::Run
} else if self.current_commit == self.latest_commit {
Self::log("commits are equal");
ShouldRun::DontRun
} else {
Self::log("nothing prevents run");
ShouldRun::Run
}
}
#[allow(unused_variables)]
fn log(msg: &str) {
#[cfg(test)]
println!("{msg}");
}
}
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
enum ShouldRun {
Run,
DontRun,
}
#[cfg(test)]
mod test_run_decision {
use super::{Decision, ShouldRun};
#[test]
fn is_not_git() {
let mut d = Decision::default();
d.no_dry_run();
d.no_force();
d.is_not_git();
d.is_clean();
assert_eq!(d.should_run(), ShouldRun::Run);
}
#[test]
fn unchanged() {
let mut d = Decision::default();
d.no_dry_run();
d.no_force();
d.is_git();
d.is_clean();
d.latest_commit("abcd");
d.current_commit("abcd");
assert_eq!(d.should_run(), ShouldRun::DontRun);
}
#[test]
fn unchanged_with_force() {
let mut d = Decision::default();
d.no_dry_run();
d.force();
d.is_git();
d.is_clean();
d.latest_commit("abcd");
d.current_commit("abcd");
assert_eq!(d.should_run(), ShouldRun::Run);
}
#[test]
fn unchanged_commit_but_dirty() {
let mut d = Decision::default();
d.no_dry_run();
d.no_force();
d.is_git();
d.is_dirty();
d.latest_commit("abcd");
d.current_commit("abcd");
assert_eq!(d.should_run(), ShouldRun::Run);
}
#[test]
fn commit_changed() {
let mut d = Decision::default();
d.no_dry_run();
d.no_force();
d.is_git();
d.is_clean();
d.latest_commit("abcd");
d.current_commit("efgh");
assert_eq!(d.should_run(), ShouldRun::Run);
}
#[test]
fn dry_run_for_unchanged() {
let mut d = Decision::default();
d.dry_run();
d.no_force();
d.is_git();
d.is_clean();
d.latest_commit("abcd");
d.current_commit("abcd");
assert_eq!(d.should_run(), ShouldRun::DontRun);
}
#[test]
fn dry_run_for_unchanged_but_dirty() {
let mut d = Decision::default();
d.dry_run();
d.no_force();
d.is_git();
d.is_dirty();
d.latest_commit("abcd");
d.current_commit("efgh");
assert_eq!(d.should_run(), ShouldRun::DontRun);
}
#[test]
fn dry_run_for_commit_changed() {
let mut d = Decision::default();
d.dry_run();
d.no_force();
d.is_git();
d.is_clean();
d.latest_commit("abcd");
d.current_commit("efgh");
assert_eq!(d.should_run(), ShouldRun::DontRun);
}
#[test]
fn dry_run_for_unchanged_with_force() {
let mut d = Decision::default();
d.dry_run();
d.no_force();
d.is_git();
d.is_clean();
d.latest_commit("abcd");
d.current_commit("abcd");
assert_eq!(d.should_run(), ShouldRun::DontRun);
}
#[test]
fn dry_run_for_unchanged_but_dirty_with_force() {
let mut d = Decision::default();
d.dry_run();
d.no_force();
d.is_git();
d.is_dirty();
d.latest_commit("abcd");
d.current_commit("efgh");
assert_eq!(d.should_run(), ShouldRun::DontRun);
}
#[test]
fn dry_run_for_commit_changed_with_force() {
let mut d = Decision::default();
d.dry_run();
d.no_force();
d.is_git();
d.is_clean();
d.latest_commit("abcd");
d.current_commit("efgh");
assert_eq!(d.should_run(), ShouldRun::DontRun);
}
}