vms-catalog
Recipe card from the charly-vm plugin (Kinds — schema authoring).
vm.yml is the authoring surface for kind: vm entities — VM primitives that pair with either a remote cloud-image URL (source.kind: cloud_image) or an in-repo bootc container image (source.kind: bootc). Loaded through charly.yml’s import: (or inline under its root). Entries are resolved by LoadUnified into VmSpec Go types (generated in sdk/spec/) and consumed by charly vm build, charly vm create, and charly bundle add vm:<name>.
The VM surface parallels the candy: image surface: one YAML entry per entity, kind-keyed, discovered through includes. The Go types that back it live in /charly-internals:vm-spec; the rendering paths in /charly-internals:libvirt-renderer and /charly-internals:cloud-init-renderer.
File layout
Section titled “File layout”# vm.yml — name-first nodes (a per-kind split file loaded via charly.yml `import:`, or inline under its root)<name>: vm: source: kind: cloud_image | bootc # cloud_image branch: url: https://… checksum: { type: sha256, value?: <hex> } base_user: arch # adopt this account (see Adopt pattern below) cache: ~/.cache/charly/vm-images/ # optional override # bootc branch: box: <candy: image entry name> # `box:` source field → a `candy:` image carrying base:/from: transport: registry | containers-storage | oci | oci-archive rootfs: ext4 | xfs | btrfs root_size: 10G # optional cap; rest of disk stays unpartitioned kernel_args: "…" # Hardware (both branches): disk_size: 20G | "10 GiB" | 1T # VIRTUAL size; qcow2 is lazily/sparsely allocated (grows on demand) ram: 4G | "8192M" cpu: 4 # yaml key is singular `cpu` (VmSpec.Cpus) machine: q35 | virt | i440fx # default: host-native firmware: bios | uefi-insecure | uefi-secure # default: bios backend: auto | libvirt | qemu # default: auto autostart: true # start at host boot (libvirt only — see below) # Network: network: mode: user | bridge | nat bridge: br0 # only when mode=bridge mac: "52:54:…" # optional pin; default stable-from-name port_forwards: ["8080:80", "auto:6443"] # "<host>:<guest>", additive to SSH forward; host may be `auto` → free host port auto-allocated at vm-create + persisted in vm_state (the sibling of ssh.port_auto; use it so concurrent beds sharing one entity never collide) # SSH + key injection: ssh: user: arch | root | … # defaults: cloud_image→"charly", bootc→"root" port: 2222 # host port → guest :22 key_source: auto | generate | none | /abs/path.pub key_injection: smbios: auto | enabled | disabled cloud_init: auto | enabled | disabled # Cloud-init (structured intent): cloud_init: timezone: UTC packages: [sudo, spice-vdagent, …] runcmd: ["…", …] charly_install: strategy: auto | none # Libvirt XML knobs (see [/charly-internals:libvirt-renderer](/recipes/internals/libvirt-renderer/)): libvirt: devices: channels: [{type: unix, name: org.qemu.guest_agent.0}] # qemu-guest-agent graphics: […] video: [{model: virtio, vram: 65536, heads: 1, accel3d: false}] rng: [{model: virtio, backend: /dev/urandom}] memballoon: {model: virtio} hostdevs: […] # PCI passthrough (charly vm gpu list) filesystems: # virtiofs/9p host↔guest shares - {driver: virtiofs, accessmode: passthrough, source: /home/me, target: workspace} # memory_backing is auto-paired (memfd + shared) for any virtiofs shareautostart — start the VM at host boot
Section titled “autostart — start the VM at host boot”autostart: true sets libvirt’s domain autostart flag. Because charly VMs run under
qemu:///session (no portable user-level virtqemud.socket to socket-activate at
boot), charly vm create also enables loginctl enable-linger (idempotent) and
writes + enables a per-VM user oneshot charly-autostart-<domain>.service that
virsh -c qemu:///session starts the domain at boot (charly vm destroy removes it).
Requires backend: libvirt — validation rejects autostart: true with
backend: qemu. Reapplied on every charly vm create / charly update. Pair with a
1 TB+ disk_size freely — the qcow2 is sparse, so a large virtual disk costs only
the bytes written.
filesystems — virtiofs host directory shares
Section titled “filesystems — virtiofs host directory shares”libvirt.devices.filesystems[] exposes a host directory inside the guest:
{driver: virtiofs, accessmode: passthrough, source: <host dir>, target: <tag>}.
The shared-memory backing virtiofs requires (memfd/shared) is auto-paired
— you don’t declare memory_backing yourself. The guest mounts the target: tag
with the /charly-distros:workspace-mount layer (a systemd .mount unit for the
workspace tag → /workspace) or any mount -t virtiofs <tag> <dir>.
graphics listen: field — three accepted shapes
Section titled “graphics listen: field — three accepted shapes”graphics[].listen is how you control the <listen> children of a
<graphics> element. Three equivalent shapes, all unmarshal to the same
internal list:
# (1) Scalar address (shorthand for one TCP listener):listen: 127.0.0.1
# (2) Single map (explicit type control — socket | address | network):listen: type: socket # libvirt auto-allocates the UNIX socket path# or:listen: type: address address: 127.0.0.1
# (3) List of maps (multiple listeners on one <graphics>):listen: - type: socket - type: address address: 127.0.0.1Prefer type: socket for charly-managed VMs. virt-manager and
remote-viewer --connect qemu+ssh://… auto-forward UNIX sockets over
the libvirt RPC channel — GUI clients work out of the box against a
remote libvirt with zero ssh -L setup. TCP loopback listeners are
never auto-tunneled, by design. See /charly-vm:arch-cloud-vm
“Connecting from a remote workstation”.
source.kind: cloud_image
Section titled “source.kind: cloud_image”Use when the VM is built from an externally published qcow2 (Arch cloud image from pkgbuild.com, Fedora Cloud, Ubuntu Cloud, Debian Cloud, CentOS Cloud, etc.). The build pipeline fetches the URL, integrity-checks it via sha256 (sidecar auto-resolved when checksum.value is empty), creates a qcow2 overlay at spec.disk_size, renders a NoCloud seed ISO, and hands off to libvirt/QEMU.
Canonical example: /charly-vm:arch-cloud-vm. Only existing cloud_image VM in the repo — read it before authoring another one. It documents the non-obvious decisions learned the hard way:
- BIOS firmware is usually the right default. Distribution cloud images ship both a BIOS boot partition and an EFI System Partition, but the ESP’s bootloader binary often has an embedded grub.cfg that predates the maintainer’s latest
/etc/default/grub(Arch’s upstream issue withfbcon=nodefer). BIOS boot reads/boot/grub/grub.cfgdirectly from the root fs, which is always current. - virtio-gpu, not QXL. See
/charly-internals:libvirt-renderer“video model choice” — virtio-gpu is the modern default for Linux guests. - Generous resource sizing.
pacman -S spice-vdagentpulls in GTK3 + X11 (~200 MB download, ~1 GB installed); running at 2 GiB RAM stalls cloud-init. Size for the workload: 8 GiB / 4 cpus is reasonable for a workstation-class dev VM.
Authoring a new cloud_image VM
Section titled “Authoring a new cloud_image VM”- Find the upstream qcow2 URL + verify a sha256 sidecar exists (
.SHA256 / .sha256 / .sha256sum). - Identify the pre-existing user account in the upstream image (
arch,ubuntu,fedora,debian,cloud-user, etc.). This becomessource.base_user:— triggers the adopt pattern described below. - Start from
/charly-vm:arch-cloud-vmas a template. Changeurl,base_user, distro-specific cloud_initpackage:andruncmd:. - Pick firmware: default to
biosunless the upstream image explicitly requires UEFI (e.g., secure boot lock-in). - Run
charly vm build <name>— observe the fetched qcow2 sha256 + rendered seed ISO path. - Run
charly vm create <name>+charly vm ssh <name>to verify cloud-init completed.
source.kind: bootc
Section titled “source.kind: bootc”Use when the VM is built from an in-repo bootc container image (a candy: image entry with bootc: true). charly vm build runs bootc install to-disk --via-loopback inside a privileged container to produce the qcow2/raw disk.
No bootc VM ships in the repo today; a bootc vm: node pairs a candy: image carrying bootc: true with the VM hardware spec. By convention a -bootc suffix marks the bootc VM entity (distinguished from an equivalent container-form deploy by the vm: kind key, not by the name).
Authoring a new bootc VM
Section titled “Authoring a new bootc VM”- Ensure the paired container image has
bootc: truedeclared and builds cleanly. - Add a
vm:node withsource.kind: bootc+source.box: <entry-name>(the bootc source field isbox:, notimage:). - Size disk/ram/cpus for the workload (see the relevant per-pod or
/charly-distros:<name> / /charly-languages:<name> / /charly-infrastructure:<name> / /charly-tools:<name>skill’s VM Configuration section for the authoritative numbers). - Run
charly vm build <vm-name>. See/charly-vm:vmknown-caveats section for bootc-specific gotchas (rootful storage split, nested-container--transport containers-storage, loopback device mount namespace).
Adopt pattern: base_user (cloud_image only)
Section titled “Adopt pattern: base_user (cloud_image only)”Mirrors the container-side base_user: + user_policy: adopt pattern documented in /charly-image:image “user_policy”. The key insight: don’t recreate accounts cloud-init already shipped — just append the SSH pubkey and move on.
When source.base_user: is set, the cloud-init renderer ([/charly-internals:cloud-init-renderer](/recipes/internals/cloud-init-renderer/)::composeUsers) emits a merge-by-name entry:
# Rendered user-datausers: - default - name: <base_user> ssh_authorized_keys: - ssh-ed25519 AAAA…cloud-init interprets users: [default, {name: <base_user>}] as “keep the distro’s default account untouched, append SSH key to the named account”. Result: no useradd, no sudoers rewrite, no shell change, no home-directory relocation — just the pubkey lands in ~<user>/.ssh/authorized_keys on first boot.
spec.ssh.user defaults to source.base_user, so charly vm ssh <name> connects as the adopted account without extra declaration.
Leave base_user: empty only when the upstream has no default account — in which case author a full custom user entry in cloud_init.users: with sudo/groups/shell fields. Don’t do this when adopting works; useradd-at-first-boot races with other cloud-init modules and is harder to reason about.
charly_install.strategy: auto (cloud_image)
Section titled “charly_install.strategy: auto (cloud_image)”charly_install.strategy: auto in cloud_init: wires charly’s in-guest installer (/charly-internals:cloud-init-renderer → emitted runcmd: entries) so the provisioned VM comes up with charly already installed. Lets charly bundle add vm:<name> apply host-deploy-style layer recipes inside the VM over SSH without a bootstrap round-trip. See /charly-internals:cloud-init-renderer for the emission + handshake.
strategy: none skips the step entirely — useful when the VM will be managed by something other than charly after provisioning.
Validation rules
Section titled “Validation rules”Load-time errors raised by the closed #Vm CUE schema (sdk/schema/vm.cue, see /charly-internals:vm-spec):
source.kindmust be one ofcloud_image,bootc,clone,imported,bootstrap; each arm requires its own fields and forbids the others’.cloud_imagerequiresurl:;bootcrequires thebox:source field resolving to acandy:image entry (carryingbase:/from:);clonerequiresfrom_vm:+from_snapshot:;importedrequireslibvirt_name:+disk_path:+disk_format:;bootstraprequiresbuilder:+distro:.firmware:must be one ofbios,uefi-insecure,uefi-secure;uefi-secureadditionally requires an explicitlibvirt.features.smm: true.network.mode:must be one ofuser,bridge,nat,network.ssh.key_source:must parse asauto,generate,none, or an absolute path;ssh.portandssh.port_autoare mutually exclusive.ssh.key_injection.smbios/.cloud_initmust be one ofauto,enabled,disabled.- the
libvirt:subtree is modeled + closed by#LibvirtDomainin the same schema — unknown keys and bad enums fail fast.
Migration from legacy (box.bootc / box.vm / box.libvirt)
Section titled “Migration from legacy (box.bootc / box.vm / box.libvirt)”Projects predating this schema had three coupled fields on image entries (candy: nodes carrying base:/from:): bootc: true, vm: {...}, libvirt: [...]. All three were deleted in the hard cutover, and the step that harvested them into VM nodes lived in the migration chain that was removed at the 2026.186.2323 migration-baseline reset. So charly migrate no longer converts those legacy fields: a config still carrying them predates the supported schema floor (2026.174.1100) and is unmigratable — charly migrate refuses it (predates the supported floor … re-author against the current schema) and changes nothing on disk. Re-author such a VM by hand as a name-first <name>: {vm: {…}} node (the shape this skill documents).
For a config already within the migratable window ([floor, HEAD)), charly migrate is one idempotent command that brings it to the current schema. See /charly-build:migrate for the floor/HEAD gate and the full command reference, and /charly-internals:cutover-policy for why hard-cutover was the chosen policy.
Cross-References
Section titled “Cross-References”/charly-vm:vm— thecharly vm build/create/start/stop/ssh/consolecommand family/charly-build:migrate—charly migrateconversion from legacy/charly-core:deploy—charly bundle add vm:<name>for in-guest layer application/charly-vm:arch-cloud-vm— canonical cloud_image VM/charly-internals:vm-spec— Go type reference/charly-internals:libvirt-renderer— libvirt XML emission/charly-internals:cloud-init-renderer— NoCloud seed ISO + user-data emission/charly-internals:ovmf— UEFI firmware path resolution (whenfirmware:≠bios)/charly-internals:vm-deploy-target— the external vm deploy in the InstallPlan pipeline/charly-internals:cutover-policy— Hard Cutover by Default policy/charly-distros:cloud-init— guest-side cloud-init layer (pairs with host-sidecloud_init:emission)/charly-distros:qemu-guest-agent— virtio-serial channel for host↔guest comms
Live-deploy verification: see /charly-check:check (the 10 Testing Standards) and /charly-internals:disposable.