fix: venv inside deployment

This commit is contained in:
2026-07-24 11:13:29 +02:00
parent 910b11947e
commit 73d64300f2
8 changed files with 124 additions and 318 deletions
+40 -37
View File
@@ -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<PathBuf>,
/// Server entry point, relative to the entry's `src` directory.
/// Server entry point, relative to the entry's `dir`.
server_file: Option<PathBuf>,
/// Virtualenv directory, relative to the entry's `dir`.
venv: Option<PathBuf>,
}
/// 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<String, RawLauncherEntry>,
@@ -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<String, LauncherEntry>,
}
@@ -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<Config> {
.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<Config> {
.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();
-210
View File
@@ -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<PathBuf> {
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<PathBuf> {
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());
}
}
+27
View File
@@ -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<String, String> {
))
}
}
/// 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<Vec<PathBuf>, String> {
let entries =
fs::read_dir(dir).map_err(|e| format!("cannot read directory '{}': {e}", dir.display()))?;
let mut venvs: Vec<PathBuf> = 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()
}
+2 -23
View File
@@ -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<String>,
},
/// 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(())
+48 -18
View File
@@ -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<String>,
/// Virtualenv directory names inside the selected deployment.
venvs: Vec<String>,
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<PathBuf> {
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);
+1 -1
View File
@@ -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),
}
}