"""
Client for vCenter's ESX Lifecycle Manager (vLCM) REST API.
Patches ESXi hosts in a vSphere cluster via the desired-image workflow:
depots supply update payloads, a *desired image* draft is imported,
validated and committed against a base image (plus optional add-ons), an
apply policy controls remediation behavior (quick boot, DPM, HA), and the
scan/check/stage/apply actions drive compliance checking and remediation
against the cluster's committed software spec. Endpoints live under::
/api/esx/settings/depots/...
/api/esx/settings/clusters/{cluster_id}/software/...
/api/esx/settings/clusters/{cluster_id}/policies/apply
All calls go through the vCenter session
(:mod:`saltext.vcf.utils.vcenter`), so the standard ``saltext.vcf.vcenter``
pillar config is what selects the target vCenter — no separate connection
config is needed for this domain. Mutating calls append ``vmw-task=true``
and return a vCenter CIS task id (a bare string, or ``{"value": "<id>"}``);
pass that response to :func:`wait_for_task` to block for completion.
"""
import json
import requests
from saltext.vcf.utils import vcenter
_DEPOTS = "/api/esx/settings/depots"
_CLUSTER = "/api/esx/settings/clusters/{cluster}"
def _cluster_path(cluster, suffix=""):
return _CLUSTER.format(cluster=cluster) + suffix
def _compact(body):
"""Drop ``None``-valued keys so optional fields aren't sent at all."""
return {k: v for k, v in (body or {}).items() if v is not None}
# ---------------------------------------------------------------------------
# Depots
# ---------------------------------------------------------------------------
[docs]
def online_depot_list(opts, profile=None):
"""Return the list of configured online depots."""
return vcenter.api_get(opts, f"{_DEPOTS}/online", profile=profile)
[docs]
def online_depot_create(opts, body, profile=None):
"""Create an online depot. *body* carries at least a ``url``."""
return vcenter.api_post(opts, f"{_DEPOTS}/online", body=_compact(body), profile=profile)
[docs]
def online_depot_update(opts, depot_id, body, profile=None):
"""Update an existing online depot."""
return vcenter.api_patch(
opts, f"{_DEPOTS}/online/{depot_id}", body=_compact(body), profile=profile
)
[docs]
def offline_depot_list(opts, profile=None):
"""Return the list of configured offline depots."""
return vcenter.api_get(opts, f"{_DEPOTS}/offline", profile=profile)
[docs]
def offline_depot_get(opts, depot_id, profile=None):
"""Return a single offline depot."""
return vcenter.api_get(opts, f"{_DEPOTS}/offline/{depot_id}", profile=profile)
[docs]
def offline_depot_create(opts, body, profile=None):
"""Create an offline depot (task). *body* carries at least ``location``."""
return vcenter.api_post(
opts,
f"{_DEPOTS}/offline",
body=_compact(body),
params={"vmw-task": "true"},
profile=profile,
)
[docs]
def offline_depot_delete(opts, depot_id, profile=None):
"""Delete an offline depot (task)."""
return vcenter.api_delete(
opts, f"{_DEPOTS}/offline/{depot_id}", params={"vmw-task": "true"}, profile=profile
)
[docs]
def depot_sync(opts, profile=None):
"""Trigger a sync of all depots (task).
Some vCenter builds don't expose this action and respond 404; that's
reported back as ``{"skipped": True}`` rather than raised, since
there's nothing to sync against on those builds.
"""
try:
return vcenter.api_post(
opts, _DEPOTS, params={"action": "sync", "vmw-task": "true"}, profile=profile
)
except requests.HTTPError as exc:
if exc.response is not None and exc.response.status_code == 404:
return {"skipped": True}
raise
# ---------------------------------------------------------------------------
# Desired image
# ---------------------------------------------------------------------------
[docs]
def desired_image_get(opts, cluster, profile=None):
"""Return the cluster's current desired image (committed software spec)."""
return vcenter.api_get(opts, _cluster_path(cluster, "/software"), profile=profile)
[docs]
def base_image_get(opts, cluster, profile=None):
"""Return the cluster's available base images."""
return vcenter.api_get(opts, _cluster_path(cluster, "/software/base-image"), profile=profile)
[docs]
def drafts_list(opts, cluster, profile=None):
"""Return the list of desired-image drafts on the cluster."""
return vcenter.api_get(opts, _cluster_path(cluster, "/software/drafts"), profile=profile)
[docs]
def draft_get(opts, cluster, draft_id, profile=None):
"""Return a single desired-image draft."""
return vcenter.api_get(
opts, _cluster_path(cluster, f"/software/drafts/{draft_id}"), profile=profile
)
[docs]
def draft_delete(opts, cluster, draft_id, profile=None):
"""Delete a desired-image draft."""
return vcenter.api_delete(
opts, _cluster_path(cluster, f"/software/drafts/{draft_id}"), profile=profile
)
[docs]
def draft_import_software_spec(opts, cluster, image_spec, profile=None):
"""Import *image_spec* (a vLCM software spec dict) as a new draft.
Unlike the other draft/lifecycle actions, this one is not
``vmw-task``-based — it always completes synchronously and returns the
new draft directly, so no ``vmw-task=true`` is sent.
"""
body = {"source_type": "JSON_STRING", "software_spec": json.dumps(image_spec)}
return vcenter.api_post(
opts,
_cluster_path(cluster, "/software/drafts"),
body=body,
params={"action": "import-software-spec"},
profile=profile,
)
[docs]
def draft_validate(opts, cluster, draft_id, profile=None):
"""Validate a desired-image draft (task)."""
return vcenter.api_post(
opts,
_cluster_path(cluster, f"/software/drafts/{draft_id}"),
params={"action": "validate", "vmw-task": "true"},
profile=profile,
)
[docs]
def draft_commit(opts, cluster, draft_id, message=None, profile=None):
"""Commit a desired-image draft (task), making it the cluster's desired image.
Always sends the ``message`` field, defaulting to ``""`` — this vAPI
action rejects a request with no ``Content-Type: application/json``
body at all (400 Bad Request), even though *message* is optional.
"""
return vcenter.api_post(
opts,
_cluster_path(cluster, f"/software/drafts/{draft_id}"),
body={"message": message if message is not None else ""},
params={"action": "commit", "vmw-task": "true"},
profile=profile,
)
# ---------------------------------------------------------------------------
# Apply policy
# ---------------------------------------------------------------------------
[docs]
def apply_policy_get(opts, cluster, profile=None):
"""Return the cluster's remediation apply policy."""
return vcenter.api_get(opts, _cluster_path(cluster, "/policies/apply"), profile=profile)
[docs]
def apply_policy_set(opts, cluster, policy_spec, profile=None):
"""Replace the cluster's remediation apply policy."""
return vcenter.api_put(
opts, _cluster_path(cluster, "/policies/apply"), body=policy_spec, profile=profile
)
# ---------------------------------------------------------------------------
# Lifecycle actions (compliance scan, precheck, stage, remediate)
# ---------------------------------------------------------------------------
def _lifecycle_action(opts, cluster, action, body=None, profile=None):
return vcenter.api_post(
opts,
_cluster_path(cluster, "/software"),
body=body,
params={"action": action, "vmw-task": "true"},
profile=profile,
)
[docs]
def compliance_scan(opts, cluster, commit="1", hosts=None, profile=None):
"""Scan the cluster for compliance against its desired image (task).
*commit* selects which committed software spec to scan against
(``"1"`` is the current one — this vAPI action requires the field, it
doesn't default it server-side). *hosts*, if given, restricts the scan
to those host ids; omitted means the whole cluster (sent as ``[]``).
"""
body = {"commit": commit, "hosts": hosts if hosts is not None else []}
return _lifecycle_action(opts, cluster, "scan", body=body, profile=profile)
[docs]
def precheck(opts, cluster, commit="1", profile=None):
"""Run remediation prechecks on the cluster (task). See :func:`compliance_scan` on *commit*."""
return _lifecycle_action(opts, cluster, "check", body={"commit": commit}, profile=profile)
[docs]
def stage(opts, cluster, commit="1", hosts=None, profile=None):
"""Stage the desired image on cluster hosts (task). See :func:`compliance_scan`."""
body = {"commit": commit, "hosts": hosts if hosts is not None else []}
return _lifecycle_action(opts, cluster, "stage", body=body, profile=profile)
# ---------------------------------------------------------------------------
# Reports
# ---------------------------------------------------------------------------
def _report_or_none(opts, cluster, suffix, profile=None):
try:
return vcenter.api_get(
opts, _cluster_path(cluster, f"/software/reports/{suffix}"), profile=profile
)
except requests.HTTPError as exc:
if exc.response is not None and exc.response.status_code == 404:
return None
raise
[docs]
def last_check_result(opts, cluster, profile=None):
"""Return the cluster's last compliance/precheck result, or ``None`` if none exists yet."""
return _report_or_none(opts, cluster, "last-check-result", profile=profile)
[docs]
def apply_impact_report(opts, cluster, profile=None):
"""Return the cluster's apply-impact report, or ``None`` if none exists yet."""
return _report_or_none(opts, cluster, "apply-impact", profile=profile)
[docs]
def last_apply_result(opts, cluster, profile=None):
"""Return the cluster's last apply (remediate) result, or ``None`` if none exists yet."""
return _report_or_none(opts, cluster, "last-apply-result", profile=profile)
# ---------------------------------------------------------------------------
# Task polling
# ---------------------------------------------------------------------------
def _task_id(resp):
"""Extract a task id from a ``vmw-task=true`` response.
Such responses are either a bare task-id string or ``{"value": "<id>"}``.
"""
if isinstance(resp, dict) and "value" in resp:
return resp["value"]
return resp
[docs]
def wait_for_task(opts, resp, timeout=1800, poll_interval=10, profile=None):
"""Block until the task referenced by *resp* (a ``vmw-task=true`` response) completes.
Returns the final task dict. Raises ``RuntimeError`` if the task fails,
or ``TimeoutError`` if it doesn't reach a terminal state within
*timeout*. See :func:`saltext.vcf.utils.vcenter.wait_for_task`.
"""
return vcenter.wait_for_task(
opts, _task_id(resp), timeout=timeout, poll_interval=poll_interval, profile=profile
)