mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+51
-1
@@ -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<LandingOverlay>,
|
||||
pub retention: Option<RetentionOverlay>,
|
||||
pub restore: Option<RestoreOverlay>,
|
||||
pub console: Option<ConsoleOverlay>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -60,6 +61,13 @@ pub struct RestoreOverlay {
|
||||
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
|
||||
/// 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
|
||||
+28
@@ -118,6 +118,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
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<dyn std::error::Error>> {
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.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<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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user