aboutsummaryrefslogtreecommitdiff
path: root/src/project.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/project.rs')
-rw-r--r--src/project.rs535
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}"))
+ })
+}