feature/agent #11

Merged
GW_MC merged 10 commits from feature/agent into master 2025-12-22 18:29:29 +08:00
4 changed files with 142 additions and 19 deletions
Showing only changes of commit 7781878c2d - Show all commits

56
apps/agent/Dockerfile Normal file
View File

@@ -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"]

View File

@@ -0,0 +1,4 @@
#!/bin/sh
# Run the agent as the unprivileged 'app' user
cd /app
exec s6-setuidgid app ./yanpm-agent

View File

@@ -0,0 +1,3 @@
#!/bin/sh
# Run nginx in foreground (s6 will supervise it)
exec nginx -g 'daemon off;'

View File

@@ -5,7 +5,7 @@ mod routes;
use axum::routing::get; use axum::routing::get;
use axum::{Router, routing::post}; use axum::{Router, routing::post};
use clap::Parser; use clap::{Arg, Command};
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
@@ -15,33 +15,89 @@ use tracing::{error, info};
use crate::commands::NginxService; use crate::commands::NginxService;
use crate::routes::{status, validate, validate_and_reload, write_config}; use crate::routes::{status, validate, validate_and_reload, write_config};
#[derive(Parser)] const SOCK_ARG: &str = "sock";
struct Args { const NGINX_CONFIG_DIR_ARG: &str = "nginx_config_dir";
/// Unix socket path to bind the daemon to const SOCK_ENV: &str = "YANPM_AGENT_SOCK";
sock: String, const NGINX_CONFIG_DIR_ENV: &str = "YANPM_NGINX_CONFIG_DIR";
const SOCK_DEFAULT: &str = "./yanpm-agent.sock";
/// Directory where generated nginx config files will be written const NGINX_CONFIG_DIR_DEFAULT: &str = "/etc/nginx/conf.d";
#[arg(long, default_value = "/etc/nginx/conf.d")]
nginx_config_dir: PathBuf,
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let args = Args::parse(); let args = Command::new("yanpm-agent")
let sock = args.sock; .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::<String>(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::<String>(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); let path = PathBuf::from(&sock);
if let Some(dir) = path.parent() { 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 // 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 // If an existing path exists at the socket location, ensure it's a socket
match tokio::fs::metadata(&path).await { match tokio::fs::metadata(&path).await {
Ok(md) => { Ok(md) => {
use std::os::unix::fs::FileTypeExt; use std::os::unix::fs::FileTypeExt;
if md.file_type().is_socket() { 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 { } else {
return Err( return Err(
format!("Socket path {} exists and is not a socket", path.display()).into(), format!("Socket path {} exists and is not a socket", path.display()).into(),
@@ -49,11 +105,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
} }
} }
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} 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) // 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) // set socket perms to 0660 (best-effort)
if let Err(err) = 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(0o660)).await
@@ -72,12 +130,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
.route("/validate_and_reload", post(validate_and_reload)) .route("/validate_and_reload", post(validate_and_reload))
.route("/validate", post(validate)) .route("/validate", post(validate))
.route("/write_config", post(write_config)) .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?; scheduler.start().await?;
info!("Starting yanpm-daemon on unix socket: {}", sock); 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(()) Ok(())
} }