From 8b362ed03ce314ec93ba04068ab901ee338f0140 Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 14 Jul 2026 14:30:52 +0200 Subject: [PATCH] feat: add deployment workflow --- src/deploy.rs | 210 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 21 +++++ 2 files changed, 231 insertions(+) create mode 100644 src/deploy.rs diff --git a/src/deploy.rs b/src/deploy.rs new file mode 100644 index 0000000..3e75c1b --- /dev/null +++ b/src/deploy.rs @@ -0,0 +1,210 @@ +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/main.rs b/src/main.rs index 7569ed1..855ba3c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod config; +mod deploy; mod detect; mod launcher; mod tui; @@ -8,6 +9,7 @@ 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)] @@ -40,6 +42,20 @@ 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<()> { @@ -60,6 +76,11 @@ 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(())