The optional proxmox-token Secret (token, ca.pem) mounted at /etc/proxy-operator/proxmox, make proxmox-token-secret with content checks that never print the token, a commented proxmox-home entry in both providers configs, and config/samples/proxy_proxmox.yaml. TestLoadConfig_proxmoxSampleEntry loads the commented entry from both files, and a second test ties the sample Proxy's placement to it. docs/proxmox-provider.md is the user guide: prerequisites and privileges, cluster setup, config reference, placement, what lands on Proxmox, logging, tracing, timeouts, health check, error classes, known gaps and testing. providers-existing.md gains the proxmox column and section; architecture.md, the README, providers.md and CLAUDE.md gain their rows. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
251 lines
11 KiB
Go
251 lines
11 KiB
Go
package provider
|
|
|
|
import (
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sigs.k8s.io/yaml"
|
|
)
|
|
|
|
const validProxmoxYAML = `
|
|
providers:
|
|
- name: proxmox-home
|
|
type: proxmox
|
|
proxmox:
|
|
url: https://192.168.0.8:8006
|
|
tokenFile: /etc/proxy-operator/proxmox/token
|
|
caFile: /etc/proxy-operator/proxmox/ca.pem
|
|
nodes: [pv1-1, pv1-2]
|
|
isoStorage: proxy-seeds
|
|
storage: local-lvm
|
|
bridge: vmbr0
|
|
vlanTag: 4
|
|
vmidRange: {min: 150000, max: 150999}
|
|
templates:
|
|
debian-13: {vmid: 9001}
|
|
machineTypes:
|
|
small: {cores: 1, memoryMiB: 1024}
|
|
large: {cores: 4, memoryMiB: 4096, diskGiB: 20}
|
|
requestTimeoutSeconds: 45
|
|
installGuestAgent: false
|
|
`
|
|
|
|
func TestLoadConfig_proxmoxValid(t *testing.T) {
|
|
t.Parallel()
|
|
cfg, err := LoadConfig([]byte(validProxmoxYAML))
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig() error = %v, want nil", err)
|
|
}
|
|
c := cfg.Providers[0].Proxmox
|
|
if c == nil {
|
|
t.Fatal("providers[0].proxmox = nil")
|
|
}
|
|
if c.URL != "https://192.168.0.8:8006" || c.ISOStorage != "proxy-seeds" || c.VLANTag != 4 {
|
|
t.Errorf("proxmox = %+v, want url, isoStorage and vlanTag parsed", c)
|
|
}
|
|
if len(c.Nodes) != 2 || c.Nodes[1] != "pv1-2" {
|
|
t.Errorf("nodes = %v, want [pv1-1 pv1-2]", c.Nodes)
|
|
}
|
|
if c.VMIDRange == nil || c.VMIDRange.Min != 150000 || c.VMIDRange.Max != 150999 {
|
|
t.Errorf("vmidRange = %+v, want 150000 to 150999", c.VMIDRange)
|
|
}
|
|
if c.Templates["debian-13"].VMID != 9001 {
|
|
t.Errorf("templates[debian-13] = %+v, want vmid 9001", c.Templates["debian-13"])
|
|
}
|
|
if mt := c.MachineTypes["large"]; mt.Cores != 4 || mt.MemoryMiB != 4096 || mt.DiskGiB != 20 {
|
|
t.Errorf("machineTypes[large] = %+v, want {4 4096 20}", mt)
|
|
}
|
|
if c.InstallGuestAgent == nil || *c.InstallGuestAgent {
|
|
t.Errorf("installGuestAgent = %v, want explicit false", c.InstallGuestAgent)
|
|
}
|
|
}
|
|
|
|
func TestValidateProxmox_table(t *testing.T) {
|
|
t.Parallel()
|
|
block := func(body string) string {
|
|
return "providers:\n - name: proxmox-home\n type: proxmox\n proxmox:\n" + indent(body, 6)
|
|
}
|
|
const base = `
|
|
url: https://192.168.0.8:8006
|
|
tokenFile: /t
|
|
nodes: [pv1-1]
|
|
isoStorage: proxy-seeds
|
|
templates: {debian-13: {vmid: 9001}}
|
|
machineTypes: {small: {cores: 1, memoryMiB: 1024}}
|
|
`
|
|
with := func(old, repl string) string {
|
|
if !strings.Contains(base, old) {
|
|
t.Fatalf("base has no %q", old)
|
|
}
|
|
return block(strings.Replace(base, old, repl, 1))
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
yaml string
|
|
wantErrSub string
|
|
}{
|
|
{name: "valid minimal", yaml: block(base)},
|
|
{name: "valid full", yaml: validProxmoxYAML},
|
|
{name: "valid with trailing slash", yaml: with("url: https://192.168.0.8:8006", "url: https://192.168.0.8:8006/")},
|
|
{name: "missing block", yaml: "providers:\n - name: proxmox-home\n type: proxmox\n", wantErrSub: "proxmox block is required"},
|
|
{name: "missing url", yaml: with("url: https://192.168.0.8:8006\n", ""), wantErrSub: "proxmox.url is required"},
|
|
{name: "http url", yaml: with("https://", "http://"), wantErrSub: "must be an https URL"},
|
|
{name: "url with api path", yaml: with("8006", "8006/api2/json"), wantErrSub: "must have no path"},
|
|
{name: "missing tokenFile", yaml: with("tokenFile: /t\n", ""), wantErrSub: "proxmox.tokenFile is required"},
|
|
{name: "no nodes", yaml: with("nodes: [pv1-1]", "nodes: []"), wantErrSub: "proxmox.nodes must list at least one node"},
|
|
{name: "duplicate node", yaml: with("nodes: [pv1-1]", "nodes: [pv1-1, pv1-1]"), wantErrSub: `duplicate node "pv1-1"`},
|
|
{name: "node with a slash", yaml: with("nodes: [pv1-1]", "nodes: [pv1/1]"), wantErrSub: "is not a Proxmox node name"},
|
|
{name: "node in uppercase", yaml: with("nodes: [pv1-1]", "nodes: [PV1]"), wantErrSub: "is not a Proxmox node name"},
|
|
{name: "missing isoStorage", yaml: with("isoStorage: proxy-seeds\n", ""), wantErrSub: "proxmox.isoStorage is required"},
|
|
{name: "bad isoStorage id", yaml: with("isoStorage: proxy-seeds", "isoStorage: 1seeds"), wantErrSub: "proxmox.isoStorage \"1seeds\" is not a Proxmox storage id"},
|
|
{name: "bad storage id", yaml: with("isoStorage: proxy-seeds", "isoStorage: proxy-seeds\nstorage: a/b"), wantErrSub: "proxmox.storage \"a/b\""},
|
|
{name: "vlanTag too large", yaml: with("isoStorage: proxy-seeds", "isoStorage: proxy-seeds\nvlanTag: 4095"), wantErrSub: "proxmox.vlanTag must be between 1 and 4094"},
|
|
{name: "vlanTag negative", yaml: with("isoStorage: proxy-seeds", "isoStorage: proxy-seeds\nvlanTag: -1"), wantErrSub: "proxmox.vlanTag"},
|
|
{name: "vmidRange min below 100", yaml: with("isoStorage: proxy-seeds", "isoStorage: proxy-seeds\nvmidRange: {min: 50, max: 500}"), wantErrSub: "proxmox.vmidRange must satisfy"},
|
|
{name: "vmidRange min above max", yaml: with("isoStorage: proxy-seeds", "isoStorage: proxy-seeds\nvmidRange: {min: 2000, max: 1000}"), wantErrSub: "proxmox.vmidRange must satisfy"},
|
|
{name: "vmidRange max too large", yaml: with("isoStorage: proxy-seeds", "isoStorage: proxy-seeds\nvmidRange: {min: 100, max: 1000000000}"), wantErrSub: "proxmox.vmidRange must satisfy"},
|
|
{name: "no templates", yaml: with("templates: {debian-13: {vmid: 9001}}", "templates: {}"), wantErrSub: "proxmox.templates must define at least one template"},
|
|
{name: "template vmid below 100", yaml: with("vmid: 9001", "vmid: 99"), wantErrSub: `proxmox.templates["debian-13"].vmid must be at least 100`},
|
|
{name: "no machineTypes", yaml: with("machineTypes: {small: {cores: 1, memoryMiB: 1024}}", "machineTypes: {}"), wantErrSub: "proxmox.machineTypes must define at least one profile"},
|
|
{name: "cores zero", yaml: with("cores: 1", "cores: 0"), wantErrSub: `proxmox.machineTypes["small"] needs cores > 0`},
|
|
{name: "memory zero", yaml: with("memoryMiB: 1024", "memoryMiB: 0"), wantErrSub: "memoryMiB > 0"},
|
|
{name: "negative diskGiB", yaml: with("memoryMiB: 1024", "memoryMiB: 1024, diskGiB: -5"), wantErrSub: "diskGiB must not be negative"},
|
|
{name: "negative timeout", yaml: with("isoStorage: proxy-seeds", "isoStorage: proxy-seeds\nrequestTimeoutSeconds: -1"), wantErrSub: "proxmox.requestTimeoutSeconds must not be negative"},
|
|
{name: "unknown key is rejected", yaml: with("isoStorage: proxy-seeds", "isoStorage: proxy-seeds\nsshKeyFile: /k"), wantErrSub: "parsing providers config"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := LoadConfig([]byte(tc.yaml))
|
|
if tc.wantErrSub == "" {
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig() error = %v, want nil", err)
|
|
}
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// commentedEntry cuts the commented-out providers entry named name out of a
|
|
// config file and strips its "# " at the entry's own indent, so a sample
|
|
// that is shipped commented out is still loaded by a test.
|
|
func commentedEntry(t *testing.T, path, name string) string {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("reading %s: %v", path, err)
|
|
}
|
|
var out []string
|
|
var prefix string
|
|
for line := range strings.SplitSeq(string(data), "\n") {
|
|
if prefix == "" {
|
|
if strings.TrimSpace(line) == "# - name: "+name {
|
|
prefix = line[:strings.Index(line, "#")]
|
|
out = append(out, prefix+"- name: "+name)
|
|
}
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(line, prefix+"# ") {
|
|
break
|
|
}
|
|
out = append(out, prefix+strings.TrimPrefix(line, prefix+"# "))
|
|
}
|
|
if prefix == "" {
|
|
t.Fatalf("%s has no commented entry %q", path, name)
|
|
}
|
|
return strings.Join(out, "\n") + "\n"
|
|
}
|
|
|
|
func TestLoadConfig_proxmoxSampleEntry(t *testing.T) {
|
|
t.Parallel()
|
|
for name, path := range map[string]string{
|
|
"sample": "../../config/samples/providers-config.yaml",
|
|
"configmap": "../../config/manager/providers_config.yaml",
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
cfg, err := LoadConfig([]byte("providers:\n" + commentedEntry(t, path, "proxmox-home")))
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig(proxmox-home from %s) error = %v, want nil", path, err)
|
|
}
|
|
p := cfg.Providers[0]
|
|
// LoadConfig already refused a proxmox block under another type.
|
|
if p.Name != "proxmox-home" || p.Proxmox == nil {
|
|
t.Fatalf("provider %q has proxmox block %v, want proxmox-home with one", p.Name, p.Proxmox)
|
|
}
|
|
c := p.Proxmox
|
|
if c.URL != "https://192.168.0.8:8006" {
|
|
t.Errorf("url = %q, want https://192.168.0.8:8006 (the address in the node certificate)", c.URL)
|
|
}
|
|
if c.TokenFile != "/etc/proxy-operator/proxmox/token" || c.CAFile != "/etc/proxy-operator/proxmox/ca.pem" {
|
|
t.Errorf("tokenFile, caFile = %q, %q, want the proxmox-token Secret's mount paths", c.TokenFile, c.CAFile)
|
|
}
|
|
if c.InsecureSkipTLSVerify {
|
|
t.Error("insecureSkipTLSVerify = true, want it left commented out")
|
|
}
|
|
if len(c.Nodes) != 1 || c.Nodes[0] != "pv1-1" {
|
|
t.Errorf("nodes = %v, want [pv1-1]", c.Nodes)
|
|
}
|
|
if c.ISOStorage != "proxy-seeds" || c.Storage != "" || c.Bridge != "" || c.VLANTag != 4 {
|
|
t.Errorf("isoStorage, storage, bridge, vlanTag = %q, %q, %q, %d, want proxy-seeds, \"\", \"\", 4", c.ISOStorage, c.Storage, c.Bridge, c.VLANTag)
|
|
}
|
|
if len(c.Templates) != 1 || c.Templates["debian-13"].VMID != 9001 {
|
|
t.Errorf("templates = %v, want {debian-13: {vmid: 9001}}", c.Templates)
|
|
}
|
|
if mt, ok := c.MachineTypes["small"]; !ok || mt.Cores != 1 || mt.MemoryMiB != 1024 || mt.DiskGiB != 0 {
|
|
t.Errorf("machineTypes[small] = %+v (present %v), want {1 1024 0}", mt, ok)
|
|
}
|
|
if c.VMIDRange != nil || c.RequestTimeoutSeconds != 0 || c.InstallGuestAgent != nil || c.VendorData != "" {
|
|
t.Errorf("optional fields set: %+v, want vmidRange, requestTimeoutSeconds, installGuestAgent and vendorData left commented out", c)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadConfig_proxmoxSampleProxyMatchesSampleEntry(t *testing.T) {
|
|
t.Parallel()
|
|
cfg, err := LoadConfig([]byte("providers:\n" + commentedEntry(t, "../../config/samples/providers-config.yaml", "proxmox-home")))
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig() error = %v, want nil", err)
|
|
}
|
|
data, err := os.ReadFile("../../config/samples/proxy_proxmox.yaml")
|
|
if err != nil {
|
|
t.Fatalf("reading the sample Proxy: %v", err)
|
|
}
|
|
var px struct {
|
|
Spec struct {
|
|
Provider string `json:"provider"`
|
|
Placement struct {
|
|
Zone string `json:"zone"`
|
|
MachineType string `json:"machineType"`
|
|
Image string `json:"image"`
|
|
} `json:"placement"`
|
|
} `json:"spec"`
|
|
}
|
|
if err := yaml.Unmarshal(data, &px); err != nil {
|
|
t.Fatalf("parsing the sample Proxy: %v", err)
|
|
}
|
|
c := cfg.Providers[0].Proxmox
|
|
if px.Spec.Provider != cfg.Providers[0].Name {
|
|
t.Errorf("spec.provider = %q, want %q", px.Spec.Provider, cfg.Providers[0].Name)
|
|
}
|
|
if !slices.Contains(c.Nodes, px.Spec.Placement.Zone) {
|
|
t.Errorf("placement.zone = %q, not in nodes %v", px.Spec.Placement.Zone, c.Nodes)
|
|
}
|
|
if _, ok := c.MachineTypes[px.Spec.Placement.MachineType]; !ok {
|
|
t.Errorf("placement.machineType = %q, not in machineTypes", px.Spec.Placement.MachineType)
|
|
}
|
|
if _, ok := c.Templates[px.Spec.Placement.Image]; !ok {
|
|
t.Errorf("placement.image = %q, not in templates", px.Spec.Placement.Image)
|
|
}
|
|
}
|