use std::collections::BTreeMap;
use std::path::Path;
use std::time::Duration;
use isoloom_manifest::{Provider, SnakeName, Step, Template};
use super::{READ_TIMEOUT, Vagrant};
use crate::Result;
use crate::error::Error;
use crate::events::{Stream, WarningCode};
use crate::executor::{Cx, Echo, call, call_ok};
use crate::process::{Captured, ProcessSpec};
const RECONCILE_DESTROY: Duration = Duration::from_secs(180);
const CONTROLLER: &str = "isoloom-controller";
const HALTING: &str = "/run/isoloom-halting";
const HALT_POLLS: u32 = 60;
const BOOT_TIMEOUT: Duration = Duration::from_secs(1800);
const HALT_TIMEOUT: Duration = Duration::from_secs(300);
pub(super) fn recorded_providers(dotfile: &Path) -> Vec<String> {
let Ok(machines) = std::fs::read_dir(dotfile.join("machines")) else {
return Vec::new();
};
let mut out: Vec<String> = machines
.flatten()
.filter_map(|m| std::fs::read_dir(m.path()).ok())
.flat_map(std::iter::Iterator::flatten)
.filter(|p| p.path().join("id").is_file())
.map(|p| p.file_name().to_string_lossy().into_owned())
.collect();
out.sort();
out.dedup();
out
}
pub(super) async fn reconcile_provider(v: &Vagrant, cx: &Cx<'_>, step: &Step) -> Result<()> {
let dotfile = v.dotfile(cx)?;
let asked = cx.scope.provider.clone().unwrap_or_default();
if !recorded_providers(&dotfile).iter().any(|p| *p != asked) {
return Ok(());
}
let id = step.id.as_str();
cx.events
.warning(WarningCode::OtherProvider, "this run last ran on another provider: clearing that run first");
let destroy = v.vagrant(cx, id, &["destroy", "--force"])?;
if let Err(e) = call(cx, id, &destroy, RECONCILE_DESTROY, Echo::Shown).await {
cx.events.warning(WarningCode::StepTolerated, &e.to_string());
}
remove_dir(&dotfile)
}
pub(super) fn reset_state(v: &Vagrant, cx: &Cx<'_>) -> Result<()> {
remove_dir(&v.dotfile(cx)?)
}
fn remove_dir(dir: &Path) -> Result<()> {
match std::fs::symlink_metadata(dir) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(Error::io(dir, "removed")(e)),
Ok(m) if m.file_type().is_symlink() || m.is_file() => std::fs::remove_file(dir).map_err(Error::io(dir, "removed")),
Ok(_) => std::fs::remove_dir_all(dir).map_err(Error::io(dir, "removed")),
}
}
pub(super) fn vm_names(cx: &Cx<'_>, provider: Provider) -> Vec<String> {
cx.run
.manifest()
.machines
.iter()
.filter_map(|m| match &m.native {
Some(isoloom_manifest::Native::Vagrant { vm_names, .. }) => vm_names.get(&provider).cloned(),
_ => None,
})
.collect()
}
pub(super) fn vboxmanage(args: &[&str]) -> ProcessSpec {
ProcessSpec::new("VBoxManage", args.iter().copied())
}
pub(super) async fn discard_aborted_saved(cx: &Cx<'_>, step: &Step) -> Result<()> {
let id = step.id.as_str();
for name in vm_names(cx, Provider::Virtualbox) {
let info = call(cx, id, &vboxmanage(&["showvminfo", &name, "--machinereadable"]), READ_TIMEOUT, Echo::Quiet).await?;
if !info.exit.is_some_and(crate::process::Exit::success) || !aborted_saved(&info.stdout) {
continue;
}
cx.events.warning(
WarningCode::SavedStateDiscarded,
&format!("{name}: its saved state was cut off; it boots fresh"),
);
call_ok(cx, id, &vboxmanage(&["discardstate", &name]), READ_TIMEOUT, Echo::Quiet).await?;
}
Ok(())
}
pub(super) fn aborted_saved(lines: &[String]) -> bool {
lines.iter().any(|l| l.trim() == "VMState=\"aborted-saved\"")
}
pub(super) async fn wake_controller(v: &Vagrant, cx: &Cx<'_>, step: &Step, with: &BTreeMap<SnakeName, Template>) -> Result<()> {
let id = step.id.as_str();
let machine = controller(cx, with)?;
for _ in 0..HALT_POLLS {
if !running(v, cx, id, &machine).await? {
break;
}
let probe = v.vagrant(cx, id, &["ssh", &machine, "-c", &format!("test -e {HALTING}")])?;
let halting = call(cx, id, &probe, READ_TIMEOUT, Echo::Quiet).await?;
if !halting.exit.is_some_and(crate::process::Exit::success) {
return Ok(());
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
let limit = step.timeout_s.map_or(BOOT_TIMEOUT, |s| Duration::from_secs(u64::from(s.get())));
let up = v.vagrant(cx, id, &["up", &machine, "--no-provision"])?;
call_ok(cx, id, &up, limit, Echo::Shown).await.map(drop)
}
const ESXI_ALREADY_OFF: [&str; 2] = ["existingState = \"poweredOff\"", "in the current state (Powered off)"];
pub(super) async fn park_controller(v: &Vagrant, cx: &Cx<'_>, step: &Step, with: &BTreeMap<SnakeName, Template>) -> Result<()> {
let id = step.id.as_str();
let machine = controller(cx, with)?;
let limit = step.timeout_s.map_or(HALT_TIMEOUT, |s| Duration::from_secs(u64::from(s.get())));
let halt = call(cx, id, &v.vagrant(cx, id, &["halt", &machine])?, limit, Echo::Quiet).await?;
let halted = halt.exit.is_some_and(crate::process::Exit::success);
if !halted && already_off(&halt) {
cx.events.log(id, Stream::Stdout, &format!("{machine} was already off."));
return Ok(());
}
let said = halt
.stdout
.iter()
.map(|l| (Stream::Stdout, l))
.chain(halt.stderr.iter().map(|l| (Stream::Stderr, l)));
for (stream, line) in said {
cx.events.log(id, stream, line);
}
if halted {
return Ok(());
}
for _ in 0..HALT_POLLS {
if !running(v, cx, id, &machine).await? {
return Ok(());
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
Err(Error::ToolFailed {
step: id.to_owned(),
reason: cx.events.mask(&format!("`vagrant halt {machine}` failed: {}", halt.stderr.join("\n").trim())),
})
}
fn already_off(halt: &Captured) -> bool {
halt.stdout
.iter()
.chain(&halt.stderr)
.any(|line| ESXI_ALREADY_OFF.iter().any(|said| line.contains(said)))
}
fn controller(cx: &Cx<'_>, with: &BTreeMap<SnakeName, Template>) -> Result<String> {
match with.iter().find(|(k, _)| k.as_str() == "machine") {
Some((_, t)) => cx.vars.render(t, "with.machine"),
None => Ok(CONTROLLER.to_owned()),
}
}
async fn running(v: &Vagrant, cx: &Cx<'_>, id: &str, machine: &str) -> Result<bool> {
let spec = v.vagrant(cx, id, &["status", machine, "--machine-readable"])?;
let status = call_ok(cx, id, &spec, READ_TIMEOUT, Echo::Quiet).await?;
Ok(super::status::parse(&status).states.get(machine).map(String::as_str) == Some("running"))
}