Files
takana/crates/takana-agent/tests/client_against_stub_bus.rs
T
Sergio 24bcf1783c takana etapa 4: los 10 crates de librería y el CLI pasan a takana-*
hammer-{core,build,bootstrap,overlay,journal,mirror,upgrade,agent,recover,cli}
→ takana-*, con sus deps de workspace, sus identificadores en el fuente y las
referencias -p de los scripts.

VERIFICADO que no mueve nada del corpus: `takana hash recipes/zlib.toml`
devuelve b3:dc363f26… , idéntico a antes del renombre. Los nombres de crate no
entran en hash_inputs, pero eso se comprueba, no se supone. 600 tests en verde.

DOS BINARIOS SE CONGELAN, y no por prolijidad:

- `hammerd` — paquete Y binario. Es componente de Stage 1 de la distro (musl,
  busybox, hammerd, arje-zero), lo supervisa arje-zero en el sistema arrancado,
  `PRESEED=hammerd` lo nombra en selfhost-verify y sus bytes anclan el baseline
  of_tree. El nombre del crate va en los símbolos ⇒ renombrarlo mueve los bytes.

- `hammer-recover` — el PAQUETE se renombra a takana-recover, el BINARIO no.
  hammer-live-install.sh lo copia a /usr/sbin/hammer-recover en sistemas ya
  instalados y hornea un hook de arranque que lo invoca por ese nombre:
  renombrarlo rompe máquinas instaladas, no el repo.

Consecuencia que hay que anotar igual: al renombrar hammer-core, los bytes de
hammerd cambian de todos modos porque linkea contra un crate con otro nombre.
El baseline of_tree del selfhost hay que rehacerlo — es efecto de la etapa 4,
no de un cambio de hammerd.

Las referencias en comentarios de recetas y docs (rutas hammer-core/src/…)
quedan para la etapa 5: son texto, no mueven hash.
2026-09-09 18:46:41 +00:00

204 lines
7.5 KiB
Rust

