mod actions;
mod forwarded;
mod orphans;
mod status;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::Duration;
use isoloom_manifest::{Action, Native, SnakeName, Step, Template, VagrantIdentity, VagrantProvider, access};
use super::{Attached, Attacker, Driver, Observed};
use crate::Result;
use crate::error::Error;
use crate::executor::{Cx, Echo, call_ok, step_env};
use crate::process::ProcessSpec;
use crate::scope::provider_id;
use crate::state::Op;
use crate::status::{MachineStatus, PublishedPort, ShellCommand};
const DOTFILE_VAR: &str = "VAGRANT_DOTFILE_PATH";
const READ_TIMEOUT: Duration = Duration::from_secs(120);
const ATTACH_TIMEOUT: Duration = Duration::from_secs(1800);
const DETACH_TIMEOUT: Duration = Duration::from_secs(300);
const PLUGINS: &str = "plugins";
const ESXI: &str = "vmware_esxi";
const LOOPBACK: &str = "127.0.0.1";
#[derive(Debug, Clone)]
pub(crate) struct Vagrant {
identity: VagrantIdentity,
}
impl Vagrant {
pub(crate) fn new(identity: VagrantIdentity) -> Vagrant {
Vagrant { identity }
}
fn dotfile(&self, cx: &Cx<'_>) -> Result<PathBuf> {
match &self.identity.dotfile {
Some(t) => Ok(cx.run.dir().join(cx.vars.render(t.template(), "identity.vagrant.dotfile")?)),
None => Ok(cx.run.dir().join(".vagrant")),
}
}
pub(crate) fn elevates(&self, scope: &crate::scope::Scope) -> bool {
let asked = scope.provider.as_deref();
self.identity
.providers
.iter()
.any(|p| Some(provider_id(p.id).as_str()) == asked && p.elevation == isoloom_manifest::Elevation::Hyperv)
}
fn provider(&self, cx: &Cx<'_>) -> Result<&VagrantProvider> {
let asked = cx.scope.provider.as_deref().ok_or_else(|| Error::Provider {
provider: String::new(),
reason: "a Vagrant run needs a provider: pass one of the manifest's (`virtualbox`, `libvirt`, โฆ)".into(),
})?;
self.identity
.providers
.iter()
.find(|p| provider_id(p.id) == asked)
.ok_or_else(|| Error::Provider {
provider: asked.to_owned(),
reason: "isn't one of this run's providers".into(),
})
}
fn vagrant(&self, cx: &Cx<'_>, id: &str, args: &[&str]) -> Result<ProcessSpec> {
let mut env = self.env(cx)?;
env.extend(step_env(cx, id));
env.extend(cx.relayed.iter().flat_map(|r| r.env.iter().cloned()));
Ok(ProcessSpec {
program: "vagrant".into(),
args: args.iter().map(|a| (*a).to_owned()).collect(),
cwd: Some(cx.run.dir().to_path_buf()),
env,
stdin: None,
})
}
async fn guest_addresses(&self, cx: &Cx<'_>, read: &status::Reading) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let manifest = cx.run.manifest();
for m in manifest.machines.iter().filter(|m| answers(manifest, m)) {
let define = define_name(m);
if read.states.get(define).map(|s| status::machine_state(s)) != Some(crate::status::MachineState::Running) {
continue;
}
let Ok(spec) = self.vagrant(cx, "status", &["ssh-config", define]) else {
continue;
};
if let Ok(text) = call_ok(cx, "status", &spec, READ_TIMEOUT, Echo::Quiet).await
&& let Some(host) = status::host_name(&text)
{
out.insert(define.to_owned(), host);
}
}
out
}
async fn plugins(&self, cx: &Cx<'_>) -> Result<Vec<String>> {
let spec = self.vagrant(cx, PLUGINS, &["plugin", "list", "--machine-readable"])?;
let out = call_ok(cx, PLUGINS, &spec, READ_TIMEOUT, Echo::Quiet).await?;
Ok(status::plugins(&out))
}
}
impl Driver for Vagrant {
async fn preflight(&self, cx: &Cx<'_>) -> Result<()> {
let p = self.provider(cx)?;
let id = provider_id(p.id);
let refuse = |reason: String| Error::Provider { provider: id.clone(), reason };
if !p.hosts.contains(&cx.scope.host) {
return Err(refuse("doesn't exist on this host".into()));
}
if let Some(why) = &p.refusal {
return Err(refuse(format!("this run can't run there: {why}")));
}
if let Some(plugin) = &p.plugin
&& !self.plugins(cx).await?.iter().any(|name| name == plugin.as_str())
{
return Err(refuse(format!("needs the Vagrant plugin `{plugin}`: `vagrant plugin install {plugin}`")));
}
Ok(())
}
fn prepare(&self, cx: &Cx<'_>) -> Result<()> {
let dotfile = self.dotfile(cx)?;
match dotfile.parent() {
Some(parent) if parent.starts_with(cx.run.runner.config.home.as_path()) => crate::store::private_dir(parent).map_err(Error::io(parent, "created")),
_ => Ok(()),
}
}
fn env(&self, cx: &Cx<'_>) -> Result<Vec<(String, String)>> {
Ok(vec![(DOTFILE_VAR.to_owned(), self.dotfile(cx)?.display().to_string())])
}
async fn action(&self, cx: &Cx<'_>, step: &Step, action: Action, with: &BTreeMap<SnakeName, Template>) -> Result<()> {
match action {
Action::VagrantReconcileProvider => actions::reconcile_provider(self, cx, step).await,
Action::VagrantDiscardAbortedSaved => actions::discard_aborted_saved(cx, step).await,
Action::VagrantRemoveOrphans => orphans::remove(cx, step).await,
Action::VagrantResetState => actions::reset_state(self, cx),
Action::VagrantWakeController => actions::wake_controller(self, cx, step, with).await,
Action::VagrantParkController => actions::park_controller(self, cx, step, with).await,
Action::VagrantCheckForwardedPorts => forwarded::check(cx, step).await,
other => Err(super::foreign_action("Vagrant", step, other)),
}
}
async fn observe(&self, cx: &Cx<'_>) -> Result<Observed> {
let out = super::status_report(cx).await?;
let read = status::parse(&out);
let guests = if cx.scope.provider.as_deref() == Some(ESXI) {
Some(self.guest_addresses(cx, &read).await)
} else {
None
};
Ok(machines(cx.run.manifest(), &read, guests.as_ref()))
}
fn after_down(&self, _cx: &Cx<'_>) -> Result<()> {
Ok(())
}
async fn attach(&self, cx: &Cx<'_>, attacker: Attacker<'_>, image: Option<&str>, _network: Option<&str>) -> Result<Attached> {
let native = adhoc_unsupported(attacker, "hot-adding an ad-hoc access VM (this lab has no on-demand access machine)")?;
let provider = cx.scope.provider.as_ref().map(|p| format!("--provider={p}"));
let mut args = vec!["up", native];
if let Some(flag) = &provider {
args.push(flag);
}
let mut spec = self.vagrant(cx, Op::Attach.as_str(), &args)?;
if let Some(box_name) = image {
spec.env.push((access::BOX_ENV.to_owned(), box_name.to_owned()));
}
call_ok(cx, Op::Attach.as_str(), &spec, ATTACH_TIMEOUT, Echo::Shown).await?;
Ok(Attached::default())
}
async fn detach(&self, cx: &Cx<'_>, attacker: Attacker<'_>) -> Result<()> {
let native = adhoc_unsupported(attacker, "hot-removing an ad-hoc access VM (this lab has no on-demand access machine)")?;
let spec = self.vagrant(cx, Op::Detach.as_str(), &["destroy", "-f", native])?;
call_ok(cx, Op::Detach.as_str(), &spec, DETACH_TIMEOUT, Echo::Shown).await?;
Ok(())
}
fn shell_command(&self, cx: &Cx<'_>, attacker: Attacker<'_>) -> Result<ShellCommand> {
let native = adhoc_unsupported(attacker, "a shell into an ad-hoc access VM (this lab has no on-demand access machine)")?;
Ok(ShellCommand {
program: "vagrant".into(),
args: vec!["ssh".into(), native.into()],
cwd: Some(cx.run.dir().to_path_buf()),
})
}
}
fn adhoc_unsupported<'a>(attacker: Attacker<'a>, what: &str) -> Result<&'a str> {
match attacker {
Attacker::Machine(native) => Ok(native),
Attacker::AdHoc => Err(Error::Unsupported { what: what.to_owned() }),
}
}
fn answers(manifest: &isoloom_manifest::Manifest, m: &isoloom_manifest::Machine) -> bool {
m.ports.iter().any(|p| p.publish.is_some()) || manifest.guest.as_ref().is_some_and(|g| g.machine == m.name)
}
fn define_name(m: &isoloom_manifest::Machine) -> &str {
match &m.native {
Some(Native::Vagrant { vagrant, .. }) => vagrant.as_str(),
_ => m.name.as_str(),
}
}
fn machines(manifest: &isoloom_manifest::Manifest, read: &status::Reading, guests: Option<&BTreeMap<String, String>>) -> Observed {
let mut out = Observed::default();
for m in &manifest.machines {
let define = define_name(m);
let state = read
.states
.get(define)
.map_or(crate::status::MachineState::Absent, |s| status::machine_state(s));
let host = match guests {
Some(guests) => guests.get(define).cloned(),
None => Some(LOOPBACK.to_owned()),
};
let mut published = Vec::new();
let host = host.filter(|_| state != crate::status::MachineState::Absent);
if let Some(host) = host.as_ref().filter(|_| answers(manifest, m)) {
out.hosts.insert(m.name.to_string(), host.clone());
}
if host.is_some() {
for p in &m.ports {
let Some(forwarded) = p.publish.as_ref().and_then(|x| x.preferred) else {
continue;
};
let host_port = if guests.is_some() { p.port.get() } else { forwarded.get() };
published.push(PublishedPort {
port: p.port.get(),
protocol: p.kind.transport(),
host_port,
});
out.published.insert((m.name.to_string(), p.name.to_string()), host_port);
}
}
out.machines.push(MachineStatus {
name: m.name.to_string(),
role: m.role,
state,
addresses: m.addresses.iter().map(|(n, a)| (n.to_string(), a.to_string())).collect(),
published,
});
}
out
}
#[cfg(test)]
mod tests;