diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 62a5bc99..29dc777c 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -517,8 +517,27 @@ pub struct HiveUrls { pub matrix: Option, } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +/// Wire-schema version for [`HostResponse`]. Bump only for a breaking +/// change — removing, renaming, or retyping an existing field; adding a +/// new `Option`/`skip_serializing_if` field (the pattern every field on +/// this struct already follows) is not breaking and needs no bump. A +/// client compares this against its own compiled-in copy of the constant +/// and warns (does not fail the request) on mismatch — see +/// `hivectl::client::version_mismatch_hint`, the one place that reads it. +pub const HOST_SOCK_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct HostResponse { + /// Wire-schema version this response was built against, see + /// [`HOST_SOCK_VERSION`]. `#[serde(default)]` so a pre-version daemon + /// (or a hand-built test fixture) deserializes as `0` rather than + /// failing to parse — `0` reads as "older than any real version" to a + /// mismatch check, which is the correct answer for wire predating + /// this field. Always populated on the write side: every constructor + /// below routes through `Self::default()`, whose `Default` impl (not + /// derived — see below) sets this to the current version. + #[serde(default)] + pub version: u32, pub ok: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, @@ -568,6 +587,31 @@ pub struct HostResponse { pub quota: Option>, } +/// Hand-written rather than `#[derive(Default)]` for one reason: +/// `version` must default to [`HOST_SOCK_VERSION`], not `u32::default()` +/// (`0`). Every field below it is the same all-`None`/`false`/empty shape +/// `derive(Default)` would have produced — only `version` differs — and +/// every constructor in `impl HostResponse` builds through `..Self::default()`, +/// so this is the one place that has to get it right. +impl Default for HostResponse { + fn default() -> Self { + Self { + version: HOST_SOCK_VERSION, + ok: false, + error: None, + agents: None, + approvals: None, + urls: None, + agent_statuses: None, + agent_exists: None, + queued_dags: None, + nodes: None, + messages: Vec::new(), + quota: None, + } + } +} + impl HostResponse { #[must_use] pub fn success() -> Self { diff --git a/hivectl/src/client.rs b/hivectl/src/client.rs index 14bd4427..54a434f1 100644 --- a/hivectl/src/client.rs +++ b/hivectl/src/client.rs @@ -69,12 +69,53 @@ pub async fn request(socket: &Path, req: HostRequest) -> Result { bail!("server closed connection without responding"); } let resp: HostResponse = serde_json::from_str(line.trim())?; + if let Some(hint) = version_mismatch_hint(resp.version) { + eprintln!("{hint}"); + } Ok(resp) } +/// Message to print (stderr, non-fatal) when the daemon's `HostResponse` +/// wire version differs from what this `hivectl` build was compiled +/// against. `None` when they match. Split out from the print site so it's +/// unit-testable, same pattern as `connect_hint` above — a mismatch is not +/// a request failure (most fields still decode fine across a one-version +/// skew), just something the operator should know before trusting a +/// response that might be missing a field this build expects. +fn version_mismatch_hint(daemon_version: u32) -> Option { + (daemon_version != hive_host_sock::HOST_SOCK_VERSION).then(|| { + format!( + "warning: hive-c0re host.sock wire version {daemon_version} does not match \ + hivectl's {} — upgrade hivectl or the daemon; some response fields may be \ + missing or misread", + hive_host_sock::HOST_SOCK_VERSION + ) + }) +} + #[cfg(test)] mod tests { - use super::{ErrorKind, Path, connect_hint}; + use super::{ErrorKind, Path, connect_hint, version_mismatch_hint}; + + /// A daemon on the same version this `hivectl` was built against gets + /// no warning — the common case, and the only one that must stay silent. + #[test] + fn matching_version_has_no_hint() { + assert!(version_mismatch_hint(hive_host_sock::HOST_SOCK_VERSION).is_none()); + } + + /// A mismatch names both versions, so the operator knows which side + /// (hivectl or the daemon) is behind, not just that they disagree. + #[test] + fn mismatched_version_names_both_numbers() { + let other = hive_host_sock::HOST_SOCK_VERSION + 1; + let h = version_mismatch_hint(other).expect("versions differ"); + assert!(h.contains(&other.to_string()), "{h}"); + assert!( + h.contains(&hive_host_sock::HOST_SOCK_VERSION.to_string()), + "{h}" + ); + } fn hint(kind: ErrorKind) -> String { connect_hint(kind, Path::new("/run/hyperhive/host.sock"))