Files
egress-proxies-operator/internal/provider/kvm/conn.go
T
kacerrandClaude Fable 5.1 220967660d Give the manager a writable /tmp for seed ISOs; keep the libvirt connection on classified errors
The manager runs with a read-only root filesystem and the seed ISO
writer stages files in a temp directory, so the first in-cluster Create
failed with "creating iso writer: read-only file system". Mount a 64 MiB
emptyDir on /tmp.

That error is classified ErrPermanent by writeSeed, yet hostPool.call
treated every non-libvirt error as a transport failure and dropped the
cached SSH connection, redialling on each retry. Errors that already
carry a provider sentinel are local verdicts, not evidence about the
link; leave the connection alone for those.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 21:06:34 +00:00

321 lines
10 KiB
Go

package kvm
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"sync"
"time"
libvirt "github.com/digitalocean/go-libvirt"
"github.com/go-logr/logr"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
const (
defaultHandshakeTimeout = 30 * time.Second
keepaliveInterval = 15 * time.Second
)
// libvirtAPI is the slice of *libvirt.Libvirt the provider uses, and the
// test seam: the fake in kvm_test.go implements exactly this.
type libvirtAPI interface {
DomainLookupByName(name string) (libvirt.Domain, error)
DomainDefineXML(xml string) (libvirt.Domain, error)
DomainCreate(d libvirt.Domain) error
DomainDestroy(d libvirt.Domain) error
DomainUndefineFlags(d libvirt.Domain, flags libvirt.DomainUndefineFlagsValues) error
DomainGetState(d libvirt.Domain, flags uint32) (state int32, reason int32, err error)
DomainGetMetadata(d libvirt.Domain, typ int32, uri libvirt.OptString, flags libvirt.DomainModificationImpact) (string, error)
DomainInterfaceAddresses(d libvirt.Domain, source uint32, flags uint32) ([]libvirt.DomainInterface, error)
ConnectListAllDomains(needResults int32, flags libvirt.ConnectListAllDomainsFlags) ([]libvirt.Domain, uint32, error)
StoragePoolLookupByName(name string) (libvirt.StoragePool, error)
StoragePoolRefresh(pool libvirt.StoragePool, flags uint32) error
StorageVolLookupByName(pool libvirt.StoragePool, name string) (libvirt.StorageVol, error)
StorageVolGetPath(vol libvirt.StorageVol) (string, error)
StorageVolCreateXML(pool libvirt.StoragePool, xml string, flags libvirt.StorageVolCreateFlags) (libvirt.StorageVol, error)
StorageVolUpload(vol libvirt.StorageVol, r io.Reader, offset, length uint64, flags libvirt.StorageVolUploadFlags) error
StorageVolDownload(vol libvirt.StorageVol, w io.Writer, offset, length uint64, flags libvirt.StorageVolDownloadFlags) error
StorageVolDelete(vol libvirt.StorageVol, flags libvirt.StorageVolDeleteFlags) error
}
// hostClient is one live connection to a hypervisor.
type hostClient struct {
api libvirtAPI
// done is closed once the connection is known to be lost. A nil channel
// never fires, which suits fakes.
done <-chan struct{}
close func()
}
func (c *hostClient) alive() bool {
select {
case <-c.done:
return false
default:
return true
}
}
// hostDialer opens a connection to one host. The SSH implementation is the
// real one; tests inject their own.
type hostDialer func(ctx context.Context, host provider.KVMHost) (*hostClient, error)
// hostPool caches one connection per host, dialing lazily and redialing
// after the connection drops. Dials for different hosts don't block each
// other; concurrent callers for the same host share a single dial.
type hostPool struct {
dial hostDialer
mu sync.Mutex
entries map[string]*hostEntry
}
type hostEntry struct {
mu sync.Mutex
client *hostClient
}
func newHostPool(dial hostDialer) *hostPool {
return &hostPool{dial: dial, entries: make(map[string]*hostEntry)}
}
func (p *hostPool) entry(name string) *hostEntry {
p.mu.Lock()
defer p.mu.Unlock()
e, ok := p.entries[name]
if !ok {
e = &hostEntry{}
p.entries[name] = e
}
return e
}
func (p *hostPool) get(ctx context.Context, log logr.Logger, host provider.KVMHost) (*hostClient, error) {
e := p.entry(host.Name)
e.mu.Lock()
defer e.mu.Unlock()
if e.client != nil {
if e.client.alive() {
return e.client, nil
}
log.V(1).Info("libvirt connection lost, redialing", "address", host.Address)
e.client.close()
e.client = nil
}
start := time.Now()
c, err := p.dial(ctx, host)
if err != nil {
log.V(1).Info("libvirt host dial failed", "address", host.Address, "duration", time.Since(start).Round(time.Millisecond), "error", err.Error())
return nil, fmt.Errorf("connecting to host %s (%s): %w", host.Name, host.Address, err)
}
log.V(1).Info("libvirt host connected", "address", host.Address, "duration", time.Since(start).Round(time.Millisecond))
e.client = c
return c, nil
}
// drop closes and forgets c if it is still the cached client for host, so a
// connection that was already replaced by a redial is left alone.
func (p *hostPool) drop(host string, c *hostClient) {
e := p.entry(host)
e.mu.Lock()
defer e.mu.Unlock()
if e.client == c {
e.client = nil
}
c.close()
}
// call runs fn against host's connection. go-libvirt RPCs take no context
// and block until the daemon answers, so when ctx ends first the connection
// is closed — which unblocks fn with an error — and ctx.Err() is returned.
// A transport error also drops the cached connection so the next call
// redials instead of reusing a dead link. "Transport error" means anything
// that is neither a libvirt.Error nor an error fn already classified with a
// provider sentinel (a failed seed build, a bad placement): those are local
// verdicts, not evidence about the link. When log has V(1) enabled, every
// RPC fn makes is traced through it (see tracingAPI), as are connection
// events.
func (p *hostPool) call(ctx context.Context, log logr.Logger, host provider.KVMHost, fn func(api libvirtAPI) error) error {
log = log.WithName("libvirt")
c, err := p.get(ctx, log, host)
if err != nil {
return err
}
api := c.api
if log.V(1).Enabled() {
api = &tracingAPI{inner: c.api, log: log}
}
err = awaitOrCancel(ctx, func() error { return fn(api) }, func() {
log.V(1).Info("libvirt connection dropped", "reason", "context cancelled", "error", ctx.Err().Error())
p.drop(host.Name, c)
})
if err != nil && !isLibvirtError(err) && !isClassified(err) && !errors.Is(err, ctx.Err()) {
log.V(1).Info("libvirt connection dropped", "reason", "transport error", "error", err.Error())
p.drop(host.Name, c)
}
return err
}
// awaitOrCancel runs fn in the background and returns its result, unless ctx
// ends first: then abort is invoked (it must make fn return eventually, e.g.
// by closing the connection fn is blocked on) and ctx.Err() is returned. The
// abandoned fn's result is discarded.
func awaitOrCancel(ctx context.Context, fn func() error, abort func()) error {
errc := make(chan error, 1)
go func() { errc <- fn() }()
select {
case err := <-errc:
return err
case <-ctx.Done():
abort()
return ctx.Err()
}
}
// sshAuth holds the pieces of the SSH client config shared by every host:
// the private key and the host-key policy, both loaded once at startup so a
// missing or unreadable file fails the operator fast rather than on the
// first Create.
type sshAuth struct {
signer ssh.Signer
hostKey ssh.HostKeyCallback
}
func newSSHAuth(cfg provider.KVMConfig) (*sshAuth, error) {
key, err := os.ReadFile(cfg.SSHKeyFile)
if err != nil {
return nil, fmt.Errorf("reading kvm.sshKeyFile: %w", err)
}
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return nil, fmt.Errorf("parsing kvm.sshKeyFile: %w", err)
}
hostKey := ssh.InsecureIgnoreHostKey() //nolint:gosec // opt-in via config, documented as insecure
if !cfg.InsecureIgnoreHostKey {
hostKey, err = knownhosts.New(cfg.KnownHostsFile)
if err != nil {
return nil, fmt.Errorf("loading kvm.knownHostsFile: %w", err)
}
}
return &sshAuth{signer: signer, hostKey: hostKey}, nil
}
// dialer returns the production hostDialer: SSH to the host, forward its
// libvirt unix socket through the connection, and open a libvirt session
// over that.
func (a *sshAuth) dialer() hostDialer {
return func(ctx context.Context, host provider.KVMHost) (*hostClient, error) {
client, err := a.dialSSH(ctx, host)
if err != nil {
return nil, err
}
hc, err := connectLibvirt(ctx, client, host.Socket)
if err != nil {
client.Close()
return nil, err
}
return hc, nil
}
}
// dialSSH establishes the SSH connection. The handshake is bounded by ctx's
// deadline, or defaultHandshakeTimeout when ctx has none, since a
// half-open hypervisor must not pin a reconcile worker indefinitely.
func (a *sshAuth) dialSSH(ctx context.Context, host provider.KVMHost) (*ssh.Client, error) {
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(defaultHandshakeTimeout)
}
var d net.Dialer
nc, err := d.DialContext(ctx, "tcp", host.Address)
if err != nil {
return nil, fmt.Errorf("ssh dial: %w", err)
}
_ = nc.SetDeadline(deadline)
conn, chans, reqs, err := ssh.NewClientConn(nc, host.Address, &ssh.ClientConfig{
User: host.User,
Auth: []ssh.AuthMethod{ssh.PublicKeys(a.signer)},
HostKeyCallback: a.hostKey,
})
if err != nil {
nc.Close()
return nil, fmt.Errorf("ssh handshake: %w", err)
}
_ = nc.SetDeadline(time.Time{})
return ssh.NewClient(conn, chans, reqs), nil
}
// unixForward adapts an SSH client to go-libvirt's socket.Dialer: each Dial
// opens a direct-streamlocal channel to the libvirt socket on the host.
type unixForward struct {
client *ssh.Client
path string
}
func (u unixForward) Dial() (net.Conn, error) {
return u.client.Dial("unix", u.path)
}
// connectLibvirt opens the libvirt session over the SSH client and starts a
// keepalive loop so a dead link is noticed within roughly two intervals
// rather than at the TCP stack's leisure. Closing the returned client tears
// down the SSH connection, which in turn ends the libvirt session.
func connectLibvirt(ctx context.Context, client *ssh.Client, socket string) (*hostClient, error) {
lv := libvirt.NewWithDialer(unixForward{client: client, path: socket})
if err := awaitOrCancel(ctx, lv.Connect, func() { client.Close() }); err != nil {
return nil, fmt.Errorf("libvirt connect via %s: %w", socket, err)
}
stop := make(chan struct{})
var once sync.Once
go keepalive(client, keepaliveInterval, stop)
return &hostClient{
api: lv,
done: lv.Disconnected(),
close: func() {
once.Do(func() {
close(stop)
client.Close()
})
},
}, nil
}
// keepalive sends OpenSSH keepalive requests until stop closes; a failed or
// unanswered request closes the client, which surfaces as Disconnected on
// the libvirt side and makes the pool redial.
func keepalive(client *ssh.Client, interval time.Duration, stop <-chan struct{}) {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
}
replied := make(chan error, 1)
go func() {
_, _, err := client.SendRequest("keepalive@openssh.com", true, nil)
replied <- err
}()
select {
case <-stop:
return
case err := <-replied:
if err != nil {
client.Close()
return
}
case <-time.After(interval):
client.Close()
return
}
}
}