crates/gunnar-ui-server/src/release.rs
//! **The release layout on the host**, ported out of `deploy/deploy.sh`.
//!
//! ```text
//! /opt/gunnar-ui/
//! ├── current -> releases/<tag> the symlink the unit points through
//! ├── previous -> releases/<tag> what a rollback goes back to
//! ├── releases/<tag>/{gunnar-ui-server,dist/}
//! ├── console_key, state.redb state, deliberately outside the releases
//! └── data/ gunnar's own, untouched by any of this
//! ```
//!
//! The reasoning is the script's and it survives whole:
//!
//! * a release is assembled BESIDE the running one and takes its place by
//! renaming a symlink, which is one atomic syscall. What this replaced was
//! `mv dist dist.old && mv dist.new dist`, where the second half did not run
//! and the host was left with no bundle at all;
//! * everything is checked in the staging directory BEFORE anything in use is
//! touched, so a bad upload cannot take down what is running;
//! * the check includes the exact hashed wasm file this build produced, because
//! a restart that quietly kept serving the old directory still answers
//! `/health`;
//! * `previous` always points at whatever `current` was, so a rollback is one
//! rename too and running it twice puts you back where you started;
//! * a few releases are kept, and whatever `current` and `previous` resolve to
//! is never pruned.
//!
//! What is NEW here is the fourth of the script's known traps: `deploy.sh`'s
//! verify step rolled back unconditionally when the console did not come up, so
//! a FIRST deploy to a fresh host — which has no `previous` — answered
//! `no previous release to go back to` from inside an error path and exited 1
//! on top of an exit 1. [`Layout::rollback`] returns
//! [`RollbackRefused::NoPrevious`] instead, which a caller can tell apart from a
//! rollback that failed.
//!
//! Pure `std::fs`: no shell, no subprocess, and every function below is driven
//! against a temporary tree in this module's own tests.
use std::io;
use std::path::{Path, PathBuf};
/// A staging directory that is not a release. Reported by name, never as a bool:
/// `deploy.sh` printed one sentence per case and an operator needs the same.
#[derive(Debug, PartialEq, Eq)]
pub enum StageRefused {
/// No `gunnar-ui-server`, or one without an executable bit.
NotExecutable(PathBuf),
/// No `dist/index.html`.
NoIndex(PathBuf),
/// The bundle is missing the hashed wasm this build produced — so it is not
/// this build's bundle, whatever else it is.
NoWitness { witness: String, dist: PathBuf },
}
impl std::fmt::Display for StageRefused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StageRefused::NotExecutable(p) => {
write!(
f,
"the staged binary {} is missing or not executable",
p.display()
)
}
StageRefused::NoIndex(p) => {
write!(f, "the staged bundle {} has no index.html", p.display())
}
StageRefused::NoWitness { witness, dist } => write!(
f,
"the staged bundle {} is missing {witness} — it is not the bundle this build \
produced, and a console serving the old directory still answers /health",
dist.display()
),
}
}
}
/// Why a rollback did nothing.
#[derive(Debug, PartialEq, Eq)]
pub enum RollbackRefused {
/// There has only ever been one release here. **This is not a failure of the
/// rollback**, and the caller must not report it as one: it is what a first
/// deploy to a fresh host looks like when the verify step goes looking for a
/// way back. `deploy.sh` ran straight into it.
NoPrevious(PathBuf),
/// `current` is not a symlink, so there is nothing to swap.
NoCurrent(PathBuf),
}
impl std::fmt::Display for RollbackRefused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RollbackRefused::NoPrevious(root) => write!(
f,
"{}/previous does not exist: nothing has ever been swapped out here, so there \
is no release to go back to. On a first deploy this is the expected answer \
and the console that did not come up is still the only one there is.",
root.display()
),
RollbackRefused::NoCurrent(root) => {
write!(
f,
"{}/current is not a symlink; this is not a release layout",
root.display()
)
}
}
}
}
/// What a swap did, for the line the caller prints.
#[derive(Debug, PartialEq, Eq)]
pub struct Swapped {
pub now: PathBuf,
/// `None` on the very first deploy to a host.
pub was: Option<PathBuf>,
}
/// The layout, rooted at `/opt/gunnar-ui` in production and at a temporary
/// directory in every test below.
#[derive(Debug, Clone)]
pub struct Layout {
root: PathBuf,
}
impl Layout {
pub fn at(root: impl Into<PathBuf>) -> Layout {
Layout { root: root.into() }
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn current(&self) -> PathBuf {
self.root.join("current")
}
pub fn previous(&self) -> PathBuf {
self.root.join("previous")
}
pub fn releases(&self) -> PathBuf {
self.root.join("releases")
}
pub fn incoming(&self, tag: &str) -> PathBuf {
self.root.join("incoming").join(tag)
}
pub fn release(&self, tag: &str) -> PathBuf {
self.releases().join(tag)
}
/// **Check the staged release, before anything in use is touched.**
pub fn stage_check(&self, staging: &Path, witness: &str) -> Result<(), StageRefused> {
let bin = staging.join("gunnar-ui-server");
if !is_executable_file(&bin) {
return Err(StageRefused::NotExecutable(bin));
}
let dist = staging.join("dist");
if !dist.join("index.html").is_file() {
return Err(StageRefused::NoIndex(dist));
}
if !dist.join(witness).is_file() {
return Err(StageRefused::NoWitness {
witness: witness.to_string(),
dist,
});
}
Ok(())
}
/// **Swap the staged release in.** `incoming/<tag>` becomes `releases/<tag>`,
/// `current` points at it, and `previous` points at whatever `current` was.
///
/// The caller is expected to have run [`Layout::stage_check`] first; this
/// runs it again, because the check is cheap and the window between the two
/// is where an interrupted upload would land.
pub fn swap(&self, tag: &str, witness: &str) -> io::Result<Swapped> {
let staging = self.incoming(tag);
self.stage_check(&staging, witness)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
let release = self.release(tag);
std::fs::create_dir_all(self.releases())?;
if release.exists() {
std::fs::remove_dir_all(&release)?;
}
std::fs::rename(&staging, &release)?;
let was = std::fs::read_link(self.current()).ok();
// rename(2) over an existing symlink: the switch is one atomic step, and
// there is no moment where `current` does not resolve.
point(&self.current(), &relative_release(tag))?;
if let Some(was) = &was {
point(&self.previous(), was)?;
}
// `incoming` only ever holds one staging directory at a time; the script
// rmdir'd it and ignored the failure, and so does this.
let _ = std::fs::remove_dir(self.root.join("incoming"));
Ok(Swapped {
now: relative_release(tag),
was,
})
}
/// **Go back.** `current` and `previous` trade places, so running it twice
/// puts you back where you started.
pub fn rollback(&self) -> Result<Swapped, RollbackRefused> {
let target = std::fs::read_link(self.previous())
.map_err(|_| RollbackRefused::NoPrevious(self.root.clone()))?;
let now = std::fs::read_link(self.current())
.map_err(|_| RollbackRefused::NoCurrent(self.root.clone()))?;
point(&self.previous(), &now)
.and_then(|()| point(&self.current(), &target))
.map_err(|_| RollbackRefused::NoCurrent(self.root.clone()))?;
Ok(Swapped {
now: target,
was: Some(now),
})
}
/// **Keep a few to roll back through, and never delete what is in use.**
/// Newest first by modification time, exactly as `ls -1dt` ordered them.
/// Returns what it removed.
pub fn prune(&self, keep: usize) -> io::Result<Vec<PathBuf>> {
let mut dirs: Vec<(std::time::SystemTime, PathBuf)> = Vec::new();
let Ok(entries) = std::fs::read_dir(self.releases()) else {
return Ok(Vec::new());
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
let m = e
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::UNIX_EPOCH);
dirs.push((m, p));
}
}
dirs.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.cmp(&a.1)));
let pinned: Vec<PathBuf> = [self.current(), self.previous()]
.iter()
.filter_map(|l| std::fs::canonicalize(l).ok())
.collect();
let mut removed = Vec::new();
for (_, p) in dirs.into_iter().skip(keep) {
let real = std::fs::canonicalize(&p).unwrap_or_else(|_| p.clone());
if pinned.contains(&real) {
continue;
}
std::fs::remove_dir_all(&p)?;
removed.push(p);
}
Ok(removed)
}
}
/// `releases/<tag>` — the symlinks are RELATIVE, as the script wrote them, so the
/// whole tree can be moved or bind-mounted without every link going dangling.
fn relative_release(tag: &str) -> PathBuf {
PathBuf::from("releases").join(tag)
}
/// Point `link` at `target` atomically: make the new link under a temporary name
/// beside it, then `rename(2)` it over. There is no moment where `link` does not
/// resolve. This is `ln -sfn x tmp && mv -Tf tmp link`.
fn point(link: &Path, target: &Path) -> io::Result<()> {
let tmp = link.with_extension("tmp");
let _ = std::fs::remove_file(&tmp);
std::os::unix::fs::symlink(target, &tmp)?;
std::fs::rename(&tmp, link)
}
fn is_executable_file(p: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
const WITNESS: &str = "gunnar_ui_web_bg.wasm";
fn stage(l: &Layout, tag: &str, witness: Option<&str>, exec: bool) -> PathBuf {
let s = l.incoming(tag);
std::fs::create_dir_all(s.join("dist")).unwrap();
std::fs::write(s.join("gunnar-ui-server"), b"#!/bin/false\n").unwrap();
if exec {
std::fs::set_permissions(
s.join("gunnar-ui-server"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
}
std::fs::write(s.join("dist/index.html"), b"<!doctype html>").unwrap();
if let Some(w) = witness {
std::fs::write(s.join("dist").join(w), b"\0asm").unwrap();
}
s
}
#[test]
fn a_bad_upload_is_named_and_never_reaches_current() {
let tmp = tempfile::tempdir().unwrap();
let l = Layout::at(tmp.path());
let s = stage(&l, "t1", Some(WITNESS), false);
assert!(matches!(
l.stage_check(&s, WITNESS),
Err(StageRefused::NotExecutable(_))
));
let s = stage(&l, "t2", None, true);
assert!(matches!(
l.stage_check(&s, WITNESS),
Err(StageRefused::NoWitness { .. })
));
std::fs::remove_file(s.join("dist/index.html")).unwrap();
assert!(matches!(
l.stage_check(&s, WITNESS),
Err(StageRefused::NoIndex(_))
));
// …and a swap over a refused staging leaves nothing behind.
assert!(l.swap("t2", WITNESS).is_err());
assert!(!l.current().exists());
}
#[test]
fn the_first_swap_has_no_previous_and_the_second_one_does() {
let tmp = tempfile::tempdir().unwrap();
let l = Layout::at(tmp.path());
stage(&l, "a", Some(WITNESS), true);
let first = l.swap("a", WITNESS).unwrap();
assert_eq!(first.now, PathBuf::from("releases/a"));
assert_eq!(first.was, None);
assert!(!l.previous().exists());
stage(&l, "b", Some(WITNESS), true);
let second = l.swap("b", WITNESS).unwrap();
assert_eq!(second.was, Some(PathBuf::from("releases/a")));
assert_eq!(
std::fs::read_link(l.current()).unwrap(),
PathBuf::from("releases/b")
);
assert_eq!(
std::fs::read_link(l.previous()).unwrap(),
PathBuf::from("releases/a")
);
// `current` resolves at every point, which is the property `mv dist
// dist.old && mv dist.new dist` did not have.
assert!(l.current().join("dist/index.html").is_file());
}
#[test]
fn a_first_deploy_that_does_not_come_up_is_told_there_is_no_way_back() {
// THE TRAP. deploy.sh's verify step called itself with --rollback on any
// failure, and on a fresh host that ran into `no previous release to go
// back to` — an error inside an error path. It is an ANSWER, not a
// failure, and it has to be one a caller can match on.
let tmp = tempfile::tempdir().unwrap();
let l = Layout::at(tmp.path());
stage(&l, "a", Some(WITNESS), true);
l.swap("a", WITNESS).unwrap();
assert_eq!(
l.rollback(),
Err(RollbackRefused::NoPrevious(tmp.path().to_path_buf()))
);
// …and the one release there is, is still the one being served.
assert_eq!(
std::fs::read_link(l.current()).unwrap(),
PathBuf::from("releases/a")
);
}
#[test]
fn rolling_back_twice_puts_you_back_where_you_started() {
let tmp = tempfile::tempdir().unwrap();
let l = Layout::at(tmp.path());
for tag in ["a", "b"] {
stage(&l, tag, Some(WITNESS), true);
l.swap(tag, WITNESS).unwrap();
}
l.rollback().unwrap();
assert_eq!(
std::fs::read_link(l.current()).unwrap(),
PathBuf::from("releases/a")
);
l.rollback().unwrap();
assert_eq!(
std::fs::read_link(l.current()).unwrap(),
PathBuf::from("releases/b")
);
}
#[test]
fn pruning_keeps_the_newest_and_never_what_is_in_use() {
let tmp = tempfile::tempdir().unwrap();
let l = Layout::at(tmp.path());
for tag in ["a", "b", "c", "d"] {
stage(&l, tag, Some(WITNESS), true);
l.swap(tag, WITNESS).unwrap();
// mtime resolution on some filesystems is coarse enough that four
// directories made in one millisecond sort arbitrarily.
std::thread::sleep(std::time::Duration::from_millis(12));
}
// current -> d, previous -> c. Keep 2 ⇒ a and b go.
let removed = l.prune(2).unwrap();
assert_eq!(removed.len(), 2, "{removed:?}");
assert!(l.release("c").is_dir() && l.release("d").is_dir());
assert!(!l.release("a").exists() && !l.release("b").exists());
// …and a keep so small it would delete what is being served does not.
assert!(l.prune(0).unwrap().is_empty());
assert!(l.current().join("dist/index.html").is_file());
}
}