VM
A VM userdata wraps one guest. You get one from lab:vm("name", "profile") (or the dot-sugar lab.<name> after creation), and every operation against the guest dispatches through it.
VM handles are cheap to clone — passing them around or storing them in tables doesn't duplicate state.
Constructing #
| Source | Returns |
|---|---|
lab:vm("name", "profile") | New VM in lab, in the Created state. |
lab:vm("name", "profile", opts) | Same, with sizing opts (memory, cpus). See boot opts. |
lab:vm("name") | Lookup the VM previously declared with that name. Errors if absent. |
lab.name | Same lookup as lab:vm("name"), returns nil on miss. |
provium:vm_fixture("path") | Restored from a cached fixture; already booted. |
State machine #
Created ──:boot()──> Booted ──:pause()──> Paused
│ │
├─:reset()→Booted ├─:resume()→Booted
├─:power_button()──> Shutdown
└─:shutdown()─────> Shutdown
vm:state() returns "created", "booted", "paused", "shutdown", or "dead". Operations against a VM in the wrong state error cleanly with the offending state in the message.
Boot opts #
Boot opts split between the two call sites. The sizing keys go in the third arg to lab:vm(name, profile, opts) — the scheduler and QEMU need them when the VM is declared. The boot-shaping keys go to vm:boot(opts) and merge per field into the VM's pending boot options.
| Key | Where | Type | Description |
|---|---|---|---|
memory | lab:vm opts | int (bytes) or string "512M"/"2G" | VM memory cap. Hands to QEMU as -m <size>. |
cpus | lab:vm opts | int | vCPU count. Hands to QEMU as -smp <n>. |
kernel_cmdline | vm:boot opts | string | Replaces the profile's cmdline. |
rng_seed | vm:boot opts | int (u64) | Seeds the guest's virtio-rng. Use for determinism. |
initial_time | vm:boot opts | int or float (seconds since epoch) | Sets the guest's wall clock at boot. |
files | vm:boot opts | array of {path=string, content=string} | Files to inject into the guest's filesystem before init runs. |
Example:
local vm = provium:
vm:
Lifecycle methods #
vm:boot(opts?) #
Boots the guest. Returns self so calls chain (provium:vm("a", "peios"):boot()). The optional opts table (kernel_cmdline, rng_seed, initial_time, files) merges per field into the VM's pending boot options. Errors if already booted.
Emits a vm_spawned event after the guest is up and the agent has handshaken. The event payload carries {file, vm_name, profile, memory_bytes, cid}.
vm:pause() #
Pauses the guest's vCPUs. Returns nothing. Errors if not in Booted.
vm:resume() #
Resumes a paused guest. Returns nothing. Errors if not in Paused.
vm:shutdown() #
Tears down the guest. Idempotent in the sense that Created, Booted, Paused, and Dead all transition to Shutdown. Emits a vm_shutdown event with {file, vm_name, duration_ns}.
vm:reset() #
Warm reboot — guest re-runs its boot path. Errors if not in Booted. The post-reset state is still Booted.
vm:power_button() #
Sends ACPI power-button to the guest. Triggers a graceful shutdown sequence. Errors if not in Booted. Post-call state is Shutdown.
vm:close() #
Auto-close hook used by the resource walker. Idempotent. Calls :shutdown() if not already in Shutdown. Test code rarely calls this directly — the harness fires it for every VM at file end (or per-test if provium.reset_between_tests = true).
Snapshot and restore #
vm:snapshot(path?) #
Take a snapshot. With an explicit path, writes there; without, writes to a tempfile. Returns a Snapshot userdata wrapping the path.
local s = vm:
local s2 = vm:
vm:restore(snap_or_path) #
Restore from a Snapshot userdata (preferred) or a bare path string. Errors if not in Created or Shutdown.
vm:
vm:
Layer-1 ops #
Layer-1 ops are the high-level "do it like SSH" primitives. They dispatch through the agent's exec / file / stat handlers.
vm:run(cmd_or_args, opts?) #
Run a command. Two forms:
vm:run("echo hi")— runs through/bin/sh -c "echo hi". Shell metacharacters work.vm:run("echo", {"hi"})— direct exec. No shell. The second arg is a positional-arg array.vm:run("echo", {args={"hi"}, env={K="v"}, cwd="/tmp", stdin="…", env_clear=true, timeout="5s"})— opts form. Auto-detected by the presence of a recognised opts key (env,env_clear,cwd,stdin,timeout,timeout_ms,args); a table with none of them is treated as the legacy array. Mixed array entries withargs=are not allowed; use one form or the other.
Returns a RunResult userdata.
| Opts key | Type | Description |
|---|---|---|
args | array of strings | Direct positional args (only used with the table-as-opts form). |
env | string→string map | Environment variables. |
env_clear | bool | When true, the guest sees only env; otherwise env merges with the agent's environment. |
cwd | string | Working directory inside the guest. |
stdin | string | Bytes piped to the process's stdin. |
timeout_ms | int | Hard wall-clock timeout in milliseconds. |
timeout | int / float / string | Same, expressed as seconds or "5s" / "500ms" / "2m" / "1h". Negative or NaN errors. |
vm:run_async(cmd, opts?) #
Like :run, but returns a Process userdata immediately. The agent does not auto-kill; you control the lifetime via proc:kill() / proc:wait(). Passing timeout here is rejected — use proc:wait(timeout) instead.
vm:read_file(path) #
Returns the file contents as a Lua string. Errors on agent-side read failure (ENOENT, EACCES, etc.).
vm:write_file(path, data) #
Replaces the file's contents with data. Creates the file if absent. Returns nothing.
vm:push_file(host_path, guest_path, opts?) #
Read a file from the host filesystem and write its bytes to guest_path in the guest. Inside a fixture, the host file is folded into the fixture's cache key automatically — rebuilding the host file (typical case: a binary under test) invalidates the snapshot. Relative host_path is resolved against the directory of the file containing the call. Pass {auto_dep = false} to skip the auto-fold for a single call. See files and handles and fixtures and dependencies.
vm:stat(path) #
Returns a table:
| Field | Type | Description |
|---|---|---|
size | int (bytes) | File size. |
mtime | float (seconds since epoch) | Modification time. |
mtime_ns | int (ns since epoch) | Same, full precision. |
perm | int | POSIX mode bits (≤ 4095, i.e. 0o7777). |
entry_type | string | One of "file", "directory", "symlink", "fifo", "socket", "block_device", "char_device", "other". |
vm:listdir(path) #
Returns an array of {name=string, entry_type=string} tables.
vm:mkdir(path, opts?) #
Create a directory. Opts: {parents=bool, perm=int}. With parents=true, intermediate directories are created (mkdir -p). perm is the POSIX mode for the new directory.
vm:unlink(path) #
Remove a file or empty directory.
vm:rename(from, to) #
Atomic rename within the guest filesystem.
vm:open_file(path, mode_table) #
Returns a File userdata.
The mode table accepts:
| Key | Type | Effect |
|---|---|---|
read | bool | Open for reading. |
write | bool | Open for writing. |
create | bool | Create if absent. |
truncate | bool | Truncate to zero bytes on open. |
append | bool | Append-only writes. |
exclusive | bool | Combine with create=true to require the file not already exist (O_EXCL). |
perm | int | POSIX mode for newly-created files. |
At least one of read, write, or append must be true; an empty mode table errors at open time with a pointer.
vm:tail_file(path, opts?) #
Open a streaming subscription to a file. Returns a Tail userdata. Opts: {start = "beginning" | "end" | <offset>} ("start" is accepted as an alias for "beginning"). Default is "end" — only bytes appended after the call are streamed. <offset> may be a non-negative integer (absolute byte offset), a negative integer (N bytes before EOF — provium stats and resolves), or a finite float (truncated toward zero, same sign semantics).
vm:fd_stream(fd_or_file) #
Open a streaming subscription to an existing file handle. Accepts either an integer handle id (from file:fd()) or a File userdata directly. Returns a Tail.
Layer-0 ops #
Layer-0 ops are direct kernel-level primitives — syscall(2) and ioctl(2). Use these to exercise driver paths or to test syscall semantics directly.
vm:syscall(nr, ...) #
Two call shapes:
- Integer-only:
vm:syscall(nr, a1, a2, a3, a4, a5, a6)— up to 6 integer arguments. - Table form:
vm:syscall(nr, {args={a1, a2, …}, bufs={"…", …}, ptrs={1, 3}, nested={…}}). Buffers are byte arrays;ptrs[i]says which arg slot (0-indexed)bufs[i]should be spliced into. The buffer's address is filled in for the kernel.
Nested pointers. nested is a list of {parent=N, child=M, offset=K} entries (parent/child are 1-based indices into bufs). Before the arg-slot ptrs are applied, the agent writes bufs[M]'s address into bufs[N] at byte offset (an 8-byte native-endian pointer). This is how you pass a struct argument that itself contains a pointer — a buffer pointing at another buffer. Use it for ioctls invoked via raw syscall(SYS_ioctl, fd, cmd, &struct) whose struct has an inner output-buffer pointer (a query/info ioctl), or for syscalls whose argument struct embeds pointers (kacs_access_check-style). Each pointed-at buffer is returned post-call in out_bufs at its own index.
-- ioctl(fd, QUERY, &args) where struct kacs_query_args { u32 class; u32 buf_len; u64 buf_ptr; }
-- buf_ptr (offset 8) must point at the output buffer.
local args = string. -- buf_ptr filled in by nested
local out = string.
local r = vm:
local data = r. -- the QUERY output buffer
Returns a table:
| Field | Type | Description |
|---|---|---|
ret | int | Syscall return value. |
result | int | Same as ret (alias for clarity). |
errno | int | Errno if the syscall returned negative; 0 otherwise. |
out_bufs | array of strings | Post-syscall contents of the supplied bufs (including nested-pointer target buffers). Useful for read-style syscalls. |
The same bufs / ptrs / nested options are available on worker:syscall.
vm:ioctl(fd, cmd, data?, opts?) #
Direct ioctl(2). fd and cmd are integers. data is an optional byte string passed as the third arg's pointer. opts.bufs and opts.ptr_offsets work like :syscall's bufs / ptrs.
Returns {ret, result, out_data, out_bufs}. out_data is the post-call value of data (for ioctls that write back through the same buffer).
Hypervisor resources #
vm:nic(name) #
Returns a Nic userdata for the named bridge attachment, or for a guest-style interface name (eth0, enp0s3, ens3). Guest-name lookup maps to the Nth attached bridge sorted by bridge name.
vm:disk(id) #
Returns a Disk userdata for an already-attached disk. Errors if no disk with that id.
vm:attach_disk(opts?) #
Attaches a new disk and returns a Disk userdata. Opts: {id=string, size=int, image=string}. Defaults: id = attached-<vm_name>, size = 4 GiB, image = no backing file.
Console and clock #
vm:console() #
Returns a Console userdata.
vm:clock() #
Returns a Clock userdata.
Workers #
vm:spawn_worker(opts?) #
Spawns a sub-agent connection so test code can dispatch concurrent ops against the same guest. Returns a Worker userdata. Opts are reserved for future use; passing {thread=true} errors with a future-work pointer.
Batch operations #
vm:batch(fn) #
Collect multiple ops inside fn(b) and dispatch them as one wire round-trip. b exposes:
b:run(...)(with the same shape asvm:run)b:read_file(path)b:write_file(path, data)b:stat(path)b:listdir(path)b:mkdir(path, opts)b:unlink(path)b:rename(from, to)b:syscall(nr, a, b, …)— integer-args form only; the table form is rejected here.
Returns a Lua array of {ok=value} or {err=msg} entries, one per op. A failure on op N does not short-circuit the rest of the batch.
local results = vm:
-- results = {{ok=nil}, {ok=nil}, {ok="1"}, {err="No such file or directory"}}
Accessors #
vm:name() — Returns the VM's name as given to lab:vm. #
vm:profile() — Returns the profile name. #
vm:state() — Returns "created", "booted", "paused", "shutdown", or "dead". #
vm:cid() — Returns the assigned vsock CID, or nil before boot. #
vm:is_quiescent() — true if no in-flight ops, no open files or streams. #
vm:open_file_count() — Number of files currently open through the agent. #
vm:open_stream_count() — Number of streams currently active (tails, fd-streams, console-reads, captures). #
RunResult #
The return value of vm:run(...) (and worker:run(...), proc:wait()).
Fields:
| Field | Type | Description |
|---|---|---|
exit_code | int | Exit code. -1 for signalled, -2 for timed_out. Use signal and timed_out to disambiguate. |
stdout | string | Captured stdout. |
stderr | string | Captured stderr. |
status | string | "exited", "signalled", "timed_out". |
timed_out | bool | true if the timeout fired. |
signal | int or nil | Signal number when status is signalled, else nil. |
Methods:
result:ok()— convenience forresult.exit_code == 0.result:assert_ok()— raises if not OK; the error message includes status, stdout, and stderr (with signal name when signalled).