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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
__pycache__/
*.pyc
*.egg-info/
dist/
build/

**/target/
/*/tools/
/*/image
Expand Down
46 changes: 46 additions & 0 deletions sev_verify/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# sev_verify

Host-side testing harness for SEV-SNP certification. Reads TOML manifests that declare which tests to run, imports per-test Python modules that define executable steps, and orchestrates execution across host and guest environments.

## Usage

```bash
# Run a specific certification level
python3 -m sev_verify /path/to/guest.efi -v 3.0

# Run multiple levels
python3 -m sev_verify /path/to/guest.efi -v 3.0 -v 3.1

# Run all certifications found in cert_tests/
python3 -m sev_verify /path/to/guest.efi
```

## How it works

1. Discover manifests at `cert_tests/*/manifest.toml`. Each manifest declares test entries (name, scope, module path).

2. For each test, import its Python module from the same `cert_tests/<level>/` directory and call `steps()` to get the ordered list of `Step` objects. Steps specify a shell command, where it runs (host or guest), what constitutes success, and a timeout.

3. Execute steps sequentially. Host steps run locally via subprocess. Guest steps are sent to the VM over a dedicated serial channel (`ttyS1`). For tests with `scope: guest` or `scope: mixed`, a QEMU SNP guest is launched before the first guest step and torn down after the last.

4. Write results to `results/`.
Comment on lines +20 to +26

## Layout

```
sev_verify/ Harness package
cli.py CLI arg parsing + entry point
models.py Step, TestDefinition, CertificationDefinition
cert_tests/ Certification levels
common/ Shared test modules
snphost_ok.py Test modules
...
cert_3_0/ Level 3.0
manifest.toml What to run
...
results/ Output (gitignored)
```

## Requirements

Python 3.11+ (uses `tomllib` from stdlib). No external packages.
1 change: 1 addition & 0 deletions sev_verify/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""sev-verify: SEV-SNP certification testing harness."""
7 changes: 7 additions & 0 deletions sev_verify/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""python3 -m sev_verify"""

import sys

from .cli import main

sys.exit(main())
Empty file.
Empty file.
2 changes: 2 additions & 0 deletions sev_verify/cert_tests/cert_3_0/manifest.toml
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you/we planning to only maintain one certificate definition per generation?

Or what is your idea for the certificate definition structure?

I was looking at your command-line arguments and noticed that we would run certificates per CPU generation. I do like that approach.

My original intent was to go much more granular, something like:

-v 3.0.0-0 -v 3.1.2-0 -v 3.0.1-2

But I feel like that would require users to type too much if they want to run more than one test.

We’ll keep it as you proposed, but I’m curious how you envision the full manifest evolving as we add new versions across the different certificate generations.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or I guess, now that I read a little more, I think I would need an example on what a full manifest would look like.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My original intent was to go much more granular, something like:
-v 3.0.0-0 -v 3.1.2-0 -v 3.0.1-2

This would definitely be useful, maybe even to a single test level. Let me work on adding handling for that. And yeah I'll flesh out some examples - I had the vague idea that each test would be its own python module, but didn't think about the sub-groupings of certificates. So good questions, I need to work with @ajcaldelas to get the certificate structure thought through & how we can populate everything.

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
version = "3.0"
description = "SEV 3.0 Tests (AMD EPYC 7003+) - Current Level 3.0.0-0"
Comment on lines +1 to +2
Empty file.
24 changes: 24 additions & 0 deletions sev_verify/cert_tests/common/snphost_ok.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""snphost-ok: Verify SNP is enabled and functional on the host."""

from sev_verify.models import Step


def steps() -> list[Step]:
return [
Step(
name="snphost-ok",
type="required",
runs_on="host",
command="snphost ok",
expected_result="exit_code:0",
timeout=30,
),
Step(
name="snphost-show-guests",
type="info",
runs_on="host",
command="snphost show guests",
expected_result="exit_code:0",
timeout=10,
),
]
128 changes: 128 additions & 0 deletions sev_verify/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""CLI arg parsing and entry point for sev_verify."""

from __future__ import annotations

import argparse
import sys
import tomllib
from pathlib import Path

from .models import CertificationDefinition, TestDefinition


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="sev_verify",
description="SEV-SNP certification testing harness",
)
parser.add_argument(
"path_to_guest",
help="Path to the guest image/UKI",
)
parser.add_argument(
"--version",
"-v",
dest="versions",
action="append",
default=[],
help="Certification version(s) to run (e.g. 3.0). Repeatable. "
"If omitted, all cert_tests/*/manifest.toml are used.",
)
return parser.parse_args(argv)


def load_manifest(toml_path: Path) -> CertificationDefinition:
"""Load and validate a TOML certification manifest."""
try:
with open(toml_path, "rb") as f:
data = tomllib.load(f)
except OSError as exc:
raise ValueError(f"Cannot read manifest {toml_path}: {exc}") from exc
except tomllib.TOMLDecodeError as exc:
raise ValueError(f"Malformed TOML in {toml_path}: {exc}") from exc

for key in ("version", "description"):
if key not in data:
raise ValueError(f"{toml_path}: missing required key {key!r}")

raw_tests = data.get("tests", [])
if not isinstance(raw_tests, list):
raise ValueError(f"{toml_path}: 'tests' must be an array of tables")

