mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-28 07:50:47 +08:00
httpd: Add SIGHUP config reloading
Enable hot-reloading of web configuration (pinned repos, avatar, banner, description) by sending SIGHUP to the process, eliminating the need for server restarts when updating these settings. Co-authored-by: Fintan Halpenny <fintan.halpenny@gmail.com>
This commit is contained in:
co-authored by
Fintan Halpenny
parent
ebe4e426e8
commit
7dfca539b4
Generated
+1
@@ -2867,6 +2867,7 @@ dependencies = [
|
||||
"libc",
|
||||
"mio 1.0.2",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
|
||||
@@ -39,7 +39,7 @@ radicle-term = { version = "0.14.0", default-features = false }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
thiserror = { version = "2" }
|
||||
tokio = { version = "1.47.1", default-features = false, features = ["macros", "rt-multi-thread"] }
|
||||
tokio = { version = "1.47.1", default-features = false, features = ["macros", "rt-multi-thread", "signal"] }
|
||||
tower-http = { version = "0.6.6", default-features = false, features = ["trace", "cors", "set-header"] }
|
||||
tracing = { version = "0.1.41", default-features = false, features = ["std", "log"] }
|
||||
tracing-logfmt = { version = "0.3.5", optional = true }
|
||||
|
||||
+188
-3
@@ -14,7 +14,8 @@ use radicle::node::NodeId;
|
||||
use radicle::patch::cache::Patches as _;
|
||||
use radicle::storage::git::Repository;
|
||||
use radicle::storage::{ReadRepository, ReadStorage};
|
||||
use radicle::Profile;
|
||||
use radicle::{web, Profile};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
mod error;
|
||||
mod json;
|
||||
@@ -29,17 +30,54 @@ pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION");
|
||||
// This version has to be updated on every breaking change to the radicle-httpd API.
|
||||
pub const API_VERSION: &str = "6.1.0";
|
||||
|
||||
/// Thread-safe wrapper around radicle's web configuration.
|
||||
///
|
||||
/// This struct provides concurrent read/write access to web configuration
|
||||
/// that can be dynamically reloaded (e.g., via SIGHUP) without restarting the server.
|
||||
/// All access is synchronized via an async [`RwLock`] to prevent race conditions.
|
||||
#[derive(Clone)]
|
||||
pub struct WebConfig {
|
||||
inner: Arc<RwLock<web::Config>>,
|
||||
}
|
||||
|
||||
impl WebConfig {
|
||||
/// Creates a new WebConfig from a [`Profile`]'s web configuration.
|
||||
pub fn from_profile(profile: &Profile) -> Self {
|
||||
let config = profile.config.web.clone();
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(config)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the underlying web configuration.
|
||||
pub async fn read(&self) -> web::Config {
|
||||
self.inner.read().await.clone()
|
||||
}
|
||||
|
||||
/// Atomically updates the config by applying a function while holding the write lock.
|
||||
/// This prevents lost updates when multiple tasks attempt concurrent modifications.
|
||||
pub async fn update<F>(&self, f: F)
|
||||
where
|
||||
F: FnOnce(&mut web::Config),
|
||||
{
|
||||
let mut config = self.inner.write().await;
|
||||
f(&mut config);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Context {
|
||||
profile: Arc<Profile>,
|
||||
cache: Option<Cache>,
|
||||
web_config: WebConfig,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
pub fn new(profile: Arc<Profile>, options: &Options) -> Self {
|
||||
pub fn new(profile: Arc<Profile>, web_config: WebConfig, options: &Options) -> Self {
|
||||
Self {
|
||||
profile,
|
||||
profile: profile.clone(),
|
||||
cache: options.cache.map(Cache::new),
|
||||
web_config,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +149,14 @@ impl Context {
|
||||
Ok((repo, doc))
|
||||
}
|
||||
|
||||
/// Returns a reference to the thread-safe web configuration.
|
||||
///
|
||||
/// Use this instead of accessing [`radicle::web::Config`] from the [`Profile`] to ensure
|
||||
/// you get the latest config after dynamic reloads.
|
||||
pub fn web_config(&self) -> &WebConfig {
|
||||
&self.web_config
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn profile(&self) -> &Arc<Profile> {
|
||||
&self.profile
|
||||
@@ -255,3 +301,142 @@ mod repo {
|
||||
pub seeding: usize,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::test;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_web_config_accessor() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test::seed(tmp.path());
|
||||
|
||||
let config = ctx.web_config.read().await;
|
||||
assert_eq!(config.pinned.repositories.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_web_config_reload_simulation() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test::seed(tmp.path());
|
||||
|
||||
{
|
||||
let config = ctx.web_config.read().await;
|
||||
assert_eq!(config.pinned.repositories.len(), 0);
|
||||
assert_eq!(config.description, None);
|
||||
}
|
||||
|
||||
{
|
||||
ctx.web_config
|
||||
.update(|config| {
|
||||
config.description = Some("Updated description".to_string());
|
||||
config.avatar_url = Some("https://example.com/avatar.png".to_string());
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
{
|
||||
let config = ctx.web_config.read().await;
|
||||
assert_eq!(config.description, Some("Updated description".to_string()));
|
||||
assert_eq!(
|
||||
config.avatar_url,
|
||||
Some("https://example.com/avatar.png".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_web_config_concurrent_reads() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test::seed(tmp.path());
|
||||
|
||||
let mut handles = vec![];
|
||||
for _ in 0..10 {
|
||||
let ctx_clone = ctx.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
let config = ctx_clone.web_config.read().await;
|
||||
config.pinned.repositories.len()
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
for handle in handles {
|
||||
handle.await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_web_config_preserves_data_across_reads() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test::seed(tmp.path());
|
||||
|
||||
{
|
||||
ctx.web_config
|
||||
.update(|config| {
|
||||
config.banner_url = Some("https://example.com/banner.png".to_string());
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
for _ in 0..5 {
|
||||
let config = ctx.web_config.read().await;
|
||||
assert_eq!(
|
||||
config.banner_url,
|
||||
Some("https://example.com/banner.png".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_immutable_after_reload() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test::seed(tmp.path());
|
||||
let original_key = ctx.profile.public_key;
|
||||
let original_home = ctx.profile.home.path().to_path_buf();
|
||||
|
||||
{
|
||||
ctx.web_config
|
||||
.update(|config| {
|
||||
config.description = Some("Updated".to_string());
|
||||
config.avatar_url = Some("https://example.com/new-avatar.png".to_string());
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
assert_eq!(ctx.profile.public_key, original_key);
|
||||
assert_eq!(ctx.profile.home.path(), original_home);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_empty_pinned_repos_transitions() {
|
||||
use radicle::identity::RepoId;
|
||||
use std::str::FromStr;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test::seed(tmp.path());
|
||||
|
||||
assert_eq!(ctx.web_config.read().await.pinned.repositories.len(), 0);
|
||||
|
||||
let rid1 = RepoId::from_str("rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp").unwrap();
|
||||
let rid2 = RepoId::from_str("rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE").unwrap();
|
||||
|
||||
{
|
||||
ctx.web_config
|
||||
.update(|config| {
|
||||
config.pinned.repositories.insert(rid1);
|
||||
config.pinned.repositories.insert(rid2);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
assert_eq!(ctx.web_config.read().await.pinned.repositories.len(), 2);
|
||||
|
||||
{
|
||||
ctx.web_config
|
||||
.update(|config| {
|
||||
config.pinned.repositories.clear();
|
||||
})
|
||||
.await;
|
||||
}
|
||||
assert_eq!(ctx.web_config.read().await.pinned.repositories.len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ async fn delegates_repos_handler(
|
||||
let page = page.unwrap_or(0);
|
||||
let per_page = per_page.unwrap_or(10);
|
||||
let storage = &ctx.profile.storage;
|
||||
let pinned = &ctx.profile.config.web.pinned;
|
||||
let web_config = ctx.web_config().read().await;
|
||||
let pinned = &web_config.pinned;
|
||||
let mut repos = match show {
|
||||
RepoQuery::All => storage
|
||||
.repositories()?
|
||||
|
||||
@@ -92,7 +92,7 @@ async fn node_handler(State(ctx): State<Context>) -> impl IntoResponse {
|
||||
agent,
|
||||
config,
|
||||
node_state.to_string(),
|
||||
ctx.profile.config.web.clone(),
|
||||
ctx.web_config().read().await,
|
||||
);
|
||||
|
||||
Ok::<_, Error>(cached_response(response, 600))
|
||||
@@ -265,4 +265,37 @@ mod routes {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_node_uses_reloadable_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let seed = seed(tmp.path());
|
||||
|
||||
{
|
||||
seed.web_config
|
||||
.update(|config| {
|
||||
config.description = Some("Test node description".to_string());
|
||||
config.avatar_url = Some("https://example.com/avatar.png".to_string());
|
||||
config.banner_url = Some("https://example.com/banner.png".to_string());
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
let app = super::router(seed.clone())
|
||||
.layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))));
|
||||
let response = get(&app, "/node").await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let json_response = response.json().await;
|
||||
|
||||
assert_eq!(json_response["description"], json!("Test node description"));
|
||||
assert_eq!(
|
||||
json_response["avatarUrl"],
|
||||
json!("https://example.com/avatar.png")
|
||||
);
|
||||
assert_eq!(
|
||||
json_response["bannerUrl"],
|
||||
json!("https://example.com/banner.png")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,12 +61,13 @@ async fn repo_root_handler(
|
||||
per_page,
|
||||
} = qs;
|
||||
let page = page.unwrap_or(0);
|
||||
let web_config = ctx.web_config().read().await;
|
||||
let per_page = per_page.unwrap_or_else(|| match show {
|
||||
RepoQuery::Pinned => ctx.profile.config.web.pinned.repositories.len(),
|
||||
RepoQuery::Pinned => web_config.pinned.repositories.len(),
|
||||
_ => 10,
|
||||
});
|
||||
let storage = &ctx.profile.storage;
|
||||
let pinned = &ctx.profile.config.web.pinned;
|
||||
let pinned = &web_config.pinned;
|
||||
let policies = ctx.profile.policies()?;
|
||||
|
||||
let mut repos = match show {
|
||||
@@ -1846,4 +1847,35 @@ mod routes {
|
||||
let response = get(&app, format!("/repos/{RID_PRIVATE}/remotes")).await;
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_repos_uses_reloadable_pinned_config() {
|
||||
use radicle::identity::RepoId;
|
||||
use std::str::FromStr;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let seed = seed(tmp.path());
|
||||
|
||||
let app = super::router(seed.clone())
|
||||
.layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080))));
|
||||
let response = get(&app, "/repos?show=pinned").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let repos = response.json().await;
|
||||
assert_eq!(repos.as_array().unwrap().len(), 0);
|
||||
|
||||
{
|
||||
let rid = RepoId::from_str(RID).unwrap();
|
||||
seed.web_config
|
||||
.update(|config| {
|
||||
config.pinned.repositories.insert(rid);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
let response = get(&app, "/repos?show=pinned").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let repos = response.json().await;
|
||||
assert_eq!(repos.as_array().unwrap().len(), 1);
|
||||
assert_eq!(repos[0]["rid"], json!(RID));
|
||||
}
|
||||
}
|
||||
|
||||
+47
-18
@@ -10,6 +10,8 @@ use std::str;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
@@ -68,7 +70,35 @@ pub async fn run(options: Options) -> anyhow::Result<()> {
|
||||
|
||||
tracing::info!("using radicle home at {}", profile.home().path().display());
|
||||
|
||||
let app = router(options, profile)?
|
||||
let web_config = api::WebConfig::from_profile(&profile);
|
||||
let profile = Arc::new(profile);
|
||||
let ctx = api::Context::new(profile.clone(), web_config.clone(), &options);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut sighup = signal(SignalKind::hangup()).expect("Failed to register SIGHUP handler");
|
||||
|
||||
loop {
|
||||
sighup.recv().await;
|
||||
tracing::info!("Received SIGHUP, reloading web configuration");
|
||||
|
||||
match Profile::load() {
|
||||
Ok(new_profile) => {
|
||||
web_config
|
||||
.update(|config| {
|
||||
*config = new_profile.config.web.clone();
|
||||
})
|
||||
.await;
|
||||
tracing::info!("Web configuration reloaded successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to reload configuration: {:#}", e);
|
||||
tracing::warn!("Continuing with previous configuration");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let app = router(options, profile, ctx)?
|
||||
.layer(middleware::from_fn(tracing_middleware))
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
@@ -116,10 +146,7 @@ pub async fn run(options: Options) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
/// Create a router consisting of other sub-routers.
|
||||
fn router(options: Options, profile: Profile) -> anyhow::Result<Router> {
|
||||
let profile = Arc::new(profile);
|
||||
let ctx = api::Context::new(profile.clone(), &options);
|
||||
|
||||
fn router(options: Options, profile: Arc<Profile>, ctx: api::Context) -> anyhow::Result<Router> {
|
||||
let api_router = api::router(ctx);
|
||||
let git_router = git::router(profile.clone(), options.aliases);
|
||||
let raw_router = raw::router(profile);
|
||||
@@ -217,19 +244,21 @@ mod routes {
|
||||
#[tokio::test]
|
||||
async fn test_invalid_route_returns_404() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let app = super::router(
|
||||
super::Options {
|
||||
aliases: HashMap::new(),
|
||||
listen: DualAddr::Tcp(SocketAddr::from(([0, 0, 0, 0], 8080))),
|
||||
cache: None,
|
||||
},
|
||||
test::profile(tmp.path(), [0xff; 32]),
|
||||
)
|
||||
.unwrap()
|
||||
.layer(MockConnectInfo(DualAddr::Tcp(SocketAddr::from((
|
||||
[0, 0, 0, 0],
|
||||
8080,
|
||||
)))));
|
||||
let options = super::Options {
|
||||
aliases: HashMap::new(),
|
||||
listen: DualAddr::Tcp(SocketAddr::from(([0, 0, 0, 0], 8080))),
|
||||
cache: None,
|
||||
};
|
||||
let profile = test::profile(tmp.path(), [0xff; 32]);
|
||||
let web_config = crate::api::WebConfig::from_profile(&profile);
|
||||
let profile = std::sync::Arc::new(profile);
|
||||
let ctx = crate::api::Context::new(profile.clone(), web_config, &options);
|
||||
let app = super::router(options, profile, ctx)
|
||||
.unwrap()
|
||||
.layer(MockConnectInfo(DualAddr::Tcp(SocketAddr::from((
|
||||
[0, 0, 0, 0],
|
||||
8080,
|
||||
)))));
|
||||
|
||||
let response = test::get(&app, "/aa/a").await;
|
||||
|
||||
|
||||
@@ -330,7 +330,8 @@ fn seed_with_signer<G: Signer<Signature>>(
|
||||
cache: Some(crate::DEFAULT_CACHE_SIZE),
|
||||
};
|
||||
|
||||
Context::new(Arc::new(profile), &options)
|
||||
let web_config = crate::api::WebConfig::from_profile(&profile);
|
||||
Context::new(Arc::new(profile), web_config, &options)
|
||||
}
|
||||
|
||||
pub async fn get(app: &Router, path: impl ToString) -> Response {
|
||||
|
||||
Reference in New Issue
Block a user