//! 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());
}
}
neiam
/devendra
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
17.5 KiB · text
History
d69022d