Odin/crates/odin-daemon/tests/daemon_process.rs
Meta Vens c94b5c4f85 Odin tests: the real binary; an acknowledged put survives SIGKILL, SIGTERM stops it cleanly
From Soul's SIGTERM probe (scratch 5c379dd), made a test. It runs the built
odin-daemon under an Idunn-shaped world with the warming probe and lease
grant made over RUDP. With puts written before they are acknowledged, the
SIGKILL round is the rule's test; the SIGTERM case pins `main`'s signal
wiring, which no in-process test reaches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:30:32 +02:00

350 lines
14 KiB
Rust

//! The real odin-daemon binary, run under an Idunn-shaped world: its bundle,
//! signer descriptors, projection and write lease on disk, and Idunn's warming
//! probe and lease grant made over RUDP as Idunn makes them. It reaches what
//! the in-process tests cannot: `main`'s signal wiring and a process that dies.
//! Unix only, like the daemon.
use std::fs::File;
use std::net::{SocketAddr, UdpSocket};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::{Result, bail, ensure};
use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore};
use cultmesh_rs::{
CultMeshRudpDocumentPublishOptions, CultMeshRudpSnapshotOptions,
publish_cultnet_message_to_rudp_catalog, request_raw_snapshot_from_rudp_catalog,
};
use cultnet_rs::*;
use odin_daemon::IncarnationRef;
fn digest(byte: char) -> String {
format!("sha256-{}", byte.to_string().repeat(64))
}
fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64
}
fn rfc3339(millis: u64) -> String {
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(millis as i64)
.unwrap()
.to_rfc3339()
}
/// Replace the whole store at `path` with `entries`.
fn write_store(path: &Path, entries: &[CultCacheEnvelope]) -> Result<()> {
let store = SingleFileMessagePackBackingStore::new(path);
let current = if path.is_file() {
store.pull_all_read_only_snapshot()?
} else {
Vec::new()
};
ensure!(store.compare_exchange_snapshot(&current, entries)?, "store write lost a race");
Ok(())
}
fn credential_bytes(store: &Path) -> Result<Vec<u8>> {
Ok(rmp_serde::to_vec(&SingleFileMessagePackBackingStore::new(store).pull_all()?)?)
}
struct Odin {
_temp: tempfile::TempDir,
root: PathBuf,
candidate: SocketAddr,
child: Child,
store: PathBuf,
}
impl Odin {
fn log(&self) -> String {
std::fs::read_to_string(self.root.join("odin.log")).unwrap_or_default()
}
/// Put one provider document and return once the server acknowledged it.
fn put(&self, key: &str) -> Result<()> {
let message = CultNetMessage::DocumentPutRaw {
message_id: format!("put-{key}"),
document: CultNetRawDocumentRecord {
schema_id: "ghostlight.doc.v1".into(),
record_key: key.into(),
stored_at: rfc3339(now()),
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
payload: vec![1],
source_runtime_id: None,
source_agent_id: None,
source_role: None,
tags: None,
},
};
let mut options = CultMeshRudpDocumentPublishOptions::odin(self.candidate, "test-publisher");
options.flush_timeout = Duration::from_secs(3);
publish_cultnet_message_to_rudp_catalog(&message, options)
}
fn holds(&self, key: &str) -> Result<bool> {
Ok(SingleFileMessagePackBackingStore::new(&self.store)
.pull_all_read_only_snapshot()?
.iter()
.any(|entry| entry.r#type == "ghostlight.doc" && entry.key == key))
}
fn signal(&mut self, signal: &str) -> Result<ExitStatus> {
let pid = self.child.id().to_string();
ensure!(Command::new("kill").args([signal, &pid]).status()?.success());
let deadline = Instant::now() + Duration::from_secs(20);
loop {
if let Some(status) = self.child.try_wait()? {
return Ok(status);
}
ensure!(Instant::now() < deadline, "Odin did not exit on {signal}");
std::thread::sleep(Duration::from_millis(10));
}
}
}
impl Drop for Odin {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// Launch Odin as Idunn does and grant it its lease; returns once Odin has
/// written its store, which it does on its first pass after activating.
fn start_odin() -> Result<Odin> {
let temp = tempfile::tempdir()?;
let root = temp.path().to_path_buf();
let candidate = UdpSocket::bind("127.0.0.1:0")?.local_addr()?;
let t0 = now();
let idunn = enroll_service_identity_at::<IdunnServiceIdentity>(&root.join("idunn.cc"))?;
enroll_service_identity_at::<OdinTopologyIdentity>(&root.join("odin-topology.cc"))?;
let provider =
enroll_service_identity_at::<GameCultProviderHealthIdentity>(&root.join("odin-provider.cc"))?;
std::fs::write(root.join("topology-cred"), credential_bytes(&root.join("odin-topology.cc"))?)?;
std::fs::write(root.join("provider-cred"), credential_bytes(&root.join("odin-provider.cc"))?)?;
export_service_identity_trust_anchor::<IdunnServiceIdentity>(&idunn, &root.join("idunn-anchor.cc"))?;
let expected = IdunnExpectedIncarnationRecord {
schema_version: IDUNN_EXPECTED_INCARNATION_SCHEMA.into(),
target: "odin".into(),
plan_id: digest('1'),
incarnation_id: "odin/generation-1".into(),
sealed_release_id: digest('2'),
source_repository: "github.com/GameCult/Odin".into(),
source_revision: "3".repeat(40),
recipe_sha256: digest('4'),
runtime_id: "odin-runtime".into(),
expected_signer_identity_id: provider.entry().identity_id.clone(),
health_contract: "odin.runtime-health.v1".into(),
artifact_sha256: digest('5'),
state_schema_generation: Some("odin-v2".into()),
state_contract_sha256: Some(
"sha256-4f2f2dcd931d16f6b02bf295f41227b867aa982765661d55fa9f29fb2db7e449".into(),
),
write_lease_required: true,
route: Some(IdunnExpectedRoute {
route_id: "odin-route".into(),
transport: "rudp".into(),
stable_endpoint: "rudp://odin.internal:1000".into(),
candidate_endpoint: format!("rudp://{candidate}"),
}),
capabilities: vec![IdunnExpectedCapability {
capability: "odin.verse-rendezvous".into(),
schema: "odin.verse-topology.v1".into(),
compatibility: "v1".into(),
minimum_capacity: 1,
}],
dependencies: Vec::new(),
};
expected.validate()?;
let launch = IdunnRuntimeActivationLaunch::issue(&expected, digest('7'), t0 - 20, &idunn)?;
let activation = launch.activation().clone();
let mut activation_credential = Vec::new();
launch.write_credential(&mut activation_credential)?;
std::fs::write(root.join("activation-cred"), &activation_credential)?;
let expected_envelope = |key: &str| -> Result<CultCacheEnvelope> {
Ok(CultCacheEnvelope {
key: key.into(),
r#type: IdunnExpectedIncarnationRecord::TYPE.into(),
payload: expected.canonical_bytes()?,
stored_at: rfc3339(t0 - 30),
schema_id: Some(IDUNN_EXPECTED_INCARNATION_SCHEMA.into()),
})
};
let activation_envelope = |key: &str| -> Result<CultCacheEnvelope> {
Ok(CultCacheEnvelope {
key: key.into(),
r#type: IdunnRuntimeActivationRecord::TYPE.into(),
payload: activation.canonical_bytes()?,
stored_at: rfc3339(activation.issued_at_unix_millis),
schema_id: Some(IDUNN_RUNTIME_ACTIVATION_SCHEMA.into()),
})
};
let bundle = root.join("bundle");
std::fs::create_dir_all(&bundle)?;
write_store(&bundle.join("expected.cc"), &[expected_envelope("odin")?])?;
write_store(&bundle.join("activation.cc"), &[activation_envelope("odin")?])?;
let anchor = GameCultServiceTrustAnchorRecord {
schema_version: GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA.into(),
trust_anchor_id: "root/odin/runtime-presence".into(),
service_id: "odin".into(),
runtime_id: expected.runtime_id.clone(),
signer_identity_id: provider.entry().identity_id.clone(),
signer_public_key: provider.entry().public_key.clone(),
signature_algorithm: "ed25519".into(),
signing_purpose: GAMECULT_RUNTIME_PRESENCE_HEALTH_SIGNING_PURPOSE.into(),
signed_schema: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(),
binding_authority: "root".into(),
bound_at_unix_millis: t0 - 100,
expires_at_unix_millis: None,
private_state_exposed: false,
};
let incarnation_key = IncarnationRef::of(&expected)?.key();
let projection = root.join("projection.cc");
let mut projected = vec![
expected_envelope(&incarnation_key)?,
CultCacheEnvelope {
key: anchor.trust_anchor_id.clone(),
r#type: GameCultServiceTrustAnchorRecord::TYPE.into(),
payload: rmp_serde::to_vec(&anchor)?,
stored_at: rfc3339(anchor.bound_at_unix_millis),
schema_id: Some(GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA.into()),
},
activation_envelope(&incarnation_key)?,
];
write_store(&projection, &projected)?;
let lease_path = root.join("lease.cc");
File::create(root.join("lease.cc.lock"))?;
let state_root = root.join("state");
std::fs::create_dir_all(&state_root)?;
// Descriptors 3 and 4 carry the signer credentials, as systemd passes them.
let child = Command::new("bash")
.arg("-c")
.arg(format!(
"exec 3<{activation} 4<{provider}; exec {bin} --state-root {state} --idunn-projection {projection} --idunn-anchor {anchor}",
activation = root.join("activation-cred").display(),
provider = root.join("provider-cred").display(),
bin = env!("CARGO_BIN_EXE_odin-daemon"),
state = state_root.display(),
projection = projection.display(),
anchor = root.join("idunn-anchor.cc").display(),
))
.env("GAMECULT_IDUNN_RUNTIME_BUNDLE", &bundle)
.env("GAMECULT_IDUNN_CANDIDATE_BIND", candidate.to_string())
.env("GAMECULT_IDUNN_PROCESS_WRITE_LEASE", &lease_path)
.env("ODIN_TOPOLOGY_IDENTITY", root.join("topology-cred"))
.env("LISTEN_PID", "1")
.env("LISTEN_FDS", "2")
.env(
"LISTEN_FDNAMES",
format!("{IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME}:gamecult-runtime-presence-identity"),
)
.stdout(Stdio::null())
.stderr(Stdio::from(File::create(root.join("odin.log"))?))
.spawn()?;
let mut odin = Odin {
_temp: temp,
root,
candidate,
child,
store: state_root.join("topology.cc"),
};
// Idunn's warming probe, then the lease naming that exact warming presence.
let deadline = Instant::now() + Duration::from_secs(20);
let response = loop {
match request_raw_snapshot_from_rudp_catalog(CultMeshRudpSnapshotOptions {
target: candidate,
runtime_id: "test-idunn".into(),
message_id: format!("warming-{}", now()),
schema_ids: Some(vec![GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into()]),
record_keys: Some(vec!["odin".into()]),
..Default::default()
}) {
Ok(response) => break response,
Err(error) => {
if let Some(status) = odin.child.try_wait()? {
bail!("Odin exited while warming ({status}): {}", odin.log());
}
ensure!(Instant::now() < deadline, "Odin never answered warming: {error:#}");
std::thread::sleep(Duration::from_millis(100));
}
}
};
let CultNetMessage::SnapshotResponseRaw { documents, .. } = response else {
bail!("the warming probe was not answered with raw documents")
};
let presence: GameCultRuntimePresenceHealthRecord = rmp_serde::from_slice(&documents[0].payload)?;
ensure!(presence.state == "warming", "{}", presence.state);
let lease = IdunnProcessWriteLeaseRecord {
schema_version: IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into(),
target: "odin".into(),
expected_projection_sha256: expected.canonical_sha256()?,
plan_id: expected.plan_id.clone(),
incarnation_id: expected.incarnation_id.clone(),
sealed_release_id: expected.sealed_release_id.clone(),
activation_witness_sha256: activation.canonical_sha256()?,
state_schema_generation: "odin-v2".into(),
state_contract_sha256: expected.state_contract_sha256.clone().unwrap(),
runtime_id: expected.runtime_id.clone(),
runtime_instance_id: activation.runtime_instance_id.clone(),
warming_presence_sha256: presence.canonical_sha256()?,
lease_epoch: 1,
issued_at_unix_millis: now() - 5,
};
let lease_envelope = |key: &str| -> Result<CultCacheEnvelope> {
Ok(CultCacheEnvelope {
key: key.into(),
r#type: IdunnProcessWriteLeaseRecord::TYPE.into(),
payload: lease.canonical_bytes()?,
stored_at: rfc3339(lease.issued_at_unix_millis),
schema_id: Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into()),
})
};
write_store(&lease_path, &[lease_envelope("odin")?])?;
projected.push(lease_envelope(&incarnation_key)?);
write_store(&projection, &projected)?;
while !odin.store.is_file() {
if let Some(status) = odin.child.try_wait()? {
bail!("Odin exited while activating ({status}): {}", odin.log());
}
ensure!(Instant::now() < deadline, "Odin never activated: {}", odin.log());
std::thread::sleep(Duration::from_millis(20));
}
Ok(odin)
}
/// A put Odin acknowledged is on disk: killed outright right after the
/// acknowledgement, inside the interval its own changes wait for, Odin loses
/// none of them.
#[test]
fn an_acknowledged_put_survives_sigkill() -> Result<()> {
for round in 0..3 {
let mut odin = start_odin()?;
// Inside the interval that Odin's activation write started.
std::thread::sleep(Duration::from_millis(300));
let key = format!("doc-{round}");
odin.put(&key)?;
odin.signal("-KILL")?;
ensure!(odin.holds(&key)?, "round {round}: the acknowledged put was lost: {}", odin.log());
}
Ok(())
}
/// SIGTERM reaches the serving loop as a stop request: Odin makes its last
/// write and exits cleanly, rather than waiting to be killed.
#[test]
fn a_stop_signal_ends_odin_cleanly() -> Result<()> {
let mut odin = start_odin()?;
let status = odin.signal("-TERM")?;
let log = odin.log();
ensure!(status.success(), "Odin exited {status}: {log}");
ensure!(log.contains("Odin stopping on request"), "{log}");
ensure!(!log.contains("final store write was not made"), "{log}");
Ok(())
}