Add KVM provider pure builders: domain XML, volumes, seed ISO, metadata

Introduces internal/provider/kvm with the side-effect-free pieces of the
provider: providerID format/parse, the namespaced <metadata> element that
carries the GC contract, q35 domain XML over pool volumes, volume XML for
overlays and raw seeds, the NoCloud seed ISO built with kdomanski/iso9660,
and config defaults. All unit-tested.

Step 2 of docs/plans/2026-09-04-1404-kvm-provider.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 20:26:17 +00:00
co-authored by Claude Fable 5.1
parent bd633c0288
commit 7aea14fbb3
15 changed files with 972 additions and 13 deletions
@@ -4,7 +4,7 @@ Plan: [2026-09-04-1404-kvm-provider.md](../plans/2026-09-04-1404-kvm-provider.md
- [x] Step 0 — Save plan + branch `feat/kvm-provider` - [x] Step 0 — Save plan + branch `feat/kvm-provider`
- [x] Step 1 — Config (`KVMConfig` & friends, `validate()` case, tests) - [x] Step 1 — Config (`KVMConfig` & friends, `validate()` case, tests)
- [ ] Step 2 — Pure builders (`domainxml.go`, `volume.go`, `seed.go`, `id.go`, `metadata.go`) - [x] Step 2 — Pure builders (`domainxml.go`, `volume.go`, `seed.go`, `id.go`, `metadata.go`)
- [ ] Step 3 — Transport + errors (`conn.go`, `errors.go`) - [ ] Step 3 — Transport + errors (`conn.go`, `errors.go`)
- [ ] Step 3b — Image cache (`image.go`) - [ ] Step 3b — Image cache (`image.go`)
- [ ] Step 4 — Provider methods (`kvm.go`) over a fake API - [ ] Step 4 — Provider methods (`kvm.go`) over a fake API
@@ -52,3 +52,50 @@ in favour of `NewWithDialer(socket.Dialer)`, where `Dialer` is a one-method
`Dial() (net.Conn, error)` interface. Step 3 should wrap the SSH client's unix `Dial() (net.Conn, error)` interface. Step 3 should wrap the SSH client's unix
forward in that interface instead of using `New`; the plan's transport design forward in that interface instead of using `New`; the plan's transport design
is otherwise unaffected. is otherwise unaffected.
## Step 2 — Pure builders
`internal/provider/kvm/` now has the five builders from the plan plus
`kvm.go` holding the package doc and `withDefaults` (disk size 10 GiB, the
default vendor-data, `/var/run/libvirt/libvirt-sock`, pool `default`, image
format `qcow2`). Everything is a pure function over `encoding/xml` structs or
strings, unit-tested by unmarshalling the output back and checking fields.
Two design choices differ from the plan text:
- **Domain disks reference pool volumes, not host paths.** The plan had
`buildDomainXML` take the overlay and seed paths (needing two
`StorageVolGetPath` round-trips per Create). The domain XML now uses
`<disk type="volume"><source pool=... volume=.../>`, so only the *base*
image path is ever looked up (the overlay's `<backingStore>` needs a real
path). Fewer RPCs and the builder's inputs are all names.
- **Capacities are always `unit="bytes"`.** libvirt's `G` means GiB but
`GB` means 10^9; spelling the byte count out removes the ambiguity from
both the overlay and the seed/base volumes.
The metadata element is written with a default namespace
(`<instance xmlns="…/kvm">`) because `encoding/xml` cannot emit prefixes on
marshal. libvirt may hand it back prefixed; `parseInstanceMetadata` is
tested against both spellings since `encoding/xml` matches on the namespace
URI. Whether libvirt accepts the default-namespace form on `DomainDefineXML`
is confirmed in Step 6 against a real host, not here.
Checked before relying on `github.com/kdomanski/iso9660` for the seed: its
filename mangling lowercases and keeps `-`, so `user-data`/`meta-data`/
`vendor-data` come out as `user-data;1` etc. in the directory record, which
the Linux isofs driver (and therefore cloud-init's NoCloud mount) presents as
`user-data`. The seed test opens the ISO with the same library's reader and
asserts label `cidata`, all three files, and byte-identical output for
identical inputs. `NewWriter()` stages files in a temp directory, so
`Cleanup()` is deferred.
`go mod tidy` ran as part of this step; it promoted `kdomanski/iso9660` to a
direct dependency and dropped `go-libvirt` again because nothing imports it
yet — Step 3 brings it back.
Worth noting: `withDefaults` copies the `Images` map and `Hosts` slice
instead of mutating the config in place, so the registry's shared
`ProviderConfig` stays untouched. The default vendor-data installs
`qemu-guest-agent` via `packages:` + `runcmd`, which means the first boot
needs network access to the distro mirrors; a Proxy on an isolated bridge
would still work (ARP discovery), just without the agent.
+5 -4
View File
@@ -5,6 +5,7 @@ go 1.26.0
require ( require (
cloud.google.com/go/compute v1.65.0 cloud.google.com/go/compute v1.65.0
github.com/go-logr/logr v1.4.3 github.com/go-logr/logr v1.4.3
github.com/kdomanski/iso9660 v0.4.0
github.com/onsi/ginkgo/v2 v2.27.4 github.com/onsi/ginkgo/v2 v2.27.4
github.com/onsi/gomega v1.39.0 github.com/onsi/gomega v1.39.0
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
@@ -78,17 +79,17 @@ require (
go.uber.org/zap v1.27.1 // indirect go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.54.0 // indirect golang.org/x/crypto v0.56.0 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/mod v0.37.0 // indirect golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.57.0 // indirect golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.40.0 // indirect golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.47.0 // indirect golang.org/x/tools v0.48.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
+10 -8
View File
@@ -96,6 +96,8 @@ github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kdomanski/iso9660 v0.4.0 h1:BPKKdcINz3m0MdjIMwS0wx1nofsOjxOq8TOr45WGHFg=
github.com/kdomanski/iso9660 v0.4.0/go.mod h1:OxUSupHsO9ceI8lBLPJKWBTphLemjrCQY8LPXM7qSzU=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@@ -198,12 +200,12 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
@@ -214,12 +216,12 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+224
View File
@@ -0,0 +1,224 @@
package kvm
import (
"encoding/xml"
"fmt"
"time"
)
const guestAgentChannel = "org.qemu.guest_agent.0"
// domainParams is everything buildDomainXML needs, already resolved from the
// config and placement so the builder stays a pure function.
type domainParams struct {
Name string
UID string
CreatedAt time.Time
VCPUs int32
MemoryMiB int64
Pool string
Bridge string
DiskVol string
SeedVol string
}
// buildDomainXML renders a q35 KVM guest: host-passthrough CPU, a virtio
// overlay disk and a SATA cdrom for the seed (both referenced as pool
// volumes so no host paths are needed), one virtio NIC on the host bridge,
// a serial console, the guest-agent channel, a virtio RNG, no graphics, and
// the operator's metadata element.
func buildDomainXML(p domainParams) (string, error) {
d := domainXML{
Type: "kvm",
Name: p.Name,
Metadata: domainMetadataXML{Instance: newInstanceMetadata(p.UID, p.CreatedAt)},
Memory: sizeXML{Unit: "MiB", Value: p.MemoryMiB},
VCPU: p.VCPUs,
OS: osXML{
Type: osTypeXML{Arch: "x86_64", Machine: "q35", Value: "hvm"},
Boot: bootXML{Dev: "hd"},
},
Features: featuresXML{ACPI: &struct{}{}, APIC: &struct{}{}},
CPU: cpuXML{Mode: "host-passthrough"},
Clock: clockXML{Offset: "utc"},
OnPoweroff: "destroy",
OnReboot: "restart",
OnCrash: "destroy",
Devices: devicesXML{
Disks: []diskXML{
{
Type: "volume",
Device: "disk",
Driver: driverXML{Name: "qemu", Type: formatQcow2},
Source: diskSourceXML{Pool: p.Pool, Volume: p.DiskVol},
Target: targetXML{Dev: "vda", Bus: "virtio"},
},
{
Type: "volume",
Device: "cdrom",
Driver: driverXML{Name: "qemu", Type: formatRaw},
Source: diskSourceXML{Pool: p.Pool, Volume: p.SeedVol},
Target: targetXML{Dev: "sda", Bus: "sata"},
ReadOnly: &struct{}{},
},
},
Interfaces: []interfaceXML{{
Type: "bridge",
Source: ifaceSourceXML{Bridge: p.Bridge},
Model: modelXML{Type: "virtio"},
}},
Serials: []serialXML{{Type: "pty", Target: portXML{Port: 0}}},
Consoles: []consoleXML{{Type: "pty", Target: consoleTargetXML{Type: "serial", Port: 0}}},
Channels: []channelXML{{
Type: "unix",
Target: channelTargetXML{Type: "virtio", Name: guestAgentChannel},
}},
RNG: rngXML{Model: "virtio", Backend: rngBackendXML{Model: "random", Value: "/dev/urandom"}},
},
}
out, err := xml.Marshal(d)
if err != nil {
return "", fmt.Errorf("marshalling domain xml: %w", err)
}
return string(out), nil
}
type domainXML struct {
XMLName xml.Name `xml:"domain"`
Type string `xml:"type,attr"`
Name string `xml:"name"`
Metadata domainMetadataXML `xml:"metadata"`
Memory sizeXML `xml:"memory"`
VCPU int32 `xml:"vcpu"`
OS osXML `xml:"os"`
Features featuresXML `xml:"features"`
CPU cpuXML `xml:"cpu"`
Clock clockXML `xml:"clock"`
OnPoweroff string `xml:"on_poweroff"`
OnReboot string `xml:"on_reboot"`
OnCrash string `xml:"on_crash"`
Devices devicesXML `xml:"devices"`
}
type domainMetadataXML struct {
Instance instanceMetadata
}
type sizeXML struct {
Unit string `xml:"unit,attr"`
Value int64 `xml:",chardata"`
}
type osXML struct {
Type osTypeXML `xml:"type"`
Boot bootXML `xml:"boot"`
}
type osTypeXML struct {
Arch string `xml:"arch,attr"`
Machine string `xml:"machine,attr"`
Value string `xml:",chardata"`
}
type bootXML struct {
Dev string `xml:"dev,attr"`
}
type featuresXML struct {
ACPI *struct{} `xml:"acpi"`
APIC *struct{} `xml:"apic"`
}
type cpuXML struct {
Mode string `xml:"mode,attr"`
}
type clockXML struct {
Offset string `xml:"offset,attr"`
}
type devicesXML struct {
Disks []diskXML `xml:"disk"`
Interfaces []interfaceXML `xml:"interface"`
Serials []serialXML `xml:"serial"`
Consoles []consoleXML `xml:"console"`
Channels []channelXML `xml:"channel"`
RNG rngXML `xml:"rng"`
}
type diskXML struct {
Type string `xml:"type,attr"`
Device string `xml:"device,attr"`
Driver driverXML `xml:"driver"`
Source diskSourceXML `xml:"source"`
Target targetXML `xml:"target"`
ReadOnly *struct{} `xml:"readonly"`
}
type driverXML struct {
Name string `xml:"name,attr"`
Type string `xml:"type,attr"`
}
type diskSourceXML struct {
Pool string `xml:"pool,attr"`
Volume string `xml:"volume,attr"`
}
type targetXML struct {
Dev string `xml:"dev,attr"`
Bus string `xml:"bus,attr"`
}
type interfaceXML struct {
Type string `xml:"type,attr"`
Source ifaceSourceXML `xml:"source"`
Model modelXML `xml:"model"`
}
type ifaceSourceXML struct {
Bridge string `xml:"bridge,attr"`
}
type modelXML struct {
Type string `xml:"type,attr"`
}
type serialXML struct {
Type string `xml:"type,attr"`
Target portXML `xml:"target"`
}
type portXML struct {
Port int `xml:"port,attr"`
}
type consoleXML struct {
Type string `xml:"type,attr"`
Target consoleTargetXML `xml:"target"`
}
type consoleTargetXML struct {
Type string `xml:"type,attr"`
Port int `xml:"port,attr"`
}
type channelXML struct {
Type string `xml:"type,attr"`
Target channelTargetXML `xml:"target"`
}
type channelTargetXML struct {
Type string `xml:"type,attr"`
Name string `xml:"name,attr"`
}
type rngXML struct {
Model string `xml:"model,attr"`
Backend rngBackendXML `xml:"backend"`
}
type rngBackendXML struct {
Model string `xml:"model,attr"`
Value string `xml:",chardata"`
}
+113
View File
@@ -0,0 +1,113 @@
package kvm
import (
"encoding/xml"
"strings"
"testing"
"time"
)
func testDomainParams() domainParams {
return domainParams{
Name: "proxy-abc123def456ghij",
UID: "11111111-2222-3333-4444-555555555555",
CreatedAt: time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC),
VCPUs: 2,
MemoryMiB: 2048,
Pool: "default",
Bridge: "br0",
DiskVol: "proxy-abc123def456ghij.qcow2",
SeedVol: "proxy-abc123def456ghij-seed.iso",
}
}
func TestBuildDomainXML_fieldByField(t *testing.T) {
t.Parallel()
raw, err := buildDomainXML(testDomainParams())
if err != nil {
t.Fatalf("buildDomainXML() error = %v", err)
}
var d domainXML
if err := xml.Unmarshal([]byte(raw), &d); err != nil {
t.Fatalf("output is not well-formed XML: %v\n%s", err, raw)
}
if d.Type != "kvm" || d.Name != "proxy-abc123def456ghij" {
t.Errorf("type/name = %s/%s", d.Type, d.Name)
}
if d.VCPU != 2 || d.Memory.Value != 2048 || d.Memory.Unit != "MiB" {
t.Errorf("vcpu/memory = %d/%d %s, want 2/2048 MiB", d.VCPU, d.Memory.Value, d.Memory.Unit)
}
if d.OS.Type.Value != "hvm" || d.OS.Type.Arch != "x86_64" || d.OS.Type.Machine != "q35" || d.OS.Boot.Dev != "hd" {
t.Errorf("os = %+v", d.OS)
}
if d.Features.ACPI == nil || d.Features.APIC == nil {
t.Errorf("features = %+v, want acpi and apic", d.Features)
}
if d.CPU.Mode != "host-passthrough" {
t.Errorf("cpu mode = %q", d.CPU.Mode)
}
if d.OnPoweroff != "destroy" || d.OnReboot != "restart" || d.OnCrash != "destroy" {
t.Errorf("lifecycle = %s/%s/%s", d.OnPoweroff, d.OnReboot, d.OnCrash)
}
m := d.Metadata.Instance
if !m.managed() || m.UID != "11111111-2222-3333-4444-555555555555" || m.CreatedAt != "2026-09-04T12:00:00Z" {
t.Errorf("metadata = %+v", m)
}
if !strings.Contains(raw, `xmlns="`+metadataURI+`"`) {
t.Errorf("metadata element is not namespaced; libvirt rejects that:\n%s", raw)
}
if len(d.Devices.Disks) != 2 {
t.Fatalf("disks = %d, want 2", len(d.Devices.Disks))
}
disk, seed := d.Devices.Disks[0], d.Devices.Disks[1]
if disk.Type != "volume" || disk.Device != "disk" || disk.Driver.Type != "qcow2" ||
disk.Source.Pool != "default" || disk.Source.Volume != "proxy-abc123def456ghij.qcow2" ||
disk.Target.Dev != "vda" || disk.Target.Bus != "virtio" || disk.ReadOnly != nil {
t.Errorf("overlay disk = %+v", disk)
}
if seed.Type != "volume" || seed.Device != "cdrom" || seed.Driver.Type != "raw" ||
seed.Source.Pool != "default" || seed.Source.Volume != "proxy-abc123def456ghij-seed.iso" ||
seed.Target.Bus != "sata" || seed.ReadOnly == nil {
t.Errorf("seed cdrom = %+v", seed)
}
if len(d.Devices.Interfaces) != 1 {
t.Fatalf("interfaces = %d, want 1", len(d.Devices.Interfaces))
}
if nic := d.Devices.Interfaces[0]; nic.Type != "bridge" || nic.Source.Bridge != "br0" || nic.Model.Type != "virtio" {
t.Errorf("interface = %+v", nic)
}
if len(d.Devices.Channels) != 1 || d.Devices.Channels[0].Target.Name != guestAgentChannel || d.Devices.Channels[0].Target.Type != "virtio" {
t.Errorf("channels = %+v, want one virtio %s channel", d.Devices.Channels, guestAgentChannel)
}
if len(d.Devices.Serials) != 1 || len(d.Devices.Consoles) != 1 || d.Devices.Consoles[0].Target.Type != "serial" {
t.Errorf("serial/console = %+v/%+v", d.Devices.Serials, d.Devices.Consoles)
}
if d.Devices.RNG.Model != "virtio" || d.Devices.RNG.Backend.Value != "/dev/urandom" {
t.Errorf("rng = %+v", d.Devices.RNG)
}
if strings.Contains(raw, "<graphics") {
t.Errorf("domain has a graphics device; headless VMs must not:\n%s", raw)
}
}
func TestBuildDomainXML_escapesUserValues(t *testing.T) {
t.Parallel()
p := testDomainParams()
p.Bridge = `br0" onload="x`
raw, err := buildDomainXML(p)
if err != nil {
t.Fatalf("buildDomainXML() error = %v", err)
}
var d domainXML
if err := xml.Unmarshal([]byte(raw), &d); err != nil {
t.Fatalf("output is not well-formed XML: %v", err)
}
if d.Devices.Interfaces[0].Source.Bridge != p.Bridge {
t.Errorf("bridge = %q, want the raw value back", d.Devices.Interfaces[0].Source.Bridge)
}
}
+21
View File
@@ -0,0 +1,21 @@
package kvm
import (
"fmt"
"strings"
)
// The providerID carries the host, so Get/Delete stay correct after a
// placement.zone edit mid-replacement — re-reading the spec then would look
// at the wrong hypervisor.
func formatProviderID(host, name string) string {
return fmt.Sprintf("hosts/%s/domains/%s", host, name)
}
func parseProviderID(id string) (host, name string, err error) {
parts := strings.Split(id, "/")
if len(parts) != 4 || parts[0] != "hosts" || parts[2] != "domains" || parts[1] == "" || parts[3] == "" {
return "", "", fmt.Errorf("malformed kvm providerID %q, want hosts/<host>/domains/<name>", id)
}
return parts[1], parts[3], nil
}
+41
View File
@@ -0,0 +1,41 @@
package kvm
import "testing"
func TestParseProviderID_roundTrip(t *testing.T) {
t.Parallel()
id := formatProviderID("nas1", "proxy-abc123def456ghij")
if id != "hosts/nas1/domains/proxy-abc123def456ghij" {
t.Fatalf("formatProviderID() = %q", id)
}
host, name, err := parseProviderID(id)
if err != nil {
t.Fatalf("parseProviderID(%q) error = %v", id, err)
}
if host != "nas1" || name != "proxy-abc123def456ghij" {
t.Errorf("parseProviderID(%q) = %q, %q", id, host, name)
}
}
func TestParseProviderID_malformed(t *testing.T) {
t.Parallel()
tests := []struct {
name string
id string
}{
{"empty", ""},
{"gcp style", "zones/europe-west1-b/instances/proxy-x"},
{"missing name", "hosts/nas1/domains/"},
{"missing host", "hosts//domains/proxy-x"},
{"too many segments", "hosts/nas1/domains/proxy-x/extra"},
{"bare name", "proxy-x"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if _, _, err := parseProviderID(tc.id); err == nil {
t.Errorf("parseProviderID(%q) error = nil, want error", tc.id)
}
})
}
}
+47
View File
@@ -0,0 +1,47 @@
// Package kvm implements the provider contract on libvirt/KVM hypervisors.
// Each configured host is reached over SSH with the libvirt unix socket
// forwarded through the connection, so the hypervisors need no libvirtd TCP
// or TLS listener and the operator image needs no libvirt libraries. VMs
// boot a stock cloud image (fetched into the host's storage pool on first
// use) through a qcow2 overlay, with the Proxy's cloud-init delivered on a
// NoCloud seed ISO built in-process.
package kvm
import "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
const (
defaultDiskSizeGB = 10
defaultSocket = "/var/run/libvirt/libvirt-sock"
defaultPool = "default"
formatQcow2 = "qcow2"
formatRaw = "raw"
)
func withDefaults(cfg provider.KVMConfig) provider.KVMConfig {
if cfg.DiskSizeGB == 0 {
cfg.DiskSizeGB = defaultDiskSizeGB
}
if cfg.VendorData == "" {
cfg.VendorData = defaultVendorData
}
images := make(map[string]provider.KVMImage, len(cfg.Images))
for k, img := range cfg.Images {
if img.Format == "" {
img.Format = formatQcow2
}
images[k] = img
}
cfg.Images = images
hosts := make([]provider.KVMHost, len(cfg.Hosts))
for i, h := range cfg.Hosts {
if h.Socket == "" {
h.Socket = defaultSocket
}
if h.Pool == "" {
h.Pool = defaultPool
}
hosts[i] = h
}
cfg.Hosts = hosts
return cfg
}
+44
View File
@@ -0,0 +1,44 @@
package kvm
import (
"testing"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
func TestWithDefaults(t *testing.T) {
t.Parallel()
cfg := withDefaults(provider.KVMConfig{
Images: map[string]provider.KVMImage{
"debian": {URL: "https://x/d.qcow2"},
"raw": {URL: "https://x/r.img", Format: "raw"},
},
Hosts: []provider.KVMHost{
{Name: "a", Address: "a:22", User: "virt", Bridge: "br0"},
{Name: "b", Address: "b:22", User: "virt", Bridge: "br1", Socket: "/run/s", Pool: "fast"},
},
})
if cfg.DiskSizeGB != defaultDiskSizeGB {
t.Errorf("diskSizeGb = %d, want %d", cfg.DiskSizeGB, defaultDiskSizeGB)
}
if cfg.VendorData != defaultVendorData {
t.Errorf("vendorData = %q, want the default", cfg.VendorData)
}
if cfg.Images["debian"].Format != "qcow2" || cfg.Images["raw"].Format != "raw" {
t.Errorf("image formats = %q/%q, want qcow2 default and raw kept", cfg.Images["debian"].Format, cfg.Images["raw"].Format)
}
if a := cfg.Hosts[0]; a.Socket != defaultSocket || a.Pool != defaultPool {
t.Errorf("host a = %+v, want default socket and pool", a)
}
if b := cfg.Hosts[1]; b.Socket != "/run/s" || b.Pool != "fast" {
t.Errorf("host b = %+v, want explicit socket and pool kept", b)
}
}
func TestWithDefaults_keepsExplicitValues(t *testing.T) {
t.Parallel()
cfg := withDefaults(provider.KVMConfig{DiskSizeGB: 42, VendorData: "#cloud-config\n"})
if cfg.DiskSizeGB != 42 || cfg.VendorData != "#cloud-config\n" {
t.Errorf("cfg = %+v, want explicit values kept", cfg)
}
}
+57
View File
@@ -0,0 +1,57 @@
package kvm
import (
"encoding/xml"
"fmt"
"time"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
// metadataURI namespaces the operator's element inside the domain's
// <metadata>. libvirt has no labels or tags, but it stores any namespaced XML
// there and hands it back through DomainGetMetadata, which is how the GC
// contract (managed flag + owning UID) and the creation time survive across
// operator restarts.
const metadataURI = "https://gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/kvm"
type instanceMetadata struct {
XMLName xml.Name `xml:"https://gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/kvm instance"`
Managed string `xml:"managed"`
UID string `xml:"uid"`
CreatedAt string `xml:"createdAt"`
}
func newInstanceMetadata(uid string, createdAt time.Time) instanceMetadata {
return instanceMetadata{
Managed: provider.LabelManagedYes,
UID: uid,
CreatedAt: createdAt.UTC().Format(time.RFC3339),
}
}
// parseInstanceMetadata decodes the element as DomainGetMetadata returns it.
// libvirt may re-serialise it with a namespace prefix rather than the default
// namespace it was defined with; encoding/xml matches on namespace URI, so
// both spellings decode.
func parseInstanceMetadata(s string) (instanceMetadata, error) {
var m instanceMetadata
if err := xml.Unmarshal([]byte(s), &m); err != nil {
return instanceMetadata{}, fmt.Errorf("parsing domain metadata: %w", err)
}
return m, nil
}
func (m instanceMetadata) managed() bool {
return m.Managed == provider.LabelManagedYes
}
// createdAt returns the zero time on a malformed timestamp: orphan GC treats
// zero as "old", so a corrupt value can never protect a domain forever.
func (m instanceMetadata) createdAt() time.Time {
t, err := time.Parse(time.RFC3339, m.CreatedAt)
if err != nil {
return time.Time{}
}
return t
}
+79
View File
@@ -0,0 +1,79 @@
package kvm
import (
"encoding/xml"
"strings"
"testing"
"time"
)
func TestInstanceMetadata_roundTrip(t *testing.T) {
t.Parallel()
created := time.Date(2026, 9, 4, 12, 30, 0, 0, time.FixedZone("CEST", 2*3600))
raw, err := xml.Marshal(newInstanceMetadata("uid-1", created))
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if !strings.Contains(string(raw), `xmlns="`+metadataURI+`"`) {
t.Errorf("marshalled metadata lacks the namespace declaration: %s", raw)
}
m, err := parseInstanceMetadata(string(raw))
if err != nil {
t.Fatalf("parseInstanceMetadata() error = %v", err)
}
if !m.managed() || m.UID != "uid-1" {
t.Errorf("parsed = %+v, want managed with uid-1", m)
}
if got := m.createdAt(); !got.Equal(created) || got.Location() != time.UTC {
t.Errorf("createdAt() = %v, want %v in UTC", got, created)
}
}
func TestParseInstanceMetadata_libvirtPrefixedForm(t *testing.T) {
t.Parallel()
// libvirt re-serialises the stored element with whatever prefix it
// chooses; only the namespace URI is contractual.
in := `<op:instance xmlns:op="` + metadataURI + `">` +
`<op:managed>true</op:managed><op:uid>uid-2</op:uid>` +
`<op:createdAt>2026-09-04T10:00:00Z</op:createdAt></op:instance>`
m, err := parseInstanceMetadata(in)
if err != nil {
t.Fatalf("parseInstanceMetadata() error = %v", err)
}
if !m.managed() || m.UID != "uid-2" || m.createdAt().Hour() != 10 {
t.Errorf("parsed = %+v", m)
}
}
func TestParseInstanceMetadata_notManaged(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in string
wantErr bool
}{
{"managed false", `<instance xmlns="` + metadataURI + `"><managed>false</managed><uid>u</uid></instance>`, false},
{"foreign element", `<foo xmlns="urn:other"><managed>true</managed></foo>`, true},
{"garbage", `not xml`, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
m, err := parseInstanceMetadata(tc.in)
if (err != nil) != tc.wantErr {
t.Fatalf("error = %v, wantErr %v", err, tc.wantErr)
}
if m.managed() {
t.Errorf("managed() = true for %q", tc.in)
}
})
}
}
func TestInstanceMetadata_createdAtMalformedIsZero(t *testing.T) {
t.Parallel()
m := instanceMetadata{CreatedAt: "yesterday"}
if !m.createdAt().IsZero() {
t.Errorf("createdAt() = %v, want zero time", m.createdAt())
}
}
+52
View File
@@ -0,0 +1,52 @@
package kvm
import (
"bytes"
"fmt"
"strings"
"github.com/kdomanski/iso9660"
)
// seedLabel is the volume label cloud-init's NoCloud datasource looks for.
const seedLabel = "cidata"
// defaultVendorData is the cloud-init vendor-data shipped on every seed
// unless the config overrides it. cloud-init merges it under the Proxy's own
// user-data, so stock images grow a guest agent (which lets the provider
// read the VM's IP before ARP sees traffic) without the user's cloud-init
// having to know about it.
const defaultVendorData = `#cloud-config
packages:
- qemu-guest-agent
runcmd:
- [systemctl, enable, --now, qemu-guest-agent]
`
// buildSeedISO renders a NoCloud seed: user-data verbatim, vendor-data as
// given, and a meta-data whose instance-id is the deterministic VM name, so a
// re-created seed for the same name is byte-identical and cloud-init never
// mistakes a reboot for a new instance.
func buildSeedISO(name, userData, vendorData string) ([]byte, error) {
w, err := iso9660.NewWriter()
if err != nil {
return nil, fmt.Errorf("creating iso writer: %w", err)
}
defer w.Cleanup() //nolint:errcheck // temp-dir cleanup; nothing to do on failure
files := []struct{ name, body string }{
{"user-data", userData},
{"meta-data", fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", name, name)},
{"vendor-data", vendorData},
}
for _, f := range files {
if err := w.AddFile(strings.NewReader(f.body), f.name); err != nil {
return nil, fmt.Errorf("adding %s to seed iso: %w", f.name, err)
}
}
var buf bytes.Buffer
if err := w.WriteTo(&buf, seedLabel); err != nil {
return nil, fmt.Errorf("writing seed iso: %w", err)
}
return buf.Bytes(), nil
}
+91
View File
@@ -0,0 +1,91 @@
package kvm
import (
"bytes"
"io"
"strings"
"testing"
"github.com/kdomanski/iso9660"
)
func readSeed(t *testing.T, iso []byte) (label string, files map[string]string) {
t.Helper()
img, err := iso9660.OpenImage(bytes.NewReader(iso))
if err != nil {
t.Fatalf("OpenImage() error = %v", err)
}
label, err = img.Label()
if err != nil {
t.Fatalf("Label() error = %v", err)
}
root, err := img.RootDir()
if err != nil {
t.Fatalf("RootDir() error = %v", err)
}
children, err := root.GetChildren()
if err != nil {
t.Fatalf("GetChildren() error = %v", err)
}
files = make(map[string]string, len(children))
for _, c := range children {
body, err := io.ReadAll(c.Reader())
if err != nil {
t.Fatalf("reading %s: %v", c.Name(), err)
}
files[c.Name()] = string(body)
}
return label, files
}
func TestBuildSeedISO_contents(t *testing.T) {
t.Parallel()
userData := "#cloud-config\npackages: [squid]\n"
iso, err := buildSeedISO("proxy-abc123def456ghij", userData, defaultVendorData)
if err != nil {
t.Fatalf("buildSeedISO() error = %v", err)
}
label, files := readSeed(t, iso)
if !strings.EqualFold(label, seedLabel) {
t.Errorf("label = %q, want %q", label, seedLabel)
}
if len(files) != 3 {
t.Errorf("files = %v, want exactly user-data, meta-data, vendor-data", files)
}
if files["user-data"] != userData {
t.Errorf("user-data = %q, want verbatim cloud-init", files["user-data"])
}
if files["vendor-data"] != defaultVendorData {
t.Errorf("vendor-data = %q", files["vendor-data"])
}
if got, want := files["meta-data"], "instance-id: proxy-abc123def456ghij\nlocal-hostname: proxy-abc123def456ghij\n"; got != want {
t.Errorf("meta-data = %q, want %q", got, want)
}
}
func TestBuildSeedISO_deterministic(t *testing.T) {
t.Parallel()
a, err := buildSeedISO("proxy-x", "#cloud-config\n", "")
if err != nil {
t.Fatalf("first buildSeedISO() error = %v", err)
}
b, err := buildSeedISO("proxy-x", "#cloud-config\n", "")
if err != nil {
t.Fatalf("second buildSeedISO() error = %v", err)
}
if !bytes.Equal(a, b) {
t.Error("two builds for the same inputs differ; a re-created seed must be byte-identical")
}
}
func TestBuildSeedISO_emptyUserData(t *testing.T) {
t.Parallel()
iso, err := buildSeedISO("proxy-x", "", defaultVendorData)
if err != nil {
t.Fatalf("buildSeedISO() error = %v", err)
}
_, files := readSeed(t, iso)
if body, ok := files["user-data"]; !ok || body != "" {
t.Errorf("user-data = %q (present=%v), want an empty file to still exist", body, ok)
}
}
+78
View File
@@ -0,0 +1,78 @@
package kvm
import (
"encoding/xml"
"fmt"
)
const bytesPerGiB = 1 << 30
func overlayVolumeName(name string) string { return name + ".qcow2" }
func seedVolumeName(name string) string { return name + "-seed.iso" }
// volumeSpec describes a storage volume to create. A non-empty BackingPath
// makes it a copy-on-write overlay of that file.
type volumeSpec struct {
Name string
SizeBytes int64
Format string
BackingPath string
BackingFormat string
}
func overlayVolume(name string, sizeGB int64, basePath, baseFormat string) volumeSpec {
return volumeSpec{
Name: overlayVolumeName(name),
SizeBytes: sizeGB * bytesPerGiB,
Format: formatQcow2,
BackingPath: basePath,
BackingFormat: baseFormat,
}
}
func rawVolume(name string, sizeBytes int64) volumeSpec {
return volumeSpec{Name: name, SizeBytes: sizeBytes, Format: formatRaw}
}
// buildVolumeXML renders the volume for StorageVolCreateXML. Capacity is
// always given in bytes so no libvirt unit-suffix ambiguity (G vs GiB) can
// creep in.
func buildVolumeXML(v volumeSpec) (string, error) {
x := volumeXML{
Type: "file",
Name: v.Name,
Capacity: sizeXML{Unit: "bytes", Value: v.SizeBytes},
Target: volumeTargetXML{Format: formatXML{Type: v.Format}},
}
if v.BackingPath != "" {
x.BackingStore = &backingStoreXML{Path: v.BackingPath, Format: formatXML{Type: v.BackingFormat}}
}
out, err := xml.Marshal(x)
if err != nil {
return "", fmt.Errorf("marshalling volume xml: %w", err)
}
return string(out), nil
}
type volumeXML struct {
XMLName xml.Name `xml:"volume"`
Type string `xml:"type,attr"`
Name string `xml:"name"`
Capacity sizeXML `xml:"capacity"`
Target volumeTargetXML `xml:"target"`
BackingStore *backingStoreXML `xml:"backingStore"`
}
type volumeTargetXML struct {
Format formatXML `xml:"format"`
}
type formatXML struct {
Type string `xml:"type,attr"`
}
type backingStoreXML struct {
Path string `xml:"path"`
Format formatXML `xml:"format"`
}
+62
View File
@@ -0,0 +1,62 @@
package kvm
import (
"encoding/xml"
"strings"
"testing"
)
func TestVolumeNames(t *testing.T) {
t.Parallel()
if got := overlayVolumeName("proxy-x"); got != "proxy-x.qcow2" {
t.Errorf("overlayVolumeName = %q", got)
}
if got := seedVolumeName("proxy-x"); got != "proxy-x-seed.iso" {
t.Errorf("seedVolumeName = %q", got)
}
}
func TestBuildVolumeXML_overlay(t *testing.T) {
t.Parallel()
raw, err := buildVolumeXML(overlayVolume("proxy-x", 10, "/var/lib/libvirt/images/base-debian-13.qcow2", "qcow2"))
if err != nil {
t.Fatalf("buildVolumeXML() error = %v", err)
}
var v volumeXML
if err := xml.Unmarshal([]byte(raw), &v); err != nil {
t.Fatalf("output is not well-formed XML: %v", err)
}
if v.Type != "file" || v.Name != "proxy-x.qcow2" {
t.Errorf("type/name = %s/%s", v.Type, v.Name)
}
if v.Capacity.Unit != "bytes" || v.Capacity.Value != 10*bytesPerGiB {
t.Errorf("capacity = %d %s, want %d bytes", v.Capacity.Value, v.Capacity.Unit, 10*bytesPerGiB)
}
if v.Target.Format.Type != "qcow2" {
t.Errorf("format = %q", v.Target.Format.Type)
}
if v.BackingStore == nil {
t.Fatalf("backingStore missing:\n%s", raw)
}
if v.BackingStore.Path != "/var/lib/libvirt/images/base-debian-13.qcow2" || v.BackingStore.Format.Type != "qcow2" {
t.Errorf("backingStore = %+v", v.BackingStore)
}
}
func TestBuildVolumeXML_rawHasNoBackingStore(t *testing.T) {
t.Parallel()
raw, err := buildVolumeXML(rawVolume("proxy-x-seed.iso", 372736))
if err != nil {
t.Fatalf("buildVolumeXML() error = %v", err)
}
if strings.Contains(raw, "backingStore") {
t.Errorf("raw volume must not declare a backing store:\n%s", raw)
}
var v volumeXML
if err := xml.Unmarshal([]byte(raw), &v); err != nil {
t.Fatalf("output is not well-formed XML: %v", err)
}
if v.Name != "proxy-x-seed.iso" || v.Capacity.Value != 372736 || v.Target.Format.Type != "raw" {
t.Errorf("volume = %+v", v)
}
}