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>
This commit is contained in:
2026-09-16 16:38:48 +00:00
co-authored by Claude Fable 5.1
parent 4bd9aae03b
commit 95768281be
14 changed files with 1080 additions and 12 deletions
+97
View File
@@ -0,0 +1,97 @@
package kvm2
import (
"errors"
"fmt"
"strings"
"github.com/digitalocean/go-libvirt"
"golang.org/x/crypto/ssh/knownhosts"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
// permanentf and transientf classify an error at the source, so classify
// keeps that class no matter what wraps it later.
func permanentf(format string, args ...any) error {
return fmt.Errorf("%w: %s", provider.ErrPermanent, fmt.Sprintf(format, args...))
}
func transientf(format string, args ...any) error {
return fmt.Errorf("%w: %s", provider.ErrTransient, fmt.Sprintf(format, args...))
}
// sshAuthFailed is the substring x/crypto/ssh puts in every authentication
// failure ("ssh: handshake failed: ssh: unable to authenticate, attempted
// methods [...]").
const sshAuthFailed = "unable to authenticate"
// classify maps any error reaching the provider's boundary to one of the
// four contract sentinels, per spec entry 13. op is the contract operation:
// a vanished domain or volume inside Create is Transient, because the
// contract forbids NotFound from Create.
func classify(op string, err error) error {
if err == nil {
return provider.ErrTransient
}
for _, class := range []error{provider.ErrNotFound, provider.ErrQuotaExceeded, provider.ErrPermanent, provider.ErrTransient} {
if errors.Is(err, class) {
return class
}
}
if code, ok := libvirtCode(err); ok {
return classifyLibvirt(op, code)
}
var keyErr *knownhosts.KeyError
if errors.As(err, &keyErr) {
return provider.ErrPermanent
}
if strings.Contains(err.Error(), sshAuthFailed) {
return provider.ErrPermanent
}
// Everything else retries: a missing key or known_hosts file (the
// optional Secret has not landed yet), context cancellation and
// deadlines, net.OpError, io.EOF, and anything unrecognised.
return provider.ErrTransient
}
func classifyLibvirt(op string, code libvirt.ErrorNumber) error {
switch code {
case libvirt.ErrNoDomain, libvirt.ErrNoStorageVol:
if op == "create" {
return provider.ErrTransient
}
return provider.ErrNotFound
case libvirt.ErrNoStoragePool, libvirt.ErrXMLError, libvirt.ErrNoSupport, libvirt.ErrInvalidArg,
libvirt.ErrAuthFailed, libvirt.ErrAccessDenied:
return provider.ErrPermanent
default:
return provider.ErrTransient
}
}
// libvirtCode extracts the libvirt error number from err, if it is or wraps
// a libvirt.Error. That type has a value receiver, so errors.As needs a
// value target.
func libvirtCode(err error) (libvirt.ErrorNumber, bool) {
var le libvirt.Error
if !errors.As(err, &le) {
return 0, false
}
return libvirt.ErrorNumber(le.Code), true
}
func hasCode(err error, code libvirt.ErrorNumber) bool {
got, ok := libvirtCode(err)
return ok && got == code
}
// libvirtErrorFields returns the key/value pairs a log line carries for a
// libvirt error, or nil when err is not one.
func libvirtErrorFields(err error) []any {
code, ok := libvirtCode(err)
if !ok {
return nil
}
return []any{"libvirtCode", int(code), "libvirtError", code.String()}
}