// 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" { 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::()?; if major < 6 { command.env( "CONTAINERS_CONF_OVERRIDE", self.root.join("infra/podman.conf"), ); } command .arg("--root") .arg(self.root.join("var/podman/storage")) .arg("--runroot") .arg(self.root.join("var/podman/run")); } Ok(command) } pub(crate) fn platform(&self) -> Result { 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"), ] .to_vec() } else { ["[::]:80:80", "[::]:443:443", "[::]:443:443/udp"] .map(str::to_owned) .to_vec() }; for port in ports { command.args(["--publish", &port]); } } for key in &service.environment { command.arg("--env").arg(key).env( key, environment .get(key) .with_context(|| format!("Missing secret {key} in .env"))?, ); } for mount in &service.mounts { let source = match mount.source.as_str() { "@caddy" => self.root.join(if settings.mode == Mode::Local { "infra/Caddyfile.local" } else { "infra/Caddyfile" }), "@web" => self.root.join(if settings.mode == Mode::Local { "var/local/web" } else { "web" }), "@cgit" => self.root.join(if settings.mode == Mode::Local { "var/local/cgitrc" } else { "infra/containers/cgit/cgitrc" }), other => self.path(other)?, }; let mode = if mount.read_only { "ro,z" } else { "rw,z" }; command .arg("--volume") .arg(format!("{}:{}:{mode}", source.display(), mount.target)); } for mount in &service.tmpfs { command.args(["--tmpfs", mount]); } process::capture(command.arg(&service.image))?; Ok(()) } fn wait_service(&self, settings: &Settings, name: &str, service: &Service) -> Result<()> { let container = Self::container_name(settings, name); for _attempt in 0..120 { if process::available( self.command("podman")? .args(["exec", &container]) .args(&service.health), ) { return Ok(()); } std::thread::sleep(Duration::from_millis(500)); } bail!("Service did not become ready: {name}; inspect its Podman logs") } pub(crate) fn status(&self, settings: &Settings) -> Result<()> { process::run(self.command("podman")?.args([ "ps", "--all", "--filter", &format!("label=io.stack.project={}", settings.project), ])) } pub(crate) fn down(&self, settings: &Settings) -> Result<()> { for name in self.configuration(settings)?.services.keys() { process::capture(self.command("podman")?.args([ "rm", "--force", "--ignore", &Self::container_name(settings, name), ]))?; } let network = format!("{}-net", settings.project); if process::available( self.command("podman")? .args(["network", "exists", &network]), ) { process::capture(self.command("podman")?.args(["network", "rm", &network]))?; } Ok(()) } fn path(&self, relative: &str) -> Result { 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}")) }) }