Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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());
    }
}