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:

LayerFile areaWhat you add
1. Swaggersrc/firecracker/swagger/firecracker.yamlthe new path + response schema (the API is the contract)
2. Parsersrc/firecracker/src/api_server/match (GET, "/vmm-info") → build a VmmAction
3. Actionsrc/vmm/src/rpc_interface.rsa new VmmAction variant (or reuse) + a VmmData variant
4. Controllersrc/vmm/src/rpc_interface.rsthe dispatch arm that reads state and returns VmmData
5. State readsrc/vmm/src/resources.rs / lib.rsthe 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 VmmData variants, and the parser's helper functions drift across branches (especially after the crate merge into vmm). Treat the snippets as templates. For each one, the lab first gives you the rg that 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 VmmAction variant 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 keep serde field names stable. Confirm whether VmmData is #[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 the rg from Step 1 and use the exact name your branch has. The MachineConfig getter in particular has been renamed across versions; copy what the existing GET /machine-config arm 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.

RuleWhyHow to satisfy it
Swagger ↔ parser must agreeThe YAML is the published contract; clients generate from itAdd both in the same PR; never one without the other
Additive only / backward compatibleOld clients must keep workingNew endpoints and new optional response fields are fine; never remove or rename a field, never change a field's type
No new VmmAction mid-enumOrdering can matter for serialization/version negotiationAppend where the codebase appends; keep serde names stable
Read-only ⇒ no snapshot impactA Get carries no state into a snapshotConfirm your variant isn't serialized into the microVM state file
Tests + CHANGELOGMaintainers require integration tests for new functionality and a CHANGELOG noteAdd a pytest that curls the endpoint; add an ### Added line to CHANGELOG.md
DCO sign-offEvery 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 VmmAction variant (or a documented reuse) and a VmmData::VmmInfo(VmmInfo) payload, added the way the existing Get* 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 curl returns the expected JSON both before and after InstanceStart, with started flipping false → true.
  • tools/devtool fmt and checkstyle pass; 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

  1. Reuse instead of add. Redo the lab by extending an existing endpoint — add a single new optional field to the GET /machine-config or GET /version response 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).
  2. 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 the started transition. New functionality requires an integration test for a real PR (Level 5).
  3. 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.
  4. Trace your own action. Apply the Lab 3.1 instrumentation to your GetVmmInfo arm and watch it cross the channel and dispatch on the VMM thread — your code is now on the path you traced.
  5. 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:

  1. List the five layers an API GET touches, in order, and the file area for each.
  2. Why does your endpoint return started: false from one controller and started: true from the other? Name both controllers and what determines which is active.
  3. Your parser arm contains no logic — it just builds a VmmAction. Where is the logic, and on which thread does it run?
  4. Why must the swagger entry and the parser be added in the same change, and what kind of issue results when they drift apart?
  5. Adding a VmmAction variant 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?
  6. 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?
  7. 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.