17.5 KiB · text History e832e77
//! Shared protocol types for devendra.
//!
//! This crate is deliberately dependency-light (no iroh, no tokio): it defines
//! *what* the controller and agent exchange, not *how*. The transport is an
//! [iroh-docs] document whose entries are keyed by the conventions in [`keys`].
//!
//! The controller is the sole writer of `manifest` and `files/*` entries for a
//! host; the agent is the sole writer of that host's `status` entry. File
//! bodies are stored as their own document entries (not just referenced by
//! hash) so that iroh-docs replicates them to the agent along with the
//! manifest — an unreferenced blob would not sync on its own.
//!
//! [iroh-docs]: https://docs.rs/iroh-docs

use serde::{Deserialize, Serialize};

pub mod keys;

/// Bumped whenever the shape of [`Manifest`] or [`Status`] changes in a way
/// that isn't backward compatible. Both sides compare this on load.
pub const PROTOCOL_VERSION: u32 = 1;

/// A stable identifier for a managed host, chosen by the operator (e.g.
/// `web-01`). Used to namespace document keys. Must be a valid path segment:
/// no `/` and no NUL.
pub type HostId = String;

/// The complete desired state for a single host at a point in time.
///
/// Reconciliation is idempotent: applying the same manifest twice makes no
/// changes the second time. The `generation` monotonically increases each time
/// the controller publishes a new desired state, letting the agent report
/// exactly which revision it has converged to.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Manifest {
    /// Protocol version this manifest was authored against.
    pub protocol_version: u32,
    /// Monotonic revision counter, incremented by the controller on each publish.
    pub generation: u64,
    /// System packages to install/remove. Reconciled first, since files and
    /// services may depend on installed packages.
    #[serde(default)]
    pub packages: Vec<PackageResource>,
    /// Directories to ensure exist. Reconciled before files, since files may
    /// live inside them.
    #[serde(default)]
    pub directories: Vec<DirResource>,
    /// Config files to install (podman quadlets, drop-ins, etc.).
    #[serde(default)]
    pub files: Vec<FileResource>,
    /// Services to bring to a desired state.
    #[serde(default)]
    pub services: Vec<ServiceResource>,
}

impl Manifest {
    /// An empty manifest at the given generation — a valid "manage nothing" state.
    pub fn empty(generation: u64) -> Self {
        Self {
            protocol_version: PROTOCOL_VERSION,
            generation,
            packages: Vec::new(),
            directories: Vec::new(),
            files: Vec::new(),
            services: Vec::new(),
        }
    }

    /// True if any installed/removed file lives under a systemd unit-generator
    /// or unit directory, meaning `systemctl daemon-reload` is required before
    /// acting on services. Quadlets (`*.container`, `*.pod`, …) live under
    /// these paths and are expanded by a generator at daemon-reload time.
    pub fn requires_daemon_reload(&self) -> bool {
        self.files.iter().any(|f| f.affects_systemd())
    }
}

/// A single config file the agent should install (or remove).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileResource {
    /// Absolute destination path on the target host.
    pub path: String,
    /// Desired state of the file.
    #[serde(default)]
    pub state: FileState,
    /// Hex-encoded BLAKE3 hash of the intended content. Lets the agent skip
    /// the content fetch and the write when the on-disk file already matches.
    /// `None` when `state` is [`FileState::Absent`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub content_blake3: Option<String>,
    /// Unix permission bits (e.g. `0o644`). `None` leaves existing mode alone
    /// on update, and defaults to `0o644` on create.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mode: Option<u32>,
    /// Owner username. `None` leaves ownership unmanaged.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub owner: Option<String>,
    /// Owner group name. `None` leaves group unmanaged.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub group: Option<String>,
}

impl FileResource {
    /// Whether installing/removing this file requires a `daemon-reload`. True
    /// for anything under systemd's unit or generator directories, which is
    /// where podman quadlets (`/etc/containers/systemd/…`) and unit files live.
    pub fn affects_systemd(&self) -> bool {
        const SYSTEMD_DIRS: &[&str] = &[
            "/etc/containers/systemd/",
            "/etc/systemd/",
            "/usr/lib/systemd/",
            "/run/systemd/",
            "/usr/local/lib/systemd/",
        ];
        SYSTEMD_DIRS.iter().any(|d| self.path.starts_with(d))
    }
}

/// A directory the agent should ensure exists (or is removed). Parent
/// directories are created as needed. Reconciling is idempotent: an existing
/// directory with the desired mode/owner is left untouched.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DirResource {
    /// Absolute path on the target host.
    pub path: String,
    /// Whether the directory should be present (default) or absent.
    #[serde(default)]
    pub state: FileState,
    /// Unix permission bits (e.g. `0o755`). `None` uses `0o755` on create and
    /// leaves an existing directory's mode alone.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mode: Option<u32>,
    /// Owner username. `None` leaves ownership unmanaged.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub owner: Option<String>,
    /// Owner group name. `None` leaves group unmanaged.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub group: Option<String>,
}

