Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions crates/proof-rlm/src/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,14 @@ mod tests {
"inspection runs no miner code"
);
let outputs = [
// The whole authored set, first: it is the variant a host baked
// before `945e143f` cannot decode, and the one whose payload is a
// whole document rather than a report. A wire round trip that
// skips it is exactly how a host↔guest skew ships.
VmJobOutput::Authored(Box::new(crate::fixtures::authored_set(
&crate::fixtures::topic(),
rules().rules,
))),
VmJobOutput::Rules(rules().rules),
VmJobOutput::Baseline(crate::fixtures::report_for(&req, 0.5)),
VmJobOutput::Inspected(crate::runner::InspectOutcome {
Expand All @@ -966,7 +974,44 @@ mod tests {
assert!(json.contains("\"output\""), "{json}");
let back: VmJobOutput = serde_json::from_str(&json).expect("round trip");
assert_eq!(back, out);
// The set travels with **every** part, not only the vector: a
// guest that emits `Authored` and a host that reads it must agree
// on the whole document, and a part that did not survive the wire
// would arrive as an incomplete set (or not at all).
if let VmJobOutput::Authored(set) = &back {
assert!(
set.is_complete(),
"the set lost parts on the wire: {:?}",
set.missing_parts()
);
assert_eq!(set.missing_parts(), Vec::<&str>::new());
}
}
}

/// The wire tag is a **contract**: `Authored` travels as `"authored"`, and
/// a host that does not know that tag fails to decode the frame.
///
/// This pins the tag rather than the Rust name, because the tag is what
/// two independently built binaries agree on — the guest emits it, the
/// control plane and the orchestrator decode it, and none of them share a
/// build.
#[test]
fn the_authored_tag_is_a_wire_contract() {
let set = crate::fixtures::authored_set(&crate::fixtures::topic(), rules().rules);
let json = serde_json::to_string(&VmJobOutput::Authored(Box::new(set))).expect("json");
let value: serde_json::Value = serde_json::from_str(&json).expect("json");
assert_eq!(value["output"], "authored", "{json}");
// Adjacently tagged (`output` / `body`): the document is the body, and
// it is an object with the five parts rather than a bare vector.
let body = value["body"].as_object().expect("the set is the body");
for part in proof_topic_authoring::PARTS {
assert!(body.contains_key(part), "{part} did not travel: {json}");
}
// And the old tag still means what it meant: a fragment.
let rules_json = serde_json::to_string(&VmJobOutput::Rules(rules().rules)).expect("json");
let rules_value: serde_json::Value = serde_json::from_str(&rules_json).expect("json");
assert_eq!(rules_value["output"], "rules", "{rules_json}");
}

/// A topic whose params select an in-guest runner gets **one experiment
Expand Down
31 changes: 28 additions & 3 deletions crates/proof-vm-agent/src/fixtures_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use std::time::Duration;

use async_trait::async_trait;
use proof_canon::ChecklistRule;
use proof_rlm::fixtures::report_for;
use proof_rlm::fixtures::{authored_set, report_for};
use proof_rlm::{
ArtifactFile, Checklist, CustomRunRequest, InspectOutcome, LogFile, RetainPolicy, RunOutcome,
TopicVmSpec, VmJob, VmJobOutput,
Expand Down Expand Up @@ -62,6 +62,13 @@ pub struct FakeHypervisor {
/// Whether an experiment VM attests the paid job it ran (the host's
/// view). `false` models a host that lost track of what it booted.
experiment_attests: AtomicBool,
/// Whether `ProposeRules` answers with the whole authored set
/// (`VmJobOutput::Authored`) instead of a bare rule vector
/// (`VmJobOutput::Rules`). Default **true**: the tip's guest emits the
/// set, so a fake that only ever answered `Rules` would let the whole
/// orchestrator hop be exercised without ever carrying the variant a
/// host baked before `945e143f` cannot decode.
authored_complete: AtomicBool,
boots: Mutex<Vec<BootedVm>>,
/// Specs of every VM booted, by id (experiment VMs carry `experiment`).
specs: Mutex<Vec<(String, TopicVmSpec)>>,
Expand Down Expand Up @@ -96,6 +103,7 @@ impl FakeHypervisor {
dead: Mutex::new(BTreeSet::new()),
dies_under_job: AtomicBool::new(false),
experiment_attests: AtomicBool::new(true),
authored_complete: AtomicBool::new(true),
boots: Mutex::new(Vec::new()),
specs: Mutex::new(Vec::new()),
jobs: Mutex::new(Vec::new()),
Expand Down Expand Up @@ -171,6 +179,12 @@ impl FakeHypervisor {
*self.proposed.lock().unwrap() = rules;
}

/// Answer `ProposeRules` with the whole authored set (default) or with a
/// bare rule vector (an adaptor/guest baked before the set existed).
pub fn set_authored_complete(&self, v: bool) {
self.authored_complete.store(v, Ordering::SeqCst);
}

/// Make every job take this long (to exercise `Busy`).
pub fn set_job_delay(&self, d: Option<Duration>) {
*self.job_delay.lock().unwrap() = d;
Expand Down Expand Up @@ -338,8 +352,19 @@ impl Hypervisor for FakeHypervisor {
)));
}
Ok(match job {
VmJob::ProposeRules { .. } => JobOutcome {
output: VmJobOutput::Rules(self.proposed.lock().unwrap().clone()),
VmJob::ProposeRules { topic, .. } => JobOutcome {
// The tip's guest answers `Authored` (the whole set) when its
// adaptor wrote `authoring.json`, and `Rules` (a fragment)
// when it only had `rules.json`. Both travel this hop, so both
// are reachable here.
output: if self.authored_complete.load(Ordering::SeqCst) {
VmJobOutput::Authored(Box::new(authored_set(
topic,
self.proposed.lock().unwrap().clone(),
)))
} else {
VmJobOutput::Rules(self.proposed.lock().unwrap().clone())
},
sister: None,
},
VmJob::Baseline { request } => JobOutcome {
Expand Down
75 changes: 75 additions & 0 deletions crates/proof-vm-agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,81 @@ mod tests {
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
}

/// The whole authored set crosses the orchestrator's **HTTP** hop, every
/// part, and comes back as the set.
///
/// This is the hop the live FAIL travelled: the guest emits `Authored`,
/// the orchestrator stamps and re-encodes it, and the control plane reads
/// it. A host baked before `945e143f` has no `authored` variant and cannot
/// decode this response — so the shape is pinned here, at the layer that
/// does the re-encoding, rather than only inside the guest.
#[tokio::test]
async fn the_whole_authored_set_crosses_the_job_hop() {
let hv = FakeHypervisor::new(0.8);
let (app, _) = app(hv.clone(), "authored-hop");
let rec = create(&app).await;
let topic = proof_rlm::fixtures::topic();
let body = serde_json::to_vec(&RunJobRequest {
topic_id: rec.handle.topic_id.clone(),
job: VmJob::ProposeRules {
topic: Box::new(topic.clone()),
current_version: None,
current: None,
},
})
.expect("json");
let (status, bytes) = post_bytes(&app, &paths::vm_jobs(&rec.handle.vm_id), body).await;
assert_eq!(
status,
StatusCode::OK,
"{}",
String::from_utf8_lossy(&bytes)
);
// The wire the control plane reads: `output` / `body`, with the set as
// the body and the tag `authored`.
let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
value["output"]["output"], "authored",
"the hop must carry the set, not a fragment: {value}"
);
for part in proof_rlm::AUTHORING_PARTS {
assert!(
value["output"]["body"].get(part).is_some(),
"{part} did not cross the hop: {value}"
);
}
let out: RunJobResponse = serde_json::from_slice(&bytes).expect("response");
let VmJobOutput::Authored(set) = out.output else {
panic!("expected the set, got a fragment");
};
assert!(set.is_complete(), "missing {:?}", set.missing_parts());
assert_eq!(set.topic_id, topic.id);
assert!(!set.migrations.is_empty() && !set.apis.is_empty());
// And a guest baked before the set existed still crosses as `rules`.
hv.set_authored_complete(false);
let body = serde_json::to_vec(&RunJobRequest {
topic_id: rec.handle.topic_id.clone(),
job: VmJob::ProposeRules {
topic: Box::new(topic),
current_version: None,
current: None,
},
})
.expect("json");
let (status, bytes) = post_bytes(&app, &paths::vm_jobs(&rec.handle.vm_id), body).await;
assert_eq!(
status,
StatusCode::OK,
"{}",
String::from_utf8_lossy(&bytes)
);
let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
value["output"]["output"], "rules",
"a fragment keeps its own tag: {value}"
);
}

/// A job or teardown that names another topic than the VM's never reaches
/// the hypervisor — whether the mismatch is in the envelope or the job.
#[tokio::test]
Expand Down
9 changes: 8 additions & 1 deletion crates/proof-vm-fc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -403,8 +403,15 @@ impl FirecrackerOrchestrator {
)));
}
serde_json::from_slice::<T>(&bytes).map(Some).map_err(|e| {
// The control plane and the orchestrator are separately built
// binaries. An answer carrying a variant this build does not know
// is a **build skew**, not a malformed answer — the shape of the
// live FAIL, where the guest emitted the authored set and the
// reader could not decode it. `proof_vm_proto` owns the wording so
// both host decoders say the same thing.
backend(format!(
"orchestrator answer ({method} {path}) did not parse: {e}"
"orchestrator answer ({method} {path}) did not parse: {}",
proof_vm_proto::guest::decode_skew(&bytes, &e)
))
})
}
Expand Down
Loading