ESXi Plugin Implementation
The ESXi plugin provides Cloudify lifecycle management for standalone ESXi hosts and their virtual machines, leveraging the govc CLI tool for infrastructure interaction. It defines three primary node types - esxi.nodes.Host, esxi.nodes.VM, and esxi.nodes.Snapshot - each mapped to specific Python task handlers in esxi_plugin.tasks . The plugin enforces a “reconcile-first” pattern, where operations first check for existing resources to adopt them rather than failing on duplicates, ensuring idempotent state convergence 1.
Cloudify Node Types and Interfaces
Section titled “Cloudify Node Types and Interfaces”The plugin defines three node types derived from cloudify.nodes.Root, each exposing specific lifecycle interfaces .
esxi.nodes.Host: Represents the ESXi host connection. It requireshost(FQDN/IP) andpasswordproperties, with optionaluser(defaulting toroot),verify_tls,ca_bundle, andthumbprintfor TLS configuration . Its lifecycle mapscreatetoprobe_host(validating connectivity),configuretorecord_host_facts(capturing version/build info), anddeletetonoop_op.esxi.nodes.VM: Represents a virtual machine. Key properties includename,ova_url(required if the VM doesn’t exist),datastore, and resource specs (cpu,memory_mb) . It supportscreate,configure,start,stop, anddeleteoperations .esxi.nodes.Snapshot: Represents a VM snapshot. Properties includename,description,memory(include memory state), andquiesce(quiesce guest filesystem) . It exposescreateanddeleteinterfaces .
govc Binary Integration
Section titled “govc Binary Integration”The GovcClient class in esxi_plugin/govc.py wraps the vendored govc binary, handling environment setup and subprocess execution 2.
- Binary Management: The binary is located via
importlib.resources(orpkg_resources) and made executable if necessary. - Environment & Security: Credentials are passed via environment variables (
GOVC_USERNAME,GOVC_PASSWORD), never on the command line. TLS verification is controlled viaGOVC_INSECURE,GOVC_TLS_CA_CERTS, orGOVC_TLS_KNOWN_HOSTS. - Execution: The
runmethod constructs theargv, inserting-jsonimmediately after the subcommand to ensure proper parsing. It capturesstderr, redacting passwords, and raisesGovcErroron non-zero exit codes.
Lifecycle Task Handlers
Section titled “Lifecycle Task Handlers”Task handlers in esxi_plugin/tasks.py implement the Cloudify lifecycle operations, utilizing a reconcile-first strategy 1.
Host Operations
Section titled “Host Operations”probe_host: Runsgovc aboutto verify connectivity and credentials. It logs the API type, warning if it is notHostAgent(indicating vCenter, which is handled by a different plugin).record_host_facts: Captures ESXi version, build, and API type into runtime properties. It also enumerates existing VMs usinggovc lsandgovc vm.info, storing their MOID, power state, and config inctx.instance.runtime_properties["vms"].
VM Operations
Section titled “VM Operations”create_vm: Checks for an existing VM usingfind_vm_by_name. If found, it adopts the VM and powers it on if configured. If not, it imports the OVA usinggovc import.ova. It then resolves the MOID and optionally waits for an IP address usinggovc vm.ip.configure_vm: Reshapes CPU and memory usinggovc vm.changeif properties differ.start_vm/stop_vm: Manages power state viagovc vm.power.stop_vmanddelete_vmswallow “not found” errors to ensure idempotency.delete_vm: Powers off the VM, destroys it viagovc vm.destroy, and cleans up runtime properties.
Snapshot Operations
Section titled “Snapshot Operations”create_snapshot: Resolves the target VM name via relationships. Checks for existing snapshots usingfind_snapshot_by_name. If not found, creates it viagovc snapshot.createwith optional-m(memory) and-q(quiesce) flags.delete_snapshot: Removes the snapshot viagovc snapshot.removeand cleans up runtime properties.
Reconciliation Helpers
Section titled “Reconciliation Helpers”The esxi_plugin.reconcile module provides helper functions to support the reconcile-first pattern 3.
find_vm_by_name: Usesgovc vm.infoto find a VM by name and returns its MOID.find_snapshot_by_name: Usesgovc snapshot.treeto traverse the snapshot hierarchy and find a snapshot by name, returning its MOID.
"""Lifecycle operation entrypoints invoked by the DAP mgmtworker.
Every mutating operation reconciles first: look up the resource by name,
adopt it if it exists, persist identifiers immediately, then act. Delete
operations swallow ``not found`` errors so retries converge to the
desired state.
"""
from __future__ import annotations
import contextlib
import re
from cloudify import ctx
from cloudify.decorators import operation
from cloudify.exceptions import NonRecoverableError
from .govc import GovcClient, GovcError, HostConnection
from .reconcile import find_snapshot_by_name, find_vm_by_name
# --- helpers --------------------------------------------------------------
def _client_for_host_node() -> GovcClient:
p = ctx.node.properties
conn = HostConnection(
host=p["host"],
user=p.get("user") or "root",
password=p["password"],
verify_tls=bool(p.get("verify_tls", True)),
ca_bundle=p.get("ca_bundle") or None,
thumbprint=p.get("thumbprint") or None,
)
return GovcClient(conn, logger=ctx.logger)
def _client_for_dependent_node() -> GovcClient:
"""Build a GovcClient from the related Host node's properties."""
for rel in ctx.instance.relationships:
target_props = rel.target.node.properties
"""Subprocess wrapper around the vendored ``govc`` binary.
Credentials are passed through environment variables (``GOVC_USERNAME``,
``GOVC_PASSWORD``); never on argv, never embedded in the URL. ``-json``
is inserted right after the subcommand so govc's CLI parser sees it
before any positional argument.
"""
from __future__ import annotations
import json
import os
import stat
import subprocess
from dataclasses import dataclass
try:
from importlib.resources import files
def resource_filename(pkg: str, name: str) -> str:
return str(files(pkg).joinpath(name))
except ImportError: # pragma: no cover
from pkg_resources import resource_filename # type: ignore[no-redef]
class GovcError(RuntimeError):
"""Non-zero govc exit. ``stderr`` is captured and redacted."""
def __init__(self, returncode: int, argv: list[str], stderr: str):
self.returncode = returncode
self.argv = argv
self.stderr = stderr
super().__init__(
f"govc {argv[0] if argv else '<?>'} exited {returncode}: {stderr.strip()[:400]}"
)
@dataclass(frozen=True)
class HostConnection:
host: str
"""Reconcile-first helpers. Mutating operations call these first so a
retry adopts an existing resource instead of duplicating it.
"""
from __future__ import annotations
from .govc import GovcClient, GovcError
def find_vm_by_name(govc: GovcClient, name: str) -> str | None:
"""Return the VM's managed-object id, or None if no VM with this name exists."""
try:
out = govc.run("vm.info", name, json_out=True)
except GovcError as e:
if "not found" in (e.stderr or "").lower():
return None
raise
if not isinstance(out, dict):
return None
vms = out.get("virtualMachines") or out.get("VirtualMachines") or []
if not vms:
return None
self_ref = vms[0].get("self") or vms[0].get("Self") or {}
return self_ref.get("value") or self_ref.get("Value")
def find_snapshot_by_name(govc: GovcClient, vm_name: str, snapshot_name: str) -> str | None:
"""Return the snapshot's managed-object id, or None."""
try:
out = govc.run("snapshot.tree", "-vm", vm_name, "-i", "-D", json_out=True)
except GovcError as e:
if "not found" in (e.stderr or "").lower():
return None
raise
return _walk_snapshot_tree(out, snapshot_name.lower())
def _walk_snapshot_tree(node, want: str) -> str | None:
if isinstance(node, list):
for item in node: