kanade-shared 0.42.0

Shared wire types, NATS subject helpers, KV constants, YAML manifest schema, and teravars-backed config loader for the kanade endpoint-management system
Documentation
//! `state.*` method types — endpoint health snapshot + push notifications.
//!
//! Drives the Client App's "Health" tab (SPEC §2.1 use case 2):
//! BitLocker / AV signature / OS-patch / cert-expiry / disk-free /
//! agent-self-update + arbitrary additional compliance checks. The
//! snapshot is computed agent-side on demand (`state.snapshot`) and
//! pushed when underlying checks flip via `state.changed`.

use serde::{Deserialize, Serialize};

// ---------- state.snapshot ----------

/// `state.snapshot` takes no params.
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
pub struct StateSnapshotParams {}

/// Full state bundle — the SPA renders this verbatim on the Health
/// tab. SPEC §2.12.8's complete-conversation example pins the
/// shape:
///
/// ```jsonc
/// {"pc_id":"PC1234","online":true,"vpn":"connected",
///  "checks":[{"name":"bitlocker","status":"ok"}],
///  "agent_version":"0.4.0","target_version":"0.4.0"}
/// ```
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct StateSnapshot {
    /// Agent's `pc_id` — duplicated here from the handshake so the
    /// SPA can refresh the snapshot independently without
    /// re-handshaking.
    pub pc_id: String,
    /// `true` when the agent currently has a NATS connection open.
    /// Distinct from the OS-level network state — operators care
    /// about "is fleet management reachable" specifically.
    pub online: bool,
    /// VPN posture. Free-form string today (`"connected"` /
    /// `"disconnected"` / `"unknown"` / a vendor-specific status)
    /// because SPEC §2.1's compliance checks are
    /// site-specific. Future SPEC version may tighten this into an
    /// enum.
    pub vpn: String,
    /// Ordered list of compliance check results. Each [`Check`]
    /// item is rendered as a row on the Health tab; failing rows
    /// surface a "修復する" button per SPEC §2.1.
    pub checks: Vec<Check>,
    /// Currently-running agent binary version
    /// (`CARGO_PKG_VERSION`). Same value as
    /// [`super::system::VersionResult::agent_version`].
    pub agent_version: String,
    /// Version the agent self-updater is targeting. When this
    /// differs from `agent_version`, the SPA shows "restart pending"
    /// on the Health tab.
    pub target_version: String,
}

/// One compliance check result. `name` is the stable id (used as
/// React key + analytics label); `status` drives the row's color;
/// `detail` is human-readable text for the row body. `troubleshoot`
/// is the optional `Manifest.id` of the job whose execute button
/// fixes this check — `None` means the check has no auto-remediation.
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct Check {
    pub name: String,
    pub status: CheckStatus,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
    /// Manifest id of a `category: troubleshoot` job that fixes
    /// this check. The Client App renders a "修復する" button when
    /// present (SPEC §2.1). The job MUST have `user_invokable:
    /// true` — if not, `jobs.execute` returns `Unauthorized` when
    /// the button is clicked.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub troubleshoot: Option<String>,
}

/// Four-state result mirroring the SPA's color palette: ok = green,
/// warn = yellow, fail = red, unknown = grey. Wire-encoded as
/// snake_case (`"ok"` / `"warn"` / `"fail"` / `"unknown"`) — the
/// PascalCase convention is reserved for [`super::error::ErrorKind`]
/// where SPEC §2.12.9 specifically pins it.
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum CheckStatus {
    /// Check passed.
    Ok,
    /// Non-blocking finding. SPA renders yellow; user can ignore.
    Warn,
    /// Failed — SPA renders red. If a `troubleshoot` manifest is
    /// declared, the "修復する" button is enabled.
    Fail,
    /// Check couldn't run (agent timed out, WMI hang, …). SPA
    /// renders grey "Unknown" — operator should investigate via
    /// `system.log_tail`.
    Unknown,
}

// ---------- state.subscribe ----------

/// `state.subscribe` takes no params.
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
pub struct StateSubscribeParams {}

/// `state.subscribe` returns an opaque subscription handle. The
/// client passes it back to `state.unsubscribe` to stop the push
/// stream; SPEC §2.12.7 says subscriptions are auto-cleaned on
/// disconnect, so a well-behaved client never needs to remember
/// these across reconnects.
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct StateSubscribeResult {
    pub subscription: String,
}

