Lab 3.3: Build It — A Custom API Action
You have traced the control plane twice (Labs 3.1, 3.2). Now you build a new piece of it. In this lab
you add a small, read-only, safe API endpoint to Firecracker end-to-end: the swagger entry, the
request parsing in the API server, a new VmmAction variant (or a reuse of an existing one), the
controller handling that produces the answer, and the VmmData response that becomes JSON on the
socket. You will build it, run it, and curl it.
This is the smallest possible real feature, and it exercises every concept of Level 3:
ParsedRequest, VmmAction, the API→VMM channel, the controller dispatch, VmmData, and the
serialization back to HTTP. It is read-only and adds no mutable state, no new device, and no
fast-path code, which is exactly why it's the right shape for a first API change. The point you will
internalize: this is how real features start — and adding API surface area, even a harmless GET,
comes with a runbook and back-compat expectations that you must respect from day one.
The endpoint you build (pick one; this lab uses the first):
GET /vmm-info -> {"vmm_version":"<v>", "vcpu_count":<n>, "mem_size_mib":<m>, "started":<bool>}
It reports a few fields drawn from VmResources / Vmm state — proving the action ran inside the
VMM thread and read live state, not something the API thread fabricated.
Background
A control operation in Firecracker is a VmmAction that the API thread sends to the VMM thread over
the mpsc channel; the active controller dispatches it and returns a Result<VmmData, VmmActionError>
(see the Level 3 overview and the
API server and action channel deep dive). To add
a read-only GET, you touch five places, in this order:
| Layer | File area | What you add |
|---|---|---|
| 1. Swagger | src/firecracker/swagger/firecracker.yaml | the new path + response schema (the API is the contract) |
| 2. Parser | src/firecracker/src/api_server/ | match (GET, "/vmm-info") → build a VmmAction |
| 3. Action | src/vmm/src/rpc_interface.rs | a new VmmAction variant (or reuse) + a VmmData variant |
| 4. Controller | src/vmm/src/rpc_interface.rs | the dispatch arm that reads state and returns VmmData |
| 5. State read | src/vmm/src/resources.rs / lib.rs | the getter(s) the arm calls |
Because your endpoint is read-only and needs live state (vCPU count, whether the guest started),
the natural home is a variant that the RuntimeApiController answers — and, for the pre-boot
fields, the PrebootApiController can answer the resource-only part too. You will decide which
controller(s) handle it. This is a deliberate design choice you make, exactly as a maintainer would.
Deep-dive companions: api-server-and-action-channel.md and the-event-manager.md (the loop that delivers your action).
Why This Lab Matters for Contributors
Every API feature Firecracker has — /balloon, /mmds, /snapshot, the newer /version — started
as exactly this five-layer change: a swagger entry, a parser, a VmmAction, a controller arm, a
VmmData. The maintainers will judge a real API PR on whether you followed the existing patterns
and respected the contract: the swagger must match the parser, the change must be backward
compatible, and a new field never breaks an old client. Doing a read-only version first teaches
the mechanics without the risk that a mutating endpoint carries (state validation, lifecycle rules,
snapshot compatibility). When you later propose a real feature, you will already know the runbook.
Prerequisites
- Firecracker builds:
tools/devtool build(Level 1). - You completed Lab 3.1 and Lab 3.2 — you know the channel and the threads.
- You can boot a microVM by hand (you'll curl the endpoint before and after boot).
- A branch to work on:
git checkout -b lab-3.3-vmm-info
Note: This lab gives you Rust in the shape of the existing code, but the exact field names, the
VmmDatavariants, and the parser's helper functions drift across branches (especially after the crate merge intovmm). Treat the snippets as templates. For each one, the lab first gives you thergthat finds the real pattern to copy, then shows the shape. Always copy the live pattern; never paste these verbatim and expect a clean build.
Step-by-Step Tasks
Step 1 (8 min) — Find the patterns you will copy
Before writing anything, locate the three existing things you will mirror: an existing read-only
GET (so you copy a working shape), the VmmAction and VmmData enums, and an existing
controller arm that returns data.
# (a) An existing read-only GET to copy — /version or / (instance info) or /machine-config GET:
rg -rn "GET|version|instance.info|InstanceInfo|machine-config" src/firecracker/src/api_server/ | rg -i "get|version|instance" | head
# (b) The action + data enums:
rg -n "enum VmmAction" src/vmm/src/rpc_interface.rs
rg -n "enum VmmData" src/vmm/src/rpc_interface.rs
rg -n "GetVmMachineConfig|GetVmInstanceInfo|GetFullVmConfig|GetMMDS" src/vmm/src/rpc_interface.rs | head
# (c) A controller arm that returns VmmData (copy this dispatch shape):
rg -n "VmmData::|=> Ok\(VmmData" src/vmm/src/rpc_interface.rs | head -30
# (d) The state you'll read:
rg -n "struct VmResources|fn vm_config|fn machine_config|vcpu_count|mem_size_mib" src/vmm/src/resources.rs | head
rg -n "struct InstanceInfo|app_name|version|state" src/vmm/src/ -g '!target' | head
Pick the closest existing GET as your model — GET /version and the GET /machine-config paths are
both good, because they're read-only and return a VmmData variant. Read both arms in
rpc_interface.rs end to end before you write a line.
Decide: which controller answers your endpoint? The cleanest choice for a read-only info endpoint
is to have both controllers handle it (the action is harmless pre- and post-boot), or to reuse an
existing Get* variant and just add a field. For this lab we add a new variant handled by both
controllers, which teaches the full machinery.
Step 2 (10 min) — Add the VmmAction and VmmData variants
In src/vmm/src/rpc_interface.rs, add a new action variant. Mirror the existing Get* variants
exactly (no payload — it's a pure query).
#![allow(unused)] fn main() { // In `enum VmmAction` — add alongside the other Get* variants. // Copy the surrounding doc-comment style from the variant above it. /// Get a summary of VMM + resource info. Read-only; safe pre- and post-boot. GetVmmInfo, }
Add the response payload to enum VmmData. Mirror an existing data variant such as
MachineConfiguration(MachineConfig).
#![allow(unused)] fn main() { // In `enum VmmData` — the success payload for GetVmmInfo. VmmInfo(VmmInfo), }
Define the small VmmInfo struct. Put it where the other response payloads live (or next to the
enum), and derive what the existing payloads derive — look first:
rg -n "derive\(.*Serialize|derive\(.*Debug" src/vmm/src/rpc_interface.rs | head
rg -n "struct MachineConfig\b" src/vmm/src/vmm_config/machine_config.rs
#![allow(unused)] fn main() { /// A read-only summary returned by `GET /vmm-info`. #[derive(Debug, Clone, serde::Serialize)] pub struct VmmInfo { /// Firecracker version (same string as `GET /version`). pub vmm_version: String, /// Configured number of vCPUs. pub vcpu_count: u8, /// Configured guest memory in MiB. pub mem_size_mib: usize, /// Whether the microVM has been started (InstanceStart issued). pub started: bool, } }
Warning: Adding a
VmmActionvariant is editing a public, versioned contract. The enum is part of how the API server and the VMM agree on operations; in some Firecracker versions adjacent enums participate in snapshot/version negotiation. A read-only Get variant is the safe end of this — it carries no state and is never serialized into a snapshot — but you must still add it at the end of the relevant lists where the codebase appends, not in the middle, and keepserdefield names stable. Confirm whetherVmmDatais#[serde(untagged)]/tagged on your branch (rg -n "serde" src/vmm/src/rpc_interface.rs) so your JSON shape matches the existing endpoints.
Step 3 (12 min) — Handle the action in both controllers
Find the dispatch match in each controller and add an arm. The preboot controller has the
resources; the runtime controller has both the resources and the live Vmm.
rg -n "impl PrebootApiController|impl RuntimeApiController|fn handle_preboot_request|fn handle_request|match request|match action" src/vmm/src/rpc_interface.rs | head
PrebootApiController — reads VmResources only; started is false (we're pre-boot):
#![allow(unused)] fn main() { // Inside PrebootApiController's dispatch match, alongside the other Get* arms. VmmAction::GetVmmInfo => { let cfg = self.vm_resources.vm_config(); // copy the real getter name from Step 1 Ok(VmmData::VmmInfo(VmmInfo { vmm_version: crate::FIRECRACKER_VERSION.to_string(), // copy the real version const/source vcpu_count: cfg.vcpu_count, mem_size_mib: cfg.mem_size_mib, started: false, })) } }
RuntimeApiController — reads from the live Vmm; started is true:
#![allow(unused)] fn main() { // Inside RuntimeApiController's dispatch match. VmmAction::GetVmmInfo => { let vmm = self.vmm.lock().expect("poisoned lock"); // copy the real lock/access pattern let cfg = vmm.machine_config(); // copy the real getter Ok(VmmData::VmmInfo(VmmInfo { vmm_version: vmm.version().to_string(), // copy the real accessor vcpu_count: cfg.vcpu_count, mem_size_mib: cfg.mem_size_mib, started: true, })) } }
Tip: Do not invent accessor names. For each
self.vm_resources.vm_config(),vmm.machine_config(),vmm.version(), and the version constant, run thergfrom Step 1 and use the exact name your branch has. TheMachineConfiggetter in particular has been renamed across versions; copy what the existingGET /machine-configarm calls.
If the Rust compiler complains that a match is non-exhaustive after you add the variant, that's the
codebase doing its job — both controllers must handle every VmmAction, or explicitly reject it. If
you decided your endpoint is runtime-only, the preboot arm should
Err(VmmActionError::OperationNotSupportedPreBoot) (copy the real error variant name) instead of
returning data.
Step 4 (10 min) — Parse the request in the API server
Add the (GET, "/vmm-info") route to the parser. Find the router and an existing GET parser to
mirror.
rg -rn "fn parse|RequestAction|ParsedRequest::new|GET =>|\"version\"|\"machine-config\"" src/firecracker/src/api_server/ | head -30
# The per-endpoint parser module for an existing GET:
ls src/firecracker/src/api_server/
rg -rn "VmmAction::GetMachineConfiguration|VmmAction::GetVmInstanceInfo|VmmAction::GetFullVmConfig" src/firecracker/src/api_server/ | head
In the router's GET branch, add a case for the new path that builds your action. Mirror the existing read-only GET exactly:
#![allow(unused)] fn main() { // In the API server request router, GET arm — mirror the /version or /machine-config case. "vmm-info" => Ok(ParsedRequest::new_sync(VmmAction::GetVmmInfo)), }
The helper that wraps a VmmAction into a ParsedRequest (here new_sync) has a real name on your
branch — copy it from the neighbouring GET case you just found. A read-only GET takes no body and no
path parameters, so this is the simplest possible parser arm.
Step 5 (8 min) — Add the swagger entry
The OpenAPI spec is the API contract. Add the path and its response schema. Find the spec and an existing GET to copy:
rg -n "^paths:|/version:|/machine-config:|get:|responses:" src/firecracker/swagger/firecracker.yaml | head -40
Add an entry mirroring /version (a simple read-only GET):
/vmm-info:
get:
summary: Returns a read-only summary of VMM and resource info.
operationId: describeVmmInfo
responses:
200:
description: VMM info returned successfully
content:
application/json:
schema:
$ref: "#/components/schemas/VmmInfo"
And add the schema under components: schemas: (copy the style of an existing schema like
MachineConfiguration):
VmmInfo:
type: object
properties:
vmm_version:
type: string
vcpu_count:
type: integer
mem_size_mib:
type: integer
started:
type: boolean
Note: Keep the swagger and the parser in lockstep. A path in the parser that isn't in the swagger (or vice versa) is a review-blocking inconsistency — it's a common "docs drift" issue you read about in issue-roadmap stage 1. Maintainers check this by hand and with tooling; treat the YAML as code.
Step 6 (10 min) — Build, run, curl
# Build (debug, fast):
tools/devtool build
ARCH=$(uname -m)
BIN=build/cargo_target/${ARCH}-unknown-linux-musl/debug/firecracker
API=/tmp/fc-3.3.socket
rm -f "$API"
sudo "$BIN" --api-sock "$API" &
sleep 0.3
# Configure (pre-boot), then hit your endpoint BEFORE starting:
curl -sS -X PUT --unix-socket "$API" \
--data '{"vcpu_count":2,"mem_size_mib":512}' http://localhost/machine-config
echo "== before InstanceStart =="
curl -sS --unix-socket "$API" http://localhost/vmm-info ; echo
Expected (pre-boot — handled by PrebootApiController, started: false):
{"vmm_version":"1.x.0","vcpu_count":2,"mem_size_mib":512,"started":false}
Now boot and hit it again (provide your kernel + rootfs as in Lab 3.2):
curl -sS -X PUT --unix-socket "$API" \
--data '{"kernel_image_path":"./vmlinux-6.1.x","boot_args":"console=ttyS0 reboot=k panic=1"}' \
http://localhost/boot-source
curl -sS -X PUT --unix-socket "$API" \
--data '{"drive_id":"rootfs","path_on_host":"./ubuntu-24.04.ext4","is_root_device":true,"is_read_only":false}' \
http://localhost/drives/rootfs
curl -sS -X PUT --unix-socket "$API" \
--data '{"action_type":"InstanceStart"}' http://localhost/actions
sleep 0.5
echo "== after InstanceStart =="
curl -sS --unix-socket "$API" http://localhost/vmm-info ; echo
Expected (post-boot — handled by RuntimeApiController, started: true):
{"vmm_version":"1.x.0","vcpu_count":2,"mem_size_mib":512,"started":true}
The started field flipping false → true across InstanceStart is your proof that both
controllers handled the action and that you read live state, not a constant baked at parse time.
sudo pkill -f "fc-3.3.socket" 2>/dev/null ; rm -f /tmp/fc-3.3.socket
Step 7 (8 min) — Make it pass the gates
A real PR must pass the style and build gates. Run them now.
tools/devtool fmt # cargo fmt + clippy --fix + cargo sort + formatters
tools/devtool checkstyle # the style gate
# Clippy is warnings-as-errors in this repo:
tools/devtool build --release 2>&1 | tail -20
Fix anything clippy flags. Common ones for a change like this: an unused import, a missing
doc-comment on the public VmmInfo, a match arm ordering lint. This is the difference between
"compiles" and "mergeable."
The API-Change Runbook and Back-Compat Expectations
Even a read-only GET is an API change, and Firecracker's contract is strict. Internalize this
runbook now; you will follow it for every real API PR.
| Rule | Why | How to satisfy it |
|---|---|---|
| Swagger ↔ parser must agree | The YAML is the published contract; clients generate from it | Add both in the same PR; never one without the other |
| Additive only / backward compatible | Old clients must keep working | New endpoints and new optional response fields are fine; never remove or rename a field, never change a field's type |
No new VmmAction mid-enum | Ordering can matter for serialization/version negotiation | Append where the codebase appends; keep serde names stable |
| Read-only ⇒ no snapshot impact | A Get carries no state into a snapshot | Confirm your variant isn't serialized into the microVM state file |
| Tests + CHANGELOG | Maintainers require integration tests for new functionality and a CHANGELOG note | Add a pytest that curls the endpoint; add an ### Added line to CHANGELOG.md |
| DCO sign-off | Every commit needs Signed-off-by (no CLA) | git commit -s; amend with --amend -s |
# Add a CHANGELOG entry (find the Unreleased/Added section):
rg -n "Unreleased|### Added" CHANGELOG.md | head
# Add an integration test — find an existing API test to mirror:
rg -rln "machine-config|/version|api_socket|def test_" tests/integration_tests/functional/ | head
A real version of this PR would add a tests/integration_tests/functional/test_api.py-style test
that boots a microVM, curls /vmm-info, and asserts the JSON — mirroring an existing API test. Write
one as a stretch goal below.
Implementation Requirements / Deliverables
-
A new
VmmActionvariant (or a documented reuse) and aVmmData::VmmInfo(VmmInfo)payload, added the way the existingGet*variants are. - Both controllers (or your chosen one, with the other explicitly rejecting) handle the action and read live state.
-
A parser arm for
(GET, "/vmm-info")that builds the action and contains no business logic. - A swagger entry (path + schema) that matches the parser and the response exactly.
-
The endpoint builds, runs, and
curlreturns the expected JSON both before and afterInstanceStart, withstartedflippingfalse → true. -
tools/devtool fmtandcheckstylepass; the release build is clippy-clean. - (Stretch / real-PR) an integration test and a CHANGELOG entry, with DCO-signed commits.
Troubleshooting
error[E0004]: non-exhaustive patterns: \GetVmmInfo` not covered`
You added the VmmAction variant but not a match arm in one of the controllers. That's the
compiler enforcing the contract: every VmmAction must be handled. Add the arm (return data, or
an explicit Err(VmmActionError::...) if that controller shouldn't answer it).
404 on GET /vmm-info
The parser arm isn't matching. Check the path string exactly ("vmm-info" vs "/vmm-info" — copy
how the neighbouring GET writes it) and confirm you added it in the GET branch of the router, not
PUT/PATCH. Re-run the rg from Step 4 to see how the existing /version case is keyed.
The JSON shape is wrong (extra wrapper / wrong field names)
Your VmmInfo serde derives or the VmmData tagging don't match the existing endpoints. Compare
your output against GET /machine-config's shape; check whether VmmData is tagged or untagged on
your branch (rg -n "serde" src/vmm/src/rpc_interface.rs) and match the existing variants' attributes.
started is always false (even after boot)
Your runtime-controller arm isn't being hit, or it's reading the wrong source. After InstanceStart,
the active controller is RuntimeApiController; confirm your arm is in that impl and that it reads
the live Vmm, not VmResources. Re-read Lab 3.1 Step 5 on which controller is active when.
clippy fails the build
Read the lint. A new public struct/variant without a doc-comment, an unused import from copying a
pattern, or a needless clone() are the usual suspects. tools/devtool fmt auto-fixes many;
the rest you fix by hand. Clippy is warnings-as-errors here — a warning is a failure.
Swagger validation fails in CI
Your YAML doesn't parse or the $ref doesn't resolve. Validate locally by mirroring an existing
schema exactly (indentation matters in YAML), and confirm the $ref path
(#/components/schemas/VmmInfo) matches where you put the schema.
Expected Output
== before InstanceStart ==
{"vmm_version":"1.x.0","vcpu_count":2,"mem_size_mib":512,"started":false}
== after InstanceStart ==
{"vmm_version":"1.x.0","vcpu_count":2,"mem_size_mib":512,"started":true}
$ tools/devtool checkstyle
... OK
Stretch Goals
- Reuse instead of add. Redo the lab by extending an existing endpoint — add a single new
optional field to the
GET /machine-configorGET /versionresponse instead of a new path. This is the more common real-world shape and forces you to confront backward compatibility head-on (a new field must be additive and old clients must ignore it). - Write the integration test. Mirror an existing API test
(
rg -rln "def test_" tests/integration_tests/functional/test_api.py), boot a microVM, curl/vmm-info, and assert the JSON and thestartedtransition. New functionality requires an integration test for a real PR (Level 5). - Add a runtime-only field. Add a field that only exists post-boot (e.g. a live device count
read from the
DeviceManager). The preboot arm must omit it or return a sentinel; the runtime arm fills it. This teaches the preboot/runtime asymmetry concretely. - Trace your own action. Apply the Lab 3.1 instrumentation to your
GetVmmInfoarm and watch it cross the channel and dispatch on the VMM thread — your code is now on the path you traced. - Open it as a (draft) PR shape. Make the CHANGELOG entry, sign your commits (
git commit -s), and write a PR description that explains the additive, read-only, back-compatible nature of the change. Don't actually open it upstream — but produce the artifact a maintainer would expect, and compare it against the PR quality guide.
Validation / Self-check
Answer without looking back:
- List the five layers an API
GETtouches, in order, and the file area for each. - Why does your endpoint return
started: falsefrom one controller andstarted: truefrom the other? Name both controllers and what determines which is active. - Your parser arm contains no logic — it just builds a
VmmAction. Where is the logic, and on which thread does it run? - Why must the swagger entry and the parser be added in the same change, and what kind of issue results when they drift apart?
- Adding a
VmmActionvariant edits a versioned contract. What makes a read-only Get the safe end of that, and what two rules must you still follow when adding it? - The compiler errored with "non-exhaustive patterns" until you handled the variant in both controllers. What contract is that error enforcing, and why is it good that it's a compile error and not a runtime one?
- For a real PR (not just a local build), name three things beyond "it compiles and curls" that the maintainers require.
When curl returns your JSON before and after boot with started flipping, the gates pass, and you
can answer all seven, you've completed Lab 3.3 — and Level 3. Continue to
Level 4: KVM, vCPUs, and the Run Loop, where you leave the control plane and
enter the KVM_RUN loop the vCPU threads run.