From 19a0280d7dabd4ca637d8070cc7d3691a0e85bd1 Mon Sep 17 00:00:00 2001 From: Doan Luu Duc Tai Date: Fri, 30 Oct 2026 14:00:58 -0500 Subject: Manage the portable stack with Rust and rootless Podman --- src/archive.rs | 406 +++++++++++++++++++++++++++++++++++++++++++ src/files.rs | 336 ++++++++++++++++++++++++++++++++++++ src/images.rs | 215 +++++++++++++++++++++++ src/main.rs | 252 +++++++++++++++++++++++++++ src/process.rs | 70 ++++++++ src/project.rs | 535 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/release.rs | 131 ++++++++++++++ 7 files changed, 1945 insertions(+) create mode 100644 src/archive.rs create mode 100644 src/files.rs create mode 100644 src/images.rs create mode 100644 src/main.rs create mode 100644 src/process.rs create mode 100644 src/project.rs create mode 100644 src/release.rs (limited to 'src') 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 +// +// 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 . + +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, +} + +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> { + 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::>()?; + 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::() + ); + 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 +// +// 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 . + +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, +} + +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 { + 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 { + 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> { + 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 { + 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 { + 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 +// +// 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 . + +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, +} + +fn inspect(project: &Project, name: &str) -> Result { + let records: Vec = 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, + new_tags: &mut Vec, +) -> 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> { + 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 +// +// 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 . + +#![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, + #[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, + #[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, + #[arg(long)] + include_images: bool, + #[arg(long, default_value = archive::RECIPIENT)] + recipient: String, + #[arg(long)] + key: Option, + }, + #[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 }, + #[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 { + 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 +// +// 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 . + +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 { + 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::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..bb96fe0 --- /dev/null +++ b/src/project.rs @@ -0,0 +1,535 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// 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 . + +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, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Service { + pub(crate) image: String, + pub(crate) build: Option, + #[serde(default)] + gateway: bool, + #[serde(default)] + environment: Vec, + #[serde(default)] + drop_capabilities: bool, + health: Vec, + mounts: Vec, + #[serde(default)] + tmpfs: Vec, +} + +#[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 { + 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 { + 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 { + 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::() + ), + 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 { + 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 { + 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" { + 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 { + 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 { + 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::>() + .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> { + 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 = + dotenvy::from_path_iter(self.root.join(".env"))? + .collect::>()?; + 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, + ) -> 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"), + ] + } else { + [ + "80:80".to_owned(), + "443:443".to_owned(), + "443:443/udp".to_owned(), + ] + }; + 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), + ]))?; + } + process::capture(self.command("podman")?.args([ + "network", + "rm", + "--ignore", + &format!("{}-net", settings.project), + ]))?; + Ok(()) + } + + fn path(&self, relative: &str) -> Result { + 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 +// +// 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 . + +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) +} -- cgit v1.2.3