//! E2E del cliente del bus (Fase 6) contra un servidor stub.
//!
//! No depende de `hammerd` ni del lab: un mini-servidor JSON-líneas que conoce el
//! handshake y responde a `Compile` con `BuildReady` sintético. Demuestra que el cliente
//! síncrono multiplexa correctamente respuestas (BuildReady) y eventos asíncronos (Modified).
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::thread;
use std::time::Duration;
use takana_core::proto::{Cap, Command, Event, Peer, RecipeInline, PROTOCOL_VERSION};
fn start_stub_bus(sock: PathBuf) -> std::sync::mpsc::Sender<Event> {
let (tx_async, rx_async) = std::sync::mpsc::channel::<Event>();
let _ = std::fs::remove_file(&sock);
let listener = UnixListener::bind(&sock).expect("bind stub");
thread::Builder::new()
.name("stub-bus".into())
.spawn(move || {
// Sólo aceptamos una conexión: el Receiver no es Clone, se mueve al handler.
if let Some(Ok(stream)) = listener.incoming().next() {
thread::spawn(move || stub_serve(stream, rx_async));
}
})
.unwrap();
tx_async
}
fn stub_serve(stream: UnixStream, rx_async: std::sync::mpsc::Receiver<Event>) {
let reader_stream = stream.try_clone().unwrap();
let mut writer = stream;
let writer_clone = writer.try_clone().unwrap();
// Hilo que reenvía los eventos asíncronos al cliente. Mover el writer impide que
// se mezclen los bytes con las respuestas (cada Sender los serializa atómicamente).
let async_writer = std::sync::Arc::new(std::sync::Mutex::new(writer_clone));
let aw_for_async = async_writer.clone();
thread::spawn(move || {
for ev in rx_async {
let line = serde_json::to_string(&ev).unwrap();
let mut g = aw_for_async.lock().unwrap();
let _ = g.write_all(line.as_bytes());
let _ = g.write_all(b"\n");
let _ = g.flush();
}
});
let mut reader = BufReader::new(reader_stream);
let mut first = String::new();
reader.read_line(&mut first).unwrap();
let cmd: Command = serde_json::from_str(first.trim()).unwrap();
let Command::Hello { ver: _, client: _ } = cmd else {
return;
};
// welcome
let w = Event::Welcome {
ver: PROTOCOL_VERSION,
caps: vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::Init],
peer: Peer { uid: 1000, gid: 1000, pid: 1 },
};
let mut g = async_writer.lock().unwrap();
g.write_all(serde_json::to_string(&w).unwrap().as_bytes()).unwrap();
g.write_all(b"\n").unwrap();
g.flush().unwrap();
drop(g);
// Loop de comandos.
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {}
Err(_) => break,
}
if line.trim().is_empty() {
continue;
}
let cmd: Command = match serde_json::from_str(line.trim()) {
Ok(c) => c,
Err(_) => continue,
};
let response = match cmd {
Command::Compile { recipe } => {
// El stub refleja la evidencia declarada como un veredicto "todo pasa" para
// ejercitar el camino H1c del cliente (sin lab real que la ejecute).
let verdict = (!recipe.evidence.is_empty()).then(|| {
takana_core::proto::EvidenceVerdict {
all_passed: true,
max_level: recipe.evidence.checks.iter().map(|c| c.kind).max(),
checks: recipe
.evidence
.checks
.iter()
.map(|c| takana_core::proto::EvidenceCheckVerdict {
kind: c.kind,
cmd: c.cmd.clone(),
passed: true,
detail: "stub ok".into(),
})
.collect(),
}
});
Event::BuildReady {
recipe: recipe.name.clone(),
artifact: format!(
"b3:stub-{}",
recipe.commit.as_deref().or(recipe.sha256.as_deref()).unwrap_or("nosrc")
),
verdict,
}
}
Command::Query { what, .. } => Event::QueryResult {
what,
value: serde_json::json!({"stub": true}),
},
Command::Init { cmd } => Event::InitAck { cmd },
Command::Inject { artifact, target, .. } => Event::Injected {
artifact,
target,
files: 1,
},
Command::Hello { .. } => Event::Error {
code: "duplicate_hello".into(),
msg: "ya hicimos hello".into(),
},
};
let mut g = async_writer.lock().unwrap();
g.write_all(serde_json::to_string(&response).unwrap().as_bytes())
.unwrap();
g.write_all(b"\n").unwrap();
g.flush().unwrap();
drop(g);
// Cierra solo si el cliente cierra; aquí seguimos.
if !writer.flush().is_ok() {
break;
}
}
}
#[test]
fn client_handshake_compile_and_async_modified() {
let d = tempfile::tempdir().unwrap();
let sock = d.path().join("stub.sock");
let async_tx = start_stub_bus(sock.clone());
// Pequeño wait para que el listener bindee.
for _ in 0..50 {
if sock.exists() { break }
thread::sleep(Duration::from_millis(10));
}
let mut client = takana_agent::AgentClient::connect(&sock).expect("connect");
assert_eq!(client.welcome.ver, PROTOCOL_VERSION);
assert!(client.welcome.caps.contains(&Cap::Compile));
let recipe = RecipeInline {
name: "grep".into(),
repo: Some("git://example/grep.git".into()),
commit: Some("abc123".into()),
tarball: None,
sha256: None,
patch: None,
compiler: "zig-cc".into(),
target: "x86_64-linux-musl".into(),
link: "static".into(),
flags: vec![],
evidence: takana_core::Evidence {
checks: vec![takana_core::EvidenceCheck {
kind: takana_core::EvidenceKind::CmdExit,
cmd: "grep --version".into(),
expected_exit: 0,
expected_output: None,
}],
},
};
let outcome = client
.compile(recipe, Duration::from_secs(2))
.expect("BuildReady");
assert_eq!(outcome.artifact, "b3:stub-abc123");
// [H1c] el veredicto de la evidencia llega por BuildReady y el cliente lo expone.
let verdict = outcome.verdict.expect("verdict presente (declaramos evidencia)");
assert!(verdict.all_passed);
assert_eq!(verdict.checks.len(), 1);
assert_eq!(verdict.max_level, Some(takana_core::EvidenceKind::CmdExit));
// El stub también puede empujar eventos asíncronos. Inyectamos un Modified y vemos
// que el cliente lo entrega por drain_async()/next_async().
async_tx
.send(Event::Modified {
path: "/bin/grep".into(),
op: "replace".into(),
ts: "2026-06-09T00:00:00Z".into(),
})
.unwrap();
let ev = client.next_async(Duration::from_secs(2)).expect("async");
match ev {
Event::Modified { path, .. } => assert_eq!(path, "/bin/grep"),
other => panic!("esperaba Modified, llegó {other:?}"),
}
}