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.
This commit is contained in:
292
apps/agent/src/commands.rs
Normal file
292
apps/agent/src/commands.rs
Normal file
@@ -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<HashMap<(String, u64), Arc<RwLock<()>>>>,
|
||||
// commands
|
||||
reload_cmd: Arc<reload::ReloadCommand>,
|
||||
validate_cmd: Arc<validate::ValidateCommand>,
|
||||
write_config_cmd: Arc<write_config::WriteConfigCommand>,
|
||||
}
|
||||
|
||||
impl NginxService {
|
||||
pub async fn new(
|
||||
scheduler: Arc<JobScheduler>,
|
||||
nginx_config_dir: std::path::PathBuf,
|
||||
) -> Result<Arc<Self>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
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<dyn std::error::Error + Send + Sync>> {
|
||||
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<dyn std::error::Error + Send + Sync>> {
|
||||
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<dyn std::error::Error + Send + Sync>> {
|
||||
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<RwLock<()>> {
|
||||
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<dyn Error + Send + Sync>> {
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
98
apps/agent/src/commands/reload.rs
Normal file
98
apps/agent/src/commands/reload.rs
Normal file
@@ -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<bool>,
|
||||
}
|
||||
|
||||
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<ValidateCommand>,
|
||||
) -> Result<(i32, String), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// 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/<filename>
|
||||
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
|
||||
}
|
||||
}
|
||||
85
apps/agent/src/commands/run.rs
Normal file
85
apps/agent/src/commands/run.rs
Normal file
@@ -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<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// 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<dyn std::error::Error + Send + Sync>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
47
apps/agent/src/commands/validate.rs
Normal file
47
apps/agent/src/commands/validate.rs
Normal file
@@ -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<dyn std::error::Error + Send + Sync>> {
|
||||
run_cmd("nginx", &["-t"], 10).await
|
||||
}
|
||||
|
||||
pub async fn validate(
|
||||
&self,
|
||||
config_name: &str,
|
||||
timestamp: u64,
|
||||
) -> Result<(i32, String), Box<dyn std::error::Error + Send + Sync>> {
|
||||
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
|
||||
}
|
||||
}
|
||||
131
apps/agent/src/commands/write_config.rs
Normal file
131
apps/agent/src/commands/write_config.rs
Normal file
@@ -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<dyn std::error::Error + Send + Sync>> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
83
apps/agent/src/main.rs
Normal file
83
apps/agent/src/main.rs
Normal file
@@ -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<dyn std::error::Error + Send + Sync>> {
|
||||
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(())
|
||||
}
|
||||
130
apps/agent/src/routes.rs
Normal file
130
apps/agent/src/routes.rs
Normal file
@@ -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<Arc<NginxService>>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> 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<Arc<NginxService>>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> 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<Arc<NginxService>>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> 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()
|
||||
}
|
||||
Reference in New Issue
Block a user