diff options
| author | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-30 14:00:58 -0500 |
|---|---|---|
| committer | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-30 14:00:58 -0500 |
| commit | 19a0280d7dabd4ca637d8070cc7d3691a0e85bd1 (patch) | |
| tree | de4591ed54ef860f04dd88bb3aae51dee51237a7 /src/project.rs | |
| parent | 006a1e94075e90fccfe248cd66bf7d0586a86279 (diff) | |
Manage the portable stack with Rust and rootless Podman
Diffstat (limited to 'src/project.rs')
| -rw-r--r-- | src/project.rs | 535 |
1 files changed, 535 insertions, 0 deletions
diff --git a/src/project.rs b/src/project.rs new file mode 100644 index 0000000..bb96fe0 --- /dev/null +++ b/src/project.rs @@ -0,0 +1,535 @@ +// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai> +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +use std::collections::BTreeMap; +use std::env; +use std::fs::{self, File, OpenOptions, Permissions}; +use std::io::Read; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use anyhow::{Context, Result, bail, ensure}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{files, process}; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Mode { + #[default] + Local, + Production, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Settings { + pub(crate) mode: Mode, + pub(crate) port: u16, + pub(crate) project: String, + #[serde(default)] + pub(crate) online: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Configuration { + pub(crate) format: u32, + pub(crate) services: BTreeMap<String, Service>, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Service { + pub(crate) image: String, + pub(crate) build: Option<String>, + #[serde(default)] + gateway: bool, + #[serde(default)] + environment: Vec<String>, + #[serde(default)] + drop_capabilities: bool, + health: Vec<String>, + mounts: Vec<Mount>, + #[serde(default)] + tmpfs: Vec<String>, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Mount { + source: String, + target: String, + #[serde(default)] + read_only: bool, +} + +#[derive(Debug)] +pub(crate) struct Project { + pub(crate) root: PathBuf, +} + +impl Project { + pub(crate) fn discover(explicit: Option<&Path>) -> Result<Self> { + if let Some(path) = explicit { + return Self::at(path); + } + let current = env::current_dir()?; + for candidate in current.ancestors() { + if candidate.join("infra/services.toml").is_file() { + return Self::at(candidate); + } + } + let executable = env::current_exe()?; + for candidate in executable.ancestors().skip(1).take(3) { + if candidate.join("infra/services.toml").is_file() { + return Self::at(candidate); + } + } + bail!("Run from the stack directory or supply --root PATH") + } + + pub(crate) fn at(path: &Path) -> Result<Self> { + let root = if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir()?.join(path) + }; + Ok(Self { root }) + } + + pub(crate) fn settings(&self) -> Result<Settings> { + let path = self.root.join("var/state.json"); + if path.exists() { + return Ok(serde_json::from_reader(File::open(path)?)?); + } + Ok(Settings { + mode: Mode::Local, + port: 8080, + project: format!( + "stack-{}", + files::hash_bytes(self.root.as_os_str().as_encoded_bytes()) + .chars() + .take(10) + .collect::<String>() + ), + online: false, + }) + } + + pub(crate) fn save(&self, settings: &Settings) -> Result<()> { + files::write( + &self.root.join("var/state.json"), + &serde_json::to_vec_pretty(settings)?, + 0o600, + ) + } + + pub(crate) fn lock(&self) -> Result<File> { + files::private_dir(&self.root.join("var"))?; + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(self.root.join("var/operation.lock"))?; + file.try_lock() + .context("Another stack operation is already running")?; + Ok(file) + } + + pub(crate) fn command(&self, name: &str) -> Result<Command> { + let mut command = Command::new(name); + command.current_dir(&self.root); + if let Some(tools) = self + .root + .ancestors() + .map(|p| p.join(".tools")) + .find(|p| p.join("bin/cargo-deny").is_file()) + { + let mut paths = vec![tools.join("bin")]; + if let Some(inherited) = env::var_os("PATH") { + paths.extend(env::split_paths(&inherited)); + } + command.env("PATH", env::join_paths(paths)?); + } + if name == "podman" { + command + .arg("--root") + .arg(self.root.join("var/podman/storage")) + .arg("--runroot") + .arg(self.root.join("var/podman/run")); + } + Ok(command) + } + + pub(crate) fn platform(&self) -> Result<String> { + let info: Value = serde_json::from_slice( + &process::capture(self.command("podman")?.args(["info", "--format", "json"]))?.stdout, + )?; + Ok(format!( + "{}/{}", + field(&info, &["host", "os"])? + .as_str() + .context("Missing host OS")?, + field(&info, &["host", "arch"])? + .as_str() + .context("Missing host architecture")? + )) + } + + pub(crate) fn configuration(&self, settings: &Settings) -> Result<Configuration> { + let mut config: Configuration = + toml::from_str(&fs::read_to_string(self.root.join("infra/services.toml"))?)?; + ensure!( + config.format == 1 && config.services.contains_key("caddy"), + "Invalid services configuration" + ); + if self.root.join("var/image-lock.json").is_file() && !settings.online { + let lock: Value = + serde_json::from_reader(File::open(self.root.join("var/image-lock.json"))?)?; + for (name, service) in &mut config.services { + field(&lock, &["services", name, "reference"])? + .as_str() + .context("Invalid image lock")? + .clone_into(&mut service.image); + } + } + Ok(config) + } + + pub(crate) fn initialize(&self, settings: &Settings) -> Result<()> { + files::private_dir(&self.root.join("var/data"))?; + let env_file = self.root.join(".env"); + if !env_file.exists() { + let mut random = [0; 32]; + File::open("/dev/urandom")?.read_exact(&mut random)?; + let secret = hex::encode(random); + files::write( + &env_file, + format!("SEARXNG_SECRET={secret}\n").as_bytes(), + 0o600, + )?; + } + fs::set_permissions(&env_file, Permissions::from_mode(0o600))?; + for name in ["repos", "caddy/data", "caddy/config", "searxng"] { + fs::create_dir_all(self.root.join("var/data").join(name))?; + } + fs::set_permissions( + self.root.join("var/data/repos"), + Permissions::from_mode(0o755), + )?; + let repository = self.root.join("var/data/repos/stack"); + if !repository.exists() { + if self.root.join(".git").is_dir() { + process::capture( + self.command("git")? + .args(["clone", "--bare", "."]) + .arg(&repository), + )?; + } else { + process::capture( + self.command("git")? + .args(["init", "--bare", "--initial-branch=main"]) + .arg(&repository), + )?; + } + } + files::copy_tree( + &self.root.join("infra/searxng"), + &self.root.join("var/data/searxng"), + )?; + if settings.mode == Mode::Local { + self.local_site(settings.port)?; + } + Ok(()) + } + + fn local_site(&self, port: u16) -> Result<()> { + let generated = self.root.join("var/local/web"); + if generated.exists() { + files::regular_tree(&generated)?; + fs::remove_dir_all(&generated)?; + } + files::copy_tree(&self.root.join("web"), &generated)?; + let index = generated.join("index.html"); + let html = fs::read_to_string(&index)? + .replace("https://git.doan.luuduct.ai", "/git") + .replace("https://search.doan.luuduct.ai", "/search") + .replace("https://openpgpkey.luuduct.ai", ""); + files::write(&index, html.as_bytes(), 0o644)?; + let template = self.root.join("infra/containers/cgit/cgitrc.local"); + let content = fs::read_to_string(&template)? + .lines() + .map(|line| { + if line.starts_with("clone-prefix=") { + format!("clone-prefix=http://localhost:{port}/git") + } else { + line.to_owned() + } + }) + .collect::<Vec<_>>() + .join("\n"); + files::write( + &self.root.join("var/local/cgitrc"), + format!("{content}\n").as_bytes(), + 0o644, + ) + } + + pub(crate) fn container_name(settings: &Settings, service: &str) -> String { + format!("{}-{service}", settings.project) + } + + pub(crate) fn inspect_containers(&self, settings: &Settings) -> Result<Vec<Value>> { + let output = process::text(self.command("podman")?.args([ + "ps", + "--all", + "--quiet", + "--filter", + &format!("label=io.stack.project={}", settings.project), + ]))?; + let ids: Vec<_> = output.split_whitespace().collect(); + ensure!(!ids.is_empty(), "No stack containers found"); + Ok(serde_json::from_slice( + &process::capture(self.command("podman")?.arg("inspect").args(ids))?.stdout, + )?) + } + + pub(crate) fn up(&self, settings: &Settings) -> Result<()> { + ensure!( + (1024..=65_534).contains(&settings.port), + "Local port must be between 1024 and 65534" + ); + self.initialize(settings)?; + self.save(settings)?; + let config = self.configuration(settings)?; + let network = format!("{}-net", settings.project); + if !process::available( + self.command("podman")? + .args(["network", "exists", &network]), + ) { + process::capture( + self.command("podman")? + .args(["network", "create", &network]), + )?; + } + let environment: BTreeMap<String, String> = + dotenvy::from_path_iter(self.root.join(".env"))? + .collect::<std::result::Result<_, _>>()?; + for (name, service) in config + .services + .iter() + .filter(|(_, service)| !service.gateway) + .chain( + config + .services + .iter() + .filter(|(_, service)| service.gateway), + ) + { + self.ensure_image(settings, service)?; + self.start_service(settings, &network, name, service, &environment)?; + self.wait_service(settings, name, service)?; + } + process::line(format_args!( + "Running {}", + if settings.mode == Mode::Production { + "https://doan.luuduct.ai".to_owned() + } else { + format!("http://localhost:{}", settings.port) + } + )) + } + + pub(crate) fn ensure_image(&self, settings: &Settings, service: &Service) -> Result<()> { + if settings.mode == Mode::Local + && (settings.online || !self.root.join("var/image-lock.json").exists()) + && let Some(context) = &service.build + { + return process::run( + self.command("podman")? + .args(["build", "--tag", &service.image]) + .arg(self.path(context)?), + ); + } + if process::available( + self.command("podman")? + .args(["image", "exists", &service.image]), + ) { + return Ok(()); + } + ensure!( + settings.mode == Mode::Local, + "Production images must be built and deployed from the development machine" + ); + process::run(self.command("podman")?.args(["pull", &service.image])) + } + + fn start_service( + &self, + settings: &Settings, + network: &str, + name: &str, + service: &Service, + environment: &BTreeMap<String, String>, + ) -> Result<()> { + let container = Self::container_name(settings, name); + let mut command = self.command("podman")?; + command.args([ + "run", + "--detach", + "--replace", + "--pull", + "never", + "--name", + &container, + "--network", + network, + "--network-alias", + name, + "--restart", + "unless-stopped", + "--label", + &format!("io.stack.project={}", settings.project), + "--label", + &format!("io.stack.service={name}"), + "--security-opt", + "no-new-privileges", + ]); + if service.drop_capabilities { + command.args(["--cap-drop", "ALL"]); + } + if service.gateway { + let ports = if settings.mode == Mode::Local { + let tls = settings.port.checked_add(1).context("Port overflow")?; + [ + format!("127.0.0.1:{}:80", settings.port), + format!("127.0.0.1:{tls}:443"), + format!("127.0.0.1:{tls}:443/udp"), + ] + } else { + [ + "80:80".to_owned(), + "443:443".to_owned(), + "443:443/udp".to_owned(), + ] + }; + for port in ports { + command.args(["--publish", &port]); + } + } + for key in &service.environment { + command.arg("--env").arg(key).env( + key, + environment + .get(key) + .with_context(|| format!("Missing secret {key} in .env"))?, + ); + } + for mount in &service.mounts { + let source = match mount.source.as_str() { + "@caddy" => self.root.join(if settings.mode == Mode::Local { + "infra/Caddyfile.local" + } else { + "infra/Caddyfile" + }), + "@web" => self.root.join(if settings.mode == Mode::Local { + "var/local/web" + } else { + "web" + }), + "@cgit" => self.root.join(if settings.mode == Mode::Local { + "var/local/cgitrc" + } else { + "infra/containers/cgit/cgitrc" + }), + other => self.path(other)?, + }; + let mode = if mount.read_only { "ro,z" } else { "rw,z" }; + command + .arg("--volume") + .arg(format!("{}:{}:{mode}", source.display(), mount.target)); + } + for mount in &service.tmpfs { + command.args(["--tmpfs", mount]); + } + process::capture(command.arg(&service.image))?; + Ok(()) + } + + fn wait_service(&self, settings: &Settings, name: &str, service: &Service) -> Result<()> { + let container = Self::container_name(settings, name); + for _attempt in 0..120 { + if process::available( + self.command("podman")? + .args(["exec", &container]) + .args(&service.health), + ) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(500)); + } + bail!("Service did not become ready: {name}; inspect its Podman logs") + } + + pub(crate) fn status(&self, settings: &Settings) -> Result<()> { + process::run(self.command("podman")?.args([ + "ps", + "--all", + "--filter", + &format!("label=io.stack.project={}", settings.project), + ])) + } + + pub(crate) fn down(&self, settings: &Settings) -> Result<()> { + for name in self.configuration(settings)?.services.keys() { + process::capture(self.command("podman")?.args([ + "rm", + "--force", + "--ignore", + &Self::container_name(settings, name), + ]))?; + } + process::capture(self.command("podman")?.args([ + "network", + "rm", + "--ignore", + &format!("{}-net", settings.project), + ]))?; + Ok(()) + } + + fn path(&self, relative: &str) -> Result<PathBuf> { + let path = Path::new(relative); + ensure!( + path.components() + .all(|part| matches!(part, Component::Normal(_))), + "Service paths must stay inside stack" + ); + Ok(self.root.join(path)) + } +} + +pub(crate) fn field<'a>(value: &'a Value, path: &[&str]) -> Result<&'a Value> { + path.iter().try_fold(value, |node, key| { + node.get(key) + .with_context(|| format!("Missing JSON field {key}")) + }) +} |
