Files
egress-proxies-operator/internal/provider/kvm2/errors_test.go
T
kacerrandClaude Fable 5.1 95768281be kvm2: config block, constructor registration, IDs, state mapping and classify
The kvm2 block reuses KVMConfig; validateKVM gains a block-name variant so
messages say kvm2. The package skeleton has a non-dialing New, strict
providerID parsing, the state table and a 34-row classify test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:38:48 +00:00

147 lines
8.3 KiB
Go

package kvm2
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"net"
"os"
"testing"
"github.com/digitalocean/go-libvirt"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
func libvirtErr(code libvirt.ErrorNumber, msg string) error {
return libvirt.Error{Code: uint32(code), Message: msg}
}
// Captured from x/crypto/ssh when the offered key is not in authorized_keys.
const sshAuthFailedMsg = "ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain"
func TestClassify_table(t *testing.T) {
t.Parallel()
tests := []struct {
name string
op string
err error
want error
}{
{name: "nil is transient", op: "get", err: nil, want: provider.ErrTransient},
{name: "ErrNoDomain on get", op: "get", err: libvirtErr(libvirt.ErrNoDomain, "Domain not found: no domain with matching name 'proxy-x'"), want: provider.ErrNotFound},
{name: "ErrNoDomain on delete", op: "delete", err: libvirtErr(libvirt.ErrNoDomain, "Domain not found"), want: provider.ErrNotFound},
{name: "ErrNoDomain on create is transient", op: "create", err: libvirtErr(libvirt.ErrNoDomain, "Domain not found"), want: provider.ErrTransient},
{name: "ErrNoStorageVol on get", op: "get", err: libvirtErr(libvirt.ErrNoStorageVol, "Storage volume not found: no storage vol with matching name 'proxy-x.qcow2'"), want: provider.ErrNotFound},
{name: "ErrNoStorageVol on create is transient", op: "create", err: libvirtErr(libvirt.ErrNoStorageVol, "Storage volume not found"), want: provider.ErrTransient},
{name: "ErrNoStoragePool", op: "create", err: libvirtErr(libvirt.ErrNoStoragePool, "Storage pool not found: no storage pool with matching name 'default'"), want: provider.ErrPermanent},
{name: "ErrXMLError", op: "create", err: libvirtErr(libvirt.ErrXMLError, "XML error: missing domain name"), want: provider.ErrPermanent},
{name: "ErrNoSupport", op: "create", err: libvirtErr(libvirt.ErrNoSupport, "this function is not supported by the connection driver"), want: provider.ErrPermanent},
{name: "ErrInvalidArg", op: "get", err: libvirtErr(libvirt.ErrInvalidArg, "invalid argument: unsupported flags"), want: provider.ErrPermanent},
{name: "ErrAuthFailed", op: "list", err: libvirtErr(libvirt.ErrAuthFailed, "authentication failed: access denied by policy"), want: provider.ErrPermanent},
{name: "ErrAccessDenied", op: "list", err: libvirtErr(libvirt.ErrAccessDenied, "access denied"), want: provider.ErrPermanent},
{name: "ErrOperationInvalid is transient", op: "create", err: libvirtErr(libvirt.ErrOperationInvalid, "Requested operation is not valid: domain is already running"), want: provider.ErrTransient},
{name: "ErrInternalError is transient", op: "get", err: libvirtErr(libvirt.ErrInternalError, "internal error: connection closed"), want: provider.ErrTransient},
{name: "unknown libvirt code is transient", op: "get", err: libvirtErr(libvirt.ErrorNumber(9999), "?"), want: provider.ErrTransient},
{name: "wrapped libvirt error keeps its code", op: "get", err: fmt.Errorf("looking up domain: %w", libvirtErr(libvirt.ErrNoDomain, "Domain not found")), want: provider.ErrNotFound},
{name: "knownhosts unknown host", op: "get", err: &knownhosts.KeyError{}, want: provider.ErrPermanent},
{name: "knownhosts mismatch", op: "get", err: &knownhosts.KeyError{Want: []knownhosts.KnownKey{{Filename: "/kh", Line: 1, Key: testPublicKey(t)}}}, want: provider.ErrPermanent},
{name: "wrapped knownhosts error", op: "create", err: fmt.Errorf("dialing beelink: %w", &knownhosts.KeyError{}), want: provider.ErrPermanent},
{name: "ssh unable to authenticate", op: "create", err: errors.New(sshAuthFailedMsg), want: provider.ErrPermanent},
{name: "wrapped ssh unable to authenticate", op: "list", err: fmt.Errorf("dialing beelink: %w", errors.New(sshAuthFailedMsg)), want: provider.ErrPermanent},
{name: "missing key file is transient", op: "create", err: fmt.Errorf("reading ssh key file /k: %w", fs.ErrNotExist), want: provider.ErrTransient},
{name: "os.PathError not exist is transient", op: "create", err: &os.PathError{Op: "open", Path: "/k", Err: fs.ErrNotExist}, want: provider.ErrTransient},
{name: "context canceled", op: "get", err: context.Canceled, want: provider.ErrTransient},
{name: "context deadline", op: "get", err: context.DeadlineExceeded, want: provider.ErrTransient},
{name: "net.OpError", op: "get", err: &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")}, want: provider.ErrTransient},
{name: "io.EOF", op: "list", err: io.EOF, want: provider.ErrTransient},
{name: "io.ErrUnexpectedEOF", op: "list", err: io.ErrUnexpectedEOF, want: provider.ErrTransient},
{name: "anything else is transient", op: "get", err: errors.New("anything"), want: provider.ErrTransient},
{name: "pre-classified NotFound stays", op: "create", err: fmt.Errorf("x: %w", provider.ErrNotFound), want: provider.ErrNotFound},
{name: "pre-classified Quota stays", op: "create", err: fmt.Errorf("x: %w", provider.ErrQuotaExceeded), want: provider.ErrQuotaExceeded},
{name: "pre-classified Permanent stays", op: "get", err: fmt.Errorf("x: %w", permanentf("bad %s", "thing")), want: provider.ErrPermanent},
{name: "pre-classified Transient stays", op: "get", err: fmt.Errorf("x: %w", transientf("blip %d", 1)), want: provider.ErrTransient},
{name: "pre-classified beats a libvirt code underneath", op: "get", err: fmt.Errorf("%w: %w", provider.ErrPermanent, libvirtErr(libvirt.ErrNoDomain, "x")), want: provider.ErrPermanent},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := classify(tc.op, tc.err); !errors.Is(got, tc.want) {
t.Errorf("classify(%q, %v) = %v, want %v", tc.op, tc.err, got, tc.want)
}
})
}
}
func TestHasCode(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
code libvirt.ErrorNumber
want bool
}{
{name: "matching code", err: libvirtErr(libvirt.ErrNoDomain, "x"), code: libvirt.ErrNoDomain, want: true},
{name: "matching code wrapped", err: fmt.Errorf("w: %w", libvirtErr(libvirt.ErrStorageVolExist, "x")), code: libvirt.ErrStorageVolExist, want: true},
{name: "other code", err: libvirtErr(libvirt.ErrNoDomain, "x"), code: libvirt.ErrNoStorageVol, want: false},
{name: "not a libvirt error", err: errors.New("x"), code: libvirt.ErrNoDomain, want: false},
{name: "nil", err: nil, code: libvirt.ErrNoDomain, want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := hasCode(tc.err, tc.code); got != tc.want {
t.Errorf("hasCode(%v, %v) = %v, want %v", tc.err, tc.code, got, tc.want)
}
})
}
}
func TestLibvirtErrorFields(t *testing.T) {
t.Parallel()
got := libvirtErrorFields(fmt.Errorf("w: %w", libvirtErr(libvirt.ErrNoDomain, "x")))
want := []any{"libvirtCode", 42, "libvirtError", "ErrNoDomain"}
if len(got) != len(want) {
t.Fatalf("libvirtErrorFields() = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("libvirtErrorFields()[%d] = %v (%T), want %v (%T)", i, got[i], got[i], want[i], want[i])
}
}
if fields := libvirtErrorFields(errors.New("x")); fields != nil {
t.Errorf("libvirtErrorFields(non-libvirt) = %v, want nil", fields)
}
if fields := libvirtErrorFields(libvirtErr(libvirt.ErrorNumber(9999), "x")); fields[3] != "ErrorNumber(9999)" {
t.Errorf("libvirtErrorFields(unknown code)[3] = %v, want ErrorNumber(9999)", fields[3])
}
}
func TestPermanentfAndTransientf(t *testing.T) {
t.Parallel()
perm := permanentf("host %q missing", "x")
if !errors.Is(perm, provider.ErrPermanent) || perm.Error() != `provider: permanent error: host "x" missing` {
t.Errorf("permanentf() = %q, want ErrPermanent with the message", perm)
}
trans := transientf("retry %d", 3)
if !errors.Is(trans, provider.ErrTransient) || trans.Error() != "provider: transient error: retry 3" {
t.Errorf("transientf() = %q, want ErrTransient with the message", trans)
}
}
// testPublicKey parses a fixed ed25519 public key so a knownhosts.KeyError
// with a Want entry can be built without generating keys.
func testPublicKey(t *testing.T) ssh.PublicKey {
t.Helper()
const line = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGcZBhnMPTyDwFn9lBhwIutqLb2bSlhcZoqpuWv7VuKI test"
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
if err != nil {
t.Fatalf("ParseAuthorizedKey: %v", err)
}
return key
}