diff --git a/site/deploy/Caddyfile b/site/deploy/Caddyfile new file mode 100644 index 000000000..35aeb7dfc --- /dev/null +++ b/site/deploy/Caddyfile @@ -0,0 +1,18 @@ +# thirdshift.app, served from the copy of site/ that publish.sh swaps in. +# Caddy gets and renews the TLS certificates itself. + +thirdshift.app { + # The short install command always gets the latest release's installer. + # A 302, not a 301, so clients never cache one release's address. + redir /install.sh https://github.com/JacobStephens2/thirdshift/releases/latest/download/thirdshift-installer.sh 302 + + # publish.sh never copies the deploy directory; this is a second guard. + respond /deploy/* 404 + + root * /srv/thirdshift.app/current + file_server +} + +www.thirdshift.app { + redir https://thirdshift.app{uri} permanent +} diff --git a/site/deploy/README.md b/site/deploy/README.md new file mode 100644 index 000000000..c28bd0f32 --- /dev/null +++ b/site/deploy/README.md @@ -0,0 +1,63 @@ +# Deploying thirdshift.app + +This directory holds the configuration that serves thirdshift.app. It is never served itself. + +- `Caddyfile`: the site block. It serves the published copy of `site/` with automatic TLS, permanently redirects `www.thirdshift.app` to `https://thirdshift.app`, and sends `/install.sh` with a 302 to the latest release's installer. +- `publish.sh`: fetches `main` into a checkout kept only for deploying. When `site/` (apart from this directory) has changed since the last publish, it copies it into the web root and swaps it in with one rename. It writes the published commit to `commit.txt` at the site root. It is safe to re-run, and does nothing when nothing changed. +- `thirdshift-site-publish.service` and `.timer`: run `publish.sh` every five minutes as the unprivileged `thirdshift-site` user. + +The deploy only ever pulls from the public repository, so GitHub holds no credentials for the server. There are no analytics. + +## Install + +Installing is a manual step. Run these as root on the server, which already runs Caddy. + +1. Create the user, the deploy checkout and the web root: + + ```sh + useradd --system --home-dir /var/lib/thirdshift-site --create-home --shell /usr/sbin/nologin thirdshift-site + install -d -o thirdshift-site -g thirdshift-site -m 755 /srv/thirdshift.app + sudo -u thirdshift-site git clone https://github.com/JacobStephens2/thirdshift.git /var/lib/thirdshift-site/checkout + ``` + +2. Publish once by hand, and check that `/srv/thirdshift.app/current` points at a copy of `site/` with a `commit.txt`: + + ```sh + sudo -u thirdshift-site sh /var/lib/thirdshift-site/checkout/site/deploy/publish.sh /var/lib/thirdshift-site/checkout /srv/thirdshift.app + cat /srv/thirdshift.app/current/commit.txt + ``` + +3. Install and start the timer: + + ```sh + cp /var/lib/thirdshift-site/checkout/site/deploy/thirdshift-site-publish.service \ + /var/lib/thirdshift-site/checkout/site/deploy/thirdshift-site-publish.timer \ + /etc/systemd/system/ + systemctl daemon-reload + systemctl enable --now thirdshift-site-publish.timer + systemctl list-timers thirdshift-site-publish.timer + ``` + +4. Add the contents of `Caddyfile` to the server's Caddyfile, then validate and reload: + + ```sh + caddy validate --config /etc/caddy/Caddyfile + systemctl reload caddy + ``` + + On a host with SELinux enforcing, label the web root so Caddy may read it: + + ```sh + semanage fcontext -a -t httpd_sys_content_t '/srv/thirdshift.app(/.*)?' + restorecon -R /srv/thirdshift.app + ``` + +5. Check it from anywhere: + + ```sh + curl -I https://thirdshift.app # 200 + curl -I https://www.thirdshift.app # 301 to https://thirdshift.app/ + curl -I https://thirdshift.app/install.sh # 302 to the latest thirdshift-installer.sh + ``` + +Every publish moves the deploy checkout to the tip of `main`, even when nothing served changed. The timer runs `publish.sh` from that checkout, so changes to the script take effect on the next run. Changes to the units or the Caddy block need steps 3 or 4 again, which copy them from the same checkout. Publish logs are in `journalctl -u thirdshift-site-publish`. diff --git a/site/deploy/publish.sh b/site/deploy/publish.sh new file mode 100755 index 000000000..4b89efbc1 --- /dev/null +++ b/site/deploy/publish.sh @@ -0,0 +1,74 @@ +#!/bin/sh +# Publishes thirdshift.app: moves a checkout kept for deploying to the tip of +# main and, when the served part of site/ changed since the last publish, +# copies it into the web root and swaps it in atomically. Safe to re-run; it does +# nothing when nothing changed. +# +# Usage: publish.sh +# +# Web root layout: +# copies/./ one copy of site/ per publish, without deploy/ +# current -> copies/... the copy Caddy serves; swapped with one rename +# current/commit.txt the commit of main the copy was taken from +# +# Needs only git and GNU coreutils (mv -T), so it runs on Linux. +set -eu + +# The body is a function so the whole script is read before the checkout it +# lives in is moved to the new commit. +main() { + if [ "$#" -ne 2 ]; then + echo "usage: publish.sh " >&2 + exit 2 + fi + checkout=$1 + root=$2 + + git -C "$checkout" fetch --quiet origin +refs/heads/main:refs/remotes/origin/main + commit=$(git -C "$checkout" rev-parse --verify 'origin/main^{commit}') + # Always, so the units and Caddy block installed from the checkout are + # main's even when no served file changed. + git -C "$checkout" checkout --quiet --force --detach "$commit" + + previous= + if [ -L "$root/current" ]; then + previous=$(readlink "$root/current") + published=$(cat "$root/current/commit.txt" 2>/dev/null || true) + if site_unchanged "$checkout" "$published" "$commit"; then + return + fi + fi + + mkdir -p "$root/copies" + copy=$(mktemp -d "$root/copies/$commit.XXXXXX") + chmod 755 "$copy" + cp -R "$checkout/site/." "$copy/" + rm -rf "$copy/deploy" + printf '%s\n' "$commit" >"$copy/commit.txt" + + rm -f "$root/current.new" + ln -s "copies/${copy##*/}" "$root/current.new" + mv -T "$root/current.new" "$root/current" + + # Keep the copy just replaced, for requests still reading it. + for old in "$root"/copies/*; do + [ -e "$old" ] || continue + case "copies/${old##*/}" in + "copies/${copy##*/}" | "$previous") ;; + *) rm -rf "$old" ;; + esac + done + + echo "published $commit" +} + +# site_unchanged +# Whether the served site, site/ without deploy/, is the same at both commits. +# An empty or unknown published commit counts as changed. +site_unchanged() { + [ -n "$2" ] && + git -C "$1" cat-file -e "$2^{commit}" 2>/dev/null && + git -C "$1" diff --quiet "$2" "$3" -- site ':(exclude)site/deploy' +} + +main "$@" diff --git a/site/deploy/thirdshift-site-publish.service b/site/deploy/thirdshift-site-publish.service new file mode 100644 index 000000000..1d1d776b3 --- /dev/null +++ b/site/deploy/thirdshift-site-publish.service @@ -0,0 +1,18 @@ +[Unit] +Description=Publish thirdshift.app from the main branch +Documentation=https://github.com/JacobStephens2/thirdshift/tree/main/site/deploy +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +User=thirdshift-site +Group=thirdshift-site +Environment=HOME=/var/lib/thirdshift-site +ExecStart=/bin/sh /var/lib/thirdshift-site/checkout/site/deploy/publish.sh /var/lib/thirdshift-site/checkout /srv/thirdshift.app +UMask=0022 +NoNewPrivileges=yes +PrivateTmp=yes +ProtectHome=yes +ProtectSystem=strict +ReadWritePaths=/var/lib/thirdshift-site /srv/thirdshift.app diff --git a/site/deploy/thirdshift-site-publish.timer b/site/deploy/thirdshift-site-publish.timer new file mode 100644 index 000000000..89fd0342e --- /dev/null +++ b/site/deploy/thirdshift-site-publish.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Publish thirdshift.app every five minutes +Documentation=https://github.com/JacobStephens2/thirdshift/tree/main/site/deploy + +[Timer] +OnBootSec=1min +OnUnitActiveSec=5min + +[Install] +WantedBy=timers.target diff --git a/tests/packaging.rs b/tests/packaging.rs index 65ada265a..6dbab9db4 100644 --- a/tests/packaging.rs +++ b/tests/packaging.rs @@ -1,6 +1,6 @@ //! The crates.io package: it carries the sources and the Factory skills the -//! binary embeds, and none of the repository's own agent tooling, docs or -//! tests. +//! binary embeds, and none of the repository's own agent tooling, docs, site +//! or tests. use std::process::Command; @@ -26,7 +26,7 @@ fn package_list() -> Vec { #[test] fn package_leaves_out_the_repository_tooling() { for path in package_list() { - for excluded in [".agents/", ".grok/", ".claude/", "docs/", "tests/"] { + for excluded in [".agents/", ".grok/", ".claude/", "docs/", "site/", "tests/"] { assert!(!path.starts_with(excluded), "{path} is in the package"); } } diff --git a/tests/site_deploy.rs b/tests/site_deploy.rs new file mode 100644 index 000000000..d4b82bc8f --- /dev/null +++ b/tests/site_deploy.rs @@ -0,0 +1,315 @@ +//! The thirdshift.app deploy configuration in `site/deploy/`: the publish +//! script run against a local origin and a temporary web root, and the Caddy +//! block and systemd units checked with their own tools where installed. +//! +//! The publish script targets the Linux server and swaps its symlink with GNU +//! `mv -T`, so its tests run on Linux only. + +#![cfg_attr(not(target_os = "linux"), allow(dead_code))] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::SystemTime; + +use serde_json::Value; +use tempfile::TempDir; + +fn deploy_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("site/deploy") +} + +/// Keeps the machine's git configuration (signing, hooks, default branch) out +/// of the test's git and the publish script's. +fn without_user_git_config(command: &mut Command) -> &mut Command { + command + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") +} + +fn git(dir: &Path, args: &[&str]) -> String { + let output = without_user_git_config(&mut Command::new("git")) + .args(args) + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .output() + .expect("could not run git"); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +/// Whether `tool` is on PATH; the Caddy and systemd checks skip without it. +fn installed(tool: &str) -> bool { + Command::new(tool).arg("version").output().is_ok() +} + +/// A bare origin, a contributor's clone that pushes to it, the deploy +/// checkout the publish script fetches into, and an empty web root. +struct Deploy { + temp: TempDir, + /// The commit on main before the test adds any. + first_commit: String, +} + +impl Deploy { + fn new() -> Self { + let temp = TempDir::new().unwrap(); + git( + temp.path(), + &["init", "-q", "--bare", "-b", "main", "origin.git"], + ); + git(temp.path(), &["clone", "-q", "origin.git", "contributor"]); + let mut deploy = Deploy { + temp, + first_commit: String::new(), + }; + deploy.first_commit = deploy.commit(&[ + ("site/index.html", "home v1"), + ("site/prompts/index.html", "prompts v1"), + ("site/deploy/publish.sh", "not for the web"), + ("README.md", "readme v1"), + ]); + git( + deploy.temp_dir(), + &["clone", "-q", "origin.git", "checkout"], + ); + fs::create_dir(deploy.web_root()).unwrap(); + deploy + } + + fn temp_dir(&self) -> &Path { + self.temp.path() + } + + fn checkout(&self) -> PathBuf { + self.temp_dir().join("checkout") + } + + fn web_root(&self) -> PathBuf { + self.temp_dir().join("www") + } + + /// Commits `files` in the contributor's clone and pushes them to main. + fn commit(&self, files: &[(&str, &str)]) -> String { + let clone = self.temp_dir().join("contributor"); + for (path, contents) in files { + let path = clone.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); + } + git(&clone, &["add", "-A"]); + git(&clone, &["commit", "-q", "-m", "change"]); + git(&clone, &["push", "-q", "origin", "HEAD:main"]); + git(&clone, &["rev-parse", "HEAD"]) + } + + fn publish(&self) { + let output = without_user_git_config(&mut Command::new(deploy_dir().join("publish.sh"))) + .arg(self.checkout()) + .arg(self.web_root()) + .output() + .expect("could not run publish.sh"); + assert!( + output.status.success(), + "publish.sh failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + /// The directory the web root's `current` symlink points at. + fn current(&self) -> PathBuf { + fs::canonicalize(self.web_root().join("current")).unwrap() + } + + fn served(&self, path: &str) -> String { + fs::read_to_string(self.web_root().join("current").join(path)).unwrap() + } + + /// Every entry under the web root, with modification times, so a run + /// that changes anything at all shows up as a difference. + fn snapshot(&self) -> Vec<(PathBuf, SystemTime)> { + fn walk(dir: &Path, out: &mut Vec<(PathBuf, SystemTime)>) { + for entry in fs::read_dir(dir).unwrap() { + let entry = entry.unwrap(); + let meta = fs::symlink_metadata(entry.path()).unwrap(); + out.push((entry.path(), meta.modified().unwrap())); + if meta.is_dir() { + walk(&entry.path(), out); + } + } + } + let mut out = Vec::new(); + walk(&self.web_root(), &mut out); + out.sort(); + out + } +} + +#[test] +#[cfg(target_os = "linux")] +fn publishing_serves_the_site_without_the_deploy_directory_and_records_the_commit() { + let deploy = Deploy::new(); + + deploy.publish(); + + assert_eq!(deploy.served("index.html"), "home v1"); + assert_eq!(deploy.served("prompts/index.html"), "prompts v1"); + assert_eq!( + deploy.served("commit.txt"), + format!("{}\n", deploy.first_commit) + ); + assert!(!deploy.current().join("deploy").exists()); + assert!(!deploy.current().join("README.md").exists()); +} + +#[test] +#[cfg(target_os = "linux")] +fn publishing_again_with_no_new_commits_changes_nothing() { + let deploy = Deploy::new(); + deploy.publish(); + let before = deploy.snapshot(); + + deploy.publish(); + + assert_eq!(deploy.snapshot(), before); +} + +#[test] +#[cfg(target_os = "linux")] +fn a_commit_outside_the_served_site_changes_nothing_served() { + let deploy = Deploy::new(); + deploy.publish(); + let before = deploy.snapshot(); + let head = deploy.commit(&[ + ("README.md", "readme v2"), + ("site/deploy/publish.sh", "still not for the web"), + ]); + + deploy.publish(); + + assert_eq!(deploy.snapshot(), before); + // The deploy checkout still follows main, so the units and Caddy block a + // human installs from it are current. + assert_eq!(git(&deploy.checkout(), &["rev-parse", "HEAD"]), head); + assert_eq!( + fs::read_to_string(deploy.checkout().join("site/deploy/publish.sh")).unwrap(), + "still not for the web" + ); +} + +#[test] +#[cfg(target_os = "linux")] +fn a_commit_to_the_site_swaps_in_a_new_copy() { + let deploy = Deploy::new(); + deploy.publish(); + let old = deploy.current(); + + let head = deploy.commit(&[("site/index.html", "home v2")]); + deploy.publish(); + + assert_ne!(deploy.current(), old); + assert!( + fs::symlink_metadata(deploy.web_root().join("current")) + .unwrap() + .is_symlink() + ); + assert_eq!(deploy.served("index.html"), "home v2"); + assert_eq!(deploy.served("commit.txt"), format!("{head}\n")); + // The copy it replaced is left whole for requests still reading it. + assert_eq!( + fs::read_to_string(old.join("index.html")).unwrap(), + "home v1" + ); +} + +#[test] +#[cfg(target_os = "linux")] +fn only_the_live_and_previous_copies_are_kept() { + let deploy = Deploy::new(); + deploy.publish(); + for n in 2..=4 { + deploy.commit(&[("site/index.html", &format!("home v{n}"))]); + deploy.publish(); + } + + let kept = fs::read_dir(deploy.web_root().join("copies")) + .unwrap() + .count(); + + assert_eq!(kept, 2); + assert_eq!(deploy.served("index.html"), "home v4"); +} + +#[test] +fn the_caddy_block_adapts() { + if !installed("caddy") { + eprintln!("skipping: caddy is not installed"); + return; + } + let output = Command::new("caddy") + .args(["adapt", "--adapter", "caddyfile", "--config"]) + .arg(deploy_dir().join("Caddyfile")) + .output() + .unwrap(); + assert!( + output.status.success(), + "caddy adapt: {}", + String::from_utf8_lossy(&output.stderr) + ); + let config: Value = serde_json::from_slice(&output.stdout).unwrap(); + + let site = caddy_route(&config, "thirdshift.app"); + assert!(site.contains(r#""handler":"file_server""#), "{site}"); + assert!(site.contains(&redirect( + 302, + "https://github.com/JacobStephens2/thirdshift/releases/latest/download/thirdshift-installer.sh" + )), "{site}"); + let www = caddy_route(&config, "www.thirdshift.app"); + assert!( + www.contains(&redirect(301, "https://thirdshift.app{http.request.uri}")), + "{www}" + ); +} + +/// The adapted Caddy route that matches `host`, as JSON text. +fn caddy_route(config: &Value, host: &str) -> String { + let routes = &config["apps"]["http"]["servers"]["srv0"]["routes"]; + routes + .as_array() + .unwrap() + .iter() + .find(|route| route["match"][0]["host"][0] == host) + .unwrap_or_else(|| panic!("no route for {host} in {routes}")) + .to_string() +} + +/// How a redirect handler reads in adapted Caddy JSON. +fn redirect(status: u16, location: &str) -> String { + format!(r#""headers":{{"Location":["{location}"]}},"status_code":{status}"#) +} + +#[test] +fn the_systemd_units_verify() { + if !installed("systemd-analyze") { + eprintln!("skipping: systemd-analyze is not installed"); + return; + } + let output = Command::new("systemd-analyze") + .args(["verify", "--man=no"]) + .arg(deploy_dir().join("thirdshift-site-publish.service")) + .arg(deploy_dir().join("thirdshift-site-publish.timer")) + .output() + .unwrap(); + assert!( + output.status.success() && output.stderr.is_empty(), + "systemd-analyze verify: {}", + String::from_utf8_lossy(&output.stderr) + ); +}