Skip to main content

squib_api/
handlers.rs

1//! Axum handlers per [20-firecracker-api.md §
2//! 2](../../../specs/20-firecracker-api.md#2-server-shape).
3//!
4//! Three handler categories:
5//!
6//! - **Read-only fast path** (§ 5.1) — `GET /`, `GET /version`, `GET /vm/config`, `GET
7//!   /machine-config`, `GET /balloon`, `GET /balloon/statistics`, `GET /mmds`. These read directly
8//!   from `ArcSwap` and never touch the VMM channel, so a long-running `PUT /snapshot/load` cannot
9//!   cause liveness probes to time out.
10//! - **Mutating** — every PUT/PATCH/DELETE/POST. Validates the JSON body via `Raw<T> → T::TryFrom`,
11//!   validates phase, dispatches to the VMM through [`RuntimeApiController`], applies the per-class
12//!   timeout.
13//! - **Fallback** — unknown paths translated to `400 Bad Request` per the upstream shape (squib has
14//!   no 404; see [20 § 3](../../../specs/20-firecracker-api.md#3-error-envelope)).
15
16#![deny(
17    clippy::unwrap_used,
18    clippy::expect_used,
19    clippy::indexing_slicing,
20    clippy::panic
21)]
22
23use std::sync::Arc;
24
25use axum::{
26    Json,
27    extract::{Path, State},
28    http::{StatusCode, Uri},
29    response::{IntoResponse, Response},
30};
31use serde_json::json;
32
33use crate::{
34    action::{ApiAction, ApiResponse},
35    controller::RuntimeApiController,
36    error::{ApiError, Result},
37    schemas::{
38        BalloonConfig, BalloonHintingOp, BalloonStatsUpdate, BalloonUpdate, BootSourceConfig,
39        CpuConfig, DriveConfig, DriveId, DrivePatch, EntropyConfig, HotplugMemoryConfig,
40        HotplugMemoryUpdate, IfaceId, InstanceActionInfo, InstanceInfo, LoggerConfig,
41        MachineConfig, MachineConfigPatch, MetricsConfig, MmdsConfig, MmdsContents,
42        NetworkInterfaceConfig, NetworkPatch, PmemConfig, PmemPatch, SerialConfig,
43        SnapshotCreateConfig, SnapshotLoadConfig, VersionResponse, VmStateChange, VsockConfig,
44        actions::RawInstanceActionInfo,
45        balloon::{RawBalloonConfig, RawBalloonStatsUpdate, RawBalloonUpdate},
46        boot_source::RawBootSourceConfig,
47        cpu_config::RawCpuConfig,
48        drive::{RawDriveConfig, RawDrivePatch},
49        entropy::RawEntropyConfig,
50        hotplug_memory::{RawHotplugMemoryConfig, RawHotplugMemoryUpdate},
51        logger::RawLoggerConfig,
52        machine_config::{RawMachineConfig, RawMachineConfigPatch},
53        metrics::RawMetricsConfig,
54        mmds::RawMmdsConfig,
55        network::{RawNetworkInterfaceConfig, RawNetworkPatch},
56        pmem::{RawPmemConfig, RawPmemPatch},
57        serial::RawSerialConfig,
58        snapshot::{RawSnapshotCreateConfig, RawSnapshotLoadConfig},
59        vm::RawVmPatch,
60        vsock::RawVsockConfig,
61    },
62};
63
64/// Convert a validation error string into a 400 `ApiError::BadRequest`.
65///
66/// Every `Raw* → Validated` `TryFrom` returns `Error = String`; this collapses the
67/// repeated `.map_err(ApiError::BadRequest)?` boilerplate at every handler.
68fn bad(s: String) -> ApiError {
69    ApiError::BadRequest(s)
70}
71
72/// Helper: turn the controller dispatch result into an axum response.
73fn finish(resp: ApiResponse) -> Response {
74    match resp {
75        ApiResponse::NoContent => StatusCode::NO_CONTENT.into_response(),
76        ApiResponse::Json(v) => (StatusCode::OK, Json(v)).into_response(),
77        ApiResponse::Fault {
78            status,
79            fault_message,
80        } => {
81            let status = StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_REQUEST);
82            (status, Json(crate::error::FaultMessage::new(fault_message))).into_response()
83        }
84    }
85}
86
87// ---------------- Read-only fast path ----------------
88
89/// `GET /` — `InstanceInfo`. Read-only fast path; never touches the VMM channel.
90pub async fn get_root(
91    State(controller): State<Arc<RuntimeApiController>>,
92) -> Result<Json<InstanceInfo>> {
93    Ok(Json(controller.snapshot().instance_info.clone()))
94}
95
96/// `GET /version` — Firecracker-compat version string.
97pub async fn get_version(
98    State(controller): State<Arc<RuntimeApiController>>,
99) -> Result<Json<VersionResponse>> {
100    Ok(Json(VersionResponse {
101        firecracker_version: controller.snapshot().firecracker_version.clone(),
102    }))
103}
104
105/// `GET /vm/config` — materialized `VmmConfig` (read-only fast path).
106pub async fn get_vm_config(
107    State(controller): State<Arc<RuntimeApiController>>,
108) -> Result<Json<serde_json::Value>> {
109    Ok(Json((*controller.snapshot().vm_config).clone()))
110}
111
112/// `GET /machine-config` — read-only mirror of the last-applied machine config (or an
113/// empty object pre-boot).
114pub async fn get_machine_config(
115    State(controller): State<Arc<RuntimeApiController>>,
116) -> Result<Json<serde_json::Value>> {
117    let snap = controller.snapshot();
118    let cfg = snap
119        .vm_config
120        .get("machine-config")
121        .cloned()
122        .unwrap_or_else(|| json!({}));
123    Ok(Json(cfg))
124}
125
126/// `GET /balloon` — read-only mirror.
127pub async fn get_balloon(
128    State(controller): State<Arc<RuntimeApiController>>,
129) -> Result<Json<serde_json::Value>> {
130    let snap = controller.snapshot();
131    let cfg = snap
132        .vm_config
133        .get("balloon")
134        .cloned()
135        .unwrap_or_else(|| json!({}));
136    Ok(Json(cfg))
137}
138
139/// `GET /balloon/statistics` — read-only mirror.
140pub async fn get_balloon_statistics(
141    State(controller): State<Arc<RuntimeApiController>>,
142) -> Result<Json<serde_json::Value>> {
143    let snap = controller.snapshot();
144    let stats = snap
145        .vm_config
146        .get("balloon_statistics")
147        .cloned()
148        .unwrap_or_else(|| json!({}));
149    Ok(Json(stats))
150}
151
152/// `GET /mmds` — return the MMDS data store tree.
153pub async fn get_mmds(
154    State(controller): State<Arc<RuntimeApiController>>,
155) -> Result<Json<serde_json::Value>> {
156    let snap = controller.snapshot();
157    let tree = snap
158        .vm_config
159        .get("mmds")
160        .cloned()
161        .unwrap_or(serde_json::Value::Null);
162    Ok(Json(tree))
163}
164
165// ---------------- Mutating handlers ----------------
166
167/// `PUT /boot-source`.
168pub async fn put_boot_source(
169    State(controller): State<Arc<RuntimeApiController>>,
170    Json(raw): Json<RawBootSourceConfig>,
171) -> Result<Response> {
172    let cfg = BootSourceConfig::try_from(raw).map_err(bad)?;
173    let resp = controller.dispatch(ApiAction::PutBootSource(cfg)).await?;
174    Ok(finish(resp))
175}
176
177/// `PUT /machine-config`.
178pub async fn put_machine_config(
179    State(controller): State<Arc<RuntimeApiController>>,
180    Json(raw): Json<RawMachineConfig>,
181) -> Result<Response> {
182    let cfg = MachineConfig::try_from(raw).map_err(bad)?;
183    let resp = controller
184        .dispatch(ApiAction::PutMachineConfig(cfg))
185        .await?;
186    Ok(finish(resp))
187}
188
189/// `PATCH /machine-config`.
190pub async fn patch_machine_config(
191    State(controller): State<Arc<RuntimeApiController>>,
192    Json(raw): Json<RawMachineConfigPatch>,
193) -> Result<Response> {
194    let patch = MachineConfigPatch::try_from(raw).map_err(bad)?;
195    let resp = controller
196        .dispatch(ApiAction::PatchMachineConfig(patch))
197        .await?;
198    Ok(finish(resp))
199}
200
201/// `PUT /drives/{id}`. The URL `{id}` must match the body `drive_id`.
202pub async fn put_drive(
203    State(controller): State<Arc<RuntimeApiController>>,
204    Path(id): Path<String>,
205    Json(raw): Json<RawDriveConfig>,
206) -> Result<Response> {
207    if raw.drive_id != id {
208        return Err(ApiError::BadRequest(
209            "Invalid drive: URL id and body drive_id must match".into(),
210        ));
211    }
212    let cfg = DriveConfig::try_from(raw).map_err(bad)?;
213    let resp = controller.dispatch(ApiAction::PutDrive(cfg)).await?;
214    Ok(finish(resp))
215}
216
217/// `PATCH /drives/{id}`.
218pub async fn patch_drive(
219    State(controller): State<Arc<RuntimeApiController>>,
220    Path(id): Path<String>,
221    Json(raw): Json<RawDrivePatch>,
222) -> Result<Response> {
223    if raw.drive_id != id {
224        return Err(ApiError::BadRequest(
225            "Invalid drive: URL id and body drive_id must match".into(),
226        ));
227    }
228    let patch = DrivePatch::try_from(raw).map_err(bad)?;
229    let resp = controller.dispatch(ApiAction::PatchDrive(patch)).await?;
230    Ok(finish(resp))
231}
232
233/// `DELETE /drives/{id}`.
234pub async fn delete_drive(
235    State(controller): State<Arc<RuntimeApiController>>,
236    Path(id): Path<String>,
237) -> Result<Response> {
238    let drive_id = DriveId::new(id).map_err(bad)?;
239    let resp = controller
240        .dispatch(ApiAction::DeleteDrive { drive_id })
241        .await?;
242    Ok(finish(resp))
243}
244
245/// `PUT /network-interfaces/{id}`.
246pub async fn put_network(
247    State(controller): State<Arc<RuntimeApiController>>,
248    Path(id): Path<String>,
249    Json(raw): Json<RawNetworkInterfaceConfig>,
250) -> Result<Response> {
251    if raw.iface_id != id {
252        return Err(ApiError::BadRequest(
253            "Invalid network-interface: URL id and body iface_id must match".into(),
254        ));
255    }
256    let cfg = NetworkInterfaceConfig::try_from(raw).map_err(bad)?;
257    let resp = controller.dispatch(ApiAction::PutNetwork(cfg)).await?;
258    Ok(finish(resp))
259}
260
261/// `PATCH /network-interfaces/{id}`.
262pub async fn patch_network(
263    State(controller): State<Arc<RuntimeApiController>>,
264    Path(id): Path<String>,
265    Json(raw): Json<RawNetworkPatch>,
266) -> Result<Response> {
267    if raw.iface_id != id {
268        return Err(ApiError::BadRequest(
269            "Invalid network-interface: URL id and body iface_id must match".into(),
270        ));
271    }
272    let patch = NetworkPatch::try_from(raw).map_err(bad)?;
273    let resp = controller.dispatch(ApiAction::PatchNetwork(patch)).await?;
274    Ok(finish(resp))
275}
276
277/// `DELETE /network-interfaces/{id}`.
278pub async fn delete_network(
279    State(controller): State<Arc<RuntimeApiController>>,
280    Path(id): Path<String>,
281) -> Result<Response> {
282    let iface_id = IfaceId::new(id).map_err(bad)?;
283    let resp = controller
284        .dispatch(ApiAction::DeleteNetwork { iface_id })
285        .await?;
286    Ok(finish(resp))
287}
288
289/// `PUT /vsock`.
290pub async fn put_vsock(
291    State(controller): State<Arc<RuntimeApiController>>,
292    Json(raw): Json<RawVsockConfig>,
293) -> Result<Response> {
294    let cfg = VsockConfig::try_from(raw).map_err(bad)?;
295    let resp = controller.dispatch(ApiAction::PutVsock(cfg)).await?;
296    Ok(finish(resp))
297}
298
299/// `PUT /mmds` — replace the data store.
300pub async fn put_mmds(
301    State(controller): State<Arc<RuntimeApiController>>,
302    Json(value): Json<serde_json::Value>,
303) -> Result<Response> {
304    let resp = controller
305        .dispatch(ApiAction::PutMmds(MmdsContents::new(value)))
306        .await?;
307    Ok(finish(resp))
308}
309
310/// `PATCH /mmds` — JSON-merge-patch the data store.
311pub async fn patch_mmds(
312    State(controller): State<Arc<RuntimeApiController>>,
313    Json(value): Json<serde_json::Value>,
314) -> Result<Response> {
315    let resp = controller
316        .dispatch(ApiAction::PatchMmds(MmdsContents::new(value)))
317        .await?;
318    Ok(finish(resp))
319}
320
321/// `PUT /mmds/config`.
322pub async fn put_mmds_config(
323    State(controller): State<Arc<RuntimeApiController>>,
324    Json(raw): Json<RawMmdsConfig>,
325) -> Result<Response> {
326    let cfg = MmdsConfig::try_from(raw).map_err(bad)?;
327    let resp = controller.dispatch(ApiAction::PutMmdsConfig(cfg)).await?;
328    Ok(finish(resp))
329}
330
331/// `PUT /balloon`.
332pub async fn put_balloon(
333    State(controller): State<Arc<RuntimeApiController>>,
334    Json(raw): Json<RawBalloonConfig>,
335) -> Result<Response> {
336    let cfg = BalloonConfig::try_from(raw).map_err(bad)?;
337    let resp = controller.dispatch(ApiAction::PutBalloon(cfg)).await?;
338    Ok(finish(resp))
339}
340
341/// `PATCH /balloon`.
342pub async fn patch_balloon(
343    State(controller): State<Arc<RuntimeApiController>>,
344    Json(raw): Json<RawBalloonUpdate>,
345) -> Result<Response> {
346    let upd = BalloonUpdate::try_from(raw).map_err(bad)?;
347    let resp = controller.dispatch(ApiAction::PatchBalloon(upd)).await?;
348    Ok(finish(resp))
349}
350
351/// `PATCH /balloon/statistics`.
352pub async fn patch_balloon_statistics(
353    State(controller): State<Arc<RuntimeApiController>>,
354    Json(raw): Json<RawBalloonStatsUpdate>,
355) -> Result<Response> {
356    let upd = BalloonStatsUpdate::try_from(raw).map_err(bad)?;
357    let resp = controller
358        .dispatch(ApiAction::PatchBalloonStats(upd))
359        .await?;
360    Ok(finish(resp))
361}
362
363/// `PATCH /balloon/hinting/{op}` handler. The op is validated against `start | status | stop`.
364///
365/// Body is ignored upstream (the entire signal lives in the `{op}` URL segment); we
366/// accept any JSON body to stay shape-compatible.
367pub async fn patch_balloon_hinting(
368    State(controller): State<Arc<RuntimeApiController>>,
369    Path(op): Path<String>,
370) -> Result<Response> {
371    let op = BalloonHintingOp::from_url_segment(&op).map_err(bad)?;
372    let resp = controller
373        .dispatch(ApiAction::PatchBalloonHinting { op })
374        .await?;
375    Ok(finish(resp))
376}
377
378/// `PUT /entropy`.
379pub async fn put_entropy(
380    State(controller): State<Arc<RuntimeApiController>>,
381    Json(raw): Json<RawEntropyConfig>,
382) -> Result<Response> {
383    let cfg = EntropyConfig::try_from(raw).map_err(bad)?;
384    let resp = controller.dispatch(ApiAction::PutEntropy(cfg)).await?;
385    Ok(finish(resp))
386}
387
388/// `PUT /serial`.
389pub async fn put_serial(
390    State(controller): State<Arc<RuntimeApiController>>,
391    Json(raw): Json<RawSerialConfig>,
392) -> Result<Response> {
393    let cfg = SerialConfig::try_from(raw).map_err(bad)?;
394    let resp = controller.dispatch(ApiAction::PutSerial(cfg)).await?;
395    Ok(finish(resp))
396}
397
398/// `PUT /pmem/{id}`.
399pub async fn put_pmem(
400    State(controller): State<Arc<RuntimeApiController>>,
401    Path(id): Path<String>,
402    Json(raw): Json<RawPmemConfig>,
403) -> Result<Response> {
404    if raw.pmem_id != id {
405        return Err(ApiError::BadRequest(
406            "Invalid pmem: URL id and body pmem_id must match".into(),
407        ));
408    }
409    let cfg = PmemConfig::try_from(raw).map_err(bad)?;
410    let resp = controller.dispatch(ApiAction::PutPmem(cfg)).await?;
411    Ok(finish(resp))
412}
413
414/// `PATCH /pmem/{id}`.
415pub async fn patch_pmem(
416    State(controller): State<Arc<RuntimeApiController>>,
417    Path(id): Path<String>,
418    Json(raw): Json<RawPmemPatch>,
419) -> Result<Response> {
420    if raw.pmem_id != id {
421        return Err(ApiError::BadRequest(
422            "Invalid pmem: URL id and body pmem_id must match".into(),
423        ));
424    }
425    let patch = PmemPatch::try_from(raw).map_err(bad)?;
426    let resp = controller.dispatch(ApiAction::PatchPmem(patch)).await?;
427    Ok(finish(resp))
428}
429
430/// `DELETE /pmem/{id}`.
431pub async fn delete_pmem(
432    State(controller): State<Arc<RuntimeApiController>>,
433    Path(id): Path<String>,
434) -> Result<Response> {
435    let pmem_id = DriveId::new(id).map_err(bad)?;
436    let resp = controller
437        .dispatch(ApiAction::DeletePmem { pmem_id })
438        .await?;
439    Ok(finish(resp))
440}
441
442/// `GET /hotplug/memory` — read-only mirror.
443pub async fn get_hotplug_memory(
444    State(controller): State<Arc<RuntimeApiController>>,
445) -> Result<Json<serde_json::Value>> {
446    let snap = controller.snapshot();
447    let cfg = snap
448        .vm_config
449        .get("hotplug-memory")
450        .cloned()
451        .unwrap_or_else(|| serde_json::json!({}));
452    Ok(Json(cfg))
453}
454
455/// `PUT /hotplug/memory`.
456pub async fn put_hotplug_memory(
457    State(controller): State<Arc<RuntimeApiController>>,
458    Json(raw): Json<RawHotplugMemoryConfig>,
459) -> Result<Response> {
460    let cfg = HotplugMemoryConfig::try_from(raw).map_err(bad)?;
461    let resp = controller
462        .dispatch(ApiAction::PutHotplugMemory(cfg))
463        .await?;
464    Ok(finish(resp))
465}
466
467/// `PATCH /hotplug/memory`.
468pub async fn patch_hotplug_memory(
469    State(controller): State<Arc<RuntimeApiController>>,
470    Json(raw): Json<RawHotplugMemoryUpdate>,
471) -> Result<Response> {
472    let upd = HotplugMemoryUpdate::try_from(raw).map_err(bad)?;
473    let resp = controller
474        .dispatch(ApiAction::PatchHotplugMemory(upd))
475        .await?;
476    Ok(finish(resp))
477}
478
479/// `PUT /cpu-config`.
480pub async fn put_cpu_config(
481    State(controller): State<Arc<RuntimeApiController>>,
482    Json(raw): Json<RawCpuConfig>,
483) -> Result<Response> {
484    let cfg = CpuConfig::try_from(raw).map_err(bad)?;
485    let resp = controller.dispatch(ApiAction::PutCpuConfig(cfg)).await?;
486    Ok(finish(resp))
487}
488
489/// `PUT /actions`.
490pub async fn put_actions(
491    State(controller): State<Arc<RuntimeApiController>>,
492    Json(raw): Json<RawInstanceActionInfo>,
493) -> Result<Response> {
494    let info = InstanceActionInfo::from(raw);
495    let resp = controller
496        .dispatch(ApiAction::Action(info.action_type))
497        .await?;
498    Ok(finish(resp))
499}
500
501/// `PATCH /vm` — pause/resume.
502pub async fn patch_vm(
503    State(controller): State<Arc<RuntimeApiController>>,
504    Json(raw): Json<RawVmPatch>,
505) -> Result<Response> {
506    let action = match raw.state {
507        VmStateChange::Paused => ApiAction::PatchVm(VmStateChange::Paused),
508        VmStateChange::Resumed => ApiAction::PatchVm(VmStateChange::Resumed),
509    };
510    let resp = controller.dispatch(action).await?;
511    Ok(finish(resp))
512}
513
514/// `PUT /snapshot/create`.
515pub async fn put_snapshot_create(
516    State(controller): State<Arc<RuntimeApiController>>,
517    Json(raw): Json<RawSnapshotCreateConfig>,
518) -> Result<Response> {
519    let cfg = SnapshotCreateConfig::try_from(raw).map_err(bad)?;
520    let resp = controller.dispatch(ApiAction::SnapshotCreate(cfg)).await?;
521    Ok(finish(resp))
522}
523
524/// `PUT /snapshot/load`.
525pub async fn put_snapshot_load(
526    State(controller): State<Arc<RuntimeApiController>>,
527    Json(raw): Json<RawSnapshotLoadConfig>,
528) -> Result<Response> {
529    let cfg = SnapshotLoadConfig::try_from(raw).map_err(bad)?;
530    let resp = controller.dispatch(ApiAction::SnapshotLoad(cfg)).await?;
531    Ok(finish(resp))
532}
533
534/// `PUT /logger`.
535pub async fn put_logger(
536    State(controller): State<Arc<RuntimeApiController>>,
537    Json(raw): Json<RawLoggerConfig>,
538) -> Result<Response> {
539    let cfg = LoggerConfig::try_from(raw).map_err(bad)?;
540    let resp = controller.dispatch(ApiAction::PutLogger(cfg)).await?;
541    Ok(finish(resp))
542}
543
544/// `PUT /metrics`.
545pub async fn put_metrics(
546    State(controller): State<Arc<RuntimeApiController>>,
547    Json(raw): Json<RawMetricsConfig>,
548) -> Result<Response> {
549    let cfg = MetricsConfig::try_from(raw).map_err(bad)?;
550    let resp = controller.dispatch(ApiAction::PutMetrics(cfg)).await?;
551    Ok(finish(resp))
552}
553
554/// Fallback for unmatched paths — translate to 400 with a `fault_message`.
555pub async fn fallback(uri: Uri) -> ApiError {
556    ApiError::NotFound(uri.to_string())
557}