feat(vm-provision): let Proxmox download cloud images into an import storage
Provisioning downloaded every cloud image over SSH into /var/lib/vz/template/netork-images, on the node's root filesystem. On a small root that fills up and takes Proxmox down with it (netOrk #480). When the node has an active storage with content type "import" (Proxmox 8.2+), Proxmox now does it itself: download-url with checksum verification into that storage, then import-from as the root disk. The file is named after a hash of the full URL and reused when present. Proxmox takes the format from the extension and has no ".img", so Ubuntu's qcow2 .img is stored as .qcow2 -- a wrong guess fails at import instead of attaching a qcow2 container as a raw disk. Without an import storage, or for an image type Proxmox cannot import, the SSH download is used as before.
This commit is contained in:
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import yaml
|
||||
from typing import Any, Dict, List
|
||||
@@ -24,6 +25,43 @@ _logger = logging.getLogger(__name__)
|
||||
# download cost once.
|
||||
_IMAGE_CACHE_DIR = "/var/lib/vz/template/netork-images"
|
||||
|
||||
# Proxmox reads an import volume's format off its extension and accepts only
|
||||
# these. Ubuntu ships its qcow2 cloud images as ".img", so that is stored as
|
||||
# qcow2: if an .img is really raw, the import fails loudly, whereas guessing
|
||||
# raw for a qcow2 would attach the qcow2 container as a raw disk silently.
|
||||
_IMPORT_EXTENSIONS = {"qcow2": "qcow2", "raw": "raw", "vmdk": "vmdk", "img": "qcow2"}
|
||||
|
||||
# Characters Proxmox keeps in a content file name (PVE::Storage's
|
||||
# SAFE_CHAR_CLASS_RE); anything else it would rewrite behind our back.
|
||||
_UNSAFE_FILENAME_CHARS = re.compile(r"[^A-Za-z0-9\-.+=_]")
|
||||
|
||||
|
||||
def _url_key(image_url: str) -> str:
|
||||
"""Short hash of the full URL — Ubuntu and others publish a new build
|
||||
under the same basename every day, so the basename alone is no cache key."""
|
||||
return hashlib.sha256(image_url.encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
def _split_checksum(image_checksum: str) -> tuple[str, str]:
|
||||
"""``"<algo>:<hex>"`` as ``(algo, hex)``; the algorithm defaults to sha256."""
|
||||
algo, _, expected = image_checksum.partition(":")
|
||||
return (algo or "sha256").lower(), expected
|
||||
|
||||
|
||||
def _import_volume_name(image_url: str) -> str | None:
|
||||
"""The file name *image_url* gets in an import storage.
|
||||
|
||||
None when Proxmox cannot import the image's type at all (compressed,
|
||||
ISO, no extension) — provisioning then downloads it over SSH as before.
|
||||
"""
|
||||
basename = image_url.rstrip("/").rsplit("/", 1)[-1]
|
||||
stem, dot, ext = basename.rpartition(".")
|
||||
extension = _IMPORT_EXTENSIONS.get(ext.lower()) if dot and stem else None
|
||||
if extension is None:
|
||||
return None
|
||||
safe_stem = _UNSAFE_FILENAME_CHARS.sub("_", stem)
|
||||
return f"netork-{_url_key(image_url)}-{safe_stem}.{extension}"
|
||||
|
||||
|
||||
class ProxmoxVMProvisionMixin:
|
||||
"""Mixin to add VM provisioning to ProxmoxDriver."""
|
||||
@@ -89,11 +127,9 @@ class ProxmoxVMProvisionMixin:
|
||||
before raising.
|
||||
"""
|
||||
filename = image_url.rstrip("/").rsplit("/", 1)[-1]
|
||||
url_hash = hashlib.sha256(image_url.encode()).hexdigest()[:12]
|
||||
local_path = f"{_IMAGE_CACHE_DIR}/{url_hash}-{filename}"
|
||||
local_path = f"{_IMAGE_CACHE_DIR}/{_url_key(image_url)}-{filename}"
|
||||
|
||||
algo, _, expected = (image_checksum or "").partition(":")
|
||||
algo = (algo or "sha256").lower()
|
||||
algo, expected = _split_checksum(image_checksum or "")
|
||||
|
||||
max_attempts = 2
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
@@ -133,6 +169,125 @@ class ProxmoxVMProvisionMixin:
|
||||
|
||||
raise AssertionError("unreachable") # loop always returns or raises above
|
||||
|
||||
def _find_import_storage(self) -> str | None:
|
||||
"""The first storage on this node that accepts content "import".
|
||||
|
||||
Such a storage (Proxmox 8.2+) can take a cloud image straight from its
|
||||
URL. Inactive storages (typically a share that is not mounted) are
|
||||
skipped rather than failed on: the SSH path still works without them.
|
||||
Node-scoped for the same reason as _find_default_image_storage.
|
||||
"""
|
||||
for storage in self._node_api().storage.get():
|
||||
content = storage.get("content", "").split(",")
|
||||
if "import" not in content:
|
||||
continue
|
||||
if storage.get("enabled", 1) == 0 or storage.get("active", 1) == 0:
|
||||
continue
|
||||
return storage["storage"]
|
||||
return None
|
||||
|
||||
def _import_cloud_image(
|
||||
self,
|
||||
storage: str,
|
||||
filename: str,
|
||||
image_url: str,
|
||||
image_checksum: str | None,
|
||||
timeout: int,
|
||||
) -> str:
|
||||
"""Have Proxmox download *image_url* into *storage*; returns the volume id.
|
||||
|
||||
Proxmox runs the download as a task and verifies the checksum itself.
|
||||
A file already in the storage is reused — the name carries a hash of
|
||||
the full URL, and Proxmox only creates it once the download (and its
|
||||
checksum check) has succeeded, so an existing file is a complete one.
|
||||
"""
|
||||
volid = f"{storage}:import/{filename}"
|
||||
present = self._node_api().storage(storage).content.get(content="import")
|
||||
if any(item.get("volid") == volid for item in present):
|
||||
_logger.info(f"Cloud image already in {storage}: {volid}")
|
||||
return volid
|
||||
|
||||
params: dict[str, Any] = {"url": image_url, "content": "import", "filename": filename}
|
||||
if image_checksum:
|
||||
algo, expected = _split_checksum(image_checksum)
|
||||
params["checksum"] = expected
|
||||
params["checksum-algorithm"] = algo
|
||||
_logger.info(f"Downloading cloud image {image_url} into {volid}")
|
||||
upid = self._node_api().storage(storage)("download-url").post(**params)
|
||||
self._wait_for_task(upid, timeout=timeout)
|
||||
return volid
|
||||
|
||||
def _import_over_ssh(
|
||||
self,
|
||||
vmid: int,
|
||||
image_storage: str,
|
||||
image_url: str,
|
||||
image_checksum: str | None,
|
||||
download_timeout: int,
|
||||
timeout: int,
|
||||
) -> None:
|
||||
"""Download the image on the node and import it as the root disk.
|
||||
|
||||
The path for nodes without an import storage, or for an image type
|
||||
Proxmox cannot import itself.
|
||||
"""
|
||||
local_path = self._download_cloud_image(image_url, image_checksum, timeout=download_timeout)
|
||||
_logger.info(f"Importing {local_path} into VM {vmid} on storage {image_storage}")
|
||||
self._run_node_command(
|
||||
f"qm importdisk {vmid} {local_path} {image_storage} --format qcow2",
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# Proxmox leaves the imported disk as an "unusedN" reference — find
|
||||
# it and attach it as the boot disk.
|
||||
imported_config = self._node_api().qemu(vmid).config.get()
|
||||
unused_value = next((v for k, v in imported_config.items() if k.startswith("unused")), None)
|
||||
if not unused_value:
|
||||
raise RuntimeError(
|
||||
f"Disk import for VM {vmid} did not produce an unused disk reference"
|
||||
)
|
||||
self._node_api().qemu(vmid).config.post(
|
||||
scsi0=f"{unused_value},discard=on",
|
||||
boot="order=scsi0",
|
||||
)
|
||||
|
||||
def _attach_root_disk(
|
||||
self,
|
||||
vmid: int,
|
||||
image_storage: str,
|
||||
image_url: str,
|
||||
image_checksum: str | None,
|
||||
download_timeout: int,
|
||||
timeout: int,
|
||||
) -> None:
|
||||
"""Get the cloud image onto the node and make it the VM's root disk.
|
||||
|
||||
Through an import storage when the node has one — Proxmox downloads,
|
||||
verifies and copies the image itself — and over SSH otherwise.
|
||||
"""
|
||||
import_storage = self._find_import_storage()
|
||||
filename = _import_volume_name(image_url) if import_storage else None
|
||||
if not import_storage or not filename:
|
||||
self._import_over_ssh(
|
||||
vmid, image_storage, image_url, image_checksum, download_timeout, timeout
|
||||
)
|
||||
return
|
||||
|
||||
volid = self._import_cloud_image(
|
||||
import_storage, filename, image_url, image_checksum, timeout=download_timeout
|
||||
)
|
||||
_logger.info(f"Importing {volid} into VM {vmid} on storage {image_storage}")
|
||||
upid = (
|
||||
self._node_api()
|
||||
.qemu(vmid)
|
||||
.config.post(
|
||||
scsi0=f"{image_storage}:0,import-from={volid},discard=on",
|
||||
boot="order=scsi0",
|
||||
)
|
||||
)
|
||||
if upid:
|
||||
self._wait_for_task(upid, timeout=timeout)
|
||||
|
||||
def _find_default_image_storage(self) -> str:
|
||||
"""Find a storage suitable for VM root disks (content includes 'images').
|
||||
|
||||
@@ -306,30 +461,9 @@ class ProxmoxVMProvisionMixin:
|
||||
)
|
||||
|
||||
# Step 3: Download cloud image (cached) and import as root disk
|
||||
local_path = self._download_cloud_image(
|
||||
image_url, image_checksum, timeout=download_timeout
|
||||
)
|
||||
image_storage = storage or self._find_default_image_storage()
|
||||
|
||||
_logger.info(f"Importing {local_path} into VM {vmid} on storage {image_storage}")
|
||||
self._run_node_command(
|
||||
f"qm importdisk {vmid} {local_path} {image_storage} --format qcow2",
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# Proxmox leaves the imported disk as an "unusedN" reference — find
|
||||
# it and attach it as the boot disk.
|
||||
imported_config = self._node_api().qemu(vmid).config.get()
|
||||
unused_value = next(
|
||||
(v for k, v in imported_config.items() if k.startswith("unused")), None
|
||||
)
|
||||
if not unused_value:
|
||||
raise RuntimeError(
|
||||
f"Disk import for VM {vmid} did not produce an unused disk reference"
|
||||
)
|
||||
self._node_api().qemu(vmid).config.post(
|
||||
scsi0=f"{unused_value},discard=on",
|
||||
boot="order=scsi0",
|
||||
self._attach_root_disk(
|
||||
vmid, image_storage, image_url, image_checksum, download_timeout, timeout
|
||||
)
|
||||
|
||||
# Step 4: Configure network interfaces (CPU/memory already set at shell creation)
|
||||
|
||||
Reference in New Issue
Block a user