//! 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 () 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, users_file: Result, authelia_bin: Result, ) -> 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 { 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> { crate::bao::prompt_no_echo(&io::stdin(), prompt) } fn working(&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, users_file: Result, authelia_bin: Result, 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(attempts: usize, every: Duration, mut f: impl FnMut() -> Result) -> Result { 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, 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 = 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>) -> Option> { 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 { 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 { self.bao.grant_bootstrap(root_token) } fn add_user(&mut self, user: &NewUser) -> Result> { 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 { forge::request_admin(self.target()?, name) } fn declare_ruth(&mut self, hive: &str, exists: bool) -> Result { 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 { 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>) -> 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>, 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(); } }