Collapse duplicated logic the review found in PR #5 without changing behaviour: one releaseFinalizer helper for the four finalizer exits in reconcileDelete, classify reusing isClassified and provider.Class, the image URL check and format/firmware values shared from the provider package, imageSource and the metadata parser embedding their config structs instead of copying fields, and the SSH loader as a small struct that keeps only the SSH fields and is seeded by New instead of loading the key twice. Tracing builds its V(2)-only lists only at V(2). Tests: drop a duplicate New test, fold near-identical tests into tables (connection drops, metadata parsing, cached images), add mustSSHAuth, and use wantClass where classes were checked by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
412 lines
11 KiB
Go
412 lines
11 KiB
Go
package kvm
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
|
|
libvirt "github.com/digitalocean/go-libvirt"
|
|
)
|
|
|
|
// fakeAPI is an in-memory libvirtAPI: pools of volumes and a set of
|
|
// domains, with per-method error injection. It models just enough libvirt
|
|
// semantics for the provider's decisions to be observable — ErrStorageVolExist
|
|
// on duplicate create, ErrNoDomain/ErrNoStorageVol on missing lookups,
|
|
// ErrOperationInvalid on destroying a stopped domain, and so on.
|
|
type fakeAPI struct {
|
|
mu sync.Mutex
|
|
poolPath string
|
|
pools map[string]map[string]*fakeVol
|
|
domains map[string]*fakeDomain
|
|
calls []string
|
|
fail map[string]error
|
|
failOnce map[string]error
|
|
// uploadHook, when set, sees every StorageVolUpload before the fake
|
|
// reads the stream; it can return an error to simulate a broken upload.
|
|
uploadHook func(vol string, r io.Reader) error
|
|
}
|
|
|
|
type fakeVol struct {
|
|
format string
|
|
capacity int64
|
|
backing string
|
|
data []byte
|
|
}
|
|
|
|
type fakeDomain struct {
|
|
name string
|
|
xml string
|
|
metadata string
|
|
running bool
|
|
// addrs is what DomainInterfaceAddresses returns per source.
|
|
addrs map[uint32][]libvirt.DomainInterface
|
|
}
|
|
|
|
func newFakeAPI(pools ...string) *fakeAPI {
|
|
f := &fakeAPI{
|
|
poolPath: "/var/lib/libvirt/images",
|
|
pools: map[string]map[string]*fakeVol{},
|
|
domains: map[string]*fakeDomain{},
|
|
fail: map[string]error{},
|
|
failOnce: map[string]error{},
|
|
}
|
|
for _, p := range pools {
|
|
f.pools[p] = map[string]*fakeVol{}
|
|
}
|
|
return f
|
|
}
|
|
|
|
// enter records the call and returns any injected error for method.
|
|
func (f *fakeAPI) enter(method string) error {
|
|
f.calls = append(f.calls, method)
|
|
if err, ok := f.failOnce[method]; ok {
|
|
delete(f.failOnce, method)
|
|
return err
|
|
}
|
|
return f.fail[method]
|
|
}
|
|
|
|
func (f *fakeAPI) callCount(method string) int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
n := 0
|
|
for _, c := range f.calls {
|
|
if c == method {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (f *fakeAPI) hasCall(method string) bool { return f.callCount(method) > 0 }
|
|
|
|
func (f *fakeAPI) vol(pool, name string) *fakeVol {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.pools[pool][name]
|
|
}
|
|
|
|
func (f *fakeAPI) putVol(pool, name, format string, data []byte) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.pools[pool][name] = &fakeVol{format: format, capacity: int64(len(data)), data: data}
|
|
}
|
|
|
|
func (f *fakeAPI) volNames(pool string) []string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []string
|
|
for n := range f.pools[pool] {
|
|
out = append(out, n)
|
|
}
|
|
slices.Sort(out)
|
|
return out
|
|
}
|
|
|
|
func (f *fakeAPI) domain(name string) *fakeDomain {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.domains[name]
|
|
}
|
|
|
|
func (f *fakeAPI) putDomain(d *fakeDomain) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.domains[d.name] = d
|
|
}
|
|
|
|
func lvError(code libvirt.ErrorNumber, format string, args ...any) error {
|
|
return libvirt.Error{Code: uint32(code), Message: fmt.Sprintf(format, args...)}
|
|
}
|
|
|
|
// --- domains ---
|
|
|
|
func (f *fakeAPI) DomainLookupByName(name string) (libvirt.Domain, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err := f.enter("DomainLookupByName"); err != nil {
|
|
return libvirt.Domain{}, err
|
|
}
|
|
if _, ok := f.domains[name]; !ok {
|
|
return libvirt.Domain{}, lvError(libvirt.ErrNoDomain, "Domain not found: no domain with matching name '%s'", name)
|
|
}
|
|
return libvirt.Domain{Name: name}, nil
|
|
}
|
|
|
|
func (f *fakeAPI) DomainDefineXML(raw string) (libvirt.Domain, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err := f.enter("DomainDefineXML"); err != nil {
|
|
return libvirt.Domain{}, err
|
|
}
|
|
var d domainXML
|
|
if err := xml.Unmarshal([]byte(raw), &d); err != nil {
|
|
return libvirt.Domain{}, lvError(libvirt.ErrXMLError, "%v", err)
|
|
}
|
|
meta, err := xml.Marshal(d.Metadata.Instance)
|
|
if err != nil {
|
|
return libvirt.Domain{}, lvError(libvirt.ErrXMLError, "%v", err)
|
|
}
|
|
// libvirt hands the element back without its namespace declaration
|
|
// (seen on 11.6); mimic that so Get/ListByTag parse the real shape.
|
|
stored := strings.Replace(string(meta), ` xmlns="`+metadataURI+`"`, "", 1)
|
|
existing := f.domains[d.Name]
|
|
nd := &fakeDomain{name: d.Name, xml: raw, metadata: stored}
|
|
if existing != nil {
|
|
nd.running = existing.running
|
|
nd.addrs = existing.addrs
|
|
}
|
|
f.domains[d.Name] = nd
|
|
return libvirt.Domain{Name: d.Name}, nil
|
|
}
|
|
|
|
func (f *fakeAPI) DomainCreate(d libvirt.Domain) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err := f.enter("DomainCreate"); err != nil {
|
|
return err
|
|
}
|
|
dom, ok := f.domains[d.Name]
|
|
if !ok {
|
|
return lvError(libvirt.ErrNoDomain, "no domain %s", d.Name)
|
|
}
|
|
if dom.running {
|
|
return lvError(libvirt.ErrOperationInvalid, "domain is already running")
|
|
}
|
|
dom.running = true
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeAPI) DomainDestroy(d libvirt.Domain) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err := f.enter("DomainDestroy"); err != nil {
|
|
return err
|
|
}
|
|
dom, ok := f.domains[d.Name]
|
|
if !ok {
|
|
return lvError(libvirt.ErrNoDomain, "no domain %s", d.Name)
|
|
}
|
|
if !dom.running {
|
|
return lvError(libvirt.ErrOperationInvalid, "domain is not running")
|
|
}
|
|
dom.running = false
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeAPI) DomainUndefineFlags(d libvirt.Domain, _ libvirt.DomainUndefineFlagsValues) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err := f.enter("DomainUndefineFlags"); err != nil {
|
|
return err
|
|
}
|
|
if _, ok := f.domains[d.Name]; !ok {
|
|
return lvError(libvirt.ErrNoDomain, "no domain %s", d.Name)
|
|
}
|
|
delete(f.domains, d.Name)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeAPI) DomainGetState(d libvirt.Domain, _ uint32) (int32, int32, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err := f.enter("DomainGetState"); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
dom, ok := f.domains[d.Name]
|
|
if !ok {
|
|
return 0, 0, lvError(libvirt.ErrNoDomain, "no domain %s", d.Name)
|
|
}
|
|
if dom.running {
|
|
return int32(libvirt.DomainRunning), 0, nil
|
|
}
|
|
return int32(libvirt.DomainShutoff), 0, nil
|
|
}
|
|
|
|
func (f *fakeAPI) DomainGetMetadata(d libvirt.Domain, typ int32, uri libvirt.OptString, _ libvirt.DomainModificationImpact) (string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err := f.enter("DomainGetMetadata"); err != nil {
|
|
return "", err
|
|
}
|
|
dom, ok := f.domains[d.Name]
|
|
if !ok {
|
|
return "", lvError(libvirt.ErrNoDomain, "no domain %s", d.Name)
|
|
}
|
|
if typ != int32(libvirt.DomainMetadataElement) || len(uri) != 1 || uri[0] != metadataURI || dom.metadata == "" {
|
|
return "", lvError(libvirt.ErrNoDomainMetadata, "Requested metadata element is not present")
|
|
}
|
|
return dom.metadata, nil
|
|
}
|
|
|
|
func (f *fakeAPI) DomainInterfaceAddresses(d libvirt.Domain, source uint32, _ uint32) ([]libvirt.DomainInterface, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err := f.enter("DomainInterfaceAddresses"); err != nil {
|
|
return nil, err
|
|
}
|
|
dom, ok := f.domains[d.Name]
|
|
if !ok {
|
|
return nil, lvError(libvirt.ErrNoDomain, "no domain %s", d.Name)
|
|
}
|
|
if !dom.running {
|
|
return nil, lvError(libvirt.ErrOperationInvalid, "domain is not running")
|
|
}
|
|
if source == uint32(libvirt.DomainInterfaceAddressesSrcAgent) && dom.addrs[source] == nil {
|
|
return nil, lvError(libvirt.ErrAgentUnresponsive, "Guest agent is not responding")
|
|
}
|
|
return dom.addrs[source], nil
|
|
}
|
|
|
|
func (f *fakeAPI) ConnectListAllDomains(_ int32, _ libvirt.ConnectListAllDomainsFlags) ([]libvirt.Domain, uint32, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err := f.enter("ConnectListAllDomains"); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
names := make([]string, 0, len(f.domains))
|
|
for n := range f.domains {
|
|
names = append(names, n)
|
|
}
|
|
slices.Sort(names)
|
|
out := make([]libvirt.Domain, 0, len(names))
|
|
for _, n := range names {
|
|
out = append(out, libvirt.Domain{Name: n})
|
|
}
|
|
return out, uint32(len(out)), nil
|
|
}
|
|
|
|
// --- storage ---
|
|
|
|
func (f *fakeAPI) StoragePoolLookupByName(name string) (libvirt.StoragePool, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err := f.enter("StoragePoolLookupByName"); err != nil {
|
|
return libvirt.StoragePool{}, err
|
|
}
|
|
if _, ok := f.pools[name]; !ok {
|
|
return libvirt.StoragePool{}, lvError(libvirt.ErrNoStoragePool, "Storage pool not found: no storage pool with matching name '%s'", name)
|
|
}
|
|
return libvirt.StoragePool{Name: name}, nil
|
|
}
|
|
|
|
func (f *fakeAPI) StoragePoolRefresh(pool libvirt.StoragePool, _ uint32) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err := f.enter("StoragePoolRefresh"); err != nil {
|
|
return err
|
|
}
|
|
if _, ok := f.pools[pool.Name]; !ok {
|
|
return lvError(libvirt.ErrNoStoragePool, "no pool %s", pool.Name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeAPI) StorageVolLookupByName(pool libvirt.StoragePool, name string) (libvirt.StorageVol, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err := f.enter("StorageVolLookupByName"); err != nil {
|
|
return libvirt.StorageVol{}, err
|
|
}
|
|
if _, ok := f.pools[pool.Name][name]; !ok {
|
|
return libvirt.StorageVol{}, lvError(libvirt.ErrNoStorageVol, "Storage volume not found: no storage vol with matching name '%s'", name)
|
|
}
|
|
return libvirt.StorageVol{Pool: pool.Name, Name: name, Key: f.poolPath + "/" + name}, nil
|
|
}
|
|
|
|
func (f *fakeAPI) StorageVolGetPath(vol libvirt.StorageVol) (string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err := f.enter("StorageVolGetPath"); err != nil {
|
|
return "", err
|
|
}
|
|
if _, ok := f.pools[vol.Pool][vol.Name]; !ok {
|
|
return "", lvError(libvirt.ErrNoStorageVol, "no vol %s", vol.Name)
|
|
}
|
|
return f.poolPath + "/" + vol.Name, nil
|
|
}
|
|
|
|
func (f *fakeAPI) StorageVolCreateXML(pool libvirt.StoragePool, raw string, _ libvirt.StorageVolCreateFlags) (libvirt.StorageVol, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err := f.enter("StorageVolCreateXML"); err != nil {
|
|
return libvirt.StorageVol{}, err
|
|
}
|
|
vols, ok := f.pools[pool.Name]
|
|
if !ok {
|
|
return libvirt.StorageVol{}, lvError(libvirt.ErrNoStoragePool, "no pool %s", pool.Name)
|
|
}
|
|
var v volumeXML
|
|
if err := xml.Unmarshal([]byte(raw), &v); err != nil {
|
|
return libvirt.StorageVol{}, lvError(libvirt.ErrXMLError, "%v", err)
|
|
}
|
|
if _, exists := vols[v.Name]; exists {
|
|
return libvirt.StorageVol{}, lvError(libvirt.ErrStorageVolExist, "storage volume '%s' exists already", v.Name)
|
|
}
|
|
fv := &fakeVol{format: v.Target.Format.Type, capacity: v.Capacity.Value}
|
|
if v.BackingStore != nil {
|
|
fv.backing = v.BackingStore.Path
|
|
}
|
|
vols[v.Name] = fv
|
|
return libvirt.StorageVol{Pool: pool.Name, Name: v.Name, Key: f.poolPath + "/" + v.Name}, nil
|
|
}
|
|
|
|
func (f *fakeAPI) StorageVolUpload(vol libvirt.StorageVol, r io.Reader, _, _ uint64, _ libvirt.StorageVolUploadFlags) error {
|
|
f.mu.Lock()
|
|
if err := f.enter("StorageVolUpload"); err != nil {
|
|
f.mu.Unlock()
|
|
return err
|
|
}
|
|
fv, ok := f.pools[vol.Pool][vol.Name]
|
|
hook := f.uploadHook
|
|
f.mu.Unlock()
|
|
if !ok {
|
|
return lvError(libvirt.ErrNoStorageVol, "no vol %s", vol.Name)
|
|
}
|
|
if hook != nil {
|
|
if err := hook(vol.Name, r); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Read outside the lock: the stream may be a slow HTTP body.
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return fmt.Errorf("reading upload stream: %w", err)
|
|
}
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
fv.data = data
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeAPI) StorageVolDownload(vol libvirt.StorageVol, w io.Writer, _, _ uint64, _ libvirt.StorageVolDownloadFlags) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err := f.enter("StorageVolDownload"); err != nil {
|
|
return err
|
|
}
|
|
fv, ok := f.pools[vol.Pool][vol.Name]
|
|
if !ok {
|
|
return lvError(libvirt.ErrNoStorageVol, "no vol %s", vol.Name)
|
|
}
|
|
_, err := io.Copy(w, bytes.NewReader(fv.data))
|
|
return err
|
|
}
|
|
|
|
func (f *fakeAPI) StorageVolDelete(vol libvirt.StorageVol, _ libvirt.StorageVolDeleteFlags) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if err := f.enter("StorageVolDelete"); err != nil {
|
|
return err
|
|
}
|
|
if _, ok := f.pools[vol.Pool][vol.Name]; !ok {
|
|
return lvError(libvirt.ErrNoStorageVol, "no vol %s", vol.Name)
|
|
}
|
|
delete(f.pools[vol.Pool], vol.Name)
|
|
return nil
|
|
}
|