tests: list[TestDefinition] = []
for i, entry in enumerate(raw_tests):
if not isinstance(entry, dict):
raise ValueError(f"{toml_path}: tests[{i}] must be a table")
try:
tests.append(TestDefinition(**entry))
except TypeError as exc:
# unknown or missing fields in the [[tests]] table
raise ValueError(f"{toml_path}: tests[{i}] has invalid fields: {exc}") from exc
except ValueError as exc:
# failed __post_init__ validation (bad scope, empty name, ...)
raise ValueError(f"{toml_path}: tests[{i}]: {exc}") from exc

return CertificationDefinition(
version=str(data["version"]),
description=str(data["description"]),
tests=tests,
)



def discover_manifests(cert_dir: Path, versions: list[str]) -> list[Path]:
"""Find all manifest.toml files in cert_tests/ subdirectories."""
if not cert_dir.is_dir():
return []

if not versions:
return sorted(cert_dir.glob("*/manifest.toml"))

manifest_paths = []
for version in versions:
subfolder = "cert_" + version.replace(".", "_")
mpath = cert_dir / subfolder / "manifest.toml"
if not mpath.exists():
print(f"Error: no manifest for version {version!r} "
f"(expected {mpath})", file=sys.stderr)
continue
manifest_paths.append(mpath)

return manifest_paths


def print_certification(cert: CertificationDefinition) -> None:
"""Print certification header."""
header = f" Certification {cert.version} "
print(f"──{header}{'─' * (60 - len(header))}")
print(f" {cert.description}")


def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
guest_path = Path(args.path_to_guest)

if not guest_path.exists():
print(f"Error: guest path does not exist: {guest_path}", file=sys.stderr)
return 1

cert_dir = Path(__file__).resolve().parent / "cert_tests"

manifest_paths = discover_manifests(cert_dir, args.versions)

if not manifest_paths:
print(
"Error: no manifest.toml found in cert_tests/*/",
file=sys.stderr,
)
Comment on lines +114 to +117
return 1

print(f" Guest: {guest_path}")
print()

for manifest_path in manifest_paths:
cert = load_manifest(manifest_path)
print_certification(cert)
print()

return 0
118 changes: 118 additions & 0 deletions sev_verify/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Data model dataclasses for sev_verify."""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Literal, get_args


# Single source of truth for the enum values
StepType = Literal["setup", "required", "info"]
RunsOn = Literal["host", "guest"]
Scope = Literal["host", "guest", "mixed"]


@dataclass
class Step:
"""A single executable step within a test."""

name: str
type: StepType
runs_on: RunsOn
command: str
expected_result: str # e.g. "exit_code:0", "stdout_contains:PASS"
timeout: int = 60

def __post_init__(self) -> None:
if not self.name:
raise ValueError("Step.name must not be empty")
if not self.command:
raise ValueError(f"Step {self.name!r}: command must not be empty")
if self.type not in get_args(StepType):
raise ValueError(
f"Step {self.name!r}: invalid type {self.type!r}; "
f"expected one of {get_args(StepType)}"
)
if self.runs_on not in get_args(RunsOn):
raise ValueError(
f"Step {self.name!r}: invalid runs_on {self.runs_on!r}; "
f"expected one of {get_args(RunsOn)}"
)
if self.timeout <= 0:
raise ValueError(
f"Step {self.name!r}: timeout must be positive, got {self.timeout}"
)


@dataclass
class TestDefinition:
"""A test declared in the TOML manifest."""

name: str
module: str # dotted module path, e.g. "cert_tests.common.snphost_ok"
scope: Scope

def __post_init__(self) -> None:
if not self.name:
raise ValueError("TestDefinition.name must not be empty")
if not self.module:
raise ValueError(f"TestDefinition {self.name!r}: module must not be empty")
if self.scope not in get_args(Scope):
raise ValueError(
f"TestDefinition {self.name!r}: invalid scope {self.scope!r}; "
f"expected one of {get_args(Scope)}"
)

@property
def requires_vm(self) -> bool:
return self.scope in ("guest", "mixed")


@dataclass
class CertificationDefinition:
"""Top-level certification suite loaded from a TOML manifest."""

version: str
description: str
tests: list[TestDefinition] = field(default_factory=list)

def __post_init__(self) -> None:
if not self.version:
raise ValueError("CertificationDefinition.version must not be empty")


# ── Runtime result models (populated during execution) ──────────


@dataclass
class StepResult:
"""Result of executing a single Step."""

step: Step
result: Literal["pass", "fail", "error", "skip"]
exit_code: int | None = None
stdout: str | None = None
stderr: str | None = None
duration_ms: int | None = None


@dataclass
class TestResult:
"""Result of executing a TestDefinition."""

test: TestDefinition
result: Literal["pass", "fail", "error"]
step_results: list[StepResult] = field(default_factory=list)
started_at: str | None = None
completed_at: str | None = None


@dataclass
class CertificationResult:
"""Result of executing a CertificationDefinition."""

certification: CertificationDefinition
result: Literal["pass", "fail", "error"]
test_results: list[TestResult] = field(default_factory=list)
started_at: str | None = None
completed_at: str | None = None
Loading