diff --git a/src/api/admin.rs b/src/api/admin.rs index 05fd028..9cab504 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -900,6 +900,17 @@ pub async fn admin_config_put( // the next request); any restart-required field is persisted + flagged so the // operator applies it with a graceful restart. if any_restart { + // In `auto` mode, self-restart shortly AFTER responding so the persisted change + // takes effect (systemd `Restart=always` brings us back). The console warns the + // operator before saving in auto mode; `manual` just flags restart_pending. + if state.cfg().console.restart_apply_mode == crate::config::RestartApplyMode::Auto { + let shutdown = state.shutdown.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(750)).await; + tracing::info!("config auto-apply: self-restart to apply a restart-required change"); + shutdown.notify_one(); + }); + } Ok(Json(PutConfigResponse { applied: "restart-required".into(), restart_pending: true, diff --git a/src/config.rs b/src/config.rs index 44b71b4..03fb4d2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -171,6 +171,32 @@ pub struct Config { pub messaging: MessagingConfig, pub premium: PremiumConfig, pub feed: FeedConfig, + pub console: ConsoleConfig, +} + +/// How a restart-required config change is applied. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum RestartApplyMode { + /// Persist + flag; the operator applies it with a graceful restart. + Manual, + /// The instance self-restarts after saving (the console warns first). Requires the + /// service to be brought back on exit (systemd `Restart=always`). + Auto, +} + +impl RestartApplyMode { + pub fn as_str(&self) -> &'static str { + match self { + RestartApplyMode::Manual => "manual", + RestartApplyMode::Auto => "auto", + } + } +} + +/// Operator-console preferences (runtime-safe, DB-overridable via /admin/config). +#[derive(Clone)] +pub struct ConsoleConfig { + pub restart_apply_mode: RestartApplyMode, } #[derive(Clone)] @@ -912,6 +938,12 @@ impl Config { allowlist_npubs: env_list("FEED_ALLOWLIST_NPUBS"), extra_relays: env_list("FEED_EXTRA_RELAYS"), }, + console: ConsoleConfig { + restart_apply_mode: match env_or("RESTART_APPLY_MODE", "manual").as_str() { + "auto" => RestartApplyMode::Auto, + _ => RestartApplyMode::Manual, + }, + }, }; cfg.validate()?; @@ -1502,6 +1534,9 @@ mod tests { allowlist_npubs: Vec::new(), extra_relays: Vec::new(), }, + console: ConsoleConfig { + restart_apply_mode: RestartApplyMode::Manual, + }, } } diff --git a/src/config_overlay.rs b/src/config_overlay.rs index 83c6052..62c44f1 100644 --- a/src/config_overlay.rs +++ b/src/config_overlay.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; -use crate::config::{Config, RestorePolicy}; +use crate::config::{Config, RestartApplyMode, RestorePolicy}; use crate::error::AppError; /// Sparse patch over the env-derived [`Config`]. Each section is independent; a `None` @@ -21,6 +21,7 @@ pub struct SettingsOverlay { pub landing: Option, pub retention: Option, pub restore: Option, + pub console: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -60,6 +61,13 @@ pub struct RestoreOverlay { pub pow_max_bits: Option, } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct ConsoleOverlay { + /// How a restart-required config change applies: `manual` | `auto`. + pub restart_apply_mode: Option, +} + /// Whether a changed field applies live or needs a restart. Drives BOTH the hot-swap /// decision and the `GET /admin/config` annotation — the single source of truth. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -82,6 +90,7 @@ pub fn runtime_class(section: &str, field: &str) -> RuntimeClass { ("landing", _) => RuntimeSafe, ("retention", _) => RuntimeSafe, ("restore", _) => RuntimeSafe, + ("console", _) => RuntimeSafe, _ => RestartRequired, } } @@ -169,6 +178,20 @@ impl Config { } } + if let Some(cs) = &ov.console { + if let Some(m) = &cs.restart_apply_mode { + c.console.restart_apply_mode = match m.as_str() { + "manual" => RestartApplyMode::Manual, + "auto" => RestartApplyMode::Auto, + other => { + return Err(AppError::ValidationError(format!( + "invalid console.restart_apply_mode {other:?} (want manual|auto)" + ))) + } + }; + } + } + c.validate()?; Ok(c) } @@ -196,6 +219,7 @@ pub const EDITABLE_FIELDS: &[(&str, &str)] = &[ ("restore", "pow_free_days"), ("restore", "pow_days_per_bit"), ("restore", "pow_max_bits"), + ("console", "restart_apply_mode"), ]; impl Config { @@ -230,6 +254,9 @@ impl Config { pow_days_per_bit: Some(self.restore.pow_days_per_bit), pow_max_bits: Some(self.restore.pow_max_bits), }), + console: Some(ConsoleOverlay { + restart_apply_mode: Some(self.console.restart_apply_mode.as_str().to_string()), + }), } } } @@ -262,6 +289,29 @@ mod tests { runtime_class("retention", "erasure_enabled"), RuntimeClass::RestartRequired ); + assert_eq!( + runtime_class("console", "restart_apply_mode"), + RuntimeClass::RuntimeSafe + ); assert_eq!(runtime_class("mystery", "field"), RuntimeClass::RestartRequired); } + + #[test] + fn console_overlay_parses_restart_apply_mode() { + let ov: SettingsOverlay = + serde_json::from_str(r#"{"console":{"restart_apply_mode":"auto"}}"#).unwrap(); + assert_eq!( + ov.console.unwrap().restart_apply_mode.as_deref(), + Some("auto") + ); + } + + #[test] + fn every_editable_field_has_a_runtime_class() { + // EDITABLE_FIELDS is the enumerable surface GET /admin/config annotates. + for (section, field) in EDITABLE_FIELDS { + let _ = runtime_class(section, field); // no panic / all covered + } + assert!(EDITABLE_FIELDS.iter().any(|(s, f)| *s == "console" && *f == "restart_apply_mode")); + } } diff --git a/src/lib.rs b/src/lib.rs index afa0778..5a773c6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -82,6 +82,10 @@ pub struct AppState { /// Admin token minter/verifier. `Some` only when the admin surface is /// enabled; the admin plane and guard refuse all requests otherwise. pub admin_sessions: Option, + /// Signalled to trigger a graceful shutdown from INSIDE the app — e.g. an operator + /// config change in `auto` restart mode. `main` awaits this alongside SIGTERM; + /// systemd `Restart=always` brings the instance back with the new config applied. + pub shutdown: Arc, } impl AppState { diff --git a/src/main.rs b/src/main.rs index 09f8b9b..2ec0345 100644 --- a/src/main.rs +++ b/src/main.rs @@ -118,6 +118,7 @@ async fn main() -> Result<(), Box> { web_challenges: Arc::default(), prices: prices_cache, admin_sessions, + shutdown: Arc::new(tokio::sync::Notify::new()), }); // Periodic GC of expired website-auth challenges. Bounds the single-node @@ -394,6 +395,7 @@ async fn main() -> Result<(), Box> { }); } + let shutdown = state.shutdown.clone(); let app = build_router(state); let listener = tokio::net::TcpListener::bind(&addr).await?; @@ -403,6 +405,32 @@ async fn main() -> Result<(), Box> { listener, app.into_make_service_with_connect_info::(), ) + .with_graceful_shutdown(shutdown_signal(shutdown)) .await?; Ok(()) } + +/// Completes on SIGTERM / Ctrl-C, or when the app signals a restart (an operator config +/// change in `auto` mode). Lets axum drain in-flight requests before exit; systemd +/// `Restart=always` then brings the instance back with the new config. +async fn shutdown_signal(app_triggered: Arc) { + let ctrl_c = async { + let _ = tokio::signal::ctrl_c().await; + }; + #[cfg(unix)] + let term = async { + if let Ok(mut sig) = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + { + sig.recv().await; + } + }; + #[cfg(not(unix))] + let term = std::future::pending::<()>(); + tokio::select! { + _ = ctrl_c => {} + _ = term => {} + _ = app_triggered.notified() => {} + } + tracing::info!("shutdown signal received; draining in-flight requests"); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 9066fb7..01b76d0 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -99,6 +99,7 @@ pub async fn try_app_with(mutate: impl FnOnce(&mut Config)) -> Option { web_challenges: Arc::default(), prices, admin_sessions, + shutdown: Arc::default(), }); Some(TestApp {