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:
@@ -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 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 3b — Image cache (`image.go`)
|
||||
- [ ] 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
|
||||
forward in that interface instead of using `New`; the plan's transport design
|
||||
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,6 +5,7 @@ go 1.26.0
|
||||
require (
|
||||
cloud.google.com/go/compute v1.65.0
|
||||
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/gomega v1.39.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
@@ -78,17 +79,17 @@ require (
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // 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/mod v0.37.0 // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.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/tools v0.47.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
|
||||
|
||||
@@ -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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
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/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
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/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
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.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||
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/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
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/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
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/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
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.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
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/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
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/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user