A host name is pasted into the providerID hosts/<name>/domains/<vm>, which parseProviderID splits on "/", so a name containing a slash produced an ID that never parsed again: Get, Delete and GC all failed permanently and the VM leaked with the Proxy's finalizer stuck. The image map key has the same defect and lands in a file name rather than an ID: base-<key>.<format> in the host's storage pool. Both now have to match a plain identifier pattern. Machine-type keys are left alone; they are only ever compared, never embedded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
227 lines
7.8 KiB
Go
227 lines
7.8 KiB
Go
package provider
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const validKVMYAML = `
|
|
providers:
|
|
- name: kvm-home
|
|
type: kvm
|
|
kvm:
|
|
sshKeyFile: /etc/proxy-operator/kvm/id_ed25519
|
|
knownHostsFile: /etc/proxy-operator/kvm/known_hosts
|
|
diskSizeGb: 20
|
|
images:
|
|
debian-13:
|
|
url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
|
|
sha256: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
|
ubuntu:
|
|
url: http://mirror.local/noble.img
|
|
format: raw
|
|
machineTypes:
|
|
small: {vcpus: 1, memoryMiB: 1024}
|
|
medium: {vcpus: 2, memoryMiB: 2048}
|
|
hosts:
|
|
- name: nas1
|
|
address: nas1.home.lan:22
|
|
user: virt
|
|
bridge: br0
|
|
- name: lab2
|
|
address: 10.0.0.2:2222
|
|
user: root
|
|
socket: /run/libvirt/virtqemud-sock
|
|
pool: fast
|
|
bridge: br1
|
|
`
|
|
|
|
func TestLoadConfig_kvmValid(t *testing.T) {
|
|
t.Parallel()
|
|
cfg, err := LoadConfig([]byte(validKVMYAML))
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig() error = %v, want nil", err)
|
|
}
|
|
k := cfg.Providers[0].KVM
|
|
if k == nil {
|
|
t.Fatal("providers[0].kvm = nil")
|
|
}
|
|
if k.SSHKeyFile != "/etc/proxy-operator/kvm/id_ed25519" || k.DiskSizeGB != 20 {
|
|
t.Errorf("kvm = %+v, want sshKeyFile and diskSizeGb parsed", k)
|
|
}
|
|
if got := k.Images["debian-13"].SHA256; !strings.HasPrefix(got, "0123") {
|
|
t.Errorf("images[debian-13].sha256 = %q", got)
|
|
}
|
|
if k.Images["ubuntu"].Format != "raw" {
|
|
t.Errorf("images[ubuntu].format = %q, want raw", k.Images["ubuntu"].Format)
|
|
}
|
|
if mt := k.MachineTypes["medium"]; mt.VCPUs != 2 || mt.MemoryMiB != 2048 {
|
|
t.Errorf("machineTypes[medium] = %+v, want {2 2048}", mt)
|
|
}
|
|
if len(k.Hosts) != 2 || k.Hosts[1].Socket != "/run/libvirt/virtqemud-sock" || k.Hosts[1].Pool != "fast" {
|
|
t.Errorf("hosts = %+v, want two hosts with lab2's socket and pool parsed", k.Hosts)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfig_kvmInsecureHostKeyNeedsNoKnownHosts(t *testing.T) {
|
|
t.Parallel()
|
|
yaml := strings.Replace(validKVMYAML,
|
|
"knownHostsFile: /etc/proxy-operator/kvm/known_hosts",
|
|
"insecureIgnoreHostKey: true", 1)
|
|
if _, err := LoadConfig([]byte(yaml)); err != nil {
|
|
t.Fatalf("LoadConfig() error = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfig_kvmInvalid(t *testing.T) {
|
|
t.Parallel()
|
|
minimal := func(kvmBody string) string {
|
|
return "providers:\n - name: kvm-home\n type: kvm\n kvm:\n" + indent(kvmBody, 6)
|
|
}
|
|
hostsAndTypes := `
|
|
machineTypes:
|
|
small: {vcpus: 1, memoryMiB: 1024}
|
|
hosts:
|
|
- {name: nas1, address: nas1:22, user: virt, bridge: br0}
|
|
`
|
|
tests := []struct {
|
|
name string
|
|
yaml string
|
|
wantErrSub string
|
|
}{
|
|
{
|
|
name: "missing kvm block",
|
|
yaml: "providers:\n - name: kvm-home\n type: kvm\n",
|
|
wantErrSub: "kvm block is required",
|
|
},
|
|
{
|
|
name: "missing sshKeyFile",
|
|
yaml: minimal("knownHostsFile: /kh\n" + hostsAndTypes),
|
|
wantErrSub: "kvm.sshKeyFile is required",
|
|
},
|
|
{
|
|
name: "missing knownHostsFile without insecure flag",
|
|
yaml: minimal("sshKeyFile: /key\n" + hostsAndTypes),
|
|
wantErrSub: "kvm.knownHostsFile is required unless",
|
|
},
|
|
{
|
|
name: "negative disk size",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\ndiskSizeGb: -1\n" + hostsAndTypes),
|
|
wantErrSub: "diskSizeGb must not be negative",
|
|
},
|
|
{
|
|
name: "no machine types",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nhosts:\n - {name: nas1, address: nas1:22, user: virt, bridge: br0}\n"),
|
|
wantErrSub: "machineTypes must define at least one",
|
|
},
|
|
{
|
|
name: "machine type with zero memory",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1}\nhosts:\n - {name: nas1, address: nas1:22, user: virt, bridge: br0}\n"),
|
|
wantErrSub: `machineTypes["small"] needs vcpus > 0 and memoryMiB > 0`,
|
|
},
|
|
{
|
|
name: "no hosts",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\n"),
|
|
wantErrSub: "kvm.hosts must list at least one host",
|
|
},
|
|
{
|
|
name: "duplicate host name",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\nhosts:\n - {name: nas1, address: a:22, user: virt, bridge: br0}\n - {name: nas1, address: b:22, user: virt, bridge: br0}\n"),
|
|
wantErrSub: `duplicate host name "nas1"`,
|
|
},
|
|
{
|
|
// A slash would produce hosts/dc1/rack2/domains/<vm>, which
|
|
// parseProviderID can never split back, so the VM would be
|
|
// unreachable for Get, Delete and GC alike.
|
|
name: "host name with a slash",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\nhosts:\n - {name: dc1/rack2, address: a:22, user: virt, bridge: br0}\n"),
|
|
wantErrSub: `name "dc1/rack2" must be letters, digits, dots, dashes or underscores`,
|
|
},
|
|
{
|
|
name: "image key with a slash",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nimages:\n ../etc/d: {url: https://x/y.qcow2}\n" + hostsAndTypes),
|
|
wantErrSub: `images["../etc/d"]: key must be letters, digits, dots, dashes or underscores`,
|
|
},
|
|
{
|
|
name: "host missing address",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\nhosts:\n - {name: nas1, user: virt, bridge: br0}\n"),
|
|
wantErrSub: "address is required",
|
|
},
|
|
{
|
|
name: "host missing user",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\nhosts:\n - {name: nas1, address: a:22, bridge: br0}\n"),
|
|
wantErrSub: "user is required",
|
|
},
|
|
{
|
|
name: "host missing bridge",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\nhosts:\n - {name: nas1, address: a:22, user: virt}\n"),
|
|
wantErrSub: "bridge is required",
|
|
},
|
|
{
|
|
name: "image with non-http url",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nimages:\n d: {url: file:///tmp/x.qcow2}\n" + hostsAndTypes),
|
|
wantErrSub: `images["d"].url must be an http(s) URL`,
|
|
},
|
|
{
|
|
name: "image with bad sha256",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nimages:\n d: {url: https://x/y.qcow2, sha256: abc}\n" + hostsAndTypes),
|
|
wantErrSub: `images["d"].sha256 must be 64 hex characters`,
|
|
},
|
|
{
|
|
name: "image with unknown firmware",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nimages:\n d: {url: https://x/y.qcow2, firmware: coreboot}\n" + hostsAndTypes),
|
|
wantErrSub: `images["d"].firmware must be uefi or bios`,
|
|
},
|
|
{
|
|
name: "image with unknown format",
|
|
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nimages:\n d: {url: https://x/y.vmdk, format: vmdk}\n" + hostsAndTypes),
|
|
wantErrSub: `images["d"].format must be qcow2 or raw`,
|
|
},
|
|
{
|
|
name: "kvm type with gcp block",
|
|
yaml: `
|
|
providers:
|
|
- name: p1
|
|
type: kvm
|
|
gcp:
|
|
project: my-project`,
|
|
wantErrSub: "type is kvm but a gcp block is set",
|
|
},
|
|
{
|
|
name: "gcp type with kvm block",
|
|
yaml: `
|
|
providers:
|
|
- name: p1
|
|
type: gcp
|
|
gcp:
|
|
project: my-project
|
|
kvm:
|
|
sshKeyFile: /key`,
|
|
wantErrSub: "type is gcp but a kvm block is set",
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := LoadConfig([]byte(tc.yaml))
|
|
if err == nil {
|
|
t.Fatalf("LoadConfig() error = nil, want error containing %q", tc.wantErrSub)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.wantErrSub) {
|
|
t.Errorf("LoadConfig() error = %q, want substring %q", err.Error(), tc.wantErrSub)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func indent(s string, n int) string {
|
|
pad := strings.Repeat(" ", n)
|
|
lines := strings.Split(strings.TrimLeft(s, "\n"), "\n")
|
|
for i, l := range lines {
|
|
if l != "" {
|
|
lines[i] = pad + l
|
|
}
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|