aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/archive.rs406
-rw-r--r--src/files.rs336
-rw-r--r--src/images.rs215
-rw-r--r--src/main.rs252
-rw-r--r--src/process.rs70
-rw-r--r--src/project.rs548
-rw-r--r--src/release.rs131
7 files changed, 1958 insertions, 0 deletions
diff --git a/src/archive.rs b/src/archive.rs
new file mode 100644
index 0000000..e7e6bc5
--- /dev/null
+++ b/src/archive.rs
@@ -0,0 +1,406 @@
+// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+use std::collections::BTreeSet;
+use std::fs;
+use std::path::Path;
+use std::process::Command;
+
+use anyhow::{Context, Result, ensure};
+use serde_json::Value;
+
+use crate::project::{Mode, Project, Settings, field};
+use crate::{files, process};
+
+pub(crate) const RECIPIENT: &str = "3346AA3150F8F16828BB0D14F8C66DF77AFECEA6";
+const KEY: &str = "web/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp";
+
+#[derive(Debug)]
+struct Paused<'a> {
+ project: &'a Project,
+ ids: Vec<String>,
+}
+
+impl Paused<'_> {
+ fn resume(&mut self) -> Result<()> {
+ let mut failure = None;
+ for id in self.ids.iter().rev() {
+ if let Err(error) =
+ process::capture(self.project.command("podman")?.args(["unpause", id]))
+ {
+ process::error(format_args!("Could not resume container {id}: {error:#}"))?;
+ failure = Some(error);
+ }
+ }
+ self.ids.clear();
+ if let Some(error) = failure {
+ return Err(error);
+ }
+ Ok(())
+ }
+}
+
+impl Drop for Paused<'_> {
+ fn drop(&mut self) {
+ if let Err(error) = self.resume() {
+ drop(process::error(format_args!(
+ "Container resume failed: {error:#}"
+ )));
+ }
+ }
+}
+
+pub(crate) fn containers(project: &Project, settings: &Settings) -> Result<Vec<Value>> {
+ let records = project.inspect_containers(settings)?;
+ let actual: BTreeSet<_> = records
+ .iter()
+ .map(|record| {
+ field(record, &["Config", "Labels", "io.stack.service"])?
+ .as_str()
+ .context("Missing service label")
+ })
+ .collect::<Result<_>>()?;
+ let configuration = project.configuration(settings)?;
+ let expected: BTreeSet<_> = configuration.services.keys().map(String::as_str).collect();
+ ensure!(
+ actual == expected,
+ "Running services differ from configuration; refusing an incomplete backup"
+ );
+ Ok(records)
+}
+
+fn snapshot(project: &Project, records: &[Value], stage: &Path) -> Result<()> {
+ let mut guard = Paused {
+ project,
+ ids: Vec::new(),
+ };
+ for record in records {
+ if field(record, &["State", "Running"])?.as_bool() == Some(true)
+ && field(record, &["State", "Paused"])?.as_bool() == Some(false)
+ {
+ let id = field(record, &["Id"])?
+ .as_str()
+ .context("Missing container ID")?;
+ process::capture(project.command("podman")?.args(["pause", id]))?;
+ guard.ids.push(id.to_owned());
+ }
+ }
+ let result = snapshot_files(project, records, stage);
+ let resume = guard.resume();
+ result?;
+ resume
+}
+
+fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result<()> {
+ files::copy_source(&project.root, stage)?;
+ files::copy_tree(&project.root.join(".env"), &stage.join(".env"))?;
+ if project.root.join(".git").is_dir() {
+ files::copy_tree(&project.root.join(".git"), &stage.join(".git"))?;
+ }
+ let data = project.root.join("var/data");
+ for item in fs::read_dir(&data)? {
+ let entry = item?;
+ if entry.file_name() != "caddy" {
+ files::copy_tree(
+ &entry.path(),
+ &stage.join("var/data").join(entry.file_name()),
+ )?;
+ }
+ }
+ let caddy = records
+ .iter()
+ .find(|record| {
+ field(record, &["Config", "Labels", "io.stack.service"])
+ .ok()
+ .and_then(Value::as_str)
+ == Some("caddy")
+ })
+ .context("Caddy is not present")?;
+ let id = field(caddy, &["Id"])?.as_str().context("Caddy has no ID")?;
+ let target = stage.join("var/data/caddy");
+ fs::create_dir_all(&target)?;
+ for name in ["data", "config"] {
+ process::capture(
+ project
+ .command("podman")?
+ .args(["cp", &format!("{id}:/{name}")])
+ .arg(&target),
+ )?;
+ }
+ fs::create_dir_all(stage.join("bin"))?;
+ let executable = [
+ project.root.join("bin/stack"),
+ project
+ .root
+ .join("target/x86_64-unknown-linux-musl/release/stack"),
+ ]
+ .into_iter()
+ .find(|path| path.is_file())
+ .context("Build a release locally before exporting so the backup includes a portable binary")?;
+ fs::copy(executable, stage.join("bin/stack"))?;
+ Ok(())
+}
+
+pub(crate) fn backup(
+ project: &Project,
+ destination: &Path,
+ with_images: bool,
+ recipient: &str,
+ public_key: Option<&Path>,
+) -> Result<()> {
+ ensure!(
+ recipient.len() == 40 && recipient.bytes().all(|byte| byte.is_ascii_hexdigit()),
+ "A full 40-character recipient fingerprint is required"
+ );
+ files::private_dir(destination)?;
+ let settings = project.settings()?;
+ let records = containers(project, &settings)?;
+ let temp = tempfile::Builder::new()
+ .prefix(".export-")
+ .tempdir_in(destination)?;
+ let home = temp.path().join("gnupg");
+ files::private_dir(&home)?;
+ let key = public_key.map_or_else(|| project.root.join(KEY), Path::to_path_buf);
+ let operation = backup_inner(
+ project,
+ destination,
+ with_images,
+ &Encryption {
+ recipient,
+ key: &key,
+ home: &home,
+ },
+ &records,
+ temp.path(),
+ );
+ drop(process::capture(
+ Command::new("gpgconf")
+ .arg("--homedir")
+ .arg(&home)
+ .args(["--kill", "all"]),
+ ));
+ operation
+}
+
+#[derive(Debug)]
+struct Encryption<'a> {
+ recipient: &'a str,
+ key: &'a Path,
+ home: &'a Path,
+}
+
+fn backup_inner(
+ project: &Project,
+ destination: &Path,
+ with_images: bool,
+ encryption: &Encryption<'_>,
+ records: &[Value],
+ temp: &Path,
+) -> Result<()> {
+ let Encryption {
+ recipient,
+ key,
+ home,
+ } = encryption;
+ process::capture(
+ Command::new("gpg")
+ .args(["--no-options", "--batch", "--homedir"])
+ .arg(home)
+ .args(["--import"])
+ .arg(key),
+ )?;
+ let listed = process::text(
+ Command::new("gpg")
+ .args(["--no-options", "--batch", "--homedir"])
+ .arg(home)
+ .args(["--with-colons", "--list-keys", recipient]),
+ )?;
+ ensure!(
+ listed
+ .lines()
+ .filter(|line| line.starts_with("fpr:"))
+ .any(|line| line
+ .split(':')
+ .nth(9)
+ .is_some_and(|found| found.eq_ignore_ascii_case(recipient))),
+ "Public key fingerprint does not match the requested backup recipient"
+ );
+ let stage = temp.join("stack");
+ files::private_dir(&stage)?;
+ snapshot(project, records, &stage)?;
+ for entry in fs::read_dir(stage.join("var/data/repos"))? {
+ let repo = entry?.path();
+ if repo.is_dir() {
+ process::capture(
+ Command::new("git")
+ .arg("--git-dir")
+ .arg(repo)
+ .args(["fsck", "--full"]),
+ )?;
+ }
+ }
+ if with_images {
+ crate::images::save(project, records, &stage.join("var"))?;
+ }
+ files::manifest(&stage, "backup")?;
+ let plain = temp.join("stack.tar.gz");
+ files::pack(&stage, &plain)?;
+ let name = format!(
+ "stack-{}.tar.gz.gpg",
+ chrono::Utc::now().format("%Y%m%dT%H%M%SZ")
+ );
+ let encrypted = temp.join(&name);
+ process::run(
+ Command::new("gpg")
+ .args(["--no-options", "--batch", "--homedir"])
+ .arg(home)
+ .args([
+ "--rfc4880",
+ "--trust-model",
+ "always",
+ "--recipient",
+ recipient,
+ "--cipher-algo",
+ "AES256",
+ "--compress-algo",
+ "none",
+ "--set-filename",
+ "stack.tar.gz",
+ "--output",
+ ])
+ .arg(&encrypted)
+ .arg("--encrypt")
+ .arg(plain),
+ )?;
+ let output = destination.join(&name);
+ ensure!(
+ !output.exists(),
+ "An export with this timestamp already exists"
+ );
+ fs::rename(&encrypted, &output)?;
+ let digest = format!("{} {name}\n", files::hash_file(&output)?);
+ files::write(
+ &destination.join(format!("{name}.sha256")),
+ digest.as_bytes(),
+ 0o600,
+ )?;
+ process::line(format_args!("Encrypted export: {}", output.display()))
+}
+
+pub(crate) fn restore(archive: &Path, destination: &Path) -> Result<()> {
+ ensure!(!destination.exists(), "Restore destination must not exist");
+ let parent = destination
+ .parent()
+ .context("Restore destination has no parent")?;
+ fs::create_dir_all(parent)?;
+ let temp = tempfile::Builder::new()
+ .prefix(".restore-")
+ .tempdir_in(parent)?;
+ let decrypted = temp.path().join("stack.tar.gz");
+ let source = if archive.extension().is_some_and(|suffix| suffix == "gpg") {
+ process::run(
+ Command::new("gpg")
+ .args(["--no-options", "--output"])
+ .arg(&decrypted)
+ .arg("--decrypt")
+ .arg(archive),
+ )?;
+ &decrypted
+ } else {
+ archive
+ };
+ let stage = temp.path().join("stack");
+ files::extract(source, &stage)?;
+ let manifest = files::verify(&stage)?;
+ ensure!(
+ manifest.kind == "backup" || manifest.kind == "release",
+ "Unknown archive kind"
+ );
+ let project = Project::at(&stage)?;
+ let mut settings = project.settings()?;
+ settings.mode = Mode::Local;
+ settings.project = format!(
+ "stack-{}",
+ files::hash_bytes(destination.as_os_str().as_encoded_bytes())
+ .chars()
+ .take(10)
+ .collect::<String>()
+ );
+ project.save(&settings)?;
+ if stage.join(".git").is_dir() {
+ process::capture(
+ Command::new("git")
+ .arg("-C")
+ .arg(&stage)
+ .args(["remote", "set-url", "origin"])
+ .arg(destination.join("var/data/repos/stack")),
+ )?;
+ }
+ fs::rename(&stage, destination)?;
+ process::line(format_args!(
+ "Restored and verified {}. No services started.",
+ destination.display()
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+
+ use anyhow::Result;
+
+ use crate::files;
+ use crate::project::{Mode, Project};
+
+ #[test]
+ fn restore_is_local_and_refuses_an_existing_destination() -> Result<()> {
+ let workspace = tempfile::tempdir()?;
+ let source = workspace.path().join("source");
+ files::private_dir(&source)?;
+ fs::write(source.join(".env"), "SEARXNG_SECRET=private\n")?;
+ let project = Project::at(&source)?;
+ let mut settings = project.settings()?;
+ settings.mode = Mode::Production;
+ project.save(&settings)?;
+ files::manifest(&source, "backup")?;
+ let archive = workspace.path().join("backup.tar.gz");
+ files::pack(&source, &archive)?;
+ let destination = workspace.path().join("restored");
+ super::restore(&archive, &destination)?;
+ assert_eq!(Project::at(&destination)?.settings()?.mode, Mode::Local);
+ assert_eq!(
+ fs::read_to_string(destination.join(".env"))?,
+ "SEARXNG_SECRET=private\n"
+ );
+ assert!(super::restore(&archive, &destination).is_err());
+ Ok(())
+ }
+
+ #[test]
+ fn corrupt_restore_never_publishes_a_destination() -> Result<()> {
+ let workspace = tempfile::tempdir()?;
+ let source = workspace.path().join("source");
+ files::private_dir(&source)?;
+ fs::write(source.join(".env"), "original")?;
+ files::manifest(&source, "backup")?;
+ fs::write(source.join(".env"), "tampered")?;
+ let archive = workspace.path().join("backup.tar.gz");
+ files::pack(&source, &archive)?;
+ let destination = workspace.path().join("restored");
+ assert!(super::restore(&archive, &destination).is_err());
+ assert!(!destination.exists());
+ Ok(())
+ }
+}
diff --git a/src/files.rs b/src/files.rs
new file mode 100644
index 0000000..b939311
--- /dev/null
+++ b/src/files.rs
@@ -0,0 +1,336 @@
+// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+use std::collections::BTreeMap;
+use std::fs::{self, File, OpenOptions, Permissions};
+use std::io::{Read, Write};
+use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
+use std::path::{Component, Path, PathBuf};
+
+use anyhow::{Context, Result, bail, ensure};
+use flate2::Compression;
+use flate2::read::GzDecoder;
+use flate2::write::GzEncoder;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use walkdir::WalkDir;
+
+pub(crate) const SOURCE: &[&str] = &[
+ "Cargo.toml",
+ "Cargo.lock",
+ "rust-toolchain.toml",
+ ".cargo",
+ ".gitignore",
+ ".forgejo",
+ "src",
+ "tests",
+ "crates",
+ "infra",
+ "web",
+ "docs",
+ "README.txt",
+ "LICENSE",
+ "deny.toml",
+ "about.toml",
+ "about.hbs",
+ ".env.example",
+];
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Manifest {
+ pub(crate) format: u32,
+ pub(crate) kind: String,
+ pub(crate) version: String,
+ pub(crate) created_utc: String,
+ pub(crate) files: BTreeMap<String, String>,
+}
+
+pub(crate) fn private_dir(path: &Path) -> Result<()> {
+ fs::DirBuilder::new()
+ .recursive(true)
+ .mode(0o700)
+ .create(path)?;
+ fs::set_permissions(path, Permissions::from_mode(0o700))?;
+ Ok(())
+}
+
+pub(crate) fn write(path: &Path, data: &[u8], mode: u32) -> Result<()> {
+ let parent = path.parent().context("File has no parent directory")?;
+ fs::create_dir_all(parent)?;
+ let mut pending = tempfile::NamedTempFile::new_in(parent)?;
+ pending
+ .as_file_mut()
+ .set_permissions(Permissions::from_mode(mode))?;
+ pending.write_all(data)?;
+ pending.as_file().sync_all()?;
+ pending.persist(path).map_err(|error| error.error)?;
+ Ok(())
+}
+
+pub(crate) fn hash_bytes(bytes: &[u8]) -> String {
+ hex::encode(Sha256::digest(bytes))
+}
+
+pub(crate) fn hash_file(path: &Path) -> Result<String> {
+ let mut file = File::open(path).with_context(|| format!("Cannot read {}", path.display()))?;
+ let mut digest = Sha256::new();
+ let mut buffer = [0; 8192];
+ loop {
+ let length = file.read(&mut buffer)?;
+ if length == 0 {
+ break;
+ }
+ digest.update(buffer.get(..length).context("Invalid read length")?);
+ }
+ Ok(hex::encode(digest.finalize()))
+}
+
+pub(crate) fn relative(path: &Path, base: &Path) -> Result<String> {
+ Ok(path
+ .strip_prefix(base)?
+ .to_str()
+ .context("Paths must be UTF-8")?
+ .to_owned())
+}
+
+pub(crate) fn regular_tree(path: &Path) -> Result<()> {
+ for item in WalkDir::new(path).follow_links(false) {
+ let entry = item?;
+ ensure!(
+ entry.file_type().is_dir() || entry.file_type().is_file(),
+ "Links and special files are not accepted: {}",
+ entry.path().display()
+ );
+ }
+ Ok(())
+}
+
+pub(crate) fn copy_tree(source: &Path, destination: &Path) -> Result<()> {
+ regular_tree(source)?;
+ if source.is_file() {
+ let parent = destination.parent().context("File has no parent")?;
+ fs::create_dir_all(parent)?;
+ fs::copy(source, destination)?;
+ return Ok(());
+ }
+ for item in WalkDir::new(source).follow_links(false) {
+ let entry = item?;
+ let target = destination.join(entry.path().strip_prefix(source)?);
+ if entry.file_type().is_dir() {
+ fs::create_dir_all(&target)?;
+ fs::set_permissions(&target, entry.metadata()?.permissions())?;
+ } else {
+ let parent = target.parent().context("File has no parent")?;
+ fs::create_dir_all(parent)?;
+ fs::copy(entry.path(), &target)?;
+ }
+ }
+ Ok(())
+}
+
+pub(crate) fn copy_source(root: &Path, destination: &Path) -> Result<()> {
+ for name in SOURCE {
+ let source = root.join(name);
+ if source.exists() {
+ copy_tree(&source, &destination.join(name))?;
+ }
+ }
+ Ok(())
+}
+
+pub(crate) fn inventory(root: &Path) -> Result<BTreeMap<String, String>> {
+ let mut files = BTreeMap::new();
+ for item in WalkDir::new(root).follow_links(false) {
+ let entry = item?;
+ ensure!(!entry.file_type().is_symlink(), "Archive contains a link");
+ if entry.file_type().is_file() {
+ let name = relative(entry.path(), root)?;
+ if name != "manifest.json" {
+ files.insert(name, hash_file(entry.path())?);
+ }
+ }
+ }
+ Ok(files)
+}
+
+pub(crate) fn manifest(root: &Path, kind: &str) -> Result<()> {
+ let record = Manifest {
+ format: 2,
+ kind: kind.to_owned(),
+ version: env!("CARGO_PKG_VERSION").to_owned(),
+ created_utc: chrono::Utc::now().to_rfc3339(),
+ files: inventory(root)?,
+ };
+ write(
+ &root.join("manifest.json"),
+ &serde_json::to_vec_pretty(&record)?,
+ 0o600,
+ )
+}
+
+pub(crate) fn verify(root: &Path) -> Result<Manifest> {
+ let record: Manifest = serde_json::from_reader(File::open(root.join("manifest.json"))?)?;
+ ensure!(
+ record.format == 2,
+ "Unsupported export format; legacy Python exports retain their embedded recovery helper"
+ );
+ ensure!(
+ inventory(root)? == record.files,
+ "Archive file list or checksums do not match the manifest"
+ );
+ Ok(record)
+}
+
+pub(crate) fn pack(root: &Path, archive: &Path) -> Result<()> {
+ regular_tree(root)?;
+ let file = OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .mode(0o600)
+ .open(archive)?;
+ let encoder = GzEncoder::new(file, Compression::new(3));
+ let mut builder = tar::Builder::new(encoder);
+ builder.follow_symlinks(false);
+ builder.append_dir_all("stack", root)?;
+ builder.into_inner()?.finish()?.sync_all()?;
+ Ok(())
+}
+
+pub(crate) fn member_path(path: &Path, destination: &Path) -> Result<PathBuf> {
+ let mut parts = path.components();
+ ensure!(
+ parts.next() == Some(Component::Normal("stack".as_ref())),
+ "Archive must have a stack root"
+ );
+ let mut target = destination.to_path_buf();
+ for part in parts {
+ match part {
+ Component::Normal(name) => target.push(name),
+ _ => bail!("Unsafe archive path: {}", path.display()),
+ }
+ }
+ Ok(target)
+}
+
+pub(crate) fn extract(archive: &Path, destination: &Path) -> Result<()> {
+ private_dir(destination)?;
+ let mut reader = tar::Archive::new(GzDecoder::new(File::open(archive)?));
+ let mut directories = Vec::new();
+ for item in reader.entries()? {
+ let mut entry = item?;
+ let kind = entry.header().entry_type();
+ ensure!(
+ kind.is_dir() || kind.is_file(),
+ "Archive links and special files are forbidden"
+ );
+ let target = member_path(&entry.path()?, destination)?;
+ let mode = entry.header().mode()? & 0o777;
+ if kind.is_dir() {
+ fs::create_dir_all(&target)?;
+ directories.push((target, mode | 0o700));
+ } else {
+ let parent = target.parent().context("File has no parent")?;
+ fs::create_dir_all(parent)?;
+ let mut output = OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .mode(mode)
+ .open(&target)?;
+ std::io::copy(&mut entry, &mut output)?;
+ output.sync_all()?;
+ }
+ }
+ for (path, mode) in directories.into_iter().rev() {
+ fs::set_permissions(path, Permissions::from_mode(mode))?;
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+ use std::path::Path;
+
+ use anyhow::Result;
+
+ use super::{copy_source, extract, manifest, member_path, pack, verify};
+
+ #[test]
+ fn rejects_paths_outside_archive_root() {
+ let destination = Path::new("/restore");
+ for name in ["/etc/passwd", "../escape", "stack/../escape", "other/file"] {
+ assert!(member_path(Path::new(name), destination).is_err());
+ }
+ assert_eq!(
+ member_path(Path::new("stack/web/index.html"), destination).ok(),
+ Some(destination.join("web/index.html"))
+ );
+ }
+
+ #[test]
+ fn detects_modified_and_extra_files() -> Result<()> {
+ let root = tempfile::tempdir()?;
+ fs::write(root.path().join("public.txt"), "original")?;
+ manifest(root.path(), "release")?;
+ assert!(verify(root.path()).is_ok());
+ fs::write(root.path().join("public.txt"), "changed")?;
+ assert!(verify(root.path()).is_err());
+ fs::write(root.path().join("public.txt"), "original")?;
+ fs::write(root.path().join("unexpected.txt"), "extra")?;
+ assert!(verify(root.path()).is_err());
+ Ok(())
+ }
+
+ #[test]
+ fn source_export_excludes_secrets_and_runtime() -> Result<()> {
+ let source = tempfile::tempdir()?;
+ let target = tempfile::tempdir()?;
+ fs::write(source.path().join("Cargo.toml"), "source")?;
+ fs::write(source.path().join(".env"), "private")?;
+ fs::create_dir(source.path().join("var"))?;
+ fs::write(source.path().join("var/private.key"), "private")?;
+ copy_source(source.path(), target.path())?;
+ assert!(target.path().join("Cargo.toml").is_file());
+ assert!(!target.path().join(".env").exists());
+ assert!(!target.path().join("var").exists());
+ Ok(())
+ }
+
+ #[test]
+ fn archives_round_trip_without_overwriting() -> Result<()> {
+ let workspace = tempfile::tempdir()?;
+ let source = workspace.path().join("source");
+ fs::create_dir(&source)?;
+ fs::write(source.join(".env"), "secret")?;
+ manifest(&source, "backup")?;
+ let archive = workspace.path().join("backup.tar.gz");
+ pack(&source, &archive)?;
+ let restored = workspace.path().join("restored");
+ extract(&archive, &restored)?;
+ assert!(verify(&restored).is_ok());
+ assert_eq!(fs::read_to_string(restored.join(".env"))?, "secret");
+ assert!(extract(&archive, &restored).is_err());
+ Ok(())
+ }
+
+ #[test]
+ fn rejects_symbolic_links() -> Result<()> {
+ let root = tempfile::tempdir()?;
+ std::os::unix::fs::symlink("/etc/passwd", root.path().join("link"))?;
+ assert!(super::regular_tree(root.path()).is_err());
+ Ok(())
+ }
+}
diff --git a/src/images.rs b/src/images.rs
new file mode 100644
index 0000000..11daaea
--- /dev/null
+++ b/src/images.rs
@@ -0,0 +1,215 @@
+// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+use std::collections::BTreeMap;
+use std::fs::{self, File};
+use std::path::Path;
+
+use anyhow::{Context, Result, ensure};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::project::{Mode, Project, Settings, field};
+use crate::{files, process};
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+struct Image {
+ reference: String,
+ architecture: String,
+ layers: Value,
+ id: String,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+struct ImageLock {
+ format: u32,
+ platform: String,
+ services: BTreeMap<String, Image>,
+}
+
+fn inspect(project: &Project, name: &str) -> Result<Value> {
+ let records: Vec<Value> = serde_json::from_slice(
+ &process::capture(project.command("podman")?.args(["image", "inspect", name]))?.stdout,
+ )?;
+ records
+ .into_iter()
+ .next()
+ .context("Docker returned no image")
+}
+
+pub(crate) fn save(project: &Project, containers: &[Value], destination: &Path) -> Result<()> {
+ fs::create_dir_all(destination)?;
+ let platform = project.platform()?;
+ let mut services = BTreeMap::new();
+ let mut new_tags = Vec::new();
+ let result = save_inner(
+ project,
+ containers,
+ destination,
+ &mut services,
+ &mut new_tags,
+ );
+ for tag in new_tags {
+ if let Err(error) = process::capture(project.command("podman")?.args(["image", "rm", &tag]))
+ {
+ process::error(format_args!(
+ "Could not remove temporary image tag {tag}: {error:#}"
+ ))?;
+ }
+ }
+ result?;
+ let lock = ImageLock {
+ format: 2,
+ platform,
+ services,
+ };
+ files::write(
+ &destination.join("image-lock.json"),
+ &serde_json::to_vec_pretty(&lock)?,
+ 0o600,
+ )
+}
+
+fn save_inner(
+ project: &Project,
+ containers: &[Value],
+ destination: &Path,
+ services: &mut BTreeMap<String, Image>,
+ new_tags: &mut Vec<String>,
+) -> Result<()> {
+ for container in containers {
+ let service = field(container, &["Config", "Labels", "io.stack.service"])?
+ .as_str()
+ .context("Missing service label")?;
+ let id = field(container, &["Image"])?
+ .as_str()
+ .context("Missing image ID")?;
+ let suffix: String = id.trim_start_matches("sha256:").chars().take(16).collect();
+ let alias = format!("localhost/stack-export-{service}:{suffix}");
+ if !process::available(
+ project
+ .command("podman")?
+ .args(["image", "inspect", &alias]),
+ ) {
+ process::capture(
+ project
+ .command("podman")?
+ .args(["image", "tag", id, &alias]),
+ )?;
+ new_tags.push(alias.clone());
+ }
+ let actual = inspect(project, &alias)?;
+ services.insert(
+ service.to_owned(),
+ Image {
+ reference: alias,
+ architecture: field(&actual, &["Architecture"])?
+ .as_str()
+ .context("Missing architecture")?
+ .to_owned(),
+ layers: field(&actual, &["RootFS", "Layers"])?.clone(),
+ id: field(&actual, &["Id"])?
+ .as_str()
+ .context("Missing image ID")?
+ .trim_start_matches("sha256:")
+ .to_owned(),
+ },
+ );
+ }
+ let mut command = project.command("podman")?;
+ command
+ .args([
+ "save",
+ "--multi-image-archive",
+ "--format",
+ "docker-archive",
+ "--output",
+ ])
+ .arg(destination.join("images.tar"));
+ for image in services.values() {
+ command.arg(&image.reference);
+ }
+ process::run(&mut command)
+}
+
+pub(crate) fn prepare(project: &Project, settings: &mut Settings) -> Result<()> {
+ let path = project.root.join("var/image-lock.json");
+ if settings.online || !path.is_file() {
+ return Ok(());
+ }
+ let lock: ImageLock = serde_json::from_reader(File::open(path)?)?;
+ ensure!(lock.format == 2, "Unsupported image lock format");
+ let platform = project.platform()?;
+ if platform.trim() != lock.platform {
+ ensure!(
+ settings.mode == Mode::Local,
+ "Release images target {}, but this server is {}; build a native release locally",
+ lock.platform,
+ platform.trim()
+ );
+ settings.online = true;
+ return process::line(format_args!(
+ "Using native images/builds for {}",
+ platform.trim()
+ ));
+ }
+ let missing = lock.services.values().any(|item| {
+ project.command("podman").is_ok_and(|mut command| {
+ !process::available(command.args(["image", "inspect", &item.reference]))
+ })
+ });
+ if missing {
+ let archive = project.root.join("var/images.tar");
+ ensure!(
+ archive.is_file(),
+ "Release image archive is missing; deploy a complete release"
+ );
+ process::run(
+ project
+ .command("podman")?
+ .args(["image", "load", "--input"])
+ .arg(archive),
+ )?;
+ }
+ for (service, image) in lock.services {
+ let actual = inspect(project, &image.reference)?;
+ ensure!(
+ field(&actual, &["Architecture"])?.as_str() == Some(image.architecture.as_str())
+ && field(&actual, &["RootFS", "Layers"])? == &image.layers
+ && field(&actual, &["Id"])?
+ .as_str()
+ .context("Missing image ID")?
+ .trim_start_matches("sha256:")
+ == image.id,
+ "Loaded image does not match its content lock: {service}"
+ );
+ }
+ Ok(())
+}
+
+pub(crate) fn configured(project: &Project, settings: &Settings) -> Result<Vec<Value>> {
+ let config = project.configuration(settings)?;
+ let mut records = Vec::new();
+ for (service, value) in config.services {
+ let actual = inspect(project, &value.image)?;
+ records.push(serde_json::json!({
+ "Image": field(&actual, &["Id"])?,
+ "Config": {"Labels": {"io.stack.service": service}}
+ }));
+ }
+ Ok(records)
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..ec8784e
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,252 @@
+// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+#![doc = "Rust commands for personal web infrastructure."]
+
+pub(crate) mod archive;
+pub(crate) mod files;
+pub(crate) mod images;
+pub(crate) mod process;
+pub(crate) mod project;
+pub(crate) mod release;
+
+use std::path::{Path, PathBuf};
+use std::process::ExitCode;
+
+use anyhow::Result;
+use clap::{Parser, Subcommand};
+
+use crate::project::{Mode, Project};
+
+#[derive(Debug, Parser)]
+#[command(
+ name = "stack",
+ version,
+ about = "Develop, deploy, and back up the personal web stack"
+)]
+struct Cli {
+ #[arg(
+ long,
+ global = true,
+ help = "Application root; otherwise discovered from the working directory or binary"
+ )]
+ root: Option<PathBuf>,
+ #[command(subcommand)]
+ command: Action,
+}
+
+#[derive(Debug, Subcommand)]
+enum Action {
+ #[command(about = "Start the local development services")]
+ Dev {
+ #[arg(long, default_value_t = 8080)]
+ port: u16,
+ },
+ #[command(about = "Start or update services in the selected mode")]
+ Up {
+ #[arg(long, conflicts_with = "local")]
+ production: bool,
+ #[arg(long)]
+ local: bool,
+ #[arg(long)]
+ port: Option<u16>,
+ #[arg(long)]
+ online: bool,
+ },
+ #[command(about = "Show service status")]
+ Status,
+ #[command(about = "Stop services while retaining data")]
+ Down,
+ #[command(about = "Create a GPG-encrypted application export")]
+ Backup {
+ destination: Option<PathBuf>,
+ #[arg(long)]
+ include_images: bool,
+ #[arg(long, default_value = archive::RECIPIENT)]
+ recipient: String,
+ #[arg(long)]
+ key: Option<PathBuf>,
+ },
+ #[command(
+ about = "Decrypt, validate, and restore into a new directory without starting services"
+ )]
+ Restore {
+ archive: PathBuf,
+ destination: PathBuf,
+ },
+ #[command(about = "Run formatting, strict lints, tests, documentation, and dependency checks")]
+ Check,
+ #[command(about = "Check and build a portable production release locally")]
+ Release { destination: Option<PathBuf> },
+ #[command(about = "Install a prebuilt release without compiling on the server")]
+ Activate { archive: PathBuf },
+ #[command(about = "Generate the third-party license page with GNU license names")]
+ Licenses,
+}
+
+fn main() -> ExitCode {
+ let arguments = match Cli::try_parse() {
+ Ok(arguments) => arguments,
+ Err(error) => {
+ let status = if error.use_stderr() {
+ ExitCode::FAILURE
+ } else {
+ ExitCode::SUCCESS
+ };
+ drop(error.print());
+ return status;
+ }
+ };
+ match execute(arguments) {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ drop(process::error(format_args!("Error: {error:#}")));
+ ExitCode::FAILURE
+ }
+ }
+}
+
+fn execute(arguments: Cli) -> Result<()> {
+ if let Action::Restore {
+ archive,
+ destination,
+ } = &arguments.command
+ {
+ return archive::restore(&absolute(archive)?, &absolute(destination)?);
+ }
+ let project = Project::discover(arguments.root.as_deref())?;
+ let _operation = project.lock()?;
+ match arguments.command {
+ Action::Dev { port } => {
+ let mut settings = project.settings()?;
+ settings.mode = Mode::Local;
+ settings.port = port;
+ images::prepare(&project, &mut settings)?;
+ project.up(&settings)
+ }
+ Action::Up {
+ production,
+ local,
+ port,
+ online,
+ } => {
+ let mut settings = project.settings()?;
+ if production {
+ settings.mode = Mode::Production;
+ }
+ if local {
+ settings.mode = Mode::Local;
+ }
+ if let Some(selected_port) = port {
+ settings.port = selected_port;
+ }
+ if online {
+ settings.online = true;
+ }
+ images::prepare(&project, &mut settings)?;
+ project.up(&settings)
+ }
+ Action::Status => project.status(&project.settings()?),
+ Action::Down => project.down(&project.settings()?),
+ Action::Backup {
+ destination,
+ include_images,
+ recipient,
+ key,
+ } => {
+ let output = destination.map_or_else(
+ || Ok(project.root.join("var/exports")),
+ |path| absolute(&path),
+ )?;
+ archive::backup(
+ &project,
+ &output,
+ include_images,
+ &recipient,
+ key.as_deref(),
+ )
+ }
+ Action::Release { destination } => {
+ let output = destination
+ .map_or_else(|| Ok(project.root.join("dist")), |path| absolute(&path))?;
+ release::create(&project, &output)
+ }
+ Action::Activate { archive } => release::activate(&absolute(&archive)?, &project),
+ Action::Check => check(&project),
+ Action::Licenses => licenses(&project),
+ Action::Restore { .. } => anyhow::bail!("Restore action was not handled"),
+ }
+}
+
+fn absolute(path: &Path) -> Result<PathBuf> {
+ Ok(if path.is_absolute() {
+ path.to_path_buf()
+ } else {
+ std::env::current_dir()?.join(path)
+ })
+}
+
+fn licenses(project: &Project) -> Result<()> {
+ process::run(project.command("cargo")?.args([
+ "about",
+ "generate",
+ "about.hbs",
+ "--output-file",
+ "web/THIRD-PARTY-LICENSES.html",
+ ]))
+}
+
+fn check(project: &Project) -> Result<()> {
+ process::run(
+ project
+ .command("cargo")?
+ .args(["fmt", "--all", "--", "--check"]),
+ )?;
+ process::run(project.command("cargo")?.args([
+ "clippy",
+ "--locked",
+ "--workspace",
+ "--all-targets",
+ "--all-features",
+ "--",
+ "-D",
+ "warnings",
+ ]))?;
+ process::run(project.command("cargo")?.args([
+ "test",
+ "--locked",
+ "--workspace",
+ "--all-targets",
+ "--all-features",
+ ]))?;
+ process::run(
+ project
+ .command("cargo")?
+ .env("RUSTDOCFLAGS", "-D warnings")
+ .args([
+ "doc",
+ "--locked",
+ "--workspace",
+ "--no-deps",
+ "--document-private-items",
+ ]),
+ )?;
+ process::run(
+ project
+ .command("cargo")?
+ .args(["deny", "--all-features", "check"]),
+ )?;
+ licenses(project)
+}
diff --git a/src/process.rs b/src/process.rs
new file mode 100644
index 0000000..eb48bc8
--- /dev/null
+++ b/src/process.rs
@@ -0,0 +1,70 @@
+// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+use std::fmt;
+use std::io::{self, Write};
+use std::process::{Command, Output};
+
+use anyhow::{Context, Result, bail};
+
+pub(crate) fn capture(command: &mut Command) -> Result<Output> {
+ let output = command.output().with_context(|| {
+ format!(
+ "Could not start {}",
+ command.get_program().to_string_lossy()
+ )
+ })?;
+ if !output.status.success() {
+ bail!(
+ "{} failed ({}): {}",
+ command.get_program().to_string_lossy(),
+ output.status,
+ String::from_utf8_lossy(&output.stderr).trim()
+ );
+ }
+ Ok(output)
+}
+
+pub(crate) fn text(command: &mut Command) -> Result<String> {
+ String::from_utf8(capture(command)?.stdout).context("Command returned non-UTF-8 output")
+}
+
+pub(crate) fn run(command: &mut Command) -> Result<()> {
+ let status = command.status().with_context(|| {
+ format!(
+ "Could not start {}",
+ command.get_program().to_string_lossy()
+ )
+ })?;
+ if !status.success() {
+ bail!(
+ "{} failed ({status})",
+ command.get_program().to_string_lossy()
+ );
+ }
+ Ok(())
+}
+
+pub(crate) fn available(command: &mut Command) -> bool {
+ command.output().is_ok_and(|output| output.status.success())
+}
+
+pub(crate) fn line(message: fmt::Arguments<'_>) -> Result<()> {
+ writeln!(io::stdout().lock(), "{message}").context("Could not write command output")
+}
+
+pub(crate) fn error(message: fmt::Arguments<'_>) -> io::Result<()> {
+ writeln!(io::stderr().lock(), "{message}")
+}
diff --git a/src/project.rs b/src/project.rs
new file mode 100644
index 0000000..0dac2ef
--- /dev/null
+++ b/src/project.rs
@@ -0,0 +1,548 @@
+// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+use std::collections::BTreeMap;
+use std::env;
+use std::fs::{self, File, OpenOptions, Permissions};
+use std::io::Read;
+use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
+use std::path::{Component, Path, PathBuf};
+use std::process::Command;
+use std::time::Duration;
+
+use anyhow::{Context, Result, bail, ensure};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::{files, process};
+
+#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub(crate) enum Mode {
+ #[default]
+ Local,
+ Production,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Settings {
+ pub(crate) mode: Mode,
+ pub(crate) port: u16,
+ pub(crate) project: String,
+ #[serde(default)]
+ pub(crate) online: bool,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Configuration {
+ pub(crate) format: u32,
+ pub(crate) services: BTreeMap<String, Service>,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Service {
+ pub(crate) image: String,
+ pub(crate) build: Option<String>,
+ #[serde(default)]
+ gateway: bool,
+ #[serde(default)]
+ environment: Vec<String>,
+ #[serde(default)]
+ drop_capabilities: bool,
+ health: Vec<String>,
+ mounts: Vec<Mount>,
+ #[serde(default)]
+ tmpfs: Vec<String>,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+struct Mount {
+ source: String,
+ target: String,
+ #[serde(default)]
+ read_only: bool,
+}
+
+#[derive(Debug)]
+pub(crate) struct Project {
+ pub(crate) root: PathBuf,
+}
+
+impl Project {
+ pub(crate) fn discover(explicit: Option<&Path>) -> Result<Self> {
+ if let Some(path) = explicit {
+ return Self::at(path);
+ }
+ let current = env::current_dir()?;
+ for candidate in current.ancestors() {
+ if candidate.join("infra/services.toml").is_file() {
+ return Self::at(candidate);
+ }
+ }
+ let executable = env::current_exe()?;
+ for candidate in executable.ancestors().skip(1).take(3) {
+ if candidate.join("infra/services.toml").is_file() {
+ return Self::at(candidate);
+ }
+ }
+ bail!("Run from the stack directory or supply --root PATH")
+ }
+
+ pub(crate) fn at(path: &Path) -> Result<Self> {
+ let root = if path.is_absolute() {
+ path.to_path_buf()
+ } else {
+ env::current_dir()?.join(path)
+ };
+ Ok(Self { root })
+ }
+
+ pub(crate) fn settings(&self) -> Result<Settings> {
+ let path = self.root.join("var/state.json");
+ if path.exists() {
+ return Ok(serde_json::from_reader(File::open(path)?)?);
+ }
+ Ok(Settings {
+ mode: Mode::Local,
+ port: 8080,
+ project: format!(
+ "stack-{}",
+ files::hash_bytes(self.root.as_os_str().as_encoded_bytes())
+ .chars()
+ .take(10)
+ .collect::<String>()
+ ),
+ online: false,
+ })
+ }
+
+ pub(crate) fn save(&self, settings: &Settings) -> Result<()> {
+ files::write(
+ &self.root.join("var/state.json"),
+ &serde_json::to_vec_pretty(settings)?,
+ 0o600,
+ )
+ }
+
+ pub(crate) fn lock(&self) -> Result<File> {
+ files::private_dir(&self.root.join("var"))?;
+ let file = OpenOptions::new()
+ .read(true)
+ .write(true)
+ .create(true)
+ .truncate(false)
+ .mode(0o600)
+ .open(self.root.join("var/operation.lock"))?;
+ file.try_lock()
+ .context("Another stack operation is already running")?;
+ Ok(file)
+ }
+
+ pub(crate) fn command(&self, name: &str) -> Result<Command> {
+ let mut command = Command::new(name);
+ command.current_dir(&self.root);
+ if let Some(tools) = self
+ .root
+ .ancestors()
+ .map(|p| p.join(".tools"))
+ .find(|p| p.join("bin/cargo-deny").is_file())
+ {
+ let mut paths = vec![tools.join("bin")];
+ if let Some(inherited) = env::var_os("PATH") {
+ paths.extend(env::split_paths(&inherited));
+ }
+ command.env("PATH", env::join_paths(paths)?);
+ }
+ if name == "podman" {
+ let version = process::text(Command::new("podman").arg("--version"))?;
+ let major = version
+ .split_whitespace()
+ .nth(2)
+ .and_then(|value| value.split('.').next())
+ .context("Cannot determine the Podman version")?
+ .parse::<u32>()?;
+ if major < 6 {
+ command.env(
+ "CONTAINERS_CONF_OVERRIDE",
+ self.root.join("infra/podman.conf"),
+ );
+ }
+ command
+ .arg("--root")
+ .arg(self.root.join("var/podman/storage"))
+ .arg("--runroot")
+ .arg(self.root.join("var/podman/run"));
+ }
+ Ok(command)
+ }
+
+ pub(crate) fn platform(&self) -> Result<String> {
+ let info: Value = serde_json::from_slice(
+ &process::capture(self.command("podman")?.args(["info", "--format", "json"]))?.stdout,
+ )?;
+ Ok(format!(
+ "{}/{}",
+ field(&info, &["host", "os"])?
+ .as_str()
+ .context("Missing host OS")?,
+ field(&info, &["host", "arch"])?
+ .as_str()
+ .context("Missing host architecture")?
+ ))
+ }
+
+ pub(crate) fn configuration(&self, settings: &Settings) -> Result<Configuration> {
+ let mut config: Configuration =
+ toml::from_str(&fs::read_to_string(self.root.join("infra/services.toml"))?)?;
+ ensure!(
+ config.format == 1 && config.services.contains_key("caddy"),
+ "Invalid services configuration"
+ );
+ if self.root.join("var/image-lock.json").is_file() && !settings.online {
+ let lock: Value =
+ serde_json::from_reader(File::open(self.root.join("var/image-lock.json"))?)?;
+ for (name, service) in &mut config.services {
+ field(&lock, &["services", name, "reference"])?
+ .as_str()
+ .context("Invalid image lock")?
+ .clone_into(&mut service.image);
+ }
+ }
+ Ok(config)
+ }
+
+ pub(crate) fn initialize(&self, settings: &Settings) -> Result<()> {
+ files::private_dir(&self.root.join("var/data"))?;
+ let env_file = self.root.join(".env");
+ if !env_file.exists() {
+ let mut random = [0; 32];
+ File::open("/dev/urandom")?.read_exact(&mut random)?;
+ let secret = hex::encode(random);
+ files::write(
+ &env_file,
+ format!("SEARXNG_SECRET={secret}\n").as_bytes(),
+ 0o600,
+ )?;
+ }
+ fs::set_permissions(&env_file, Permissions::from_mode(0o600))?;
+ for name in ["repos", "caddy/data", "caddy/config", "searxng"] {
+ fs::create_dir_all(self.root.join("var/data").join(name))?;
+ }
+ fs::set_permissions(
+ self.root.join("var/data/repos"),
+ Permissions::from_mode(0o755),
+ )?;
+ let repository = self.root.join("var/data/repos/stack");
+ if !repository.exists() {
+ if self.root.join(".git").is_dir() {
+ process::capture(
+ self.command("git")?
+ .args(["clone", "--bare", "."])
+ .arg(&repository),
+ )?;
+ } else {
+ process::capture(
+ self.command("git")?
+ .args(["init", "--bare", "--initial-branch=main"])
+ .arg(&repository),
+ )?;
+ }
+ }
+ files::copy_tree(
+ &self.root.join("infra/searxng"),
+ &self.root.join("var/data/searxng"),
+ )?;
+ if settings.mode == Mode::Local {
+ self.local_site(settings.port)?;
+ }
+ Ok(())
+ }
+
+ fn local_site(&self, port: u16) -> Result<()> {
+ let generated = self.root.join("var/local/web");
+ if generated.exists() {
+ files::regular_tree(&generated)?;
+ fs::remove_dir_all(&generated)?;
+ }
+ files::copy_tree(&self.root.join("web"), &generated)?;
+ let index = generated.join("index.html");
+ let html = fs::read_to_string(&index)?
+ .replace("https://git.doan.luuduct.ai", "/git")
+ .replace("https://search.doan.luuduct.ai", "/search")
+ .replace("https://openpgpkey.luuduct.ai", "");
+ files::write(&index, html.as_bytes(), 0o644)?;
+ let template = self.root.join("infra/containers/cgit/cgitrc.local");
+ let content = fs::read_to_string(&template)?
+ .lines()
+ .map(|line| {
+ if line.starts_with("clone-prefix=") {
+ format!("clone-prefix=http://localhost:{port}/git")
+ } else {
+ line.to_owned()
+ }
+ })
+ .collect::<Vec<_>>()
+ .join("\n");
+ files::write(
+ &self.root.join("var/local/cgitrc"),
+ format!("{content}\n").as_bytes(),
+ 0o644,
+ )
+ }
+
+ pub(crate) fn container_name(settings: &Settings, service: &str) -> String {
+ format!("{}-{service}", settings.project)
+ }
+
+ pub(crate) fn inspect_containers(&self, settings: &Settings) -> Result<Vec<Value>> {
+ let output = process::text(self.command("podman")?.args([
+ "ps",
+ "--all",
+ "--quiet",
+ "--filter",
+ &format!("label=io.stack.project={}", settings.project),
+ ]))?;
+ let ids: Vec<_> = output.split_whitespace().collect();
+ ensure!(!ids.is_empty(), "No stack containers found");
+ Ok(serde_json::from_slice(
+ &process::capture(self.command("podman")?.arg("inspect").args(ids))?.stdout,
+ )?)
+ }
+
+ pub(crate) fn up(&self, settings: &Settings) -> Result<()> {
+ ensure!(
+ (1024..=65_534).contains(&settings.port),
+ "Local port must be between 1024 and 65534"
+ );
+ self.initialize(settings)?;
+ self.save(settings)?;
+ let config = self.configuration(settings)?;
+ let network = format!("{}-net", settings.project);
+ if !process::available(
+ self.command("podman")?
+ .args(["network", "exists", &network]),
+ ) {
+ process::capture(
+ self.command("podman")?
+ .args(["network", "create", &network]),
+ )?;
+ }
+ let environment: BTreeMap<String, String> =
+ dotenvy::from_path_iter(self.root.join(".env"))?
+ .collect::<std::result::Result<_, _>>()?;
+ for (name, service) in config
+ .services
+ .iter()
+ .filter(|(_, service)| !service.gateway)
+ .chain(
+ config
+ .services
+ .iter()
+ .filter(|(_, service)| service.gateway),
+ )
+ {
+ self.ensure_image(settings, service)?;
+ self.start_service(settings, &network, name, service, &environment)?;
+ self.wait_service(settings, name, service)?;
+ }
+ process::line(format_args!(
+ "Running {}",
+ if settings.mode == Mode::Production {
+ "https://doan.luuduct.ai".to_owned()
+ } else {
+ format!("http://localhost:{}", settings.port)
+ }
+ ))
+ }
+
+ pub(crate) fn ensure_image(&self, settings: &Settings, service: &Service) -> Result<()> {
+ if settings.mode == Mode::Local
+ && (settings.online || !self.root.join("var/image-lock.json").exists())
+ && let Some(context) = &service.build
+ {
+ return process::run(
+ self.command("podman")?
+ .args(["build", "--tag", &service.image])
+ .arg(self.path(context)?),
+ );
+ }
+ if process::available(
+ self.command("podman")?
+ .args(["image", "exists", &service.image]),
+ ) {
+ return Ok(());
+ }
+ ensure!(
+ settings.mode == Mode::Local,
+ "Production images must be built and deployed from the development machine"
+ );
+ process::run(self.command("podman")?.args(["pull", &service.image]))
+ }
+
+ fn start_service(
+ &self,
+ settings: &Settings,
+ network: &str,
+ name: &str,
+ service: &Service,
+ environment: &BTreeMap<String, String>,
+ ) -> Result<()> {
+ let container = Self::container_name(settings, name);
+ let mut command = self.command("podman")?;
+ command.args([
+ "run",
+ "--detach",
+ "--replace",
+ "--pull",
+ "never",
+ "--name",
+ &container,
+ "--network",
+ network,
+ "--network-alias",
+ name,
+ "--restart",
+ "unless-stopped",
+ "--label",
+ &format!("io.stack.project={}", settings.project),
+ "--label",
+ &format!("io.stack.service={name}"),
+ "--security-opt",
+ "no-new-privileges",
+ ]);
+ if service.drop_capabilities {
+ command.args(["--cap-drop", "ALL"]);
+ }
+ if service.gateway {
+ let ports = if settings.mode == Mode::Local {
+ let tls = settings.port.checked_add(1).context("Port overflow")?;
+ [
+ format!("127.0.0.1:{}:80", settings.port),
+ format!("127.0.0.1:{tls}:443"),
+ format!("127.0.0.1:{tls}:443/udp"),
+ ]
+ .to_vec()
+ } else {
+ ["[::]:80:80", "[::]:443:443", "[::]:443:443/udp"]
+ .map(str::to_owned)
+ .to_vec()
+ };
+ for port in ports {
+ command.args(["--publish", &port]);
+ }
+ }
+ for key in &service.environment {
+ command.arg("--env").arg(key).env(
+ key,
+ environment
+ .get(key)
+ .with_context(|| format!("Missing secret {key} in .env"))?,
+ );
+ }
+ for mount in &service.mounts {
+ let source = match mount.source.as_str() {
+ "@caddy" => self.root.join(if settings.mode == Mode::Local {
+ "infra/Caddyfile.local"
+ } else {
+ "infra/Caddyfile"
+ }),
+ "@web" => self.root.join(if settings.mode == Mode::Local {
+ "var/local/web"
+ } else {
+ "web"
+ }),
+ "@cgit" => self.root.join(if settings.mode == Mode::Local {
+ "var/local/cgitrc"
+ } else {
+ "infra/containers/cgit/cgitrc"
+ }),
+ other => self.path(other)?,
+ };
+ let mode = if mount.read_only { "ro,z" } else { "rw,z" };
+ command
+ .arg("--volume")
+ .arg(format!("{}:{}:{mode}", source.display(), mount.target));
+ }
+ for mount in &service.tmpfs {
+ command.args(["--tmpfs", mount]);
+ }
+ process::capture(command.arg(&service.image))?;
+ Ok(())
+ }
+
+ fn wait_service(&self, settings: &Settings, name: &str, service: &Service) -> Result<()> {
+ let container = Self::container_name(settings, name);
+ for _attempt in 0..120 {
+ if process::available(
+ self.command("podman")?
+ .args(["exec", &container])
+ .args(&service.health),
+ ) {
+ return Ok(());
+ }
+ std::thread::sleep(Duration::from_millis(500));
+ }
+ bail!("Service did not become ready: {name}; inspect its Podman logs")
+ }
+
+ pub(crate) fn status(&self, settings: &Settings) -> Result<()> {
+ process::run(self.command("podman")?.args([
+ "ps",
+ "--all",
+ "--filter",
+ &format!("label=io.stack.project={}", settings.project),
+ ]))
+ }
+
+ pub(crate) fn down(&self, settings: &Settings) -> Result<()> {
+ for name in self.configuration(settings)?.services.keys() {
+ process::capture(self.command("podman")?.args([
+ "rm",
+ "--force",
+ "--ignore",
+ &Self::container_name(settings, name),
+ ]))?;
+ }
+ let network = format!("{}-net", settings.project);
+ if process::available(
+ self.command("podman")?
+ .args(["network", "exists", &network]),
+ ) {
+ process::capture(self.command("podman")?.args(["network", "rm", &network]))?;
+ }
+ Ok(())
+ }
+
+ fn path(&self, relative: &str) -> Result<PathBuf> {
+ let path = Path::new(relative);
+ ensure!(
+ path.components()
+ .all(|part| matches!(part, Component::Normal(_))),
+ "Service paths must stay inside stack"
+ );
+ Ok(self.root.join(path))
+ }
+}
+
+pub(crate) fn field<'a>(value: &'a Value, path: &[&str]) -> Result<&'a Value> {
+ path.iter().try_fold(value, |node, key| {
+ node.get(key)
+ .with_context(|| format!("Missing JSON field {key}"))
+ })
+}
diff --git a/src/release.rs b/src/release.rs
new file mode 100644
index 0000000..88b4093
--- /dev/null
+++ b/src/release.rs
@@ -0,0 +1,131 @@
+// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+use std::fs;
+use std::path::Path;
+
+use anyhow::{Context, Result, ensure};
+
+use crate::project::{Mode, Project};
+use crate::{files, images, process};
+
+pub(crate) fn create(project: &Project, destination: &Path) -> Result<()> {
+ crate::check(project)?;
+ process::run(project.command("cargo")?.args([
+ "build",
+ "--locked",
+ "--release",
+ "--target",
+ "x86_64-unknown-linux-musl",
+ ]))?;
+ files::write(
+ &project.root.join("bin/stack"),
+ &fs::read(
+ project
+ .root
+ .join("target/x86_64-unknown-linux-musl/release/stack"),
+ )?,
+ 0o755,
+ )?;
+ let mut settings = project.settings()?;
+ settings.mode = Mode::Local;
+ settings.online = true;
+ for service in project.configuration(&settings)?.services.values() {
+ project.ensure_image(&settings, service)?;
+ }
+ files::private_dir(destination)?;
+ let temp = tempfile::Builder::new()
+ .prefix(".release-")
+ .tempdir_in(destination)?;
+ let stage = temp.path().join("stack");
+ files::private_dir(&stage)?;
+ files::copy_source(&project.root, &stage)?;
+ fs::create_dir_all(stage.join("bin"))?;
+ fs::copy(
+ project
+ .root
+ .join("target/x86_64-unknown-linux-musl/release/stack"),
+ stage.join("bin/stack"),
+ )?;
+ images::save(
+ project,
+ &images::configured(project, &settings)?,
+ &stage.join("var"),
+ )?;
+ files::manifest(&stage, "release")?;
+ let output = destination.join(format!(
+ "stack-release-{}.tar.gz",
+ chrono::Utc::now().format("%Y%m%dT%H%M%SZ")
+ ));
+ files::pack(&stage, &output)?;
+ let filename = output
+ .file_name()
+ .context("Missing archive name")?
+ .to_string_lossy();
+ files::write(
+ &output.with_extension("gz.sha256"),
+ format!("{} {filename}\n", files::hash_file(&output)?).as_bytes(),
+ 0o600,
+ )?;
+ process::line(format_args!("Release: {}", output.display()))
+}
+
+pub(crate) fn activate(archive: &Path, project: &Project) -> Result<()> {
+ let temp = tempfile::Builder::new()
+ .prefix(".activate-")
+ .tempdir_in(&project.root)?;
+ let stage = temp.path().join("stack");
+ files::extract(archive, &stage)?;
+ ensure!(
+ files::verify(&stage)?.kind == "release",
+ "Activation requires a release archive"
+ );
+ let pending = Project::at(&stage)?;
+ let mut settings = project.settings()?;
+ settings.mode = Mode::Production;
+ settings.online = false;
+ let lock = stage.join("var/image-lock.json");
+ let info: serde_json::Value = serde_json::from_slice(&fs::read(&lock)?)?;
+ ensure!(
+ info.get("platform").and_then(serde_json::Value::as_str)
+ == Some(project.platform()?.as_str()),
+ "Release architecture does not match the server"
+ );
+ let _config = pending.configuration(&settings)?;
+ for name in files::SOURCE {
+ let source = stage.join(name);
+ if source.exists() {
+ let target = project.root.join(name);
+ if target.is_dir() {
+ files::regular_tree(&target)?;
+ fs::remove_dir_all(&target)?;
+ }
+ files::copy_tree(&source, &target)?;
+ }
+ }
+ files::write(
+ &project.root.join("bin/stack"),
+ &fs::read(stage.join("bin/stack"))?,
+ 0o755,
+ )?;
+ for name in ["images.tar", "image-lock.json"] {
+ files::copy_tree(
+ &stage.join("var").join(name),
+ &project.root.join("var").join(name),
+ )?;
+ }
+ images::prepare(project, &mut settings)?;
+ project.up(&settings)
+}