From 714debc94b0ef2b543949241f1eb04ae5738ad0c Mon Sep 17 00:00:00 2001 From: tv Date: Sun, 25 Oct 2026 13:39:44 +0100 Subject: add force module usable by API and CLI --- src/api/peers.rs | 12 ++++++---- src/config/mod.rs | 14 +++++++++++ src/force.rs | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 30 ++++++++++++++++------- src/remake/api.rs | 47 ++++++------------------------------ 5 files changed, 121 insertions(+), 54 deletions(-) create mode 100644 src/force.rs (limited to 'src') diff --git a/src/api/peers.rs b/src/api/peers.rs index 919f187..dc4bd3b 100644 --- a/src/api/peers.rs +++ b/src/api/peers.rs @@ -100,8 +100,8 @@ pub(crate) struct PeerListResponse { #[derive(Debug, Deserialize)] pub(crate) struct DeletePeerQuery { - #[serde(default)] - force: bool, + /// The checks to override (`force`): `alive`. + force: Option, } #[utoipa::path( @@ -197,12 +197,12 @@ pub(crate) async fn list_peers( tag = "Peers", params( ("peer" = String, Path, description = "Peer public key"), - ("force" = Option, Query, description = "Also forget a peer that is currently alive; it returns on its next message") + ("force" = Option, Query, description = "`true` or `alive`: also forget a peer that is currently alive; it returns on its next message") ), responses( (status = 204, description = "Swarm-wide forget recorded"), (status = 400, description = "Not a public key, or the local node's own key"), - (status = 409, description = "Peer is alive; pass force=true"), + (status = 409, description = "Peer is alive; pass force=alive"), (status = 503, description = "Node is not ready") ) )] @@ -217,6 +217,8 @@ pub(crate) async fn delete_peer( if peer.is_empty() || peer == state.local_id() { return Err(StatusCode::BAD_REQUEST); } + let force = crate::force::Force::parse(query.force.as_deref(), &["alive"]) + .map_err(|_| StatusCode::BAD_REQUEST)?; // Unknown locally is fine: other nodes may still know the peer. if let Some(peer_state_mutex) = state.get_peer_state(&peer).await { @@ -224,7 +226,7 @@ pub(crate) async fn delete_peer( .lock() .await .is_alive(&state.timing, Instant::now()) - && !query.force + && !force.allows("alive") { return Err(StatusCode::CONFLICT); } diff --git a/src/config/mod.rs b/src/config/mod.rs index 0771e7e..c83ca70 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -65,6 +65,9 @@ const fn option( } } +/// What `--force` may override at start (`force`). +pub(crate) const FORCE: &[&str] = &["swarmId"]; + /// Every option. Names are the long option names and the configuration file keys. pub(crate) const OPTIONS: &[OptionSpec] = &[ option( @@ -109,6 +112,13 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ Some("false"), "Start a new swarm, as its genesis node, instead of joining one through seeds. Every start with it starts another new swarm: drop it once the swarm exists, and give the node seeds instead. Can't be combined with seeds. With a --state-file that remembers a swarm, it starts that swarm again instead (empty; nodes of it still running merge with this one).", ), + option( + "force", + Kind::List, + "NAME,[NAME...]", + None, + "Override checks that otherwise refuse to start: true for all, or names: swarmId (a backup of another swarm than the state file remembers).", + ), option( "recover-from", Kind::Text, @@ -238,6 +248,8 @@ pub(crate) struct Settings { pub(crate) genesis: bool, pub(crate) state_file: Option, pub(crate) recover_from: Option, + #[serde(default)] + pub(crate) force: Vec, pub(crate) swarm_key_file: Option, pub(crate) alias: Option, #[serde(default = "default_keepalive_secs")] @@ -311,6 +323,8 @@ impl Settings { ); } } + crate::force::Force::parse(self.force.iter().map(String::as_str), FORCE) + .map_err(|reason| anyhow!("force: {reason}"))?; // Contradictions, not overrides: an error wherever the options come from. if self.genesis && !self.seeds.is_empty() { bail!( diff --git a/src/force.rs b/src/force.rs new file mode 100644 index 0000000..40f0ffc --- /dev/null +++ b/src/force.rs @@ -0,0 +1,72 @@ +//! `force`: overriding checks that otherwise refuse. Conflicts are errors by default; +//! each check that can be overridden has a name, and `force` lists the names to +//! override, or is `true` for all of them (`false`, or nothing, for none). +//! +//! The same format everywhere: the `?force=` query parameter of the API and the +//! `--force` option. Names are camelCase, like the API's fields (`switchDelaySecs`), +//! on the command line too. An unknown name is an error, so a typo can't silently +//! override nothing. + +/// The checks a `force` value overrides. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct Force { + all: bool, + names: Vec<&'static str>, +} + +impl Force { + /// Parses `values` (each `true`, `false`, or a comma-separated list of names), + /// allowing only the names in `known`. + pub(crate) fn parse<'a>( + values: impl IntoIterator, + known: &[&'static str], + ) -> Result { + let mut force = Force::default(); + for value in values { + for name in value.split(',').map(str::trim) { + match name { + "" | "false" => {} + "true" => force.all = true, + name => match known.iter().find(|known| **known == name) { + Some(known) => force.names.push(known), + None => { + return Err(format!( + "unknown force {name:?}: use true, or a list of {}", + known.join(", ") + )); + } + }, + } + } + } + Ok(force) + } + + /// Whether the check `name` is overridden. + pub(crate) fn allows(&self, name: &str) -> bool { + self.all || self.names.contains(&name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const KNOWN: &[&str] = &["switchDelaySecs", "supersede"]; + + #[test] + fn all_some_or_none() { + let all = Force::parse(["true"], KNOWN).unwrap(); + assert!(all.allows("supersede") && all.allows("switchDelaySecs")); + + let some = Force::parse(["supersede"], KNOWN).unwrap(); + assert!(some.allows("supersede") && !some.allows("switchDelaySecs")); + let list = Force::parse(["switchDelaySecs, supersede"], KNOWN).unwrap(); + assert!(list.allows("supersede") && list.allows("switchDelaySecs")); + + for none in [vec![], vec!["false"], vec![""]] { + assert!(!Force::parse(none, KNOWN).unwrap().allows("supersede")); + } + assert!(Force::parse(["swarm-id"], KNOWN).is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 76e5ff0..88cbc6f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Duration; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use automerge::AutoCommit; use tokio::sync::{RwLock, broadcast, mpsc, watch}; use tracing::{debug, error, info, trace, warn}; @@ -19,6 +19,7 @@ mod backoff; mod config; mod document; mod files; +mod force; mod logging; mod remake; mod state; @@ -87,6 +88,8 @@ async fn main() -> Result<()> { "Using Mycelium REST API" ); + let force = force::Force::parse(args.force.iter().map(String::as_str), config::FORCE) + .map_err(anyhow::Error::msg)?; let files = Arc::new(files::Files::default()); let mut seeds: Vec = args.seeds.clone(); let (state_file, remembered) = match &args.state_file { @@ -123,6 +126,22 @@ async fn main() -> Result<()> { Some(path) => Some(bootstrap::load_backup(path, &files)?), None => None, }; + if let (Some(backup), Some(remembered)) = (&recovered, &remembered) { + if backup.swarm_id != remembered.swarm_id { + if !force.allows("swarmId") { + bail!( + "The backup is of swarm {}, but the state file remembers swarm {}; add --force swarmId to recover the backup's swarm anyway", + backup.swarm_id, + remembered.swarm_id + ); + } + warn!( + backup = %backup.swarm_id, + remembered = %remembered.swarm_id, + "The backup is of another swarm than the remembered one; recovering the backup's (--force swarmId)" + ); + } + } let (initial_doc, recovered_epoch, recovered_swarm) = match recovered { Some(backup) => (backup.doc, Some(backup.epoch), Some(backup.swarm_id)), None => (AutoCommit::new(), None, None), @@ -193,16 +212,9 @@ async fn main() -> Result<()> { trace!("Application state initialized"); // Join the remembered swarm and no other; HELLOs claim it. A backup to recover from - // names its swarm itself, which wins. + // names its swarm itself (checked against the remembered one above). let start = match recovered_swarm { Some(swarm_id) => { - if let Some(remembered) = remembered.filter(|r| r.swarm_id != swarm_id) { - warn!( - backup = %swarm_id, - remembered = %remembered.swarm_id, - "The backup is of another swarm than the remembered one; recovering the backup's" - ); - } state.set_expected_swarm_id(swarm_id).await?; bootstrap::Start::Recovered } diff --git a/src/remake/api.rs b/src/remake/api.rs index 3530423..6b1a30c 100644 --- a/src/remake/api.rs +++ b/src/remake/api.rs @@ -73,6 +73,7 @@ use super::records::{ }; use super::{Host, kind, vote}; use crate::document::store::StoreError; +use crate::force::Force; #[derive(Debug, Default, Deserialize)] pub(crate) struct WaitQuery { @@ -85,7 +86,7 @@ pub(crate) struct WaitQuery { pub(crate) struct StartQuery { #[serde(default)] wait: bool, - /// The checks to override (`Force`). + /// The checks to override (`force`). force: Option, // Only for an import uploaded as `application/octet-stream`, which has no JSON // body to carry them: @@ -160,42 +161,8 @@ struct StartRequest { switch_delay_secs: Option, } -/// The checks `?force=` overrides: `true` all of them, or a comma-separated list. -#[derive(Debug, Default, Clone, Copy)] -struct Force { - switch_delay: bool, - supersede: bool, -} - -impl Force { - fn parse(value: Option<&str>) -> Result { - let mut force = Force::default(); - match value.map(str::trim) { - None | Some("" | "false") => {} - Some("true") => { - force = Force { - switch_delay: true, - supersede: true, - }; - } - Some(list) => { - for name in list.split(',').map(str::trim) { - match name { - "switchDelaySecs" => force.switch_delay = true, - "supersede" => force.supersede = true, - other => { - return Err(format!( - "unknown force {other:?}: use true, or a list of \ - switchDelaySecs and supersede" - )); - } - } - } - } - } - Ok(force) - } -} +/// What `?force=` may override on a proposal (`force`). +const FORCE: &[&str] = &["switchDelaySecs", "supersede"]; /// An expired remake of the epoch a proposal would supersede. #[derive(Debug)] @@ -333,7 +300,7 @@ async fn start_kind( if !host.is_node_ready().await { return error_response(StatusCode::SERVICE_UNAVAILABLE, "node is not ready", None); } - let force = match Force::parse(query.force.as_deref()) { + let force = match Force::parse(query.force.as_deref(), FORCE) { Ok(force) => force, Err(reason) => return error_response(StatusCode::BAD_REQUEST, reason, None), }; @@ -436,7 +403,7 @@ async fn start_kind( let keepalive = dormant_after / 4; let switch_delay = request.switch_delay_secs.map(Duration::from_secs); match switch_delay { - Some(delay) if delay < keepalive && force.switch_delay => { + Some(delay) if delay < keepalive && force.allows("switchDelaySecs") => { warn!( switch_delay_secs = delay.as_secs(), keepalive_secs = keepalive.as_secs(), @@ -502,7 +469,7 @@ async fn start_kind( id.clone(), proposal, base, - force.supersede, + force.allows("supersede"), now, grace, )); -- cgit v1.3.1