diff --git a/src/config.rs b/src/config.rs index fb488ea..0240671 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,12 @@ use std::collections::HashMap; +use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::Deserialize; +use crate::detect::detect_venvs; + /// A launcher entry as it appears in `defaults.json`, where `gui_file` and /// `server_file` may be omitted (falling back to the top-level defaults). #[derive(Debug, Deserialize)] @@ -44,10 +47,52 @@ pub struct LauncherEntry { pub struct Config { /// Base directory containing the source/app directories. pub deployments_base: PathBuf, + /// Default GUI entry point, relative to a deployment directory. + pub default_gui_file: PathBuf, + /// Default server entry point, relative to a deployment directory. + pub default_server_file: PathBuf, /// Resolved launcher entries, keyed by beamline. pub entries: HashMap, } +impl Config { + /// Discover launchable deployments on disk under `deployments_base`, one + /// entry per (deployment, virtualenv) pair, labelled `"/"`. + /// + /// These are the "custom" deployments that are present on the machine but + /// not named in the config. Entry points use the configured defaults. + pub fn discovered_entries(&self) -> Vec<(String, LauncherEntry)> { + let mut found = Vec::new(); + let Ok(dirs) = fs::read_dir(&self.deployments_base) else { + return found; + }; + + for deployment in dirs.flatten() { + if !deployment.file_type().map(|t| t.is_dir()).unwrap_or(false) { + continue; + } + let dir = deployment.path(); + let name = deployment.file_name().to_string_lossy().into_owned(); + + for venv in detect_venvs(&dir).unwrap_or_default() { + let venv_name = venv + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + found.push(( + format!("{name}/{venv_name}"), + LauncherEntry { + python: venv.join("bin/python"), + gui_file: join_relative(&dir, &self.default_gui_file), + server_file: join_relative(&dir, &self.default_server_file), + }, + )); + } + } + found + } +} + /// Join `rel` onto `base`, treating `rel` as relative even if it starts with a /// leading separator (as the entry points in `defaults.json` could). fn join_relative(base: &Path, rel: &Path) -> PathBuf { @@ -99,6 +144,8 @@ pub fn load_config(json: &str) -> Result { Ok(Config { deployments_base: raw.deployments_base, + default_gui_file: raw.default_gui_file, + default_server_file: raw.default_server_file, entries, }) } diff --git a/src/env_file.rs b/src/env_file.rs new file mode 100644 index 0000000..16b175a --- /dev/null +++ b/src/env_file.rs @@ -0,0 +1,103 @@ +use std::path::Path; + +/// Load `key=value` pairs from an env file to add to a process's environment. +/// +/// If the file does not exist (or cannot be read) this logs a warning and +/// returns an empty set rather than failing — the env file is optional. +pub fn load_env_file(path: &Path) -> Vec<(String, String)> { + match std::fs::read_to_string(path) { + Ok(contents) => parse_env(&contents), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + eprintln!( + "warning: env file {} not found, continuing without it", + path.display() + ); + Vec::new() + } + Err(err) => { + eprintln!( + "warning: could not read env file {} ({err}), continuing without it", + path.display() + ); + Vec::new() + } + } +} + +/// Parse `key=value` lines into pairs. Blank lines, `#` comments, and lines +/// without a `=` are ignored. Keys and values are trimmed of surrounding +/// whitespace. +fn parse_env(contents: &str) -> Vec<(String, String)> { + contents + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + let (key, value) = line.split_once('=')?; + Some((key.trim().to_string(), value.trim().to_string())) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_basic_pairs() { + let env = parse_env("A=1\nB=two"); + assert_eq!( + env, + vec![ + ("A".to_string(), "1".to_string()), + ("B".to_string(), "two".to_string()) + ] + ); + } + + #[test] + fn ignores_blank_lines_and_comments() { + let env = parse_env("\n# a comment\nA=1\n\n # indented comment\nB=2\n"); + assert_eq!( + env, + vec![ + ("A".to_string(), "1".to_string()), + ("B".to_string(), "2".to_string()) + ] + ); + } + + #[test] + fn trims_surrounding_whitespace() { + let env = parse_env(" KEY = value "); + assert_eq!(env, vec![("KEY".to_string(), "value".to_string())]); + } + + #[test] + fn keeps_equals_signs_in_value() { + let env = parse_env("URL=postgres://h/db?a=1&b=2"); + assert_eq!( + env, + vec![("URL".to_string(), "postgres://h/db?a=1&b=2".to_string())] + ); + } + + #[test] + fn skips_lines_without_equals() { + let env = parse_env("A=1\ngarbage\nB=2"); + assert_eq!( + env, + vec![ + ("A".to_string(), "1".to_string()), + ("B".to_string(), "2".to_string()) + ] + ); + } + + #[test] + fn empty_input_yields_nothing() { + assert!(parse_env("").is_empty()); + } +} diff --git a/src/launcher.rs b/src/launcher.rs index ea0d5e6..7d775d4 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -5,10 +5,12 @@ use anyhow::{Context, Result, bail}; use crate::config::LauncherEntry; -/// Run `python ` on the system, waiting for it to finish. -pub fn launch(python: &Path, entrypoint: &Path) -> Result<()> { +/// Run `python ` on the system with `env` added to its environment, +/// waiting for it to finish. +fn run(python: &Path, entrypoint: &Path, env: &[(String, String)]) -> Result<()> { let status = Command::new(python) .arg(entrypoint) + .envs(env.iter().map(|(key, value)| (key, value))) .status() .with_context(|| { format!( @@ -29,10 +31,17 @@ pub fn launch(python: &Path, entrypoint: &Path) -> Result<()> { Ok(()) } -pub fn launch_server(entry: &LauncherEntry) -> Result<()> { - launch(&entry.python, &entry.server_file) +/// Run `python ` on the system, waiting for it to finish. +pub fn launch(python: &Path, entrypoint: &Path) -> Result<()> { + run(python, entrypoint, &[]) +} + +/// Launch the server entry point with extra `env` variables added to its +/// environment. +pub fn launch_server(entry: &LauncherEntry, env: &[(String, String)]) -> Result<()> { + run(&entry.python, &entry.server_file, env) } pub fn launch_gui(entry: &LauncherEntry) -> Result<()> { - launch(&entry.python, &entry.gui_file) + run(&entry.python, &entry.gui_file, &[]) } diff --git a/src/main.rs b/src/main.rs index d345401..6483ac3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod config; mod detect; +mod env_file; mod launcher; mod tui; @@ -14,6 +15,8 @@ use detect::detect_beamline; const INSTALLED_CONFIG: &str = "/etc/aarel/defaults.json"; /// Config file shipped in the source tree, used as a fallback. const LOCAL_CONFIG: &str = "data/defaults.json"; +/// Default env file added to the server's environment when launching. +const DEFAULT_ENV_FILE: &str = "/etc/aaredaq/aaredaq.env"; #[derive(Parser)] #[command(name = "aarel")] @@ -49,16 +52,21 @@ enum Command { /// Launch the AareGUI with default settings #[command(alias = "g")] Gui { - /// Config entry (beamline) to launch. Defaults to the detected beamline. + /// Config entry (deployment) to launch. Defaults to the detected beamline. #[arg(long)] entry: Option, }, /// Launch the AareDAQ server with default settings #[command(alias = "s")] Server { - /// Config entry (beamline) to launch. Defaults to the detected beamline. + /// Config entry (deployment) to launch. Defaults to the detected beamline. #[arg(long)] entry: Option, + + /// Env file (`key=value` per line) added to the server's environment. + /// Ignored with a warning if it does not exist. + #[arg(long, default_value = DEFAULT_ENV_FILE)] + env_file: PathBuf, }, } @@ -80,7 +88,10 @@ fn main() -> Result<()> { } } Command::Gui { entry } => launcher::launch_gui(lookup_entry(&config, entry)?)?, - Command::Server { entry } => launcher::launch_server(lookup_entry(&config, entry)?)?, + Command::Server { entry, env_file } => { + let env = env_file::load_env_file(&env_file); + launcher::launch_server(lookup_entry(&config, entry)?, &env)? + } } Ok(())