Phase 4: host VMM subcommand and output formatting¶
Master plan: PLAN-measure.md · Previous phase: PLAN-measure-phase-03-guest-op.md
Status: Not started¶
Mission¶
Wire the Commands::Measure clap variant, the run_measure()
host function, and the output formatters (human + JSON) into
src/vmm/src/main.rs. After phase 4 the user can run:
instar measure [--size SIZE | FILENAME] -O <fmt> [--output {human,json}]
[--cluster-size N] [--refcount-bits N] [--extended-l2]
[--subformat S] [--grain-size N] [--block-size N]
and get qemu-img-byte-identical output (where qemu-img also
supports the target). Phase 4 is single-source-device only;
backing-chain composition and -o key=value,... parsing stay
out of scope (the -o shorthand is phase 5; chain is master-plan
follow-up).
Phase 4 also adds a tight smoke-test suite in tests/test_measure.py
that catches obvious regressions. The full cross-version
baseline-pinned tests live in phase 7 once phase 6 has generated
the baselines.
Why this is its own phase¶
Phase 3 finished the boundary plumbing but left no way for a
user to invoke it. Phase 4 is purely host-side glue: clap args,
config serialization, output formatting, and CLI dispatch. None
of phase 4's code runs inside the guest, and none of it changes
the call-table ABI. This is the right granularity to ship before
the heavier phase 5 (-o parsing), phase 6 (baselines), and
phase 7 (integration tests) build on top of it.
The phase is split into 5 steps so each commit is small and
self-contained: shadow constants + arg surface + stub dispatch,
then printers, then the heavy run_measure body, then Python
smoke tests, then docs.
Architecture¶
Shadow magic/flag constants¶
The existing src/vmm/src/main.rs shadows every *Config /
*Result magic and flag as plain const declarations at the top
of the file so the host can write them into guest memory without
pulling in the no_std crate's traits. Phase 4 adds the same for
measure (matching the values introduced in phase 3a):
const MEASURE_CONFIG_MAGIC: u32 = 0x4D454153; // "MEAS"
const MEASURE_CONFIG_FLAG_EXTENDED_L2: u32 = 1 << 0;
const MEASURE_CONFIG_FLAG_LAZY_REFCOUNTS: u32 = 1 << 1;
const MEASURE_CONFIG_FLAG_COMPAT_V3: u32 = 1 << 2;
const MEASURE_CONFIG_FLAG_COMPRESS: u32 = 1 << 3;
const MEASURE_CONFIG_PREALLOC_MASK: u32 = 0b11 << 4;
const MEASURE_CONFIG_PREALLOC_OFF: u32 = 0 << 4;
const MEASURE_CONFIG_PREALLOC_METADATA: u32 = 1 << 4;
const MEASURE_CONFIG_PREALLOC_FALLOC: u32 = 2 << 4;
const MEASURE_CONFIG_PREALLOC_FULL: u32 = 3 << 4;
const MEASURE_RESULT_MAGIC: u32 = 0x4D524553; // "MRES"
const MEASURE_RESULT_ERROR_OK: u32 = 0;
const MEASURE_RESULT_ERROR_OVERFLOW: u32 = 1;
const MEASURE_RESULT_ERROR_INVALID_OPTION: u32 = 2;
const MEASURE_RESULT_ERROR_INVALID_SIZE: u32 = 3;
Place these next to the existing CONVERT_CONFIG_* constants
(around line 105 in main.rs as of phase 3's last commit).
CLI surface¶
#[derive(Args, Debug)]
struct MeasureArgs {
/// Source image file. Mutually exclusive with --size.
#[arg(conflicts_with = "size")]
input: Option<String>,
/// Compute the measure for a hypothetical empty image of this size.
/// Mutually exclusive with FILENAME.
/// Accepts suffixes K, M, G, T (parsed by parse_memory_size).
#[arg(long, short = 's', value_name = "SIZE", conflicts_with = "input")]
size: Option<String>,
/// Target output format. Supported: raw, qcow2, vmdk, vpc (VHD), vhdx.
/// Default: raw (matching qemu-img).
#[arg(short = 'O', long = "target-format", default_value = "raw")]
target_format: String,
/// Source format override (rare; usually auto-detected).
/// Accepted for parity with qemu-img -f.
#[arg(short = 'f', long = "format")]
source_format: Option<String>,
/// Output format: human (default) or json.
#[arg(long, default_value = "human", value_parser = ["human", "json"])]
output: String,
/// Sector size for source I/O. Default: 65536.
#[arg(long, default_value = "65536")]
sector_size: u32,
// --- per-target qcow2 options ---
/// qcow2 cluster size in bytes. Power of two in [512, 2 MiB].
/// Default (when -O qcow2): 65536.
#[arg(long, default_value = "0")]
cluster_size: u32,
/// qcow2 refcount entry width in bits. Must be in {1,2,4,8,16,32,64}.
/// Default (when -O qcow2): 16.
#[arg(long, default_value = "0")]
refcount_bits: u8,
/// qcow2 extended L2 entries (16-byte with subcluster bitmaps).
#[arg(long)]
extended_l2: bool,
/// qcow2 lazy refcounts. Accepted but does not affect required size.
#[arg(long)]
lazy_refcounts: bool,
/// qcow2 compat level: "0.10" (v2) or "1.1" (v3, default).
#[arg(long, default_value = "1.1", value_parser = ["0.10", "1.1"])]
compat: String,
/// qcow2 compression flag (does not change required; accepted for parity).
#[arg(long)]
compress: bool,
/// qcow2 preallocation mode.
#[arg(long, default_value = "off",
value_parser = ["off", "metadata", "falloc", "full"])]
preallocation: String,
// --- per-target vmdk options ---
/// vmdk subformat. Default (when -O vmdk): monolithicSparse.
#[arg(long, default_value = "",
value_parser = ["", "monolithicSparse", "streamOptimized",
"monolithicFlat"])]
subformat: String,
/// vmdk grain size in bytes. Power of two in [4 KiB, 64 KiB].
/// Default (when -O vmdk): 65536.
#[arg(long, default_value = "0")]
grain_size: u32,
// --- per-target vhd / vhdx options ---
/// vhd / vhdx block size in bytes. Power of two; vhd: [512 KiB, 2 GiB],
/// vhdx: [1 MiB, 256 MiB]. Default (when -O vpc): 2 MiB; default (when -O vhdx): 32 MiB.
#[arg(long, default_value = "0")]
block_size: u32,
}
Notes on the arg shape:
- FILENAME is positional and optional because --size provides
an alternative path. Clap's conflicts_with enforces mutual
exclusion. Exactly one of the two must be present; validate at
the top of run_measure.
- -O target-format accepts raw, qcow2, vmdk, vpc,
vhdx. vpc is qemu-img's name for VHD; map to
ImageFormat::Vhd for the wire encoding.
- -f source-format is accepted but only validated against the
parser's detection result if both are present and disagree —
the simple route is to ignore -f entirely in phase 4 and add
a warning in phase 5 / 10. Document the limitation.
- All per-target --<option> flags default to 0 / "" to
mean "use the format default". The guest binary substitutes
defaults from Qcow2Opts::default() etc. (phase 1's
Default impls).
- No -o key=value,... parser in phase 4. Each option is its
own clap flag. Phase 5 will add -o on top: it parses the
comma-separated string and sets the same fields.
Output formatting¶
qemu-img-byte-identical:
Human (--output human):
JSON (--output json):
qemu-img's --output=json). Key
fully-allocated is hyphenated even though JSON allows
underscores — match qemu-img exactly. Trailing newline.
For target formats that qemu-img doesn't support (vmdk, vpc,
vhdx), instar produces the same format anyway — these are
instar-only outputs (documented in docs/quirks.md).
For the error != 0 case, the host emits a clear error to
stderr and sets the exit code to 1. Error messages:
- ERROR_OVERFLOW: "measure: overflow computing target size"
- ERROR_INVALID_OPTION: "measure: invalid option for target format"
- ERROR_INVALID_SIZE: "measure: source image is unsupported format"
run_measure algorithm¶
Mirroring run_check / run_info structure:
- Validate args:
- Exactly one of
--sizeorFILENAMEis set. sector_sizeis power of two in [512, MAX_SECTOR_SIZE].target_formatmaps to a supportedImageFormat(raw, qcow2, vmdk, vhd, vhdx).-
Format-specific option validation (cluster_size power of two, etc.) is best left to the guest, but obvious local-validatable errors (e.g.
--cluster-size 1000not power of two,--refcount-bits 7) get an early host-side check so the user gets feedback before VMM startup. -
Resolve target format and options:
target_formatstring → numericImageFormat.- Translate
--compat 0.10|1.1→FLAG_COMPAT_V3bit. - Translate
--preallocation off|metadata|falloc|full→PREALLOC_*bits. - Translate
--subformat monolithicSparse|streamOptimized| monolithicFlat→vmdk_subformat: u8. -
Translate (currently absent) VHD subformat default to 0 (Dynamic); a
--subformat fixedflag could be added later but is not in scope for phase 4 (qemu-img doesn't support vhd measure anyway). -
Reject VMDK monolithicFlat sources: same logic the convert path uses (
peek_is_vmdk_descriptor). monolithicFlat sources require multi-device chain setup, which phase 4 doesn't support. Surface a clear error: "measure: monolithicFlat source images are not yet supported (use convert -f / qemu-img instead)". -
Load binaries:
core.binandmeasure.binviaget_binary_path()andload_guest_binary()(existing helpers). -
Build MeasureConfig in a
[u8; sizeof::<MeasureConfig>()]buffer usingguest_mem.write_obj(...)at the right offsets (matching the field layout inshared::MeasureConfig). This is straight-line code mirroring howrun_checkwritesCheckConfigat lines 3430-3431 — but with more fields.
For --size mode: virtual_size_override = parse_memory_size(s).
For source-image mode: virtual_size_override = 0.
- Set up the source device:
-
--sizemode: no source device needed. Pass a stub read-only device backed by an empty file? Actually the guest binary handles the override path without ever callingread_input_sector. Still need some device 0 because the call table'sget_input_capacity(0)will be called during validation. Decision: open/dev/null(or a 1-byte tmpfile) as device 0. The guest checksvirtual_size_overridebefore touching the device.Alternative cleaner approach: set
device_count = 0in the device set and never construct the device. But the guest'scoreboot path may expect at least one device. Confirm during step 4c by readingsrc/core/src/main.rs; the safe answer (always present device 0 backed by a tiny tmpfile) avoids edge cases. -
Source-image mode: open the file as a
BackingStoreand wrap in aVirtioBlockDevice, single device only. No chain. -
Run the VM: existing
run_vmm()pattern. The main loop already decodesGuestMessagepayloads in the IoOut handler. Step 4c adds a new arm matchingSome(guest_::GuestMessage_::Payload::MeasureResult(_))that: - Stores the result for printing after the VM exits, or
- Prints immediately and tracks exit code.
The check operation uses the immediate-print pattern (line 3590-3609). Match that.
- Print result + return exit code:
- On
error == ERROR_OK: callprint_measure_result(msg, args.output)and returnOk(()). - On non-zero error: print stderr message, return
Err(...).
IoOut handler arm¶
The existing match in the main event loop has arms for
InfoResult, CheckResult, CompareResult. Step 4c adds:
Some(guest_::GuestMessage_::Payload::MeasureResult(m)) => {
print_measure_result(&msg, &args.output);
measure_error = m.error;
}
where measure_error is tracked outside the loop to influence
the final exit code. The pattern is the same as
check_passed (line 3595).
Smoke tests (tests/test_measure.py)¶
The full test suite ships in phase 7 with cross-version baselines from phase 6. Phase 4 ships ~6 narrow smoke tests that confirm the CLI is wired and outputs the right shape:
class TestMeasureSmoke(InstarTestBase):
def test_size_raw_human(self):
# instar measure --size 1M -O raw
# Expect "required size: 1048576\nfully allocated size: 1048576\n"
...
def test_size_raw_json(self):
# instar measure --size 1M -O raw --output json
# Expect {"required": 1048576, "fully-allocated": 1048576}
...
def test_size_qcow2_default(self):
# instar measure --size 1M -O qcow2
# Expect the values pinned in crates/measure phase 1 fixtures:
# required=327680, fully-allocated=1376256
...
def test_size_qcow2_with_cluster(self):
# instar measure --size 1M -O qcow2 --cluster-size 512
# Expect required=22528, fully-allocated=1071104
...
def test_source_image_qcow2(self):
# instar measure <some-qcow2-test-image> -O qcow2
# Expect numeric output (don't pin exact values — phase 7).
...
def test_conflicting_args_rejected(self):
# instar measure --size 1M somefile.qcow2 -O raw
# Expect non-zero exit, clear error
...
def test_invalid_cluster_size(self):
# instar measure --size 1M -O qcow2 --cluster-size 1000
# Expect non-zero exit
...
These run as part of make test-integration and make test.
Pinning --size 1M -O qcow2 against literal numbers couples
phase 4 to qemu-img 10.x output exactly — which is also what
phase 1's fixture table uses, so a regression there cascades
to phase 4. That coupling is intentional: a CLI break should
not silently pass.
Open questions¶
-
--size 0: qemu-img errors with "Image size must be non-negative" actually accepts size=0 and returnsrequired=0, fully-allocated=0. Recommendation: match the actual qemu-img behaviour (accept). Confirm with a test. -
-fsource-format override: phase 4 accepts the flag for parity but ignores it (parsers auto-detect). Should we warn if both-f vmdkand the detected format disagree, or silently ignore? Recommendation: silently ignore in phase 4; add a verbose-only warning in phase 5 if any user actually notices. qemu-img has the same flag for parity withinfo, but for measure it's also effectively unused. -
VHD subformat selection: qemu-img doesn't support VHD measure, so there's no compatibility constraint. Phase 4 defaults to Dynamic (matching convert's default) and does not expose a
fixedflag. If users want fixed-VHD measurement, add--subformat fixedin a follow-up. -
vpcvsvhdtarget name: qemu-img usesvpcexclusively; instarconvertacceptsvpcas the alias. Phase 4 acceptsvpcand maps toImageFormat::Vhd. No alternative spelling. -
Source-image mode with very large source (1 TiB+ qcow2): the scanner walks every L1 entry. Performance-wise the metadata is ~2 MB for a 1 TB image — handful of seconds. No special handling. If it becomes an issue, optimise the scan_allocation path later.
-
--sizemode with no device: confirmcore.bin's boot path toleratesdevice_count = 0. If not, fall back to opening a tiny tmpfile. Decide during step 4c. -
Format-specific options leaking across targets: e.g. the user passes
--cluster-size 64kwith-O vmdk. Clap accepts it (no rejection wired); the host could either silently ignore or error. Recommendation: silently ignore in phase 4 (the qcow2-only option just falls into the per-target dispatch and never gets used). Phase 5's-oparser may want to be stricter.
Execution¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 4a | medium | sonnet | none | Add the shadow magic/flag constants (MEASURE_CONFIG_MAGIC, MEASURE_CONFIG_FLAG_*, MEASURE_CONFIG_PREALLOC_*, MEASURE_RESULT_*) at the top of src/vmm/src/main.rs next to the existing per-op constants. Add MeasureArgs struct mirroring the schema above. Add Commands::Measure(MeasureArgs) to the enum Commands. Add the dispatch arm in main() that calls run_measure(args, verbose). Add a stub fn run_measure(args: MeasureArgs, verbose: bool) -> Result<(), Box<dyn std::error::Error>> that returns Err("measure: not yet implemented".into()). Run make lint, make test-rust, pre-commit run --all-files. Only src/vmm/src/main.rs modified. |
| 4b | medium | sonnet | none | Add fn print_measure_result(msg, output) and fn print_measure_result_json(result) to src/vmm/src/main.rs, mirroring the print_check_result / print_check_result_json pattern. JSON format MUST match qemu-img byte-for-byte: four-space indent, fully-allocated key (hyphen), no trailing comma. Human format: two lines, required size: N and fully allocated size: N. Both formatters take &guest_::GuestMessage to match the existing pattern. Run lint / test / pre-commit. Touch only src/vmm/src/main.rs. |
| 4c | high | opus | none | Implement the full run_measure body. Flow: validate args (exactly one of --size or FILENAME; sector_size power of two; target_format maps to a supported ImageFormat); resolve target format and options into a MeasureConfig; reject VMDK monolithicFlat sources with a clear error (use the existing peek_is_vmdk_descriptor helper); load core.bin + measure.bin; build the MeasureConfig and write it field-by-field at OPERATION_CONFIG_ADDR using guest_mem.write_obj (mirror the existing per-op patterns at run_check line 3430+ and run_convert for the more elaborate option writing); set up source device(s) — single source for FILENAME mode, a stub for --size mode (open /dev/null opened read-only is one option; the guest binary won't read it because virtual_size_override short-circuits the scan path, but the core boot path may still call get_input_capacity(0) so device 0 must exist); run the VM via the existing run_vmm-style event loop pattern (look at run_check around line 3580-3630 for the IoOut handler usage); add a new Some(guest_::GuestMessage_::Payload::MeasureResult(m)) arm to that IoOut match that calls print_measure_result(&msg, &args.output) and tracks measure_error = m.error; after the loop returns, if measure_error != MEASURE_RESULT_ERROR_OK emit a clear stderr message and return Err(...). High effort because: this function ties together everything from phases 1-3 on the host side. Multiple subtle interactions: clap arg validation, MeasureConfig field layout (must match shared::MeasureConfig byte-for-byte — refer to the field offsets), VM lifecycle, IoOut decoding. Read run_check carefully as the template; deviate only where measure's needs differ. |
| 4d | medium | sonnet | none | Add tests/test_measure.py with a TestMeasureSmoke(InstarTestBase) class containing ~7 tests: --size 1M -O raw human/json, --size 1M -O qcow2 default (assert required=327680, fully-allocated=1376256), --size 1M -O qcow2 --cluster-size 512 (assert required=22528, fully-allocated=1071104 — values from phase 1 fixture rows), source-image happy-path using one of the existing safe-tier qcow2 test images (don't pin exact numbers; just assert the JSON parses and required <= fully-allocated), conflicting --size 1M FILENAME exits non-zero, invalid --cluster-size 1000 exits non-zero. Use the existing InstarTestBase helpers (self.run_instar(...) etc.; if a helper for measure doesn't exist, follow the pattern from test_check_formats.py or test_compare.py). Run make test-integration if reachable, or document the command for the user to run. Touch only tests/test_measure.py (new file). |
| 4e | low | sonnet | none | Update ARCHITECTURE.md to mention the new measure subcommand (one paragraph mirroring how convert is described — CLI surface, output formats, divergences from qemu-img where qemu-img doesn't support a target). Add an ### Added entry to CHANGELOG.md Unreleased noting the new instar measure CLI surface (cite the phase plan). Cross-reference docs/quirks.md for the future "vmdk/vhd/vhdx measure is instar-only" note (do not yet write docs/measure.md — that ships in phase 10 once all options are stable). Run pre-commit run --all-files. Touch only ARCHITECTURE.md and CHANGELOG.md. |
Total: 5 commits.
Out of scope for phase 4¶
-o key=value,...parser (phase 5).--snapshot/-l SNAPSHOTsource-side snapshot extraction (master-plan future work).- Backing-chain composition (master-plan future work).
- VMDK monolithicFlat source support (rejected with clear error; multi-device infrastructure required).
- Cross-version baseline generation (phase 6).
- Cross-version-matrix integration tests (phase 7).
docs/measure.mduser guide (phase 10).docs/quirks.md"subcommands beyond qemu-img" section (phase 10 — the divergences are simple enough that phase 4's ARCHITECTURE.md mention covers it for now).-fsource-format override is accepted but does nothing (parser auto-detects; documented limitation).
Success criteria¶
instar measure --size 1M -O qcow2produces qemu-img-byte- identical output for the--output=jsoncase (matching the values phase 1 pinned in QCOW2_CASES).instar measure --size 1M -O qcow2and-O rawproduce the matching human-format output.instar measure <qcow2-image> -O qcow2runs end-to-end and produces a valid result (numeric output, exit code 0).- VMDK monolithicFlat source returns a clear error.
- Invalid options (cluster_size not a power of two, mutually
exclusive
--size+ FILENAME) return non-zero exit and a clear error. make instarbuilds;make lintclean;make test-rustpasses;make test-integrationincludestest_measure.pyand passes;pre-commit run --all-filesclean.make check-binary-sizesstill passes (no guest binary changes in phase 4).ARCHITECTURE.mdandCHANGELOG.mdupdated.
Risks and mitigations¶
MeasureConfigfield offsets disagree between host and guest. Phase 3 placed the struct inshared/src/lib.rsso both sides use the same layout, but the host writes viaguest_mem.write_objat hand-specified byte offsets — drift is possible. Mitigation: step 4c's brief should call out usingstd::mem::offset_of!(stable in recent Rust) or a small helper that takes aMeasureConfigvalue, serialises it withbytemuck::bytes_of()ifMeasureConfig: Pod, and writes the entire struct in onewrite_slicecall. Cleanest: build aMeasureConfigvalue on the host and write it whole. Use whichever approach the existing operations use (some write individual fields, some usewrite_objon the whole struct — checkrun_convert).--sizemode without a source device: if core's boot path requires device 0, the VM will fail in unfamiliar ways. Mitigation: step 4c's brief instructs to open a tiny tmpfile as a stub device 0 and verify with a--sizetest in 4d.- Output format byte-for-byte mismatch with qemu-img:
whitespace, newlines, key spelling. Mitigation: smoke tests
in 4d pin literal expected bytes for the JSON output. The
fixture table values come from phase 1, where they were
sourced from live
qemu-img. vpcvsvhdconfusion: users typing-O vhdinstead of-O vpcget a clap error. Mitigation: clap arg validation rejects unknown names early. If we decide to be lenient, accept both with an alias.
Back brief¶
Before executing any step, the executing agent should
back-brief: which existing operation is the closest template
(run_check is the simplest with chain-optional setup;
run_convert is the most elaborate for options writing), and
which files are being touched. The reviewer should confirm
no step bleeds into phase 5 (-o parsing), phase 6 (baseline
generation), or phase 7 (integration tests against real
testdata images).