diff --git a/crates/pearlite-state/Cargo.toml b/crates/pearlite-state/Cargo.toml index 244ac3b..a2edd35 100644 --- a/crates/pearlite-state/Cargo.toml +++ b/crates/pearlite-state/Cargo.toml @@ -12,3 +12,11 @@ repository.workspace = true workspace = true [dependencies] +serde = { workspace = true } +toml = { workspace = true } +time = { workspace = true } +uuid = { workspace = true } +thiserror = { workspace = true } +schemars = { workspace = true } +# toml_edit lands in chunk M1-W1-E with the atomic-write implementation +# that needs it for unknown-field preservation. diff --git a/crates/pearlite-state/src/errors.rs b/crates/pearlite-state/src/errors.rs new file mode 100644 index 0000000..d659696 --- /dev/null +++ b/crates/pearlite-state/src/errors.rs @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Mohamed Hammad + +//! Errors emitted by `pearlite-state`. + +use std::path::PathBuf; +use thiserror::Error; + +/// Errors emitted while reading, writing, or migrating `state.toml`. +#[derive(Debug, Error)] +pub enum StateError { + /// The TOML failed to parse. + #[error("invalid TOML in state file: {0}")] + InvalidToml(#[from] toml::de::Error), + /// `state.toml` was not found at the expected path. + #[error("state file not found: {0}")] + NotFound(PathBuf), + /// I/O error while reading or writing the file. + #[error("I/O error on state file: {0}")] + Io(#[from] std::io::Error), + /// Unknown `schema_version` — the on-disk file is from a future + /// Pearlite release and cannot be read by this build. + #[error("unknown schema_version {found}; this build supports up to {supported}")] + UnsupportedSchemaVersion { + /// Version found in the file. + found: u32, + /// Highest version this build can read. + supported: u32, + }, + /// `state.host` does not match the system `hostname()`. + #[error("state.host '{state_host}' does not match system hostname '{system_host}'")] + HostMismatch { + /// Host recorded in `state.toml`. + state_host: String, + /// Live `hostname(1)` value. + system_host: String, + }, + /// TOML serialisation failed (should be unreachable for well-formed + /// `State` values). + #[error("could not serialize state: {0}")] + SerializeFailed(#[from] toml::ser::Error), +} diff --git a/crates/pearlite-state/src/failure.rs b/crates/pearlite-state/src/failure.rs new file mode 100644 index 0000000..0b0e3cd --- /dev/null +++ b/crates/pearlite-state/src/failure.rs @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Mohamed Hammad + +//! `[[failures]]` records — pointers to per-plan failure JSON files. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use time::OffsetDateTime; +use uuid::Uuid; + +/// One entry in `state.toml`'s `[[failures]]` array. +/// +/// The full forensic record (stderr, `investigate_commands`, +/// `rollback_command`, …) lives in `/var/lib/pearlite/failures/.json` +/// per PRD §11.4. This entry holds only the pointer plus enough metadata +/// to render `pearlite gen list` without reading every JSON record. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct FailureRef { + /// Plan UUID this failure pertains to. + #[schemars(with = "String")] + pub plan_id: Uuid, + /// UTC timestamp the apply failed. + #[serde(with = "time::serde::iso8601")] + #[schemars(with = "String")] + pub failed_at: OffsetDateTime, + /// Failure class per PRD §8.5: 1 preflight, 2 plan, 3 recoverable, + /// 4 incoherent, 5 catastrophic. + pub class: u8, + /// Process exit code at failure. + pub exit_code: u8, + /// Path to the JSON record under `/var/lib/pearlite/failures/`. + pub record_path: PathBuf, +} diff --git a/crates/pearlite-state/src/history.rs b/crates/pearlite-state/src/history.rs new file mode 100644 index 0000000..8e006d8 --- /dev/null +++ b/crates/pearlite-state/src/history.rs @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Mohamed Hammad + +//! `[[history]]` and snapshot-reference records. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +/// One entry in `state.toml`'s `[[history]]` array — a summary of one +/// successful `apply`. The full plan and per-action breakdown live in +/// `/var/lib/pearlite/plans/.json`, referenced by `plan_id`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct HistoryEntry { + /// Plan UUID (canonical persistent identifier). + #[schemars(with = "String")] + pub plan_id: Uuid, + /// Host-scoped monotonic generation number. + pub generation: u64, + /// UTC timestamp the apply completed successfully. + #[serde(with = "time::serde::iso8601")] + #[schemars(with = "String")] + pub applied_at: OffsetDateTime, + /// Wall-clock duration of the apply, in milliseconds. + pub duration_ms: u64, + /// Pre-apply snapshot reference. + pub snapshot_pre: SnapshotRef, + /// Post-apply snapshot reference. + pub snapshot_post: SnapshotRef, + /// Number of actions executed in this apply. + pub actions_executed: u32, + /// Git revision of the config repo at apply time, if available. + #[serde(default)] + pub git_revision: Option, + /// Whether the config repo had uncommitted changes at apply time. + #[serde(default)] + pub git_dirty: bool, + /// One-line summary, e.g. `"+8 -2 ~4 (8 installs, 2 removals, 4 config updates)"`. + pub summary: String, +} + +/// Reference to a Snapper snapshot. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct SnapshotRef { + /// Snapper snapshot ID. + pub id: u64, + /// Snapper snapshot label. + pub label: String, + /// UTC creation timestamp. + #[serde(with = "time::serde::iso8601")] + #[schemars(with = "String")] + pub created_at: OffsetDateTime, +} diff --git a/crates/pearlite-state/src/lib.rs b/crates/pearlite-state/src/lib.rs index a48b36b..90b3689 100644 --- a/crates/pearlite-state/src/lib.rs +++ b/crates/pearlite-state/src/lib.rs @@ -2,3 +2,23 @@ // Copyright (C) 2026 Mohamed Hammad //! Persistent Pearlite state: atomic read/write, migrations, history. +//! +//! [`State`] is the in-memory mirror of `/var/lib/pearlite/state.toml` — +//! the load-bearing artifact in PRD §7. The `read` / `write_atomic` / +//! `append_*` operations land in chunk M1-W1-E; this scaffold defines the +//! types and errors only. + +mod errors; +mod failure; +mod history; +mod reconciliation; +mod schema; + +pub use errors::StateError; +pub use failure::FailureRef; +pub use history::{HistoryEntry, SnapshotRef}; +pub use reconciliation::{ReconciliationAction, ReconciliationEntry}; +pub use schema::{ + Adopted, ConfigFileRecord, KernelRecord, Managed, SCHEMA_VERSION, ServicesState, State, + UserEnvRecord, +}; diff --git a/crates/pearlite-state/src/reconciliation.rs b/crates/pearlite-state/src/reconciliation.rs new file mode 100644 index 0000000..12b052e --- /dev/null +++ b/crates/pearlite-state/src/reconciliation.rs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Mohamed Hammad + +//! `[[reconciliations]]` records — one per `pearlite reconcile --commit`. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +/// One entry in `state.toml`'s `[[reconciliations]]` array. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ReconciliationEntry { + /// Plan UUID generated for this reconciliation. + #[schemars(with = "String")] + pub plan_id: Uuid, + /// UTC timestamp when the reconciliation committed. + #[serde(with = "time::serde::iso8601")] + #[schemars(with = "String")] + pub committed_at: OffsetDateTime, + /// Resolution chosen for the drift items at commit time. + pub action: ReconciliationAction, + /// Number of packages classified during this reconciliation. + pub package_count: u32, +} + +/// How a reconciliation resolved its detected drift. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ReconciliationAction { + /// Non-interactive `--adopt-all`: every drift item moved to `adopted`. + AdoptAll, + /// Interactive prompt path: per-package decisions taken. + Interactive, + /// Reconciliation aborted before any state was written. + Skipped, +} diff --git a/crates/pearlite-state/src/schema.rs b/crates/pearlite-state/src/schema.rs new file mode 100644 index 0000000..332f955 --- /dev/null +++ b/crates/pearlite-state/src/schema.rs @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Mohamed Hammad + +//! Top-level [`State`] struct and the `[managed.*]` / `[adopted.*]` records +//! it owns. + +use crate::failure::FailureRef; +use crate::history::HistoryEntry; +use crate::reconciliation::ReconciliationEntry; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::PathBuf; +use time::OffsetDateTime; + +/// Current `schema_version` written by this build. +/// +/// Incremented when the on-disk shape changes incompatibly. Migration +/// from older versions lands in `migrate.rs` (chunk M1-W1-F). +pub const SCHEMA_VERSION: u32 = 1; + +/// In-memory mirror of `/var/lib/pearlite/state.toml`. +/// +/// The full schema is specified in PRD §7.2. `State` is mutated only by +/// `pearlite-engine`; every other crate consumes it read-only. +/// +/// `Eq` is intentionally **not** derived: the `reserved` field carries a +/// `toml::Value` map for forward-compatibility, and `toml::Value::Float` +/// wraps an `f64` that is not `Eq`. Use `PartialEq` (which is derived) +/// for equality checks in tests. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct State { + /// Schema version of the file at last write. + pub schema_version: u32, + /// Hostname this state belongs to. Mismatch with `hostname()` is + /// preflight Class 1. + pub host: String, + /// Pearlite version that last wrote the file. + pub tool_version: String, + /// Path to the user's Pearlite config repository at last apply. + pub config_dir: PathBuf, + /// Timestamp of the last successful `apply`. + #[serde(with = "time::serde::iso8601::option", default)] + #[schemars(with = "Option")] + pub last_apply: Option, + /// Timestamp of the last write of any kind. + #[serde(with = "time::serde::iso8601::option", default)] + #[schemars(with = "Option")] + pub last_modified: Option, + /// Pearlite-managed packages, configs, services, kernel, and user envs. + #[serde(default)] + pub managed: Managed, + /// User-flagged "leave alone" packages. + #[serde(default)] + pub adopted: Adopted, + /// Append-only log of successful applies. + #[serde(default)] + pub history: Vec, + /// Log of `pearlite reconcile --commit` invocations. + #[serde(default)] + pub reconciliations: Vec, + /// Pointers to `/var/lib/pearlite/failures/.json` records. + #[serde(default)] + pub failures: Vec, + /// Forward-compat namespace; arbitrary unknown keys preserved on + /// round-trip via the toml_edit-based atomic writer. Excluded from + /// the JSON Schema because its values are untyped by design. + #[serde(default)] + #[schemars(skip)] + pub reserved: BTreeMap, +} + +/// `[managed.*]` namespace: everything Pearlite has installed or written. +#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +pub struct Managed { + /// pacman/AUR packages installed via Pearlite. + #[serde(default)] + pub pacman: Vec, + /// Cargo crates installed via Pearlite. + #[serde(default)] + pub cargo: Vec, + /// Per-file metadata for managed `/etc` config files. + #[serde(default)] + pub config_files: Vec, + /// systemd unit state at last apply. + #[serde(default)] + pub services: ServicesState, + /// Per-user Home Manager generation and config hash. + #[serde(default)] + pub user_env: Vec, + /// Kernel package, version, cmdline, modules at last apply. + #[serde(default)] + pub kernel: Option, +} + +/// One managed `/etc` config file's record. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ConfigFileRecord { + /// Absolute destination path on the host. + pub target: PathBuf, + /// Source path within the user's config repo. + pub source_in_repo: PathBuf, + /// SHA-256 of the file contents at last successful write, hex-encoded. + pub sha256: String, + /// File mode (`stat(2).st_mode & 0o7777`). + pub mode: u32, + /// Owning user. + pub owner: String, + /// Owning group. + pub group: String, +} + +/// systemd-unit state recorded by Pearlite at apply time. +#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +pub struct ServicesState { + /// Units enabled (and started) by Pearlite. + #[serde(default)] + pub enabled: Vec, + /// Units disabled by Pearlite. + #[serde(default)] + pub disabled: Vec, + /// Units masked by Pearlite. + #[serde(default)] + pub masked: Vec, +} + +/// One user's Home Manager generation pointer. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct UserEnvRecord { + /// Login name. + pub user: String, + /// Home Manager generation number at last apply. + pub generation: u64, + /// SHA-256 of the user's HM config directory at last apply. + pub config_hash: String, +} + +/// Kernel record: package, version, cmdline, modules at last apply. +#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +pub struct KernelRecord { + /// Kernel package name (e.g. `linux-cachyos`). + pub package: String, + /// Installed kernel version string. + pub version: String, + /// Kernel command-line as last applied. + #[serde(default)] + pub cmdline: Vec, + /// Loaded modules as last applied. + #[serde(default)] + pub modules: Vec, +} + +/// `[adopted.*]` namespace: drift items the user has explicitly told +/// Pearlite to leave alone. +#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +pub struct Adopted { + /// Adopted pacman/AUR packages. + #[serde(default)] + pub pacman: Vec, + /// Adopted cargo crates. + #[serde(default)] + pub cargo: Vec, +}