Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ All notable changes to virtkit will be documented in this file.
`vk run --numa off|auto|interleave|N` decides for one VM. Single-node hosts, which are
most of them, are unaffected.

- **`vk list` and `vk dev list` show live host memory usage beside the VM's configured
memory.** The detailed VM record adds `MEM USED`, and JSON output includes
`mem_used_bytes` for scripts.

## [0.71.0] - 2026-09-14

### Changed
Expand Down
36 changes: 23 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -435,12 +435,20 @@ vk list
```

```
PID UPTIME NAME SERVICES PROJECT PUBLISHED
41230 2h14m app/Dockerfile:dev - ~/app 127.0.0.1:8443->localhost:443
41877 35m shop db, redis, web, +4 ~/shop 127.0.0.1:5432->127.0.0.1:5432@db
PID UPTIME MEM NAME SERVICES PROJECT PUBLISHED
41230 2h14m 1.2G/8G app/Dockerfile:dev - ~/app 127.0.0.1:8443->localhost:443
41877 35m 5.9G/16G shop db, redis, web, +4 ~/shop 127.0.0.1:5432->127.0.0.1:5432@db
```

NAME is the built Dockerfile with its target stage, the compose primary, or the image ref.
MEM is what the VM is costing the host now over the size it booted with: the resident
memory of its whole process tree — the guest, its service VMs, the switch, the virtiofsds
and the forwards — counted proportionally so a page several of them map is charged once,
over the `--mem` token as the run recorded it. When proportional usage is unavailable,
resident usage is used instead and may count shared pages more than once. The total includes
service VMs and helpers, so it is not the primary guest's memory utilization.
Either half is `-` on its own when unknown,
and the cell is a bare `-` when neither is. NAME is the built Dockerfile with its target
stage, the compose primary, or the image ref.
SERVICES lists the compose services running beside the primary, or every declared one when
the VM cannot be asked; `-` for none. Past three names, the rest are counted (`+4`).
PROJECT is the run's `--workspace`, then its `--workdir`, then its launch directory, with
Expand Down Expand Up @@ -482,6 +490,7 @@ GUEST IP 10.0.0.2
VMM libkrun (pid 41902)
CPUS 4
MEM 8G
MEM USED 5.9 GiB
NESTED no
ATOP LOG -
SERVICES db running 10.0.0.3 vsock-auto:///home/me/shop/.vk/svc-db/vsock.sock:4444
Expand All @@ -496,15 +505,16 @@ PUBLISHED pg 127.0.0.1:5432->127.0.0.1:5432@db pid 42011
```

`--json` gives an array of objects, one per VM, with `pid`, `label`, `project_dir`,
`exec_addr`, `state_dir`, `vmm`, `vmm_pid`, `cpus`, `mem`, `nested`, `guest_ip` (the eth0
address on a `--net` LAN), `ssh_addr`, `atop_log`, `created_secs`, `uptime_secs`,
`services` (every declared compose service with its `name`, `exec_addr`, `state` and LAN
`ip`), and `published` (each publisher's `name`, `listen`, `to` and `pid`, plus `via` when
a compose sibling dials — the one `vk publish ensure --via` named — and `"unconfirmed":
true` when its liveness could not be checked). `--field` picks fields without jq, one
`--field` per field: one line per VM and tab-separated in flag order, or with `--json`
objects holding only those fields; a dotted path reaches into nested values, and a key a
record omits reads `null`:
`exec_addr`, `state_dir`, `vmm`, `vmm_pid`, `cpus`, `mem`, `mem_used_bytes` (what the VM's
process tree holds on the host now, in bytes — `null` when it could not be read),
`nested`, `guest_ip` (the eth0 address on a `--net` LAN), `ssh_addr`, `atop_log`,
`created_secs`, `uptime_secs`, `services` (every declared compose service with its
`name`, `exec_addr`, `state` and LAN `ip`), and `published` (each publisher's `name`,
`listen`, `to` and `pid`, plus `via` when a compose sibling dials — the one
`vk publish ensure --via` named — and `"unconfirmed": true` when its liveness could not
be checked). `--field` picks fields without jq, one `--field` per field: one line per VM
and tab-separated in flag order, or with `--json` objects holding only those fields; a
dotted path reaches into nested values, and a key a record omits reads `null`:

```sh
vk list . --field pid # the pid to hand to vk stop
Expand Down
10 changes: 7 additions & 3 deletions docs/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,9 +474,13 @@ vk dev gc ENVIRONMENT_NAME --yes

`list` and `gc` work from anywhere without a project config. Copy names from
`list`; they identify state directories, not just the config's `dev` or `hook`
selector. `gc` refuses running environments. `--all-stale` selects stopped state
whose workspace is gone or which never recorded a boot, including leftovers
from throwaway tasks. It does not mean every stopped development environment.
selector. The `MEM` column shows what a running environment's VM holds on the
host now over the size it booted with (`1.2G/8G`), the same figure `vk list`
reports; a stopped environment reads `-`, and `mem_used_bytes` in `--json` is
null for it. `gc` refuses running environments. `--all-stale` selects stopped
state whose workspace is gone or which never recorded a boot, including
leftovers from throwaway tasks. It does not mean every stopped development
environment.

Without `--yes`, GC asks on a terminal; without a terminal it only lists what
would be removed. GC deletes state directories, including managed data inside
Expand Down
10 changes: 6 additions & 4 deletions vk-driver/src/dev/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,10 +356,12 @@ enum DevAction {
///
/// Host-wide, and needs no config in the current directory: one row per state directory
/// under `$XDG_STATE_HOME/virtkit/dev` — which workspace and environment it belongs to,
/// whether it is running, which vk created it, how long ago it last booted and what it
/// holds on disk (`--no-sizes` skips the measure). Flagged when its workspace is gone, or
/// when it recorded no boot at all — the shape a task run in a throwaway environment leaves.
/// Reads only.
/// whether it is running, which vk created it, how long ago it last booted, what its VM
/// holds in memory and what it holds on disk (`--no-sizes` skips the disk measure). MEM
/// is the running VM's whole process tree over the size it booted with (`1.2G/8G`), as
/// `vk list` reports it, and `-` for an environment that is not running. Flagged when its
/// workspace is gone, or when it recorded no boot at all — the shape a task run in a
/// throwaway environment leaves. Reads only.
List {
/// print the same facts as JSON
#[arg(long)]
Expand Down
109 changes: 93 additions & 16 deletions vk-driver/src/dev/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
//! `vk dev list --json` is an array of [`Row`], and its field names are the interface: they
//! are added to, never renamed or repurposed. `size_bytes` is the exception that is absent
//! rather than null — measuring a state directory walks all of it, and `--no-sizes` skips
//! the default measurement.
//! the default measurement. `mem_used_bytes` is always requested; null means the environment
//! is stopped or its memory could not be read.

use std::os::unix::fs::FileTypeExt;
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -72,6 +73,20 @@ impl Flag {
}
}

/// A running VM as [`scan`] needs it: the state directory that ties it to a row, plus the
/// memory facts that row reports. Carried as its own type rather than a [`crate::vms::VmEntry`]
/// so `scan` stays a pure function over facts a test can state outright — measuring a live
/// process tree is the caller's job, not the scan's.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Running {
/// the VM's `--state-dir`, as the registry recorded it
pub state_dir: PathBuf,
/// what its whole process tree holds on the host now (`crate::usage::tree_resident`);
/// `None` when the tree could not be read
pub mem_used: Option<u64>,
/// the memory size it booted with, the `--mem` token verbatim
pub mem: Option<String>,
}
/// One state directory, as `vk dev list` reports it.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Row {
Expand All @@ -87,6 +102,14 @@ pub struct Row {
pub booted_secs: Option<u64>,
/// how long ago that boot was, at the time of the scan
pub age_secs: Option<u64>,
/// what the environment's VM holds on the host right now, in bytes: its whole process
/// tree, counted proportionally, the same figure `vk list` reports. `null` for an
/// environment that is not running — a stopped one holds nothing, which is a fact, not a
/// missing measurement — and for a running one whose tree could not be read.
pub mem_used_bytes: Option<u64>,
/// the memory size its VM booted with, the `--mem` token as recorded; `null` when it is
/// not running, or the run recorded none
pub mem: Option<String>,
/// what the directory holds; measured by default, omitted with `--no-sizes`
#[serde(skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<u64>,
Expand Down Expand Up @@ -115,7 +138,7 @@ pub struct Entry {
/// List state directories under `base`, using `running` to identify active VMs. Read-only.
/// Keep rows with absent or unreadable `dev.json` so `gc` can collect them.
/// `sizes` measures each directory, walking its root images and server trees.
pub fn scan(base: &Path, running: &[PathBuf], sizes: bool) -> Vec<Row> {
pub fn scan(base: &Path, running: &[Running], sizes: bool) -> Vec<Row> {
let Ok(entries) = std::fs::read_dir(base) else {
return Vec::new();
};
Expand All @@ -128,7 +151,7 @@ pub fn scan(base: &Path, running: &[PathBuf], sizes: bool) -> Vec<Row> {
rows
}

fn row(dir: &Path, running: &[PathBuf], sizes: bool) -> Row {
fn row(dir: &Path, running: &[Running], sizes: bool) -> Row {
let identity = std::fs::read(dir.join("dev.json"))
.ok()
.and_then(|b| serde_json::from_slice::<crate::dev::Identity>(&b).ok());
Expand All @@ -139,7 +162,9 @@ fn row(dir: &Path, running: &[PathBuf], sizes: bool) -> Row {
// The registry records canonical state dirs, so compare against both forms: the base
// itself reaches us through `$HOME`, which is a symlink on some hosts.
let canonical = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
let is_running = running.iter().any(|r| r == dir || r == &canonical);
let live = running
.iter()
.find(|r| r.state_dir == dir || r.state_dir == canonical);
let mut flags = Vec::new();
// Require the workspace's parent to exist: an unmounted share or unplugged disk must
// not make its environments stale and let `gc --all-stale --yes` destroy their storage.
Expand All @@ -160,7 +185,7 @@ fn row(dir: &Path, running: &[PathBuf], sizes: bool) -> Row {
dir: dir.to_path_buf(),
workspace,
environment: manifest("environment"),
status: match (is_running, identity.is_some()) {
status: match (live.is_some(), identity.is_some()) {
(true, _) => Status::Running,
(false, true) => Status::Stopped,
(false, false) => Status::NeverBooted,
Expand All @@ -171,6 +196,8 @@ fn row(dir: &Path, running: &[PathBuf], sizes: bool) -> Row {
.filter(|by| !by.is_empty()),
booted_secs,
age_secs: booted_secs.map(|s| crate::vms::unix_now().saturating_sub(s)),
mem_used_bytes: live.and_then(|r| r.mem_used),
mem: live.and_then(|r| r.mem.clone()),
size_bytes: sizes.then(|| crate::dev::storage::dir_size(dir)),
flags,
}
Expand Down Expand Up @@ -253,6 +280,7 @@ pub fn render(rows: &[Row]) -> String {
.map(short_creator)
.unwrap_or_default(),
r.age_secs.map(crate::vms::fmt_uptime).unwrap_or_default(),
crate::vms::mem_cell(r.mem_used_bytes, r.mem.as_deref()),
fmt_size(r.size_bytes),
r.flags
.iter()
Expand All @@ -270,6 +298,7 @@ pub fn render(rows: &[Row]) -> String {
"STATUS",
"CREATED BY",
"LAST BOOT",
"MEM",
"ON DISK",
"FLAGS",
],
Expand Down Expand Up @@ -409,21 +438,38 @@ pub fn remove(selected: &[Row]) -> Result<String> {
Ok(out)
}

/// The state dirs VMs are currently up on.
/// Running VMs' state directories. `remove` needs only liveness, so this skips the memory
/// walk in [`running_vms`].
fn running_dirs() -> Vec<PathBuf> {
crate::vms::running()
.into_iter()
.map(|e| e.state_dir)
.collect()
}
/// The running VMs, each with the live memory reading its row reports. One `/proc` walk per
/// VM, which is cheap beside the stat walk of every file that `sizes` does by default — and
/// unlike that one it has no opt-out, since a row with no memory figure would not say
/// whether the VM holds nothing or was never asked.
fn running_vms() -> Vec<Running> {
crate::vms::running()
.into_iter()
.map(|e| Running {
mem_used: i32::try_from(e.pid)
.ok()
.and_then(crate::usage::tree_resident),
mem: e.mem,
state_dir: e.state_dir,
})
.collect()
}

/// Every environment this host keeps state for, as `vk dev list` and `vk dev gc` see it:
/// the state base scanned against what is running. Measuring what each holds on disk is a stat
/// walk of every file in it, done by default; `--no-sizes` (`sizes = false`) opts out.
pub fn state(sizes: bool) -> Result<Vec<Row>> {
Ok(scan(
&crate::dev::plan::dev_state_base()?,
&running_dirs(),
&running_vms(),
sizes,
))
}
Expand Down Expand Up @@ -612,8 +658,17 @@ mod tests {
std::fs::create_dir_all(&base).unwrap();
let dir = booted(&base, "repo-aaaa", &workspace, "vk 0.62.0 (abcdef)");

let rows = scan(&base, &[dir], true);
let live = Running {
state_dir: dir,
mem_used: Some(1_288_490_189),
mem: Some("8G".into()),
};
let rows = scan(&base, std::slice::from_ref(&live), true);
assert_eq!(rows[0].status, Status::Running);
// A running row carries the live figure and the size it booted with, so the MEM
// column has both halves; a stopped one has neither (see the render test).
assert_eq!(rows[0].mem_used_bytes, Some(1_288_490_189));
assert_eq!(rows[0].mem.as_deref(), Some("8G"));
assert!(!rows[0].stale());
let e = select_gc(rows, &["repo-aaaa".into()], false).unwrap_err();
assert!(format!("{e:#}").contains("repo-aaaa is running"), "{e:#}");
Expand Down Expand Up @@ -659,37 +714,59 @@ mod tests {
fn render_aligns_the_columns_and_names_what_is_missing() {
let tmp = scratch("render");
let base = tmp.0.join("state");
let workspace = tmp.0.join("repo");
std::fs::create_dir_all(&base).unwrap();
std::fs::create_dir_all(&workspace).unwrap();
let up = booted(&base, "a-live-dddd", &workspace, "vk 0.62.0 (abcdef)");
booted(
&base,
"gone-bbbb",
&tmp.0.join("removed"),
"vk 0.62.0 (abcdef)",
);
ephemeral(&base, "repo-hook-cccc");
let mut rows = scan(&base, &[], true);
// Fixed, so the column reads the same on every run.
rows[0].age_secs = Some(7200);
let live = Running {
state_dir: up,
mem_used: Some(1_288_490_189),
mem: Some("8G".into()),
};
let mut rows = scan(&base, std::slice::from_ref(&live), true);
// Fixed, so the columns read the same on every run.
for row in &mut rows {
row.age_secs = row.age_secs.map(|_| 7200);
}

let out = render(&rows);
let lines: Vec<&str> = out.lines().collect();
assert!(lines[0].starts_with("NAME"), "{out}");
assert!(
lines[0].contains("CREATED BY LAST BOOT ON DISK FLAGS"),
lines[0].contains("CREATED BY LAST BOOT MEM ON DISK FLAGS"),
"{out}"
);
// The running row is the only one holding memory: what its tree holds now over the
// size it booted with, the same cell `vk list` prints.
assert!(
lines[1].contains("running") && lines[1].contains("1.2G/8G"),
"{out}"
);
assert!(
lines[1].contains("stopped") && lines[1].contains("vk 0.62.0"),
lines[2].contains("stopped") && lines[2].contains("vk 0.62.0"),
"{out}"
);
// A stopped environment holds nothing, and the column says so rather than guessing
// from the `--mem` its last boot used.
assert!(
lines[1].contains("2h0m") && lines[1].ends_with("workspace missing"),
lines[2].contains("2h0m") && lines[2].ends_with("workspace missing"),
"{out}"
);
assert!(
lines[2].contains(" - ") || lines[2].contains(" - "),
"a stopped row dashes MEM: {out}"
);
// Nothing was recorded, so the workspace and environment columns say so.
assert!(lines[2].starts_with("repo-hook-cccc ?"), "{out}");
assert!(lines[3].starts_with("repo-hook-cccc ?"), "{out}");
assert!(
lines[2].contains("never booted") && lines[2].ends_with("ephemeral"),
lines[3].contains("never booted") && lines[3].ends_with("ephemeral"),
"{out}"
);
assert_eq!(render(&[]), "no dev environment state on this host\n");
Expand Down
21 changes: 12 additions & 9 deletions vk-driver/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1909,15 +1909,18 @@ enum Cmd {
},
/// List the running vk VMs
///
/// VMs started with `--state-dir`, with their pid, uptime, name, compose services,
/// project directory (`--workspace`, then `--workdir`, then launch directory) and
/// published ports (`listen->to`; `@service` when a compose sibling dials). The table
/// folds `$HOME` to `~` and names at most three services; `--wide` shows every service,
/// the project directory in full and the exec-channel address. With PID or DIR, only the
/// VM with that pid, or the VMs whose project is DIR or below it (or whose state dir is
/// DIR); a selector that names exactly one VM prints its full record instead of a table
/// row. The record folds nothing, so `--wide` has nothing to add there. Use `--json` or
/// `--field` for scripts; neither takes `--wide`, since both already report every field.
/// VMs started with `--state-dir`, with their pid, uptime, memory, name, compose
/// services, project directory (`--workspace`, then `--workdir`, then launch directory)
/// and published ports (`listen->to`; `@service` when a compose sibling dials). MEM is
/// what the VM's whole process tree holds on the host right now over the size it booted
/// with (`1.2G/8G`) — the guest, its service VMs and their helpers, counted
/// proportionally so pages they share are charged once. The table folds `$HOME` to `~`
/// and names at most three services; `--wide` shows every service, the project directory
/// in full and the exec-channel address. With PID or DIR, only the VM with that pid, or
/// the VMs whose project is DIR or below it (or whose state dir is DIR); a selector that
/// names exactly one VM prints its full record instead of a table row. The record folds
/// nothing, so `--wide` has nothing to add there. Use `--json` or `--field` for scripts;
/// neither takes `--wide`, since both already report every field.
#[command(display_order = 6)]
List {
/// which VMs: a PID, or those whose project is DIR or below it (default: all)
Expand Down
Loading