/// A system package to install or remove via the host's package manager
/// (zypper/dnf/apt/pacman, auto-detected by the agent).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackageResource {
    /// Package name as the host's package manager knows it.
    pub name: String,
    /// Whether the package should be installed ([`FileState::Present`], default)
    /// or removed ([`FileState::Absent`]).
    #[serde(default)]
    pub state: FileState,
}

/// Whether a [`FileResource`], [`DirResource`], or [`PackageResource`] should be
/// present or removed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FileState {
    /// File should exist with the given content/mode/owner.
    #[default]
    Present,
    /// File should not exist; removed if present.
    Absent,
}

/// A systemd service (unit) to reconcile toward a desired state. Both fields
/// are optional so a resource can manage only enablement, only run-state, or
/// both — mirroring Ansible's `service` module.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServiceResource {
    /// Unit name, including suffix (e.g. `myapp.service`, `myapp.socket`).
    pub name: String,
    /// Desired enablement (start on boot). `None` leaves it unmanaged.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub enabled: Option<bool>,
    /// Desired run-state. `None` leaves it unmanaged.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub state: Option<ServiceState>,
}

/// The run-state an agent should drive a unit to. `Restarted` and `Reloaded`
/// are actions rather than steady states: they are applied on every
/// reconcile only when the manifest generation is new or a dependency file
/// changed (see the reconciler for the exact policy).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ServiceState {
    /// Unit should be active; started if inactive.
    Started,
    /// Unit should be inactive; stopped if active.
    Stopped,
    /// Unit should be restarted this reconcile.
    Restarted,
    /// Unit should be reloaded this reconcile.
    Reloaded,
}

/// The agent's report of the outcome of applying a manifest, written back into
/// the document under the host's `status` key.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Status {
    pub protocol_version: u32,
    /// Which manifest generation this status reflects.
    pub generation: u64,
    /// Overall result.
    pub result: ApplyResult,
    /// Per-resource outcomes, in manifest order (files then services).
    #[serde(default)]
    pub changes: Vec<ChangeRecord>,
    /// Human-readable summary or top-level error message.
    #[serde(default)]
    pub message: String,
    /// Unix epoch milliseconds when the agent finished this reconcile. Supplied
    /// by the agent (this crate has no clock).
    pub applied_at_ms: u64,
}

/// Overall outcome of a reconcile.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApplyResult {
    /// All resources converged; nothing needed changing.
    Ok,
    /// All resources converged; one or more changes were made.
    Changed,
    /// At least one resource failed to converge.
    Failed,
}

/// The outcome for one file or service resource.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChangeRecord {
    /// Identifier of the resource (file path or unit name).
    pub target: String,
    /// What happened.
    pub action: ChangeAction,
    /// Detail on failure, or a note on success.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub detail: String,
}

/// What the reconciler did to a single resource.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangeAction {
    /// Already in the desired state; no action taken.
    Unchanged,
    /// Resource was created/installed.
    Created,
    /// Resource content/state was updated.
    Updated,
    /// Resource was removed.
    Removed,
    /// A service action (start/stop/restart/reload/enable/disable) was run.
    Acted,
    /// Reconciling this resource failed.
    Failed,
}

impl ChangeAction {
    /// Whether this action represents an actual change to the system.
    pub fn is_change(self) -> bool {
        !matches!(self, ChangeAction::Unchanged)
    }
}

/// Compute the hex-encoded BLAKE3 hash of `bytes`, matching the encoding used
/// in [`FileResource::content_blake3`].
pub fn content_hash(bytes: &[u8]) -> String {
    blake3::hash(bytes).to_hex().to_string()
}

/// Periodic host system metrics, written by the agent. Kept tiny and separate
/// from status/service reports so it can refresh on its own cadence.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HostMetrics {
    pub protocol_version: u32,
    /// Unix epoch milliseconds when the metrics were sampled.
    pub reported_at_ms: u64,
    /// Seconds since boot (`/proc/uptime`).
    pub uptime_secs: u64,
    /// Load averages over 1, 5, and 15 minutes (`/proc/loadavg`).
    pub load1: f64,
    pub load5: f64,
    pub load15: f64,
}

/// A periodic report of the *actual* runtime state of monitored services,
/// written by the agent to the host's `service-states` key. Distinct from
/// [`Status`] (which is the outcome of a reconcile): this reflects live systemd
/// state and is refreshed on its own cadence, only when something changes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServiceReport {
    pub protocol_version: u32,
    /// Unix epoch milliseconds when the agent last observed these states.
    pub reported_at_ms: u64,
    /// One entry per monitored service (those desired [`ServiceState::Started`]).
    #[serde(default)]
    pub services: Vec<ServiceRuntime>,
}

/// The observed runtime state of a single service.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServiceRuntime {
    /// Unit name, e.g. `web.service`.
    pub name: String,
    /// Traffic-light health derived from systemd's active state.
    pub health: ServiceHealth,
    /// Raw systemd active-state string (`active`, `failed`, `inactive`, …).
    pub detail: String,
}

