feat: env file

This commit is contained in:
2026-08-11 16:01:26 +02:00
parent 34698b0576
commit acbdc20f38
4 changed files with 178 additions and 8 deletions
+47
View File
@@ -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<String, LauncherEntry>,
}
impl Config {
/// Discover launchable deployments on disk under `deployments_base`, one
/// entry per (deployment, virtualenv) pair, labelled `"<deployment>/<venv>"`.
///
/// 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<Config> {
Ok(Config {
deployments_base: raw.deployments_base,
default_gui_file: raw.default_gui_file,
default_server_file: raw.default_server_file,
entries,
})
}
+103
View File
@@ -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());
}
}
+14 -5
View File
@@ -5,10 +5,12 @@ use anyhow::{Context, Result, bail};
use crate::config::LauncherEntry;
/// Run `python <entrypoint>` on the system, waiting for it to finish.
pub fn launch(python: &Path, entrypoint: &Path) -> Result<()> {
/// Run `python <entrypoint>` 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 <entrypoint>` 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, &[])
}
+14 -3
View File
@@ -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<String>,
},
/// 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<String>,
/// 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(())