mod copy_module;
mod state;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use isoloom_manifest::{Action, ExpiryUnit, SnakeName, Step, Template, TerraformIdentity};
use super::{Attached, Attacker, Driver, Observed};
use crate::Result;
use crate::error::Error;
use crate::executor::Cx;
use crate::status::{MachineState, MachineStatus, ShellCommand};
const LOCK_FILE: &str = ".terraform.tfstate.lock.info";
const FORGOTTEN: [&str; 6] = [
state::STATE_FILE,
"terraform.tfstate.backup",
".terraform",
"known_hosts",
"ssh_config",
copy_module::COPY,
];
const PUBLISHED: &str = "published";
#[derive(Debug, Clone)]
pub(crate) struct Terraform {
identity: TerraformIdentity,
}
impl Terraform {
pub(crate) fn new(identity: TerraformIdentity) -> Terraform {
Terraform { identity }
}
fn module(&self, cx: &Cx<'_>) -> PathBuf {
cx.run.dir().join(self.identity.module.as_str())
}
}
pub(crate) fn plugin_cache(home: &Path) -> PathBuf {
home.join("terraform-plugins")
}
impl Driver for Terraform {
fn preflight(&self, cx: &Cx<'_>) -> impl Future<Output = Result<()>> {
let module = self.module(cx);
let copied = copied_from(cx).and_then(|from| copy_module::inside_project(cx.run.dir(), &from));
let has_tf = copy_module::has_terraform_files(copied.as_deref().unwrap_or(&module));
let missing = std::io::Error::new(
std::io::ErrorKind::NotFound,
"no Terraform module (no `.tf` file): generate the run's files again",
);
std::future::ready(if has_tf { Ok(()) } else { Err(Error::io(&module, "read")(missing)) })
}
fn prepare(&self, cx: &Cx<'_>) -> Result<()> {
let cache = plugin_cache(&cx.run.runner.config.home);
crate::store::private_dir(&cache).map_err(Error::io(&cache, "created"))
}
fn env(&self, cx: &Cx<'_>) -> Result<Vec<(String, String)>> {
let cache = plugin_cache(&cx.run.runner.config.home);
let mut env = vec![("TF_PLUGIN_CACHE_DIR".to_owned(), cache.display().to_string())];
env.extend(expiry_var(cx));
Ok(env)
}
fn action(&self, cx: &Cx<'_>, step: &Step, action: Action, with: &BTreeMap<SnakeName, Template>) -> impl Future<Output = Result<()>> {
let done = cx.run.runner.store.run_dir(cx.run.name()).and_then(|state_dir| match action {
Action::TerraformClearLock => remove(&state_dir.join(LOCK_FILE)),
Action::TerraformReadState => state::read(&state_dir).map(drop),
Action::TerraformCopyModule => {
let at = step.id.as_str();
let (from, when) = (cx.vars.with_arg(with, at, "from")?, cx.vars.with_arg(with, at, "when")?);
copy_module::run(cx, at, from.as_deref(), when.as_deref())
}
other => Err(super::foreign_action("Terraform", step, other)),
});
std::future::ready(done)
}
fn observe(&self, cx: &Cx<'_>) -> impl Future<Output = Result<Observed>> {
let read = cx.run.runner.store.run_dir(cx.run.name()).and_then(|dir| state::read(&dir));
std::future::ready(read.map(|tf| observed(cx.run.manifest(), &tf)))
}
fn after_down(&self, cx: &Cx<'_>) -> Result<()> {
let state_dir = cx.run.runner.store.run_dir(cx.run.name())?;
FORGOTTEN.iter().try_for_each(|f| remove(&state_dir.join(f)))
}
fn attach(&self, _cx: &Cx<'_>, _attacker: Attacker<'_>, _image: Option<&str>, _network: Option<&str>) -> impl Future<Output = Result<Attached>> {
std::future::ready(Err(Error::Unsupported {
what: "hot-adding an access machine on a cloud target".into(),
}))
}
fn detach(&self, _cx: &Cx<'_>, _attacker: Attacker<'_>) -> impl Future<Output = Result<()>> {
std::future::ready(Err(Error::Unsupported {
what: "hot-removing an access machine on a cloud target".into(),
}))
}
fn shell_command(&self, _cx: &Cx<'_>, _attacker: Attacker<'_>) -> Result<ShellCommand> {
Err(Error::Unsupported {
what: "a shell into a cloud access machine".into(),
})
}
}
fn observed(manifest: &isoloom_manifest::Manifest, tf: &state::TfState) -> Observed {
let machine_state = match (tf.resources, tf.tainted) {
(false, _) => MachineState::Absent,
(true, true) => MachineState::Unhealthy,
(true, false) => MachineState::Running,
};
let machines = manifest
.machines
.iter()
.map(|m| MachineStatus {
name: m.name.to_string(),
role: m.role,
state: machine_state.clone(),
addresses: m.addresses.iter().map(|(n, a)| (n.to_string(), a.to_string())).collect(),
published: Vec::new(),
})
.collect();
let published = tf
.maps
.get(PUBLISHED)
.into_iter()
.flatten()
.filter_map(|(key, address)| {
let (machine, port) = key.split_once('/')?;
Some(((machine.to_owned(), port.to_owned()), state::port_of(address)?))
})
.collect();
Observed {
machines,
published,
hosts: BTreeMap::new(),
outputs: tf.outputs.clone(),
sensitive: tf.sensitive.clone(),
}
}
fn copied_from(cx: &Cx<'_>) -> Option<String> {
let up = cx.run.manifest().lifecycle.up.as_slice();
up.iter().find_map(|s| match &s.run {
isoloom_manifest::Run::Action {
action: Action::TerraformCopyModule,
with,
} => cx.vars.with_arg(with, s.id.as_str(), "from").ok().flatten(),
_ => None,
})
}
fn expiry_var(cx: &Cx<'_>) -> Option<(String, String)> {
let expiry = cx.run.manifest().expiry.as_ref()?;
let at = cx.expires_at?;
let value = match expiry.unit {
ExpiryUnit::UnixSeconds => at.to_string(),
ExpiryUnit::MinutesFromNow => {
let now = crate::clock::unix_seconds(cx.run.runner.config.clock.now());
(at.saturating_sub(now) / 60).to_string()
}
};
Some((format!("TF_VAR_{}", expiry.tf_var), value))
}
fn remove(path: &Path) -> Result<()> {
let done = if path.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
};
match done {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(Error::io(path, "removed")(e)),
_ => Ok(()),
}
}
#[cfg(test)]
mod tests;