feat: VM provisioning, and reboot_host on ESXi
create_vm_from_cloud_init takes the same qcow2/raw cloud images netOrk offers for Proxmox. ESXi can neither boot nor download them, so the driver does both: download with checksum check and one retry, convert with qemu-img to a streamOptimized VMDK (cached by URL), import through a minimal OVF descriptor over NFC, pin requested MACs, grow the disk, and attach a NoCloud seed ISO placed next to the VM's files. NoCloud rather than guestinfo because it needs nothing in the guest; the user-data installs open-vm-tools, which the driver declares as its guest agent. A failure after the import removes the VM again. Placement is a pure decision over inventory rows: a connected host outside maintenance mode that sees the datastore and every port group, with the resource pool and VM folder found by walking up to the datacenter -- one path for a standalone host and for a vCenter. Two faults vcsim surfaced and the tests now pin: a chunked upload body next to a Content-Length is refused with 500, so the disk goes up as a sized file object that also reports lease progress; and a device edit replaces the device as sent, so disk and NIC edits start from the live objects, backing included (vcsim panicked on a disk without one). destroy_vm, get_vm_status, get_network_targets (port groups with their fixed VLAN) and get_image_storages complete the contract. reboot_host on ESXi uses RebootHost_Task and refuses outside maintenance mode: force=True would cut power to running VMs. Tested against vcsim in ESXi and vCenter mode, end to end.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
"""Cloud images (qcow2/raw) converted to streamOptimized VMDKs, cached by URL.
|
||||
|
||||
ESXi cannot boot a qcow2 and cannot download one itself, so the conversion
|
||||
runs where the driver runs: download, verify, ``qemu-img convert``. The
|
||||
result is kept, keyed by URL, so the next VM from the same image skips both
|
||||
steps. Only the converted disk is kept; the download is deleted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
_CHUNK = 1024 * 1024
|
||||
DEFAULT_CACHE_DIR = Path(tempfile.gettempdir()) / "napalm-vmware-images"
|
||||
|
||||
Fetch = Callable[[str, Path, float], None]
|
||||
Convert = Callable[[Path, Path], None]
|
||||
VirtualSize = Callable[[Path], int]
|
||||
|
||||
|
||||
def _qemu_img() -> str:
|
||||
path = shutil.which("qemu-img")
|
||||
if path is None:
|
||||
raise RuntimeError(
|
||||
"qemu-img is not installed where netOrk runs the provisioning job; "
|
||||
"it is needed to convert cloud images for VMware (package qemu-utils)"
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def fetch(url: str, dest: Path, timeout: float) -> None: # pragma: no cover - network
|
||||
with requests.get(url, stream=True, timeout=timeout) as response:
|
||||
response.raise_for_status()
|
||||
with dest.open("wb") as out:
|
||||
for chunk in response.iter_content(_CHUNK):
|
||||
out.write(chunk)
|
||||
|
||||
|
||||
def convert_to_vmdk(src: Path, dst: Path) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
_qemu_img(),
|
||||
"convert",
|
||||
"-O",
|
||||
"vmdk",
|
||||
"-o",
|
||||
"subformat=streamOptimized",
|
||||
str(src),
|
||||
str(dst),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def virtual_size(path: Path) -> int:
|
||||
"""The disk size the image describes, in bytes (not the file size)."""
|
||||
out = subprocess.run(
|
||||
[_qemu_img(), "info", "--output", "json", str(path)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
return int(json.loads(out)["virtual-size"])
|
||||
|
||||
|
||||
def _digest(path: Path, algorithm: str) -> str:
|
||||
h = hashlib.new(algorithm)
|
||||
with path.open("rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(_CHUNK), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
class ImageCache:
|
||||
"""Converted images in ``directory``, one ``<key>.vmdk`` plus ``<key>.json`` each."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
directory: Path = DEFAULT_CACHE_DIR,
|
||||
*,
|
||||
fetch: Fetch = fetch,
|
||||
convert: Convert = convert_to_vmdk,
|
||||
virtual_size: VirtualSize = virtual_size,
|
||||
) -> None:
|
||||
self._dir = Path(directory)
|
||||
self._fetch = fetch
|
||||
self._convert = convert
|
||||
self._virtual_size = virtual_size
|
||||
|
||||
def vmdk(self, url: str, checksum: str | None, timeout: float) -> tuple[Path, int]:
|
||||
"""``(path to the VMDK, virtual disk size in bytes)`` for ``url``."""
|
||||
self._dir.mkdir(parents=True, exist_ok=True)
|
||||
key = hashlib.sha256(url.encode()).hexdigest()[:16]
|
||||
vmdk, meta = self._dir / f"{key}.vmdk", self._dir / f"{key}.json"
|
||||
if vmdk.exists() and meta.exists():
|
||||
return vmdk, int(json.loads(meta.read_text())["virtual_size"])
|
||||
|
||||
download = self._dir / f"{key}.download"
|
||||
try:
|
||||
self._download_verified(url, checksum, download, timeout)
|
||||
size = self._virtual_size(download)
|
||||
partial = self._dir / f"{key}.vmdk.partial"
|
||||
self._convert(download, partial)
|
||||
# Rename last: a VMDK that exists is a VMDK that is complete, even
|
||||
# when two jobs convert the same image at once.
|
||||
os.replace(partial, vmdk)
|
||||
meta.write_text(json.dumps({"url": url, "virtual_size": size}))
|
||||
finally:
|
||||
download.unlink(missing_ok=True)
|
||||
return vmdk, size
|
||||
|
||||
def _download_verified(
|
||||
self, url: str, checksum: str | None, dest: Path, timeout: float
|
||||
) -> None:
|
||||
algorithm, _, expected = (checksum or "").rpartition(":")
|
||||
algorithm = (algorithm or "sha256").lower()
|
||||
for _attempt in (1, 2):
|
||||
self._fetch(url, dest, timeout)
|
||||
if not checksum:
|
||||
return
|
||||
actual = _digest(dest, algorithm)
|
||||
if actual.lower() == expected.lower():
|
||||
return
|
||||
dest.unlink(missing_ok=True)
|
||||
raise RuntimeError(f"Checksum mismatch for {url}: expected {expected}, got {actual}")
|
||||
@@ -0,0 +1,120 @@
|
||||
"""A minimal OVF 1.0 descriptor for importing one converted cloud image.
|
||||
|
||||
vSphere builds the VM from this (``OvfManager.CreateImportSpec``) and then
|
||||
takes the disk contents over NFC. The descriptor only has to describe what
|
||||
the image does not: CPU, memory, one disk and the NICs.
|
||||
|
||||
Devices are the ones VMware recommends for a 64-bit Linux guest -- PVSCSI and
|
||||
VMXNET3. Both drivers are in the mainline kernel; whether every distribution's
|
||||
*cloud* kernel carries them is one of the things #305 checks on real hardware.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from xml.sax.saxutils import escape, quoteattr
|
||||
|
||||
_HW_VERSION = "vmx-13" # ESXi 6.5 and later
|
||||
_GUEST_OS = "otherLinux64Guest"
|
||||
_STREAM_OPTIMIZED = "http://www.vmware.com/interfaces/specifications/vmdk.html#streamOptimized"
|
||||
|
||||
|
||||
def _item(**elements: str) -> str:
|
||||
# CIM requires the rasd elements in alphabetical order.
|
||||
body = "".join(f"<rasd:{k}>{escape(v)}</rasd:{k}>" for k, v in sorted(elements.items()))
|
||||
return f"<Item>{body}</Item>"
|
||||
|
||||
|
||||
def _nic_items(nic_count: int, first_instance: int) -> str:
|
||||
return "".join(
|
||||
_item(
|
||||
AutomaticAllocation="true",
|
||||
Connection=f"net{i}",
|
||||
ElementName=f"Network adapter {i + 1}",
|
||||
InstanceID=str(first_instance + i),
|
||||
ResourceSubType="VmxNet3",
|
||||
ResourceType="10",
|
||||
)
|
||||
for i in range(nic_count)
|
||||
)
|
||||
|
||||
|
||||
def ovf_descriptor(
|
||||
name: str,
|
||||
*,
|
||||
cpu: int,
|
||||
memory_mb: int,
|
||||
capacity_bytes: int,
|
||||
nic_count: int,
|
||||
firmware: str = "bios",
|
||||
) -> str:
|
||||
networks = "".join(
|
||||
f'<Network ovf:name="net{i}"><Description>net{i}</Description></Network>'
|
||||
for i in range(nic_count)
|
||||
)
|
||||
hardware = "".join(
|
||||
[
|
||||
_item(
|
||||
AllocationUnits="hertz * 10^6",
|
||||
ElementName=f"{cpu} virtual CPU(s)",
|
||||
InstanceID="1",
|
||||
ResourceType="3",
|
||||
VirtualQuantity=str(cpu),
|
||||
),
|
||||
_item(
|
||||
AllocationUnits="byte * 2^20",
|
||||
ElementName=f"{memory_mb} MB of memory",
|
||||
InstanceID="2",
|
||||
ResourceType="4",
|
||||
VirtualQuantity=str(memory_mb),
|
||||
),
|
||||
_item(
|
||||
Address="0",
|
||||
ElementName="SCSI controller 0",
|
||||
InstanceID="3",
|
||||
ResourceSubType="VirtualSCSI",
|
||||
ResourceType="6",
|
||||
),
|
||||
_item(
|
||||
AddressOnParent="0",
|
||||
ElementName="Hard disk 1",
|
||||
HostResource="ovf:/disk/vmdisk1",
|
||||
InstanceID="4",
|
||||
Parent="3",
|
||||
ResourceType="17",
|
||||
),
|
||||
_nic_items(nic_count, first_instance=5),
|
||||
]
|
||||
)
|
||||
firmware_config = (
|
||||
'<vmw:Config ovf:required="false" vmw:key="firmware" vmw:value="efi"/>'
|
||||
if firmware == "efi"
|
||||
else ""
|
||||
)
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<Envelope xmlns="http://schemas.dmtf.org/ovf/envelope/1"'
|
||||
' xmlns:ovf="http://schemas.dmtf.org/ovf/envelope/1"'
|
||||
' xmlns:rasd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData"'
|
||||
' xmlns:vssd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_VirtualSystemSettingData"'
|
||||
' xmlns:vmw="http://www.vmware.com/schema/ovf">'
|
||||
'<References><File ovf:href="disk.vmdk" ovf:id="file1"/></References>'
|
||||
"<DiskSection><Info>Virtual disks</Info>"
|
||||
f'<Disk ovf:capacity="{capacity_bytes}" ovf:capacityAllocationUnits="byte"'
|
||||
f' ovf:diskId="vmdisk1" ovf:fileRef="file1" ovf:format="{_STREAM_OPTIMIZED}"/>'
|
||||
"</DiskSection>"
|
||||
f"<NetworkSection><Info>Networks</Info>{networks}</NetworkSection>"
|
||||
f"<VirtualSystem ovf:id={quoteattr(name)}>"
|
||||
"<Info>A virtual machine</Info>"
|
||||
f"<Name>{escape(name)}</Name>"
|
||||
f'<OperatingSystemSection ovf:id="101" vmw:osType="{_GUEST_OS}"><Info>Guest OS</Info>'
|
||||
"</OperatingSystemSection>"
|
||||
"<VirtualHardwareSection><Info>Virtual hardware</Info>"
|
||||
"<System><vssd:ElementName>Virtual Hardware Family</vssd:ElementName>"
|
||||
"<vssd:InstanceID>0</vssd:InstanceID>"
|
||||
f"<vssd:VirtualSystemIdentifier>{escape(name)}</vssd:VirtualSystemIdentifier>"
|
||||
f"<vssd:VirtualSystemType>{_HW_VERSION}</vssd:VirtualSystemType></System>"
|
||||
f"{hardware}{firmware_config}"
|
||||
"</VirtualHardwareSection>"
|
||||
"</VirtualSystem>"
|
||||
"</Envelope>"
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Where a new VM goes: a pure decision over plain inventory rows.
|
||||
|
||||
A host qualifies when it is connected, not in maintenance mode, and sees both
|
||||
a usable datastore and every requested network. Among those, the pair with
|
||||
the most free space wins -- or the named datastore, when one is given. The
|
||||
resource pool comes from the host's compute resource and the VM folder from
|
||||
the datacenter above it; a standalone ESXi host has the same shape
|
||||
(``ha-compute-res`` under ``ha-datacenter``), so one path serves both.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
Row = dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Inventory:
|
||||
hosts: list[Row]
|
||||
datastores: list[Row]
|
||||
networks: list[Row]
|
||||
compute_resources: list[Row]
|
||||
folders: list[Row] = field(default_factory=list)
|
||||
datacenters: list[Row] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Placement:
|
||||
host: str
|
||||
host_name: str
|
||||
datastore: str
|
||||
datastore_name: str
|
||||
resource_pool: str
|
||||
folder: str
|
||||
datacenter_name: str
|
||||
networks: list[str]
|
||||
|
||||
|
||||
def _usable_datastores(inv: Inventory, storage: str | None) -> dict[str, Row]:
|
||||
usable = {
|
||||
ds["_moref"]: ds
|
||||
for ds in inv.datastores
|
||||
if (ds.get("summary") or {}).get("accessible")
|
||||
and (ds.get("summary") or {}).get("maintenanceMode", "normal") == "normal"
|
||||
and (storage is None or ds.get("name") == storage)
|
||||
}
|
||||
if storage is not None and not usable:
|
||||
raise ValueError(f"There is no usable datastore named {storage!r}")
|
||||
return usable
|
||||
|
||||
|
||||
def _host_networks(
|
||||
host: Row, names_by_moref: dict[str, str], wanted: list[str]
|
||||
) -> list[str] | None:
|
||||
"""The host's network moref for each wanted name, or None if one is missing."""
|
||||
by_name = {names_by_moref.get(m): m for m in host.get("network") or []}
|
||||
found = [by_name.get(name) for name in wanted]
|
||||
return None if None in found else [m for m in found if m]
|
||||
|
||||
|
||||
def _datacenter(inv: Inventory, compute_resource: str) -> Row:
|
||||
parents = {row["_moref"]: row.get("parent") for row in inv.compute_resources + inv.folders}
|
||||
datacenters = {dc["_moref"]: dc for dc in inv.datacenters}
|
||||
node: str | None = compute_resource
|
||||
while node is not None and node not in datacenters:
|
||||
node = parents.get(node)
|
||||
if node is None:
|
||||
raise RuntimeError(f"No datacenter above {compute_resource!r}")
|
||||
return datacenters[node]
|
||||
|
||||
|
||||
def choose_placement(inv: Inventory, storage: str | None, networks: list[str]) -> Placement:
|
||||
usable = _usable_datastores(inv, storage)
|
||||
names = {n["_moref"]: n.get("name", "") for n in inv.networks}
|
||||
best: tuple[int, Row, Row, list[str]] | None = None
|
||||
for host in sorted(inv.hosts, key=lambda h: h.get("name", "")):
|
||||
if host.get("runtime.connectionState") != "connected" or host.get(
|
||||
"runtime.inMaintenanceMode"
|
||||
):
|
||||
continue
|
||||
host_nets = _host_networks(host, names, networks)
|
||||
if host_nets is None:
|
||||
continue
|
||||
for ds_ref in host.get("datastore") or []:
|
||||
ds = usable.get(ds_ref)
|
||||
free = int((ds or {}).get("summary", {}).get("freeSpace", 0))
|
||||
if ds is not None and (best is None or free > best[0]):
|
||||
best = (free, host, ds, host_nets)
|
||||
if best is None:
|
||||
raise ValueError(
|
||||
f"There is no host that sees datastore {storage or '(any)'} and networks {networks}"
|
||||
)
|
||||
_, host, ds, host_nets = best
|
||||
compute = next(c for c in inv.compute_resources if c["_moref"] == host.get("parent"))
|
||||
dc = _datacenter(inv, compute["_moref"])
|
||||
return Placement(
|
||||
host=host["_moref"],
|
||||
host_name=host.get("name", ""),
|
||||
datastore=ds["_moref"],
|
||||
datastore_name=ds.get("name", ""),
|
||||
resource_pool=compute["resourcePool"],
|
||||
folder=dc["vmFolder"],
|
||||
datacenter_name=dc.get("name", ""),
|
||||
networks=host_nets,
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""cloud-init's NoCloud seed: user-data, meta-data, network-config on an ISO.
|
||||
|
||||
NoCloud rather than VMware's guestinfo datasource because it needs nothing in
|
||||
the guest: guestinfo is read through open-vm-tools, which a generic cloud
|
||||
image does not ship -- it is one of the things the user-data installs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
import pycdlib
|
||||
import yaml
|
||||
|
||||
#: The volume label cloud-init's NoCloud datasource looks for.
|
||||
_LABEL = "cidata"
|
||||
|
||||
|
||||
def user_data(cloud_init_config: dict[str, Any], ssh_public_keys: list[str] | None) -> str:
|
||||
"""The ``#cloud-config`` document, with ``ssh_public_keys`` merged in."""
|
||||
config = dict(cloud_init_config)
|
||||
if ssh_public_keys:
|
||||
keys = list(config.get("ssh_authorized_keys") or [])
|
||||
keys += [k for k in ssh_public_keys if k not in keys]
|
||||
config["ssh_authorized_keys"] = keys
|
||||
return "#cloud-config\n" + yaml.safe_dump(config, sort_keys=False)
|
||||
|
||||
|
||||
def meta_data(hostname: str, instance_id: str) -> str:
|
||||
return yaml.safe_dump({"instance-id": instance_id, "local-hostname": hostname}, sort_keys=False)
|
||||
|
||||
|
||||
def network_config(nics: list[tuple[str, bool]]) -> dict[str, Any] | None:
|
||||
"""Netplan v2 config: DHCP on every ``(mac, dhcp)`` NIC that asks for it.
|
||||
|
||||
``None`` when no NIC does -- cloud-init then leaves networking alone
|
||||
rather than being told to configure nothing.
|
||||
"""
|
||||
ethernets = {
|
||||
f"nic{i}": {"match": {"macaddress": mac}, "dhcp4": True}
|
||||
for i, (mac, dhcp) in enumerate(nics)
|
||||
if dhcp
|
||||
}
|
||||
return {"version": 2, "ethernets": ethernets} if ethernets else None
|
||||
|
||||
|
||||
def nocloud_iso(user: str, meta: str, network: dict[str, Any] | None) -> bytes:
|
||||
"""An ISO 9660 image (Rock Ridge + Joliet) holding the seed files."""
|
||||
files = {"user-data": user, "meta-data": meta}
|
||||
if network is not None:
|
||||
files["network-config"] = yaml.safe_dump(network, sort_keys=False)
|
||||
iso = pycdlib.PyCdlib()
|
||||
iso.new(interchange_level=3, joliet=3, rock_ridge="1.09", vol_ident=_LABEL)
|
||||
for index, (name, content) in enumerate(files.items()):
|
||||
data = content.encode()
|
||||
iso.add_fp(
|
||||
io.BytesIO(data),
|
||||
len(data),
|
||||
f"/SEED{index}.;1",
|
||||
rr_name=name,
|
||||
joliet_path=f"/{name}",
|
||||
)
|
||||
out = io.BytesIO()
|
||||
iso.write_fp(out)
|
||||
iso.close()
|
||||
return out.getvalue()
|
||||
@@ -0,0 +1,113 @@
|
||||
"""The two HTTP transfers provisioning needs: a disk over NFC, a file to a datastore.
|
||||
|
||||
Both authenticate with the vSphere session's own cookie, so no second login
|
||||
and no credentials beyond the ones the driver already holds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
|
||||
#: vSphere drops an NFC lease that reports no progress for five minutes.
|
||||
_PROGRESS_EVERY_SECONDS = 20.0
|
||||
|
||||
|
||||
class ProgressFile:
|
||||
"""A file body with a length, calling ``report(percent)`` as it is read.
|
||||
|
||||
Sized so ``requests`` sends it with a Content-Length instead of chunked:
|
||||
vSphere's NFC endpoint refuses a chunked disk upload.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: Path,
|
||||
report: Callable[[int], None],
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self._fh = path.open("rb")
|
||||
self._size = path.stat().st_size
|
||||
self._sent = 0
|
||||
self._report = report
|
||||
self._clock = clock
|
||||
self._last = clock()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self._size
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
chunk = self._fh.read(size)
|
||||
self._sent += len(chunk)
|
||||
if chunk and self._clock() - self._last >= _PROGRESS_EVERY_SECONDS:
|
||||
self._report(min(99, self._sent * 100 // (self._size or 1)))
|
||||
self._last = self._clock()
|
||||
return chunk
|
||||
|
||||
def close(self) -> None:
|
||||
self._fh.close()
|
||||
|
||||
|
||||
def upload_disk(
|
||||
url: str, vmdk: Path, cookie: str, verify: bool, report: Callable[[int], None]
|
||||
) -> None:
|
||||
"""Stream a streamOptimized VMDK to an NFC lease's device URL."""
|
||||
body = ProgressFile(vmdk, report)
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/x-vnd.vmware-streamVmdk", "Cookie": cookie},
|
||||
verify=verify,
|
||||
timeout=(30, 600),
|
||||
)
|
||||
finally:
|
||||
body.close()
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def lease_url(url: str, hostname: str, port: int) -> str:
|
||||
"""An NFC device URL with its ``*`` host filled in.
|
||||
|
||||
ESXi answers ``https://*/nfc/...``: "the host you are talking to". A port
|
||||
other than 443 has to be carried over, or the upload misses a NAT'd host.
|
||||
Through a vCenter the URL already names the ESXi host that takes the disk.
|
||||
"""
|
||||
host = hostname if port == 443 else f"{hostname}:{port}"
|
||||
return url.replace("://*/", f"://{host}/", 1)
|
||||
|
||||
|
||||
def datastore_url(base: str, path: str) -> str:
|
||||
"""``https://host/folder/<path>`` -- the datastore file browser endpoint."""
|
||||
return f"{base}/folder/{quote(path)}"
|
||||
|
||||
|
||||
def upload_file(
|
||||
base: str,
|
||||
datacenter: str,
|
||||
datastore: str,
|
||||
path: str,
|
||||
data: bytes,
|
||||
cookie: str,
|
||||
verify: bool,
|
||||
) -> None:
|
||||
"""Put ``data`` at ``[datastore] path``."""
|
||||
response = requests.put(
|
||||
datastore_url(base, path),
|
||||
params={"dcPath": datacenter, "dsName": datastore},
|
||||
data=data,
|
||||
headers={"Content-Type": "application/octet-stream", "Cookie": cookie},
|
||||
verify=verify,
|
||||
timeout=(30, 120),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def session_cookie(si: Any) -> str:
|
||||
"""The ``vmware_soap_session`` cookie of a pyVmomi ServiceInstance."""
|
||||
return si._stub.cookie
|
||||
Reference in New Issue
Block a user