spice
Recipe card from the charly-check plugin (Commands — runtime CLI verbs).
SPICE — VM display-protocol check verb
Section titled “SPICE — VM display-protocol check verb”Overview
Section titled “Overview”spice: is a DECLARATIVE check verb — authored as spice: <method> inside a
candy/box plan check:/run: step. There is no host charly check spice
command. The SPICE-wire implementation (and the upstream SPICE client
library + its cgo opus/portaudio audio transitives) was dep-shed into the
out-of-tree candy/plugin-spice plugin module; at check time the host
dispatches spice: through the provider registry to that out-of-process
plugin (the same path a bed’s checks take via charly check live / charly check run). This mirrors the adb: / appium: / kube: externalizations
(see charly/check_cmd.go).
The verb proves the SPICE server is speaking the protocol correctly on the
wire — not just that the TCP port is open: main-channel handshake, auth,
channel enumeration, native display-channel image decode, and input injection.
VM-only — it needs a running libvirt VM that exposes a <graphics type='spice'>
device.
The plugin resolves its endpoint via a reverse-leg; then speaks the wire
Section titled “The plugin resolves its endpoint via a reverse-leg; then speaks the wire”charly core owns NO go-libvirt. The out-of-process spice plugin resolves its own
dialable endpoint through the GENERIC cc.ResolveGraphicsEndpoint("spice")
reverse-leg; the host side of that leg (resolveVerbGraphics in
charly/check_graphics_endpoint.go — a permanent core STAY on its own x/crypto/ssh
containment reason, a DIFFERENT file from the endpoint-resolve reverse-leg
charly/check_endpoint_resolve.go, whose resolution bodies relocated to
candy/plugin-check under the compiled-in-REQUIRED placement class, #55 W3 B7)
DELEGATES the vm.yml → libvirt-domain →
live-XML → <graphics type='spice'> resolution to the out-of-process vm plugin
(invokeVmPlugin("resolve-spice", …) → candy/plugin-vm’s ResolveVmTarget /
SpiceEndpoint, where the go-libvirt deps live), passing the resolved per-deploy DOMAIN
IDENTITY (Runner.vmTargetName() — the deploy name, not the shared kind:vm entity, P33)
as the domain target. The host opens any qemu+ssh://
side tunnel itself (tracked for post-Invoke teardown) and returns a plain DIALABLE
endpoint (+ the SPICE ticket); the spice plugin just dials it and runs the method.
Authoring the spice: verb in a plan step
Section titled “Authoring the spice: verb in a plan step”The verb is one inline Op carried by a step — an ordered list item under the
candy/box plan: (a display/handshake probe is a check: step; an input action
that changes guest state is a run: step). The method name is the scalar value for
a bare-method step (spice: status), or the method: key of the spice: map when
the step carries spice-exclusive fields — those live INSIDE the spice: map:
| Method | Declarative form | Map fields | Description |
|---|---|---|---|
status |
spice: status |
— | handshake + channel enumeration (first line SPICE: ok) |
screenshot |
spice: {method: screenshot, artifact: …} |
artifact: |
native SPICE display-channel decode → PNG |
cursor |
spice: {method: cursor, artifact: …} |
artifact: |
capture cursor bitmap + position → PNG |
record |
spice: {method: record, action: start|stop, fps?} + record_name?/artifact:` on stop |
artifact: + validators |
HOST-SIDE MJPEG video capture of the VM display: start polls the SPICE display framebuffer at fps (default 5), encoding every poll as a JPEG frame into a single MJPEG stream; stop flushes it to the host artifact (pure-Go; limits: server video streams/GLZ are not decoded by the vendored client, audio stubbed) |
click |
spice: {method: click, x: …, y: …} |
x:, y:, button: |
mouse press/release via the inputs channel |
mouse |
spice: {method: mouse, x: …, y: …} |
x:, y: |
pointer move (no click) |
type |
spice: {method: type, text: …} |
text: |
type text as PC-AT scancodes |
key |
spice: {method: key, key: …} |
key: |
press one named key (Return, Escape, F2, …) |
The artifact validators (artifact_min_bytes:, artifact_min_dimensions:,
artifact_not_uniform:) are spice-exclusive too, so they live inside the spice:
map; only the shared matchers (stdout:, stderr:, exit_status:) and
context:/timeout:/id: stay siblings of the spice: key. context: [deploy]
— the verb needs a running VM; under charly check box (no running VM) it
skips, and a SPICE-less deployment (e.g. a GPU desktop with no <graphics type='spice'> device) is reported N/A SKIP.
The method-name enum (status/screenshot/cursor/click/mouse/type/key)
and every spice modifier live in the plugin’s OWN input schema
(candy/plugin-spice/schema/spice.cue, #SpiceInput), served over the Describe
channel and spliced onto the base for validation — so authoring is UNCHANGED from a
built-in verb (spice: status, not plugin: spice); the internal
plugin/plugin_input wire envelope the sugar desugars to is never authored.
Example — each step is an ordered list item under the candy/box plan::
- check: the SPICE server completes the handshake and enumerates channels id: spice-handshake spice: status context: [deploy] stdout: - contains: ok - contains: "inputs: ready"- check: the SPICE display channel decodes a non-uniform framebuffer id: desktop-rendered spice: method: screenshot artifact: /tmp/spice-shot.png artifact_not_uniform: true context: [deploy]Input injection is authored as run: steps (each one mutates guest state) — a
console login sequence walks the form with spice: key / spice: type steps:
- run: type the username and password at the console id: drive-login spice: method: key key: return context: [deploy]# … followed by `spice: {method: type, text: arch}` and `spice: {method: key, key: tab}`# steps to walk the login form.
### Capturing the display as video (`record`)
```yaml- check: SPICE video recording starts on the VM display id: spice-rec-start context: [deploy] spice: method: record action: start record_name: vm-walk fps: 5- check: a desktop-visible action runs during the capture id: spice-rec-drive context: [deploy] command: "notify-send 'demo' 'capturing' 2>/dev/null || true; sleep 3"- check: the recording stops and a real MJPEG stream lands on the HOST id: spice-rec-stop context: [deploy] spice: method: record action: stop record_name: vm-walk artifact: /tmp/demo.mjpeg artifact_min_bytes: 20000The artifact is pulled host-side at stop BEFORE the disposable bed’s teardown
destroys the venue (copy-before-teardown); reality-check with ffprobe (frame count)
and a frame extract (ffmpeg -i <file>.mjpeg -frames:v 1 out.png).
## Remote libvirt (qemu+ssh://)
A `spice:` step can target a VM on a remote libvirt host. Set`CHARLY_LIBVIRT_URI=qemu+ssh://[user@]host/session` (the former `--uri` flagcarried this same env): the host-side pre-resolver runs locally, discovers theremote SPICE endpoint, and opens the side tunnel transparently — libvirt RPCrides the SSH control channel; the SPICE display channel gets a dedicatedforward.
- For VMs that declare `<listen type='socket'/>` (the arch default after the socket-listen cutover), the host forwards the UNIX socket.- For TCP-listener VMs, the host opens a `127.0.0.1:<random>` forward.
GUI clients (virt-manager, `remote-viewer --connect qemu+ssh://…`) don't needany charly involvement for socket listeners — they auto-forward via libvirtRPC fd-passing. See [`/charly-vm:arch-cloud-vm`](/recipes/vm/arch-cloud-vm/) "Connecting from a remote workstation"for the complete story.
## What it does (and doesn't)
- **Speaks SPICE on the wire.** Main channel handshake, auth (None or SPICE_TICKET), channel enumeration (Display/Inputs/Cursor/Playback/ Record/Webdav), display channel with native QUIC/GLZ/LZ/LZ4 image decode, input channel for key/mouse events.- **Endpoint resolved host-side.** The host loads vm.yml, finds the running libvirt domain, parses live XML via `libvirtxml.Domain`, and extracts the SPICE host/port/passwd from the `<graphics type='spice'>` element (honoring autoport='yes') before handing the plugin a dialable endpoint. The operator escape-hatches the former CLI exposed (`--address`, `--socket`, `--password`) are NOT part of the declarative verb — the endpoint comes from the bed's VM context.- **Native SPICE screenshot.** `spice: screenshot` is a native SPICE display-channel decode (NOT libvirt `DomainScreenshot`) — it proves the SPICE display path renders pixels end-to-end. `type`/`key` emit PC-AT scancodes (the friendly-keyname → scancode table lives in the plugin).- **Not implemented.** Audio playback/record (no user story) — the plugin is built WITHOUT `-tags spice_audio`, so the upstream library's cgo opus/portaudio audio channels are not linked. Complex agent-channel operations (clipboard, resolution change); the upstream library exposes them but the verb doesn't wrap them yet — use the `libvirt:` verb (`libvirt: passwd`, `libvirt: guest/exec`) for the corresponding management ops.
## Architecture split (vs. the `libvirt:` verb)
The verbs are single-protocol by design:
- **`spice:`** — every byte flows through the SPICE wire. Use when the thing under test is "is SPICE itself healthy?".- **`libvirt:`** — every call goes through libvirtd RPC. Use when the thing under test is "is the VM working?" (framebuffer capture, keyboard injection, snapshots, domain state). The `libvirt:` verb is likewise a declarative check verb served out-of-process — by `candy/plugin-vm` (see [`/charly-check:libvirt`](/recipes/check/libvirt/)).
For input testing, prefer `spice: type`/`key`/`click` to prove the SPICE wiredelivers input to the guest. For display testing, compare `spice: screenshot`against `libvirt: screenshot` — if both render the same pixels, the SPICEserver + the guest framebuffer agree.
## Implementation
The `spice:` verb and its SPICE-wire client live in the out-of-tree`candy/plugin-spice` plugin module (an external-charly-verb plugin), NOT incharly's core (which carries no SPICE library and no opus/portaudio cgo deps).
- `candy/plugin-spice/provider.go` — the out-of-process verb provider: it dials the host-pre-resolved endpoint, dispatches the method, then self-evaluates the stdout/stderr/exit_status matchers + the artifact validators itself.- `candy/plugin-spice/session.go` / `methods.go` — the connection wrapper over the SPICE library's `Connector`/`Driver` interfaces and the per-method implementations.- `candy/plugin-spice/third_party/spice` — the vendored Shells-com/spice library, built without the audio tag (`ch-audio-stub.go`).- `candy/plugin-spice/schema/spice.cue` — the plugin's served CUE schema: the `#SpiceInput` def carries the method enum + every spice modifier, served over the Describe channel and spliced onto the base for authored-input validation.
Host side:
- `charly/check_graphics_endpoint.go` — `resolveVerbGraphics("spice")`, the host side of the `cc.ResolveGraphicsEndpoint` reverse-leg: delegates vm.yml → libvirt domain → live XML → SPICE endpoint to the vm plugin, opens any qemu+ssh:// side tunnel, and returns a dialable endpoint (+ ticket) to the spice plugin. Stays in core (it owns no go-libvirt, and directly imports spec/sshx — x/crypto/ssh containment); shared with `vnc:` (the venue-aware vnc/spice resolution is one function). NOT `charly/check_endpoint_resolve.go` — that is the DIFFERENT, sibling reverse-leg file whose resolution bodies relocated to `candy/plugin-check/resolve_endpoint.go` (#55 W3 B7).- `candy/plugin-vm/vm_target.go` — the OUT-OF-PROCESS VM target resolution (`ResolveVmTarget` / `SpiceEndpoint`, go-libvirt); `VmTarget.XML` gives the live `libvirtxml.Domain`. The host reaches it via `invokeVmPlugin("resolve-spice", …)`, passing `Runner.vmTargetName()` (the resolved per-deploy DOMAIN IDENTITY — the deploy name via `vmDomainIdentity`, not the shared `kind:vm` entity, P33; the plugin prefixes `charly-`).- The registry dispatch: `providerRegistry.ResolveVerb("spice")` → the out-of-process `grpcProvider` → `invokeVerbProvider`, which hands the plugin the full `#Op` as params after the host pre-resolves the endpoint.
## Dependencies
These now live in `candy/plugin-spice`, NOT in charly's core:
- `github.com/Shells-com/spice` (MIT) — SPICE client library, vendored under `third_party/spice`.- `github.com/hraban/opus` + `github.com/gordonklaus/portaudio` — cgo audio transitives, indirect and NOT linked (the plugin is built without `-tags spice_audio`).
The VM target resolution (go-libvirt / `libvirtxml`) runs OUT-OF-PROCESS in`candy/plugin-vm/vm_target.go` — the host reaches it via`invokeVmPlugin("resolve-spice", …)`, not a direct core call, and passes theresolved per-deploy DOMAIN IDENTITY (`Runner.vmTargetName()` — the deploy name, not theshared `kind:vm` entity, P33) so the plugin addresses the live domain `charly-<deploy>`,correctly distinct per bed even when several beds share one entity.
## Related skills
- [`/charly-check:libvirt`](/recipes/check/libvirt/) — the sibling declarative `libvirt:` check verb, served out-of-process by `candy/plugin-vm` (libvirt RPC: framebuffer screenshot, send-key, QMP, snapshots, guest agent).- [`/charly-check:check`](/recipes/check/check/) — the unified check system and the Op (a plan step) that holds every verb discriminator + modifier.- [`/charly-vm:arch-cloud-vm`](/recipes/vm/arch-cloud-vm/) — the arch VM that ships SPICE by default; "Connecting from a remote workstation".- [`/charly-internals:plugin`](/recipes/internals/plugin/) — the external-charly-verb plugin model the spice verb follows.
## When to Use This Skill
**MUST be invoked** for any task involving the `spice:` declarative check verb,SPICE protocol debugging, or proving a libvirt VM's SPICE display path is aliveend-to-end. Invoke this skill BEFORE reading the plugin's Go source.