From 4ca59d2bb60310cd78398624655b8ec412c653da Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sun, 21 Dec 2025 15:32:42 +0800 Subject: [PATCH 01/10] feat: add agent module with Nginx service commands and routes - Introduced a new agent module with commands for managing Nginx configurations. - Implemented `NginxService` for handling reload, validation, and configuration writing. - Added routes for status, validation, and configuration writing using Axum. - Created necessary command files: `reload.rs`, `run.rs`, `validate.rs`, `write_config.rs`. - Updated `Cargo.toml` and `Cargo.lock` to include new dependencies. - Added `.gitignore` for the agent module. - Updated `justfile` to include OpenAPI generation for the agent. --- Cargo.lock | 137 ++++++++++- Cargo.toml | 3 +- apps/agent/.gitignore | 1 + apps/agent/Cargo.toml | 14 ++ apps/agent/src/commands.rs | 292 ++++++++++++++++++++++++ apps/agent/src/commands/reload.rs | 98 ++++++++ apps/agent/src/commands/run.rs | 85 +++++++ apps/agent/src/commands/validate.rs | 47 ++++ apps/agent/src/commands/write_config.rs | 131 +++++++++++ apps/agent/src/main.rs | 83 +++++++ apps/agent/src/routes.rs | 130 +++++++++++ justfile | 5 + 12 files changed, 1023 insertions(+), 3 deletions(-) create mode 100644 apps/agent/.gitignore create mode 100644 apps/agent/Cargo.toml create mode 100644 apps/agent/src/commands.rs create mode 100644 apps/agent/src/commands/reload.rs create mode 100644 apps/agent/src/commands/run.rs create mode 100644 apps/agent/src/commands/validate.rs create mode 100644 apps/agent/src/commands/write_config.rs create mode 100644 apps/agent/src/main.rs create mode 100644 apps/agent/src/routes.rs diff --git a/Cargo.lock b/Cargo.lock index 6966840..5826b40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -489,6 +489,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + [[package]] name = "clap" version = "4.5.53" @@ -690,6 +700,17 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "croner" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa42bcd3d846ebf66e15bd528d1087f75d1c6c1c66ebff626178a106353c576" +dependencies = [ + "chrono", + "derive_builder", + "strum", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -790,6 +811,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", + "strsim", "syn 2.0.110", ] @@ -864,6 +886,37 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.110", +] + [[package]] name = "derive_more" version = "2.0.1" @@ -1474,9 +1527,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744436df46f0bde35af3eda22aeaba453aada65d8f1c171cd8a5f59030bd69f" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", @@ -2017,6 +2070,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -2324,6 +2388,24 @@ dependencies = [ "serde", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -3339,6 +3421,12 @@ dependencies = [ "time", ] +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.11" @@ -3650,6 +3738,21 @@ name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.110", +] [[package]] name = "subtle" @@ -3856,6 +3959,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-cron-scheduler" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f50e41f200fd8ed426489bd356910ede4f053e30cebfbd59ef0f856f0d7432a" +dependencies = [ + "chrono", + "chrono-tz", + "croner", + "num-derive", + "num-traits", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "tokio-macros" version = "2.6.0" @@ -4699,6 +4818,20 @@ dependencies = [ "hashlink", ] +[[package]] +name = "yanpm-agent" +version = "0.1.0" +dependencies = [ + "axum", + "clap", + "serde", + "serde_json", + "tokio", + "tokio-cron-scheduler", + "tracing", + "tracing-subscriber", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 9c3e719..c0031ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,9 @@ [workspace] -members = [ +members = [ "apps/api", "apps/container", "apps/cli", + "apps/agent", "public/shared", "public/database", "public/migration" diff --git a/apps/agent/.gitignore b/apps/agent/.gitignore new file mode 100644 index 0000000..f0a56ac --- /dev/null +++ b/apps/agent/.gitignore @@ -0,0 +1 @@ +*.sock \ No newline at end of file diff --git a/apps/agent/Cargo.toml b/apps/agent/Cargo.toml new file mode 100644 index 0000000..53c344f --- /dev/null +++ b/apps/agent/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "yanpm-agent" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = { version = "0.8.7", features = ["form", "http1", "json", "matched-path", "original-uri", "query", "tokio", "tower-log", "tracing", "macros"] } +tokio = { version = "1", features = ["fs", "io-util", "io-std", "macros", "net", "parking_lot", "process", "rt", "rt-multi-thread", "signal", "sync", "time", "tracing"] } +tracing = { version = "0.1.41", features = ["std", "attributes"] } +tracing-subscriber = { version = "0.3.20", features = ["smallvec", "fmt", "ansi", "tracing-log", "std", "json", "serde", "serde_json", "time", "tracing"] } +serde_json = { version = "1.0.145", features = ["std"] } +serde = { version = "1.0.228", features = ["std", "derive"] } +tokio-cron-scheduler = { version = "0.15.1", features = ["signal"] } +clap = { version = "4", features = ["derive"] } diff --git a/apps/agent/src/commands.rs b/apps/agent/src/commands.rs new file mode 100644 index 0000000..377bd6c --- /dev/null +++ b/apps/agent/src/commands.rs @@ -0,0 +1,292 @@ +mod reload; +mod run; +mod validate; +mod write_config; + +use std::{ + collections::HashMap, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +use tokio::sync::{Mutex, RwLock}; +use tokio_cron_scheduler::{Job, JobScheduler}; +use tracing::{error, info}; + +use crate::commands::write_config::INTERNAL_CONFIG_FOLDER_NAME; + +const OLD_CONFIG_CLEANUP_THRESHOLD: u64 = 3600; + +pub struct NginxService { + // lock for nginx reload, and timestamp tracking + nginx_lock: Mutex<()>, + last_applied: AtomicU64, + // lock for write_config per (config_name, timestamp) + #[allow(clippy::type_complexity)] + write_config_lock: RwLock>>>, + // commands + reload_cmd: Arc, + validate_cmd: Arc, + write_config_cmd: Arc, +} + +impl NginxService { + pub async fn new( + scheduler: Arc, + nginx_config_dir: std::path::PathBuf, + ) -> Result, Box> { + let nginx_service = Arc::new(NginxService { + nginx_lock: Mutex::new(()), + last_applied: AtomicU64::new(0), + write_config_lock: RwLock::new(HashMap::new()), + // commands + reload_cmd: Arc::new(reload::ReloadCommand::default()), + validate_cmd: Arc::new(validate::ValidateCommand::new(nginx_config_dir.clone())), + write_config_cmd: Arc::new(write_config::WriteConfigCommand::new(nginx_config_dir)), + }); + let mut nginx_service_clone = nginx_service.clone(); + + scheduler + .clone() + // cleanup every 10 minutes + .add(Job::new_async("0 */10 * * * *", move |_uuid, _l| { + info!("Running nginx_service cleanup job"); + let nginx_service_clone = nginx_service_clone.clone(); + let job = Box::pin(async move { + nginx_service_clone.cleanup_unused_lock().await; + }); + info!("NginxService cleanup job completed"); + job + })?) + .await?; + + nginx_service_clone = nginx_service.clone(); + + scheduler + .clone() + // cleanup every hour + .add(Job::new_async("0 0 */1 * * *", move |_uuid, _l| { + info!("Running nginx_service old config cleanup job"); + let nginx_service_clone = nginx_service_clone.clone(); + let job = Box::pin(async move { + nginx_service_clone.cleanup_old_configs().await; + }); + info!("NginxService old config cleanup job completed"); + job + })?) + .await?; + + Ok(nginx_service) + } + + pub async fn validate_and_reload( + &self, + config_name: &str, + timestamp: u64, + ) -> Result<(i32, String), Box> { + let cur = self.last_applied.load(Ordering::SeqCst); + if cur > timestamp { + return Err("Another operation is in progress with higher timestamp value".into()); + } + + // acquire write lock to update nginx_lock + let _nginx_guard = self.nginx_lock.lock().await; + // acquire write lock for this config+timestamp + let rw_lock = self.acquire_file_write_lock(config_name, timestamp).await; + let _guard = rw_lock.write().await; + + match self + .reload_cmd + .validate_and_reload(config_name, timestamp, self.validate_cmd.clone()) + .await + { + Ok((code, output)) => { + // update last_applied + self.last_applied.store(timestamp, Ordering::SeqCst); + Ok((code, output)) + } + Err(e) => Err(e), + } + } + + pub async fn write_config( + &self, + config_name: &str, + timestamp: u64, + content: &str, + ) -> Result<(), Box> { + let rw_lock = self.acquire_file_write_lock(config_name, timestamp).await; + let _guard = rw_lock.write().await; + // call the write_config command + self.write_config_cmd + .write_config(config_name, timestamp, content) + .await + } + + pub async fn validate( + &self, + config_name: &str, + timestamp: u64, + ) -> Result<(i32, String), Box> { + self.validate_cmd.validate(config_name, timestamp).await + } + + async fn cleanup_unused_lock(&self) { + let mut _write_lock = self.write_config_lock.write().await; + (*_write_lock).retain(|_, lock| { + // retain only locks that are currently held (readers or writers) + lock.try_write().is_err() + }); + } + + async fn cleanup_old_configs(&self) { + // list all files within nginx_config_dir/YANPM that is older than now - OLD_CONFIG_CLEANUP_THRESHOLD + let cutoff = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + - OLD_CONFIG_CLEANUP_THRESHOLD; + + let nginx_config_dir = self.validate_cmd.nginx_config_dir(); + let yanpm_dir = nginx_config_dir.join(INTERNAL_CONFIG_FOLDER_NAME); + + let read_dir = match tokio::fs::read_dir(&yanpm_dir).await { + Ok(rd) => rd, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // directory does not exist, nothing to clean up + return; + } + Err(e) => { + error!( + "Error reading {} config directory {}: {}", + INTERNAL_CONFIG_FOLDER_NAME, + yanpm_dir.display(), + e + ); + return; + } + }; + + tokio::pin!(read_dir); + while let Some(entry) = read_dir.next_entry().await.unwrap_or(None) { + let metadata = match entry.metadata().await { + Ok(md) => md, + Err(e) => { + error!( + "Error getting metadata for file {}: {}", + entry.path().display(), + e + ); + continue; + } + }; + if let Ok(modified) = metadata.modified() + && let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) + { + let mtime_secs = duration.as_secs(); + if mtime_secs < cutoff { + // file is older than cutoff, remove it + if let Err(e) = tokio::fs::remove_file(entry.path()).await { + error!( + "Error removing old config file {}: {}", + entry.path().display(), + e + ); + } else { + info!("Removed old config file {}", entry.path().display()); + } + } + } + } + } + + async fn acquire_file_write_lock(&self, config_name: &str, timestamp: u64) -> Arc> { + let mut write_lock = self.write_config_lock.write().await; + write_lock + .entry((config_name.to_string(), timestamp)) + .or_insert_with(|| Arc::new(RwLock::new(()))) + .clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::error::Error; + use std::sync::Arc as StdArc; + use tokio::time::{Duration, sleep}; + + impl NginxService { + // Test helper that simulates a long-running reload without invoking external commands. + pub async fn test_simulated_reload( + &self, + config_name: &str, + timestamp: u64, + delay_ms: u64, + ) -> Result<(), Box> { + // pre-check + let cur = self.last_applied.load(Ordering::SeqCst); + if cur >= timestamp { + return Err("stale".into()); + } + + // acquire exclusive lock and re-check + let _nginx_guard = self.nginx_lock.lock().await; + let cur2 = self.last_applied.load(Ordering::SeqCst); + if cur2 >= timestamp { + return Err("stale".into()); + } + + // per-file lock + let rw_lock = self.acquire_file_write_lock(config_name, timestamp).await; + let _guard = rw_lock.write().await; + + // simulate operation + sleep(Duration::from_millis(delay_ms)).await; + + // on success update last_applied + let mut prev = self.last_applied.load(Ordering::SeqCst); + while prev < timestamp { + match self.last_applied.compare_exchange( + prev, + timestamp, + Ordering::SeqCst, + Ordering::SeqCst, + ) { + Ok(_) => break, + Err(next) => prev = next, + } + } + + Ok(()) + } + } + + #[tokio::test] + async fn concurrent_stale_is_rejected() { + let scheduler = StdArc::new(JobScheduler::new().await.unwrap()); + let svc = NginxService::new(scheduler.clone(), std::env::temp_dir()) + .await + .unwrap(); + + let s1 = svc.clone(); + let h1 = tokio::spawn(async move { s1.test_simulated_reload("cfg", 2, 200).await }); + + // let second start shortly after first so it will wait for the mutex + sleep(Duration::from_millis(20)).await; + + let s2 = svc.clone(); + let h2 = tokio::spawn(async move { s2.test_simulated_reload("cfg", 1, 10).await }); + + let r1 = h1.await.unwrap(); + assert!(r1.is_ok(), "first (newer) task should succeed"); + + let r2 = h2.await.unwrap(); + assert!( + r2.is_err(), + "second (older) task should be rejected as stale" + ); + } +} diff --git a/apps/agent/src/commands/reload.rs b/apps/agent/src/commands/reload.rs new file mode 100644 index 0000000..601c6a3 --- /dev/null +++ b/apps/agent/src/commands/reload.rs @@ -0,0 +1,98 @@ +use std::path::Path; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use tokio::sync::Mutex; +use tracing::error; + +use crate::commands::write_config::INTERNAL_CONFIG_FOLDER_NAME; +use crate::commands::{run::run_cmd, validate::ValidateCommand}; + +pub struct ReloadCommand { + is_reloading: Mutex, +} + +struct ReloadResetGuard<'a> { + guard: tokio::sync::MutexGuard<'a, bool>, +} + +impl<'a> Drop for ReloadResetGuard<'a> { + fn drop(&mut self) { + *self.guard = false; + } +} + +impl Default for ReloadCommand { + fn default() -> Self { + Self { + is_reloading: Mutex::new(false), + } + } +} + +impl ReloadCommand { + pub async fn validate_and_reload( + &self, + config_name: &str, + timestamp: u64, + validate_cmd: Arc, + ) -> Result<(i32, String), Box> { + // ensure the written fragment exists + validate_cmd.validate(config_name, timestamp).await?; + + // Now atomically swap the YANPM.conf symlink to point to the new fragment + // so nginx -t validates the composed main config. If validation fails, + // attempt to restore the previous symlink. + let filename = crate::commands::run::to_file_name(config_name, timestamp)?; + let nginx_dir = validate_cmd.nginx_config_dir(); + let symlink_path = nginx_dir.join("YANPM.conf"); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let tmp_name = format!("YANPM.conf.tmp.{}.{}", std::process::id(), now); + let tmp_path = nginx_dir.join(&tmp_name); + + // prepare relative target: INTERNAL_CONFIG_FOLDER_NAME/ + let rel_target = Path::new(INTERNAL_CONFIG_FOLDER_NAME).join(&filename); + + // read previous target if exists + let previous_target = std::fs::read_link(&symlink_path).ok(); + + // Acquire reload guard before mutating the symlink to avoid races + let reloading_lock = self.is_reloading.lock().await; + if *reloading_lock { + return Err("Reload already in progress".into()); + } + // set flag to true and ensure it is reset on drop + let mut mut_guard = reloading_lock; + *mut_guard = true; + let _reset_guard = ReloadResetGuard { guard: mut_guard }; + + // create temporary symlink and atomically rename into place + std::os::unix::fs::symlink(&rel_target, &tmp_path)?; + tokio::fs::rename(&tmp_path, &symlink_path).await?; + + // validate composed main config now that symlink points to new fragment + if let Err(e) = validate_cmd.validate_all().await { + // restore previous symlink state while still holding the guard + if let Some(prev) = previous_target { + let restore_tmp = + nginx_dir.join(format!("YANPM.conf.restore.{}.{}", std::process::id(), now)); + std::os::unix::fs::symlink(&prev, &restore_tmp)?; + if let Err(err) = tokio::fs::rename(&restore_tmp, &symlink_path).await { + error!( + "Failed to restore previous YANPM.conf symlink after validation error: {}", + err + ); + } + } else if let Err(err) = tokio::fs::remove_file(&symlink_path).await { + error!( + "Failed to remove YANPM.conf symlink after validation error: {}", + err + ); + } + return Err(e); + } + + // reload the running nginx master process (no -c) so it reloads its configured main config + run_cmd("nginx", &["-s", "reload"], 10).await + } +} diff --git a/apps/agent/src/commands/run.rs b/apps/agent/src/commands/run.rs new file mode 100644 index 0000000..5f60862 --- /dev/null +++ b/apps/agent/src/commands/run.rs @@ -0,0 +1,85 @@ +use std::time::Duration; + +use tokio::{process::Command, time::timeout}; +use tracing::error; + +pub fn to_file_name( + config_name: &str, + timestamp: u64, +) -> Result> { + // reject empty or unsafe names to avoid path traversal or invalid filesystem chars + if config_name.is_empty() { + return Err("config_name is empty".into()); + } + if config_name.len() > 255 { + return Err("config_name too long".into()); + } + if config_name.contains('/') || config_name.contains('\\') || config_name.contains("..") { + return Err("config_name contains invalid path characters".into()); + } + if !config_name + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-._".contains(c)) + { + return Err("config_name contains invalid characters".into()); + } + + Ok(format!("{}_{}.conf", timestamp, config_name)) +} + +pub async fn run_cmd( + cmd: &str, + args: &[&str], + dur_s: u64, +) -> Result<(i32, String), Box> { + let mut c = Command::new(cmd); + c.args(args); + let res = timeout(Duration::from_secs(dur_s), c.output()).await; + let out = match res { + Ok(Ok(out)) => out, + Ok(Err(e)) => return Err(Box::new(e)), + Err(_) => { + return Err(Box::new(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "command timeout", + ))); + } + }; + let code = out.status.code().unwrap_or(-1); + let output = String::from_utf8_lossy(&[out.stdout, out.stderr].concat()).to_string(); + if code != 0 { + error!("command failed ({}): {}", code, output); + return Err(format!("command failed ({}): {}", code, output).into()); + } + Ok((code, output)) +} + +#[cfg(test)] +mod tests { + use super::to_file_name; + + #[test] + fn to_file_name_valid() { + let res = to_file_name("myconf", 1234).expect("should succeed"); + assert_eq!(res, "1234_myconf.conf"); + } + + #[test] + fn to_file_name_empty() { + assert!(to_file_name("", 1).is_err()); + } + + #[test] + fn to_file_name_invalid_chars() { + assert!(to_file_name("bad/name", 1).is_err()); + assert!(to_file_name("bad\\name", 1).is_err()); + assert!(to_file_name("bad..name", 1).is_err()); + assert!(to_file_name("bad$name", 1).is_err()); + } + + #[test] + fn to_file_name_too_long() { + let long = "a".repeat(300); + assert!(to_file_name(&long, 1).is_err()); + } +} diff --git a/apps/agent/src/commands/validate.rs b/apps/agent/src/commands/validate.rs new file mode 100644 index 0000000..ed02987 --- /dev/null +++ b/apps/agent/src/commands/validate.rs @@ -0,0 +1,47 @@ +use crate::commands::{run::run_cmd, write_config::INTERNAL_CONFIG_FOLDER_NAME}; +use std::path::PathBuf; + +pub struct ValidateCommand { + nginx_config_dir: PathBuf, +} + +impl ValidateCommand { + pub fn new(nginx_config_dir: PathBuf) -> Self { + Self { nginx_config_dir } + } + + pub fn nginx_config_dir(&self) -> PathBuf { + self.nginx_config_dir.clone() + } + + pub async fn validate_all( + &self, + ) -> Result<(i32, String), Box> { + run_cmd("nginx", &["-t"], 10).await + } + + pub async fn validate( + &self, + config_name: &str, + timestamp: u64, + ) -> Result<(i32, String), Box> { + let filename = crate::commands::run::to_file_name(config_name, timestamp)?; + // fragments are written into the YANPM subdirectory + let full_path = self + .nginx_config_dir + .join(INTERNAL_CONFIG_FOLDER_NAME) + .join(&filename); + + // ensure the fragment file exists + if tokio::fs::metadata(&full_path).await.is_err() { + return Err(format!("Config file not found: {}", full_path.display()).into()); + } + + run_cmd( + "nginx", + &["-t", "-c", full_path.to_str().ok_or("invalid config path")?], + 10, + ) + .await + } +} diff --git a/apps/agent/src/commands/write_config.rs b/apps/agent/src/commands/write_config.rs new file mode 100644 index 0000000..350a4e7 --- /dev/null +++ b/apps/agent/src/commands/write_config.rs @@ -0,0 +1,131 @@ +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::io::AsyncWriteExt; + +use crate::commands::run::to_file_name; + +pub const INTERNAL_CONFIG_FOLDER_NAME: &str = "YANPM"; +const FILE_SIZE_LIMIT: usize = 10 * 1024 * 1024; // 10MB + +pub struct WriteConfigCommand { + nginx_config_dir: PathBuf, +} + +impl WriteConfigCommand { + pub fn new(nginx_config_dir: PathBuf) -> Self { + Self { nginx_config_dir } + } + pub async fn write_config( + &self, + config_name: &str, + timestamp: u64, + content: &str, + ) -> Result<(), Box> { + let filename = to_file_name(config_name, timestamp)?; + let path = self.nginx_config_dir.clone(); + // ensure main config dir exists + tokio::fs::create_dir_all(&path).await?; + + // create YANPM subdir where fragment files live + let yanpm_dir = path.join(INTERNAL_CONFIG_FOLDER_NAME); + tokio::fs::create_dir_all(&yanpm_dir).await?; + let final_path = yanpm_dir.join(&filename); + + // limit size to 10MB + if content.len() > FILE_SIZE_LIMIT { + return Err(format!( + "content exceeds {}MB size limit", + FILE_SIZE_LIMIT / (1024 * 1024) + ) + .into()); + } + + // create a temporary filename in the same directory for atomic replace + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let tmp_filename = format!("{}.tmp.{}.{}", filename, std::process::id(), now); + // create tmp file in the same directory as final file to ensure atomic rename + let tmp_path = yanpm_dir.join(tmp_filename); + + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&tmp_path) + .await?; + file.write_all(content.as_bytes()).await?; + // ensure data is flushed to disk; propagate errors + file.sync_all().await?; + + // atomically move the tmp file into the YANPM dir + tokio::fs::rename(&tmp_path, &final_path).await?; + + // set explicit permissions (rw-r-----) + tokio::fs::set_permissions(&final_path, std::fs::Permissions::from_mode(0o640)).await?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{INTERNAL_CONFIG_FOLDER_NAME, WriteConfigCommand}; + use std::time::SystemTime; + use std::time::UNIX_EPOCH; + + #[tokio::test] + async fn write_config_success_and_cleanup() { + let base = std::env::temp_dir().join(format!( + "yanpm_test_{}_{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + // ensure clean + let _ = tokio::fs::remove_dir_all(&base).await; + let cmd = WriteConfigCommand::new(base.clone()); + + let config_name = "unittest"; + let timestamp = 42u64; + let content = "hello world"; + + cmd.write_config(config_name, timestamp, content) + .await + .expect("write should succeed"); + + let filename = super::to_file_name(config_name, timestamp).unwrap(); + let final_path = base.join(INTERNAL_CONFIG_FOLDER_NAME).join(&filename); + let data = tokio::fs::read_to_string(&final_path) + .await + .expect("file should exist"); + assert_eq!(data, content); + + // cleanup + tokio::fs::remove_dir_all(&base).await.expect("cleanup"); + } + + #[tokio::test] + async fn write_config_size_limit() { + let base = std::env::temp_dir().join(format!( + "yanpm_test_{}_{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = tokio::fs::remove_dir_all(&base).await; + let cmd = WriteConfigCommand::new(base.clone()); + + // exceed 10MB limit + let large = vec![b'a'; 10 * 1024 * 1024 + 1]; + let large_str = String::from_utf8_lossy(&large).to_string(); + + let res = cmd.write_config("big", 1, &large_str).await; + assert!(res.is_err()); + + let _ = tokio::fs::remove_dir_all(&base).await; + } +} diff --git a/apps/agent/src/main.rs b/apps/agent/src/main.rs new file mode 100644 index 0000000..f977490 --- /dev/null +++ b/apps/agent/src/main.rs @@ -0,0 +1,83 @@ +#![forbid(unsafe_code)] + +mod commands; +mod routes; + +use axum::routing::get; +use axum::{Router, routing::post}; +use clap::Parser; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::net::UnixListener; +use tracing::{error, info}; + +use crate::commands::NginxService; +use crate::routes::{status, validate, validate_and_reload, write_config}; + +#[derive(Parser)] +struct Args { + /// Unix socket path to bind the daemon to + sock: String, + + /// Directory where generated nginx config files will be written + #[arg(long, default_value = "/etc/nginx/conf.d")] + nginx_config_dir: PathBuf, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args = Args::parse(); + let sock = args.sock; + + let path = PathBuf::from(&sock); + if let Some(dir) = path.parent() { + tokio::fs::create_dir_all(dir).await?; + // permissive; set tighter perms in production via image/build steps + tokio::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o770)).await?; + } + // If an existing path exists at the socket location, ensure it's a socket + match tokio::fs::metadata(&path).await { + Ok(md) => { + use std::os::unix::fs::FileTypeExt; + if md.file_type().is_socket() { + tokio::fs::remove_file(&path).await?; + } else { + return Err( + format!("Socket path {} exists and is not a socket", path.display()).into(), + ); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + + // bind using tokio's UnixListener (avoids converting a blocking std listener) + let listener = UnixListener::bind(&path)?; + // set socket perms to 0660 (best-effort) + if let Err(err) = + tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o660)).await + { + error!( + "Warning: failed to set permissions on socket {}: {}", + path.display(), + err + ); + } + + let scheduler = Arc::new(tokio_cron_scheduler::JobScheduler::new().await?); + + let app = Router::new() + .route("/status", get(status)) + .route("/validate_and_reload", post(validate_and_reload)) + .route("/validate", post(validate)) + .route("/write_config", post(write_config)) + .with_state(NginxService::new(scheduler.clone(), args.nginx_config_dir).await?); + + scheduler.start().await?; + + info!("Starting yanpm-daemon on unix socket: {}", sock); + axum::serve::serve(listener, app).await?; + + Ok(()) +} diff --git a/apps/agent/src/routes.rs b/apps/agent/src/routes.rs new file mode 100644 index 0000000..77c8733 --- /dev/null +++ b/apps/agent/src/routes.rs @@ -0,0 +1,130 @@ +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, from_value}; +use std::sync::Arc; +use tracing::warn; + +use crate::commands::NginxService; + +#[derive(Serialize)] +pub struct StatusResp { + pub ok: bool, +} + +pub async fn status() -> impl IntoResponse { + let resp = StatusResp { ok: true }; + (axum::http::StatusCode::OK, axum::Json(resp)) +} + +#[derive(Serialize)] +pub struct ValidateAndReloadResp { + pub rc: i32, + pub ro: String, +} + +#[derive(Deserialize)] +pub struct ValidateBody { + config_name: String, + timestamp: u64, +} + +pub async fn validate( + State(nginx_controller): State>, + Json(payload): Json, +) -> impl IntoResponse { + let params: ValidateBody = match from_value(payload) { + Ok(req) => req, + Err(e) => { + warn!("Invalid validate request: {}", e); + return (StatusCode::BAD_REQUEST).into_response(); + } + }; + + let (_code, _output) = match nginx_controller + .validate(¶ms.config_name, params.timestamp) + .await + { + Ok(res) => res, + Err(e) => { + let resp = serde_json::json!({ "error": e.to_string() }); + return (StatusCode::INTERNAL_SERVER_ERROR, axum::Json(resp)).into_response(); + } + }; + + (axum::http::StatusCode::OK,).into_response() +} + +#[derive(Deserialize)] +pub struct ValidateAndReloadBody { + config_name: String, + timestamp: u64, +} + +pub async fn validate_and_reload( + State(nginx_controller): State>, + Json(payload): Json, +) -> impl IntoResponse { + let params: ValidateAndReloadBody = match from_value(payload) { + Ok(req) => req, + Err(e) => { + warn!("Invalid validate_and_reload request: {}", e); + return (StatusCode::BAD_REQUEST).into_response(); + } + }; + + let (code, output) = match nginx_controller + .validate_and_reload(¶ms.config_name, params.timestamp) + .await + { + Ok(res) => res, + Err(e) => { + let resp = ValidateAndReloadResp { + rc: -1, + ro: e.to_string(), + }; + return (StatusCode::INTERNAL_SERVER_ERROR, axum::Json(resp)).into_response(); + } + }; + + let resp = ValidateAndReloadResp { + rc: code, + ro: output, + }; + (axum::http::StatusCode::OK, axum::Json(resp)).into_response() +} + +#[derive(Deserialize)] +pub struct WriteConfigBody { + config_name: String, + timestamp: u64, + content: String, +} + +pub async fn write_config( + State(nginx_controller): State>, + Json(payload): Json, +) -> impl IntoResponse { + let body: WriteConfigBody = match from_value(payload) { + Ok(req) => req, + Err(e) => { + warn!("Invalid write_config request: {}", e); + return (StatusCode::BAD_REQUEST).into_response(); + } + }; + + match nginx_controller + .write_config(&body.config_name, body.timestamp, &body.content) + .await + { + Ok(_) => (), + Err(e) => { + let resp = serde_json::json!({ "error": e.to_string() }); + return (StatusCode::INTERNAL_SERVER_ERROR, axum::Json(resp)).into_response(); + } + }; + + (axum::http::StatusCode::OK,).into_response() +} diff --git a/justfile b/justfile index 91832fc..37cf4ba 100644 --- a/justfile +++ b/justfile @@ -48,6 +48,11 @@ generate-openapi: # Generate API client for frontend cd apps/frontend && \ pnpm generate:openapi + # Generate OpenAPI spec for agent + cd apps/agent && \ + cargo run -- --generate-openapi --openapi-output ./openapi.yaml + # TODO: Generate API client for agent in api + generate-all: generate-entity generate-openapi From 7781878c2d26c7a86e5c2333a51c1bf625b2c55e Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sun, 21 Dec 2025 17:51:43 +0800 Subject: [PATCH 02/10] feat: implement Dockerfile and service scripts for yanpm-agent --- apps/agent/Dockerfile | 56 +++++++++++++ apps/agent/docker/s6/services.d/agent/run | 4 + apps/agent/docker/s6/services.d/nginx/run | 3 + apps/agent/src/main.rs | 98 ++++++++++++++++++----- 4 files changed, 142 insertions(+), 19 deletions(-) create mode 100644 apps/agent/Dockerfile create mode 100644 apps/agent/docker/s6/services.d/agent/run create mode 100644 apps/agent/docker/s6/services.d/nginx/run diff --git a/apps/agent/Dockerfile b/apps/agent/Dockerfile new file mode 100644 index 0000000..ebf4b93 --- /dev/null +++ b/apps/agent/Dockerfile @@ -0,0 +1,56 @@ +FROM rust:1.92-alpine3.23 AS builder + +# Install build deps and binutils (for strip) +RUN apk add --no-cache build-base musl-dev openssl-dev pkgconfig ca-certificates curl binutils +WORKDIR /app + +# Copy manifest first to leverage Docker layer caching for dependencies +COPY ./Cargo.toml ./ +RUN cargo fetch --locked || true + +COPY ./src ./src + +# Build the release binary and strip it to reduce size +RUN cargo build --release --bin yanpm-agent && \ + strip target/release/yanpm-agent || true + +FROM nginx:mainline-alpine3.23 AS base + +# Expose typical HTTP ports used by nginx +EXPOSE 80 443 + +ENV YANPM_AGENT_SOCK=/var/run/yanpm/yanpm-agent.sock +ENV YANPM_NGINX_CONFIG_DIR=/etc/nginx/conf.d + +WORKDIR /app + +# Install ca-certificates for TLS and minimal tools +RUN apk add --no-cache ca-certificates curl + +# Install s6-overlay +ENV S6_OVERLAY_VERSION=v3.2.1.0 +ADD https://github.com/just-containers/s6-overlay/releases/download/${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp +RUN tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz && rm /tmp/s6-overlay-noarch.tar.xz +ADD https://github.com/just-containers/s6-overlay/releases/download/${S6_OVERLAY_VERSION}/s6-overlay-x86_64.tar.xz /tmp/s6-overlay.tar.xz +RUN tar -C / -Jxpf /tmp/s6-overlay.tar.xz && rm /tmp/s6-overlay.tar.xz + +# Create non-root user for agent and set permissions +RUN addgroup -S app && adduser -S -G app app + +# add user to nginx group to allow reading of nginx configs +RUN adduser app nginx +# create directory for yanpm agent socket +RUN mkdir -p /var/run/yanpm && chown -R app:app /var/run/yanpm + +# Copy s6 service definitions (created in repo under s6/) into image +COPY ./docker/s6/services.d /etc/services.d +RUN chmod +x /etc/services.d/*/run + +COPY --from=builder /app/target/release/yanpm-agent ./yanpm-agent + +RUN chown -R app:app /app/yanpm-agent \ + && chmod +x /app/yanpm-agent \ + && chown app:app /app + +# s6-overlay provides /init as the init process +ENTRYPOINT ["/init"] diff --git a/apps/agent/docker/s6/services.d/agent/run b/apps/agent/docker/s6/services.d/agent/run new file mode 100644 index 0000000..fb30033 --- /dev/null +++ b/apps/agent/docker/s6/services.d/agent/run @@ -0,0 +1,4 @@ +#!/bin/sh +# Run the agent as the unprivileged 'app' user +cd /app +exec s6-setuidgid app ./yanpm-agent diff --git a/apps/agent/docker/s6/services.d/nginx/run b/apps/agent/docker/s6/services.d/nginx/run new file mode 100644 index 0000000..7aa2d44 --- /dev/null +++ b/apps/agent/docker/s6/services.d/nginx/run @@ -0,0 +1,3 @@ +#!/bin/sh +# Run nginx in foreground (s6 will supervise it) +exec nginx -g 'daemon off;' diff --git a/apps/agent/src/main.rs b/apps/agent/src/main.rs index f977490..1c83666 100644 --- a/apps/agent/src/main.rs +++ b/apps/agent/src/main.rs @@ -5,7 +5,7 @@ mod routes; use axum::routing::get; use axum::{Router, routing::post}; -use clap::Parser; +use clap::{Arg, Command}; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::sync::Arc; @@ -15,33 +15,89 @@ use tracing::{error, info}; use crate::commands::NginxService; use crate::routes::{status, validate, validate_and_reload, write_config}; -#[derive(Parser)] -struct Args { - /// Unix socket path to bind the daemon to - sock: String, - - /// Directory where generated nginx config files will be written - #[arg(long, default_value = "/etc/nginx/conf.d")] - nginx_config_dir: PathBuf, -} +const SOCK_ARG: &str = "sock"; +const NGINX_CONFIG_DIR_ARG: &str = "nginx_config_dir"; +const SOCK_ENV: &str = "YANPM_AGENT_SOCK"; +const NGINX_CONFIG_DIR_ENV: &str = "YANPM_NGINX_CONFIG_DIR"; +const SOCK_DEFAULT: &str = "./yanpm-agent.sock"; +const NGINX_CONFIG_DIR_DEFAULT: &str = "/etc/nginx/conf.d"; #[tokio::main] async fn main() -> Result<(), Box> { - let args = Args::parse(); - let sock = args.sock; + let args = Command::new("yanpm-agent") + .arg( + Arg::new("sock") + .short('s') + .long("sock") + .value_name("SOCK_PATH") + .help("Unix socket path to bind the agent daemon to") + .required(false), + ) + .arg( + Arg::new("nginx_config_dir") + .short('d') + .long("nginx-config-dir") + .value_name("NGINX_CONFIG_DIR") + .help("Directory where generated nginx config files will be written") + .required(false), + ) + .about("YANPM Agent Daemon") + .get_matches(); + + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_target(false) + .with_level(true) + .with_timer(tracing_subscriber::fmt::time::SystemTime) + .finish(); + + tracing::subscriber::set_global_default(subscriber) + .expect("Failed to set global default subscriber"); + + let sock = args + .get_one::(SOCK_ARG) + .cloned() + .unwrap_or_else(|| std::env::var(SOCK_ENV).unwrap_or_else(|_| SOCK_DEFAULT.to_string())); + let nginx_config_dir = args + .get_one::(NGINX_CONFIG_DIR_ARG) + .cloned() + .unwrap_or_else(|| { + std::env::var(NGINX_CONFIG_DIR_ENV) + .unwrap_or_else(|_| NGINX_CONFIG_DIR_DEFAULT.to_string()) + }); let path = PathBuf::from(&sock); if let Some(dir) = path.parent() { - tokio::fs::create_dir_all(dir).await?; + tokio::fs::create_dir_all(dir).await.unwrap_or_else(|err| { + error!( + "Warning: failed to create socket directory {}: {}", + dir.display(), + err + ) + }); // permissive; set tighter perms in production via image/build steps - tokio::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o770)).await?; + tokio::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o770)) + .await + .unwrap_or_else(|err| { + error!( + "Warning: failed to set permissions on socket directory {}: {}", + dir.display(), + err + ) + }); } // If an existing path exists at the socket location, ensure it's a socket match tokio::fs::metadata(&path).await { Ok(md) => { use std::os::unix::fs::FileTypeExt; if md.file_type().is_socket() { - tokio::fs::remove_file(&path).await?; + tokio::fs::remove_file(&path).await.unwrap_or_else(|err| { + error!( + "Warning: failed to remove existing socket file {}: {}", + path.display(), + err + ) + }); } else { return Err( format!("Socket path {} exists and is not a socket", path.display()).into(), @@ -49,11 +105,13 @@ async fn main() -> Result<(), Box> { } } Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(e.into()), + Err(e) => { + return Err(format!("Failed to stat socket path {}: {}", path.display(), e).into()); + } } // bind using tokio's UnixListener (avoids converting a blocking std listener) - let listener = UnixListener::bind(&path)?; + let listener = UnixListener::bind(&path).expect("Failed to bind to unix socket"); // set socket perms to 0660 (best-effort) if let Err(err) = tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o660)).await @@ -72,12 +130,14 @@ async fn main() -> Result<(), Box> { .route("/validate_and_reload", post(validate_and_reload)) .route("/validate", post(validate)) .route("/write_config", post(write_config)) - .with_state(NginxService::new(scheduler.clone(), args.nginx_config_dir).await?); + .with_state(NginxService::new(scheduler.clone(), PathBuf::from(nginx_config_dir)).await?); scheduler.start().await?; info!("Starting yanpm-daemon on unix socket: {}", sock); - axum::serve::serve(listener, app).await?; + axum::serve::serve(listener, app) + .await + .expect("Failed to start axum server"); Ok(()) } From b823fe628185f5691570f9b371a74df772bc50e7 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sun, 21 Dec 2025 18:52:26 +0800 Subject: [PATCH 03/10] feat: Fix permission and env errors, add loggings, socket perm args --- apps/agent/Dockerfile | 2 + apps/agent/docker/s6/services.d/agent/run | 1 + apps/agent/src/main.rs | 97 +++++++++++++++++------ 3 files changed, 75 insertions(+), 25 deletions(-) diff --git a/apps/agent/Dockerfile b/apps/agent/Dockerfile index ebf4b93..6703c92 100644 --- a/apps/agent/Dockerfile +++ b/apps/agent/Dockerfile @@ -19,8 +19,10 @@ FROM nginx:mainline-alpine3.23 AS base # Expose typical HTTP ports used by nginx EXPOSE 80 443 +ENV S6_KEEP_ENV=1 ENV YANPM_AGENT_SOCK=/var/run/yanpm/yanpm-agent.sock ENV YANPM_NGINX_CONFIG_DIR=/etc/nginx/conf.d +ENV YANPM_AGENT_SOCK_PERM=660 WORKDIR /app diff --git a/apps/agent/docker/s6/services.d/agent/run b/apps/agent/docker/s6/services.d/agent/run index fb30033..bb9d2c2 100644 --- a/apps/agent/docker/s6/services.d/agent/run +++ b/apps/agent/docker/s6/services.d/agent/run @@ -1,4 +1,5 @@ #!/bin/sh # Run the agent as the unprivileged 'app' user cd /app +echo "Starting yanpm-agent..." exec s6-setuidgid app ./yanpm-agent diff --git a/apps/agent/src/main.rs b/apps/agent/src/main.rs index 1c83666..de41b4c 100644 --- a/apps/agent/src/main.rs +++ b/apps/agent/src/main.rs @@ -10,20 +10,33 @@ use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::sync::Arc; use tokio::net::UnixListener; -use tracing::{error, info}; +use tracing::{error, info, warn}; use crate::commands::NginxService; use crate::routes::{status, validate, validate_and_reload, write_config}; const SOCK_ARG: &str = "sock"; const NGINX_CONFIG_DIR_ARG: &str = "nginx_config_dir"; +const SOCK_PERM_ARG: &str = "sock_perm"; const SOCK_ENV: &str = "YANPM_AGENT_SOCK"; +const SOCK_PERM_ENV: &str = "YANPM_AGENT_SOCK_PERM"; const NGINX_CONFIG_DIR_ENV: &str = "YANPM_NGINX_CONFIG_DIR"; const SOCK_DEFAULT: &str = "./yanpm-agent.sock"; const NGINX_CONFIG_DIR_DEFAULT: &str = "/etc/nginx/conf.d"; +const SOCK_PERM_DEFAULT: &str = "660"; #[tokio::main] async fn main() -> Result<(), Box> { + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_target(false) + .with_level(true) + .with_timer(tracing_subscriber::fmt::time::SystemTime) + .finish(); + + tracing::subscriber::set_global_default(subscriber) + .expect("Failed to set global default subscriber"); + let args = Command::new("yanpm-agent") .arg( Arg::new("sock") @@ -41,30 +54,17 @@ async fn main() -> Result<(), Box> { .help("Directory where generated nginx config files will be written") .required(false), ) + .arg( + Arg::new("sock_perm") + .long("sock-perm") + .value_name("SOCK_PERM") + .help("Permissions to set on the unix socket (in octal), e.g. 660") + .required(false), + ) .about("YANPM Agent Daemon") .get_matches(); - let subscriber = tracing_subscriber::fmt() - .with_max_level(tracing::Level::INFO) - .with_target(false) - .with_level(true) - .with_timer(tracing_subscriber::fmt::time::SystemTime) - .finish(); - - tracing::subscriber::set_global_default(subscriber) - .expect("Failed to set global default subscriber"); - - let sock = args - .get_one::(SOCK_ARG) - .cloned() - .unwrap_or_else(|| std::env::var(SOCK_ENV).unwrap_or_else(|_| SOCK_DEFAULT.to_string())); - let nginx_config_dir = args - .get_one::(NGINX_CONFIG_DIR_ARG) - .cloned() - .unwrap_or_else(|| { - std::env::var(NGINX_CONFIG_DIR_ENV) - .unwrap_or_else(|_| NGINX_CONFIG_DIR_DEFAULT.to_string()) - }); + let (sock, nginx_config_dir, sock_perm) = get_args(&args).await?; let path = PathBuf::from(&sock); if let Some(dir) = path.parent() { @@ -112,9 +112,9 @@ async fn main() -> Result<(), Box> { // bind using tokio's UnixListener (avoids converting a blocking std listener) let listener = UnixListener::bind(&path).expect("Failed to bind to unix socket"); - // set socket perms to 0660 (best-effort) + // set socket perms to sock_perm (best-effort) if let Err(err) = - tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o660)).await + tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(sock_perm)).await { error!( "Warning: failed to set permissions on socket {}: {}", @@ -132,12 +132,59 @@ async fn main() -> Result<(), Box> { .route("/write_config", post(write_config)) .with_state(NginxService::new(scheduler.clone(), PathBuf::from(nginx_config_dir)).await?); - scheduler.start().await?; + scheduler.clone().start().await?; info!("Starting yanpm-daemon on unix socket: {}", sock); axum::serve::serve(listener, app) .await .expect("Failed to start axum server"); + info!("Shutting down yanpm-daemon"); Ok(()) } + +async fn get_args( + args: &clap::ArgMatches, +) -> Result<(String, String, u32), Box> { + let sock = args + .get_one::(SOCK_ARG) + .cloned() + .unwrap_or_else(|| std::env::var(SOCK_ENV).unwrap_or_else(|_| SOCK_DEFAULT.to_string())); + let nginx_config_dir = args + .get_one::(NGINX_CONFIG_DIR_ARG) + .cloned() + .unwrap_or_else(|| { + std::env::var(NGINX_CONFIG_DIR_ENV) + .unwrap_or_else(|_| NGINX_CONFIG_DIR_DEFAULT.to_string()) + }); + let sock_perm = args + .get_one::(SOCK_PERM_ARG) + .cloned() + .unwrap_or_else(|| { + std::env::var(SOCK_PERM_ENV).unwrap_or_else(|_| SOCK_PERM_DEFAULT.to_string()) + }); + + if sock_perm.len() != 3 || !sock_perm.chars().all(|c| ('0'..='7').contains(&c)) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "Invalid socket permission string: {}. Must be a 3-digit octal number.", + sock_perm + ), + ) + .into()); + } + + if sock_perm.chars().last().unwrap() > '0' { + warn!( + "Socket permission string {} allows others to access the socket. This may be a security risk.", + sock_perm + ); + }; + + Ok(( + sock, + nginx_config_dir, + u32::from_str_radix(&sock_perm, 8).expect("Failed to parse socket permission string"), + )) +} From 61ecd91219302099afb5fe2699a0e83c28344277 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sun, 21 Dec 2025 19:32:48 +0800 Subject: [PATCH 04/10] feat: add nix dependency and enhance socket permissions handling --- Cargo.lock | 13 +++++ apps/agent/Cargo.toml | 1 + apps/agent/Dockerfile | 20 +++---- .../docker/s6/cont-init.d/10-create-app-user | 58 +++++++++++++++++++ apps/agent/docker/s6/services.d/agent/run | 4 +- apps/agent/src/main.rs | 53 ++++++++++++++--- 6 files changed, 129 insertions(+), 20 deletions(-) create mode 100644 apps/agent/docker/s6/cont-init.d/10-create-app-user diff --git a/Cargo.lock b/Cargo.lock index 5826b40..d4b7e5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2029,6 +2029,18 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4824,6 +4836,7 @@ version = "0.1.0" dependencies = [ "axum", "clap", + "nix", "serde", "serde_json", "tokio", diff --git a/apps/agent/Cargo.toml b/apps/agent/Cargo.toml index 53c344f..7e254c6 100644 --- a/apps/agent/Cargo.toml +++ b/apps/agent/Cargo.toml @@ -12,3 +12,4 @@ serde_json = { version = "1.0.145", features = ["std"] } serde = { version = "1.0.228", features = ["std", "derive"] } tokio-cron-scheduler = { version = "0.15.1", features = ["signal"] } clap = { version = "4", features = ["derive"] } +nix = { version = "0.30.1", features = ["user", "fs"] } diff --git a/apps/agent/Dockerfile b/apps/agent/Dockerfile index 6703c92..2501668 100644 --- a/apps/agent/Dockerfile +++ b/apps/agent/Dockerfile @@ -23,6 +23,9 @@ ENV S6_KEEP_ENV=1 ENV YANPM_AGENT_SOCK=/var/run/yanpm/yanpm-agent.sock ENV YANPM_NGINX_CONFIG_DIR=/etc/nginx/conf.d ENV YANPM_AGENT_SOCK_PERM=660 +ENV YANPM_AGENT_SOCK_GID="" +ENV YANPM_AGENT_UID=1000 +ENV YANPM_AGENT_GID=1000 WORKDIR /app @@ -36,23 +39,18 @@ RUN tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz && rm /tmp/s6-overlay-noarch.ta ADD https://github.com/just-containers/s6-overlay/releases/download/${S6_OVERLAY_VERSION}/s6-overlay-x86_64.tar.xz /tmp/s6-overlay.tar.xz RUN tar -C / -Jxpf /tmp/s6-overlay.tar.xz && rm /tmp/s6-overlay.tar.xz -# Create non-root user for agent and set permissions -RUN addgroup -S app && adduser -S -G app app - -# add user to nginx group to allow reading of nginx configs -RUN adduser app nginx -# create directory for yanpm agent socket -RUN mkdir -p /var/run/yanpm && chown -R app:app /var/run/yanpm +# Runtime user creation handled by s6 cont-init (see /etc/cont-init.d) +# create directory for yanpm agent socket; ownership will be fixed at container start +RUN mkdir -p /var/run/yanpm # Copy s6 service definitions (created in repo under s6/) into image COPY ./docker/s6/services.d /etc/services.d -RUN chmod +x /etc/services.d/*/run +COPY ./docker/s6/cont-init.d /etc/cont-init.d +RUN chmod +x /etc/services.d/*/run && chmod +x /etc/cont-init.d/* COPY --from=builder /app/target/release/yanpm-agent ./yanpm-agent -RUN chown -R app:app /app/yanpm-agent \ - && chmod +x /app/yanpm-agent \ - && chown app:app /app +RUN chmod +x /app/yanpm-agent # s6-overlay provides /init as the init process ENTRYPOINT ["/init"] diff --git a/apps/agent/docker/s6/cont-init.d/10-create-app-user b/apps/agent/docker/s6/cont-init.d/10-create-app-user new file mode 100644 index 0000000..7e195fe --- /dev/null +++ b/apps/agent/docker/s6/cont-init.d/10-create-app-user @@ -0,0 +1,58 @@ +#!/bin/sh +set -eu + +YANPM_AGENT_UID="${YANPM_AGENT_UID:-1000}" +YANPM_AGENT_GID="${YANPM_AGENT_GID:-1000}" +# If a specific socket GID is requested, prefer that for the app group +YANPM_AGENT_GID_EFFECTIVE="${YANPM_AGENT_SOCK_GID:-${YANPM_AGENT_GID}}" +YANPM_AGENT_USER="${YANPM_AGENT_USER:-yanpm-agent}" +YANPM_AGENT_GROUP="${YANPM_AGENT_GROUP:-yanpm-agent}" + +# Ensure group exists with desired GID +if grep -qE "^${YANPM_AGENT_GROUP}:" /etc/group 2>/dev/null; then + existing_gid=$(awk -F: -v g="${YANPM_AGENT_GROUP}" '$1==g{print $3}' /etc/group) + if [ "${existing_gid}" != "${YANPM_AGENT_GID_EFFECTIVE}" ]; then + delgroup "${YANPM_AGENT_GROUP}" || true + addgroup -g "${YANPM_AGENT_GID_EFFECTIVE}" "${YANPM_AGENT_GROUP}" + fi +else + addgroup -g "${YANPM_AGENT_GID_EFFECTIVE}" "${YANPM_AGENT_GROUP}" +fi + +# Ensure user exists with desired UID and primary group +if grep -qE "^${YANPM_AGENT_USER}:" /etc/passwd 2>/dev/null; then + existing_uid=$(awk -F: -v u="${YANPM_AGENT_USER}" '$1==u{print $3}' /etc/passwd) + if [ "${existing_uid}" != "${YANPM_AGENT_UID}" ]; then + deluser "${YANPM_AGENT_USER}" || true + adduser -D -u "${YANPM_AGENT_UID}" -G "${YANPM_AGENT_GROUP}" "${YANPM_AGENT_USER}" + fi +else + adduser -D -u "${YANPM_AGENT_UID}" -G "${YANPM_AGENT_GROUP}" "${YANPM_AGENT_USER}" +fi + +# Add app user to nginx group to allow reading configs +addgroup "${YANPM_AGENT_USER}" nginx || true +# Ensure runtime directories exist and fix ownership + +mkdir -p /var/run/yanpm /app +if chown -R "${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}" /var/run/yanpm 2>/dev/null; then + echo "chown: /var/run/yanpm -> ${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}" +else + echo "Warning: failed to chown /var/run/yanpm to ${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}. This is common for bind-mounted host volumes or rootless Docker." >&2 +fi + +if chown -R "${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}" /app/yanpm-agent 2>/dev/null; then + echo "chown: /app/yanpm-agent -> ${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}" +else + echo "Warning: failed to chown /app/yanpm-agent to ${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}. Binary will still be used if permissions allow." >&2 +fi + +if chown "${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}" /app 2>/dev/null; then + echo "chown: /app -> ${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}" +else + echo "Warning: failed to chown /app to ${YANPM_AGENT_UID}:${YANPM_AGENT_GID_EFFECTIVE}." >&2 +fi + +echo "App user and group setup complete. UID:${YANPM_AGENT_UID} GID:${YANPM_AGENT_GID_EFFECTIVE}" + +exit 0 diff --git a/apps/agent/docker/s6/services.d/agent/run b/apps/agent/docker/s6/services.d/agent/run index bb9d2c2..1d5a11c 100644 --- a/apps/agent/docker/s6/services.d/agent/run +++ b/apps/agent/docker/s6/services.d/agent/run @@ -1,5 +1,5 @@ #!/bin/sh -# Run the agent as the unprivileged 'app' user +# Run the agent as the unprivileged 'yanpm-agent' user cd /app echo "Starting yanpm-agent..." -exec s6-setuidgid app ./yanpm-agent +exec s6-setuidgid yanpm-agent ./yanpm-agent diff --git a/apps/agent/src/main.rs b/apps/agent/src/main.rs index de41b4c..b626fb4 100644 --- a/apps/agent/src/main.rs +++ b/apps/agent/src/main.rs @@ -18,12 +18,15 @@ use crate::routes::{status, validate, validate_and_reload, write_config}; const SOCK_ARG: &str = "sock"; const NGINX_CONFIG_DIR_ARG: &str = "nginx_config_dir"; const SOCK_PERM_ARG: &str = "sock_perm"; +const SOCK_GID_ARG: &str = "sock_gid"; const SOCK_ENV: &str = "YANPM_AGENT_SOCK"; const SOCK_PERM_ENV: &str = "YANPM_AGENT_SOCK_PERM"; const NGINX_CONFIG_DIR_ENV: &str = "YANPM_NGINX_CONFIG_DIR"; +const SOCK_GID_ENV: &str = "YANPM_AGENT_SOCK_GID"; const SOCK_DEFAULT: &str = "./yanpm-agent.sock"; const NGINX_CONFIG_DIR_DEFAULT: &str = "/etc/nginx/conf.d"; const SOCK_PERM_DEFAULT: &str = "660"; +const SOCK_GID_DEFAULT: &str = ""; #[tokio::main] async fn main() -> Result<(), Box> { @@ -39,7 +42,7 @@ async fn main() -> Result<(), Box> { let args = Command::new("yanpm-agent") .arg( - Arg::new("sock") + Arg::new(SOCK_ARG) .short('s') .long("sock") .value_name("SOCK_PATH") @@ -47,7 +50,7 @@ async fn main() -> Result<(), Box> { .required(false), ) .arg( - Arg::new("nginx_config_dir") + Arg::new(NGINX_CONFIG_DIR_ARG) .short('d') .long("nginx-config-dir") .value_name("NGINX_CONFIG_DIR") @@ -55,16 +58,23 @@ async fn main() -> Result<(), Box> { .required(false), ) .arg( - Arg::new("sock_perm") + Arg::new(SOCK_PERM_ARG) .long("sock-perm") .value_name("SOCK_PERM") .help("Permissions to set on the unix socket (in octal), e.g. 660") .required(false), ) + .arg( + Arg::new(SOCK_GID_ARG) + .long("sock-gid") + .value_name("SOCK_GID") + .help("GID to set on the unix socket, default: current user's primary group") + .required(false), + ) .about("YANPM Agent Daemon") .get_matches(); - let (sock, nginx_config_dir, sock_perm) = get_args(&args).await?; + let (sock, nginx_config_dir, sock_perm, sock_gid) = get_args(&args).await?; let path = PathBuf::from(&sock); if let Some(dir) = path.parent() { @@ -123,6 +133,27 @@ async fn main() -> Result<(), Box> { ); } + // set socket gid to sock_gid (best-effort) + if !sock_gid.is_empty() { + use nix::unistd::{Gid, chown}; + if let Err(err) = chown( + &path, + None, + Some(Gid::from_raw( + sock_gid + .parse() + .map_err(|e| format!("Failed to parse socket GID {}: {}", sock_gid, e)) + .unwrap_or_else(|_| nix::unistd::getgid().as_raw()), + )), + ) { + error!( + "Warning: failed to set GID on socket {}: {}", + path.display(), + err + ); + } + } + let scheduler = Arc::new(tokio_cron_scheduler::JobScheduler::new().await?); let app = Router::new() @@ -145,7 +176,7 @@ async fn main() -> Result<(), Box> { async fn get_args( args: &clap::ArgMatches, -) -> Result<(String, String, u32), Box> { +) -> Result<(String, String, u32, String), Box> { let sock = args .get_one::(SOCK_ARG) .cloned() @@ -164,6 +195,13 @@ async fn get_args( std::env::var(SOCK_PERM_ENV).unwrap_or_else(|_| SOCK_PERM_DEFAULT.to_string()) }); + let sock_gid = args + .get_one::(SOCK_GID_ARG) + .cloned() + .unwrap_or_else(|| { + std::env::var(SOCK_GID_ENV).unwrap_or_else(|_| SOCK_GID_DEFAULT.to_string()) + }); + if sock_perm.len() != 3 || !sock_perm.chars().all(|c| ('0'..='7').contains(&c)) { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -177,8 +215,8 @@ async fn get_args( if sock_perm.chars().last().unwrap() > '0' { warn!( - "Socket permission string {} allows others to access the socket. This may be a security risk.", - sock_perm + "Socket permission string {} allows others to access the socket. This may be a security risk. Consider setting {} to a desired group and using a socket permission string that does not allow others to access the socket.", + sock_perm, SOCK_GID_ENV ); }; @@ -186,5 +224,6 @@ async fn get_args( sock, nginx_config_dir, u32::from_str_radix(&sock_perm, 8).expect("Failed to parse socket permission string"), + sock_gid, )) } From 7db23b01df8247b8181f3c160d263d5b346aa3f1 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 22 Dec 2025 12:54:14 +0800 Subject: [PATCH 05/10] Add testcontainer for agent image with nginx --- Cargo.lock | 343 ++++++++++++++++---- apps/cli/Cargo.toml | 2 +- apps/cli/src/cmd/db_migrate_and_generate.rs | 1 + apps/container/Cargo.toml | 2 +- apps/container/src/agent.rs | 120 +++++++ apps/container/src/db/config.rs | 6 +- apps/container/src/db/postgresql.rs | 8 +- apps/container/src/env.rs | 4 + apps/container/src/lib.rs | 18 +- apps/container/src/main.rs | 136 +++++++- apps/container/src/types.rs | 4 + apps/container/src/util.rs | 17 +- justfile | 6 +- 13 files changed, 589 insertions(+), 78 deletions(-) create mode 100644 apps/container/src/agent.rs diff --git a/Cargo.lock b/Cargo.lock index d4b7e5b..2aa5314 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,6 +93,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "argon2" version = "0.5.3" @@ -117,6 +123,22 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "astral-tokio-tar" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5" +dependencies = [ + "filetime", + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "tokio", + "tokio-stream", + "xattr", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -295,12 +317,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.10.0" @@ -342,13 +358,17 @@ dependencies = [ [[package]] name = "bollard" -version = "0.18.1" +version = "0.19.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97ccca1260af6a459d75994ad5acc1651bcabcbdbc41467cc9786519ab854c30" +checksum = "87a52479c9237eb04047ddb94788c41ca0d26eaff8b697ecfbb4c32f7fdc3b1b" dependencies = [ + "async-stream", "base64 0.22.1", + "bitflags", + "bollard-buildkit-proto", "bollard-stubs", "bytes", + "chrono", "futures-core", "futures-util", "hex", @@ -361,7 +381,9 @@ dependencies = [ "hyper-util", "hyperlocal", "log", + "num", "pin-project-lite", + "rand 0.9.2", "rustls", "rustls-native-certs", "rustls-pemfile", @@ -373,19 +395,40 @@ dependencies = [ "serde_urlencoded", "thiserror", "tokio", + "tokio-stream", "tokio-util", + "tonic", "tower-service", "url", "winapi", ] [[package]] -name = "bollard-stubs" -version = "1.47.1-rc.27.3.1" +name = "bollard-buildkit-proto" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f179cfbddb6e77a5472703d4b30436bff32929c0aa8a9008ecf23d1d3cdd0da" +checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-prost", + "ureq", +] + +[[package]] +name = "bollard-stubs" +version = "1.49.1-rc.28.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5731fe885755e92beff1950774068e0cae67ea6ec7587381536fca84f1779623" +dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "chrono", + "prost", "serde", + "serde_json", "serde_repr", "serde_with", ] @@ -1110,13 +1153,12 @@ dependencies = [ [[package]] name = "etcetera" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c7b13d0780cb82722fd59f6f57f925e143427e4a75313a6c77243bf5326ae6" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "home", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1136,6 +1178,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ferroid" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce161062fb044bd629c2393590efd47cab8d0241faf15704ffb0d47b7b4e4a35" +dependencies = [ + "portable-atomic", + "rand 0.9.2", + "web-time", +] + [[package]] name = "ff" version = "0.13.1" @@ -1579,6 +1632,19 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.17" @@ -1901,9 +1967,9 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.10.0", + "bitflags", "libc", - "redox_syscall 0.5.18", + "redox_syscall", ] [[package]] @@ -2035,7 +2101,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.10.0", + "bitflags", "cfg-if", "cfg_aliases", "libc", @@ -2050,6 +2116,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2076,6 +2156,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2113,6 +2202,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2141,7 +2241,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.10.0", + "bitflags", "cfg-if", "foreign-types", "libc", @@ -2270,7 +2370,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] @@ -2418,6 +2518,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -2467,6 +2587,12 @@ dependencies = [ "regex", ] +[[package]] +name = "portable-atomic" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f59e70c4aef1e55797c2e8fd94a4f2a973fc972cfde0e0b05f683667b0cd39dd" + [[package]] name = "potential_utf" version = "0.1.4" @@ -2563,6 +2689,38 @@ dependencies = [ "yansi", ] +[[package]] +name = "prost" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "prost-types" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" +dependencies = [ + "prost", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -2663,22 +2821,13 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "redox_syscall" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" -dependencies = [ - "bitflags 1.3.2", -] - [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags", ] [[package]] @@ -2798,7 +2947,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd490c5b18261893f14449cbd28cb9c0b637aebf161cd77900bfdedaff21ec32" dependencies = [ - "bitflags 2.10.0", + "bitflags", "once_cell", "serde", "serde_derive", @@ -2852,6 +3001,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2867,7 +3022,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.10.0", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -2880,6 +3035,7 @@ version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -3190,7 +3346,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.10.0", + "bitflags", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -3203,7 +3359,7 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ - "bitflags 2.10.0", + "bitflags", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3585,7 +3741,7 @@ dependencies = [ "atoi", "base64 0.22.1", "bigdecimal", - "bitflags 2.10.0", + "bitflags", "byteorder", "bytes", "chrono", @@ -3632,7 +3788,7 @@ dependencies = [ "atoi", "base64 0.22.1", "bigdecimal", - "bitflags 2.10.0", + "bitflags", "byteorder", "chrono", "crc", @@ -3832,18 +3988,20 @@ dependencies = [ [[package]] name = "testcontainers" -version = "0.24.0" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23bb7577dca13ad86a78e8271ef5d322f37229ec83b8d98da6d996c588a1ddb1" +checksum = "1483605f58b2fff80d786eb56a0b6b4e8b1e5423fbc9ec2e3e562fa2040d6f27" dependencies = [ + "astral-tokio-tar", "async-trait", "bollard", - "bollard-stubs", "bytes", "docker_credential", "either", - "etcetera 0.10.0", + "etcetera 0.11.0", + "ferroid", "futures", + "itertools", "log", "memchr", "parse-display", @@ -3854,7 +4012,6 @@ dependencies = [ "thiserror", "tokio", "tokio-stream", - "tokio-tar", "tokio-util", "url", ] @@ -4019,21 +4176,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tar" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d5714c010ca3e5c27114c1cdeb9d14641ace49874aa5626d7149e47aedace75" -dependencies = [ - "filetime", - "futures-core", - "libc", - "redox_syscall 0.3.5", - "tokio", - "tokio-stream", - "xattr", -] - [[package]] name = "tokio-util" version = "0.7.17" @@ -4090,6 +4232,46 @@ dependencies = [ "winnow", ] +[[package]] +name = "tonic" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" +dependencies = [ + "bytes", + "prost", + "tonic", +] + [[package]] name = "tower" version = "0.5.2" @@ -4098,9 +4280,12 @@ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", + "indexmap 2.12.0", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -4112,7 +4297,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags", "bytes", "http", "pin-project-lite", @@ -4284,6 +4469,34 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" +dependencies = [ + "base64 0.22.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf-8", + "webpki-roots 1.0.4", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.7" @@ -4296,6 +4509,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4439,6 +4658,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml index 8357949..f88fc3d 100644 --- a/apps/cli/Cargo.toml +++ b/apps/cli/Cargo.toml @@ -8,7 +8,7 @@ async-trait = "0.1.89" container-simulate = { path = "../container" } migration = {path = "../../public/migration"} shared = {path = "../../public/shared"} -testcontainers = "0.24.0" +testcontainers = "0.26.0" tokio = { version = "1.47.0", features = ["full"] } url = "2.5.7" clap = { version = "4.5.48", features = ["derive", "env"] } diff --git a/apps/cli/src/cmd/db_migrate_and_generate.rs b/apps/cli/src/cmd/db_migrate_and_generate.rs index 4e7ac08..a563431 100644 --- a/apps/cli/src/cmd/db_migrate_and_generate.rs +++ b/apps/cli/src/cmd/db_migrate_and_generate.rs @@ -54,6 +54,7 @@ fn action( for db_config in database_configs { let config = container::Config { database: db_config, + agent: None, }; let mut detached_handler = container::start_detached(&config).await; match migrate_and_generate_entity(&config, &output_path).await { diff --git a/apps/container/Cargo.toml b/apps/container/Cargo.toml index bc502e1..5c48710 100644 --- a/apps/container/Cargo.toml +++ b/apps/container/Cargo.toml @@ -9,7 +9,7 @@ path = "src/lib.rs" [dependencies] async-trait = "0.1.89" -testcontainers = "0.24.0" +testcontainers = { version = "0.26.0" } shared = { path = "../../public/shared" } tokio = { version = "1.47.0", features = ["full"] } url = "2.5.7" diff --git a/apps/container/src/agent.rs b/apps/container/src/agent.rs new file mode 100644 index 0000000..ee08852 --- /dev/null +++ b/apps/container/src/agent.rs @@ -0,0 +1,120 @@ +use std::{error::Error, sync::Arc}; +use testcontainers::{ + ContainerAsync, GenericBuildableImage, GenericImage, ImageExt, + core::{AccessMode, BuildImageOptions, ContainerPort, Mount, WaitFor}, + runners::{AsyncBuilder, AsyncRunner}, +}; + +use crate::{ + db::UnStartedContainer, + types::{ConfigInfoType, WithContainer}, +}; + +pub const SOCK_NAME: &str = "yanpm-agent.sock"; +const SOCK_FOLDER: &str = "/var/run/yanpm"; +const NGINX_CONFIG_DIR: &str = "/etc/nginx/conf.d"; + +#[derive(Clone)] +pub struct AgentContainerConfig { + pub image: String, + pub tag: String, + pub container_name: String, + pub dockerfile_path: String, + pub force_build: bool, + pub agent_config: AgentConfig, + pub nginx_config: NginxConfig, +} + +pub type AgentConfigInfoType = ConfigInfoType; + +#[derive(Clone)] +pub struct AgentContainerInfo { + pub container: Arc>, + pub config: AgentContainerConfig, +} + +impl WithContainer for AgentContainerInfo { + fn container(&self) -> &Arc> { + &self.container + } +} + +#[derive(Clone)] +pub struct AgentConfig { + pub sock_folder: String, // path to be mounted to host for unix socket + pub nginx_config_dir: String, // path to be mounted to host for nginx config files, only the agent generated folder will be mounted + pub sock_perm: u32, // permissions to set on the unix socket + pub sock_gid: String, // GID to set on the unix socket +} + +#[derive(Clone)] +pub struct NginxConfig { + pub expose_http: bool, + pub expose_https: bool, +} + +impl AgentContainerConfig { + pub fn new( + image: String, + tag: String, + container_name: String, + dockerfile_path: String, + force_build: bool, + // agent configs + agent_config: AgentConfig, + nginx_config: NginxConfig, + ) -> Self { + AgentContainerConfig { + image, + tag, + container_name, + dockerfile_path, + force_build, + // default agent configs + agent_config, + nginx_config, + } + } + + pub async fn get_unstarted_container(&self) -> Result> { + let mut image = GenericBuildableImage::new(&self.image, &self.tag) + .with_dockerfile(&self.dockerfile_path) + .build_image_with(BuildImageOptions::new().with_skip_if_exists(!self.force_build)) + .await?; + + if self.nginx_config.expose_http { + image = image.with_exposed_port(ContainerPort::Tcp(80)); + } + + if self.nginx_config.expose_https { + image = image.with_exposed_port(ContainerPort::Tcp(443)); + } + + image = image.with_wait_for(WaitFor::message_on_either_std("Starting yanpm-daemon on")); + + Ok(image + .with_container_name(self.container_name.clone()) + .with_env_var("YANPM_AGENT_SOCK", format!("{}/{}", SOCK_FOLDER, SOCK_NAME)) + .with_env_var("YANPM_NGINX_CONFIG_DIR", NGINX_CONFIG_DIR.to_string()) + .with_env_var( + "YANPM_AGENT_SOCK_PERM", + self.agent_config.sock_perm.to_string(), + ) + .with_env_var("YANPM_AGENT_SOCK_GID", self.agent_config.sock_gid.clone()) + .with_mount( + Mount::bind_mount( + self.agent_config.sock_folder.clone(), + SOCK_FOLDER.to_string(), + ) + .with_access_mode(AccessMode::ReadWrite), + ) + .with_mount( + Mount::bind_mount( + self.agent_config.nginx_config_dir.clone(), + NGINX_CONFIG_DIR.to_string(), + ) + .with_access_mode(AccessMode::ReadWrite), + ) + .start()) + } +} diff --git a/apps/container/src/db/config.rs b/apps/container/src/db/config.rs index 1c69d06..ff0be61 100644 --- a/apps/container/src/db/config.rs +++ b/apps/container/src/db/config.rs @@ -9,7 +9,7 @@ pub struct OptionalContainerConfig { } #[derive(Clone)] -pub struct ContainerConfig { +pub struct DatabaseContainerConfig { pub image: String, pub tag: String, pub container_name: String, @@ -19,8 +19,8 @@ pub struct ContainerConfig { } impl OptionalContainerConfig { - pub fn fill_with(&self, other: &ContainerConfig) -> ContainerConfig { - ContainerConfig { + pub fn fill_with(&self, other: &DatabaseContainerConfig) -> DatabaseContainerConfig { + DatabaseContainerConfig { image: self.image.clone().unwrap_or_else(|| other.image.clone()), tag: self.tag.clone().unwrap_or_else(|| other.tag.clone()), container_name: self diff --git a/apps/container/src/db/postgresql.rs b/apps/container/src/db/postgresql.rs index f4343d4..5c0cec2 100644 --- a/apps/container/src/db/postgresql.rs +++ b/apps/container/src/db/postgresql.rs @@ -11,12 +11,12 @@ use crate::{ ConfigInfoType, db::{ ContainerizedDBInfo, DBConfigInfoType, DBInfo, UnStartedContainer, - config::{ContainerConfig, OptionalContainerConfig}, + config::{DatabaseContainerConfig, OptionalContainerConfig}, }, }; -pub fn get_default_config() -> ContainerConfig { - ContainerConfig { +pub fn get_default_config() -> DatabaseContainerConfig { + DatabaseContainerConfig { container_name: "yanpm-postgres".to_string(), database_name: "postgres".to_string(), user: "postgres".to_string(), @@ -27,7 +27,7 @@ pub fn get_default_config() -> ContainerConfig { } pub struct PostgreSQLContainer { - pub config: ContainerConfig, + pub config: DatabaseContainerConfig, } #[async_trait] diff --git a/apps/container/src/env.rs b/apps/container/src/env.rs index 162883f..0c2200b 100644 --- a/apps/container/src/env.rs +++ b/apps/container/src/env.rs @@ -32,6 +32,10 @@ impl EnvFile { env_file } + pub fn write_line(&mut self, key: &str, value: &str) { + self._write_line_buffer(key, value); + } + pub fn write(&mut self, stream: &mut dyn Write, with_prefix: bool) { self._write_buffer(stream, with_prefix); } diff --git a/apps/container/src/lib.rs b/apps/container/src/lib.rs index e1b4bc3..bd2fee4 100644 --- a/apps/container/src/lib.rs +++ b/apps/container/src/lib.rs @@ -1,9 +1,11 @@ +pub mod agent; pub mod db; mod env; pub mod types; mod util; use crate::{ + agent::AgentConfigInfoType, db::DBConfigInfoType, types::{ConfigInfoType, WithContainer, WithoutContainer}, util::{ @@ -15,6 +17,7 @@ use crate::{ #[derive(Clone)] pub struct Config { pub database: DBConfigInfoType, + pub agent: Option, } // relative to the pwd @@ -56,26 +59,29 @@ impl<'a> Drop for DetachedHandle<'a> { } async fn start(config: &Config) { - let db_config = &config.database; - // // write the config files for the api server and database client println!("Writing config files..."); - write_env_files(db_config); + write_env_files(&config.database, &config.agent); println!("Config files written to:"); println!(" - {}", to_absolute_path(API_CONFIG_PATH).display()); println!(" - {}", to_absolute_path(DB_CONFIG_PATH).display()); } async fn stop(config: &Config) { - let db_config = &config.database; // stop the container println!("Stopping container..."); - stop_container(db_config, "database".to_string()).await; + println!("Stopping database container..."); + stop_container(&config.database, "database".to_string()).await; + if let Some(agent) = &config.agent { + println!("Stopping agent container..."); + stop_container(agent, "agent".to_string()).await; + } + println!("Container stopped."); // remove the generated config file println!("Removing generated config file..."); remove_file_if_exists(DB_CONFIG_PATH); remove_file_if_exists(API_CONFIG_PATH); - println!("Container stopped."); + println!("Generated config files removed."); } pub async fn start_attached(config: &Config) { diff --git a/apps/container/src/main.rs b/apps/container/src/main.rs index b4e38c3..cffd7c2 100644 --- a/apps/container/src/main.rs +++ b/apps/container/src/main.rs @@ -1,6 +1,10 @@ +use std::sync::Arc; + use clap::Parser; -use container::Config; +use container::agent::{AgentConfig, AgentContainerConfig, AgentContainerInfo, NginxConfig}; use container::start_attached; +use container::types::ConfigInfoType; +use container::{Config, agent}; use container::db::DBInfo; @@ -11,12 +15,52 @@ struct Args { /// Database type to use: 'postgres' or 'sqlite'. Can also be set with DB_TYPE env var. #[arg(long, default_value = "sqlite", env = "DB_TYPE")] db_type: String, + + // agent related + /// force build agent image + #[arg(long, default_value_t = false, env = "AGENT_FORCE_BUILD")] + agent_force_build: bool, + /// dockerfile path for building agent image + #[arg(long, env = "AGENT_DOCKERFILE_PATH", required = false)] + agent_dockerfile_path: Option, + /// host's location to mount nginx config files folder generated by the agent + #[arg(long, env = "AGENT_NGINX_CONFIG_DIR", required = false)] + agent_nginx_config_dir: Option, + /// host's location folder to mount the unix socket files + #[arg(long, env = "AGENT_SOCK_PATH", required = false)] + agent_sock_path: Option, + /// socket permissions to set on the unix socket + #[arg(long, default_value = "660", env = "AGENT_SOCK_PERM", required = false)] + agent_sock_perm: u32, + /// socket GID to set on the unix socket + #[arg(long, default_value = "", env = "AGENT_SOCK_GID", required = false)] + agent_sock_gid: String, + /// nginx expose http port + #[arg( + long, + default_value_t = true, + env = "AGENT_NGINX_EXPOSE_HTTP", + required = false + )] + agent_nginx_expose_http: bool, + /// nginx expose https port + #[arg( + long, + default_value_t = false, + env = "AGENT_NGINX_EXPOSE_HTTPS", + required = false + )] + agent_nginx_expose_https: bool, +} + +struct ParsedArgs { + db_type: String, + agent_container_config: Option, } #[tokio::main] async fn main() { - // Parse command line arguments and environment variables - let args = Args::parse(); + let args = parse_args().await; println!("Starting container with database type: {}", args.db_type); let db_config = match args.db_type.to_lowercase().as_str() { @@ -43,11 +87,97 @@ async fn main() { }; println!("Database configuration obtained."); + let agent_container = if let Some(agent_config) = &args.agent_container_config { + println!( + "Agent container will be used with socket path: {} and nginx config dir: {}", + agent_config.agent_config.sock_folder, agent_config.agent_config.nginx_config_dir + ); + Some(agent_config.get_unstarted_container().await) + } else { + println!("No agent container configuration provided, skipping agent setup."); + None + }; + let config = Config { database: db_config, + agent: match agent_container { + Some(Ok(container)) => Some(ConfigInfoType::Containerized(AgentContainerInfo { + container: Arc::new(container.await.expect("Failed to start agent container")), + config: args.agent_container_config.expect("Invalid config state"), + })), + Some(Err(e)) => { + eprintln!("Failed to set up agent container: {}", e); + std::process::exit(1); + } + None => None, + }, }; println!("Starting container..."); start_attached(&config).await; println!("Container stopped. Exiting..."); } + +async fn parse_args() -> ParsedArgs { + // Parse command line arguments and environment variables + let args = Args::parse(); + + // if any required args are missing, do not start agent + let dockerfile_path = match args.agent_dockerfile_path { + None => { + println!("Agent dockerfile path not provided, skipping agent setup."); + return ParsedArgs { + db_type: args.db_type, + agent_container_config: None, + }; + } + Some(path) => path, + }; + + let time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let agent_config = AgentConfig { + sock_folder: match args.agent_sock_path { + None => { + // create a temp dir for the socket path + let temp_dir = std::env::temp_dir().join(format!("yanpm-agent-sock-{}", time)); + std::fs::create_dir_all(&temp_dir) + .expect("Failed to create temp dir for agent socket"); + temp_dir.to_string_lossy().to_string() + } + Some(path) => path, + }, + nginx_config_dir: match args.agent_nginx_config_dir { + None => { + // create a temp dir for the nginx config dir + let temp_dir = + std::env::temp_dir().join(format!("yanpm-agent-nginx-configs-{}", time)); + std::fs::create_dir_all(&temp_dir) + .expect("Failed to create temp dir for agent nginx configs"); + temp_dir.to_string_lossy().to_string() + } + Some(path) => path, + }, + sock_perm: args.agent_sock_perm, + sock_gid: args.agent_sock_gid.clone(), + }; + + ParsedArgs { + db_type: args.db_type, + agent_container_config: Some(AgentContainerConfig { + image: "yanpm-agent".to_string(), + tag: "latest".to_string(), + container_name: format!("yanpm-agent-container-{}", time), + dockerfile_path, + force_build: args.agent_force_build, + agent_config, + nginx_config: NginxConfig { + expose_http: args.agent_nginx_expose_http, + expose_https: args.agent_nginx_expose_https, + }, + }), + } +} diff --git a/apps/container/src/types.rs b/apps/container/src/types.rs index d91a7e6..e43e702 100644 --- a/apps/container/src/types.rs +++ b/apps/container/src/types.rs @@ -10,6 +10,10 @@ pub trait WithoutContainer { fn on_delete(&self); } +impl WithoutContainer for () { + fn on_delete(&self) {} +} + #[derive(Clone)] pub enum ConfigInfoType where diff --git a/apps/container/src/util.rs b/apps/container/src/util.rs index d513904..c80fada 100644 --- a/apps/container/src/util.rs +++ b/apps/container/src/util.rs @@ -4,6 +4,7 @@ use tokio::signal::unix::{SignalKind, signal}; use crate::{ API_CONFIG_PATH, DB_CONFIG_PATH, + agent::{AgentConfigInfoType, AgentContainerInfo, SOCK_NAME}, db::DBConfigInfoType, env::{self, EnvFile}, types::{ConfigInfoType, WithContainer, WithoutContainer}, @@ -20,7 +21,7 @@ pub fn to_absolute_path(path: &str) -> PathBuf { .clean() } -pub fn write_env_files(db_config: &DBConfigInfoType) { +pub fn write_env_files(db_config: &DBConfigInfoType, agent_config: &Option) { let api_config_path_absolute = to_absolute_path(API_CONFIG_PATH); let db_config_path_absolute = to_absolute_path(DB_CONFIG_PATH); @@ -33,6 +34,20 @@ pub fn write_env_files(db_config: &DBConfigInfoType) { let mut db_env = api_env.clone(); db_env.file_type = env::EnvFileType::DotEnv; + // agent related env vars + if let Some(agent) = agent_config + && let ConfigInfoType::Containerized(agent) = agent + { + api_env.write_line( + "AGENT__SOCK__PATH", + format!("{}/{}", &agent.config.agent_config.sock_folder, SOCK_NAME).as_str(), + ); + api_env.write_line( + "AGENT__NGINX__CONFIG__DIR", + &agent.config.agent_config.nginx_config_dir, + ); + } + let mut api_file = std::fs::File::create(&api_config_path_absolute).expect("Failed to create API config file"); diff --git a/justfile b/justfile index 37cf4ba..fede830 100644 --- a/justfile +++ b/justfile @@ -2,6 +2,8 @@ set dotenv-load := true # development environment file set dotenv-filename := "./public/database/.env.generated" +DEFAULT_SIMULATE_ARGS := "--agent-dockerfile-path=../agent/Dockerfile" + cli *args: cd apps/cli && \ if [ -n "{{args}}" ]; then \ @@ -13,9 +15,9 @@ cli *args: simulate *args: cd apps/container && \ if [ -n "{{args}}" ]; then \ - cargo run --bin container-simulate -- --db-type={{args}}; \ + cargo run --bin container-simulate -- {{args}}; \ else \ - cargo run --bin container-simulate; \ + cargo run --bin container-simulate -- {{DEFAULT_SIMULATE_ARGS}}; \ fi # Usage: (following SeaORM migration commands) From 6e85bda13f0ad6bca04197c86b42e4fd25f688b2 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:32:57 +0800 Subject: [PATCH 06/10] Refactor container definitions --- apps/cli/src/cmd/db_migrate_and_generate.rs | 4 +- apps/container/src/containers.rs | 40 +++++++++++++++++++ apps/container/src/{ => containers}/agent.rs | 7 +--- apps/container/src/{ => containers}/db.rs | 15 +++---- .../src/{ => containers}/db/config.rs | 0 .../src/{ => containers}/db/postgresql.rs | 11 +++-- .../src/{ => containers}/db/sqlite.rs | 4 +- apps/container/src/env.rs | 33 +++++---------- apps/container/src/lib.rs | 10 ++--- apps/container/src/main.rs | 22 +++++----- apps/container/src/types.rs | 25 ------------ apps/container/src/util.rs | 12 ++++-- 12 files changed, 92 insertions(+), 91 deletions(-) create mode 100644 apps/container/src/containers.rs rename apps/container/src/{ => containers}/agent.rs (95%) rename apps/container/src/{ => containers}/db.rs (73%) rename apps/container/src/{ => containers}/db/config.rs (100%) rename apps/container/src/{ => containers}/db/postgresql.rs (91%) rename apps/container/src/{ => containers}/db/sqlite.rs (95%) delete mode 100644 apps/container/src/types.rs diff --git a/apps/cli/src/cmd/db_migrate_and_generate.rs b/apps/cli/src/cmd/db_migrate_and_generate.rs index a563431..79865c9 100644 --- a/apps/cli/src/cmd/db_migrate_and_generate.rs +++ b/apps/cli/src/cmd/db_migrate_and_generate.rs @@ -1,7 +1,7 @@ use clap::{Arg, Command}; -use container::{ +use container::containers::{ + ConfigInfoType, db::{DBInfo, sqlite::SQLiteContainer}, - types::ConfigInfoType, }; use migration::{generate_entity, migrate_database}; use shared::db_type::DBType; diff --git a/apps/container/src/containers.rs b/apps/container/src/containers.rs new file mode 100644 index 0000000..3552467 --- /dev/null +++ b/apps/container/src/containers.rs @@ -0,0 +1,40 @@ +pub mod agent; +pub mod db; + +use std::{pin::Pin, sync::Arc}; + +use testcontainers::{ContainerAsync, GenericImage, TestcontainersError}; + +use crate::containers::{ + agent::AgentContainerInfo, + db::{ContainerizedDBInfo, PreExistingDBInfo}, +}; + +pub type UnStartedContainer = + Pin, TestcontainersError>> + Send>>; + +pub type AgentConfigInfoType = ConfigInfoType; + +pub type DBConfigInfoType = ConfigInfoType; + +pub trait WithContainer { + fn container(&self) -> &Arc>; +} + +pub trait WithoutContainer { + fn on_delete(&self); +} + +impl WithoutContainer for () { + fn on_delete(&self) {} +} + +#[derive(Clone)] +pub enum ConfigInfoType +where + T: WithContainer, + U: WithoutContainer, +{ + Containerized(T), + PreExisting(U), +} diff --git a/apps/container/src/agent.rs b/apps/container/src/containers/agent.rs similarity index 95% rename from apps/container/src/agent.rs rename to apps/container/src/containers/agent.rs index ee08852..ebe2743 100644 --- a/apps/container/src/agent.rs +++ b/apps/container/src/containers/agent.rs @@ -5,10 +5,7 @@ use testcontainers::{ runners::{AsyncBuilder, AsyncRunner}, }; -use crate::{ - db::UnStartedContainer, - types::{ConfigInfoType, WithContainer}, -}; +use crate::{WithContainer, containers::UnStartedContainer}; pub const SOCK_NAME: &str = "yanpm-agent.sock"; const SOCK_FOLDER: &str = "/var/run/yanpm"; @@ -25,8 +22,6 @@ pub struct AgentContainerConfig { pub nginx_config: NginxConfig, } -pub type AgentConfigInfoType = ConfigInfoType; - #[derive(Clone)] pub struct AgentContainerInfo { pub container: Arc>, diff --git a/apps/container/src/db.rs b/apps/container/src/containers/db.rs similarity index 73% rename from apps/container/src/db.rs rename to apps/container/src/containers/db.rs index 11f7505..dcd61f0 100644 --- a/apps/container/src/db.rs +++ b/apps/container/src/containers/db.rs @@ -5,18 +5,15 @@ pub mod sqlite; use async_trait::async_trait; use shared::db_type::DBType; use std::error::Error; -use std::future::Future; -use std::{pin::Pin, sync::Arc}; +use std::sync::Arc; use url::Host; -use testcontainers::{ContainerAsync, GenericImage, TestcontainersError}; +use testcontainers::{ContainerAsync, GenericImage}; -use crate::{ConfigInfoType, WithContainer, WithoutContainer}; - -pub type UnStartedContainer = - Pin, TestcontainersError>> + Send>>; - -pub type DBConfigInfoType = ConfigInfoType; +use crate::{ + WithContainer, WithoutContainer, + containers::{DBConfigInfoType, UnStartedContainer}, +}; #[derive(Clone)] pub struct PreExistingDBInfo { diff --git a/apps/container/src/db/config.rs b/apps/container/src/containers/db/config.rs similarity index 100% rename from apps/container/src/db/config.rs rename to apps/container/src/containers/db/config.rs diff --git a/apps/container/src/db/postgresql.rs b/apps/container/src/containers/db/postgresql.rs similarity index 91% rename from apps/container/src/db/postgresql.rs rename to apps/container/src/containers/db/postgresql.rs index 5c0cec2..813450c 100644 --- a/apps/container/src/db/postgresql.rs +++ b/apps/container/src/containers/db/postgresql.rs @@ -9,9 +9,12 @@ use testcontainers::{ use crate::{ ConfigInfoType, - db::{ - ContainerizedDBInfo, DBConfigInfoType, DBInfo, UnStartedContainer, - config::{DatabaseContainerConfig, OptionalContainerConfig}, + containers::{ + UnStartedContainer, + db::{ + ContainerizedDBInfo, DBConfigInfoType, DBInfo, + config::{DatabaseContainerConfig, OptionalContainerConfig}, + }, }, }; @@ -53,7 +56,7 @@ impl DBInfo for PostgreSQLContainer { ); ConfigInfoType::Containerized(ContainerizedDBInfo { - db_type: crate::db::DBType::PostgreSQL, + db_type: crate::containers::db::DBType::PostgreSQL, container: Arc::new(pg_container), container_name: self.config.container_name.clone(), database_name: self.config.database_name.clone(), diff --git a/apps/container/src/db/sqlite.rs b/apps/container/src/containers/db/sqlite.rs similarity index 95% rename from apps/container/src/db/sqlite.rs rename to apps/container/src/containers/db/sqlite.rs index cf0dd40..ef0f579 100644 --- a/apps/container/src/db/sqlite.rs +++ b/apps/container/src/containers/db/sqlite.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use crate::{ ConfigInfoType, - db::{DBConfigInfoType, DBInfo, PreExistingDBInfo, UnStartedContainer}, + containers::db::{DBConfigInfoType, DBInfo, PreExistingDBInfo, UnStartedContainer}, util::to_absolute_path, }; @@ -69,7 +69,7 @@ impl DBInfo for SQLiteContainer { .expect("Failed to create SQLite database file"); // ConfigInfoType::PreExisting(PreExistingDBInfo { - db_type: crate::db::DBType::SQLite, + db_type: crate::containers::db::DBType::SQLite, url: sqlite_url, on_delete: { let db_path = self.get_db_absolute_path(); diff --git a/apps/container/src/env.rs b/apps/container/src/env.rs index 0c2200b..58710cd 100644 --- a/apps/container/src/env.rs +++ b/apps/container/src/env.rs @@ -1,7 +1,5 @@ use std::io::Write; -use shared::db_type::DBType; - #[derive(Clone, Copy)] pub enum EnvFileType { DotEnv, @@ -11,25 +9,16 @@ pub enum EnvFileType { #[derive(Clone)] pub struct EnvFile { pub file_type: EnvFileType, - pub db_type: DBType, - pub db_url: String, // buffer: serde_json::Value, } impl EnvFile { - pub fn new(file_type: EnvFileType, db_type: DBType, db_url: String) -> Self { - let mut env_file = EnvFile { + pub fn new(file_type: EnvFileType) -> Self { + EnvFile { file_type, - db_type, - db_url, buffer: serde_json::Value::Object(serde_json::Map::new()), - }; - - env_file._write_line_buffer("DATABASE__TYPE", &env_file.db_type.to_string()); - env_file._write_line_buffer("DATABASE__URL", &env_file.db_url.to_string()); - - env_file + } } pub fn write_line(&mut self, key: &str, value: &str) { @@ -131,12 +120,10 @@ mod tests { #[test] fn test_env_file_write_yaml() { - let mut env_file_nested = EnvFile::new( - EnvFileType::Yaml, - DBType::SQLite, - "mysql://user:pass@localhost/db".to_string(), - ); + let mut env_file_nested = EnvFile::new(EnvFileType::Yaml); + env_file_nested.write_line("DATABASE__TYPE", "SQLite"); + env_file_nested.write_line("DATABASE__URL", "mysql://user:pass@localhost/db"); let mut output_stream = Vec::new(); env_file_nested.write(&mut output_stream, false); let output_string = String::from_utf8(output_stream).unwrap(); @@ -150,11 +137,9 @@ DATABASE: #[test] fn test_env_file_write_env() { - let mut env_file_nested = EnvFile::new( - EnvFileType::DotEnv, - DBType::PostgreSQL, - "postgres://user:pass@localhost/db".to_string(), - ); + let mut env_file_nested = EnvFile::new(EnvFileType::DotEnv); + env_file_nested.write_line("DATABASE__TYPE", "PostgreSQL"); + env_file_nested.write_line("DATABASE__URL", "postgres://user:pass@localhost/db"); let mut output_stream = Vec::new(); env_file_nested.write(&mut output_stream, true); let output_string = String::from_utf8(output_stream).unwrap(); diff --git a/apps/container/src/lib.rs b/apps/container/src/lib.rs index bd2fee4..7f651e8 100644 --- a/apps/container/src/lib.rs +++ b/apps/container/src/lib.rs @@ -1,13 +1,11 @@ -pub mod agent; -pub mod db; +pub mod containers; mod env; -pub mod types; mod util; use crate::{ - agent::AgentConfigInfoType, - db::DBConfigInfoType, - types::{ConfigInfoType, WithContainer, WithoutContainer}, + containers::{ + AgentConfigInfoType, ConfigInfoType, DBConfigInfoType, WithContainer, WithoutContainer, + }, util::{ await_termination_signal, remove_file_if_exists, stop_container, to_absolute_path, write_env_files, diff --git a/apps/container/src/main.rs b/apps/container/src/main.rs index cffd7c2..9cb6b2c 100644 --- a/apps/container/src/main.rs +++ b/apps/container/src/main.rs @@ -1,12 +1,15 @@ use std::sync::Arc; use clap::Parser; -use container::agent::{AgentConfig, AgentContainerConfig, AgentContainerInfo, NginxConfig}; -use container::start_attached; -use container::types::ConfigInfoType; -use container::{Config, agent}; - -use container::db::DBInfo; +use container::{ + Config, + containers::{ + ConfigInfoType, + agent::{AgentConfig, AgentContainerConfig, AgentContainerInfo, NginxConfig}, + db::DBInfo, + }, + start_attached, +}; /// Command line arguments #[derive(Parser, Debug)] @@ -65,7 +68,7 @@ async fn main() { println!("Starting container with database type: {}", args.db_type); let db_config = match args.db_type.to_lowercase().as_str() { "postgres" | "pg" | "pgsql" => { - use container::db::postgresql::PostgreSQLContainer; + use container::containers::db::postgresql::PostgreSQLContainer; println!("Using PostgreSQL database"); PostgreSQLContainer::new(None) .await @@ -74,7 +77,7 @@ async fn main() { } "sqlite" | "sql" => { println!("Using SQLite database"); - use container::db::sqlite::SQLiteContainer; + use container::containers::db::sqlite::SQLiteContainer; SQLiteContainer::new(None) .await .get_db_container_config_info() @@ -89,7 +92,7 @@ async fn main() { let agent_container = if let Some(agent_config) = &args.agent_container_config { println!( - "Agent container will be used with socket path: {} and nginx config dir: {}", + "Agent container will be used with socket folder: {} and nginx config dir: {}", agent_config.agent_config.sock_folder, agent_config.agent_config.nginx_config_dir ); Some(agent_config.get_unstarted_container().await) @@ -168,6 +171,7 @@ async fn parse_args() -> ParsedArgs { ParsedArgs { db_type: args.db_type, agent_container_config: Some(AgentContainerConfig { + // TODO: allow customization of these fields via CLI args image: "yanpm-agent".to_string(), tag: "latest".to_string(), container_name: format!("yanpm-agent-container-{}", time), diff --git a/apps/container/src/types.rs b/apps/container/src/types.rs deleted file mode 100644 index e43e702..0000000 --- a/apps/container/src/types.rs +++ /dev/null @@ -1,25 +0,0 @@ -use std::sync::Arc; - -use testcontainers::{ContainerAsync, GenericImage}; - -pub trait WithContainer { - fn container(&self) -> &Arc>; -} - -pub trait WithoutContainer { - fn on_delete(&self); -} - -impl WithoutContainer for () { - fn on_delete(&self) {} -} - -#[derive(Clone)] -pub enum ConfigInfoType -where - T: WithContainer, - U: WithoutContainer, -{ - Containerized(T), - PreExisting(U), -} diff --git a/apps/container/src/util.rs b/apps/container/src/util.rs index c80fada..200ea51 100644 --- a/apps/container/src/util.rs +++ b/apps/container/src/util.rs @@ -4,10 +4,11 @@ use tokio::signal::unix::{SignalKind, signal}; use crate::{ API_CONFIG_PATH, DB_CONFIG_PATH, - agent::{AgentConfigInfoType, AgentContainerInfo, SOCK_NAME}, - db::DBConfigInfoType, + containers::{ + AgentConfigInfoType, ConfigInfoType, DBConfigInfoType, WithContainer, WithoutContainer, + agent::SOCK_NAME, + }, env::{self, EnvFile}, - types::{ConfigInfoType, WithContainer, WithoutContainer}, }; // relative to the current working directory @@ -30,7 +31,10 @@ pub fn write_env_files(db_config: &DBConfigInfoType, agent_config: &Option (config.db_type.clone(), config.url.clone()), }; - let mut api_env = EnvFile::new(env::EnvFileType::Yaml, db_type, db_url); + let mut api_env = EnvFile::new(env::EnvFileType::Yaml); + api_env.write_line("DATABASE__TYPE", db_type.to_string().as_str()); + api_env.write_line("DATABASE__URL", db_url.as_str()); + let mut db_env = api_env.clone(); db_env.file_type = env::EnvFileType::DotEnv; From 5cffb0a51932b0eba645a3a2e1903305d30d9cc5 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 22 Dec 2025 17:18:36 +0800 Subject: [PATCH 07/10] feat: add nginx reload and validation wrappers with sudo permissions --- .../s6/cont-init.d/20-install-reload-wrapper | 170 ++++++++++++++++++ apps/agent/justfile | 2 + apps/agent/src/commands/reload.rs | 13 +- apps/agent/src/commands/validate.rs | 133 +++++++++++++- apps/agent/src/commands/write_config.rs | 4 +- apps/agent/src/routes.rs | 4 +- apps/container/src/main.rs | 10 +- 7 files changed, 323 insertions(+), 13 deletions(-) create mode 100644 apps/agent/docker/s6/cont-init.d/20-install-reload-wrapper create mode 100644 apps/agent/justfile diff --git a/apps/agent/docker/s6/cont-init.d/20-install-reload-wrapper b/apps/agent/docker/s6/cont-init.d/20-install-reload-wrapper new file mode 100644 index 0000000..dff28f0 --- /dev/null +++ b/apps/agent/docker/s6/cont-init.d/20-install-reload-wrapper @@ -0,0 +1,170 @@ +#!/bin/sh +set -eu + +# This init script installs a minimal nginx reload wrapper and a sudoers +# entry so the `yanpm-agent` user can perform a controlled reload via sudo. + +WRAPPER_PATH="/usr/local/sbin/yanpm-nginx-reload" +SUDOERS_PATH="/etc/sudoers.d/yanpm-agent" +AGENT_USER="${YANPM_AGENT_USER:-yanpm-agent}" + +# validate wrapper +VALIDATE_PATH="/usr/local/sbin/yanpm-nginx-validate" +# validate file wrapper +VALIDATE_FILE_PATH="/usr/local/sbin/yanpm-nginx-validate-file" + +echo "[cont-init.d] install-reload-wrapper: setting up nginx reload helper" + +# find nginx binary +NGINX_BIN="$(command -v nginx || true)" +if [ -z "${NGINX_BIN}" ]; then + echo "Warning: nginx binary not found in PATH; wrapper will still be created but may fail at runtime." >&2 + NGINX_BIN="/usr/sbin/nginx" +fi + +# Create wrapper +mkdir -p /usr/local/sbin /etc/sudoers.d + +cat > "${WRAPPER_PATH}" <<- 'EOF' +#!/bin/sh +exec "@NGINX_BIN@" -c /etc/nginx/nginx.conf -s reload +EOF + +# Replace placeholder with actual path +sed -i "s|@NGINX_BIN@|${NGINX_BIN}|g" "${WRAPPER_PATH}" || true + +chmod 0750 "${WRAPPER_PATH}" +chown root:root "${WRAPPER_PATH}" || true + +# +# +# + +# Create validate wrapper +cat > "${VALIDATE_PATH}" <<- 'EOF' +#!/bin/sh +exec "@NGINX_BIN@" -c /etc/nginx/nginx.conf -t +EOF + +# Replace placeholder with actual path in validate wrapper +sed -i "s|@NGINX_BIN@|${NGINX_BIN}|g" "${VALIDATE_PATH}" || true + +chmod 0750 "${VALIDATE_PATH}" +chown root:root "${VALIDATE_PATH}" || true + +# +# +# + +# Create validate file wrapper (secure) +cat > "${VALIDATE_FILE_PATH}" <<-'EOF' +#!/bin/sh +set -eu + +if [ $# -ne 1 ]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +INPUT="$1" + +# Resolve absolute path +if command -v readlink >/dev/null 2>&1; then + TARGET="$(readlink -f -- "$INPUT" 2>/dev/null || true)" +elif command -v realpath >/dev/null 2>&1; then + TARGET="$(realpath -- "$INPUT" 2>/dev/null || true)" +else + echo "Error: no path resolver (readlink/realpath) available" >&2 + exit 3 +fi + +if [ -z "$TARGET" ]; then + echo "Error: cannot resolve path: $INPUT" >&2 + exit 4 +fi + +# Must be a regular file and not a symlink +if [ ! -f "$TARGET" ] || [ -L "$TARGET" ]; then + echo "Error: ${TARGET} is not a regular file" >&2 + exit 5 +fi + +# must be created by agent user +AGENT_UID="$(id -u yanpm-agent 2>/dev/null || true)" +if [ -z "$AGENT_UID" ]; then + echo "Error: yanpm-agent user not found" >&2 + exit 6 +fi + +FILE_UID="$(stat -c %u -- "$TARGET" 2>/dev/null || true)" +if [ "$FILE_UID" != "$AGENT_UID" ]; then + echo "Error: ${TARGET} not owned by yanpm-agent user" >&2 + exit 7 +fi + +# Ensure file is not world-writable; allow typical 664 (rw-rw-r--) +if command -v stat >/dev/null 2>&1; then + MODE="$(stat -c %a -- "$TARGET" 2>/dev/null || true)" + if [ -n "$MODE" ]; then + OTHERS=$(( MODE % 10 )) + if [ $(( OTHERS & 2 )) -ne 0 ]; then + echo "Error: ${TARGET} is world-writable" >&2 + exit 8 + fi + fi +elif command -v find >/dev/null 2>&1; then + if find "$TARGET" -maxdepth 0 -perm /002 -print -quit >/dev/null 2>&1; then + echo "Error: ${TARGET} is world-writable" >&2 + exit 8 + fi +fi + +exec "@NGINX_BIN@" -c "$TARGET" -t +EOF + +# Replace placeholder with actual path in validate file wrapper +sed -i "s|@NGINX_BIN@|${NGINX_BIN}|g" "${VALIDATE_FILE_PATH}" || true +chmod 0750 "${VALIDATE_FILE_PATH}" +chown root:root "${VALIDATE_FILE_PATH}" || true + +echo "Created wrapper: ${WRAPPER_PATH} (owned by root, mode 750)" + +# +# +# + +# Ensure sudoers entry exists allowing the agent to run only this wrapper as root +if command -v sudo >/dev/null 2>&1; then + echo "sudo present; creating sudoers entry" + cat > "${SUDOERS_PATH}" <<- EOF +# Allow ${AGENT_USER} to run the nginx reload and validate wrappers without a password +${AGENT_USER} ALL=(root) NOPASSWD: ${WRAPPER_PATH}, ${VALIDATE_PATH}, ${VALIDATE_FILE_PATH} +EOF + chmod 0440 "${SUDOERS_PATH}" || true + echo "Wrote sudoers entry: ${SUDOERS_PATH}" +else + echo "sudo not found; attempting to install" + if command -v apk >/dev/null 2>&1; then + apk add --no-cache sudo || true + elif command -v apt-get >/dev/null 2>&1; then + apt-get update || true + apt-get install -y sudo || true + elif command -v yum >/dev/null 2>&1; then + yum install -y sudo || true + else + echo "No known package manager to install sudo; please ensure sudo is available in the image." >&2 + fi + + if command -v sudo >/dev/null 2>&1; then + cat > "${SUDOERS_PATH}" <<- EOF +# Allow ${AGENT_USER} to run the nginx reload and validate wrappers without a password +${AGENT_USER} ALL=(root) NOPASSWD: ${WRAPPER_PATH}, ${VALIDATE_PATH}, ${VALIDATE_FILE_PATH} +EOF + chmod 0440 "${SUDOERS_PATH}" || true + echo "Installed sudo and wrote sudoers entry: ${SUDOERS_PATH}" + else + echo "Failed to install sudo; the agent will not be able to reload nginx via sudo." >&2 + fi +fi + +exit 0 diff --git a/apps/agent/justfile b/apps/agent/justfile new file mode 100644 index 0000000..9a11a0e --- /dev/null +++ b/apps/agent/justfile @@ -0,0 +1,2 @@ +build-docker: + docker build -t yanpm/agent:latest . \ No newline at end of file diff --git a/apps/agent/src/commands/reload.rs b/apps/agent/src/commands/reload.rs index 601c6a3..9d0976f 100644 --- a/apps/agent/src/commands/reload.rs +++ b/apps/agent/src/commands/reload.rs @@ -93,6 +93,17 @@ impl ReloadCommand { } // reload the running nginx master process (no -c) so it reloads its configured main config - run_cmd("nginx", &["-s", "reload"], 10).await + // Prefer the restricted sudo wrapper if available, fall back to direct nginx reload. + // TODO: allow configuring the path to the wrapper + match run_cmd("sudo", &["-n", "/usr/local/sbin/yanpm-nginx-reload"], 10).await { + Ok(res) => Ok(res), + Err(e) => { + error!( + "sudo reload wrapper failed, falling back to direct nginx reload: {}", + e + ); + run_cmd("nginx", &["-s", "reload"], 10).await + } + } } } diff --git a/apps/agent/src/commands/validate.rs b/apps/agent/src/commands/validate.rs index ed02987..eac8f46 100644 --- a/apps/agent/src/commands/validate.rs +++ b/apps/agent/src/commands/validate.rs @@ -1,3 +1,5 @@ +use tracing::{error, info, warn}; + use crate::commands::{run::run_cmd, write_config::INTERNAL_CONFIG_FOLDER_NAME}; use std::path::PathBuf; @@ -17,7 +19,51 @@ impl ValidateCommand { pub async fn validate_all( &self, ) -> Result<(i32, String), Box> { - run_cmd("nginx", &["-t"], 10).await + // Try a normal config test first. If it fails due to pid permission + // errors (common when running unprivileged against /run/nginx.pid), + // retry with a writable pid override so validation can succeed. + match run_cmd("nginx", &["-t"], 10).await { + Ok(res) => Ok(res), + Err(e) => { + info!( + "nginx -t failed: {}. Trying with privileged wrapper or writable pid override.", + e + ); + let es = e.to_string(); + if es.contains("/run/nginx.pid") && es.contains("Permission denied") { + // Try privileged validate wrapper if available (allows the agent to run + // nginx -t via sudo without modifying the main config). + match run_cmd( + "sudo", + // TODO: allow configuring the path to the wrapper + &["-n", "/usr/local/sbin/yanpm-nginx-validate"], + 10, + ) + .await + { + Ok(res) => return Ok(res), + Err(e) => { + warn!( + "Privileged validate wrapper failed: {}. Falling back to writable pid override.", + e + ); + // Fallback to the existing writable-pid override if sudo wrapper + // isn't available or fails. + let pid_path = format!( + "{}/yanpm-validate-{}.pid", + std::env::temp_dir().display(), + std::process::id() + ); + let g_arg = format!("pid {};", pid_path); + let args_vec = ["-t".to_string(), "-g".to_string(), g_arg]; + let args_ref: Vec<&str> = args_vec.iter().map(|s| s.as_str()).collect(); + return run_cmd("nginx", args_ref.as_slice(), 10).await; + } + } + } + Err(e) + } + } } pub async fn validate( @@ -37,11 +83,84 @@ impl ValidateCommand { return Err(format!("Config file not found: {}", full_path.display()).into()); } - run_cmd( - "nginx", - &["-t", "-c", full_path.to_str().ok_or("invalid config path")?], - 10, - ) - .await + // Create a temporary wrapper nginx config that provides the required + // top-level sections (`events` and `http`) and includes the fragment. + let fragment_path = full_path.to_str().ok_or("invalid config path")?.to_string(); + + let mut tmp_path = std::env::temp_dir(); + let tmp_name = format!("yanpm-validate-{}-{}.conf", timestamp, std::process::id()); + tmp_path.push(tmp_name); + + let wrapper = format!( + "worker_processes 1;\nevents {{ worker_connections 1024; }}\nhttp {{\n include {};\n}}\n", + fragment_path + ); + + // Write the temporary wrapper file + tokio::fs::write(&tmp_path, wrapper).await?; + let tmp_path_str = tmp_path + .to_str() + .ok_or("invalid temp config path")? + .to_string(); + + // Run the test against the wrapper, telling nginx to place its pid + // somewhere writable so the config test doesn't fail with permission + // errors when running as an unprivileged user. + let result = match run_cmd("nginx", &["-t", "-c", &tmp_path_str], 10).await { + Ok(res) => Ok(res), + Err(e) => { + info!( + "nginx -t failed: {}. Trying with privileged wrapper or writable pid override.", + e + ); + let es = e.to_string(); + if es.contains("/run/nginx.pid") && es.contains("Permission denied") { + // Try privileged validate wrapper if available (allows the agent to run + // nginx -t via sudo without modifying the main config). + match run_cmd( + "sudo", + // TODO: allow configuring the path to the wrapper + &[ + "-n", + "/usr/local/sbin/yanpm-nginx-validate-file", + &tmp_path_str, + ], + 10, + ) + .await + { + Ok(res) => return Ok(res), + Err(e) => { + warn!( + "Privileged validate wrapper failed: {}. Falling back to writable pid override.", + e + ); + let pid_path = format!( + "{}/yanpm-validate-{}.pid", + std::env::temp_dir().display(), + std::process::id() + ); + let g_arg = format!("pid {};", pid_path); + + let args_vec = [ + "-t".to_string(), + "-c".to_string(), + tmp_path_str.clone(), + "-g".to_string(), + g_arg, + ]; + let args_ref: Vec<&str> = args_vec.iter().map(|s| s.as_str()).collect(); + + return run_cmd("nginx", args_ref.as_slice(), 10).await; + } + } + } + Err(e) + } + }; + + let _ = tokio::fs::remove_file(&tmp_path).await; + + result } } diff --git a/apps/agent/src/commands/write_config.rs b/apps/agent/src/commands/write_config.rs index 350a4e7..30c7243 100644 --- a/apps/agent/src/commands/write_config.rs +++ b/apps/agent/src/commands/write_config.rs @@ -2,6 +2,7 @@ use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use tokio::io::AsyncWriteExt; +use tracing::info; use crate::commands::run::to_file_name; @@ -26,6 +27,7 @@ impl WriteConfigCommand { let path = self.nginx_config_dir.clone(); // ensure main config dir exists tokio::fs::create_dir_all(&path).await?; + info!("Writing config to {:?}", path.join(&filename)); // create YANPM subdir where fragment files live let yanpm_dir = path.join(INTERNAL_CONFIG_FOLDER_NAME); @@ -62,7 +64,7 @@ impl WriteConfigCommand { // set explicit permissions (rw-r-----) tokio::fs::set_permissions(&final_path, std::fs::Permissions::from_mode(0o640)).await?; - + info!("Config written and permissions set for {:?}", final_path); Ok(()) } } diff --git a/apps/agent/src/routes.rs b/apps/agent/src/routes.rs index 77c8733..2577b1b 100644 --- a/apps/agent/src/routes.rs +++ b/apps/agent/src/routes.rs @@ -43,7 +43,7 @@ pub async fn validate( } }; - let (_code, _output) = match nginx_controller + let resp = match nginx_controller .validate(¶ms.config_name, params.timestamp) .await { @@ -54,7 +54,7 @@ pub async fn validate( } }; - (axum::http::StatusCode::OK,).into_response() + (axum::http::StatusCode::OK, axum::Json(resp)).into_response() } #[derive(Deserialize)] diff --git a/apps/container/src/main.rs b/apps/container/src/main.rs index 9cb6b2c..e75ef54 100644 --- a/apps/container/src/main.rs +++ b/apps/container/src/main.rs @@ -20,6 +20,12 @@ struct Args { db_type: String, // agent related + /// agent image name + #[arg(long, default_value = "yanpm/agent", env = "AGENT_IMAGE_NAME")] + agent_image: String, + /// agent image tag + #[arg(long, default_value = "latest", env = "AGENT_IMAGE_TAG")] + agent_image_tag: String, /// force build agent image #[arg(long, default_value_t = false, env = "AGENT_FORCE_BUILD")] agent_force_build: bool, @@ -172,8 +178,8 @@ async fn parse_args() -> ParsedArgs { db_type: args.db_type, agent_container_config: Some(AgentContainerConfig { // TODO: allow customization of these fields via CLI args - image: "yanpm-agent".to_string(), - tag: "latest".to_string(), + image: args.agent_image, + tag: args.agent_image_tag, container_name: format!("yanpm-agent-container-{}", time), dockerfile_path, force_build: args.agent_force_build, From dce820332254b07c50bdaecb080ec94f1a6b40b0 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 22 Dec 2025 17:56:18 +0800 Subject: [PATCH 08/10] feat: add comprehensive documentation for yanpm-agent, including API reference, configuration, deployment, usage examples, and troubleshooting --- apps/agent/doc/README.md | 19 +++++++++ apps/agent/doc/api.md | 68 +++++++++++++++++++++++++++++++ apps/agent/doc/architecture.md | 34 ++++++++++++++++ apps/agent/doc/configuration.md | 27 ++++++++++++ apps/agent/doc/deployment.md | 62 ++++++++++++++++++++++++++++ apps/agent/doc/troubleshooting.md | 27 ++++++++++++ apps/agent/doc/usage.md | 61 +++++++++++++++++++++++++++ 7 files changed, 298 insertions(+) create mode 100644 apps/agent/doc/README.md create mode 100644 apps/agent/doc/api.md create mode 100644 apps/agent/doc/architecture.md create mode 100644 apps/agent/doc/configuration.md create mode 100644 apps/agent/doc/deployment.md create mode 100644 apps/agent/doc/troubleshooting.md create mode 100644 apps/agent/doc/usage.md diff --git a/apps/agent/doc/README.md b/apps/agent/doc/README.md new file mode 100644 index 0000000..af59131 --- /dev/null +++ b/apps/agent/doc/README.md @@ -0,0 +1,19 @@ +# yanpm-agent Documentation + +This directory contains in-depth documentation for the yanpm agent daemon (the binary built from `apps/agent`). The agent exposes a unix-socket HTTP API for writing nginx configuration fragments, validating them, and reloading nginx safely. + +Docs included: + +- `architecture.md` — Detailed explanation of the program flow and components. +- `configuration.md` — CLI flags, environment variables, defaults, and permission handling. +- `usage.md` — How to run the agent, curl examples, and systemd/docker hints. +- `api.md` — HTTP API endpoints, request and response schemas, examples. +- `deployment.md` — Deployment considerations, permissions, and systemd socket/unit examples. +- `troubleshooting.md` — Common errors and solutions. + +For implementation details, see the source in `apps/agent/src` (notably `main.rs`, `routes.rs`, and the `commands/` submodule). + +Integration notes + +- The agent is intended to run as a companion agent for the API service in `apps/api`. The API service calls the agent over the unix-domain socket to write nginx fragments, validate them, and trigger reloads. +- A production Docker image is provided by `apps/agent/Dockerfile`. That Dockerfile packages nginx + the `yanpm-agent` binary and s6-overlay service scripts so a single container can run nginx and the agent alongside each other. diff --git a/apps/agent/doc/api.md b/apps/agent/doc/api.md new file mode 100644 index 0000000..b06270e --- /dev/null +++ b/apps/agent/doc/api.md @@ -0,0 +1,68 @@ +# HTTP API Reference + +Base: HTTP over a unix-domain socket. Example using curl: `curl --unix-socket /path/to/socket -X POST http://localhost/` + +1) GET /status + + - Response: 200 OK + - Body: JSON `{ "ok": true }` + +2) POST /validate + + - Request JSON: + + ```json + { + "config_name": "example", + "timestamp": 1234567890 + } + ``` + + - Behavior: validates the fragment file named by `config_name` and `timestamp` under the agent's internal subdirectory inside the configured nginx config directory. Delegates to `ValidateCommand::validate`. + - Success: 200 OK, body is `[rc, output]` tuple serialized as JSON (actual shape is `(i32, String)` returned from the command; examine responses for exact formatting). + - Error cases: + - 400 Bad Request: invalid or malformed JSON + - 500 Internal Server Error: validation error or missing fragment file + + - Request JSON: + + ```json + { + "config_name": "example", + "timestamp": 1234567890 + } + ``` + + - Behavior: validates the fragment file named by `config_name` and `timestamp` under the agent's internal subdirectory inside the configured nginx config directory. Delegates to `ValidateCommand::validate`. + - Success: 200 OK, body is a JSON array `[rc, output]` where `rc` is the integer return code and `output` is the combined stdout/stderr string from the validation command (the command returns an `(i32, String)` tuple). + - Error cases: + - 400 Bad Request: invalid or malformed JSON + - 500 Internal Server Error: validation error or missing fragment file + +3) POST /validate_and_reload + + - Request JSON same as `/validate`. + - Behavior: runs validation and, on success, attempts to reload nginx. Returns an object with `rc` and `ro` (return code and combined stdout/stderr output). + - Success: 200 OK with body: `{ "rc": , "ro": "" }` + - Errors: 400 for malformed JSON, 500 if the validate-and-reload command fails (body presents error text). + +4) POST /write_config + + - Request JSON: + + ```json + { + "config_name": "example", + "timestamp": 1234567890, + "content": "server { ... }" + } + ``` + + - Behavior: writes the provided `content` into an agent-managed fragment file named from `config_name` and `timestamp` in the internal subdirectory under `nginx_config_dir`. + - Success: 200 OK with empty body + - Error: 400 for malformed JSON, 500 if writing the file fails + +Notes + +- The agent expects callers to choose a `config_name` and `timestamp` that together form a unique filename. The concrete filename encoding is performed by `commands::run::to_file_name` in source. +- On validation failures the returned output often contains the full `nginx -t` output; inspect `ro` or the returned JSON error messages. diff --git a/apps/agent/doc/architecture.md b/apps/agent/doc/architecture.md new file mode 100644 index 0000000..5b5b2bd --- /dev/null +++ b/apps/agent/doc/architecture.md @@ -0,0 +1,34 @@ +# Architecture and Runtime Flow + +Overview + +- The agent is an async HTTP server (axum) listening on a Unix domain socket and exposes a small JSON API to manage nginx configuration fragments. +- Core lifecycle is implemented in `apps/agent/src/main.rs`: + - parse CLI args and environment variables + - ensure the socket path and directory exist and have permissive but secure defaults + - bind a `tokio::net::UnixListener` to the socket + - create an `NginxService` (shared state) and an in-process cron `JobScheduler` + - mount axum routes (`/status`, `/validate`, `/validate_and_reload`, `/write_config`) and serve HTTP over the Unix socket + +Key components + +- `main.rs` — Bootstrapping, argument handling, socket setup and permission handling, scheduler start, and axum server startup. +- `routes.rs` — axum handlers for the HTTP API. It deserializes JSON payloads and delegates to `NginxService` methods. Handlers return appropriate HTTP status codes and JSON on error or success. +- `commands/` — Implementation of lower-level actions (writing fragment files, running `nginx -t`, validating, reloads). The `validate.rs` command contains sophisticated behavior to handle permission-limited environments by: + - creating wrapper nginx configs that include a single fragment + - trying `nginx -t` directly, attempting a privileged wrapper via `sudo` if available, and finally passing a writable PID override via `-g pid ...;` to avoid permission failures + +Concurrency and state + +- A single shared `NginxService` instance is stored in axum `State` and cloned into handlers; it holds the scheduler and the configured nginx config directory path. +- The JobScheduler is created with `tokio_cron_scheduler::JobScheduler` and started before serving requests. + +Error handling and best-effort behavior + +- Socket permission changes, GID changes, and directory creations are best-effort and log warnings on failure rather than failing hard. +- Most command failures are converted into JSON errors with appropriate HTTP status codes so callers can inspect command output. + +Integration and packaging + +- The agent is intended to run as a companion to the API server in `apps/api`. The API calls the agent over the unix socket to write fragments, validate them, and trigger reloads. +- `apps/agent/Dockerfile` builds a runtime image that includes `nginx` and the `yanpm-agent` binary (the Dockerfile uses s6-overlay to run multiple services). This image is suitable for deployments that prefer nginx and the agent colocated in a single container. diff --git a/apps/agent/doc/configuration.md b/apps/agent/doc/configuration.md new file mode 100644 index 0000000..d19be57 --- /dev/null +++ b/apps/agent/doc/configuration.md @@ -0,0 +1,27 @@ +# Configuration and Environment + +CLI flags and environment variables + +- `--sock` / `YANPM_AGENT_SOCK` (default: `./yanpm-agent.sock`) + - Path to the Unix socket file the agent will bind to. + - If the socket directory does not exist the agent attempts to create it and set mode `0770`. + +- `--nginx-config-dir` / `YANPM_NGINX_CONFIG_DIR` (default: `/etc/nginx/conf.d`) + - Directory where nginx fragments are written. The agent writes fragments into a subdirectory named by the agent (internal use). + +- `--sock-perm` / `YANPM_AGENT_SOCK_PERM` (default: `660`) + - A 3-digit octal permission string applied to the socket file (best-effort). The program validates this is a 3-digit octal string. + - If the final digit is greater than `0` a warning is logged because that allows "others" access. + +- `--sock-gid` / `YANPM_AGENT_SOCK_GID` (default: current user's primary group) + - GID to set on the socket file (best-effort). + +Validation rules and behavior + +- `sock_perm` must be exactly 3 octal digits (characters 0-7). The agent rejects invalid values at startup. +- When an existing path exists at the socket location the agent verifies it is a unix socket; if so it removes it before binding. If the path exists and is not a socket, startup fails. +- Setting permissions (`set_permissions`) and changing GID (`chown`) are attempted but non-fatal: failures are logged as warnings and the agent continues. + +Notes about nginx config directory + +- The agent writes fragments into a subdirectory (internal) of the configured `nginx_config_dir`. Ensure nginx is configured to include that subdirectory so fragments are picked up, or use `write_config` then trigger a reload. diff --git a/apps/agent/doc/deployment.md b/apps/agent/doc/deployment.md new file mode 100644 index 0000000..9c707db --- /dev/null +++ b/apps/agent/doc/deployment.md @@ -0,0 +1,62 @@ +# Deployment and Permissions + +Socket location and permissions + +- The agent binds a unix socket at the path given by `--sock` or `YANPM_AGENT_SOCK`. The agent will: + - create the parent directory (best-effort) and attempt to set its permissions to `0770` + - remove an existing socket file if it is a socket, or fail if the path exists and is not a socket + - apply the `sock_perm` (3-digit octal) to the socket file and optionally change its GID to `sock_gid` + +Systemd socket/unit example + +Create a `yanpm-agent.socket` unit that creates and owns the unix socket, and a `yanpm-agent.service` that runs the agent. Ensure the socket path used by systemd matches `--sock`. + +Docker / container notes + +- If running the agent inside a container and writing to host nginx config, bind-mount the host nginx config directory into the container at the path provided to `--nginx-config-dir`. +- Consider running the agent as a user with permission to write the nginx config directory or use a shared group and `sock_gid` so clients can access the socket. +- The repository provides a runtime image built by `apps/agent/Dockerfile` which packages `nginx` together with the `yanpm-agent` binary and s6-overlay service scripts. This image runs nginx and the agent in one container which is useful when the agent is acting as the runtime companion for the API (`apps/api`). + +Privilege escalation for validation + +- In many systems `nginx -t` may fail due to inability to access `/run/nginx.pid` or other privileged files. The agent attempts a best-effort sequence: + + 1. Run `nginx -t` directly. + 2. If that fails with permission errors, try a privileged wrapper (e.g. `/usr/local/sbin/yanpm-nginx-validate` or `yanpm-nginx-validate-file`) via `sudo -n`. + 3. If wrapper is unavailable or fails, retry `nginx -t` with a writable PID override via `-g 'pid /tmp/yanpm-validate-.pid;'`. + +Security considerations + +- Avoid setting `sock_perm` to allow world access unless explicitly intended. +- Prefer controlling socket group membership via `sock_gid` rather than making the socket world-writable. + +s6 init scripts, wrappers and sudoers (runtime) + +- Purpose: The image built by `apps/agent/Dockerfile` uses `s6-overlay` as PID 1 (the Dockerfile sets `ENTRYPOINT ["/init"]`). The repository includes `docker/s6/cont-init.d` scripts that run at container startup (one-shot) and `docker/s6/services.d` entries to run long-lived services (nginx and the agent). The cont-init scripts prepare runtime users, permissions, and helper wrappers the agent uses for privileged operations. + +- Key cont-init scripts (in the repo): + - `docker/s6/cont-init.d/10-create-app-user` — ensures the `yanpm-agent` user and group exist (honoring `YANPM_AGENT_UID`, `YANPM_AGENT_GID`, and `YANPM_AGENT_SOCK_GID`), adds the user to the `nginx` group, and attempts to chown runtime directories like `/var/run/yanpm` and `/app/yanpm-agent` (logs warnings if chown fails for bind mounts or rootless containers). + - `docker/s6/cont-init.d/20-install-reload-wrapper` — installs three helper wrappers and a sudoers entry so the `yanpm-agent` user can perform narrowly-scoped privileged operations without a password. + +- Wrapper scripts installed by `20-install-reload-wrapper`: + - `/usr/local/sbin/yanpm-nginx-reload` — runs `nginx -c /etc/nginx/nginx.conf -s reload` (used for reloading the running nginx master process). + - `/usr/local/sbin/yanpm-nginx-validate` — runs `nginx -c /etc/nginx/nginx.conf -t` (validates the main nginx config). + - `/usr/local/sbin/yanpm-nginx-validate-file` — securely validates a single nginx config file: it resolves the absolute path, ensures the target is a regular file (not a symlink), checks the file is owned by the `yanpm-agent` user, enforces it's not world-writable, then runs `nginx -c -t`. This defends against symlink and race attacks when an unprivileged agent requests privileged validation. + +- Sudoers entry: + - The init script writes `/etc/sudoers.d/yanpm-agent` with a rule allowing the configured agent user (default `yanpm-agent`) to run only the three wrappers with `NOPASSWD`. This gives the agent a limited, auditable privilege escalation surface; the agent code attempts to use these wrappers via `sudo -n` before falling back to less privileged strategies. + +- Relevant environment variables (settable in the Dockerfile or at runtime): + - `YANPM_AGENT_SOCK` — unix socket path (default set in Dockerfile: `/var/run/yanpm/yanpm-agent.sock`). + - `YANPM_NGINX_CONFIG_DIR` — nginx config dir (default `/etc/nginx/conf.d`). + - `YANPM_AGENT_SOCK_PERM` — socket permissions (octal string, default `660`). + - `YANPM_AGENT_SOCK_GID` — desired GID for the socket (optional). + - `YANPM_AGENT_UID`, `YANPM_AGENT_GID` — runtime UID/GID used to create the `yanpm-agent` user in the container. + +- How the agent uses these runtime helpers: + - `ValidateCommand` and `ReloadCommand` in the agent code try `nginx` operations directly; when permission problems occur they attempt the privileged wrappers via `sudo -n /usr/local/sbin/yanpm-nginx-validate` or `...-validate-file` and `...-reload`. The cont-init script's wrappers plus the sudoers entry implement that intended secure upgrade path. + +- Notes and recommendations: + - The `validate-file` wrapper performs ownership and permission checks; ensure written fragments are created by the `yanpm-agent` user (the agent writes files as that user when running inside the container due to `10-create-app-user`). + - The cont-init scripts attempt to install `sudo` if missing; in minimal images you may prefer providing `sudo` at build time to avoid runtime installation attempts. + - If you bind-mount host directories (e.g., `/etc/nginx/conf.d`) into the container, ensure ownership and permissions are compatible with the agent user and `YANPM_AGENT_SOCK_GID` so the socket and files are accessible as intended. diff --git a/apps/agent/doc/troubleshooting.md b/apps/agent/doc/troubleshooting.md new file mode 100644 index 0000000..444b4d2 --- /dev/null +++ b/apps/agent/doc/troubleshooting.md @@ -0,0 +1,27 @@ +# Troubleshooting + +Common issues and how to resolve them + +- Socket path exists but is not a socket + - Symptom: startup fails with an error that the socket path exists and is not a socket. + - Fix: remove the file at the socket path or choose a different `--sock` path. + +- Permission denied on socket directory or socket + - Symptom: socket creation or permission setting logs warnings; clients cannot connect. + - Fix: ensure the socket directory exists and has correct ownership/group and that `sock_perm` and `sock_gid` are configured appropriately. Consider using `chown`/`chmod` from a privileged context. + +- `nginx -t` fails with `/run/nginx.pid: Permission denied` + - Symptom: validation fails; output contains permission denied for `/run/nginx.pid`. + - Fixes (tried by the agent): + 1. If available, provide a privileged validation wrapper (e.g. `/usr/local/sbin/yanpm-nginx-validate`) that runs `nginx -t` with appropriate privileges. + 2. Ensure the agent-runner has permission to read the main nginx configuration and `/run/nginx.pid` or allow the agent to use a writable PID override. + +- Fragment file not found during validation + - Symptom: validate returns 500 with message `Config file not found`. + - Fix: make sure the fragment has been written via `/write_config` to the agent's internal subdirectory under `NGINX_CONFIG_DIR`, using the same `config_name` and `timestamp` as the validate call. + +- Wrapper or sudo not available + - Symptom: attempts to run `sudo -n /usr/local/sbin/yanpm-nginx-validate` fail. + - Fix: install a wrapper script that allows unprivileged `sudo -n` validation or configure proper permissions on nginx state files. + +If none of the above solves the problem, collect the logs produced by the agent (it uses `tracing`/`tracing_subscriber`) and include the exact command outputs from the validation steps when asking for help. diff --git a/apps/agent/doc/usage.md b/apps/agent/doc/usage.md new file mode 100644 index 0000000..d2308d4 --- /dev/null +++ b/apps/agent/doc/usage.md @@ -0,0 +1,61 @@ +# Usage and Examples + +Running locally (development) + +1. Build the agent (from repository root): + + ```sh + cargo build -p agent + ``` + +2. Run the agent with defaults (socket in current directory): + + ```sh + ./target/debug/yanpm-agent + ``` + +3. Run with explicit socket and nginx config directory: + + ```sh + ./target/debug/yanpm-agent --sock /run/yanpm/yanpm-agent.sock --nginx-config-dir /etc/nginx/conf.d + ``` + +HTTP over unix-socket examples (using `socat` / `curl` helper) + +If you want to call the API from the shell, you can use `socat` to convert the unix socket to an HTTP stream, or use tools that support unix sockets directly (e.g. `curl --unix-socket`). Examples below use `curl --unix-socket`. + +Validate a fragment by name and timestamp: + +```sh +curl --unix-socket ./yanpm-agent.sock -X POST http://localhost/validate \ + -H 'Content-Type: application/json' \ + -d '{"config_name":"example","timestamp":1234567890}' +``` + +Validate and reload (returns `rc` and `ro`): + +```sh +curl --unix-socket ./yanpm-agent.sock -X POST http://localhost/validate_and_reload \ + -H 'Content-Type: application/json' \ + -d '{"config_name":"example","timestamp":1234567890}' +``` + +Write a fragment (create or update): + +```sh +curl --unix-socket ./yanpm-agent.sock -X POST http://localhost/write_config \ + -H 'Content-Type: application/json' \ + -d '{"config_name":"example","timestamp":1234567890,"content":"server { listen 80; server_name example.local; }"}' +``` + +Status endpoint (health) + +```sh +curl --unix-socket ./yanpm-agent.sock http://localhost/status +``` + +Notes + +- Use the `config_name` and `timestamp` fields consistently: `timestamp` is typically a monotonic update ID from the caller ensuring unique file names. +- When running in containers, mount the host nginx config dir if you want the agent to write directly to host nginx configuration. +- The repository includes a runtime Docker image built by `apps/agent/Dockerfile` which bundles `nginx` and the `yanpm-agent` binary (via s6-overlay). Use that image when you want nginx and the agent colocated (the agent is intended as a runtime companion to `apps/api`). From c14af00c08646b8262c834720a3c436bfb65a981 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 22 Dec 2025 18:16:26 +0800 Subject: [PATCH 09/10] feat: update dependencies and refactor command line argument handling for yanpm-agent --- apps/agent/Cargo.toml | 2 +- apps/agent/src/commands/validate.rs | 2 +- apps/agent/src/main.rs | 91 +++++++++-------------------- justfile | 8 +-- 4 files changed, 34 insertions(+), 69 deletions(-) diff --git a/apps/agent/Cargo.toml b/apps/agent/Cargo.toml index 7e254c6..698c357 100644 --- a/apps/agent/Cargo.toml +++ b/apps/agent/Cargo.toml @@ -11,5 +11,5 @@ tracing-subscriber = { version = "0.3.20", features = ["smallvec", "fmt", "ansi" serde_json = { version = "1.0.145", features = ["std"] } serde = { version = "1.0.228", features = ["std", "derive"] } tokio-cron-scheduler = { version = "0.15.1", features = ["signal"] } -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } nix = { version = "0.30.1", features = ["user", "fs"] } diff --git a/apps/agent/src/commands/validate.rs b/apps/agent/src/commands/validate.rs index eac8f46..6ce53cf 100644 --- a/apps/agent/src/commands/validate.rs +++ b/apps/agent/src/commands/validate.rs @@ -1,4 +1,4 @@ -use tracing::{error, info, warn}; +use tracing::{info, warn}; use crate::commands::{run::run_cmd, write_config::INTERNAL_CONFIG_FOLDER_NAME}; use std::path::PathBuf; diff --git a/apps/agent/src/main.rs b/apps/agent/src/main.rs index b626fb4..72fdaff 100644 --- a/apps/agent/src/main.rs +++ b/apps/agent/src/main.rs @@ -5,7 +5,7 @@ mod routes; use axum::routing::get; use axum::{Router, routing::post}; -use clap::{Arg, Command}; +use clap::{Parser, command}; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::sync::Arc; @@ -15,10 +15,6 @@ use tracing::{error, info, warn}; use crate::commands::NginxService; use crate::routes::{status, validate, validate_and_reload, write_config}; -const SOCK_ARG: &str = "sock"; -const NGINX_CONFIG_DIR_ARG: &str = "nginx_config_dir"; -const SOCK_PERM_ARG: &str = "sock_perm"; -const SOCK_GID_ARG: &str = "sock_gid"; const SOCK_ENV: &str = "YANPM_AGENT_SOCK"; const SOCK_PERM_ENV: &str = "YANPM_AGENT_SOCK_PERM"; const NGINX_CONFIG_DIR_ENV: &str = "YANPM_NGINX_CONFIG_DIR"; @@ -28,6 +24,27 @@ const NGINX_CONFIG_DIR_DEFAULT: &str = "/etc/nginx/conf.d"; const SOCK_PERM_DEFAULT: &str = "660"; const SOCK_GID_DEFAULT: &str = ""; +/// Command line arguments +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Unix socket path to bind the agent daemon to + #[arg(short = 's', long, default_value_t = String::from(SOCK_DEFAULT), env = SOCK_ENV)] + sock: String, + + /// Directory where generated nginx config files will be written + #[arg(short = 'd', long, default_value_t = String::from(NGINX_CONFIG_DIR_DEFAULT), env = NGINX_CONFIG_DIR_ENV)] + nginx_config_dir: String, + + /// Permissions to set on the unix socket (in octal), e.g. 660 + #[arg(long, default_value_t = String::from(SOCK_PERM_DEFAULT), env = SOCK_PERM_ENV)] + sock_perm: String, + + /// GID to set on the unix socket, default: current user's primary group + #[arg(long, default_value_t = String::from(SOCK_GID_DEFAULT), env = SOCK_GID_ENV)] + sock_gid: String, +} + #[tokio::main] async fn main() -> Result<(), Box> { let subscriber = tracing_subscriber::fmt() @@ -40,39 +57,7 @@ async fn main() -> Result<(), Box> { tracing::subscriber::set_global_default(subscriber) .expect("Failed to set global default subscriber"); - let args = Command::new("yanpm-agent") - .arg( - Arg::new(SOCK_ARG) - .short('s') - .long("sock") - .value_name("SOCK_PATH") - .help("Unix socket path to bind the agent daemon to") - .required(false), - ) - .arg( - Arg::new(NGINX_CONFIG_DIR_ARG) - .short('d') - .long("nginx-config-dir") - .value_name("NGINX_CONFIG_DIR") - .help("Directory where generated nginx config files will be written") - .required(false), - ) - .arg( - Arg::new(SOCK_PERM_ARG) - .long("sock-perm") - .value_name("SOCK_PERM") - .help("Permissions to set on the unix socket (in octal), e.g. 660") - .required(false), - ) - .arg( - Arg::new(SOCK_GID_ARG) - .long("sock-gid") - .value_name("SOCK_GID") - .help("GID to set on the unix socket, default: current user's primary group") - .required(false), - ) - .about("YANPM Agent Daemon") - .get_matches(); + let args = Args::parse(); let (sock, nginx_config_dir, sock_perm, sock_gid) = get_args(&args).await?; @@ -175,32 +160,12 @@ async fn main() -> Result<(), Box> { } async fn get_args( - args: &clap::ArgMatches, + args: &Args, ) -> Result<(String, String, u32, String), Box> { - let sock = args - .get_one::(SOCK_ARG) - .cloned() - .unwrap_or_else(|| std::env::var(SOCK_ENV).unwrap_or_else(|_| SOCK_DEFAULT.to_string())); - let nginx_config_dir = args - .get_one::(NGINX_CONFIG_DIR_ARG) - .cloned() - .unwrap_or_else(|| { - std::env::var(NGINX_CONFIG_DIR_ENV) - .unwrap_or_else(|_| NGINX_CONFIG_DIR_DEFAULT.to_string()) - }); - let sock_perm = args - .get_one::(SOCK_PERM_ARG) - .cloned() - .unwrap_or_else(|| { - std::env::var(SOCK_PERM_ENV).unwrap_or_else(|_| SOCK_PERM_DEFAULT.to_string()) - }); - - let sock_gid = args - .get_one::(SOCK_GID_ARG) - .cloned() - .unwrap_or_else(|| { - std::env::var(SOCK_GID_ENV).unwrap_or_else(|_| SOCK_GID_DEFAULT.to_string()) - }); + let sock = args.sock.clone(); + let nginx_config_dir = args.nginx_config_dir.clone(); + let sock_perm = args.sock_perm.clone(); + let sock_gid = args.sock_gid.clone(); if sock_perm.len() != 3 || !sock_perm.chars().all(|c| ('0'..='7').contains(&c)) { return Err(std::io::Error::new( diff --git a/justfile b/justfile index fede830..a27a0e4 100644 --- a/justfile +++ b/justfile @@ -50,10 +50,6 @@ generate-openapi: # Generate API client for frontend cd apps/frontend && \ pnpm generate:openapi - # Generate OpenAPI spec for agent - cd apps/agent && \ - cargo run -- --generate-openapi --openapi-output ./openapi.yaml - # TODO: Generate API client for agent in api generate-all: generate-entity generate-openapi @@ -68,6 +64,10 @@ build-backend: cd apps/api && \ cargo build --release +build-docker: + cd apps/agent && \ + docker build -t yanpm/agent:latest . + build-apps: build-frontend build-backend act *args: From 0eafd6a264aef208abd93ed552d8fc3c716d5a17 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 22 Dec 2025 18:26:19 +0800 Subject: [PATCH 10/10] feat: upgrade actions/cache to v4 and clean up imports in main.rs --- .github/actions/setup-rust/action.yml | 6 +++--- apps/agent/src/main.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml index a55a77b..f6a46a2 100644 --- a/.github/actions/setup-rust/action.yml +++ b/.github/actions/setup-rust/action.yml @@ -22,7 +22,7 @@ runs: fetch-depth: 0 - name: Cache cargo registry - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.cargo/registry key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} @@ -30,7 +30,7 @@ runs: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - name: Cache cargo index - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.cargo/index key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} @@ -51,7 +51,7 @@ runs: ${{ runner.os }}-rustup- - name: Cache cargo build (target) - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: target key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }} diff --git a/apps/agent/src/main.rs b/apps/agent/src/main.rs index 72fdaff..149ec53 100644 --- a/apps/agent/src/main.rs +++ b/apps/agent/src/main.rs @@ -5,7 +5,7 @@ mod routes; use axum::routing::get; use axum::{Router, routing::post}; -use clap::{Parser, command}; +use clap::Parser; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::sync::Arc;