Source code for saltext.vcf.states.vcf_esxi_vlcm

"""State module for vCenter's ESX Lifecycle Manager (vLCM) REST API.

Drives ESXi host patching for a vSphere cluster through the desired-image
workflow: configure and sync depots, define/commit a desired image, set the
cluster's apply policy, then check compliance and remediate against it.

Depot/image/policy specs default from pillar ``saltext.vcf:esxi_vlcm`` when
not passed explicitly — see :mod:`saltext.vcf.clients.esxi_vlcm` for the
underlying REST calls.

Example::

    patch-depot:
      vcf_esxi_vlcm.depot_configured:
        - depot_type: offline
        - location: http://repo.example.com/VMware-ESXi-9.2.0.0.25504872-depot.zip

    domain-c9:
      vcf_esxi_vlcm.image_configured:
        - image_spec:
            base_image:
              version: "9.2.0.0.25504872"
        - require:
          - vcf_esxi_vlcm: patch-depot
      vcf_esxi_vlcm.remediated:
        - require:
          - vcf_esxi_vlcm.image_configured: domain-c9
"""

import logging

from saltext.vcf.clients import esxi_vlcm as c

log = logging.getLogger(__name__)

__virtualname__ = "vcf_esxi_vlcm"


def __virtual__():
    return __virtualname__


def _ret(name):
    return {"name": name, "changes": {}, "result": True, "comment": ""}


def _fail(ret, exc):
    log.exception("vcf_esxi_vlcm state %r failed", ret["name"])
    ret["result"] = False
    ret["comment"] = str(exc)
    return ret


def _pillar_section(section):
    pillar = __opts__.get("pillar", {})  # noqa: F821
    root = pillar.get("saltext.vcf", {}) or {}
    return (root.get("esxi_vlcm", {}) or {}).get(section, {}) or {}


def _task_settings(kwargs):
    task = _pillar_section("task")
    timeout = kwargs.get("task_timeout", task.get("timeout", 14400))
    poll_interval = kwargs.get("task_poll_interval", task.get("poll_interval", 30))
    return timeout, poll_interval


[docs] def depot_configured( name, depot_type=None, location=None, source_type=None, enabled=True, description=None, ownerdata=None, profile=None, **kwargs, ): """Ensure a depot supplying ESXi update payloads is configured. *depot_type* is ``"offline"`` (a downloadable ZIP bundle at *location*, the common case) or ``"online"`` (a VMware/vendor update repository URL, also passed as *location*). Idempotent: no-op if a depot pointing at the same *location* already exists. """ ret = _ret(name) depot_type = depot_type or "offline" cfg = _pillar_section(f"{depot_type}_depot") location = location or cfg.get("location") if not location: return _fail( ret, ValueError( "depot_configured requires 'location' " f"(or pillar esxi_vlcm:{depot_type}_depot:location)" ), ) try: if depot_type == "online": existing = c.online_depot_list(__opts__, profile=profile) or [] else: existing = c.offline_depot_list(__opts__, profile=profile) or [] match = next((d for d in existing if d.get("location") == location), None) except Exception as exc: # pylint: disable=broad-except return _fail(ret, exc) if match: ret["comment"] = f"{depot_type} depot {location!r} already configured" return ret if __opts__.get("test"): ret["result"] = None ret["comment"] = f"would create {depot_type} depot {location!r}" ret["changes"] = {"depot": location} return ret try: if depot_type == "online": c.online_depot_create( __opts__, {"location": location, "enabled": enabled}, profile=profile, ) else: timeout, poll_interval = _task_settings(kwargs) resp = c.offline_depot_create( __opts__, { "source_type": source_type or cfg.get("source_type", "PULL"), "location": location, "description": description, "ownerdata": ownerdata, }, profile=profile, ) c.wait_for_task( __opts__, resp, timeout=timeout, poll_interval=poll_interval, profile=profile ) except Exception as exc: # pylint: disable=broad-except return _fail(ret, exc) ret["changes"] = {"depot": location} ret["comment"] = f"{depot_type} depot {location!r} configured" return ret
[docs] def depot_synced(name, profile=None, **kwargs): """Trigger a depot sync so ESXi update payloads are up to date. Not idempotent — there's no cheap way to tell "already synced" apart from "sync never needed," so this always fires the sync action. Some vCenter builds don't expose the sync action at all (404); that's treated as a no-op, not a failure. """ ret = _ret(name) if __opts__.get("test"): ret["result"] = None ret["comment"] = "would sync depots" return ret timeout, poll_interval = _task_settings(kwargs) try: resp = c.depot_sync(__opts__, profile=profile) if isinstance(resp, dict) and resp.get("skipped"): ret["comment"] = "depot sync not supported by this vCenter; skipped" return ret c.wait_for_task( __opts__, resp, timeout=timeout, poll_interval=poll_interval, profile=profile ) except Exception as exc: # pylint: disable=broad-except return _fail(ret, exc) ret["changes"] = {"synced": True} ret["comment"] = "depots synced" return ret
def _base_version(desired_image): return ((desired_image or {}).get("base_image") or {}).get("version") def _first_draft_id(drafts): """Return the id of the first draft in a ``drafts_list`` response. vAPI "list" operations over map-typed data (drafts are keyed by draft id) commonly respond with a ``{"<id>": {...}}`` dict rather than a JSON list — the id is the dict *key*, not a field inside the value. Handles that shape as well as a list of ``{"id"/"draft_id"/"key": ...}`` objects or a bare list of id strings. Returns ``None`` if *drafts* is empty. """ if isinstance(drafts, dict): if not drafts: return None return next(iter(drafts)) if isinstance(drafts, list) and drafts: first = drafts[0] if isinstance(first, dict): return first.get("id") or first.get("draft_id") or first.get("key") return first return None
[docs] def image_configured( # pylint: disable=too-many-return-statements,too-many-branches name, cluster_id=None, image_spec=None, display_name=None, existing_draft_action="delete", validate=True, commit=True, commit_message=None, profile=None, **kwargs, ): """Ensure the cluster's desired image matches *image_spec*. Idempotent on the committed image's ``base_image.version``. If a draft already exists on the cluster, *existing_draft_action* controls what happens: ``"fail"`` raises, ``"delete"`` (default) discards it and proceeds, ``"reuse"`` uses it as-is provided its base image version already matches *image_spec*. After commit, re-reads the desired image and verifies the version landed as requested — a commit that reports success but doesn't move the version is treated as a failure. """ ret = _ret(name) cluster_id = cluster_id or name cfg = _pillar_section("image") image_spec = image_spec or cfg.get("spec") if not image_spec: return _fail( ret, ValueError("image_configured requires 'image_spec' (or pillar esxi_vlcm:image:spec)"), ) desired_version = _base_version(image_spec) try: current = c.desired_image_get(__opts__, cluster_id, profile=profile) except Exception as exc: # pylint: disable=broad-except return _fail(ret, exc) current_version = _base_version(current) if desired_version and current_version == desired_version: ret["comment"] = f"cluster {cluster_id} desired image already at {desired_version}" return ret try: drafts = c.drafts_list(__opts__, cluster_id, profile=profile) or [] except Exception as exc: # pylint: disable=broad-except return _fail(ret, exc) existing_id = _first_draft_id(drafts) draft_id = None if existing_id is not None: if existing_draft_action == "fail": return _fail( ret, RuntimeError(f"cluster {cluster_id} already has draft {existing_id!r}") ) if existing_draft_action == "reuse": try: draft_doc = c.draft_get(__opts__, cluster_id, existing_id, profile=profile) or {} except Exception as exc: # pylint: disable=broad-except return _fail(ret, exc) draft_version = _base_version(draft_doc.get("software_spec") or draft_doc) if desired_version and draft_version != desired_version: return _fail( ret, RuntimeError( f"existing draft {existing_id!r} is version {draft_version!r}, " f"not the requested {desired_version!r}" ), ) draft_id = existing_id else: # "delete" (default) if __opts__.get("test"): ret["result"] = None ret["comment"] = ( f"would delete existing draft {existing_id!r} and import {desired_version}" ) return ret try: c.draft_delete(__opts__, cluster_id, existing_id, profile=profile) except Exception as exc: # pylint: disable=broad-except return _fail(ret, exc) if __opts__.get("test"): ret["result"] = None ret["comment"] = f"would set cluster {cluster_id} desired image to {desired_version}" ret["changes"] = {"base_image_version": {"old": current_version, "new": desired_version}} return ret timeout, poll_interval = _task_settings(kwargs) try: if draft_id is None: # Import always completes synchronously — no task to wait for. c.draft_import_software_spec(__opts__, cluster_id, image_spec, profile=profile) fresh = c.drafts_list(__opts__, cluster_id, profile=profile) or [] draft_id = _first_draft_id(fresh) if draft_id is None: return _fail( ret, RuntimeError(f"could not determine draft id after import on cluster {cluster_id}"), ) if validate: resp = c.draft_validate(__opts__, cluster_id, draft_id, profile=profile) c.wait_for_task( __opts__, resp, timeout=timeout, poll_interval=poll_interval, profile=profile ) if commit: resp = c.draft_commit( __opts__, cluster_id, draft_id, message=commit_message or display_name, profile=profile, ) c.wait_for_task( __opts__, resp, timeout=timeout, poll_interval=poll_interval, profile=profile ) final = c.desired_image_get(__opts__, cluster_id, profile=profile) final_version = _base_version(final) if desired_version and final_version != desired_version: return _fail( ret, RuntimeError( f"commit reported success but cluster {cluster_id} version is " f"{final_version!r}, not the requested {desired_version!r}" ), ) except Exception as exc: # pylint: disable=broad-except return _fail(ret, exc) ret["changes"] = {"base_image_version": {"old": current_version, "new": desired_version}} ret["comment"] = f"cluster {cluster_id} desired image set to {desired_version}" return ret
[docs] def policy_configured(name, cluster_id=None, policy_spec=None, profile=None, **kwargs): """Ensure the cluster's apply policy matches *policy_spec*. Idempotent on the subset of keys present in *policy_spec* — only those keys are compared against the current policy, so extra fields vCenter populates on its own don't cause a spurious "changed" every run. """ ret = _ret(name) cluster_id = cluster_id or name policy_spec = policy_spec or _pillar_section("policy") if not policy_spec: return _fail( ret, ValueError("policy_configured requires 'policy_spec' (or pillar esxi_vlcm:policy)"), ) try: current = c.apply_policy_get(__opts__, cluster_id, profile=profile) or {} except Exception as exc: # pylint: disable=broad-except return _fail(ret, exc) diff = {k: v for k, v in policy_spec.items() if current.get(k) != v} if not diff: ret["comment"] = f"cluster {cluster_id} apply policy already matches" return ret if __opts__.get("test"): ret["result"] = None ret["comment"] = f"would update cluster {cluster_id} apply policy: {sorted(diff)}" ret["changes"] = diff return ret try: c.apply_policy_set(__opts__, cluster_id, policy_spec, profile=profile) except Exception as exc: # pylint: disable=broad-except return _fail(ret, exc) ret["changes"] = diff ret["comment"] = f"cluster {cluster_id} apply policy updated" return ret
def _lifecycle_step(name, cluster_id, slug, label, client_fn, call_kwargs, kwargs, profile): ret = _ret(name) cluster_id = cluster_id or name if __opts__.get("test"): ret["result"] = None ret["comment"] = f"would {label} cluster {cluster_id}" return ret timeout, poll_interval = _task_settings(kwargs) try: resp = client_fn(__opts__, cluster_id, profile=profile, **call_kwargs) task = c.wait_for_task( __opts__, resp, timeout=timeout, poll_interval=poll_interval, profile=profile ) except Exception as exc: # pylint: disable=broad-except return _fail(ret, exc) ret["changes"] = {slug: task.get("status")} ret["comment"] = f"cluster {cluster_id} {label} complete" return ret
[docs] def compliance_checked(name, cluster_id=None, commit="1", hosts=None, profile=None, **kwargs): """Scan the cluster for compliance against its desired image. Always runs — there's no cheap "already compliant" signal to check beforehand; use :func:`reported` to inspect the result afterward. """ return _lifecycle_step( name, cluster_id, "compliance_scan", "compliance scan", c.compliance_scan, {"commit": commit, "hosts": hosts}, kwargs, profile, )
[docs] def prechecked(name, cluster_id=None, commit="1", profile=None, **kwargs): """Run remediation prechecks on the cluster. Always runs (see :func:`compliance_checked`).""" return _lifecycle_step( name, cluster_id, "precheck", "precheck", c.precheck, {"commit": commit}, kwargs, profile )
[docs] def staged(name, cluster_id=None, commit="1", hosts=None, profile=None, **kwargs): """Stage the desired image on cluster hosts. Always runs (see :func:`compliance_checked`).""" return _lifecycle_step( name, cluster_id, "stage", "stage", c.stage, {"commit": commit, "hosts": hosts}, kwargs, profile, )
[docs] def remediated(name, cluster_id=None, accept_eula=True, profile=None, **kwargs): """Remediate (apply the desired image to) the cluster. Always runs (see :func:`compliance_checked`). Unlike :func:`compliance_checked`/:func:`staged`, remediation has no ``hosts`` filter — it always targets the whole cluster. """ ret = _ret(name) cluster_id = cluster_id or name if __opts__.get("test"): ret["result"] = None ret["comment"] = f"would remediate cluster {cluster_id}" return ret timeout, poll_interval = _task_settings(kwargs) try: resp = c.remediate(__opts__, cluster_id, accept_eula=accept_eula, profile=profile) task = c.wait_for_task( __opts__, resp, timeout=timeout, poll_interval=poll_interval, profile=profile ) except Exception as exc: # pylint: disable=broad-except return _fail(ret, exc) ret["changes"] = {"remediate": task.get("status")} ret["comment"] = f"cluster {cluster_id} remediated" return ret
[docs] def reported(name, cluster_id=None, profile=None): """Fetch the cluster's last check/apply-impact/apply reports. Always a no-op read (``changed=False``) — the reports are surfaced in ``comment`` only; use the corresponding :mod:`saltext.vcf.modules.vcf_esxi_vlcm` functions to consume the full payloads programmatically. """ ret = _ret(name) cluster_id = cluster_id or name try: last_check = c.last_check_result(__opts__, cluster_id, profile=profile) apply_impact = c.apply_impact_report(__opts__, cluster_id, profile=profile) last_apply = c.last_apply_result(__opts__, cluster_id, profile=profile) except Exception as exc: # pylint: disable=broad-except return _fail(ret, exc) ret["comment"] = ( f"cluster {cluster_id}: last_check={'present' if last_check else 'none'}, " f"apply_impact={'present' if apply_impact else 'none'}, " f"last_apply={'present' if last_apply else 'none'}" ) return ret