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
|
// 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,
|
||||||
|
|||||||
@@ -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
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user