fix: four harness bugs (profile mutation, launch expectations, result folding, boot wait) - #289
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes four pre-existing issues in the SEV verification harness to make multi-step VM runs more reliable: it preserves step-driven profile updates, correctly applies expected_result to launch exceptions, folds certification status using a severity ordering, and aborts guest-boot waits early when QEMU exits.
Changes:
cli.py: Preserve step-updatedctx.profileacross steps; fold certification outcome via severity ordering.runner.py: TreatVMLaunchErroras a checkable launch outcome by honoringexpected_result.vm_profile.py/guest_vsock.py: Pass the QEMU process handle to vsock wait logic to stop waiting when QEMU has already exited, and surface QEMU stderr context.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| sev_verify/cli.py | Re-reads ctx.profile each step; adds severity-based folding for overall certification result. |
| sev_verify/runner.py | Honors expected_result even when vm_launch raises VMLaunchError. |
| sev_verify/vm_profile.py | Forwards the QEMU process handle into boot readiness checks and appends QEMU stderr tail on exit. |
| sev_verify/guest_vsock.py | Adds optional process-aware early abort for guest readiness waits. |
Suppressed comments (1)
sev_verify/guest_vsock.py:185
- Same typing consistency issue as wait_for_guest: annotate process as subprocess.Popen[bytes] | None to match VMLaunchResult.process and the actual subprocess.Popen usage in vm_profile.
def check_guest_ready(
profile: VMProfile,
*,
timeout: float | None = None,
poll_interval: float = 2.0,
process: subprocess.Popen | None = None,
) -> tuple[bool, str]:
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (4)
sev_verify/vm_profile.py:244
processis dereferenced unconditionally (process.poll()), butcheck_guest_ready()acceptsprocess=NoneandVMLaunchResult.processis typed as optional in context. Ifprocesscan beNonein this code path, this will raiseAttributeErrorand mask the real boot failure. Consider guardingprocessexplicitly (e.g.,if process is not None and process.poll() is not None:) before accessing it.
booted, boot_error = check_guest_ready(self, process=process)
if not booted:
ok = False
message = f"Guest did not boot: {boot_error}"
if process.poll() is not None:
# QEMU's own diagnosis is far more useful than "agent not
# ready" — for a rejected launch it names the firmware
# error, e.g. "SNP_LAUNCH_FINISH ... 'Bad measurement'".
stderr_tail = _read_guest_errors(self.guest_error_log).strip()
if stderr_tail:
message = f"{message}\nQEMU stderr:\n{stderr_tail}"
sev_verify/guest_vsock.py:146
subprocess.Popen[bytes]in annotations can be a runtimeTypeError(“type 'Popen' is not subscriptable”) unless this module uses postponed evaluation of annotations (e.g.,from __future__ import annotations) or otherwise avoids evaluating the subscript at runtime. To keep this safe across runtime configurations, consider changing the annotation to an unsubscriptedsubprocess.Popen | None, or using atypingalias that won’t be evaluated at runtime.
process: subprocess.Popen[bytes] | None = None,
sev_verify/runner.py:190
_check_expected_values()documents checkingexit_codeand stdout, but this path passesstr(exc)as the “stdout” argument while also writing it tostderr. That makesexpected_result=stdout_contains:...effectively match against an error message, which is inconsistent with the documented semantics and with where the output is stored inStepResult. Consider either (a) extending_check_expected_valuesto check stderr (e.g., astderr_containskind or a generalized “output_contains”), or (b) storing the exception message instdoutwhen usingstdout_containsexpectations so the behavior is internally consistent.
# A launch that fails hard is still a launch outcome, so honour the
# step's expected_result. Reporting "error" unconditionally made it
# impossible to write a step that expects a launch to be refused —
# QEMU exiting immediately raises rather than returning ok=False.
passed = _check_expected_values(step, 1, str(exc))
return (
StepResult(
step=step,
result="pass" if passed else "error",
exit_code=1,
stderr=str(exc),
duration_ms=duration_ms,
),
sev_verify/cli.py:509
- The comparison on line 509 is hard to read and likely to violate typical line-length linting. Consider splitting into named locals (e.g.,
current_sev,candidate_sev) to improve readability and ease future edits (such as changing how unknown results are handled).
def _worse_result(current: str, candidate: str) -> str:
"""Return whichever of the two results is more severe."""
unknown_severity = max(_RESULT_SEVERITY.values()) + 1
if _RESULT_SEVERITY.get(candidate, unknown_severity) > _RESULT_SEVERITY.get(current, unknown_severity):
return candidate
return current
d6192f2 to
91648ac
Compare
execute_test assigned the loop's local `profile` onto `ctx.profile` at the top of every iteration. A callable step that replaces the profile — `dataclasses.replace` on a frozen VMProfile returns a new object, so mutating it in place is not possible — had that replacement silently overwritten before the next step ran. Any handler that reconfigures the guest for subsequent steps was therefore a no-op. Read the profile out of the context instead of writing the stale local back over it.
run_vm_launch_step reported "error" unconditionally when vm_launch raised VMLaunchError, ignoring the step's expected_result. That made it impossible to write a step that expects a launch to be refused: QEMU exiting immediately raises rather than returning ok=False, so a step declaring expected_result="exit_code:1" errored on exactly the outcome it was asserting. Evaluate expected_result against exit code 1 and the exception text, and record exit_code=1 so the result is self-describing. Steps that do not declare an expectation still default to "exit_code:0" and so still report error, leaving existing behavior unchanged.
execute_certification folded per-test results with
if tr.result != "pass":
overall = tr.result
which is last-writer-wins, not worst-wins. A run where the first test
errors and a later one merely fails reported the certification as
"fail", losing the error. The bug is latent today because both outcomes
are non-passing, but it silently downgrades severity in reports, and any
new non-passing state makes it worse.
Fold with an explicit severity ordering instead.
vm_launch checks whether QEMU is still alive exactly once, at wait_ready_seconds, and never again. wait_for_guest then polls vsock until vsock_boot_timeout with no reference to the process at all. If QEMU exits after that single check, the harness spends the rest of the boot timeout pinging a VM that no longer exists. Measured on an EPYC 9654 with a deliberately corrupted ID block measurement, sampling the process at 1 Hz: +14.6s .. +17.2s QEMU alive +17.2s .. gone step ran +13.8s .. +195.9s QEMU lived 2.6s; the step took 182.1s. ~179s was spent waiting on a dead process. Policy violations are rejected at SNP_LAUNCH_START before guest memory is loaded, so QEMU dies inside the initial check and those launches already fail in ~2s — which is why only the measurement case was slow. Pass the process handle into wait_for_guest and abort as soon as it exits. The step drops from 182.1s to ~3s and the id-block suite from ~200s to ~21s. The diagnostics matter more than the speed. Firmware had already reported the exact cause: SNP_LAUNCH_FINISH ret=-5 fw_error=11 'Bad measurement' and the harness discarded it in favour of "Vsock agent on CID ... not ready after 180.0s". A negative test asserting exit_code:1 is satisfied by that timeout just as well as by a real rejection, so it would pass identically if the guest merely hung or if the ID block were ignored entirely. The failure message now carries QEMU's exit code and its stderr tail, so a rejected launch states the firmware's reason. process defaults to None, preserving the old behavior for any caller that does not supply it.
91648ac to
be72ac2
Compare
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> fix: parameterize the QEMU process type annotation guest_vsock used a bare subprocess.Popen while VMLaunchResult.process is annotated subprocess.Popen[bytes]. Bare Popen reads as Popen[Any] to a type checker, losing the stream element type for no benefit — only .poll() and .returncode are used. Match the existing annotation. refactor: split the severity comparison into named locals The single-expression comparison ran to 107 characters and read poorly, with _RESULT_SEVERITY.get(..., unknown_severity) appearing twice on one line. Name both severities and compare them. Behavior is identical: an unrecognised state still ranks above every known one, so a typo surfaces loudly rather than masking a real failure. That rationale moves from a comment into the docstring, where it is visible to callers.
be72ac2 to
732ae03
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
sev_verify/runner.py:182
VMLaunchErrorincludes QEMU’s real exit code (see vm_profile.py:218-221), but this handler hard-codes1both for the expected_result check and the returnedStepResult.exit_code. That can misreport failures and makesexpected_result=exit_code:<actual>impossible for hard-fail launches. Consider extracting the real code from the exception message and using it for both_check_expected_valuesandStepResult.exit_code(falling back to 1 if parsing fails).
passed = _check_expected_values(step, 1, str(exc))
amd-aliem
left a comment
There was a problem hiding this comment.
lgtm, you might need to re-run one of the image build CI tests
|
Thank you, @amd-aliem |
Addresses the review comment asking whether the negative launches could check the reason for failure rather than accepting any launch error. expected_result="exit_code:1" is satisfied by anything that fails, including a boot timeout — so these steps could not distinguish a real firmware rejection from a hung guest, and would have passed even if the ID block were ignored entirely. The harness fix merged as AMDEPYC#289 makes the firmware's own text reachable from both launch-failure paths (the raise path passes str(exc) through _check_expected_values; the ok=False path now carries QEMU's stderr tail), so it can be asserted directly. Observed on an EPYC 9654 at BIOS 1.10.6: bad measurement SNP_LAUNCH_FINISH ret=-5 fw_error=11 'Bad measurement' SMT policy SNP_LAUNCH_START ret=-22 fw_error=0 '' ABI version SNP_LAUNCH_START ret=-5 fw_error=7 'Policy is not allowed' The SMT case is refused by KVM before the firmware sees it, so it has no firmware string to match; it asserts SNP_LAUNCH_START instead, which still rules out a boot timeout. Worth noting that this test therefore exercises KVM's policy validation rather than the firmware's.
Addresses the FMC/Turin review comments on AMDEPYC#247. The gap was worse than "unsupported": TCB_VERSION is laid out differently on Turin, and the two layouts are indistinguishable from the eight bytes alone. byte Milan/Genoa Turin/Venice 0 BOOT_LOADER FMC 1 TEE BOOT_LOADER 2 -- TEE 3 -- SNP 6 SNP -- 7 MICROCODE MICROCODE Decoding a Turin report with the legacy layout yielded BOOT_LOADER=FMC, TEE=BOOT_LOADER, SNP=0 — plausible values, no error. The sev crate handles this by dispatching on processor Generation (from_legacy_bytes/from_turin_bytes); we hardcoded one layout. Resolve a generation from the host's CPUID family/model, which is authoritative for a report produced by a guest on this machine and, unlike the report's own CPUID copy, present regardless of report version. parse() takes it as an argument so it stays a pure function of its input, unit-testable without hardware. When the report is v3+ it carries CPUID too, and the two are cross-checked: a mismatch means the report did not come from this machine. SUPPORTED_GENERATIONS records what has been validated on hardware, not what we believe we could decode — currently Genoa alone. An unrecognised processor raises rather than assuming a transcribed layout is right: a certification harness reporting a pass on silicon it has never run on is the failure this gate exists to prevent. Layouts for generations not yet exercised are recorded alongside it, so adding one is an entry plus a validation note. Keyed on family/model pairs rather than family/model/stepping triples: AMD scopes SEV firmware images by family and model only, and snpguest's get_processor_model partitions the same way. The module docstring now also records that this is a short-term stand-in. snpguest already parses reports correctly and generation-aware via the sev crate but exposes them only as human-readable text; once it gains machine-readable output this module should be replaced by consuming that rather than extended to cover further generations. test: assert why a negative launch was rejected, not just that it failed Addresses the review comment asking whether the negative launches could check the reason for failure rather than accepting any launch error. expected_result="exit_code:1" is satisfied by anything that fails, including a boot timeout — so these steps could not distinguish a real firmware rejection from a hung guest, and would have passed even if the ID block were ignored entirely. The harness fix merged as AMDEPYC#289 makes the firmware's own text reachable from both launch-failure paths (the raise path passes str(exc) through _check_expected_values; the ok=False path now carries QEMU's stderr tail), so it can be asserted directly. Observed on an EPYC 9654 at BIOS 1.10.6: bad measurement SNP_LAUNCH_FINISH ret=-5 fw_error=11 'Bad measurement' SMT policy SNP_LAUNCH_START ret=-22 fw_error=0 '' ABI version SNP_LAUNCH_START ret=-5 fw_error=7 'Policy is not allowed' The SMT case is refused by KVM before the firmware sees it, so it has no firmware string to match; it asserts SNP_LAUNCH_START instead, which still rules out a boot timeout. Worth noting that this test therefore exercises KVM's policy validation rather than the firmware's. refactor: drop the duplicated calculate_measurement feat: improve SMT sub-test fix: satisfy crypto module dependency This fixes sev-certify automated runs. For sev_verify only runs, it may or may not. See the updated sev_verify/readme.md. fix: decode reports whose firmware left the CPUID fields empty The parser refused any report whose CPUID bytes did not resolve to a validated generation, reporting it as an unsupported processor. That is wrong when the processor is fine and the firmware simply did not populate the field: SEV firmware 1.55 build 38 leaves all three bytes zero in version-3 reports, where build 39 fills them in. On such a platform every report was rejected, and the error blamed the CPU. The host's CPUID is authoritative — it describes the silicon this code runs on, which is what the TCB_VERSION layout depends on, and it is present regardless of report version. The report's copy is a cross-check, useful only when it resolves. So an unresolvable one is now recorded in a new cpuid_note field and decoding proceeds with the host's generation. The exception is kept for the case that does indicate a problem: both CPUIDs resolve to validated generations and disagree, meaning the report came from another machine. That branch only becomes reachable once a second generation is added to SUPPORTED_GENERATIONS; with one validated generation every disagreeing CPUID is unresolvable instead. That ordering is deliberate — a report is called foreign only when both generations are ones we have actually validated against. With no host generation supplied and an unresolvable CPUID, TCB_VERSION is now left undecoded rather than raising, matching what a v2 report already does. docs: say how to add a report version, not just a processor SUPPORTED_GENERATIONS explains how to validate and add a processor generation; KNOWN_VERSIONS only stated which versions were readable and refused the rest, leaving the reader to work out what adding one involves. Record the procedure, and that versions 4 and 5 already exist and are refused. Note the cheap case explicitly: the sev crate's ReportVariant mapping groups versions sharing a layout, so a version grouped with one already listed reads identically, which makes v4 straightforward and v5 the one needing scrutiny. Also note that additions are not always new offsets — v5's GuestPolicy and PlatformInfo changes are new bits in existing fields. State that version and generation are independent axes, since the two gates look redundant until it is clear that one decides which fields exist and the other how TCB_VERSION is ordered. feat: validate Turin and report version 5 on hardware Adds the second processor generation and the second report version, both confirmed on an EPYC 9575F rather than transcribed. The Turin TCB_VERSION layout had been taken from the sev crate and never exercised. A version 5 report from that machine carried REPORTED_TCB bytes 0103020600000062, which decode under the Turin layout to bootloader=3 tee=2 snp=6 microcode=98 fmc=1 — matching snphost show tcb exactly. The same bytes under the legacy layout give bootloader=1 tee=3 snp=0 microcode=98 and no FMC: plausible values, silently wrong. That is the failure this gate exists to prevent, now demonstrated rather than asserted. That report also showed version 5 moves none of the fields read here: TCB at 0x180 and CPUID at 0x188 both decoded correctly, so v5's additions are the new GuestPolicy and PlatformInfo bits the crate documents rather than relocations. Version 4 remains refused, never having been seen. Two consequences worth noting. The generation mismatch branch in parse() was unreachable while only one generation was validated; with two, a report from the wrong machine is now genuinely detected. And Turin firmware 1.58 populates the report CPUID fields that Genoa's BIOS-supplied 1.55 build 38 leaves zeroed, so both sides of that behaviour are now covered by tests.
Four pre-existing harness bugs, none of which need any knowledge of ID blocks to review. Split out so they can merge ahead of, and independently of, the ID block work in #247.
+59/-6 across 4 files, one commit per fix.
1.
cli.py— re-read the VM profile from context between stepsWhy a test would need to change the profile mid-run. The
vm_profilea test module declares is static — it is evaluated before the run begins. Any launch parameter that can only be computed during the run has no other way in thanctx.profile.Examples: an ID block cannot be built until the guest has been measured, and the guest cannot be measured until the run is underway, so it is necessarily compute-then-relaunch. Likewise a policy derived from observed platform state (check whether SMT is active, then set the policy bit to match), a
host_datavalue derived from something measured earlier, or a guest image chosen by a preceding step.The rule this establishes: static launch configuration belongs in
vm_profile; anything a step computes belongs inctx.profile.The bug.
execute_testassigned the loop-localprofileontoctx.profileat the top of every iteration. A callable step that replaces the profile —dataclasses.replaceon a frozenVMProfilereturns a new object, so in-place mutation is impossible — had that replacement overwritten before the next step ran. The second channel silently did not work, and any handler reconfiguring the guest for later steps was a no-op.Does this generalise to other context fields? Not today, and the reason is worth recording.
profileis the onlyStepContextfield with step → harness dataflow.launchhas the identical overwrite shape (ctx.launch = launcheach iteration) but flows the other way — the harness produces it and steps consume it — so overwriting is correct there.step_resultsis a list mutated in place, so assignment never clobbers it. The remaining fields are static per test.The fix is therefore correctly scoped, but the trap is general: any future context field intended to carry a value out of a step will hit exactly this bug.
2.
runner.py— honourexpected_resultwhen a launch raisesvm_launchhas two distinct failure modes, and only one of them raises:VMLaunchErrorerror, unconditionallyok=Falseexit_code=1,expected_resulthonouredThe
ok=Falsepath was already correct. So a step declaringexpected_result="exit_code:1"passed or errored depending on how the launch failed — something a test author can neither control nor predict.Concretely: a policy the platform cannot satisfy is rejected at
SNP_LAUNCH_STARTbefore guest memory is loaded, so QEMU dies instantly and raises → error. A launch rejected later, after QEMU is already running, returnsok=False→ pass. Two tests asserting the same thing, disagreeing purely because of rejection timing.This aligns the raise path with the return path that already worked. Steps that declare no expectation still default to
exit_code:0and still reporterror, so existing behavior is unchanged.3.
cli.py— stop a later test result masking a worse earlier oneEvery non-passing test overwrites
overall, so the certification reports the result of the last non-passing test rather than the worst one:Why it matters.
failanderrorprescribe different responses.failmeans the platform failed a check — investigate the platform.errormeans the harness could not run the check at all (module import failure, timeout, crash) — investigate the harness. Reportingfailwhen anerroroccurred sends the reader down the wrong path.Why it is subtle, and why it has survived. Today it changes only the label, never the verdict: both states are non-passing, so the certification does not pass either way, and
_highest_certified_levelis unaffected because it inspects per-test results rather than this fold.It becomes actively harmful the moment a non-passing state exists that should not dominate. The stacked PR adds
skip: a skipped test running after a failed one would overwritefailwithskip, converting "something failed" into "something was not assessed" — which reads as benign and is not.Fold with an explicit severity ordering instead. One point worth a reviewer's opinion: the ordering
pass < skip < fail < errorranks error above fail, following the usual test-framework convention that could-not-run outranks failed. That is a judgment call, not a derivation.4.
vm_profile.py/guest_vsock.py— stop waiting for a guest whose QEMU has exitedQEMU liveness is checked exactly once, at
wait_ready_seconds.wait_for_guestthen polls vsock untilvsock_boot_timeout(180s) holding no reference to the process. If QEMU exits after that single check, the harness spends the remaining timeout pinging a VM that no longer exists.Measured on an EPYC 9654, sampling the process at 1 Hz during a launch the firmware rejects:
QEMU lived 2.6s; the step took 182.1s. Reproduced identically under two BIOS versions.
The diagnostics matter more than the 179 seconds. Firmware had already reported the cause —
SNP_LAUNCH_FINISH ret=-5 fw_error=11 'Bad measurement'— and the harness discarded it in favour ofVsock agent on CID ... not ready after 180.0s. A step assertingexit_code:1is satisfied by that timeout just as well as by a real rejection, so such a test cannot fail for the right reason. The message now carries QEMU's exit code and stderr tail.processdefaults toNone, preserving behavior for any caller that does not pass it.Verification
Unit, local: the severity fold across six orderings; the liveness check with a fake process (dies at 2s → aborts at 2.0s; no handle → full timeout and the original message, i.e. backward compatible).
On hardware, this branch alone (EPYC 9654, Genoa) — the pre-existing
c3_0_0_0 attestation-testagainst four guest images, since the point of a fixes-only PR is that existing behavior still works:11/11 steps passing on each — real guest launch, report retrieval, KDS certificate and VCEK fetch, chain verification, signature/TCB verification, measurement comparison.
certified_level: 3.0.0-0.Not tested on hardware: level
3.0.0-1(snphost-config-commit), which issuesSNP_COMMIT. That was skipped deliberately on a machine mid-way through a firmware-stepping exercise rather than because of anything in this PR.Fixes 1, 2 and 4 are exercised end-to-end by the ID block test in the stacked PR: with all four in place a suite that took ~200s takes 22s, and the negative-launch steps assert real firmware rejections instead of timeouts.
Follow-up not included
/tmp/guest-error.logis a single fixed path clobbered by every launch; recovering the evidence in fix 4 required sampling it externally at 1 Hz. Writing it per-launch intoctx.artifact_dirwould make this diagnosable from artifacts alone.