feat: add deployment workflow #6
@@ -7,7 +7,7 @@
|
||||
CLI/TUI launcher for the AareDAQ server and GUI on MX beamline consoles at PSI.
|
||||
|
||||
Installed as the `aarel` command. It detects the current beamline and launches the
|
||||
matching application, or opens a TUI to pick one.
|
||||
matching application, opens a TUI to pick one, or deploys a new checkout.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -15,7 +15,32 @@ matching application, or opens a TUI to pick one.
|
||||
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 <name> # clone the repos and install them into a new virtualenv
|
||||
```
|
||||
|
||||
Use `--entry <beamline>` with `gui`/`server` to override detection, and `--config
|
||||
<path>` to use a different config file.
|
||||
Each command has a single-letter alias (`p`, `g`, `s`, `d`). `--config <path>`
|
||||
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 <name>` to launch a different one.
|
||||
|
||||
### Deploying
|
||||
|
||||
`deploy <name>` clones [aaredaq] and [aarecommon] into `<apps_base>/<name>`, then
|
||||
installs both editable into a fresh `uv` virtualenv at `<venvs_base>/<name>`. 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
|
||||
|
||||
+210
@@ -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<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());
|
||||
}
|
||||
}
|
||||
+21
@@ -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<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<()> {
|
||||
@@ -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(())
|
||||
|
||||
Reference in New Issue
Block a user