// 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, 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, } #[derive(Debug, Deserialize, Serialize)] #[serde(crate = "serde", deny_unknown_fields)] pub(super) struct Service { pub(super) image: String, pub(super) build: Option, #[serde(default)] gateway: bool, #[serde(default)] environment: Vec, #[serde(default)] drop_capabilities: bool, #[serde(default)] read_only: bool, health: Vec, mounts: Vec, #[serde(default)] tmpfs: Vec, } #[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 { 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 { 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 { 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::() ), 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 { 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 { 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(super) 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(super) 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(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> { 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 { 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}")) }) }