feat(config): configurable restart_apply_mode + graceful shutdown (Stage 3b)

- console section: restart_apply_mode (manual | auto), an operator setting (runtime-safe,
  DB-overridable via /admin/config), env RESTART_APPLY_MODE default manual.
- Graceful shutdown: main serve now awaits SIGTERM/Ctrl-C OR an in-app shutdown signal
  (AppState.shutdown Notify) via with_graceful_shutdown, draining in-flight requests.
- PUT /admin/config in `auto` mode self-restarts shortly after responding to apply a
  restart-required change (systemd Restart=always brings it back with the new config);
  `manual` just flags restart_pending. The console warns before an auto save.

Stage 3 complete: operator config is readable + editable through the real validated,
audited, precedence-based layer; runtime-safe edits apply live, infra edits restart.
cargo all-targets clean, lib tests pass.
This commit is contained in:
jwinterm
2026-07-18 14:08:33 -04:00
parent 22d362b62f
commit a80fa46daf
6 changed files with 130 additions and 1 deletions
+11
View File
@@ -900,6 +900,17 @@ pub async fn admin_config_put(
// the next request); any restart-required field is persisted + flagged so the // the next request); any restart-required field is persisted + flagged so the
// operator applies it with a graceful restart. // operator applies it with a graceful restart.
if any_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 { Ok(Json(PutConfigResponse {
applied: "restart-required".into(), applied: "restart-required".into(),
restart_pending: true, restart_pending: true,
+35
View File
@@ -171,6 +171,32 @@ pub struct Config {
pub messaging: MessagingConfig, pub messaging: MessagingConfig,
pub premium: PremiumConfig, pub premium: PremiumConfig,
pub feed: FeedConfig, 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)] #[derive(Clone)]
@@ -912,6 +938,12 @@ impl Config {
allowlist_npubs: env_list("FEED_ALLOWLIST_NPUBS"), allowlist_npubs: env_list("FEED_ALLOWLIST_NPUBS"),
extra_relays: env_list("FEED_EXTRA_RELAYS"), 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()?; cfg.validate()?;
@@ -1502,6 +1534,9 @@ mod tests {
allowlist_npubs: Vec::new(), allowlist_npubs: Vec::new(),
extra_relays: Vec::new(), extra_relays: Vec::new(),
}, },
console: ConsoleConfig {
restart_apply_mode: RestartApplyMode::Manual,
},
} }
} }
+51 -1
View File
@@ -9,7 +9,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::config::{Config, RestorePolicy}; use crate::config::{Config, RestartApplyMode, RestorePolicy};
use crate::error::AppError; use crate::error::AppError;
/// Sparse patch over the env-derived [`Config`]. Each section is independent; a `None` /// Sparse patch over the env-derived [`Config`]. Each section is independent; a `None`
@@ -21,6 +21,7 @@ pub struct SettingsOverlay {
pub landing: Option<LandingOverlay>, pub landing: Option<LandingOverlay>,
pub retention: Option<RetentionOverlay>, pub retention: Option<RetentionOverlay>,
pub restore: Option<RestoreOverlay>, pub restore: Option<RestoreOverlay>,
pub console: Option<ConsoleOverlay>,
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -60,6 +61,13 @@ pub struct RestoreOverlay {
pub pow_max_bits: Option<u32>, pub pow_max_bits: Option<u32>,
} }
#[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<String>,
}
/// Whether a changed field applies live or needs a restart. Drives BOTH the hot-swap /// 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. /// decision and the `GET /admin/config` annotation — the single source of truth.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
@@ -82,6 +90,7 @@ pub fn runtime_class(section: &str, field: &str) -> RuntimeClass {
("landing", _) => RuntimeSafe, ("landing", _) => RuntimeSafe,
("retention", _) => RuntimeSafe, ("retention", _) => RuntimeSafe,
("restore", _) => RuntimeSafe, ("restore", _) => RuntimeSafe,
("console", _) => RuntimeSafe,
_ => RestartRequired, _ => 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()?; c.validate()?;
Ok(c) Ok(c)
} }
@@ -196,6 +219,7 @@ pub const EDITABLE_FIELDS: &[(&str, &str)] = &[
("restore", "pow_free_days"), ("restore", "pow_free_days"),
("restore", "pow_days_per_bit"), ("restore", "pow_days_per_bit"),
("restore", "pow_max_bits"), ("restore", "pow_max_bits"),
("console", "restart_apply_mode"),
]; ];
impl Config { impl Config {
@@ -230,6 +254,9 @@ impl Config {
pow_days_per_bit: Some(self.restore.pow_days_per_bit), pow_days_per_bit: Some(self.restore.pow_days_per_bit),
pow_max_bits: Some(self.restore.pow_max_bits), 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"), runtime_class("retention", "erasure_enabled"),
RuntimeClass::RestartRequired RuntimeClass::RestartRequired
); );
assert_eq!(
runtime_class("console", "restart_apply_mode"),
RuntimeClass::RuntimeSafe
);
assert_eq!(runtime_class("mystery", "field"), RuntimeClass::RestartRequired); 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"));
}
} }
+4
View File
@@ -82,6 +82,10 @@ pub struct AppState {
/// Admin token minter/verifier. `Some` only when the admin surface is /// Admin token minter/verifier. `Some` only when the admin surface is
/// enabled; the admin plane and guard refuse all requests otherwise. /// enabled; the admin plane and guard refuse all requests otherwise.
pub admin_sessions: Option<AdminSessionManager>, pub admin_sessions: Option<AdminSessionManager>,
/// 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<tokio::sync::Notify>,
} }
impl AppState { impl AppState {
+28
View File
@@ -118,6 +118,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
web_challenges: Arc::default(), web_challenges: Arc::default(),
prices: prices_cache, prices: prices_cache,
admin_sessions, admin_sessions,
shutdown: Arc::new(tokio::sync::Notify::new()),
}); });
// Periodic GC of expired website-auth challenges. Bounds the single-node // Periodic GC of expired website-auth challenges. Bounds the single-node
@@ -394,6 +395,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}); });
} }
let shutdown = state.shutdown.clone();
let app = build_router(state); let app = build_router(state);
let listener = tokio::net::TcpListener::bind(&addr).await?; let listener = tokio::net::TcpListener::bind(&addr).await?;
@@ -403,6 +405,32 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
listener, listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(), app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
) )
.with_graceful_shutdown(shutdown_signal(shutdown))
.await?; .await?;
Ok(()) 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<tokio::sync::Notify>) {
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");
}
+1
View File
@@ -99,6 +99,7 @@ pub async fn try_app_with(mutate: impl FnOnce(&mut Config)) -> Option<TestApp> {
web_challenges: Arc::default(), web_challenges: Arc::default(),
prices, prices,
admin_sessions, admin_sessions,
shutdown: Arc::default(),
}); });
Some(TestApp { Some(TestApp {