// Package kvm2 implements the provider contract on libvirt/KVM hypervisors // reached over SSH: the host's libvirt unix socket is forwarded through the // SSH connection and driven with go-libvirt's RPC client. The design is in // docs/kvm2-provider-spec.md. package kvm2 import ( "context" "errors" "net/http" "os" "sync" "time" "github.com/go-logr/logr" logf "sigs.k8s.io/controller-runtime/pkg/log" "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider" "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/tracing" ) const ( defaultSocket = "/var/run/libvirt/libvirt-sock" defaultPool = "default" defaultDiskSizeGB = 10 ) // Provider is one configured kvm2 entry: a set of hosts sharing one SSH key // and one image and machine-type catalogue. type Provider struct { name string cfg provider.KVMConfig now func() time.Time hosts *hostTable http *http.Client budgets budgets tracing []tracing.Option // imageLocks serialises image fetches per (host, volume), spec entry 19. imageMu sync.Mutex imageLocks map[string]chan struct{} } // providerOption adjusts what newProvider builds; tests inject an HTTP // client bound to a test server, a span recorder and shorter budgets. type providerOption func(*Provider) func withHTTPClient(c *http.Client) providerOption { return func(p *Provider) { p.http = c } } func withBudgets(b budgets) providerOption { return func(p *Provider) { p.budgets = b } } func withTracing(opts ...tracing.Option) providerOption { return func(p *Provider) { p.tracing = opts } } var _ provider.Provider = (*Provider)(nil) // New builds a Provider from pc.KVM2. It never dials a host: the credential // files are only checked for existence, and a missing one is a warning, not // an error, because the Secret mount is optional and may land later. func New(ctx context.Context, pc provider.ProviderConfig) (provider.Provider, error) { cfg := provider.KVMConfig{} if pc.KVM2 != nil { cfg = *pc.KVM2 } cfg = withDefaults(cfg) p := newProvider(pc.Name, cfg, newLibvirtDialer(pc.Name, cfg)) log := p.logger(ctx) if _, err := os.Stat(p.cfg.SSHKeyFile); err != nil { log.Info("WARNING: ssh key file is not readable; every call fails until it is", "path", p.cfg.SSHKeyFile, "reason", err.Error()) } switch { case p.cfg.InsecureIgnoreHostKey: log.Info("WARNING: insecureIgnoreHostKey is set; host keys are not verified") default: if _, err := os.Stat(p.cfg.KnownHostsFile); err != nil { log.Info("WARNING: known_hosts file is not readable; every call fails until it is", "path", p.cfg.KnownHostsFile, "reason", err.Error()) } } return p, nil } // newProvider is the constructor tests use: d hands out the backends, the // production one being newLibvirtDialer. Without options the image // downloads use http.DefaultClient (system CA roots, no client timeout: the // per-request ctx carries the transfer budget) and the default budgets. func newProvider(name string, cfg provider.KVMConfig, d dialer, opts ...providerOption) *Provider { p := &Provider{ name: name, cfg: withDefaults(cfg), now: time.Now, hosts: newHostTable(d), http: http.DefaultClient, budgets: defaultBudgets, imageLocks: make(map[string]chan struct{}), } for _, opt := range opts { opt(p) } if p.http == nil { p.http = http.DefaultClient } return p } // withDefaults returns cfg with every optional field filled in, so the rest // of the package never re-checks for an empty value. The hosts and images // are copied, not shared with the caller. func withDefaults(cfg provider.KVMConfig) provider.KVMConfig { if cfg.DiskSizeGB == 0 { cfg.DiskSizeGB = defaultDiskSizeGB } hosts := make([]provider.KVMHost, len(cfg.Hosts)) for i, h := range cfg.Hosts { if h.Socket == "" { h.Socket = defaultSocket } if h.Pool == "" { h.Pool = defaultPool } hosts[i] = h } cfg.Hosts = hosts images := make(map[string]provider.KVMImage, len(cfg.Images)) for k, img := range cfg.Images { if img.Format == "" { img.Format = provider.KVMFormatQcow2 } if img.Firmware == "" { img.Firmware = provider.KVMFirmwareUEFI } images[k] = img } cfg.Images = images return cfg } func (p *Provider) logger(ctx context.Context) logr.Logger { return logf.FromContext(ctx).WithName("kvm2").WithValues("provider", p.name) } // host resolves a placement.zone or a providerID's host segment to a // configured host. An empty zone is allowed only when exactly one host is // configured. func (p *Provider) host(zone string) (provider.KVMHost, error) { if zone == "" { if len(p.cfg.Hosts) == 1 { return p.cfg.Hosts[0], nil } return provider.KVMHost{}, permanentf("placement.zone is required when kvm2.hosts lists more than one host") } for _, h := range p.cfg.Hosts { if h.Name == zone { return h, nil } } return provider.KVMHost{}, permanentf("host %q is not in kvm2.hosts", zone) } // wrapErr is the single exit for errors: it classifies err and wraps it with // the configured provider name, op and id. func (p *Provider) wrapErr(op, id string, err error) error { return provider.Wrap(classify(op, err), op, p.name, id, err) } var errNotImplemented = errors.New("kvm2: not implemented") // Create is not implemented yet. func (p *Provider) Create(_ context.Context, _ provider.CreateRequest) (string, error) { return "", provider.Wrap(provider.ErrPermanent, "create", p.name, "", errNotImplemented) } // Get is not implemented yet. func (p *Provider) Get(_ context.Context, _ string) (*provider.Instance, error) { return nil, provider.Wrap(provider.ErrPermanent, "get", p.name, "", errNotImplemented) } // Delete is not implemented yet. func (p *Provider) Delete(_ context.Context, _ string) error { return provider.Wrap(provider.ErrPermanent, "delete", p.name, "", errNotImplemented) } // ListByTag is not implemented yet. func (p *Provider) ListByTag(_ context.Context) ([]provider.Instance, error) { return nil, provider.Wrap(provider.ErrPermanent, "list", p.name, "", errNotImplemented) }