PLAN-resize phase 8: host VMM subcommand¶
Prompt¶
Before responding to questions or discussion points in this
document, explore the instar codebase thoroughly. Read relevant
source files, understand existing patterns (VMM structure, guest
operation layout, shared crate conventions, call table ABI,
format parsing, test infrastructure), and ground your answers in
what the code actually does today. Do not speculate about the
codebase when you could read it instead. Where a question touches
on external concepts (KVM, virtio-block, qemu-img resize CLI
surface, posix_fallocate, fallocate FALLOC_FL_ZERO_RANGE), research
as needed to give a confident answer. Flag any uncertainty
explicitly rather than guessing.
This is a phase plan under PLAN-resize.md. Refer to that master
plan for overall context. Phases 1–7 shipped the planner crate
and the guest binary; phase 8 lands the host VMM subcommand that
actually launches the guest, applies the post-pass set_len
based on the result, and renders user-facing output.
Mission¶
Add instar resize to the host CLI. The subcommand:
-
Parses a clap surface that matches
qemu-img resize:instar resize [-f FMT] [--shrink] [--preallocation PREALLOC] [--object OBJDEF] [--image-opts] [-q] [--output {human,json}] FILENAME [+-]SIZE[bkKMGTPE]--objectand--image-optsare accepted by clap but reject at runtime with"not yet supported"(matches phase 1's master-plan posture). -
Parses
[+-]SIZEwith the standard byte / k / M / G / T / P / E suffix set. A leading+or-makes it relative; absent prefix means absolute. Relative sizes require probing the existing image'scurrent_virtual_size. -
Probes the target file's format via a small host-side helper that opens the file, reads sector 0, calls
detect_format_from_header, and runs the matching per-format parser to extractcurrent_virtual_size. The probed size is required for relative-SIZE resolution and is also written intoResizeConfig.current_virtual_sizeas the cross-check the guest validates against. -
Routes raw via a host-side shortcut:
file.set_len(new)+ (phase 9) the optional preallocation post-pass. No guest launch needed. Mirrorsrun_create_raw. -
For non-raw, opens the output file
O_RDWR, attaches it as the virtio-block output device, populatesResizeConfig, launches the resize guest binary, waits for theResizeResultMessageon the serial channel, post-passesfile.set_len(result.file_size_after)(the planner reports the exact post-resize EOF), then renders output. -
Rejects
--shrinksemantics consistently with the planner: if the guest returnsShrinkBelowAllocated, surface a qemu-compatible error message. -
Output: terse human line by default —
"Image resized."— matching qemu's success message;--output=jsonproduces the structured{action, file_size_before, file_size_after, resolved_new_virtual_size, target_format}form;-q/--quietsuppresses all non-error output.
Preallocation post-pass (falloc / full) lands in phase 9.
Phase 8 routes the --preallocation flag through
ResizeConfig.flags so the guest sees it; the host-side
finalisation is a phase-9 follow-up.
What the survey turned up¶
Commandsenum atsrc/vmm/src/main.rs:2252-2270—Commands::Create(CreateArgs)is the template. Phase 8 addsCommands::Resize(ResizeArgs).CreateArgsstruct atsrc/vmm/src/main.rs:2649-2749— has the--output/-qflag conventions resize will mirror.run_createdispatch atsrc/vmm/src/main.rs:6699-6712is the dispatch template: parse opts → validate → branch raw-vs-nonraw → render.run_create_rawatsrc/vmm/src/main.rs:7540-7567is the raw-shortcut template: openO_CREAT|O_TRUNC|O_RDWR,set_len, optional preallocation,sync_all. Resize opensO_RDWR(no CREAT, no TRUNC).run_create_nonrawatsrc/vmm/src/main.rs:6804-7135is the guest-launch template: load binaries, open output, populate config, run KVM loop, harvest result, post-pass.run_create_guestatsrc/vmm/src/main.rs:7167-7504does the actual KVM setup + run loop. Decodes theCreateResultmessage from the serial port. Resize will introduce a parallelrun_resize_guestdecoding theResizeResultMessage.parse_memory_sizeatsrc/vmm/src/main.rs:344-361parses K/M/G/T suffixes without the b/P/E. Phase 8 extends it (or adds a sibling) for the full qemu-img suffix set plus the[+-]prefix.get_binary_path/load_guest_binaryatsrc/vmm/src/main.rs:1570,8051-8057load.binfiles from disk at runtime (noinclude_bytes!); resize loadsresize.binthe same way.render_create_success/render_create_success_jsonatsrc/vmm/src/main.rs:7743-7800are the rendering template (human and json branches; respectargs.quiet).validate_create_argsatsrc/vmm/src/main.rs:7819-7940+is the host-side pre-flight check template. Resize's pre-flight is much smaller —--object/--image-optsrejection, SIZE parseability, file accessibility.ResizeConfig/ResizeResultatsrc/shared/src/lib.rs:2340-2502(shipped in phase 1b), populated by phase 7a'ssend_resize_resultplumbing.
Algorithmic design¶
Clap surface¶
// src/vmm/src/main.rs
#[derive(Args, Debug)]
struct ResizeArgs {
/// FILENAME to resize.
filename: String,
/// [+-]SIZE[bkKMGTPE]. Leading `+` adds to current size;
/// `-` subtracts; absent prefix means absolute.
size: String,
/// Force the image format detection.
#[arg(short = 'f', long)]
format: Option<String>,
/// Allow shrink. Required when new < current.
#[arg(long)]
shrink: bool,
/// Preallocation mode for newly-added regions.
#[arg(long, default_value = "off",
value_parser = ["off", "metadata", "falloc", "full"])]
preallocation: String,
/// QEMU user-creatable object (e.g. encryption key).
/// Not yet supported.
#[arg(long)]
object: Option<String>,
/// Indicates FILENAME is a complete image specification.
/// Not yet supported.
#[arg(long)]
image_opts: bool,
/// Suppress the "Image resized." line on success.
#[arg(short = 'q', long)]
quiet: bool,
/// Output format.
#[arg(long, default_value = "human", value_parser = ["human", "json"])]
output: String,
}
Added to the Commands enum:
[+-]SIZE parsing¶
enum ParsedSize {
Absolute(u64),
Add(u64),
Subtract(u64),
}
fn parse_resize_size(s: &str) -> Result<ParsedSize, String> {
let (kind_fn, body): (fn(u64) -> ParsedSize, &str) =
if let Some(rest) = s.strip_prefix('+') {
(ParsedSize::Add, rest)
} else if let Some(rest) = s.strip_prefix('-') {
(ParsedSize::Subtract, rest)
} else {
(ParsedSize::Absolute, s)
};
Ok(kind_fn(parse_qemu_img_size(body)?))
}
/// Like `parse_memory_size` but supports the full qemu-img
/// suffix set: b=512, k/K=KiB, M/G/T/P/E (binary).
fn parse_qemu_img_size(s: &str) -> Result<u64, String> { ... }
Format probing¶
struct ProbedImage {
format: ImageFormat,
current_virtual_size: u64,
}
fn probe_current_size(path: &Path, forced_format: Option<&str>)
-> Result<ProbedImage, ...>
{
let mut file = OpenOptions::new().read(true).open(path)?;
let mut header = [0u8; MAX_SECTOR_SIZE];
file.read_exact(&mut header[..512])?;
let format = match forced_format {
Some("raw") => ImageFormat::Raw,
Some("qcow2") => ImageFormat::Qcow2,
Some("vmdk") => ImageFormat::Vmdk4,
Some("vpc") => ImageFormat::Vhd,
Some("vhdx") => ImageFormat::Vhdx,
Some(other) => return Err(format!("unknown format: {other}")),
None => detect_format_from_header(&header, 512, false),
};
let size = match format {
ImageFormat::Raw => file.metadata()?.len(),
ImageFormat::Qcow2 =>
qcow2::QcowHeader::parse(&header).ok_or("invalid qcow2 header")?.virtual_size,
ImageFormat::Vmdk4 =>
vmdk::Vmdk4Header::parse(&header).ok_or("invalid vmdk header")?.virtual_size,
ImageFormat::Vhd => {
// VHD footer at end of file.
let len = file.metadata()?.len();
if len < 512 { return Err("file too small for vhd"); }
file.seek(SeekFrom::Start(len - 512))?;
let mut footer = [0u8; 512];
file.read_exact(&mut footer)?;
vhd::VhdFooter::parse(&footer).ok_or("invalid vhd footer")?.current_size
}
ImageFormat::Vhdx => probe_vhdx_virtual_size(&mut file)?,
_ => return Err("unsupported format"),
};
Ok(ProbedImage { format, current_virtual_size: size })
}
Raw shortcut¶
fn run_resize_raw(args: &ResizeArgs, new_virtual_size: u64,
current_virtual_size: u64)
-> Result<(), Box<dyn Error>>
{
let mut file = OpenOptions::new().read(true).write(true).open(&args.filename)?;
file.set_len(new_virtual_size)?;
// Phase 9 will layer falloc/full here.
file.sync_all()?;
let action = if new_virtual_size > current_virtual_size {
"grow"
} else if new_virtual_size < current_virtual_size {
"shrink"
} else {
"noop"
};
render_resize_success(args, ImageFormat::Raw, current_virtual_size,
new_virtual_size, new_virtual_size, action);
Ok(())
}
Non-raw guest launch¶
fn run_resize_nonraw(args: &ResizeArgs, probed: &ProbedImage,
new_virtual_size: u64)
-> Result<(), Box<dyn Error>>
{
let core_code = load_guest_binary(get_binary_path("core.bin"))?;
let resize_code = load_guest_binary(get_binary_path("resize.bin"))?;
// Open output O_RDWR.
let output = BackingStore::open(&args.filename, false, None, true)?;
let output_capacity = output.size_in_bytes();
// Build ResizeConfig.
let mut flags: u32 = 0;
if args.shrink { flags |= ResizeConfig::FLAG_SHRINK; }
if args.quiet { flags |= ResizeConfig::FLAG_QUIET; }
flags |= encode_preallocation(&args.preallocation);
if probed.format == ImageFormat::Qcow2 {
let header = qcow2::QcowHeader::parse(...)?;
if header.extended_l2 { flags |= ResizeConfig::FLAG_EXTENDED_L2; }
}
let config = ResizeConfig {
magic: ResizeConfig::MAGIC,
target_format: probed.format as u32,
flags,
sector_size: 512,
current_virtual_size: probed.current_virtual_size,
new_virtual_size,
qcow2_cluster_size: 0, // guest re-reads from existing header
qcow2_refcount_bits: 0,
vmdk_subformat: 0,
vhd_subformat: 0,
_pad: 0,
vmdk_grain_size: 0,
block_size: 0,
_reserved: [0; 64],
};
// Launch guest; wait for ResizeResult.
let result = run_resize_guest(&core_code, &resize_code, output, &config)?;
if result.error != ResizeResult::ERROR_OK {
return Err(map_resize_error(result.error).into());
}
// Post-pass: trim/extend the file to the planner-reported
// exact size. set_len works in both directions.
let file = OpenOptions::new().write(true).open(&args.filename)?;
file.set_len(result.file_size_after)?;
file.sync_all()?;
render_resize_success(args, probed.format, probed.current_virtual_size,
new_virtual_size, result.file_size_after,
result.action_str());
Ok(())
}
run_resize_guest¶
Mirror run_create_guest byte-for-byte except:
- One device (the output) instead of two — no input device
needed because the guest reads via read_output_sector.
- Decode ResizeResultMessage instead of CreateResultMessage
in the run loop.
- The ResizeRunResult carries action, file_size_before,
file_size_after, resolved_new_virtual_size, error.
Output rendering¶
fn render_resize_success(args: &ResizeArgs, format: ImageFormat,
old_size: u64, new_virtual_size: u64,
new_file_size: u64, action: &str) {
if args.quiet { return; }
if args.output == "json" {
// {"filename":"...","format":"qcow2","action":"grow",
// "old_virtual_size":...,"new_virtual_size":...,
// "old_file_size":...,"new_file_size":...}
let s = format!("{{
\"filename\": \"{}\",
\"format\": \"{}\",
\"action\": \"{}\",
\"old_virtual_size\": {},
\"new_virtual_size\": {},
\"new_file_size\": {}
}}",
json_escape_string(&args.filename),
format.name(),
action,
old_size, new_virtual_size, new_file_size);
println!("{s}");
} else {
// Match qemu's terse line exactly so existing scripts
// that grep for it keep working.
println!("Image resized.");
}
}
Error mapping¶
fn map_resize_error(e: u32) -> String {
match e {
ResizeResult::ERROR_OK => "ok".into(),
ResizeResult::ERROR_INVALID_OPTION => "invalid resize option".into(),
ResizeResult::ERROR_INVALID_NEW_SIZE => "invalid new size".into(),
ResizeResult::ERROR_SHRINK_WITHOUT_FLAG =>
"shrinking requires --shrink (would discard data above the new size)".into(),
ResizeResult::ERROR_SHRINK_BELOW_ALLOCATED =>
"shrink rejected: allocated data lives above the new size".into(),
ResizeResult::ERROR_UNSUPPORTED_FORMAT => "format does not support resize".into(),
ResizeResult::ERROR_UNSUPPORTED_SUBFORMAT => "subformat does not support resize".into(),
ResizeResult::ERROR_UNSUPPORTED_SHRINK =>
"shrink not yet supported for this format".into(),
ResizeResult::ERROR_PREALLOCATION_UNSUPPORTED =>
"preallocation mode not supported by this format".into(),
ResizeResult::ERROR_SCRATCH_TOO_SMALL => "image too large for the resize scratch buffer".into(),
ResizeResult::ERROR_READ_FAILED => "I/O error reading the image".into(),
ResizeResult::ERROR_WRITE_FAILED => "I/O error writing the image".into(),
ResizeResult::ERROR_PARSE_FAILED => "the image header could not be parsed".into(),
ResizeResult::ERROR_HEADER_MISMATCH =>
"the image's current virtual size changed between host and guest read; \
retry the operation".into(),
_ => format!("unknown error code {e}"),
}
}
Top-level dispatch¶
fn run_resize(args: &ResizeArgs) -> Result<(), Box<dyn Error>> {
if args.object.is_some() {
return Err("--object is not yet supported".into());
}
if args.image_opts {
return Err("--image-opts is not yet supported".into());
}
let probed = probe_current_size(args.filename.as_ref(),
args.format.as_deref())?;
let parsed_size = parse_resize_size(&args.size)?;
let new_virtual_size = match parsed_size {
ParsedSize::Absolute(v) => v,
ParsedSize::Add(d) => probed.current_virtual_size.checked_add(d)
.ok_or("size overflow")?,
ParsedSize::Subtract(d) => probed.current_virtual_size.checked_sub(d)
.ok_or("size underflow")?,
};
if probed.format == ImageFormat::Raw {
run_resize_raw(args, new_virtual_size, probed.current_virtual_size)
} else {
run_resize_nonraw(args, &probed, new_virtual_size)
}
}
In the top-level command dispatch:
Test surface¶
Unit tests live alongside the new helpers in src/vmm/src/main.rs:
parse_resize_sizeaccepts absolute, additive, subtractive forms with every suffix.parse_resize_sizerejects empty, negative-absolute, unknown suffix.parse_qemu_img_sizecovers each suffix's exact multiplier.
End-to-end integration tests land in phase 11. Phase 8 can
optionally include a smoke test that builds + launches +
verifies a raw resize works, but it requires KVM access (root
or kvm group) and is brittle in CI; defer to phase 11's
tests/test_resize.py.
Open questions¶
-
Forced format vs auto-detect.
-f rawon a non-raw file means "treat as raw" (qemu's semantics). qemu allows this because raw is structureless. We match: if the user passes-f raw, the host uses raw semantics regardless of the file's actual magic bytes. Recommendation: yes, match qemu. -
What if
current_virtual_size == new_virtual_size? The planner returnsResizeAction::NoOpwith zero patches; the host emits"Image resized."anyway (qemu does too — no error, no diagnostic about it being a no-op). -
Behaviour when the file doesn't exist. qemu errors with "could not open ...". Match:
OpenOptions::openreturns a not-found error and we surface it directly. -
current_file_sizecross-check. The host'sBackingStore::size_in_bytes()reports the file size before any guest activity. The guest'scurrent_file_sizefield in the ResizeResult should match. Phase 8 doesn't enforce this strictly because the value is informational (the planner uses it internally). The phase 11 integration tests verify. -
Image-opts URI parser.
--image-optslets qemu acceptfile=...,driver=...,backing.driver=...style image specifications. We don't support this; rejecting with a clear error is the correct posture for v1. -
The probe-before-resize race. Between
probe_current_sizeandrun_resize_nonraw, another process could resize the file. The guest'scurrent_virtual_sizecross-check (in the ResizeConfig vs the parsed header) catches this and returnsHeaderMismatch. The host then maps that to a "retry the operation" message. Acceptable for v1. -
Preallocation post-pass deferral. Phase 8 routes
--preallocationintoResizeConfig.flagsso the guest sees it. The qcow2 metadata mode is already wired in the planner (rejected for now; lands in a follow-up). The host-sidefalloc/fullfinalisation for raw and for non-raw appends lands in phase 9. Recommendation: phase 8's raw shortcut does NOT honourfalloc/full; the host returns a clear "phase 9 will implement this" error if the user passes them. This keeps phase 8 focused. -
Concurrent file access. The host opens the file
O_RDWRand attaches as a virtio device. The guest writes via the call table. We do not take any flock; matches qemu's posture (the user is responsible for not running concurrent resize ops on the same file).
Execution¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 8a | medium | sonnet | none | Add Commands::Resize(ResizeArgs) to the Commands enum in src/vmm/src/main.rs and define the ResizeArgs struct with the clap surface documented in the "Clap surface" section above. Add a parse_qemu_img_size helper (extends parse_memory_size with b/P/E suffixes and case-insensitive K) and a parse_resize_size helper that returns ParsedSize::{Absolute, Add, Subtract}. Add a ParsedSize enum. Wire up the top-level dispatch in the main command match to call a stub run_resize that returns "phase 8b lands the real implementation". Add unit tests for parse_qemu_img_size (covers every suffix at exact multiplier) and parse_resize_size (covers absolute / additive / subtractive prefixes plus the empty / unknown-suffix rejection paths). make instar, make lint, make test-rust, pre-commit run --all-files clean. |
| 8b | high | opus | worktree | Implement the full run_resize in src/vmm/src/main.rs. (1) probe_current_size opens the file read-only, reads sector 0, runs detect_format_from_header (or honours -f), parses the matching format's header to extract current_virtual_size. For raw the size is the file length; for vhd the footer at file end; for vhdx use vhdx::VhdxState::init via a tiny in-process callback (or a simpler ad-hoc parse — copy the parser pattern from run_create_nonraw's read_backing_virtual_size). (2) Resolve [+-]SIZE against current_virtual_size. (3) For raw: run_resize_raw does OpenOptions::new().read(true).write(true).open() then set_len(new) + sync_all(). Reject --preallocation other than off for raw with "phase 9 will implement falloc/full post-pass" (open question 7). (4) For non-raw: run_resize_nonraw loads core.bin + resize.bin, opens the output via BackingStore::open(_, false, None, true), builds ResizeConfig, calls a new run_resize_guest modelled on run_create_guest (one output device, no input device, decode ResizeResultMessage in the serial loop). After the guest returns, post-pass set_len(result.file_size_after) + sync_all. (5) Map ResizeResult::ERROR_* to user-facing strings via a map_resize_error helper. (6) Reject --object / --image-opts with a "not yet supported" error before any I/O. The run_resize_guest function is the largest piece — model it on run_create_guest (around line 7167) but skip the input-device setup entirely. Risky: worktree isolation. |
| 8c | medium | sonnet | none | Output rendering and the global cross-checks. Add render_resize_success (human prints "Image resized."; json emits the structured form with filename / format / action / old_virtual_size / new_virtual_size / new_file_size). Add render_resize_error (human prints the mapped string to stderr; json prints {"error":"..."}). Honour --quiet. Verify the JSON form parses cleanly with serde_json::from_str in a unit test. Add a --help smoke test that confirms instar resize --help prints the expected synopsis (clap auto-generates; just assert the resulting Command builds and validates without panicking). make lint, make test-rust, pre-commit run --all-files clean. |
Out of scope for phase 8¶
- Preallocation
falloc/fullpost-pass (phase 9). Preallocation::Metadatafor qcow2 (deferred — planner rejects).- Cross-version baselines (phase 10).
- End-to-end integration tests against real images (phase 11).
- Differential fuzzing (phase 12).
- Documentation, CHANGELOG, follow-ups (phase 13).
Success criteria for phase 8¶
cargo build -p instarclean.make instarbuilds withCommands::Resizewired in.make lint,pre-commit run --all-filesclean.make test-rustpasses; new unit tests for size parsing succeed.instar resize --helpprints a sensible synopsis.- The clap surface accepts the documented flag set; clap rejects malformed invocations cleanly.
- Format probing succeeds for raw / qcow2 / vmdk / vhd / vhdx files (verified by phase 11 — phase 8's success here is just "the code compiles and the dispatch is wired").
Sub-agent guidance¶
Read these files before starting any step:
src/vmm/src/main.rs:2244-2270(Commands enum + Cli).src/vmm/src/main.rs:2559-2749(MeasureArgs + CreateArgs).src/vmm/src/main.rs:344-361(parse_memory_size).src/vmm/src/main.rs:6699-7135(run_create dispatch + run_create_raw + run_create_nonraw).src/vmm/src/main.rs:7167-7504(run_create_guest).src/vmm/src/main.rs:7743-7800(render_create_success).src/shared/src/lib.rs:2335-2502(ResizeConfig + ResizeResult).src/operations/resize/src/main.rs(the guest binary phase 7 shipped; phase 8's host wiring is the other half of that contract).
The management session review checklist is the same as prior phases.