baseVolumeName was base-<key>.<format>, byte-identical to what the kvm provider builds in the same pool -- for URL images too, since kvm takes hex(sum[:8]) and kvm2 takes hex(sum[:])[:16], the same eight bytes -- with a completely separate lock table. The shipped config has a live kvm-home entry and a commented-out kvm2-home entry on the same host, the same key debian-13 and the same default pool, and the comment invites the operator to enable it. Both integration tests use key test-image. On beelink both live overlays back onto kvm's base-debian-13.qcow2. Since cachedImage accepts any 64-hex marker when no sha256 is configured and never re-hashes the base, that meant two entries pointing one key at different URLs silently shared a file; a concurrent cold fetch let one entry delete the other's half-uploaded base and boot a guest from the remains; and a sha256 edit deleted the base in place under live overlays, which libvirt permits without complaint. The name now carries the provider entry: base-<entry>-<key>.<format>. imageSource gained an Entry field and resolveImage takes the provider name, so every production call site follows from imageSource.volumeName(). That closes the cross-entry sharing outright, kvm<->kvm2 and kvm2<->kvm2 alike, and turns the per-Provider imageLocks from a latent bug into a correct one: with names unique per entry, two entries cannot contend for a file, so no process-wide lock table is needed. The entry name now reaches a file name, so New checks it against the existing kvmIdentifier rule, exported as provider.IsKVMIdentifier rather than duplicated. Provider names are otherwise validated for all types at once and only for being non-empty and unique, so a name with a slash would have produced a volume nothing could address. One companion fix, because the naming alone does not stop it: cachedImage's digest-mismatch branch deleted the marker and the base and refetched. It now returns ErrPermanent naming both digests and telling the operator what to do. That branch was the remaining path that unlinks a good file under running domains, and the contract already said "add a new key instead" -- the code was contradicting it. The no-marker and non-hex-marker branches keep deleting, since there the base is debris. Not fixed, and said so in the docs rather than left to look closed: editing images.<key>.url is still a silent no-op, since nothing records the URL; and cross-process fencing still needs the deferred atomic publish (upload to a temp name, verify, publish), which lands where H2 put the discard. Migration is a non-event. The base-debian-13.qcow2 on beelink is kvm's, kvm keeps using it untouched, and kvm2 downloads its own copy once per host. Nothing is replaced under a running guest; the pool needs room for both, which the prerequisites now say. The eviction recipe in the guide named base-debian-13.qcow2, which after this change is kvm's file -- following it would have deleted the wrong provider's base. It now greps for the prefix and deletes the marker first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
482 lines
19 KiB
Go
482 lines
19 KiB
Go
package kvm2
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/digitalocean/go-libvirt"
|
|
"github.com/go-logr/logr"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/codes"
|
|
"go.opentelemetry.io/otel/trace"
|
|
|
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/tracing"
|
|
)
|
|
|
|
const (
|
|
// markerCapacityBytes is the marker volume's size: one lowercase hex
|
|
// sha256 digest, nothing else.
|
|
markerCapacityBytes = sha256.Size * 2
|
|
// markerReadCap bounds what is read back from a marker, so a wrong file
|
|
// under that name cannot grow memory.
|
|
markerReadCap = 1 << 10
|
|
// seedReserveBytes is what the quota precheck sets aside for the seed
|
|
// ISO next to the image and the overlay (spec entry 13).
|
|
seedReserveBytes = 16 << 20
|
|
)
|
|
|
|
var hexDigest = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
|
|
|
// imageSource is a resolved placement.image (spec entries 3 and 4): the
|
|
// catalogue entry, or a URL given directly with the catalogue defaults, plus
|
|
// the key its base volume is named after.
|
|
type imageSource struct {
|
|
Entry string
|
|
Key string
|
|
provider.KVMImage
|
|
}
|
|
|
|
func (s imageSource) volumeName() string { return baseVolumeName(s.Entry, s.Key, s.Format) }
|
|
func (s imageSource) markerName() string { return markerVolumeName(s.volumeName()) }
|
|
|
|
// resolveImage maps placement.image to a source. cfg is already defaulted,
|
|
// so a catalogue entry carries its format and firmware; a URL gets qcow2
|
|
// and uefi. entry is the provider entry's name, which the base volume is
|
|
// named after so two entries never share one file.
|
|
func resolveImage(entry string, cfg provider.KVMConfig, image string) (imageSource, error) {
|
|
if img, ok := cfg.Images[image]; ok {
|
|
return imageSource{Entry: entry, Key: image, KVMImage: img}, nil
|
|
}
|
|
if provider.IsHTTPURL(image) {
|
|
return imageSource{
|
|
Entry: entry,
|
|
Key: urlImageKey(image),
|
|
KVMImage: provider.KVMImage{URL: image, Format: provider.KVMFormatQcow2, Firmware: provider.KVMFirmwareUEFI},
|
|
}, nil
|
|
}
|
|
return imageSource{}, permanentf("placement.image %q is neither a key in kvm2.images nor an http(s) URL", image)
|
|
}
|
|
|
|
// lockImage takes the per-(host, volume) lock of spec entry 19, or gives up
|
|
// with ctx: a caller whose ctx ends while another caller's download runs
|
|
// returns promptly (contract §1.5) instead of waiting out a 20-minute
|
|
// transfer.
|
|
func (p *Provider) lockImage(ctx context.Context, hostName, volume string) (func(), error) {
|
|
release, err := p.imageLocks.acquire(ctx, hostName+"/"+volume)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: waiting for image %s on host %s: %w", provider.ErrTransient, volume, hostName, err)
|
|
}
|
|
return release, nil
|
|
}
|
|
|
|
// ensureImage makes the base image present and complete in the host's pool
|
|
// and returns its path (spec entry 4). be is the caller's pooled connection
|
|
// and carries the cheap checks; the upload itself goes over a connection of
|
|
// its own. Concurrent callers for the same (host, volume) queue on the
|
|
// lock: the first fetches, the rest find the marker.
|
|
func (p *Provider) ensureImage(ctx context.Context, host provider.KVMHost, pool libvirt.StoragePool, be backend, src imageSource) (string, error) {
|
|
unlock, err := p.lockImage(ctx, host.Name, src.volumeName())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer unlock()
|
|
log := p.logger(ctx).WithValues("host", host.Name, "volume", src.volumeName())
|
|
path, err := cachedImage(ctx, log, pool, be, src)
|
|
if err != nil || path != "" {
|
|
return path, err
|
|
}
|
|
vol, err := p.fetchImage(ctx, host, pool, be, src)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
path, err = be.StorageVolGetPath(ctx, vol)
|
|
if err != nil {
|
|
return "", fmt.Errorf("reading the path of base volume %s: %w", vol.Name, err)
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// cachedImage returns the base volume's path when the marker says the base
|
|
// is complete and its digest is the configured one (or none is configured).
|
|
// Anything else is cleared so the fetch starts from nothing, and "" is
|
|
// returned: a base without a marker is an upload that was cut off; a marker
|
|
// without a base, or one whose content is not a digest, is debris; a marker
|
|
// with a different digest means the catalogue entry changed.
|
|
func cachedImage(ctx context.Context, log logr.Logger, pool libvirt.StoragePool, be backend, src imageSource) (string, error) {
|
|
name, markerName := src.volumeName(), src.markerName()
|
|
marker, err := be.StorageVolLookupByName(ctx, pool, markerName)
|
|
switch {
|
|
case hasCode(err, libvirt.ErrNoStorageVol):
|
|
return "", deleteVolumeIfPresent(ctx, be, pool, name)
|
|
case err != nil:
|
|
return "", fmt.Errorf("looking up marker %s: %w", markerName, err)
|
|
}
|
|
digest, err := readMarker(ctx, be, marker)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
switch {
|
|
case !hexDigest.MatchString(digest):
|
|
log.V(1).Info("base image marker holds no digest; refetching")
|
|
case src.SHA256 != "" && !strings.EqualFold(digest, src.SHA256):
|
|
// Refusing rather than replacing: every overlay on this host names
|
|
// the base as its backing file, and libvirt will unlink it under
|
|
// running domains without a word.
|
|
return "", permanentf("base image %s is cached with sha256 %s but %s is configured; add the image under a new key and move Proxies to it, or delete the Proxies using it and evict the base by hand",
|
|
name, digest, strings.ToLower(src.SHA256))
|
|
default:
|
|
base, err := be.StorageVolLookupByName(ctx, pool, name)
|
|
switch {
|
|
case hasCode(err, libvirt.ErrNoStorageVol):
|
|
return "", deleteVolumeIfPresent(ctx, be, pool, markerName)
|
|
case err != nil:
|
|
return "", fmt.Errorf("looking up base volume %s: %w", name, err)
|
|
}
|
|
path, err := be.StorageVolGetPath(ctx, base)
|
|
if err != nil {
|
|
return "", fmt.Errorf("reading the path of base volume %s: %w", name, err)
|
|
}
|
|
return path, nil
|
|
}
|
|
return "", errors.Join(deleteVolumeIfPresent(ctx, be, pool, markerName), deleteVolumeIfPresent(ctx, be, pool, name))
|
|
}
|
|
|
|
// readMarker returns the digest a marker volume holds, lowercased and
|
|
// stripped of padding. The volume is 64 bytes; the read is capped anyway.
|
|
func readMarker(ctx context.Context, be backend, marker libvirt.StorageVol) (string, error) {
|
|
w := &cappedWriter{limit: markerReadCap}
|
|
if err := be.StorageVolDownload(ctx, marker, w, 0, 0, 0); err != nil {
|
|
return "", fmt.Errorf("reading marker %s: %w", marker.Name, err)
|
|
}
|
|
return strings.ToLower(strings.TrimRight(string(w.buf), "\x00 \r\n\t")), nil
|
|
}
|
|
|
|
// cappedWriter keeps the first limit bytes and accepts the rest without
|
|
// storing them, so a download into it is bounded whatever its length.
|
|
type cappedWriter struct {
|
|
limit int
|
|
buf []byte
|
|
}
|
|
|
|
func (w *cappedWriter) Write(b []byte) (int, error) {
|
|
if room := w.limit - len(w.buf); room > 0 {
|
|
w.buf = append(w.buf, b[:min(room, len(b))]...)
|
|
}
|
|
return len(b), nil
|
|
}
|
|
|
|
// deleteVolumeIfPresent removes the named volume; one that is already gone
|
|
// is success.
|
|
func deleteVolumeIfPresent(ctx context.Context, be backend, pool libvirt.StoragePool, name string) error {
|
|
vol, err := be.StorageVolLookupByName(ctx, pool, name)
|
|
if hasCode(err, libvirt.ErrNoStorageVol) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("looking up volume %s: %w", name, err)
|
|
}
|
|
if err := be.StorageVolDelete(ctx, vol, 0); err != nil && !hasCode(err, libvirt.ErrNoStorageVol) {
|
|
return fmt.Errorf("deleting volume %s: %w", name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fetchImage streams the image from its URL into a new base volume and then
|
|
// writes the marker, under the kvm2.download span (spec entry 17). The
|
|
// upload goes over a connection of its own (spec entry 18): abandoning a
|
|
// call kills the connection it ran on, and a 20-minute upload must not die
|
|
// because a poll for another Proxy was cancelled on the pooled one. The
|
|
// marker is written last, on the pooled connection, after
|
|
// StoragePoolRefresh made libvirt read the file's real format; its presence
|
|
// is what marks the base complete.
|
|
//
|
|
// Every exit between the base volume's creation and the marker deletes the
|
|
// base volume (best effort): the next attempt would delete it anyway on
|
|
// finding no marker, and removing it now frees the space at once.
|
|
func (p *Provider) fetchImage(ctx context.Context, host provider.KVMHost, pool libvirt.StoragePool, be backend, src imageSource) (libvirt.StorageVol, error) {
|
|
name := src.volumeName()
|
|
ctx, cancel := context.WithTimeout(ctx, defaultBudgets.transfer)
|
|
defer cancel()
|
|
ctx, span := tracing.StartSpan(ctx, tracing.Tracer(p.tracing...), "kvm2.download",
|
|
trace.WithSpanKind(trace.SpanKindClient),
|
|
trace.WithAttributes(
|
|
attribute.String("kvm2.host", host.Name),
|
|
attribute.String("kvm2.volume", name),
|
|
attribute.String("kvm2.url_host", urlHost(src.URL)),
|
|
attribute.Bool("kvm2.sha256_configured", src.SHA256 != ""),
|
|
))
|
|
defer span.End()
|
|
// Taken after the span starts, so the lines carry its ID.
|
|
log := p.logger(ctx).WithValues("host", host.Name, "volume", name)
|
|
start := time.Now()
|
|
|
|
// The body runs out of time before the surrounding budget does, so a
|
|
// mirror that stalls mid-image ends the upload through its reader
|
|
// rather than through the killed socket the budget would leave.
|
|
httpCtx, cancelHTTP := context.WithTimeout(ctx, defaultBudgets.transfer-transferGrace)
|
|
defer cancelHTTP()
|
|
resp, err := p.do(httpCtx, http.MethodGet, src.URL)
|
|
if err == nil {
|
|
err = checkResponse(src.URL, resp)
|
|
}
|
|
if err != nil {
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return libvirt.StorageVol{}, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
size := resp.ContentLength
|
|
span.SetAttributes(attribute.Int64("kvm2.bytes", size))
|
|
log.V(1).Info("fetching base image into host pool", "url", src.URL, "bytes", size)
|
|
|
|
vol, digest, err := p.uploadImage(ctx, host, pool, src, resp.Body, size)
|
|
if err != nil {
|
|
// uploadImage has returned, so its dedicated connection is closed
|
|
// and libvirtd has let go of the stream before the file goes.
|
|
p.discardVolume(ctx, log, be, pool, name)
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return libvirt.StorageVol{}, err
|
|
}
|
|
if err := be.StoragePoolRefresh(ctx, pool, 0); err != nil {
|
|
err = fmt.Errorf("refreshing storage pool %s after the upload: %w", pool.Name, err)
|
|
p.discardVolume(ctx, log, be, pool, name)
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return libvirt.StorageVol{}, err
|
|
}
|
|
if err := writeMarker(ctx, be, pool, src.markerName(), digest); err != nil {
|
|
p.discardVolume(ctx, log, be, pool, name)
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return libvirt.StorageVol{}, err
|
|
}
|
|
log.V(1).Info("base image cached", "bytes", size, "duration", time.Since(start), "sha256", digest)
|
|
return vol, nil
|
|
}
|
|
|
|
// uploadImage creates the base volume and streams body into it over a
|
|
// dedicated connection with a running sha256, then checks length and
|
|
// digest. The error says why: a body that ended early is Transient, a
|
|
// digest that differs from the configured one is Permanent. Discarding
|
|
// the volume is the caller's, once this connection is closed.
|
|
func (p *Provider) uploadImage(ctx context.Context, host provider.KVMHost, pool libvirt.StoragePool, src imageSource, body io.Reader, size int64) (libvirt.StorageVol, string, error) {
|
|
name := src.volumeName()
|
|
xmlDoc, err := buildVolumeXML(volumeSpec{Name: name, CapacityBytes: size, Format: src.Format})
|
|
if err != nil {
|
|
return libvirt.StorageVol{}, "", err
|
|
}
|
|
fresh, err := p.hosts.dialFresh(ctx, host)
|
|
if err != nil {
|
|
return libvirt.StorageVol{}, "", fmt.Errorf("dialling host %s for the image upload: %w", host.Name, err)
|
|
}
|
|
defer func() { _ = fresh.Close() }()
|
|
vol, err := fresh.StorageVolCreateXML(ctx, pool, xmlDoc, 0)
|
|
if err != nil {
|
|
return libvirt.StorageVol{}, "", fmt.Errorf("creating base volume %s: %w", name, err)
|
|
}
|
|
|
|
hash := sha256.New()
|
|
counted := &countingReader{r: io.TeeReader(body, hash)}
|
|
uploadErr := fresh.StorageVolUpload(ctx, vol, counted, 0, uint64(size), 0)
|
|
read, readErr := counted.state()
|
|
var digest string
|
|
if uploadErr == nil {
|
|
// Only then has the upload stopped reading; an abandoned call's
|
|
// goroutine may still be writing into the hash.
|
|
digest = hex.EncodeToString(hash.Sum(nil))
|
|
}
|
|
switch {
|
|
case readErr != nil:
|
|
err = fmt.Errorf("%w: image %s: the response ended after %d of %d bytes: %w", provider.ErrTransient, src.URL, read, size, readErr)
|
|
case uploadErr != nil:
|
|
err = fmt.Errorf("uploading base volume %s: %w", name, uploadErr)
|
|
case read != size:
|
|
err = transientf("image %s: short read, %d of %d bytes", src.URL, read, size)
|
|
case src.SHA256 != "" && !strings.EqualFold(digest, src.SHA256):
|
|
err = permanentf("image %s: sha256 %s does not match the configured %s", src.URL, digest, strings.ToLower(src.SHA256))
|
|
}
|
|
if err != nil {
|
|
return libvirt.StorageVol{}, "", err
|
|
}
|
|
return vol, digest, nil
|
|
}
|
|
|
|
// countingReader counts what the upload consumed and keeps the first
|
|
// non-EOF error the source returned. The adapter can return from an
|
|
// abandoned upload while go-libvirt still reads, so the fields are guarded.
|
|
type countingReader struct {
|
|
r io.Reader
|
|
mu sync.Mutex
|
|
n int64
|
|
err error
|
|
}
|
|
|
|
func (c *countingReader) Read(b []byte) (int, error) {
|
|
n, err := c.r.Read(b)
|
|
c.mu.Lock()
|
|
c.n += int64(n)
|
|
if err != nil && err != io.EOF && c.err == nil {
|
|
c.err = err
|
|
}
|
|
c.mu.Unlock()
|
|
return n, err
|
|
}
|
|
|
|
func (c *countingReader) state() (int64, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.n, c.err
|
|
}
|
|
|
|
// discardVolume removes a base volume a failed fetch left behind. It runs
|
|
// with ctx's values but not its cancellation: the fetch may have failed
|
|
// because ctx ended, and the adapter refuses calls on a finished ctx. The
|
|
// budget bounds it on its own. Failure is logged, not returned: the next
|
|
// attempt finds the volume without a marker and deletes it then.
|
|
func (p *Provider) discardVolume(ctx context.Context, log logr.Logger, be backend, pool libvirt.StoragePool, name string) {
|
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), defaultBudgets.rpc)
|
|
defer cancel()
|
|
if err := deleteVolumeIfPresent(ctx, be, pool, name); err != nil {
|
|
log.V(1).Info("incomplete base volume left behind; the next attempt removes it", "error", err.Error())
|
|
}
|
|
}
|
|
|
|
// writeMarker creates the raw marker volume and uploads the digest into it.
|
|
// A marker that could not be filled is removed again: an empty marker next
|
|
// to a complete base would read as debris and cost a refetch.
|
|
func writeMarker(ctx context.Context, be backend, pool libvirt.StoragePool, name, digest string) error {
|
|
xmlDoc, err := buildVolumeXML(volumeSpec{Name: name, CapacityBytes: markerCapacityBytes, Format: provider.KVMFormatRaw})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
vol, err := be.StorageVolCreateXML(ctx, pool, xmlDoc, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("creating marker %s: %w", name, err)
|
|
}
|
|
if err := be.StorageVolUpload(ctx, vol, strings.NewReader(digest), 0, uint64(len(digest)), 0); err != nil {
|
|
_ = deleteVolumeIfPresent(ctx, be, pool, name)
|
|
return fmt.Errorf("writing marker %s: %w", name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// do issues one request bound to ctx. Accept-Encoding is fixed to identity
|
|
// because a transparently decompressed body reports no Content-Length. A
|
|
// transport error is Transient.
|
|
func (p *Provider) do(ctx context.Context, method, rawURL string) (*http.Response, error) {
|
|
req, err := http.NewRequestWithContext(ctx, method, rawURL, nil)
|
|
if err != nil {
|
|
return nil, permanentf("image %s: %v", rawURL, err)
|
|
}
|
|
req.Header.Set("Accept-Encoding", "identity")
|
|
resp, err := p.http.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: fetching image %s: %w", provider.ErrTransient, rawURL, err)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// checkResponse classifies the response at the source (spec entry 13) and
|
|
// closes its body when it is unusable. 404, 403 and 410, and every other
|
|
// 4xx, mean the catalogue entry is wrong; 5xx and 429 are worth a retry. A
|
|
// missing Content-Length is Permanent: the volume's capacity must be
|
|
// declared before the upload, and a mirror that only chunks is one the
|
|
// catalogue must not point at.
|
|
func checkResponse(rawURL string, resp *http.Response) error {
|
|
var err error
|
|
code := resp.StatusCode
|
|
switch {
|
|
case code >= 200 && code < 300:
|
|
case code == http.StatusTooManyRequests || code >= 500:
|
|
err = transientf("image %s: HTTP %s", rawURL, resp.Status)
|
|
case code >= 400:
|
|
err = permanentf("image %s: HTTP %s", rawURL, resp.Status)
|
|
default:
|
|
err = transientf("image %s: unexpected HTTP %s", rawURL, resp.Status)
|
|
}
|
|
if err == nil && resp.ContentLength < 0 {
|
|
err = permanentf("image %s: response has no Content-Length; the volume capacity must be known before upload", rawURL)
|
|
}
|
|
if err == nil && resp.ContentLength == 0 {
|
|
err = permanentf("image %s: response is empty (Content-Length 0)", rawURL)
|
|
}
|
|
if err != nil {
|
|
_ = resp.Body.Close()
|
|
}
|
|
return err
|
|
}
|
|
|
|
// urlHost is the only part of the URL a span carries (contract §9).
|
|
func urlHost(rawURL string) string {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return u.Host
|
|
}
|
|
|
|
// imageSizeIfUncached returns the image's Content-Length when the base is
|
|
// not cached on the host, and 0 when the marker is present, for the quota
|
|
// precheck of spec entry 13. It never downloads: HEAD first, and a GET whose
|
|
// body is closed at once when HEAD answers anything but 2xx, since servers
|
|
// refuse HEAD with 405, 403 or 501 alike; the GET's status is what gets
|
|
// classified. A marker is taken at face value here; ensureImage decides
|
|
// whether it still matches.
|
|
func (p *Provider) imageSizeIfUncached(ctx context.Context, be backend, pool libvirt.StoragePool, src imageSource) (int64, error) {
|
|
_, err := be.StorageVolLookupByName(ctx, pool, src.markerName())
|
|
switch {
|
|
case err == nil:
|
|
return 0, nil
|
|
case !hasCode(err, libvirt.ErrNoStorageVol):
|
|
return 0, fmt.Errorf("looking up marker %s: %w", src.markerName(), err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, defaultBudgets.rpc)
|
|
defer cancel()
|
|
resp, err := p.do(ctx, http.MethodHead, src.URL)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
_ = resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
if resp, err = p.do(ctx, http.MethodGet, src.URL); err != nil {
|
|
return 0, err
|
|
}
|
|
_ = resp.Body.Close()
|
|
}
|
|
if err := checkResponse(src.URL, resp); err != nil {
|
|
return 0, err
|
|
}
|
|
return resp.ContentLength, nil
|
|
}
|
|
|
|
// checkPoolSpace is the precheck of spec entry 13: the pool must be running
|
|
// and have needed bytes available, or Create returns ErrQuotaExceeded and
|
|
// the reconciler waits QuotaRetry instead of failing the volume creation.
|
|
func checkPoolSpace(ctx context.Context, be backend, pool libvirt.StoragePool, poolName string, needed uint64) error {
|
|
state, _, _, available, err := be.StoragePoolGetInfo(ctx, pool)
|
|
if err != nil {
|
|
return fmt.Errorf("reading storage pool %s: %w", poolName, err)
|
|
}
|
|
if s := libvirt.StoragePoolState(state); s != libvirt.StoragePoolRunning {
|
|
return permanentf("storage pool %s is not running (state %s)", poolName, s)
|
|
}
|
|
if available < needed {
|
|
return fmt.Errorf("%w: storage pool %s has %d bytes available, %d needed", provider.ErrQuotaExceeded, poolName, available, needed)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// neededBytes is what one Create commits to the pool: the image when it is
|
|
// not cached yet (0 otherwise), the overlay's capacity and the seed reserve.
|
|
func neededBytes(imageBytes int64, diskSizeGB int64) uint64 {
|
|
return uint64(max(imageBytes, 0)) + uint64(max(diskSizeGB, 0))<<30 + seedReserveBytes
|
|
}
|