/// Traffic-light health for a service that is desired running.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ServiceHealth {
    /// Green — active/running as desired.
    Ok,
    /// Yellow — transitional (activating, reloading, deactivating).
    Pending,
    /// Red — failed or inactive when it should be running.
    Bad,
    /// Grey — state could not be determined.
    Unknown,
}

impl ServiceHealth {
    /// Map a systemd `is-active` state string to a traffic-light health.
    pub fn from_active_state(state: &str) -> Self {
        match state {
            "active" => ServiceHealth::Ok,
            "activating" | "reloading" | "deactivating" => ServiceHealth::Pending,
            "failed" | "inactive" => ServiceHealth::Bad,
            _ => ServiceHealth::Unknown,
        }
    }
}

/// An agent's proposal to adopt existing on-disk files into management.
///
/// Written by the agent under the host's `adopt/` keyspace (see [`keys`]),
/// never into the controller-owned manifest. The controller shows it for
/// review; on acceptance the chosen files become normal managed config. Each
/// file's body is stored as its own entry (`adopt/files/<path>`) so bodies
/// replicate alongside the report, mirroring the managed-files layout.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptionReport {
    pub protocol_version: u32,
    /// Absolute directory the agent scanned (e.g. `/etc/containers/systemd`).
    pub scan_dir: String,
    /// Unix epoch milliseconds when the scan ran (supplied by the agent).
    pub discovered_at_ms: u64,
    /// The files found, in scan order.
    #[serde(default)]
    pub files: Vec<AdoptedFile>,
}

/// One file discovered by an adoption scan.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptedFile {
    /// Absolute path on the agent's machine.
    pub path: String,
    /// Path relative to the scan directory — used to name the stored copy and
    /// preserve any subdirectory structure on acceptance.
    pub rel_path: String,
    /// Hex-encoded BLAKE3 of the body (matches the `adopt/files/<path>` entry).
    pub content_blake3: String,
    /// Permission bits (mode & 0o7777).
    pub mode: u32,
    /// Body length in bytes, for display.
    pub size: u64,
}

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

    #[test]
    fn manifest_roundtrips_through_json() {
        let m = Manifest {
            protocol_version: PROTOCOL_VERSION,
            generation: 7,
            packages: vec![PackageResource {
                name: "podman".into(),
                state: FileState::Present,
            }],
            directories: vec![DirResource {
                path: "/mnt/hass".into(),
                state: FileState::Present,
                mode: Some(0o755),
                owner: None,
                group: None,
            }],
            files: vec![FileResource {
                path: "/etc/containers/systemd/web.container".into(),
                state: FileState::Present,
                content_blake3: Some(content_hash(b"[Container]\n")),
                mode: Some(0o644),
                owner: None,
                group: None,
            }],
            services: vec![ServiceResource {
                name: "web.service".into(),
                enabled: Some(true),
                state: Some(ServiceState::Started),
            }],
        };
        let json = serde_json::to_vec(&m).unwrap();
        let back: Manifest = serde_json::from_slice(&json).unwrap();
        assert_eq!(m, back);
    }

    #[test]
    fn service_health_maps_from_systemd_state() {
        assert_eq!(
            ServiceHealth::from_active_state("active"),
            ServiceHealth::Ok
        );
        assert_eq!(
            ServiceHealth::from_active_state("activating"),
            ServiceHealth::Pending
        );
        assert_eq!(
            ServiceHealth::from_active_state("reloading"),
            ServiceHealth::Pending
        );
        assert_eq!(
            ServiceHealth::from_active_state("failed"),
            ServiceHealth::Bad
        );
        assert_eq!(
            ServiceHealth::from_active_state("inactive"),
            ServiceHealth::Bad
        );
        assert_eq!(
            ServiceHealth::from_active_state("whatever"),
            ServiceHealth::Unknown
        );
        // Serializes to a lowercase CSS-class-friendly string.
        let json = serde_json::to_string(&ServiceHealth::Ok).unwrap();
        assert_eq!(json, "\"ok\"");
    }

    #[test]
    fn quadlet_paths_require_daemon_reload() {
        let mut m = Manifest::empty(1);
        assert!(!m.requires_daemon_reload());
        m.files.push(FileResource {
            path: "/etc/containers/systemd/web.container".into(),
            state: FileState::Present,
            content_blake3: Some(content_hash(b"x")),
            mode: None,
            owner: None,
            group: None,
        });
        assert!(m.requires_daemon_reload());
    }

    #[test]
    fn plain_config_file_does_not_require_reload() {
        let mut m = Manifest::empty(1);
        m.files.push(FileResource {
            path: "/etc/myapp/config.toml".into(),
            state: FileState::Present,
            content_blake3: Some(content_hash(b"x")),
            mode: None,
            owner: None,
            group: None,
        });
        assert!(!m.requires_daemon_reload());
    }
}