diff --git a/README.md b/README.md index 025b826..6f3d997 100644 --- a/README.md +++ b/README.md @@ -15,32 +15,12 @@ matching application, opens a TUI to pick one, or deploys a new checkout. aarel pick # open the TUI to choose what to launch aarel gui # launch the GUI for the detected beamline aarel server # launch the server for the detected beamline -aarel deploy # clone the repos and install them into a new virtualenv ``` -Each command has a single-letter alias (`p`, `g`, `s`, `d`). `--config ` +Each command has a single-letter alias (`p`, `g`, `s`). `--config ` selects a different config file; it defaults to `data/defaults.json`. ### Launching `gui` and `server` pick their config entry from the detected beamline. Use `--entry ` to launch a different one. - -### Deploying - -`deploy ` clones [aaredaq] and [aarecommon] into `/`, then -installs both editable into a fresh `uv` virtualenv at `/`. It -requires `git` and `uv` on the `PATH`. - -| Flag | Effect | -| --- | --- | -| `--upgrade` | Update an existing deployment (`git pull` + reinstall) instead of refusing to overwrite it. | -| `--force` | Deploy a production name from a machine on another beamline. | - -The production names `x06da`, `x06sa` and `x10sa` may only be deployed from their -own beamline's machines. Any other name is a test or development deployment and -can be deployed anywhere. This is a guard against mistakes, not a security -boundary, and `--force` bypasses it. - -[aaredaq]: https://gitea.psi.ch/mx/aaredaq -[aarecommon]: https://gitea.psi.ch/mx/aarecommon diff --git a/data/defaults.json b/data/defaults.json index 15ccf6d..6afdaa1 100644 --- a/data/defaults.json +++ b/data/defaults.json @@ -1,20 +1,17 @@ { - "apps_base": "/sls/mx/applications/", - "venvs_base": "/sls/mx/applications/venvs", + "deployments_base": "/sls/mx/applications/aaredaq", + "default_venv_name": ".venv", "default_gui_file": "src/aare/gui/gui.py", "default_server_file": "src/aare/daq/server.py", "map": { "x10sa": { - "venv": "aaredaq-pxii", - "src": "aaredaq-pxii" + "dir": "x10sa" }, "x06da": { - "venv": "aaredaq-pxiii", - "src": "aaredaq-pxiii" + "dir": "x06da" }, "x06sa": { - "venv": "aaredaq-pxi", - "src": "aaredaq-pxi" + "dir": "x06sa" } } } diff --git a/src/config.rs b/src/config.rs index 453c856..a8a83ab 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,21 +8,21 @@ use serde::Deserialize; /// `server_file` may be omitted (falling back to the top-level defaults). #[derive(Debug, Deserialize)] struct RawLauncherEntry { - /// Virtualenv directory, relative to `venvs_base`. - venv: PathBuf, - /// Source directory, relative to `apps_base`. - src: PathBuf, - /// GUI entry point, relative to the entry's `src` directory. + /// Deployment directory, relative to `deployments_base`. + dir: PathBuf, + /// GUI entry point, relative to the entry's `dir`. gui_file: Option, - /// Server entry point, relative to the entry's `src` directory. + /// Server entry point, relative to the entry's `dir`. server_file: Option, + /// Virtualenv directory, relative to the entry's `dir`. + venv: Option, } /// The configuration as parsed directly from `defaults.json`. #[derive(Debug, Deserialize)] struct RawConfig { - apps_base: PathBuf, - venvs_base: PathBuf, + deployments_base: PathBuf, + default_venv_name: PathBuf, default_gui_file: PathBuf, default_server_file: PathBuf, map: HashMap, @@ -43,9 +43,7 @@ pub struct LauncherEntry { #[derive(Debug, Clone)] pub struct Config { /// Base directory containing the source/app directories. - pub apps_base: PathBuf, - /// Base directory containing the virtualenvs. - pub venvs_base: PathBuf, + pub deployments_base: PathBuf, /// Resolved launcher entries, keyed by beamline. pub entries: HashMap, } @@ -61,19 +59,20 @@ impl RawLauncherEntry { /// defaults. fn resolve( self, - apps_base: &Path, - venvs_base: &Path, + deployments_base: &Path, + default_venv: &Path, default_gui_file: &Path, default_server_file: &Path, ) -> LauncherEntry { - let src_dir = apps_base.join(&self.src); + let dir = deployments_base.join(&self.dir); + let venv = self.venv.as_deref().unwrap_or(default_venv); let gui_file = self.gui_file.as_deref().unwrap_or(default_gui_file); let server_file = self.server_file.as_deref().unwrap_or(default_server_file); LauncherEntry { - python: venvs_base.join(&self.venv).join("bin/python"), - gui_file: join_relative(&src_dir, gui_file), - server_file: join_relative(&src_dir, server_file), + python: dir.join(venv).join("bin/python"), + gui_file: join_relative(&dir, gui_file), + server_file: join_relative(&dir, server_file), } } } @@ -89,8 +88,8 @@ pub fn load_config(json: &str) -> Result { .into_iter() .map(|(key, entry)| { let resolved = entry.resolve( - &raw.apps_base, - &raw.venvs_base, + &raw.deployments_base, + &raw.default_venv_name, &raw.default_gui_file, &raw.default_server_file, ); @@ -99,8 +98,7 @@ pub fn load_config(json: &str) -> Result { .collect(); Ok(Config { - apps_base: raw.apps_base, - venvs_base: raw.venvs_base, + deployments_base: raw.deployments_base, entries, }) } @@ -110,20 +108,19 @@ mod tests { use super::*; const SAMPLE: &str = r#"{ - "apps_base": "/apps", - "venvs_base": "/venvs", - "default_gui_file": "/src/gui.py", - "default_server_file": "/src/server.py", + "deployments_base": "/apps", + "default_venv_name": ".venv", + "default_gui_file": "src/gui.py", + "default_server_file": "src/server.py", "map": { "x10sa": { "venv": "pxii", - "src": "aaredaq-pxii", - "gui_file": "/custom/gui.py", - "server_file": "/custom/server.py" + "dir": "aaredaq-pxii", + "gui_file": "custom/gui.py", + "server_file": "custom/server.py" }, "x06da": { - "venv": "pxiii", - "src": "aaredaq-pxiii" + "dir": "aaredaq-pxiii" } } }"#; @@ -133,7 +130,10 @@ mod tests { let config = load_config(SAMPLE).unwrap(); let entry = &config.entries["x10sa"]; - assert_eq!(entry.python, PathBuf::from("/venvs/pxii/bin/python")); + assert_eq!( + entry.python, + PathBuf::from("/apps/aaredaq-pxii/pxii/bin/python") + ); assert_eq!( entry.gui_file, PathBuf::from("/apps/aaredaq-pxii/custom/gui.py") @@ -149,7 +149,10 @@ mod tests { let config = load_config(SAMPLE).unwrap(); let entry = &config.entries["x06da"]; - assert_eq!(entry.python, PathBuf::from("/venvs/pxiii/bin/python")); + assert_eq!( + entry.python, + PathBuf::from("/apps/aaredaq-pxiii/.venv/bin/python") + ); assert_eq!( entry.gui_file, PathBuf::from("/apps/aaredaq-pxiii/src/gui.py") @@ -175,11 +178,11 @@ mod tests { fn error_reports_field_path() { // `venv` has the wrong type in the single entry. let bad = r#"{ - "apps_base": "/apps", - "venvs_base": "/venvs", - "default_gui_file": "/src/gui.py", - "default_server_file": "/src/server.py", - "map": { "x10sa": { "venv": 123, "src": "aaredaq-pxii" } } + "deployments_base": "/apps", + "default_venv_name": ".venv", + "default_gui_file": "src/gui.py", + "default_server_file": "src/server.py", + "map": { "x10sa": { "venv": 123, "dir": "aaredaq-pxii" } } }"#; let err = load_config(bad).unwrap_err(); diff --git a/src/deploy.rs b/src/deploy.rs deleted file mode 100644 index 3e75c1b..0000000 --- a/src/deploy.rs +++ /dev/null @@ -1,210 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::process::Command; - -use anyhow::{Context, Error, Result, bail}; - -use crate::config::Config; -use crate::detect::detect_beamline; - -/// The repositories cloned into every deployment. -const REPOS: [&str; 2] = [ - "https://gitea.psi.ch/mx/aaredaq.git", - "https://gitea.psi.ch/mx/aarecommon.git", -]; - -/// The production deployment names, one per beamline. These may only be -/// deployed from that beamline's own machines; every other name is a -/// test/development deployment and can be deployed anywhere. -const PRODUCTION_BEAMLINES: [&str; 3] = ["x06da", "x06sa", "x10sa"]; - -/// How a deployment deviates from the safe defaults. -#[derive(Debug, Clone, Copy, Default)] -pub struct DeployOptions { - /// Deploy a production name even when this machine's beamline doesn't match. - pub force: bool, - /// Update an existing deployment instead of refusing to touch it. - pub upgrade: bool, -} - -/// Deploy `name`: clone [`REPOS`] into a source directory under `apps_base` and -/// install them editable into a virtualenv under `venvs_base`. -/// -/// By default this refuses to touch an existing deployment; with -/// [`DeployOptions::upgrade`] it updates one in place instead. -pub fn deploy(config: &Config, name: &str, options: DeployOptions) -> Result<()> { - let src_dir = config.apps_base.join(name); - let venv_dir = config.venvs_base.join(name); - - if options.force { - eprintln!("--force: skipping the beamline check for '{name}'"); - } else { - check_beamline(name)?; - } - - if options.upgrade { - println!("upgrading '{name}' in {}", src_dir.display()); - } else { - // Without --upgrade, never clobber an existing deployment. - ensure_absent("source directory", &src_dir)?; - ensure_absent("virtualenv", &venv_dir)?; - } - - std::fs::create_dir_all(&src_dir) - .with_context(|| format!("failed to create source directory {}", src_dir.display()))?; - - let mut checkouts = Vec::new(); - for repo in REPOS { - checkouts.push(fetch_repo(repo, &src_dir)?); - } - - let python = create_venv(&venv_dir)?; - for checkout in &checkouts { - pip_install(&python, checkout)?; - } - - println!("deployed '{name}' into {}", src_dir.display()); - Ok(()) -} - -/// Guard a production deployment against being run from the wrong beamline. -/// -/// Only the names in [`PRODUCTION_BEAMLINES`] are protected; test and -/// development deployments are left alone. This catches honest mistakes, it is -/// not a security boundary. -fn check_beamline(name: &str) -> Result<()> { - if !PRODUCTION_BEAMLINES.contains(&name) { - return Ok(()); - } - - let detected = detect_beamline().map_err(Error::msg).with_context(|| { - format!( - "'{name}' is a production deployment, so it must be deployed from a '{name}' machine" - ) - })?; - - if detected != name { - bail!( - "refusing to deploy production '{name}' from a '{detected}' machine — run this on a '{name}' console, or pass --force" - ); - } - - Ok(()) -} - -/// Fail if `path` already exists, so a deployment never overwrites another. -fn ensure_absent(what: &str, path: &Path) -> Result<()> { - if path.exists() { - bail!( - "{what} '{}' already exists — pass --upgrade to update it, or pick a different deployment name", - path.display() - ); - } - Ok(()) -} - -/// Bring the checkout of `url` under `parent` up to date, cloning it if it isn't -/// there yet. Returns the path of the checkout. -fn fetch_repo(url: &str, parent: &Path) -> Result { - let dest = parent.join(repo_name(url)?); - - if dest.join(".git").is_dir() { - println!("updating {}", dest.display()); - run( - Command::new("git").arg("-C").arg(&dest).arg("pull"), - &format!("git pull in {}", dest.display()), - )?; - } else { - println!("cloning {url} into {}", dest.display()); - run( - Command::new("git").arg("clone").arg(url).arg(&dest), - &format!("git clone {url}"), - )?; - } - - Ok(dest) -} - -/// The directory a repo URL clones into: its last path segment, minus `.git`. -fn repo_name(url: &str) -> Result<&str> { - url.rsplit('/') - .next() - .map(|name| name.strip_suffix(".git").unwrap_or(name)) - .filter(|name| !name.is_empty()) - .with_context(|| format!("cannot determine repository name from '{url}'")) -} - -/// Create a virtualenv at `venv_dir` with `uv`, returning its python -/// interpreter. Reuses the virtualenv if it is already there. -fn create_venv(venv_dir: &Path) -> Result { - let python = venv_dir.join("bin/python"); - - if python.is_file() { - println!("reusing virtualenv {}", venv_dir.display()); - return Ok(python); - } - - println!("creating virtualenv {}", venv_dir.display()); - run( - Command::new("uv").arg("venv").arg(venv_dir), - &format!("uv venv {}", venv_dir.display()), - )?; - - Ok(python) -} - -/// Install the checkout at `src` into the virtualenv owning `python`, editable. -fn pip_install(python: &Path, src: &Path) -> Result<()> { - println!("installing {} (editable)", src.display()); - - run( - Command::new("uv") - .args(["pip", "install", "--python"]) - .arg(python) - .arg("--editable") - .arg(src), - &format!("uv pip install --editable {}", src.display()), - ) -} - -/// Run `command`, failing if it cannot be started or exits non-zero. -fn run(command: &mut Command, description: &str) -> Result<()> { - let status = command - .status() - .with_context(|| format!("failed to run {description}"))?; - - if !status.success() { - bail!("{description} exited with {status}"); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn repo_name_strips_git_suffix() { - assert_eq!( - repo_name("https://host/org/aare_daq.git").unwrap(), - "aare_daq" - ); - } - - #[test] - fn repo_name_without_git_suffix() { - assert_eq!(repo_name("https://host/org/aare_gui").unwrap(), "aare_gui"); - } - - #[test] - fn repo_name_rejects_trailing_slash() { - assert!(repo_name("https://host/org/").is_err()); - } - - #[test] - fn non_production_names_are_not_beamline_checked() { - // Test/dev deployments deploy anywhere, without consulting the host. - assert!(check_beamline("x10sa-test").is_ok()); - assert!(check_beamline("davids-dev-deployment").is_ok()); - } -} diff --git a/src/detect.rs b/src/detect.rs index c67b409..7758287 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -1,3 +1,6 @@ +use std::fs; +use std::path::{Path, PathBuf}; + use gethostname::gethostname; /// Detect which console/beamline we're running on based on the short hostname. @@ -15,3 +18,27 @@ pub fn detect_beamline() -> Result { )) } } + +/// Detect the Python virtual environments directly inside `dir`. +/// +/// A subdirectory is treated as a virtualenv when it contains a `pyvenv.cfg` +/// file — the marker written by both `python -m venv` and `uv venv`. The +/// returned paths are the venv directories, sorted by name. +pub fn detect_venvs(dir: &Path) -> Result, String> { + let entries = + fs::read_dir(dir).map_err(|e| format!("cannot read directory '{}': {e}", dir.display()))?; + + let mut venvs: Vec = entries + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| is_venv(path)) + .collect(); + + venvs.sort(); + Ok(venvs) +} + +/// Whether `path` is a Python virtualenv, i.e. a directory holding a +/// `pyvenv.cfg` marker file. +fn is_venv(path: &Path) -> bool { + path.is_dir() && path.join("pyvenv.cfg").is_file() +} diff --git a/src/main.rs b/src/main.rs index 855ba3c..7bb350d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,4 @@ mod config; -mod deploy; mod detect; mod launcher; mod tui; @@ -9,7 +8,6 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Error, Result, bail}; use clap::{Parser, Subcommand}; use config::{Config, LauncherEntry, load_config}; -use deploy::DeployOptions; use detect::detect_beamline; #[derive(Parser)] @@ -42,20 +40,6 @@ enum Command { #[arg(long)] entry: Option, }, - /// Deploy the AareDAQ repos into a new source directory and virtualenv - #[command(alias = "d")] - Deploy { - /// Name of the deployment, used for both the source and venv directory. - name: String, - - /// Deploy a production beamline even from a machine on another beamline. - #[arg(long)] - force: bool, - - /// Update an existing deployment instead of refusing to overwrite it. - #[arg(long)] - upgrade: bool, - }, } fn main() -> Result<()> { @@ -65,8 +49,8 @@ fn main() -> Result<()> { .with_context(|| format!("failed to read config file {}", cli.config.display()))?; let config = load_config(&json)?; - check_base_exists("apps_base", &config.apps_base, &cli.config)?; - check_base_exists("venvs_base", &config.venvs_base, &cli.config)?; + check_base_exists("deployments_base", &config.deployments_base, &cli.config)?; + check_base_exists("venvs_base", &config.deployments_base, &cli.config)?; match cli.command { Command::Pick => { @@ -76,11 +60,6 @@ fn main() -> Result<()> { } Command::Gui { entry } => launcher::launch_gui(lookup_entry(&config, entry)?)?, Command::Server { entry } => launcher::launch_server(lookup_entry(&config, entry)?)?, - Command::Deploy { - name, - force, - upgrade, - } => deploy::deploy(&config, &name, DeployOptions { force, upgrade })?, } Ok(()) diff --git a/src/tui/custom.rs b/src/tui/custom.rs index 711f831..29d35b3 100644 --- a/src/tui/custom.rs +++ b/src/tui/custom.rs @@ -1,11 +1,13 @@ use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState}; +use crate::detect::detect_venvs; + /// Which of the two lists currently has focus. #[derive(Clone, Copy, PartialEq)] enum Focus { @@ -13,10 +15,14 @@ enum Focus { Venv, } -/// The "Custom" tab: pick a source directory and a venv directory from the -/// contents of `apps_base` and `venvs_base`. +/// The "Custom" tab: pick a deployment directory from `deployments_base`, then +/// one of the virtualenvs found inside it. pub struct CustomTab { + /// Base directory the source deployments live under. + deployments_base: PathBuf, + /// Deployment directory names under `deployments_base`. sources: Vec, + /// Virtualenv directory names inside the selected deployment. venvs: Vec, source_state: ListState, venv_state: ListState, @@ -24,17 +30,19 @@ pub struct CustomTab { } impl CustomTab { - pub fn new(apps_base: &Path, venvs_base: &Path) -> Self { - let sources = list_dirs(apps_base); - let venvs = list_dirs(venvs_base); + pub fn new(deployments_base: &Path) -> Self { + let sources = list_dirs(deployments_base); - CustomTab { + let mut tab = CustomTab { + deployments_base: deployments_base.to_path_buf(), source_state: initial_state(&sources), - venv_state: initial_state(&venvs), + venv_state: ListState::default(), sources, - venvs, + venvs: Vec::new(), focus: Focus::Source, - } + }; + tab.refresh_venvs(); + tab } /// Move focus between the source and venv lists. @@ -46,28 +54,50 @@ impl CustomTab { } pub fn next(&mut self) { - let (items, state) = self.focused_mut(); - select_relative(items, state, 1); + self.move_selection(1); } pub fn previous(&mut self) { - let (items, state) = self.focused_mut(); - select_relative(items, state, -1); + self.move_selection(-1); } - fn focused_mut(&mut self) -> (&[String], &mut ListState) { + /// Move the selection in the focused list. Moving between deployments + /// re-scans the venvs of the newly selected one. + fn move_selection(&mut self, delta: isize) { match self.focus { - Focus::Source => (&self.sources, &mut self.source_state), - Focus::Venv => (&self.venvs, &mut self.venv_state), + Focus::Source => { + select_relative(&self.sources, &mut self.source_state, delta); + self.refresh_venvs(); + } + Focus::Venv => select_relative(&self.venvs, &mut self.venv_state, delta), } } + /// Recompute the venv list for the currently selected deployment. + fn refresh_venvs(&mut self) { + self.venvs = match self.selected_source_dir() { + Some(dir) => detect_venvs(&dir) + .unwrap_or_default() + .iter() + .filter_map(|path| path.file_name().map(|n| n.to_string_lossy().into_owned())) + .collect(), + None => Vec::new(), + }; + self.venv_state = initial_state(&self.venvs); + } + + /// Absolute path of the currently selected deployment directory. + fn selected_source_dir(&self) -> Option { + let name = self.sources.get(self.source_state.selected()?)?; + Some(self.deployments_base.join(name)) + } + pub fn draw(&mut self, frame: &mut Frame, area: Rect) { let [source_area, venv_area] = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) .areas(area); - let source_list = dir_list("Source", &self.sources, self.focus == Focus::Source); + let source_list = dir_list("Deployment", &self.sources, self.focus == Focus::Source); frame.render_stateful_widget(source_list, source_area, &mut self.source_state); let venv_list = dir_list("Venv", &self.venvs, self.focus == Focus::Venv); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index dc8fe36..9c31d0b 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -28,7 +28,7 @@ impl<'a> App<'a> { App { selected_tab: 0, entries: EntriesTab::new(&config.entries), - custom: CustomTab::new(&config.apps_base, &config.venvs_base), + custom: CustomTab::new(&config.deployments_base), } }