| name | vm-spec |
| description | Go type reference for VmSpec and the discriminated-union source types
(VmSource cloud_image | bootc | clone | imported | bootstrap). Documents every
field, validation rules, and the adopt-user decision. Source files:
charly/spec/cue_types_gen.go (generated), charly/schema/vm.cue, charly/vmshared/.
MUST be invoked before editing VmSpec Go code or authoring vm.yml entries.
|
vm-spec
Go type reference for the VM surface. VmSpec + VmSource + VmChecksum + VmNetwork + VmSSH + VmKeyInjection + VmCloudInit + VmCharlyInstall are the canonical types that drive charly vm build, charly vm create, and charly bundle add vm:<name>. This skill is the authoritative Go reference — field semantics, defaults, validation rules, migration history. The YAML-authoring companion is /charly-vm:vms-catalog.
Source files
| File | Contents |
|---|
charly/spec/cue_types_gen.go (generated; charly-name aliases in spec/charly_names.go) | VmSpec (= Vm), VmSource, VmChecksum, VmNetwork, VmSSH, VmKeyInjection |
charly/spec/cue_types_gen.go (generated) | VmCloudInit, VmCloudInitUser, VmCloudInitFile, VmCloudInitNetwork, VmCloudInitMirrors, VmCharlyInstall |
charly/libvirt_yaml.go | LibvirtDomain + 30+ sub-types (features, CPU, clock, devices, etc.) |
charly/schema/vm.cue + cue_kind_vm.go | #Vm — the closed CUE schema validating VmSpec + the #LibvirtDomain/#VmCloudInit subtrees (registered in the per-kind CUE registry; the Go VM/libvirt validators were deleted) |
VmSpec (top-level shape)
type VmSpec struct {
Source VmSource
DiskSize string
Ram string
Cpus int
Machine string
Firmware string
Backend string
Autostart bool
Network *VmNetwork
SSH *VmSSH
CloudInit *VmCloudInit
Libvirt *LibvirtDomain
}
Every field except Source, CloudInit, and Source-branch-specific subfields applies equally to both source kinds — the parity guarantee. Anything configurable for cloud_image VMs is configurable for bootc VMs, and vice versa.
Autostart (boot-start)
autostart: true sets libvirt's per-domain autostart flag (DomainSetAutostart, in runVmSpecCreate after define). Because VMs run under qemu:///session, that flag only fires at host boot once the session daemon is running — and there is no portable user-level virtqemud.socket to socket-activate it (Arch/CachyOS ships none). So ensureBootAutostartPrereqs (charly/vm.go) (a) runs loginctl enable-linger <user> (idempotent) and (b) writes + enables a per-VM user systemd oneshot charly-autostart-<domain>.service that runs virsh -c qemu:///session start <domain> at boot (WantedBy=default.target); virsh spawns the session daemon on demand and starts the already-defined domain — deterministic and cross-distro. charly vm destroy removes the unit (removeAutostartUserUnit). The libvirt flag is a domain property (not XML), so it survives DomainDefineXML redefinitions; runVmSpecCreate re-asserts both on every create/rebuild. The #Vm CUE schema rejects autostart: true with backend: qemu (if autostart { backend: "auto" | "libvirt" }). Additive optional field — no schema-version bump.
DiskSize is the virtual size: the bootstrap path's truncate + qemu-img convert -O qcow2 (no preallocation) produces a sparse qcow2 that grows on demand, so disk_size: 1T costs only the bytes actually written.
virtiofs shares — guest-user idmap
A libvirt.devices.filesystems[] entry with driver: virtiofs +
accessmode: passthrough + a host-path source is auto-given a guest-user
<idmap> at render time so the share is owned by the guest's interactive user
(uid 1000), not guest-root. This is what makes source: /home/<you> →
target: workspace usable as the SSH user inside the guest. Mechanism +
the exact id partition live in /charly-internals:libvirt-renderer "virtiofs
guest-user idmap"; shared-memory auto-pairing is in the same skill.
VmSource (discriminated union)
type VmSource struct {
Kind string
URL string
Checksum VmChecksum
Cache string
BaseUser string
Image string
Transport string
Rootfs string
RootSize string
KernelArgs string
}
Kind is the discriminator. The #VmSource CUE disjunction (schema/vm.cue) enforces that exactly one branch's required fields are populated and forbids cross-branch fields (each arm pins kind and marks the others _|_).
Adopt-user decision (BaseUser)
BaseUser mirrors the container-side base_user: + user_policy: adopt pattern. When set:
/charly-internals:cloud-init-renderer::composeUsers emits users: [default, {name: <base_user>, ssh_authorized_keys: [...]}] — merge-by-name, no useradd.
spec.ssh.user defaults to BaseUser.
- cloud-init appends the pubkey to
~<base_user>/.ssh/authorized_keys without touching sudoers/shell/home.
Common values: arch (Arch cloud image), ubuntu (Ubuntu Cloud), fedora (Fedora Cloud), debian (Debian Cloud), cloud-user (CentOS Cloud).
Leave empty only when the image has no default account — then declare a custom user in spec.cloud_init.users.
VmSSH / VmKeyInjection
type VmSSH struct {
User string
Port int
KeySource string
KeyInjection *VmKeyInjection
}
type VmKeyInjection struct {
SMBIOS string
CloudInit string
}
Dual-channel key injection is additive. Per-source-kind auto-defaults when KeyInjection is nil:
cloud_image → {smbios: enabled, cloud_init: enabled} — belt + suspenders; cloud-init seed ISO is always emitted anyway.
bootc → {smbios: enabled, cloud_init: disabled} — cloud-init seed ISO only emits when the guest has the cloud-init layer.
Having both channels on simultaneously is the safe default; there's no duplication cost at the guest (systemd-ssh-generator dedups entries in authorized_keys).
VmCloudInit (structured intent)
The renderer combines structured fields with renderer defaults:
Packages: prepended with {openssh, curl, tar}.
RunCmd: prepended with {systemctl enable --now sshd} so distro-specific setup can assume sshd is running.
Users: the VmSSH.User account is auto-injected unless already present; if present, the renderer appends the ssh pubkey to the user's existing entry.
Extra is a raw-YAML escape hatch merged after structured fields. Prefer structured fields; Extra exists for long-tail cloud-init options the schema doesn't cover.
VmCharlyInstall strategies
type VmCharlyInstall struct {
Strategy string
URL string
Checksum string
}
| Strategy | Behavior |
|---|
auto (default) | scp the local charly binary (os.Executable()) into the guest post-boot via the vm lifecycle hook's PrepareVenue (EnsureCharlyInGuest) |
scp | explicit form of auto |
url | cloud-init runcmd downloads charly from URL at first boot |
skip | user manages charly install; the vm lifecycle hook's PrepareVenue verifies presence only |
Validation (charly/schema/vm.cue)
The closed #Vm CUE schema (registered via cue_kind_vm.go) enforces every VmSpec
invariant — there is no Go VM validator. Key checks:
source.kind ∈ {cloud_image, bootc, clone, imported, bootstrap}; the
#VmSource disjunction requires each arm's fields and forbids cross-arm fields.
firmware: ∈ {bios (default), uefi-insecure, uefi-secure}; uefi-secure ⇒
machine ≠ i440fx AND requires an explicit libvirt.features.smm: true.
network.mode: ∈ {user (default), bridge, nat, network}; bridge ⇒ bridge: set.
ssh.port ⊕ ssh.port_auto (mutually exclusive); ssh.key_source: ∈ {auto,
generate, none} or an absolute path; ssh.key_injection.{smbios,cloud_init} ∈
{auto, enabled, disabled}.
- the
#LibvirtDomain subtree is modeled + closed in the same schema (enums/ranges/
PCI-hex + cpu.mode:custom ⇒ model); see /charly-internals:libvirt-renderer.
Failed validation → hard load-time error (the closed schema also rejects unknown
keys/typos); charly box validate runs the full concrete check.
Migration from legacy VmConfig
The legacy VmConfig type + BoxConfig.Vm + BoxConfig.Libvirt + ResolvedBox.Vm + LabelVm + LabelLibvirt were all deleted in the hard cutover. Field mapping for forensic purposes:
| Legacy location | New location |
|---|
box.bootc: true + box.vm.disk_size | vms.<name>.source.kind: bootc + vms.<name>.disk_size |
box.vm.ssh_port | vms.<name>.ssh.port |
box.vm.ram, .cpus, .rootfs, .root_size, .kernel_args | vms.<name>.ram, .cpus, source.rootfs, source.root_size, source.kernel_args |
box.vm.firmware | vms.<name>.firmware |
box.vm.network (string) | vms.<name>.network.mode |
box.libvirt: ["<xml>", …] (list of strings) | vms.<name>.libvirt.snippets: […] + structured libvirt.devices.* |
charly migrate performs this mapping idempotently. See /charly-build:migrate for the command and /charly-internals:cutover-policy for the policy.
Cross-References
/charly-vm:vms-catalog — YAML-authoring companion (when to pick cloud_image vs bootc, adopt pattern, step-by-step recipes)
/charly-internals:libvirt-renderer — LibvirtDomain rendering + pure render functions
/charly-internals:cloud-init-renderer — RenderCloudInit, composeUsers, seed ISO
/charly-internals:vm-deploy-target — the external vm deploy + the vmSubstrateLifecycle hook consuming VmSpec via DeployExecutor
/charly-internals:ovmf — ResolveOvmfForSpec reads spec.Firmware
/charly-internals:cutover-policy — why the legacy surface was deleted in one PR
/charly-vm:vm — command-family skill; reads vm.yml through VmSpec
/charly-build:migrate — charly migrate command
/charly-internals:go — Go CLI development overview