use std::collections::{BTreeMap, BTreeSet};
use nerpa_core::{Target, Value as CoreValue};
#[derive(Debug, Default, Clone)]
pub struct Fleet {
nodes: BTreeMap<Target, Machine>,
groups: BTreeMap<String, BTreeSet<Target>>,
approvers: Option<String>,
cloud: Option<Cloud>,
}
#[derive(Debug, Clone)]
pub struct Cloud {
pub folder_id: String,
pub iam_token: Option<String>,
}
#[derive(Debug, Default, Clone)]
struct Machine {
groups: Vec<String>,
values: BTreeMap<String, CoreValue>,
route: Route,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub enum Route {
#[default]
Here,
Ssh {
host: String,
config: Option<String>,
executor: Option<String>,
escalate: Vec<String>,
},
}
impl Fleet {
#[must_use]
pub fn empty() -> Self {
Self::default()
}
#[dacc_derive::doc_anchor(id = "inv-fleet-001")]
pub fn read(text: &str) -> Result<Self, String> {
let document: toml::Value =
toml::from_str(text).map_err(|error| format!("the fleet is not TOML: {error}"))?;
let mut groups: BTreeMap<String, BTreeMap<String, CoreValue>> = BTreeMap::new();
if let Some(table) = document.get("groups").and_then(toml::Value::as_table) {
for (name, values) in table {
groups.insert(name.clone(), read_values(name, values)?);
}
}
let mut fleet = Self {
approvers: read_approval(&document)?,
cloud: read_cloud(&document)?,
..Self::default()
};
let Some(nodes) = document.get("nodes").and_then(toml::Value::as_table) else {
return Ok(fleet);
};
for (name, described) in nodes {
let node = Target::new(name).map_err(|error| format!("{name:?}: {error}"))?;
for field in described
.as_table()
.into_iter()
.flatten()
.map(|(key, _)| key)
{
if !matches!(
field.as_str(),
"groups" | "values" | "ssh" | "ssh_config" | "executor" | "become"
) {
return Err(format!(
"{name}: {field:?} is not a field of a node — the port, the jump \
host and the key belong to `ssh` and are named in `ssh_config`"
));
}
}
let belongs: Vec<String> = described
.get("groups")
.and_then(toml::Value::as_array)
.map_or_else(
Vec::new,
|listed| {
listed
.iter()
.filter_map(|entry| entry.as_str().map(ToOwned::to_owned))
.collect()
},
);
for group in &belongs {
if !groups.contains_key(group) {
return Err(format!(
"{name} is in group {group:?}, which nothing describes"
));
}
fleet
.groups
.entry(group.clone())
.or_default()
.insert(node.clone());
}
let mut values: BTreeMap<String, CoreValue> = BTreeMap::new();
let mut whence: BTreeMap<String, String> = BTreeMap::new();
for group in &belongs {
for (key, value) in groups.get(group).into_iter().flatten() {
if let Some(first) = whence.get(key) {
return Err(format!(
"{name} is in {first:?} and {group:?}, and both give it {key:?}; \
one of them has to stop, or the node has to say which"
));
}
whence.insert(key.clone(), group.clone());
values.insert(key.clone(), value.clone());
}
}
if let Some(own) = described.get("values") {
values.extend(read_values(name, own)?);
}
fleet.nodes.insert(
node,
Machine {
groups: belongs,
values,
route: read_route(name, described)?,
},
);
}
Ok(fleet)
}
#[must_use]
pub(crate) fn group(&self, name: &str) -> Option<Vec<Target>> {
if !self.groups.contains_key(name)
&& !self
.nodes
.values()
.any(|machine| machine.groups.iter().any(|held| held == name))
{
return None;
}
Some(self.groups.get(name).map_or_else(
Vec::new,
|members| members.iter().cloned().collect(),
))
}
#[must_use]
pub fn route(&self, node: &Target) -> Route {
self.nodes.get(node).map_or_else(
Route::default,
|machine| machine.route.clone(),
)
}
#[must_use]
pub fn approvers(&self) -> Option<&str> {
self.approvers.as_deref()
}
#[must_use]
pub fn cloud(&self) -> Option<&Cloud> {
self.cloud.as_ref()
}
#[must_use]
pub(crate) fn values_of(&self, node: &Target) -> BTreeMap<String, CoreValue> {
self.nodes.get(node).map_or_else(
BTreeMap::new,
|machine| machine.values.clone(),
)
}
#[must_use]
pub fn len(&self) -> usize {
self.nodes.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
}
fn read_cloud(document: &toml::Value) -> Result<Option<Cloud>, String> {
let Some(cloud) = document.get("cloud").and_then(toml::Value::as_table) else {
return Ok(None);
};
let Some(yandex) = cloud.get("yandex").and_then(toml::Value::as_table) else {
return Ok(None);
};
for (field, _) in yandex {
if !matches!(field.as_str(), "folder_id" | "iam_token") {
return Err(format!(
"cloud.yandex: {field:?} is not a field of the cloud — a run needs its \
`folder_id` and its `iam_token`, and that is the whole of it"
));
}
}
let folder_id = yandex
.get("folder_id")
.and_then(toml::Value::as_str)
.ok_or_else(|| "cloud.yandex: `folder_id` is required".to_owned())?;
let iam_token = yandex
.get("iam_token")
.map(|value| {
value
.as_str()
.map(str::to_owned)
.ok_or_else(|| "cloud.yandex: `iam_token` must be text".to_owned())
})
.transpose()?;
Ok(Some(Cloud {
folder_id: folder_id.to_owned(),
iam_token,
}))
}
fn read_approval(document: &toml::Value) -> Result<Option<String>, String> {
let Some(table) = document.get("approval").and_then(toml::Value::as_table) else {
return Ok(None);
};
for (field, _) in table {
if field != "approvers" {
return Err(format!(
"approval: {field:?} is not a field of approval — who may sign is \
an `allowed_signers` file, and that is the whole of it"
));
}
}
match table.get("approvers") {
Some(named) => named.as_str().map(str::to_owned).map(Some).ok_or_else(|| {
"approval: `approvers` has to be a path to an `allowed_signers` file".to_owned()
}),
None => {
Err("approval: say who may sign — `approvers = \"/etc/nerpa/approvers\"`".to_owned())
}
}
}
fn read_route(name: &str, described: &toml::Value) -> Result<Route, String> {
let Some(host) = described.get("ssh") else {
return Ok(Route::Here);
};
let host = host
.as_str()
.ok_or_else(|| format!("{name}: `ssh` has to be a host or an alias"))?
.to_owned();
if host.starts_with('-') {
return Err(format!(
"{name}: `ssh` is {host:?}, which `ssh` would read as an option \
rather than as a host"
));
}
let config = described
.get("ssh_config")
.map(|given| {
given
.as_str()
.map(ToOwned::to_owned)
.ok_or_else(|| format!("{name}: `ssh_config` has to be a path"))
})
.transpose()?;
let executor = described
.get("executor")
.map(|given| {
let path = given
.as_str()
.ok_or_else(|| format!("{name}: `executor` has to be a path"))?;
plainly(name, "executor", path)?;
if !path.starts_with('/')
|| path.contains('~')
|| path.split('/').any(|part| part == "." || part == "..")
{
return Err(format!(
"{name}: `executor` is {path:?}, and it has to be an absolute path \
with no `~`, `.` or `..` in it: a remote shell resolves anything \
else from a directory the connecting user writes"
));
}
Ok::<String, String>(path.to_owned())
})
.transpose()?;
let escalate = described.get("become").map_or_else(
|| Ok::<Vec<String>, String>(Vec::new()),
|given| {
let written = given.as_str().ok_or_else(|| {
format!("{name}: `become` has to be a command such as \"sudo -n\"")
})?;
let words: Vec<String> = written.split_whitespace().map(ToOwned::to_owned).collect();
for word in &words {
plainly(name, "become", word)?;
}
Ok(words)
},
)?;
if !escalate.is_empty() && executor.is_none() {
return Err(format!(
"{name}: `become` needs an `executor` that root put there. Nerpa \
delivers one into the connecting user's own directory, and letting \
that user run it under sudo gives them root by another name — the \
rule would say one path and mean everything. Install it once \
(`/usr/local/sbin/nerpa-executor`, owned by root) and name it \
here"
));
}
Ok(Route::Ssh {
host,
config,
executor,
escalate,
})
}
fn plainly(whose: &str, field: &str, word: &str) -> Result<(), String> {
const PLAIN: fn(char) -> bool = |character: char| {
character.is_ascii_alphanumeric() || matches!(character, '/' | '.' | '-' | '_' | '~' | '=')
};
if word.is_empty() || !word.chars().all(PLAIN) {
return Err(format!(
"{whose}: `{field}` is {word:?}, and a remote shell would read that \
as more than the one word it is meant to be"
));
}
Ok(())
}
fn read_values(whose: &str, table: &toml::Value) -> Result<BTreeMap<String, CoreValue>, String> {
let Some(table) = table.as_table() else {
return Err(format!("{whose}: values have to be a table"));
};
table
.iter()
.map(|(key, value)| Ok((key.clone(), as_core(whose, key, value)?)))
.collect()
}
fn as_core(whose: &str, key: &str, value: &toml::Value) -> Result<CoreValue, String> {
Ok(match value {
toml::Value::String(text) => CoreValue::Text(text.clone()),
toml::Value::Integer(number) => CoreValue::Integer(*number),
toml::Value::Boolean(flag) => CoreValue::Boolean(*flag),
toml::Value::Array(items) => CoreValue::List(
items
.iter()
.map(|item| as_core(whose, key, item))
.collect::<Result<_, _>>()?,
),
toml::Value::Table(entries) => CoreValue::Map(
entries
.iter()
.map(|(inner, item)| Ok((inner.clone(), as_core(whose, inner, item)?)))
.collect::<Result<_, String>>()?,
),
other => {
return Err(format!(
"{whose}: {key:?} is {}, which a resource cannot carry",
other.type_str()
));
}
})
}