declare_ruth stored the default set plus manage_root_agent over whatever ruth held, so a rerun dropped permissions granted since. It now reads her record and adds manage_root_agent only when it is missing, and writes nothing when she holds it. A new ruth still gets the defaults plus manage_root_agent.
522 lines
17 KiB
Rust
522 lines
17 KiB
Rust
//! The real terminal and the real side effects behind `swarmctl setup`'s
|
|
//! flow in [`crate::interactive`].
|
|
//!
|
|
//! [`Terminal`] draws the flow's lines (coloured unless `NO_COLOR` or a dumb
|
|
//! terminal says not), reads answers, and shows a running action as one live
|
|
//! line. [`Live`] performs each step by calling the same functions the verbs
|
|
//! call: nothing here runs `swarmctl` again or builds a command line of its
|
|
//! own.
|
|
|
|
use std::fmt::Write as _;
|
|
use std::io::{self, IsTerminal as _, Write as _};
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use anyhow::{Context as _, Result, anyhow, bail};
|
|
use hive_types::Permission;
|
|
use swarm_controller_api::permissions::Permissions;
|
|
use swarm_wire::wanted::AgentState;
|
|
use zeroize::Zeroizing;
|
|
|
|
use crate::bao::Bao;
|
|
use crate::interactive::{Actions, HostFacts, Initialised, NewUser, StoreHost, Tone, Ui};
|
|
use crate::remote::Target;
|
|
use crate::setup::{Observed, granter_unit_exists, observe};
|
|
use crate::{AddArgs, Paths, agent, forge, permissions};
|
|
|
|
const RUTH: &str = "ruth";
|
|
const ADMINS_GROUP: &str = "admins";
|
|
|
|
/// Refuses to start anywhere the flow cannot work.
|
|
///
|
|
/// It must be root: the first steps act on the secret store, systemd and
|
|
/// authelia's users file before any of the swarm is up to authenticate
|
|
/// anyone. It must have a terminal: it prompts, and its secrets are for a
|
|
/// person's eyes.
|
|
fn precheck(is_root: bool, stdin_tty: bool, stdout_tty: bool) -> Result<()> {
|
|
if !is_root {
|
|
bail!(
|
|
"`swarmctl setup` must run as root: its first steps act on the secret store, systemd \
|
|
and authelia's users file before the swarm is up to log anyone in. Run it with sudo"
|
|
);
|
|
}
|
|
if !(stdin_tty && stdout_tty) {
|
|
bail!(
|
|
"`swarmctl setup` is interactive and needs a terminal on stdin and stdout. For \
|
|
scripts, use `swarmctl setup status` and the individual verbs: `swarmctl bao init`, \
|
|
`swarmctl bao grant-bootstrap`, `swarmctl user add`, `swarmctl forge make-admin` and \
|
|
`swarmctl agent create`"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Whether to colour output: on a terminal, unless `NO_COLOR` is set and not
|
|
/// empty (<https://no-color.org>) or the terminal is `dumb`.
|
|
fn use_colour(stdout_tty: bool, no_color: Option<&str>, term: Option<&str>) -> bool {
|
|
stdout_tty && no_color.is_none_or(str::is_empty) && term != Some("dumb")
|
|
}
|
|
|
|
/// Run `swarmctl setup`.
|
|
pub(crate) fn setup(
|
|
target: Result<Target>,
|
|
users_file: Result<PathBuf>,
|
|
authelia_bin: Result<PathBuf>,
|
|
) -> Result<()> {
|
|
let stdout_tty = io::stdout().is_terminal();
|
|
precheck(
|
|
rustix::process::geteuid().is_root(),
|
|
io::stdin().is_terminal(),
|
|
stdout_tty,
|
|
)?;
|
|
let term = std::env::var("TERM").ok();
|
|
let no_color = std::env::var("NO_COLOR").ok();
|
|
let mut ui = Terminal {
|
|
colour: use_colour(stdout_tty, no_color.as_deref(), term.as_deref()),
|
|
animate: term.as_deref() != Some("dumb"),
|
|
};
|
|
let mut actions = Live {
|
|
target,
|
|
users_file,
|
|
authelia_bin,
|
|
bao: Bao::new(),
|
|
};
|
|
crate::interactive::run(&mut ui, &mut actions).map(drop)
|
|
}
|
|
|
|
struct Terminal {
|
|
colour: bool,
|
|
animate: bool,
|
|
}
|
|
|
|
impl Terminal {
|
|
fn paint(&self, tone: Tone, text: &str) -> String {
|
|
let code = match tone {
|
|
Tone::Plain => return text.to_owned(),
|
|
Tone::Heading => "1;36",
|
|
Tone::Good => "32",
|
|
Tone::Dim => "2",
|
|
Tone::Warn => "1;33",
|
|
Tone::Bad => "31",
|
|
};
|
|
if self.colour {
|
|
format!("\x1b[{code}m{text}\x1b[0m")
|
|
} else {
|
|
text.to_owned()
|
|
}
|
|
}
|
|
}
|
|
|
|
const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
|
|
impl Ui for Terminal {
|
|
fn say(&mut self, tone: Tone, text: &str) {
|
|
println!("{}", self.paint(tone, text));
|
|
}
|
|
|
|
fn show_secret(&mut self, text: &str) -> Result<()> {
|
|
let mut out = io::stdout().lock();
|
|
out.write_all(text.as_bytes())
|
|
.and_then(|()| out.flush())
|
|
.context("writing the secret to the terminal")
|
|
}
|
|
|
|
fn ask(&mut self, prompt: &str) -> Result<String> {
|
|
print!("{}", self.paint(Tone::Heading, prompt));
|
|
io::stdout().flush().context("writing the prompt")?;
|
|
let mut line = String::new();
|
|
let read = io::stdin()
|
|
.read_line(&mut line)
|
|
.context("reading your answer")?;
|
|
if read == 0 {
|
|
bail!("input closed before an answer was given");
|
|
}
|
|
Ok(line.trim_end_matches(['\n', '\r']).to_owned())
|
|
}
|
|
|
|
fn ask_secret(&mut self, prompt: &str) -> Result<Zeroizing<String>> {
|
|
crate::bao::prompt_no_echo(&io::stdin(), prompt)
|
|
}
|
|
|
|
fn working<T>(&mut self, what: &str, f: impl FnOnce() -> T) -> T {
|
|
if !self.animate {
|
|
println!("{what}...");
|
|
return f();
|
|
}
|
|
let stop = AtomicBool::new(false);
|
|
let started = Instant::now();
|
|
std::thread::scope(|scope| {
|
|
let spinner = scope.spawn(|| {
|
|
let mut frame = 0;
|
|
while !stop.load(Ordering::Relaxed) {
|
|
print!(
|
|
"\r\x1b[K{} {what} ({}s)",
|
|
FRAMES[frame % FRAMES.len()],
|
|
started.elapsed().as_secs()
|
|
);
|
|
io::stdout().flush().ok();
|
|
frame += 1;
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
}
|
|
print!("\r\x1b[K");
|
|
io::stdout().flush().ok();
|
|
});
|
|
let result = f();
|
|
stop.store(true, Ordering::Relaxed);
|
|
spinner.join().ok();
|
|
result
|
|
})
|
|
}
|
|
}
|
|
|
|
struct Live {
|
|
target: Result<Target>,
|
|
users_file: Result<PathBuf>,
|
|
authelia_bin: Result<PathBuf>,
|
|
bao: Bao,
|
|
}
|
|
|
|
impl Live {
|
|
fn target(&self) -> Result<&Target> {
|
|
self.target.as_ref().map_err(|e| anyhow!("{e:#}"))
|
|
}
|
|
}
|
|
|
|
/// Calls `f` until it succeeds, up to `attempts` times, `every` apart. The
|
|
/// controller declares a new agent in the background, so a request about it
|
|
/// right after its creation can arrive first.
|
|
fn retry<T>(attempts: usize, every: Duration, mut f: impl FnMut() -> Result<T>) -> Result<T> {
|
|
let mut last = None;
|
|
for attempt in 0..attempts {
|
|
match f() {
|
|
Ok(value) => return Ok(value),
|
|
Err(e) => last = Some(e),
|
|
}
|
|
if attempt + 1 < attempts {
|
|
std::thread::sleep(every);
|
|
}
|
|
}
|
|
Err(last.unwrap_or_else(|| anyhow!("no attempt was made")))
|
|
}
|
|
|
|
/// Gives ruth `manage_root_agent` without taking anything else from her.
|
|
///
|
|
/// A new ruth gets the default set plus `manage_root_agent`. An existing one
|
|
/// keeps what the swarm stored for her and gains `manage_root_agent` only if
|
|
/// it is missing; one that holds it already is not written at all. Both calls
|
|
/// retry, since the controller declares a new agent in the background.
|
|
fn ensure_manager_permission(
|
|
exists: bool,
|
|
fetch: impl FnMut() -> Result<Permissions>,
|
|
mut store: impl FnMut(&[String]) -> Result<()>,
|
|
every: Duration,
|
|
) -> Result<()> {
|
|
let stored = if exists {
|
|
retry(30, every, fetch)?.permissions
|
|
} else {
|
|
None
|
|
};
|
|
let Some(granted) = with_manage_root_agent(stored) else {
|
|
return Ok(());
|
|
};
|
|
let names: Vec<String> = granted
|
|
.iter()
|
|
.map(|p| <&str>::from(*p).to_owned())
|
|
.collect();
|
|
retry(30, every, || store(&names))
|
|
}
|
|
|
|
/// `stored` plus `manage_root_agent`, or `None` when it holds it already. An
|
|
/// unset record means the default set.
|
|
fn with_manage_root_agent(stored: Option<Vec<Permission>>) -> Option<Vec<Permission>> {
|
|
let mut granted = stored.unwrap_or_else(|| Permission::DEFAULT.to_vec());
|
|
if granted.contains(&Permission::ManageRootAgent) {
|
|
return None;
|
|
}
|
|
granted.push(Permission::ManageRootAgent);
|
|
Some(granted)
|
|
}
|
|
|
|
/// Whether `program` is an executable file in a `PATH` directory.
|
|
fn on_path(program: &str) -> bool {
|
|
std::env::var_os("PATH").is_some_and(|path| on_path_in(program, &path))
|
|
}
|
|
|
|
fn on_path_in(program: &str, path: &std::ffi::OsStr) -> bool {
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
|
|
std::env::split_paths(path).any(|dir| {
|
|
std::fs::metadata(dir.join(program))
|
|
.is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
|
|
})
|
|
}
|
|
|
|
impl Actions for Live {
|
|
fn host(&self) -> HostFacts {
|
|
HostFacts {
|
|
store: StoreHost {
|
|
bao_cli: on_path("bao"),
|
|
granter_unit: granter_unit_exists(),
|
|
grant_configured: Bao::grant_configured(),
|
|
},
|
|
sso: self.users_file.is_ok() && self.authelia_bin.is_ok(),
|
|
controller: self.target.is_ok(),
|
|
}
|
|
}
|
|
|
|
fn observe(&mut self) -> Observed {
|
|
observe(&self.target, &self.users_file)
|
|
}
|
|
|
|
fn pause(&mut self, how_long: Duration) {
|
|
std::thread::sleep(how_long);
|
|
}
|
|
|
|
fn init_store(&mut self) -> Result<Initialised> {
|
|
let created = self.bao.init()?;
|
|
Ok(Initialised {
|
|
text: created.rendered()?,
|
|
root_token: created.root_token(),
|
|
})
|
|
}
|
|
|
|
fn wait_unsealed(&mut self) -> Result<()> {
|
|
self.bao.wait_unsealed()
|
|
}
|
|
|
|
fn grant_bootstrap(&mut self, root_token: &str) -> Result<String> {
|
|
self.bao.grant_bootstrap(root_token)
|
|
}
|
|
|
|
fn add_user(&mut self, user: &NewUser) -> Result<Zeroizing<String>> {
|
|
let paths = Paths {
|
|
authelia_bin: self
|
|
.authelia_bin
|
|
.as_ref()
|
|
.map_err(|e| anyhow!("{e:#}"))?
|
|
.clone(),
|
|
users_file: self
|
|
.users_file
|
|
.as_ref()
|
|
.map_err(|e| anyhow!("{e:#}"))?
|
|
.clone(),
|
|
};
|
|
crate::add_user(
|
|
&paths,
|
|
AddArgs {
|
|
username: user.username.clone(),
|
|
display_name: Some(user.display_name.clone()),
|
|
email: Some(user.email.clone()),
|
|
groups: vec![ADMINS_GROUP.to_owned()],
|
|
},
|
|
)
|
|
}
|
|
|
|
fn make_admin(&mut self, name: &str) -> Result<String> {
|
|
forge::request_admin(self.target()?, name)
|
|
}
|
|
|
|
fn declare_ruth(&mut self, hive: &str, exists: bool) -> Result<String> {
|
|
let target = self.target()?;
|
|
let hive = agent::parse_ident(hive, "hive")?;
|
|
if !exists {
|
|
agent::request_create(target, RUTH, &hive, None)?;
|
|
}
|
|
let every = Duration::from_secs(2);
|
|
ensure_manager_permission(
|
|
exists,
|
|
|| permissions::fetch(target, RUTH),
|
|
|granted| permissions::store(target, RUTH, granted, None).map(drop),
|
|
every,
|
|
)?;
|
|
retry(30, every, || {
|
|
agent::request_state(target, RUTH, &hive, AgentState::Up)
|
|
})?;
|
|
Ok(format!(
|
|
"{RUTH} is declared up on hive {hive:?} with manage_root_agent; the hive converges on its own clock"
|
|
))
|
|
}
|
|
|
|
fn create_agent(&mut self, name: &str, hive: &str) -> Result<String> {
|
|
let name = agent::parse_ident(name, "agent name")?;
|
|
let hive = agent::parse_ident(hive, "hive")?;
|
|
let resp = agent::request_create(self.target()?, &name, &hive, None)?;
|
|
let mut said = format!(
|
|
"queued job node {} to deploy {name:?} to hive {hive:?}",
|
|
resp.node_id
|
|
);
|
|
for warning in &resp.warnings {
|
|
let _ = write!(said, "; warning: {warning}");
|
|
}
|
|
Ok(said)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn setup_refuses_anyone_but_root() {
|
|
let err = precheck(false, true, true).expect_err("not root");
|
|
assert!(err.to_string().contains("must run as root"), "{err}");
|
|
// The control: root on a terminal is accepted.
|
|
precheck(true, true, true).expect("root on a terminal");
|
|
}
|
|
|
|
#[test]
|
|
fn setup_refuses_without_a_terminal_and_points_at_the_scriptable_verbs() {
|
|
for (stdin_tty, stdout_tty) in [(false, true), (true, false), (false, false)] {
|
|
let err = precheck(true, stdin_tty, stdout_tty).expect_err("no terminal");
|
|
let message = err.to_string();
|
|
assert!(
|
|
message.contains("setup status") && message.contains("swarmctl bao init"),
|
|
"{message}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn colour_follows_the_terminal_and_no_color() {
|
|
assert!(use_colour(true, None, Some("xterm")));
|
|
assert!(!use_colour(false, None, Some("xterm")), "not a terminal");
|
|
assert!(!use_colour(true, Some("1"), Some("xterm")), "NO_COLOR set");
|
|
assert!(
|
|
use_colour(true, Some(""), Some("xterm")),
|
|
"an empty NO_COLOR does not count"
|
|
);
|
|
assert!(!use_colour(true, None, Some("dumb")));
|
|
}
|
|
|
|
#[test]
|
|
fn an_uncoloured_terminal_prints_plain_text() {
|
|
let plain = Terminal {
|
|
colour: false,
|
|
animate: false,
|
|
};
|
|
assert_eq!(plain.paint(Tone::Warn, "⚠️ careful"), "⚠️ careful");
|
|
let coloured = Terminal {
|
|
colour: true,
|
|
animate: false,
|
|
};
|
|
let painted = coloured.paint(Tone::Warn, "⚠️ careful");
|
|
assert!(painted.starts_with("\x1b[") && painted.contains("⚠️ careful"));
|
|
assert_eq!(coloured.paint(Tone::Plain, "x"), "x");
|
|
}
|
|
|
|
#[test]
|
|
fn retry_gives_up_after_its_attempts_and_stops_at_the_first_success() {
|
|
let mut calls = 0;
|
|
let out = retry(5, Duration::ZERO, || {
|
|
calls += 1;
|
|
if calls < 3 {
|
|
bail!("not yet");
|
|
}
|
|
Ok(calls)
|
|
});
|
|
assert_eq!(out.expect("third try works"), 3);
|
|
|
|
let mut calls = 0;
|
|
let err = retry(4, Duration::ZERO, || -> Result<()> {
|
|
calls += 1;
|
|
bail!("never")
|
|
})
|
|
.expect_err("all fail");
|
|
assert_eq!((calls, err.to_string().as_str()), (4, "never"));
|
|
}
|
|
|
|
fn stored(permissions: Option<Vec<Permission>>) -> Permissions {
|
|
Permissions {
|
|
permissions,
|
|
..Permissions::default()
|
|
}
|
|
}
|
|
|
|
/// Runs the ruth step against `record`; returns what it stored, if anything.
|
|
fn ruth_step(exists: bool, record: &Permissions) -> (Option<Vec<String>>, usize) {
|
|
let mut written = None;
|
|
let mut fetches = 0;
|
|
ensure_manager_permission(
|
|
exists,
|
|
|| {
|
|
fetches += 1;
|
|
Ok(record.clone())
|
|
},
|
|
|granted| {
|
|
written = Some(granted.to_vec());
|
|
Ok(())
|
|
},
|
|
Duration::ZERO,
|
|
)
|
|
.expect("the step succeeds");
|
|
(written, fetches)
|
|
}
|
|
|
|
#[test]
|
|
fn an_existing_ruth_keeps_her_permissions_and_gains_manage_root_agent() {
|
|
let held = vec![
|
|
Permission::Messaging,
|
|
Permission::Logs,
|
|
Permission::WebTools,
|
|
];
|
|
let (written, _) = ruth_step(true, &stored(Some(held)));
|
|
assert_eq!(
|
|
written.expect("manage_root_agent was missing, so it is written"),
|
|
["messaging", "logs", "web_tools", "manage_root_agent"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_existing_ruth_that_holds_manage_root_agent_is_not_written() {
|
|
let held = vec![Permission::Logs, Permission::ManageRootAgent];
|
|
let (written, fetches) = ruth_step(true, &stored(Some(held)));
|
|
assert_eq!(written, None);
|
|
assert_eq!(fetches, 1, "her record was read, not assumed");
|
|
}
|
|
|
|
#[test]
|
|
fn a_new_ruth_gets_the_defaults_and_manage_root_agent_without_a_read() {
|
|
let (written, fetches) = ruth_step(false, &stored(None));
|
|
assert_eq!(
|
|
written.expect("written"),
|
|
[
|
|
"messaging",
|
|
"meta",
|
|
"inbox",
|
|
"execution",
|
|
"manage_root_agent"
|
|
]
|
|
);
|
|
assert_eq!(fetches, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn an_existing_ruth_on_the_default_set_gets_the_defaults_and_manage_root_agent() {
|
|
let (written, _) = ruth_step(true, &stored(None));
|
|
assert_eq!(
|
|
written.expect("written"),
|
|
[
|
|
"messaging",
|
|
"meta",
|
|
"inbox",
|
|
"execution",
|
|
"manage_root_agent"
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_program_on_path_is_found_only_when_it_is_executable() {
|
|
let dir = std::env::temp_dir().join(format!("swarmctl-onpath-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).expect("temp dir");
|
|
crate::tests::write_executable(&dir.join("present"), "#!/bin/sh\n");
|
|
std::fs::write(dir.join("plain"), "x").expect("write");
|
|
let path = dir.as_os_str();
|
|
assert!(on_path_in("present", path));
|
|
assert!(!on_path_in("plain", path), "a file without the execute bit");
|
|
assert!(!on_path_in("absent", path));
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
}
|