diff options
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/archive.rs | 475 | ||||
| -rw-r--r-- | src/app/chat.rs | 177 | ||||
| -rw-r--r-- | src/app/cli.rs | 329 | ||||
| -rw-r--r-- | src/app/files.rs | 384 | ||||
| -rw-r--r-- | src/app/images.rs | 215 | ||||
| -rw-r--r-- | src/app/process.rs | 70 | ||||
| -rw-r--r-- | src/app/project.rs | 539 | ||||
| -rw-r--r-- | src/app/release.rs | 127 | ||||
| -rw-r--r-- | src/app/render.rs | 130 |
9 files changed, 2446 insertions, 0 deletions
diff --git a/src/app/archive.rs b/src/app/archive.rs new file mode 100644 index 0000000..2a78644 --- /dev/null +++ b/src/app/archive.rs @@ -0,0 +1,475 @@ +// 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::app::project::{Mode, Project, Settings, field}; +use crate::app::{files, process}; + +#[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(super) 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, &["Config", "Labels", "io.stack.service"])?.as_str() != Some("chat") + && 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_chat(root: &Path, stage: &Path, name: &str) -> Result<()> { + let source = root.join("var/data").join(name).join("chat.sqlite"); + if !source.is_file() { + return Ok(()); + } + let directory = stage.join("var/data").join(name); + files::private_dir(&directory)?; + let database = + rusqlite::Connection::open_with_flags(source, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?; + database.busy_timeout(std::time::Duration::from_secs(5))?; + let destination = directory.join("chat.sqlite"); + database.backup("main", &destination, None::<fn(rusqlite::backup::Progress)>)?; + fs::set_permissions( + destination, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + )?; + Ok(()) +} + +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 !["caddy", "chat", "chat-local"] + .iter() + .any(|name| entry.file_name() == *name) + { + files::copy_tree( + &entry.path(), + &stage.join("var/data").join(entry.file_name()), + )?; + } + } + for name in ["chat", "chat-local"] { + snapshot_chat(&project.root, stage, 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"))?; + crate::app::chat::copy_artifacts(project, stage)?; + Ok(()) +} + +pub(super) 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 configuration = stack_config::Config::load(&project.root)?; + let key = public_key.map_or_else( + || Ok(project.root.join(configuration.value("BACKUP_PUBLIC_KEY")?)), + |path| Ok::<_, anyhow::Error>(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::app::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::set_permissions( + &encrypted, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + )?; + 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(super) 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::app::files; + use crate::app::project::{Mode, Project}; + + #[test] + fn chat_snapshot_is_consistent_and_private() -> Result<()> { + let workspace = tempfile::tempdir()?; + let root = workspace.path().join("source"); + let stage = workspace.path().join("snapshot"); + files::private_dir(&root.join("var/data/chat"))?; + let connection = rusqlite::Connection::open(root.join("var/data/chat/chat.sqlite"))?; + connection.execute_batch( + "CREATE TABLE messages (body TEXT); INSERT INTO messages VALUES ('ciphertext-before');", + )?; + let transaction = connection.unchecked_transaction()?; + transaction.execute("INSERT INTO messages VALUES ('uncommitted')", [])?; + super::snapshot_chat(&root, &stage, "chat")?; + let snapshot = stage.join("var/data/chat/chat.sqlite"); + let restored = rusqlite::Connection::open(&snapshot)?; + let count: i64 = + restored.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?; + anyhow::ensure!((count) == (1), "Test invariant failed"); + anyhow::ensure!( + (std::os::unix::fs::PermissionsExt::mode(&fs::metadata(snapshot)?.permissions()) + & 0o777) + == (0o600), + "Test invariant failed" + ); + transaction.rollback()?; + Ok(()) + } + + #[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)?; + anyhow::ensure!( + (Project::at(&destination)?.settings()?.mode) == (Mode::Local), + "Test invariant failed" + ); + anyhow::ensure!( + (fs::read_to_string(destination.join(".env"))?) == ("SEARXNG_SECRET=private\n"), + "Test invariant failed" + ); + anyhow::ensure!( + super::restore(&archive, &destination).is_err(), + "Test invariant failed" + ); + 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"); + anyhow::ensure!( + super::restore(&archive, &destination).is_err(), + "Test invariant failed" + ); + anyhow::ensure!(!destination.exists(), "Test invariant failed"); + Ok(()) + } +} diff --git a/src/app/chat.rs b/src/app/chat.rs new file mode 100644 index 0000000..1647018 --- /dev/null +++ b/src/app/chat.rs @@ -0,0 +1,177 @@ +// 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 crate::app::project::{Mode, Project, Settings}; +use crate::app::{files, process}; +use anyhow::{Context, Result, ensure}; +use stack_config::Config; +use std::fs; +use std::net::IpAddr; +use std::path::Path; +use std::process::Command; + +pub(super) fn web_bind(configuration: &Config) -> Result<String> { + let address: IpAddr = configuration.value("WEB_BIND")?.parse()?; + Ok(match address { + IpAddr::V4(_) => address.to_string(), + IpAddr::V6(_) => format!("[{address}]"), + }) +} + +pub(super) fn environment( + command: &mut Command, + configuration: &Config, + settings: &Settings, +) -> Result<()> { + let local = (settings.mode == Mode::Local).then_some(settings.port); + let mut public = configuration.public_values(local)?; + let origin = public + .get("CHAT_PUBLIC_ORIGIN") + .context("Missing chat gateway origin")? + .clone(); + public.insert("CHAT_ORIGIN".into(), origin); + public.insert("CHAT_BIND".into(), "0.0.0.0:8080".into()); + for (key, value) in public { + command.arg("--env").arg(&key).env(key, value); + } + Ok(()) +} + +pub(super) fn build(project: &Project) -> Result<()> { + process::run(project.command("sh")?.arg("infra/chat/build-typescript.sh"))?; + process::run(project.command("sh")?.arg("infra/chat/build-native.sh"))?; + let wasm_build = if project + .root + .join(".tools/openssl-wasi/lib/libcrypto.a") + .is_file() + { + "infra/chat/build-browser.sh" + } else { + "infra/chat/build-openssl-wasi.sh" + }; + process::run(project.command("sh")?.arg(wasm_build))?; + process::run(project.command("sh")?.arg("infra/chat/build-source.sh"))?; + package(project) +} + +pub(super) fn package(project: &Project) -> Result<()> { + let directory = project.root.join("bin/chat"); + fs::create_dir_all(&directory)?; + for (source, target) in [ + ( + "target/x86_64-unknown-linux-musl/release/stack-chat-server", + "server", + ), + ( + "target/wasm32-wasip1/release/stack-chat-crypto.wasm", + "crypto.wasm", + ), + ("var/build/chat-source.tar.gz", "SOURCE.tar.gz"), + ("infra/containers/chat/Containerfile", "Containerfile"), + ( + "var/build/typescript/crates/chat-crypto/browser/wasi.js", + "wasi.mjs", + ), + ( + "var/build/typescript/crates/chat-server/web/app.js", + "web/app.js", + ), + ( + "var/build/typescript/crates/chat-server/web/worker.js", + "web/worker.js", + ), + ("web/THIRD-PARTY-LICENSES.html", "THIRD-PARTY-LICENSES.html"), + ("docs/CHAT-APPLICATION.txt", "CHAT-APPLICATION.txt"), + ("docs/CHAT-PRODUCTION.txt", "CHAT-PRODUCTION.txt"), + ("LICENSE", "LICENSE"), + ] { + files::copy_tree(&project.root.join(source), &directory.join(target))?; + } + for name in ["index.html", "style.css", "javascript.html"] { + files::copy_tree( + &project.root.join("crates/chat-server/web").join(name), + &directory.join("web").join(name), + )?; + } + Ok(()) +} + +pub(super) fn copy_artifacts(project: &Project, destination: &Path) -> Result<()> { + let artifacts = project.root.join("bin/chat"); + ensure!( + artifacts.join("server").is_file() + && artifacts.join("crypto.wasm").is_file() + && artifacts.join("SOURCE.tar.gz").is_file(), + "Build a complete chat release locally before export" + ); + files::copy_tree(&artifacts, &destination.join("bin/chat")) +} + +pub(super) fn admin( + project: &Project, + init: bool, + reset_owner: Option<&str>, + owner_fingerprint: bool, +) -> Result<()> { + let settings = project.settings()?; + if init { + project.initialize(&settings)?; + return process::line(format_args!( + "Owner setup token is available in the single root .env" + )); + } + ensure!( + reset_owner.is_some() || owner_fingerprint, + "Use up --production to run chat; maintenance options are --owner-fingerprint and --reset-owner" + ); + let configuration = Config::load(&project.root)?; + let services = project.configuration(&settings)?; + let service = services + .services + .get("chat") + .context("Chat is not packaged in this release")?; + let mut command = project.command("podman")?; + command + .args([ + "run", + "--rm", + "--network", + "none", + "--read-only", + "--read-only-tmpfs=false", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--env", + "CHAT_SETUP_TOKEN", + ]) + .env("CHAT_SETUP_TOKEN", configuration.value("CHAT_SETUP_TOKEN")?); + environment(&mut command, &configuration, &settings)?; + command + .arg("--volume") + .arg(format!( + "{}:/app/var/data/chat:rw,z", + project.root.join("var/data/chat").display() + )) + .arg(&service.image); + if let Some(fingerprint) = reset_owner { + command.arg("--reset-owner").arg(fingerprint); + } + if owner_fingerprint { + command.arg("--owner-fingerprint"); + } + process::run(&mut command) +} diff --git a/src/app/cli.rs b/src/app/cli.rs new file mode 100644 index 0000000..1830ec1 --- /dev/null +++ b/src/app/cli.rs @@ -0,0 +1,329 @@ +// 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 clap::{Arg, ArgAction, ArgMatches, Command, error::ErrorKind}; +use std::ffi::OsString; +use std::path::PathBuf; + +#[derive(Debug)] + +pub(super) struct Cli { + pub(super) root: Option<PathBuf>, + + pub(super) command: Action, +} + +#[derive(Debug)] +pub(super) enum Action { + Chat { + init: bool, + + reset_owner: Option<String>, + + owner_fingerprint: bool, + }, + + Dev { + port: Option<u16>, + }, + + Up { + production: bool, + + local: bool, + + port: Option<u16>, + + online: bool, + }, + + Config { + production: bool, + + port: Option<u16>, + }, + + Status, + + Down, + + Backup { + destination: Option<PathBuf>, + + include_images: bool, + + recipient: Option<String>, + + key: Option<PathBuf>, + }, + + Restore { + archive: PathBuf, + destination: PathBuf, + }, + + Check, + + Release { + destination: Option<PathBuf>, + }, + + Activate { + archive: PathBuf, + }, + + Licenses, +} + +fn flag(name: &'static str) -> Arg { + Arg::new(name).long(name).action(ArgAction::SetTrue) +} + +fn path(name: &'static str) -> Arg { + Arg::new(name).value_parser(clap::builder::PathBufValueParser::new()) +} + +fn port() -> Arg { + Arg::new("port") + .long("port") + .value_parser(clap::builder::RangedU64ValueParser::<u16>::new()) +} + +fn development() -> [Command; 3] { + [ + Command::new("dev") + .about("Start the local development services") + .arg(port()), + Command::new("up") + .about("Start or update services in the selected mode") + .args([ + flag("production").conflicts_with("local"), + flag("local"), + port(), + flag("online"), + ]), + Command::new("config") + .about("Render public Caddy, website, and cgit configuration without starting services") + .args([flag("production"), port()]), + ] +} + +fn archives() -> [Command; 4] { + [ + Command::new("backup") + .about("Create a GPG-encrypted application export") + .args([ + path("destination"), + flag("include-images"), + Arg::new("recipient").long("recipient"), + path("key").long("key"), + ]), + Command::new("restore") + .about("Decrypt, validate, and restore into a new directory without starting services") + .args([ + path("archive").required(true), + path("destination").required(true), + ]), + Command::new("release") + .about("Check and build a portable production release locally") + .arg(path("destination")), + Command::new("activate") + .about("Install a prebuilt release without compiling on the server") + .arg(path("archive").required(true)), + ] +} + +fn command() -> Command { + Command::new("stack") + .version(env!("CARGO_PKG_VERSION")) + .about("Develop, deploy, and back up the personal web stack") + .subcommand_required(true) + .arg_required_else_help(true) + .arg( + path("root").long("root").global(true).help( + "Application root; otherwise discovered from the working directory or binary", + ), + ) + .subcommand( + Command::new("chat") + .about("Run experimental local chat or administer its packaged deployment") + .args([ + flag("init").conflicts_with_all(["reset-owner", "owner-fingerprint"]), + Arg::new("reset-owner") + .long("reset-owner") + .value_name("FINGERPRINT") + .conflicts_with("owner-fingerprint") + .help( + "Permanently clear owner registration and chat history after key loss", + ), + flag("owner-fingerprint") + .help("Print the registered owner's public fingerprint"), + ]), + ) + .subcommands(development()) + .subcommands(archives()) + .subcommands([ + Command::new("status").about("Show service status"), + Command::new("down").about("Stop services while retaining data"), + Command::new("check") + .about("Run formatting, strict lints, tests, documentation, and dependency checks"), + Command::new("licenses") + .about("Generate the third-party license page with GNU license names"), + ]) +} + +fn required<T: Clone + Send + Sync + 'static>( + matches: &mut ArgMatches, + name: &str, +) -> Result<T, clap::Error> { + matches.remove_one(name).ok_or_else(|| { + clap::Error::raw( + ErrorKind::MissingRequiredArgument, + format!("Missing {name}"), + ) + }) +} + +fn action(name: &str, args: &mut ArgMatches) -> Result<Action, clap::Error> { + Ok(match name { + "chat" => Action::Chat { + init: args.get_flag("init"), + reset_owner: args.remove_one("reset-owner"), + owner_fingerprint: args.get_flag("owner-fingerprint"), + }, + "dev" => Action::Dev { + port: args.remove_one("port"), + }, + "up" => Action::Up { + production: args.get_flag("production"), + local: args.get_flag("local"), + port: args.remove_one("port"), + online: args.get_flag("online"), + }, + "config" => Action::Config { + production: args.get_flag("production"), + port: args.remove_one("port"), + }, + "status" => Action::Status, + "down" => Action::Down, + "backup" => Action::Backup { + destination: args.remove_one("destination"), + include_images: args.get_flag("include-images"), + recipient: args.remove_one("recipient"), + key: args.remove_one("key"), + }, + "restore" => Action::Restore { + archive: required(args, "archive")?, + destination: required(args, "destination")?, + }, + "check" => Action::Check, + "release" => Action::Release { + destination: args.remove_one("destination"), + }, + "activate" => Action::Activate { + archive: required(args, "archive")?, + }, + "licenses" => Action::Licenses, + _ => { + return Err(clap::Error::raw( + ErrorKind::InvalidSubcommand, + "Unknown command", + )); + } + }) +} + +impl Cli { + pub(super) fn try_parse() -> Result<Self, clap::Error> { + Self::try_parse_from(std::env::args_os()) + } + + fn try_parse_from( + arguments: impl IntoIterator<Item = impl Into<OsString> + Clone>, + ) -> Result<Self, clap::Error> { + let mut matches = command().try_get_matches_from(arguments)?; + let root = matches.remove_one("root"); + let (name, mut arguments) = matches.remove_subcommand().ok_or_else(|| { + clap::Error::raw(ErrorKind::MissingSubcommand, "Select a stack command") + })?; + Ok(Self { + root, + command: action(&name, &mut arguments)?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::{Action, Cli, command}; + use anyhow::Result; + use std::path::PathBuf; + + #[test] + fn checked_command_schema() { + command().debug_assert(); + } + + #[test] + fn modes_conflicts_and_required_paths() -> Result<()> { + let parsed = Cli::try_parse_from([ + "stack", + "up", + "--root", + "/tmp/stack", + "--local", + "--port", + "8180", + "--online", + ])?; + anyhow::ensure!( + (parsed.root) == (Some(PathBuf::from("/tmp/stack"))), + "Test invariant failed" + ); + anyhow::ensure!( + matches!( + parsed.command, + Action::Up { + production: false, + local: true, + port: Some(8180), + online: true + } + ), + "Test invariant failed" + ); + for args in [ + vec!["stack", "up", "--local", "--production"], + vec!["stack", "dev", "--port", "65536"], + vec!["stack", "chat", "--init", "--reset-owner", "abc"], + vec![ + "stack", + "chat", + "--reset-owner", + "abc", + "--owner-fingerprint", + ], + vec!["stack", "restore", "archive"], + vec!["stack", "activate"], + ] { + anyhow::ensure!(Cli::try_parse_from(args).is_err(), "Test invariant failed"); + } + anyhow::ensure!( + matches!(Cli::try_parse_from(["stack", "restore", "export.gpg", "new-root"])?.command, Action::Restore { archive, destination } if archive == std::path::Path::new("export.gpg") && destination == std::path::Path::new("new-root")), + "Test invariant failed" + ); + Ok(()) + } +} diff --git a/src/app/files.rs b/src/app/files.rs new file mode 100644 index 0000000..2c612a9 --- /dev/null +++ b/src/app/files.rs @@ -0,0 +1,384 @@ +// 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(super) const SOURCE: &[&str] = &[ + "Cargo.toml", + "Cargo.lock", + "clippy.toml", + "eslint.config.ts", + "package.json", + "package-lock.json", + "tsconfig.json", + "tsconfig.browser.json", + "tsconfig.worker.json", + "tsconfig.tests.json", + "tsconfig.tools.json", + "rust-toolchain.toml", + ".cargo", + ".gitignore", + ".gitattributes", + ".forgejo", + "src", + "tests", + "crates", + "infra", + "web", + "docs", + "README.txt", + "LICENSE", + "deny.toml", + "about.toml", + "about.hbs", + ".env.example", +]; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +pub(super) struct Manifest { + pub(super) format: u32, + pub(super) kind: String, + pub(super) version: String, + pub(super) created_utc: String, + pub(super) files: BTreeMap<String, String>, +} + +pub(super) 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(super) 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(super) fn hash_bytes(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +pub(super) 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(super) fn relative(path: &Path, base: &Path) -> Result<String> { + Ok(path + .strip_prefix(base)? + .to_str() + .context("Paths must be UTF-8")? + .to_owned()) +} + +pub(super) 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(super) 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(super) 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(super) 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(super) 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(super) 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(super) 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(super) 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(super) 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() -> Result<()> { + let destination = Path::new("/restore"); + for name in ["/etc/passwd", "../escape", "stack/../escape", "other/file"] { + anyhow::ensure!( + member_path(Path::new(name), destination).is_err(), + "Test invariant failed" + ); + } + anyhow::ensure!( + (member_path(Path::new("stack/web/index.html"), destination).ok()) + == (Some(destination.join("web/index.html"))), + "Test invariant failed" + ); + Ok(()) + } + + #[test] + fn detects_modified_and_extra_files() -> Result<()> { + let root = tempfile::tempdir()?; + fs::write(root.path().join("public.txt"), "original")?; + manifest(root.path(), "release")?; + anyhow::ensure!(verify(root.path()).is_ok(), "Test invariant failed"); + fs::write(root.path().join("public.txt"), "changed")?; + anyhow::ensure!(verify(root.path()).is_err(), "Test invariant failed"); + fs::write(root.path().join("public.txt"), "original")?; + fs::write(root.path().join("unexpected.txt"), "extra")?; + anyhow::ensure!(verify(root.path()).is_err(), "Test invariant failed"); + 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")?; + for name in ["clippy.toml", "eslint.config.ts", "tsconfig.tools.json"] { + fs::write(source.path().join(name), "public lint configuration")?; + } + fs::create_dir_all(source.path().join("node_modules"))?; + fs::write( + source.path().join("node_modules/private-fixture"), + "private", + )?; + 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())?; + anyhow::ensure!( + target.path().join("Cargo.toml").is_file(), + "Test invariant failed" + ); + anyhow::ensure!( + !target.path().join(".env").exists(), + "Test invariant failed" + ); + anyhow::ensure!(!target.path().join("var").exists(), "Test invariant failed"); + for name in ["clippy.toml", "eslint.config.ts", "tsconfig.tools.json"] { + anyhow::ensure!( + target.path().join(name).is_file(), + "Required lint configuration omitted from export" + ); + } + anyhow::ensure!( + !target.path().join("node_modules").exists(), + "Installed development packages entered export" + ); + 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)?; + anyhow::ensure!(verify(&restored).is_ok(), "Test invariant failed"); + anyhow::ensure!( + (fs::read_to_string(restored.join(".env"))?) == ("secret"), + "Test invariant failed" + ); + anyhow::ensure!( + extract(&archive, &restored).is_err(), + "Test invariant failed" + ); + Ok(()) + } + + #[test] + fn rejects_symbolic_links() -> Result<()> { + let root = tempfile::tempdir()?; + std::os::unix::fs::symlink("/etc/passwd", root.path().join("link"))?; + anyhow::ensure!( + super::regular_tree(root.path()).is_err(), + "Test invariant failed" + ); + Ok(()) + } +} diff --git a/src/app/images.rs b/src/app/images.rs new file mode 100644 index 0000000..fac7aa7 --- /dev/null +++ b/src/app/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::app::project::{Mode, Project, Settings, field}; +use crate::app::{files, process}; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +struct Image { + reference: String, + architecture: String, + layers: Value, + id: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "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("Podman returned no image") +} + +pub(super) 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(super) 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(super) 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/app/process.rs b/src/app/process.rs new file mode 100644 index 0000000..02f6a6b --- /dev/null +++ b/src/app/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(super) 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(super) fn text(command: &mut Command) -> Result<String> { + String::from_utf8(capture(command)?.stdout).context("Command returned non-UTF-8 output") +} + +pub(super) 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(super) fn available(command: &mut Command) -> bool { + command.output().is_ok_and(|output| output.status.success()) +} + +pub(super) fn line(message: fmt::Arguments<'_>) -> Result<()> { + writeln!(io::stdout().lock(), "{message}").context("Could not write command output") +} + +pub(super) fn error(message: fmt::Arguments<'_>) -> io::Result<()> { + writeln!(io::stderr().lock(), "{message}") +} diff --git a/src/app/project.rs b/src/app/project.rs new file mode 100644 index 0000000..ac55ff3 --- /dev/null +++ b/src/app/project.rs @@ -0,0 +1,539 @@ +// 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, Write}; +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 stack_config::Config; + +use crate::app::{files, process}; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(crate = "serde", rename_all = "lowercase")] +pub(super) enum Mode { + #[default] + Local, + Production, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +pub(super) struct Settings { + pub(super) mode: Mode, + pub(super) port: u16, + pub(super) project: String, + #[serde(default)] + pub(super) online: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +pub(super) struct Configuration { + pub(super) format: u32, + pub(super) services: BTreeMap<String, Service>, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +pub(super) struct Service { + pub(super) image: String, + pub(super) build: Option<String>, + #[serde(default)] + gateway: bool, + #[serde(default)] + environment: Vec<String>, + #[serde(default)] + drop_capabilities: bool, + #[serde(default)] + read_only: bool, + health: Vec<String>, + mounts: Vec<Mount>, + #[serde(default)] + tmpfs: Vec<String>, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(crate = "serde", deny_unknown_fields)] +struct Mount { + source: String, + target: String, + #[serde(default)] + read_only: bool, +} + +#[derive(Debug)] +pub(super) struct Project { + pub(super) root: PathBuf, +} + +impl Project { + pub(super) 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(super) 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(super) fn settings(&self) -> Result<Settings> { + let path = self.root.join("var/state.json"); + if path.exists() { + let mut settings: Settings = serde_json::from_reader(File::open(path)?)?; + let config = Config::load(&self.root)?; + if config.is_explicit("LOCAL_HTTP_PORT") { + settings.port = config.port("LOCAL_HTTP_PORT")?; + } + return Ok(settings); + } + Ok(Settings { + mode: Mode::Local, + port: Config::load(&self.root)?.port("LOCAL_HTTP_PORT")?, + project: format!( + "stack-{}", + files::hash_bytes(self.root.as_os_str().as_encoded_bytes()) + .chars() + .take(10) + .collect::<String>() + ), + online: false, + }) + } + + pub(super) fn save(&self, settings: &Settings) -> Result<()> { + files::write( + &self.root.join("var/state.json"), + &serde_json::to_vec_pretty(settings)?, + 0o600, + ) + } + + pub(super) 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(super) 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(super) 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(super) 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(super) fn initialize(&self, settings: &Settings) -> Result<()> { + files::private_dir(&self.root.join("var/data"))?; + let env_file = self.root.join(".env"); + for key in ["SEARXNG_SECRET", "CHAT_SETUP_TOKEN"] { + if !Config::load(&self.root)?.is_explicit(key) { + let mut random = [0; 32]; + File::open("/dev/urandom")?.read_exact(&mut random)?; + let mut file = OpenOptions::new() + .create(true) + .append(true) + .mode(0o600) + .open(&env_file)?; + writeln!(file, "\n{key}={}", hex::encode(random))?; + } + } + stack_config::initialize_public(&self.root)?; + fs::set_permissions(&env_file, Permissions::from_mode(0o600))?; + for name in ["chat", "chat-local"] { + files::private_dir(&self.root.join("var/data").join(name))?; + } + 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"), + )?; + crate::app::render::prepare(self, settings) + } + + pub(super) fn container_name(settings: &Settings, service: &str) -> String { + format!("{}-{service}", settings.project) + } + + pub(super) 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(super) 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 = Config::load(&self.root)?; + 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 { + environment + .public_values(None)? + .get("SITE_ORIGIN") + .context("Missing public site origin")? + .to_owned() + } else { + format!("http://localhost:{}", settings.port) + } + )) + } + + pub(super) 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 + { + if context == "bin/chat" && !self.root.join("bin/chat/server").is_file() { + crate::app::chat::build(self)?; + } + 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: &Config, + ) -> 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.read_only { + command.args(["--read-only", "--read-only-tmpfs=false"]); + } + 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 { + let http = environment.port("HTTP_PORT")?; + let https = environment.port("HTTPS_PORT")?; + vec![ + format!("{}:{http}:{http}", crate::app::chat::web_bind(environment)?), + format!( + "{}:{https}:{https}", + crate::app::chat::web_bind(environment)? + ), + format!( + "{}:{https}:{https}/udp", + crate::app::chat::web_bind(environment)? + ), + ] + }; + for port in ports { + command.args(["--publish", &port]); + } + } + for key in &service.environment { + command + .arg("--env") + .arg(key) + .env(key, environment.value(key)?); + } + if name == "chat" { + crate::app::chat::environment(&mut command, environment, settings)?; + } + for mount in &service.mounts { + let source = match mount.source.as_str() { + "@caddy" => crate::app::render::directory(self, settings).join("Caddyfile"), + "@web" => crate::app::render::directory(self, settings).join("web"), + "@cgit" => crate::app::render::directory(self, settings).join("cgitrc"), + "@chat" => self.root.join(if settings.mode == Mode::Local { + "var/data/chat-local" + } else { + "var/data/chat" + }), + 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(super) fn status(&self, settings: &Settings) -> Result<()> { + process::run(self.command("podman")?.args([ + "ps", + "--all", + "--filter", + &format!("label=io.stack.project={}", settings.project), + ])) + } + + pub(super) 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(super) 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/app/release.rs b/src/app/release.rs new file mode 100644 index 0000000..268a3c2 --- /dev/null +++ b/src/app/release.rs @@ -0,0 +1,127 @@ +// 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::app::project::{Mode, Project}; +use crate::app::{files, images, process}; + +pub(super) fn create(project: &Project, destination: &Path) -> Result<()> { + crate::app::check(project)?; + crate::app::chat::build(project)?; + 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)?; + crate::app::chat::copy_artifacts(project, &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(super) 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, + )?; + crate::app::chat::copy_artifacts(&pending, &project.root)?; + 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) +} diff --git a/src/app/render.rs b/src/app/render.rs new file mode 100644 index 0000000..8a2d2ee --- /dev/null +++ b/src/app/render.rs @@ -0,0 +1,130 @@ +// 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 crate::app::files; +use crate::app::project::{Mode, Project, Settings}; +use anyhow::Result; +use stack_config::Config; +use std::fs; +use std::path::PathBuf; + +pub(super) fn directory(project: &Project, settings: &Settings) -> PathBuf { + project.root.join(match settings.mode { + Mode::Local => "var/local", + Mode::Production => "var/production", + }) +} + +pub(super) fn prepare(project: &Project, settings: &Settings) -> Result<()> { + let config = Config::load(&project.root)?; + let local = (settings.mode == Mode::Local).then_some(settings.port); + let output = directory(project, settings); + let web = output.join("web"); + if web.exists() { + files::regular_tree(&web)?; + fs::remove_dir_all(&web)?; + } + files::copy_tree(&project.root.join("web"), &web)?; + let file = web.join("index.html"); + files::write( + &file, + config + .render(&fs::read_to_string(&file)?, local)? + .as_bytes(), + 0o644, + )?; + let template = project.root.join(if settings.mode == Mode::Local { + "infra/Caddyfile.local" + } else { + "infra/Caddyfile" + }); + files::write( + &output.join("Caddyfile"), + config + .render(&fs::read_to_string(template)?, local)? + .as_bytes(), + 0o644, + )?; + let cgit = fs::read_to_string(project.root.join("infra/containers/cgit/cgitrc"))?; + let mut rendered = config.render(&cgit, local)?; + if let Some(port) = local { + rendered = rendered.replace( + "clone-prefix=/git", + &format!("clone-prefix=http://localhost:{port}/git"), + ); + } + files::write(&output.join("cgitrc"), rendered.as_bytes(), 0o644) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn custom_domains_and_ports_reach_public_outputs_without_secrets() -> Result<()> { + let root = tempfile::tempdir()?; + for name in ["web", "infra"] { + files::copy_tree( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(name), + &root.path().join(name), + )?; + } + fs::write( + root.path().join(".env"), + "SITE_HOST=home.example.net\nGIT_HOST=git.example.net\nHTTP_PORT=8181\nHTTPS_PORT=8443\nSEARXNG_SECRET=do-not-publish-this-sentinel\n", + )?; + let project = Project::at(root.path())?; + let mut settings = project.settings()?; + settings.mode = Mode::Production; + prepare(&project, &settings)?; + let output = directory(&project, &settings); + let caddy = fs::read_to_string(output.join("Caddyfile"))?; + anyhow::ensure!( + caddy.contains("https://home.example.net:8443"), + "Test invariant failed" + ); + anyhow::ensure!(caddy.contains("http_port 8181"), "Test invariant failed"); + anyhow::ensure!(!caddy.contains("do-not-publish"), "Test invariant failed"); + let homepage = fs::read_to_string(output.join("web/index.html"))?; + anyhow::ensure!( + homepage.contains("https://git.example.net:8443"), + "Test invariant failed" + ); + let stylesheet = files::hash_file(&root.path().join("web/styles.css"))?; + anyhow::ensure!( + homepage.contains(&format!("/styles.css?v={stylesheet}")), + "Test invariant failed" + ); + anyhow::ensure!( + homepage.contains("https://chat.doan.luuduct.ai:8443/LIMITATIONS.txt"), + "Test invariant failed" + ); + settings.mode = Mode::Local; + settings.port = 8180; + prepare(&project, &settings)?; + let preview = fs::read_to_string(directory(&project, &settings).join("web/index.html"))?; + anyhow::ensure!(preview.contains("href=\"/git/\""), "Test invariant failed"); + anyhow::ensure!( + preview.contains("http://chat.localhost:8180/LIMITATIONS.txt"), + "Test invariant failed" + ); + anyhow::ensure!( + fs::read_to_string(directory(&project, &settings).join("cgitrc"))? + .contains("clone-prefix=http://localhost:8180/git"), + "Test invariant failed" + ); + Ok(()) + } +} |
