fix(vm_provision_mixin): write Cloud-Init snippet via SSH, not the upload API

Real Proxmox's POST /nodes/{node}/storage/{storage}/upload only accepts
content in {iso, vztmpl, import} — content='snippets' is rejected
outright with a 400 ("does not have a value in the enumeration").
Snippets can only be written directly to the storage's filesystem path.

Found live, right after the previous multipart-upload fix: the VM
shell, disk import, and node-scoped storage selection all succeeded,
then create_vm_from_cloud_init failed with a 400 at the snippet write
step. Resolves the storage's path via the cluster storage config and
writes the file over SSH (base64-piped, to survive arbitrary YAML
content safely).
This commit is contained in:
Christian Manivong
2026-07-07 23:24:05 +02:00
parent 86af2cb7a7
commit 685d9b67ae
2 changed files with 110 additions and 71 deletions
+32 -14
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
import io
import base64
import logging
import time
import yaml
@@ -130,6 +130,25 @@ class ProxmoxVMProvisionMixin:
"No storage with content='images' found. Configure a storage for VM disks."
)
def _get_storage_path(self, storage: str) -> str:
"""Resolve a storage's filesystem path on the node.
Needed to write Cloud-Init snippets directly: Proxmox's
/storage/{s}/upload API only accepts content in {iso, vztmpl,
import} — "snippets" is rejected outright, so snippets must be
written straight to the filesystem instead. Only dir-backed storages
(dir, nfs, cifs, cephfs) expose "path"; those are also the only
storage types Proxmox itself allows content='snippets' on.
"""
config = self._api.storage(storage).get()
path = config.get("path")
if not path:
raise ValueError(
f"Storage '{storage}' has no filesystem path (content='snippets' "
"requires a dir/nfs/cifs/cephfs-backed storage)"
)
return path
def get_image_storages(self) -> List[StorageTargetDict]:
"""List node-available storage pools suitable for a new VM's root disk."""
targets: List[StorageTargetDict] = []
@@ -336,20 +355,19 @@ class ProxmoxVMProvisionMixin:
)
filename = f"{vmid}-user-data.yaml"
_logger.debug(f"Uploading Cloud-Init snippet {filename} to {snippet_storage}")
_logger.debug(f"Writing Cloud-Init snippet {filename} to {snippet_storage}")
# Upload to snippet storage. Proxmox's upload endpoint expects the
# "filename" parameter to BE the file (multipart), not a name
# string with separate content — proxmoxer only builds a
# multipart request when the value is an io.IOBase instance,
# otherwise it silently sends everything as a plain
# form-urlencoded POST, which real Proxmox rejects by dropping
# the connection (RemoteDisconnected, no HTTP response at all).
file_obj = io.BytesIO(user_data_yaml.encode("utf-8"))
file_obj.name = filename
self._node_api().storage(snippet_storage).upload.post(
content="snippets",
filename=file_obj,
# Proxmox's /storage/{s}/upload API only accepts content in
# {iso, vztmpl, import} — "snippets" is rejected outright
# ("does not have a value in the enumeration"). Snippets can only
# be written directly to the filesystem, so resolve the storage's
# backing path and write the file over SSH instead.
storage_path = self._get_storage_path(snippet_storage)
encoded = base64.b64encode(user_data_yaml.encode("utf-8")).decode("ascii")
self._run_node_command(
f"mkdir -p {storage_path}/snippets && "
f"echo {encoded} | base64 -d > {storage_path}/snippets/{filename}",
timeout=30,
)
# Step 7: Configure Cloud-Init references and SSH keys