/// `state.unsubscribe` params.
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct StateUnsubscribeParams {
    pub subscription: String,
}

// ---------- state.changed (push) ----------

/// Push payload for `state.changed`. Pushed by the agent when one
/// or more compliance checks flip status, or when `online` / `vpn`
/// / `agent_version` change. A full [`StateSnapshot`] is included
/// so the client doesn't need a second round-trip — the push is
/// strictly idempotent: applying a `state.changed` payload onto the
/// client's cached snapshot is a no-op replace, not a diff merge.
#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
pub struct StateChangedParams {
    /// Full snapshot at the time of the change.
    pub snapshot: StateSnapshot,
    /// Wall-clock when the agent detected the change. Lets the
    /// client surface "updated 3 s ago" without trusting its own
    /// clock for the agent's processing time.
    pub at: chrono::DateTime<chrono::Utc>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn check_status_serialises_snake_case() {
        for (variant, expected) in [
            (CheckStatus::Ok, "\"ok\""),
            (CheckStatus::Warn, "\"warn\""),
            (CheckStatus::Fail, "\"fail\""),
            (CheckStatus::Unknown, "\"unknown\""),
        ] {
            let s = serde_json::to_string(&variant).unwrap();
            assert_eq!(s, expected, "encode {variant:?}");
            let back: CheckStatus = serde_json::from_str(expected).unwrap();
            assert_eq!(back, variant, "round-trip {expected}");
        }
    }

    #[test]
    fn state_snapshot_spec_example_decodes() {
        // SPEC §2.12.8 — pinned so a rename can't drift the
        // documented contract.
        let wire = r#"{
            "pc_id":"PC1234","online":true,"vpn":"connected",
            "checks":[{"name":"bitlocker","status":"ok"},
                      {"name":"av_signature","status":"warn","detail":"3 日前"}],
            "agent_version":"0.4.0","target_version":"0.4.0"
        }"#;
        let s: StateSnapshot = serde_json::from_str(wire).expect("decode");
        assert_eq!(s.pc_id, "PC1234");
        assert!(s.online);
        assert_eq!(s.vpn, "connected");
        assert_eq!(s.checks.len(), 2);
        assert_eq!(s.checks[0].name, "bitlocker");
        assert_eq!(s.checks[0].status, CheckStatus::Ok);
        assert_eq!(s.checks[1].name, "av_signature");
        assert_eq!(s.checks[1].status, CheckStatus::Warn);
        assert_eq!(s.checks[1].detail.as_deref(), Some("3 日前"));
        assert_eq!(s.agent_version, "0.4.0");
        assert_eq!(s.target_version, "0.4.0");
    }

    #[test]
    fn check_with_troubleshoot_round_trips() {
        let c = Check {
            name: "av_signature".into(),
            status: CheckStatus::Fail,
            detail: Some("Signatures > 7 days old".into()),
            troubleshoot: Some("update-av-signatures".into()),
        };
        let json = serde_json::to_string(&c).unwrap();
        let back: Check = serde_json::from_str(&json).unwrap();
        assert_eq!(back.name, c.name);
        assert_eq!(back.status, c.status);
        assert_eq!(back.detail, c.detail);
        assert_eq!(back.troubleshoot, c.troubleshoot);
    }

    #[test]
    fn check_without_optional_fields_decodes() {
        // Minimal check — `detail` + `troubleshoot` should both be
        // absent on the wire (not `null`) thanks to
        // `skip_serializing_if`.
        let c = Check {
            name: "bitlocker".into(),
            status: CheckStatus::Ok,
            detail: None,
            troubleshoot: None,
        };
        let v = serde_json::to_value(&c).unwrap();
        assert!(v.get("detail").is_none(), "wire: {v:?}");
        assert!(v.get("troubleshoot").is_none(), "wire: {v:?}");
    }

    #[test]
    fn state_changed_push_round_trips() {
        let p = StateChangedParams {
            snapshot: StateSnapshot {
                pc_id: "PC1234".into(),
                online: true,
                vpn: "connected".into(),
                checks: vec![],
                agent_version: "0.4.0".into(),
                target_version: "0.4.0".into(),
            },
            at: chrono::Utc::now(),
        };
        let json = serde_json::to_string(&p).unwrap();
        let back: StateChangedParams = serde_json::from_str(&json).unwrap();
        assert_eq!(back.snapshot.pc_id, "PC1234");
    }
}