Fase 3 — diario de mutaciones (hammer-journal + fanotify + hammer journal)
Nuevo crate hammer-journal y watcher fanotify en hammerd. Cierra el contrato
del SDD 05 §3 ("toda mutación trazable/opaca registrada y consultable") y
liquida la deuda Fase 2 sobre el hook al diario en commit.
hammer-journal:
- MutationEvent (op, path, by, content_hash?, note?), MutationOp
Create/Replace/Edit/Delete, Source enum tagged HammerHydrate /
HammerCommit / External (distingue trazable de opaco para `export`).
- Journal: record append-only + sync_data, read_all/tail/follow (offset),
resilencia a líneas malformadas (kernel-panic mid-write se ignora).
- RFC 3339 UTC sin chrono (Hinnant civil_from_days, válido para todo i64).
- 7 unit tests cubren append/read/tail/follow/malformed/serde/timestamp.
Watcher (hammerd):
- nix::sys::fanotify con FAN_CLASS_NOTIF + FAN_CLOSE_WRITE +
FAN_EVENT_ON_CHILD sobre /bin /sbin /usr/bin /usr/sbin /lib /usr/lib /etc.
- Resuelve path por readlink(/proc/self/fd/<n>) — sin FAN_REPORT_DFID_NAME
para Fase 3 v1; el refinamiento Create/Edit con metadata viene después.
- Filtro: hammer_overlay::status(state_root) → ignora paths bajo un overlay
activo (el ruido del experimento no entra hasta el commit).
- Sin CAP_SYS_ADMIN, fanotify_init falla; hammerd lo reporta y sigue
durmiendo (preparado para que Fase 5 monte el bus encima).
- 3 unit tests sobre defaults/filtros/uid lookup.
CLI:
- hammer journal [--dir] [--tail N] [--follow] [--format pretty|json].
- hammer commit registra cada archivo promocionado/removido vía
commit_with_journal; --no-journal opt-out.
Hook overlay::commit:
- commit_with_journal(id, root, journal): para cada copied/removed emite un
MutationEvent con Source::HammerCommit { overlay }. Test directo del hook
sin overlayfs (lógica pura) más el e2e existente que ya cubre los mounts.
57 tests workspace, 0 fallos.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
fe1601e669
commit
a87b16dd85
Generated
+41
@@ -124,6 +124,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.1"
|
version = "4.6.1"
|
||||||
@@ -293,7 +299,9 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"hammer-build",
|
"hammer-build",
|
||||||
"hammer-core",
|
"hammer-core",
|
||||||
|
"hammer-journal",
|
||||||
"hammer-overlay",
|
"hammer-overlay",
|
||||||
|
"serde_json",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
@@ -312,12 +320,27 @@ dependencies = [
|
|||||||
"toml",
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hammer-journal"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"hammer-core",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hammer-overlay"
|
name = "hammer-overlay"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"hammer-core",
|
"hammer-core",
|
||||||
|
"hammer-journal",
|
||||||
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -333,6 +356,12 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
"hammer-core",
|
"hammer-core",
|
||||||
|
"hammer-journal",
|
||||||
|
"hammer-overlay",
|
||||||
|
"libc",
|
||||||
|
"nix",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
@@ -439,6 +468,18 @@ version = "2.8.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.30.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ members = [
|
|||||||
"crates/hammer-core",
|
"crates/hammer-core",
|
||||||
"crates/hammer-build",
|
"crates/hammer-build",
|
||||||
"crates/hammer-overlay",
|
"crates/hammer-overlay",
|
||||||
|
"crates/hammer-journal",
|
||||||
"crates/hammer-cli",
|
"crates/hammer-cli",
|
||||||
"crates/hammerd",
|
"crates/hammerd",
|
||||||
]
|
]
|
||||||
@@ -19,6 +20,7 @@ repository = "https://gitea.gioser.net/sergio/hammer"
|
|||||||
hammer-core = { path = "crates/hammer-core" }
|
hammer-core = { path = "crates/hammer-core" }
|
||||||
hammer-build = { path = "crates/hammer-build" }
|
hammer-build = { path = "crates/hammer-build" }
|
||||||
hammer-overlay = { path = "crates/hammer-overlay" }
|
hammer-overlay = { path = "crates/hammer-overlay" }
|
||||||
|
hammer-journal = { path = "crates/hammer-journal" }
|
||||||
|
|
||||||
# shared third-party deps, pinned at the workspace level
|
# shared third-party deps, pinned at the workspace level
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
@@ -30,6 +32,7 @@ toml = "0.8"
|
|||||||
blake3 = "1"
|
blake3 = "1"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
nix = { version = "0.30", default-features = false, features = ["fanotify", "fs", "user"] }
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ path = "src/main.rs"
|
|||||||
hammer-core.workspace = true
|
hammer-core.workspace = true
|
||||||
hammer-build.workspace = true
|
hammer-build.workspace = true
|
||||||
hammer-overlay.workspace = true
|
hammer-overlay.workspace = true
|
||||||
|
hammer-journal.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
|||||||
@@ -52,11 +52,18 @@ enum Cmd {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
state_root: Option<PathBuf>,
|
state_root: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// [Fase 2] Fusiona un overlay al FHS real y desmonta. Sin diario aún (Fase 3).
|
/// [Fase 2+3] Fusiona un overlay al FHS real y desmonta. Por defecto registra cada
|
||||||
|
/// archivo promocionado en el diario; usa `--no-journal` para saltarse el registro.
|
||||||
Commit {
|
Commit {
|
||||||
id: String,
|
id: String,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
state_root: Option<PathBuf>,
|
state_root: Option<PathBuf>,
|
||||||
|
/// Directorio del diario donde anotar la promoción.
|
||||||
|
#[arg(long, default_value = "/var/lib/hammer/journal")]
|
||||||
|
journal: PathBuf,
|
||||||
|
/// Saltarse el registro en el diario (útil para experimentos efímeros).
|
||||||
|
#[arg(long)]
|
||||||
|
no_journal: bool,
|
||||||
},
|
},
|
||||||
/// [Fase 2] Desmonta un overlay y borra su upper. El sistema vuelve al estado base.
|
/// [Fase 2] Desmonta un overlay y borra su upper. El sistema vuelve al estado base.
|
||||||
Discard {
|
Discard {
|
||||||
@@ -70,7 +77,20 @@ enum Cmd {
|
|||||||
state_root: Option<PathBuf>,
|
state_root: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// [Fase 3] Ver/seguir el diario de mutaciones.
|
/// [Fase 3] Ver/seguir el diario de mutaciones.
|
||||||
Journal,
|
Journal {
|
||||||
|
/// Directorio del diario (mismo que `hammerd --journal`).
|
||||||
|
#[arg(long, default_value = "/var/lib/hammer/journal")]
|
||||||
|
dir: PathBuf,
|
||||||
|
/// Muestra los últimos N eventos. Default: 20.
|
||||||
|
#[arg(long, short, default_value = "20")]
|
||||||
|
tail: usize,
|
||||||
|
/// Sigue el archivo: imprime nuevos eventos según aparecen.
|
||||||
|
#[arg(long, short)]
|
||||||
|
follow: bool,
|
||||||
|
/// Formato. `pretty` (humano, default) o `json` (raw passthrough).
|
||||||
|
#[arg(long, default_value = "pretty")]
|
||||||
|
format: String,
|
||||||
|
},
|
||||||
/// [Fase 4] Aplica un manifiesto .swm (reproduce y lo deja en un overlay).
|
/// [Fase 4] Aplica un manifiesto .swm (reproduce y lo deja en un overlay).
|
||||||
Apply { file: String },
|
Apply { file: String },
|
||||||
/// [Fase 4] Exporta el delta del sistema como manifiesto .swm.
|
/// [Fase 4] Exporta el delta del sistema como manifiesto .swm.
|
||||||
@@ -82,6 +102,39 @@ enum Cmd {
|
|||||||
Ctl { line: String },
|
Ctl { line: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn print_event(ev: &hammer_journal::MutationEvent, format: &str) {
|
||||||
|
match format {
|
||||||
|
"json" => {
|
||||||
|
// Re-serializa. No imprimimos la línea original (no la tenemos a mano), pero el
|
||||||
|
// formato es estable porque MutationEvent es la fuente de verdad del schema.
|
||||||
|
match serde_json::to_string(ev) {
|
||||||
|
Ok(s) => println!("{s}"),
|
||||||
|
Err(e) => eprintln!("journal: error serializando: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let actor = match &ev.by.source {
|
||||||
|
hammer_journal::Source::HammerHydrate { artifact } => format!("hydrate {artifact}"),
|
||||||
|
hammer_journal::Source::HammerCommit { overlay, artifact } => match artifact {
|
||||||
|
Some(a) => format!("commit {overlay} ({a})"),
|
||||||
|
None => format!("commit {overlay}"),
|
||||||
|
},
|
||||||
|
hammer_journal::Source::External => {
|
||||||
|
let pid = ev.by.pid.map(|p| format!("pid={p}")).unwrap_or_default();
|
||||||
|
let uid = ev.by.uid.map(|u| format!("uid={u}")).unwrap_or_default();
|
||||||
|
format!("external {pid} {uid}").trim().to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{ts} {op:<7} {path} ← {actor}",
|
||||||
|
ts = ev.ts,
|
||||||
|
op = ev.op.as_str(),
|
||||||
|
path = ev.path.display(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_writer(std::io::stderr)
|
.with_writer(std::io::stderr)
|
||||||
@@ -134,14 +187,21 @@ fn main() -> anyhow::Result<()> {
|
|||||||
let id = hammer_overlay::try_overlay(&targets, &root)?;
|
let id = hammer_overlay::try_overlay(&targets, &root)?;
|
||||||
println!("{id}");
|
println!("{id}");
|
||||||
}
|
}
|
||||||
Cmd::Commit { id, state_root } => {
|
Cmd::Commit { id, state_root, journal, no_journal } => {
|
||||||
let root = state_root
|
let root = state_root
|
||||||
.unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
|
.unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
|
||||||
let report = hammer_overlay::commit(&hammer_overlay::OverlayId(id), &root)?;
|
let id = hammer_overlay::OverlayId(id);
|
||||||
|
let report = if no_journal {
|
||||||
|
hammer_overlay::commit(&id, &root)?
|
||||||
|
} else {
|
||||||
|
let j = hammer_journal::Journal::open(&journal)?;
|
||||||
|
hammer_overlay::commit_with_journal(&id, &root, &j)?
|
||||||
|
};
|
||||||
println!(
|
println!(
|
||||||
"commit OK — {} archivo(s) promocionados, {} eliminado(s)",
|
"commit OK — {} archivo(s) promocionados, {} eliminado(s){}",
|
||||||
report.copied.len(),
|
report.copied.len(),
|
||||||
report.removed.len()
|
report.removed.len(),
|
||||||
|
if no_journal { "" } else { " (anotado en el diario)" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Cmd::Discard { id, state_root } => {
|
Cmd::Discard { id, state_root } => {
|
||||||
@@ -170,8 +230,26 @@ fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Cmd::Journal => {
|
Cmd::Journal { dir, tail, follow, format } => {
|
||||||
println!("[fase 3 pendiente] diario — ver docs/05-journal.md");
|
let j = hammer_journal::Journal::open(&dir)?;
|
||||||
|
let initial = j.tail(tail)?;
|
||||||
|
for ev in &initial {
|
||||||
|
print_event(ev, &format);
|
||||||
|
}
|
||||||
|
if follow {
|
||||||
|
use std::io::Write;
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
let mut cursor = j.end_offset()?;
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
let (news, new_cursor) = j.read_from_offset(cursor)?;
|
||||||
|
cursor = new_cursor;
|
||||||
|
for ev in &news {
|
||||||
|
print_event(ev, &format);
|
||||||
|
}
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Cmd::Apply { file } => {
|
Cmd::Apply { file } => {
|
||||||
println!("[fase 4 pendiente] apply {file} — ver docs/06-swm-format.md");
|
println!("[fase 4 pendiente] apply {file} — ver docs/06-swm-format.md");
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "hammer-journal"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
description = "El diario de mutaciones de hammer: append-only JSON-líneas sobre eventos del FHS."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
hammer-core.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
//! El diario de mutaciones. Ver `docs/05-journal.md`.
|
||||||
|
//!
|
||||||
|
//! Formato: archivo único de **JSON-líneas append-only** (`mutations.jsonl`). Cada línea es
|
||||||
|
//! un `MutationEvent` autónomo; `cat` / `grep` / `jq` los procesan sin parseo extra.
|
||||||
|
//!
|
||||||
|
//! Esta crate es **lógica pura**: nada de fanotify ni de mounts. El watcher real vive en
|
||||||
|
//! `hammerd` y empuja eventos aquí vía `Journal::record`. El hook desde
|
||||||
|
//! `hammer_overlay::commit` también empuja por aquí.
|
||||||
|
//!
|
||||||
|
//! Diseño rationale (SDD 05):
|
||||||
|
//! - El diario no bloquea acciones; sólo observa. Pero también acepta eventos "trazables"
|
||||||
|
//! (de un `hydrate` o `commit`) que llegan con `actor.source = HammerHydrate { artifact }`.
|
||||||
|
//! - Las "mutaciones opacas" (pisada manual sin rastro de artefacto) llegan con
|
||||||
|
//! `actor.source = External { pid, uid }`. Eso permite que `hammer export` distinga
|
||||||
|
//! replays reproducibles de cambios que sólo se pueden compartir como warnings.
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Operación sobre el FHS. Ver SDD 05 §2 — la lista evolucionará con los eventos que el
|
||||||
|
/// watcher fanotify aprenda a distinguir.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum MutationOp {
|
||||||
|
/// Archivo nuevo donde antes no había nada.
|
||||||
|
Create,
|
||||||
|
/// Pisado: el archivo existía y fue sustituido (mismo path, distinto contenido o inode).
|
||||||
|
Replace,
|
||||||
|
/// Edición en sitio: el archivo cambió sin que cambiara el path (típico de configs en /etc).
|
||||||
|
Edit,
|
||||||
|
/// Borrado.
|
||||||
|
Delete,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MutationOp {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
MutationOp::Create => "create",
|
||||||
|
MutationOp::Replace => "replace",
|
||||||
|
MutationOp::Edit => "edit",
|
||||||
|
MutationOp::Delete => "delete",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quién originó la mutación. Distinguir trazable vs opaca es lo que más tarde permite
|
||||||
|
/// que `hammer export` produzca un `.swm` reproducible (artefactos) más una lista de
|
||||||
|
/// warnings (pisadas manuales).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "kebab-case")]
|
||||||
|
pub enum Source {
|
||||||
|
/// Vino de un `hammer hydrate <artifact>` — la mutación es replay-able a partir del hash.
|
||||||
|
HammerHydrate { artifact: String },
|
||||||
|
/// Vino de un `hammer commit <overlay>` — origen indirecto: el `actor` fue el overlay,
|
||||||
|
/// y `artifact` es opcional (puede no haber, si la promoción fue de un edit manual).
|
||||||
|
HammerCommit {
|
||||||
|
overlay: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
artifact: Option<String>,
|
||||||
|
},
|
||||||
|
/// Pisada manual fuera del flujo de hammer. La grabamos sin juzgar — el SDD insiste en
|
||||||
|
/// que es información, no error.
|
||||||
|
External,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Actor {
|
||||||
|
pub source: Source,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pid: Option<u32>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub uid: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Un evento atómico — una línea del diario.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MutationEvent {
|
||||||
|
/// RFC 3339 UTC. El watcher fija el timestamp en el momento de la observación.
|
||||||
|
pub ts: String,
|
||||||
|
pub op: MutationOp,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub by: Actor,
|
||||||
|
/// Opcional: hash blake3 del contenido del archivo tras la mutación. Permite a `export`
|
||||||
|
/// detectar mutaciones idempotentes (mismo contenido tras N replaces → un sólo evento).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub content_hash: Option<String>,
|
||||||
|
/// Nota libre (humana). Útil para que la IA explique su intención.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle del diario: una ruta a `mutations.jsonl`. Las operaciones son thread-safe en el
|
||||||
|
/// sentido de que cada `record` abre + append + cierra; suficiente para el throughput
|
||||||
|
/// esperado (cambios humanos, no log-de-tracing).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Journal {
|
||||||
|
pub dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("io: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("json: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
#[error("journal: {0}")]
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
impl Journal {
|
||||||
|
/// Abre (creando el directorio si hace falta) un diario bajo `dir`. El archivo
|
||||||
|
/// `mutations.jsonl` se crea en el primer `record`.
|
||||||
|
pub fn open(dir: impl Into<PathBuf>) -> Result<Self> {
|
||||||
|
let dir = dir.into();
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
Ok(Self { dir })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_path(&self) -> PathBuf {
|
||||||
|
self.dir.join("mutations.jsonl")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append-only. Una línea JSON + `\n`. Maneja la creación del archivo si no existe.
|
||||||
|
pub fn record(&self, ev: &MutationEvent) -> Result<()> {
|
||||||
|
let line = serde_json::to_string(ev)?;
|
||||||
|
let mut f = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(self.file_path())?;
|
||||||
|
f.write_all(line.as_bytes())?;
|
||||||
|
f.write_all(b"\n")?;
|
||||||
|
f.sync_data()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lee todos los eventos en orden de aparición. Líneas vacías o malformadas se loguean
|
||||||
|
/// y se saltan — el diario es append-only pero queremos resilencia a truncados de
|
||||||
|
/// emergencia (kernel-panic en medio de una escritura).
|
||||||
|
pub fn read_all(&self) -> Result<Vec<MutationEvent>> {
|
||||||
|
let path = self.file_path();
|
||||||
|
if !path.is_file() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let f = std::fs::File::open(&path)?;
|
||||||
|
let rdr = BufReader::new(f);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (i, line) in rdr.lines().enumerate() {
|
||||||
|
let line = line?;
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match serde_json::from_str::<MutationEvent>(&line) {
|
||||||
|
Ok(ev) => out.push(ev),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(line = i + 1, error = %e, "journal: línea malformada, saltando");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lee los últimos `n` eventos sin cargar todo el archivo. Implementación naïve por
|
||||||
|
/// ahora (delega a `read_all` y trunca); cuando el archivo crezca añadiremos un índice
|
||||||
|
/// por bloques.
|
||||||
|
pub fn tail(&self, n: usize) -> Result<Vec<MutationEvent>> {
|
||||||
|
let mut all = self.read_all()?;
|
||||||
|
if all.len() > n {
|
||||||
|
all.drain(..(all.len() - n));
|
||||||
|
}
|
||||||
|
Ok(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Devuelve el offset en bytes del fin de archivo actual. Útil para implementar un
|
||||||
|
/// "follow" en CLI: tras un read inicial, hacemos polling desde este offset.
|
||||||
|
pub fn end_offset(&self) -> Result<u64> {
|
||||||
|
let path = self.file_path();
|
||||||
|
if !path.is_file() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let m = std::fs::metadata(&path)?;
|
||||||
|
Ok(m.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lee eventos desde un offset en bytes hasta el final actual. Devuelve los eventos y el
|
||||||
|
/// nuevo offset (para usar en la siguiente iteración del follow).
|
||||||
|
pub fn read_from_offset(&self, offset: u64) -> Result<(Vec<MutationEvent>, u64)> {
|
||||||
|
let path = self.file_path();
|
||||||
|
if !path.is_file() {
|
||||||
|
return Ok((Vec::new(), 0));
|
||||||
|
}
|
||||||
|
let mut f = std::fs::File::open(&path)?;
|
||||||
|
f.seek(SeekFrom::Start(offset))?;
|
||||||
|
let mut buf = String::new();
|
||||||
|
f.read_to_string(&mut buf)?;
|
||||||
|
let new_offset = offset + buf.len() as u64;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for line in buf.lines() {
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match serde_json::from_str::<MutationEvent>(line) {
|
||||||
|
Ok(ev) => out.push(ev),
|
||||||
|
Err(e) => tracing::warn!(error = %e, "journal: línea malformada en tail"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((out, new_offset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construye un timestamp RFC 3339 UTC del instante actual usando std-only (sin chrono).
|
||||||
|
pub fn now_rfc3339() -> String {
|
||||||
|
use std::time::SystemTime;
|
||||||
|
let d = SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let secs = d.as_secs() as i64;
|
||||||
|
let nanos = d.subsec_nanos();
|
||||||
|
let (year, month, day, hour, minute, second) = unix_to_ymdhms(secs);
|
||||||
|
format!(
|
||||||
|
"{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{:09}Z",
|
||||||
|
nanos
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Conversión epoch → (year, month, day, hour, minute, second) UTC. Algoritmo de Howard
|
||||||
|
/// Hinnant (`civil_from_days`), válido para todo rango representable por i64.
|
||||||
|
fn unix_to_ymdhms(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
|
||||||
|
let day_secs = 86_400i64;
|
||||||
|
let mut days = secs.div_euclid(day_secs);
|
||||||
|
let time_of_day = secs.rem_euclid(day_secs) as u32;
|
||||||
|
let hour = time_of_day / 3600;
|
||||||
|
let minute = (time_of_day / 60) % 60;
|
||||||
|
let second = time_of_day % 60;
|
||||||
|
|
||||||
|
days += 719_468; // shift al epoch del algoritmo (0000-03-01)
|
||||||
|
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
|
||||||
|
let doe = (days - era * 146_097) as u64; // [0, 146_096]
|
||||||
|
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
|
||||||
|
let y = yoe as i64 + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||||
|
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||||
|
let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
|
||||||
|
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
|
||||||
|
let year = (y + if month <= 2 { 1 } else { 0 }) as i32;
|
||||||
|
|
||||||
|
(year, month, day, hour, minute, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn ev(op: MutationOp, path: &str) -> MutationEvent {
|
||||||
|
MutationEvent {
|
||||||
|
ts: now_rfc3339(),
|
||||||
|
op,
|
||||||
|
path: PathBuf::from(path),
|
||||||
|
by: Actor {
|
||||||
|
source: Source::External,
|
||||||
|
pid: Some(1234),
|
||||||
|
uid: Some(1000),
|
||||||
|
},
|
||||||
|
content_hash: None,
|
||||||
|
note: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_and_read_roundtrip() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let j = Journal::open(d.path()).unwrap();
|
||||||
|
j.record(&ev(MutationOp::Replace, "/bin/grep")).unwrap();
|
||||||
|
j.record(&ev(MutationOp::Edit, "/etc/hosts")).unwrap();
|
||||||
|
let read = j.read_all().unwrap();
|
||||||
|
assert_eq!(read.len(), 2);
|
||||||
|
assert_eq!(read[0].op, MutationOp::Replace);
|
||||||
|
assert_eq!(read[0].path, PathBuf::from("/bin/grep"));
|
||||||
|
assert_eq!(read[1].op, MutationOp::Edit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_journal_reads_empty() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let j = Journal::open(d.path()).unwrap();
|
||||||
|
assert!(j.read_all().unwrap().is_empty());
|
||||||
|
assert_eq!(j.end_offset().unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tail_returns_last_n() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let j = Journal::open(d.path()).unwrap();
|
||||||
|
for i in 0..5 {
|
||||||
|
j.record(&ev(MutationOp::Create, &format!("/etc/{i}"))).unwrap();
|
||||||
|
}
|
||||||
|
let last = j.tail(2).unwrap();
|
||||||
|
assert_eq!(last.len(), 2);
|
||||||
|
assert_eq!(last[0].path, PathBuf::from("/etc/3"));
|
||||||
|
assert_eq!(last[1].path, PathBuf::from("/etc/4"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn follow_via_offset_yields_new_events_only() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let j = Journal::open(d.path()).unwrap();
|
||||||
|
j.record(&ev(MutationOp::Create, "/etc/a")).unwrap();
|
||||||
|
let cursor = j.end_offset().unwrap();
|
||||||
|
j.record(&ev(MutationOp::Create, "/etc/b")).unwrap();
|
||||||
|
j.record(&ev(MutationOp::Create, "/etc/c")).unwrap();
|
||||||
|
let (news, new_cursor) = j.read_from_offset(cursor).unwrap();
|
||||||
|
assert_eq!(news.len(), 2);
|
||||||
|
assert_eq!(news[0].path, PathBuf::from("/etc/b"));
|
||||||
|
assert_eq!(news[1].path, PathBuf::from("/etc/c"));
|
||||||
|
assert_eq!(new_cursor, j.end_offset().unwrap());
|
||||||
|
// Una segunda llamada desde el nuevo cursor devuelve vacío.
|
||||||
|
let (more, _) = j.read_from_offset(new_cursor).unwrap();
|
||||||
|
assert!(more.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_line_is_skipped_not_fatal() {
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let j = Journal::open(d.path()).unwrap();
|
||||||
|
j.record(&ev(MutationOp::Create, "/etc/a")).unwrap();
|
||||||
|
// Plantamos basura en medio (kernel panic mid-write).
|
||||||
|
let mut f = std::fs::OpenOptions::new().append(true).open(j.file_path()).unwrap();
|
||||||
|
f.write_all(b"{esto no es JSON valido\n").unwrap();
|
||||||
|
f.write_all(b"\n").unwrap(); // línea vacía
|
||||||
|
drop(f);
|
||||||
|
j.record(&ev(MutationOp::Create, "/etc/b")).unwrap();
|
||||||
|
let read = j.read_all().unwrap();
|
||||||
|
assert_eq!(read.len(), 2, "líneas válidas se preservan");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_serializes_with_tag() {
|
||||||
|
let e = ev(MutationOp::Replace, "/bin/grep");
|
||||||
|
let j = serde_json::to_string(&e).unwrap();
|
||||||
|
assert!(j.contains(r#""kind":"external""#), "got {j}");
|
||||||
|
|
||||||
|
let mut e2 = e.clone();
|
||||||
|
e2.by.source = Source::HammerHydrate {
|
||||||
|
artifact: "b3:abc".into(),
|
||||||
|
};
|
||||||
|
let j2 = serde_json::to_string(&e2).unwrap();
|
||||||
|
assert!(j2.contains(r#""kind":"hammer-hydrate""#), "got {j2}");
|
||||||
|
assert!(j2.contains(r#""artifact":"b3:abc""#), "got {j2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rfc3339_format_basics() {
|
||||||
|
let ts = now_rfc3339();
|
||||||
|
// Verifica forma `YYYY-MM-DDThh:mm:ss.nnnnnnnnnZ` minimal.
|
||||||
|
assert!(ts.len() >= 29);
|
||||||
|
assert_eq!(&ts[4..5], "-");
|
||||||
|
assert_eq!(&ts[7..8], "-");
|
||||||
|
assert_eq!(&ts[10..11], "T");
|
||||||
|
assert_eq!(&ts[13..14], ":");
|
||||||
|
assert_eq!(&ts[16..17], ":");
|
||||||
|
assert!(ts.ends_with('Z'));
|
||||||
|
// Año razonable (2026 actualmente; este test sólo verifica que no salimos del rango
|
||||||
|
// 1970..3000).
|
||||||
|
let year: i32 = ts[..4].parse().unwrap();
|
||||||
|
assert!((1970..3000).contains(&year), "year = {year}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,13 @@ description = "El overlay de experimentación de hammer: try/commit/discard/stat
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hammer-core.workspace = true
|
hammer-core.workspace = true
|
||||||
|
hammer-journal.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|||||||
@@ -218,12 +218,30 @@ pub fn discard(id: &OverlayId, state_root: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fusiona los `upper`s al FHS y desmonta. Cero diario por ahora — Fase 3 lo añade.
|
/// Fusiona los `upper`s al FHS y desmonta. Sin diario.
|
||||||
///
|
///
|
||||||
/// Estrategia: por cada mount, `rsync -a --delete-after` desde el upperdir al target.
|
/// Para registrar cada promoción en el diario (SDD 04 §2 + SDD 05), usa
|
||||||
/// Antes del rsync desmontamos el overlay; si no, rsync estaría leyendo de la vista
|
/// [`commit_with_journal`].
|
||||||
/// "merged" en lugar del upper puro (y promocionaríamos todo, no sólo los cambios).
|
|
||||||
pub fn commit(id: &OverlayId, state_root: &Path) -> Result<CommitReport> {
|
pub fn commit(id: &OverlayId, state_root: &Path) -> Result<CommitReport> {
|
||||||
|
commit_inner(id, state_root, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Como [`commit`] pero registra cada archivo promocionado/eliminado como
|
||||||
|
/// `MutationEvent` con `source = HammerCommit { overlay }`. El SDD insiste en que el ruido
|
||||||
|
/// del experimento no entra al diario hasta el commit — esto es el momento exacto.
|
||||||
|
pub fn commit_with_journal(
|
||||||
|
id: &OverlayId,
|
||||||
|
state_root: &Path,
|
||||||
|
journal: &hammer_journal::Journal,
|
||||||
|
) -> Result<CommitReport> {
|
||||||
|
commit_inner(id, state_root, Some(journal))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_inner(
|
||||||
|
id: &OverlayId,
|
||||||
|
state_root: &Path,
|
||||||
|
journal: Option<&hammer_journal::Journal>,
|
||||||
|
) -> Result<CommitReport> {
|
||||||
let base = state_root.join(id.as_str());
|
let base = state_root.join(id.as_str());
|
||||||
let state_file = base.join("state.json");
|
let state_file = base.join("state.json");
|
||||||
if !state_file.is_file() {
|
if !state_file.is_file() {
|
||||||
@@ -246,10 +264,57 @@ pub fn commit(id: &OverlayId, state_root: &Path) -> Result<CommitReport> {
|
|||||||
for m in &state.mounts {
|
for m in &state.mounts {
|
||||||
promote_upper_to_lower(&m.upper, &m.target, &mut report)?;
|
promote_upper_to_lower(&m.upper, &m.target, &mut report)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(j) = journal {
|
||||||
|
record_commit_to_journal(j, id, &report);
|
||||||
|
}
|
||||||
std::fs::remove_dir_all(&base)?;
|
std::fs::remove_dir_all(&base)?;
|
||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn record_commit_to_journal(
|
||||||
|
j: &hammer_journal::Journal,
|
||||||
|
id: &OverlayId,
|
||||||
|
report: &CommitReport,
|
||||||
|
) {
|
||||||
|
use hammer_journal::{Actor, MutationEvent, MutationOp, Source};
|
||||||
|
let actor = Actor {
|
||||||
|
source: Source::HammerCommit {
|
||||||
|
overlay: id.as_str().to_string(),
|
||||||
|
artifact: None,
|
||||||
|
},
|
||||||
|
pid: Some(std::process::id()),
|
||||||
|
uid: Some(unsafe { libc::getuid() }),
|
||||||
|
};
|
||||||
|
let ts = hammer_journal::now_rfc3339();
|
||||||
|
for p in &report.copied {
|
||||||
|
let ev = MutationEvent {
|
||||||
|
ts: ts.clone(),
|
||||||
|
op: MutationOp::Replace,
|
||||||
|
path: p.clone(),
|
||||||
|
by: actor.clone(),
|
||||||
|
content_hash: None,
|
||||||
|
note: None,
|
||||||
|
};
|
||||||
|
if let Err(e) = j.record(&ev) {
|
||||||
|
tracing::warn!(error = %e, path = %p.display(), "journal: fallo al registrar commit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for p in &report.removed {
|
||||||
|
let ev = MutationEvent {
|
||||||
|
ts: ts.clone(),
|
||||||
|
op: MutationOp::Delete,
|
||||||
|
path: p.clone(),
|
||||||
|
by: actor.clone(),
|
||||||
|
content_hash: None,
|
||||||
|
note: None,
|
||||||
|
};
|
||||||
|
if let Err(e) = j.record(&ev) {
|
||||||
|
tracing::warn!(error = %e, path = %p.display(), "journal: fallo al registrar remove");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn do_mount(lower: &Path, upper: &Path, work: &Path) -> Result<()> {
|
fn do_mount(lower: &Path, upper: &Path, work: &Path) -> Result<()> {
|
||||||
// overlayfs requiere `,` como separador. Si alguno de los paths contiene una coma,
|
// overlayfs requiere `,` como separador. Si alguno de los paths contiene una coma,
|
||||||
// overlayfs sólo soporta escaparla a través de opciones específicas — rechazamos en
|
// overlayfs sólo soporta escaparla a través de opciones específicas — rechazamos en
|
||||||
@@ -453,4 +518,36 @@ mod tests {
|
|||||||
let err = discard(&OverlayId("nope".into()), d.path()).unwrap_err().to_string();
|
let err = discard(&OverlayId("nope".into()), d.path()).unwrap_err().to_string();
|
||||||
assert!(err.contains("no encuentro overlay"), "msg = {err}");
|
assert!(err.contains("no encuentro overlay"), "msg = {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn journal_hook_records_copied_and_removed() {
|
||||||
|
// No tocamos mount real: ejercitamos directamente la función privada que el commit
|
||||||
|
// delega para anotar al diario. Eso valida el contrato sin necesidad de overlayfs.
|
||||||
|
let d = tempfile::tempdir().unwrap();
|
||||||
|
let journal = hammer_journal::Journal::open(d.path().join("journal")).unwrap();
|
||||||
|
let report = CommitReport {
|
||||||
|
copied: vec![PathBuf::from("/etc/hosts"), PathBuf::from("/bin/grep")],
|
||||||
|
removed: vec![PathBuf::from("/bin/obsoleto")],
|
||||||
|
};
|
||||||
|
super::record_commit_to_journal(&journal, &OverlayId("1234-1".into()), &report);
|
||||||
|
let events = journal.read_all().unwrap();
|
||||||
|
assert_eq!(events.len(), 3, "2 copied + 1 removed = 3 eventos");
|
||||||
|
// Los promocionados van como Replace, los removidos como Delete.
|
||||||
|
let ops_paths: Vec<_> = events
|
||||||
|
.iter()
|
||||||
|
.map(|e| (e.op, e.path.display().to_string()))
|
||||||
|
.collect();
|
||||||
|
assert!(ops_paths.contains(&(hammer_journal::MutationOp::Replace, "/etc/hosts".into())));
|
||||||
|
assert!(ops_paths.contains(&(hammer_journal::MutationOp::Replace, "/bin/grep".into())));
|
||||||
|
assert!(ops_paths.contains(&(hammer_journal::MutationOp::Delete, "/bin/obsoleto".into())));
|
||||||
|
// Todos comparten el mismo source con overlay id.
|
||||||
|
for e in &events {
|
||||||
|
match &e.by.source {
|
||||||
|
hammer_journal::Source::HammerCommit { overlay, .. } => {
|
||||||
|
assert_eq!(overlay, "1234-1");
|
||||||
|
}
|
||||||
|
other => panic!("source inesperado: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,15 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hammer-core.workspace = true
|
hammer-core.workspace = true
|
||||||
|
hammer-journal.workspace = true
|
||||||
|
hammer-overlay.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
nix.workspace = true
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
//! `hammerd` — daemon de hammer. Dos responsabilidades:
|
//! `hammerd` — daemon de hammer. Dos responsabilidades:
|
||||||
//! 1. Bus de agente: /run/agent.sock (JSON-líneas, SO_PEERCRED). Ver `docs/07-agent-bus.md`.
|
//! 1. Diario de mutaciones: fanotify sobre /bin,/sbin,/lib,/etc → `hammer-journal`.
|
||||||
//! 2. Diario de mutaciones: fanotify sobre /bin,/sbin,/lib,/etc. Ver `docs/05-journal.md`.
|
//! 2. Bus de agente: /run/agent.sock (JSON-líneas, SO_PEERCRED). Ver `docs/07-agent-bus.md`.
|
||||||
//!
|
//!
|
||||||
//! Esqueleto de arranque. Las dos subsistemas (Fases 3 y 5) se implementarán por separado;
|
//! Fase 3 implementa (1). Fase 5 añadirá (2). Si el daemon arranca sin CAP_SYS_ADMIN, el
|
||||||
//! aquí queda el binario navegable y el cableado básico de logging/argumentos.
|
//! watcher falla en init y el daemon sigue corriendo sin él (warn claro). Esto es
|
||||||
|
//! deliberado para que puedas correr `hammerd` en dev sin sudo y aún así probar la parte
|
||||||
|
//! del bus.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
|
mod watcher;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "hammerd", version, about = "Daemon de hammer: bus de agente + diario.")]
|
#[command(name = "hammerd", version, about = "Daemon de hammer: bus de agente + diario.")]
|
||||||
struct Args {
|
struct Args {
|
||||||
@@ -19,6 +25,13 @@ struct Args {
|
|||||||
/// Directorio del diario de mutaciones.
|
/// Directorio del diario de mutaciones.
|
||||||
#[arg(long, default_value = "/var/lib/hammer/journal")]
|
#[arg(long, default_value = "/var/lib/hammer/journal")]
|
||||||
journal: String,
|
journal: String,
|
||||||
|
/// Directorio raíz de overlays (para que el watcher pueda filtrar eventos bajo overlays
|
||||||
|
/// activos). Default coincide con `hammer try`.
|
||||||
|
#[arg(long, default_value = hammer_overlay::DEFAULT_STATE_ROOT)]
|
||||||
|
overlay_state_root: String,
|
||||||
|
/// Directorios extra a vigilar; si vacío, usa los defaults del FHS (SDD 05 §2).
|
||||||
|
#[arg(long = "watch")]
|
||||||
|
extra_watch: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
@@ -35,11 +48,28 @@ fn main() -> anyhow::Result<()> {
|
|||||||
agent_sock = %args.agent_sock,
|
agent_sock = %args.agent_sock,
|
||||||
init_control = %args.init_control,
|
init_control = %args.init_control,
|
||||||
journal = %args.journal,
|
journal = %args.journal,
|
||||||
"hammerd: arranque (esqueleto)"
|
"hammerd: arranque"
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO(fase-3): iniciar el watcher fanotify → diario.
|
let journal = hammer_journal::Journal::open(&args.journal)?;
|
||||||
// TODO(fase-5): servir el bus de agente en agent_sock (JSON-líneas, SO_PEERCRED).
|
let mut dirs = watcher::default_watch_dirs();
|
||||||
tracing::warn!("subsistemas pendientes: diario (Fase 3) y bus de agente (Fase 5)");
|
dirs.extend(args.extra_watch);
|
||||||
|
|
||||||
|
let overlay_root = PathBuf::from(&args.overlay_state_root);
|
||||||
|
match watcher::Watcher::start(&dirs, journal, Some(overlay_root)) {
|
||||||
|
Ok(w) => {
|
||||||
|
tracing::info!("watcher fanotify activo");
|
||||||
|
w.run_forever()?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
"watcher fanotify NO arrancó; el daemon sigue (sin diario en este run)"
|
||||||
|
);
|
||||||
|
// TODO(fase-5): aquí entra el bus de agente. De momento sólo dormimos para que
|
||||||
|
// el daemon no salga.
|
||||||
|
std::thread::park();
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
//! Watcher de mutaciones del FHS. Usa `fanotify` para observar `CLOSE_WRITE` (alguien
|
||||||
|
//! escribió en un archivo y lo cerró) sobre los directorios del SDD 05 §2.
|
||||||
|
//!
|
||||||
|
//! Diseño Fase 3 v1:
|
||||||
|
//!
|
||||||
|
//! * Marcamos como `FAN_MARK_ADD | FAN_MARK_ONLYDIR` cada directorio vigilado. La cobertura
|
||||||
|
//! por defecto (ver `default_watch_dirs`) cubre el FHS clásico de userland.
|
||||||
|
//! * Filtro mínimo: ignoramos eventos cuya ruta resuelta cae dentro de un overlay activo
|
||||||
|
//! (consultamos `hammer_overlay::status` en cada batch — para Fase 3 el coste es
|
||||||
|
//! despreciable; cuando los overlays se vuelvan numerosos cachearemos los paths).
|
||||||
|
//! * Cada evento se materializa como `MutationEvent` con `op = Replace` y `source = External`.
|
||||||
|
//! El refinamiento (Create/Edit por contenido, hashing de contenido, atribución a un
|
||||||
|
//! `hydrate` reciente) llega después.
|
||||||
|
//!
|
||||||
|
//! Privilegios: `fanotify_init` requiere `CAP_SYS_ADMIN`. Si no lo tenemos, fallamos rápido
|
||||||
|
//! con un mensaje claro y el daemon sigue arrancando los otros subsistemas.
|
||||||
|
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::os::fd::AsFd;
|
||||||
|
use std::os::unix::ffi::OsStringExt;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use hammer_journal::{Actor, Journal, MutationEvent, MutationOp, Source};
|
||||||
|
use nix::fcntl::AT_FDCWD;
|
||||||
|
use nix::sys::fanotify::{EventFFlags, Fanotify, InitFlags, MarkFlags, MaskFlags};
|
||||||
|
|
||||||
|
/// Directorios del FHS que vigilamos por defecto (SDD 05 §2).
|
||||||
|
pub fn default_watch_dirs() -> Vec<PathBuf> {
|
||||||
|
["/bin", "/sbin", "/usr/bin", "/usr/sbin", "/lib", "/usr/lib", "/etc"]
|
||||||
|
.into_iter()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("fanotify_init falló (¿CAP_SYS_ADMIN?): {0}")]
|
||||||
|
Init(nix::errno::Errno),
|
||||||
|
#[error("fanotify_mark falló sobre {path}: {err}")]
|
||||||
|
Mark { path: PathBuf, err: nix::errno::Errno },
|
||||||
|
#[error("read_events: {0}")]
|
||||||
|
Read(nix::errno::Errno),
|
||||||
|
#[error("journal: {0}")]
|
||||||
|
Journal(#[from] hammer_journal::Error),
|
||||||
|
#[error("io: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
pub struct Watcher {
|
||||||
|
fan: Fanotify,
|
||||||
|
journal: Journal,
|
||||||
|
watched: Vec<PathBuf>,
|
||||||
|
overlay_state_root: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Watcher {
|
||||||
|
/// Inicializa el watcher y registra los `dirs` como vigilados.
|
||||||
|
///
|
||||||
|
/// `overlay_state_root`, si está presente, se usa para filtrar eventos que ocurren
|
||||||
|
/// dentro de un overlay activo (no entran al diario hasta el `commit` del overlay).
|
||||||
|
pub fn start(
|
||||||
|
dirs: &[PathBuf],
|
||||||
|
journal: Journal,
|
||||||
|
overlay_state_root: Option<PathBuf>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let fan = Fanotify::init(
|
||||||
|
InitFlags::FAN_CLASS_NOTIF | InitFlags::FAN_CLOEXEC,
|
||||||
|
EventFFlags::O_RDONLY | EventFFlags::O_CLOEXEC,
|
||||||
|
)
|
||||||
|
.map_err(Error::Init)?;
|
||||||
|
|
||||||
|
for dir in dirs {
|
||||||
|
if !dir.is_dir() {
|
||||||
|
tracing::warn!(path = %dir.display(), "watcher: skip — no es directorio");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fan.mark(
|
||||||
|
MarkFlags::FAN_MARK_ADD | MarkFlags::FAN_MARK_ONLYDIR,
|
||||||
|
MaskFlags::FAN_CLOSE_WRITE | MaskFlags::FAN_EVENT_ON_CHILD,
|
||||||
|
AT_FDCWD,
|
||||||
|
Some(dir.as_path()),
|
||||||
|
)
|
||||||
|
.map_err(|err| Error::Mark { path: dir.clone(), err })?;
|
||||||
|
tracing::info!(path = %dir.display(), "watcher: vigilando");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
fan,
|
||||||
|
journal,
|
||||||
|
watched: dirs.to_vec(),
|
||||||
|
overlay_state_root,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Devuelve un fd que se puede usar con `poll`/`epoll` para esperar eventos sin bloquear.
|
||||||
|
/// Útil cuando integremos esto con el bus de agente (Fase 5).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn fd(&self) -> std::os::fd::BorrowedFd<'_> {
|
||||||
|
self.fan.as_fd()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loop bloqueante. Vuelve sólo si `read_events` falla irrecuperablemente. Se espera
|
||||||
|
/// invocarlo en su propio thread.
|
||||||
|
pub fn run_forever(&self) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
self.poll_once()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Una iteración del loop. Útil para tests.
|
||||||
|
pub fn poll_once(&self) -> Result<usize> {
|
||||||
|
let events = self.fan.read_events().map_err(Error::Read)?;
|
||||||
|
let overlay_targets = self.overlay_targets();
|
||||||
|
let mut recorded = 0;
|
||||||
|
for ev in events {
|
||||||
|
let pid = ev.pid() as u32;
|
||||||
|
let Some(fd) = ev.fd() else { continue };
|
||||||
|
let path = match path_of_fd(fd) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "watcher: no pude resolver path del fd, saltando");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !path_is_under_any(&path, &self.watched) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if path_is_under_any(&path, &overlay_targets) {
|
||||||
|
tracing::debug!(path = %path.display(), "watcher: ignoro evento bajo overlay activo");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let me = MutationEvent {
|
||||||
|
ts: hammer_journal::now_rfc3339(),
|
||||||
|
op: MutationOp::Replace,
|
||||||
|
path,
|
||||||
|
by: Actor {
|
||||||
|
source: Source::External,
|
||||||
|
pid: Some(pid),
|
||||||
|
uid: uid_of_pid(pid),
|
||||||
|
},
|
||||||
|
content_hash: None,
|
||||||
|
note: None,
|
||||||
|
};
|
||||||
|
self.journal.record(&me)?;
|
||||||
|
recorded += 1;
|
||||||
|
}
|
||||||
|
Ok(recorded)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlay_targets(&self) -> Vec<PathBuf> {
|
||||||
|
let Some(root) = &self.overlay_state_root else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
match hammer_overlay::status(root) {
|
||||||
|
Ok(overlays) => overlays
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|o| o.mounts.into_iter().map(|m| m.target))
|
||||||
|
.collect(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "watcher: status() falló, no filtro overlays");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resuelve el path real apuntado por un fd procediendo por `/proc/self/fd/<n>`. Devuelve el
|
||||||
|
/// path destino del symlink.
|
||||||
|
fn path_of_fd(fd: std::os::fd::BorrowedFd<'_>) -> std::io::Result<PathBuf> {
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
let link = format!("/proc/self/fd/{}", fd.as_raw_fd());
|
||||||
|
let target = std::fs::read_link(&link)?;
|
||||||
|
// readlink puede devolver paths suffix " (deleted)" si el archivo fue eliminado entre el
|
||||||
|
// evento y nuestra resolución. Los preservamos como están — el journal verá el evento
|
||||||
|
// pero el caller puede filtrarlos si quiere.
|
||||||
|
Ok(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_is_under_any(p: &Path, roots: &[PathBuf]) -> bool {
|
||||||
|
roots.iter().any(|r| p.starts_with(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lee `/proc/<pid>/status` y extrae el UID real. Devuelve `None` si el proceso desapareció.
|
||||||
|
fn uid_of_pid(pid: u32) -> Option<u32> {
|
||||||
|
let bytes = std::fs::read(format!("/proc/{pid}/status")).ok()?;
|
||||||
|
let text = OsString::from_vec(bytes);
|
||||||
|
let s = text.to_string_lossy();
|
||||||
|
for line in s.lines() {
|
||||||
|
if let Some(rest) = line.strip_prefix("Uid:") {
|
||||||
|
let first = rest.split_whitespace().next()?;
|
||||||
|
return first.parse().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_cover_fhs_userland() {
|
||||||
|
let d = default_watch_dirs();
|
||||||
|
assert!(d.iter().any(|p| p == Path::new("/bin")));
|
||||||
|
assert!(d.iter().any(|p| p == Path::new("/etc")));
|
||||||
|
assert!(d.iter().any(|p| p == Path::new("/usr/lib")));
|
||||||
|
assert!(!d.iter().any(|p| p == Path::new("/tmp")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_filter_under_any() {
|
||||||
|
let roots = vec![PathBuf::from("/bin"), PathBuf::from("/etc")];
|
||||||
|
assert!(path_is_under_any(Path::new("/bin/grep"), &roots));
|
||||||
|
assert!(path_is_under_any(Path::new("/etc/passwd"), &roots));
|
||||||
|
assert!(!path_is_under_any(Path::new("/tmp/x"), &roots));
|
||||||
|
assert!(!path_is_under_any(Path::new("/binx/y"), &roots));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uid_of_self_matches() {
|
||||||
|
let me = std::process::id();
|
||||||
|
let uid = uid_of_pid(me).expect("self uid");
|
||||||
|
// En entornos normales, comparable con getuid del proceso.
|
||||||
|
let expected = unsafe { libc::getuid() };
|
||||||
|
assert_eq!(uid, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-6
@@ -37,19 +37,25 @@ Alpine vía `bwrap`** — confirma el corazón "fábrica funcional → FHS mutab
|
|||||||
substrato Alpine. La VM/LXC dedicada queda como ejercicio de empaque, no como
|
substrato Alpine. La VM/LXC dedicada queda como ejercicio de empaque, no como
|
||||||
pre-requisito de validación.
|
pre-requisito de validación.
|
||||||
|
|
||||||
### Fase 2 — Overlay de experimentación ▶ *en progreso*
|
### Fase 2 — Overlay de experimentación ✅
|
||||||
- [x] Crate `hammer-overlay`: `try` / `commit` / `discard` / `status` sobre `overlayfs`.
|
- [x] Crate `hammer-overlay`: `try` / `commit` / `discard` / `status` sobre `overlayfs`.
|
||||||
- [x] Manifiesto persistente en `<state_root>/<id>/state.json`; `status` enumera.
|
- [x] Manifiesto persistente en `<state_root>/<id>/state.json`; `status` enumera.
|
||||||
- [x] `commit` promociona archivos y procesa whiteouts (char dev 0/0) como remove.
|
- [x] `commit` promociona archivos y procesa whiteouts (char dev 0/0) como remove.
|
||||||
- [x] Subcomandos del CLI: `hammer try [targets…]`, `commit <id>`, `discard <id>`, `status`.
|
- [x] Subcomandos del CLI: `hammer try [targets…]`, `commit <id>`, `discard <id>`, `status`.
|
||||||
- [x] Tests E2E con bwrap+user-ns, gateados en `HAMMER_OVERLAY_TESTS=1` (kernel-dependiente).
|
- [x] Tests E2E con bwrap+user-ns, gateados en `HAMMER_OVERLAY_TESTS=1` (kernel-dependiente).
|
||||||
- [ ] Hook al diario en `commit` (queda para Fase 3).
|
- [x] Hook al diario en `commit` (Fase 3 lo añadió vía `commit_with_journal`).
|
||||||
- [ ] Anidación real de overlays (un solo overlay activo por target hoy).
|
- [ ] Anidación real de overlays (un solo overlay activo por target hoy).
|
||||||
|
|
||||||
### Fase 3 — Diario de mutaciones
|
### Fase 3 — Diario de mutaciones ▶ *en progreso*
|
||||||
- [ ] `hammerd` con `fanotify` sobre `/bin`,`/sbin`,`/lib`,`/etc`.
|
- [x] Crate `hammer-journal`: `MutationEvent`, `Source` (HammerHydrate/HammerCommit/External),
|
||||||
- [ ] Log JSON-líneas append-only; `hammer journal`.
|
append/read/tail/follow JSON-líneas. RFC 3339 sin chrono.
|
||||||
- **Hecho cuando:** toda mutación trazable/opaca queda registrada y consultable.
|
- [x] `hammerd` con `fanotify` (FAN_CLOSE_WRITE) sobre `/bin`,`/sbin`,`/usr/bin`,`/usr/sbin`,
|
||||||
|
`/lib`,`/usr/lib`,`/etc`. Filtra eventos bajo overlays activos. Fallback graceful sin
|
||||||
|
CAP_SYS_ADMIN.
|
||||||
|
- [x] `hammer journal [--tail N] [--follow] [--format pretty|json]`.
|
||||||
|
- [x] `hammer commit` registra cada archivo promocionado vía `commit_with_journal`.
|
||||||
|
- [ ] Refinar op del watcher (Create vs Replace vs Edit con FAN_REPORT_DFID_NAME).
|
||||||
|
- [ ] Hash del contenido tras la mutación (`content_hash`) y de-dup idempotente.
|
||||||
|
|
||||||
### Fase 4 — Formato y flujo `.swm`
|
### Fase 4 — Formato y flujo `.swm`
|
||||||
- [ ] `Swm` (de/serialización YAML), `hammer export`, `hammer apply` (con overlay), `verify`.
|
- [ ] `Swm` (de/serialización YAML), `hammer export`, `hammer apply` (con overlay), `verify`.
|
||||||
|
|||||||
Reference in New Issue
Block a user