Part 1 of 10. SDDC Manager is not a view onto vCenter. It maintains its own inventory database, populated at commission time and updated only by its own workflows. vCenter maintains a different one. Nothing continuously reconciles them.

When they diverge you do not get an error. You get a workflow six weeks later that fails on a precondition check, with a message naming a component that is working perfectly. This post is about detecting that divergence before it costs you a maintenance window, and about the three API behaviours that make naive scripts report the wrong answer confidently.

01. Three API behaviours that will silently corrupt your results

Pagination, which is the one that bites hardest

Collection endpoints return a page, not a collection. The response carries a pageMetadata block, and the elements array holds only the current page. A script that reads .elements once and stops gets the first page and reports it as the whole fleet. On a small lab this is invisible, because everything fits in one page. On a real estate it means your audit quietly ignores most of your hosts.

{
  "elements": [ ... ],
  "pageMetadata": {
    "pageNumber": 0,
    "pageSize": 100,
    "totalElements": 247,     <-- you have 247, you fetched 100
    "totalPages": 3
  }
}

Token expiry mid-run

The access token is short lived. A fleet-wide audit that walks every host and cluster can outlive it. The failure is a 401 partway through a loop, which an unguarded script turns into a stack trace after twenty minutes of work. Worse, a script that catches and ignores exceptions turns it into a partial result that looks complete.

Transient 5xx during background activity

SDDC Manager runs its own scheduled work. If your audit lands during a bundle download or an inventory sync you will see intermittent 502 and 503. These are transient and retryable, and a script without backoff reports them as findings.

All three produce a plausible wrong answer rather than an obvious failure. That is what makes them dangerous in an audit script. The session class below handles all three, and every later part of this series reuses it.

02. A session class that is actually safe to schedule

#!/usr/bin/env python3
"""vcf_session.py - a reusable SDDC Manager client.

Handles the three failure modes that corrupt naive audits:
  * paginated collections           -> paginate() exhausts every page
  * access token expiry mid-run     -> transparent refresh on 401, once
  * transient 5xx from background   -> exponential backoff with jitter
     work on the appliance
"""

import logging
import random
import threading
import time
from typing import Iterator, Optional

import requests
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
log = logging.getLogger("vcf")

RETRYABLE = {429, 500, 502, 503, 504}


class VcfError(RuntimeError):
    """Non-retryable API failure, carrying the server's own message."""

    def __init__(self, status: int, path: str, body: str):
        self.status, self.path = status, path
        super().__init__(f"HTTP {status} on {path}: {body[:400]}")


class VcfSession:
    def __init__(self, host, username, password,
                 verify=False, timeout=60, max_retries=4):
        self.base = f"https://{host}/v1"
        self.timeout = timeout
        self.max_retries = max_retries
        self._creds = (username, password)
        self._lock = threading.Lock()   # guards token refresh
        self._refresh_id: Optional[str] = None
        self.s = requests.Session()
        self.s.verify = verify
        self._authenticate()

    # ---------- authentication ----------

    def _authenticate(self):
        user, pwd = self._creds
        r = self.s.post(f"{self.base}/tokens",
                        json={"username": user, "password": pwd},
                        timeout=self.timeout)
        if r.status_code >= 400:
            raise VcfError(r.status_code, "/tokens", r.text)
        data = r.json()
        self._apply(data["accessToken"])
        # refreshToken is an object in most releases, a string in some
        rt = data.get("refreshToken")
        self._refresh_id = rt.get("id") if isinstance(rt, dict) else rt
        log.debug("authenticated, refresh token %s",
                  "present" if self._refresh_id else "absent")

    def _apply(self, token: str):
        self.s.headers["Authorization"] = f"Bearer {token}"

    def _renew(self):
        """Refresh the access token. Falls back to a full re-auth.

        Serialised so that concurrent workers hitting 401 at the same
        moment do not each burn a refresh.
        """
        with self._lock:
            if self._refresh_id:
                r = self.s.patch(
                    f"{self.base}/tokens/access-token/refresh",
                    data=self._refresh_id,
                    headers={"Content-Type": "application/json"},
                    timeout=self.timeout)
                if r.status_code < 400:
                    self._apply(r.json()["accessToken"])
                    log.debug("access token refreshed")
                    return
                log.warning("refresh failed (%s), re-authenticating",
                            r.status_code)
            self._authenticate()

    # ---------- transport ----------

    def request(self, method: str, path: str, **kw):
        url = f"{self.base}{path}"
        refreshed = False

        for attempt in range(self.max_retries + 1):
            try:
                r = self.s.request(method, url, timeout=self.timeout, **kw)
            except requests.RequestException as e:
                if attempt == self.max_retries:
                    raise
                self._sleep(attempt, f"transport error: {e}")
                continue

            if r.status_code == 401 and not refreshed:
                # one refresh per call, then treat 401 as terminal
                refreshed = True
                self._renew()
                continue

            if r.status_code in RETRYABLE and attempt < self.max_retries:
                self._sleep(attempt, f"HTTP {r.status_code}")
                continue

            if r.status_code >= 400:
                raise VcfError(r.status_code, path, r.text)

            return r.json() if r.content else {}

        raise VcfError(0, path, "retries exhausted")

    def _sleep(self, attempt: int, why: str):
        # exponential backoff, jittered so parallel runs do not resonate
        delay = min(2 ** attempt, 16) + random.uniform(0, 0.75)
        log.warning("%s, retry %d in %.1fs", why, attempt + 1, delay)
        time.sleep(delay)

    def get(self, path: str, **kw):
        return self.request("GET", path, **kw)

    # ---------- pagination ----------

    def paginate(self, path: str, page_size: int = 100) -> Iterator[dict]:
        """Yield every element across every page.

        Falls back gracefully on endpoints that ignore paging params and
        return a bare list, which several VCF endpoints still do.
        """
        page = 0
        seen = 0
        while True:
            sep = "&" if "?" in path else "?"
            body = self.get(f"{path}{sep}pageNumber={page}&pageSize={page_size}")

            if isinstance(body, list):
                yield from body
                return

            elements = body.get("elements")
            if elements is None:
                yield body
                return

            yield from elements
            seen += len(elements)

            meta = body.get("pageMetadata") or {}
            total_pages = meta.get("totalPages")
            total = meta.get("totalElements")

            if total_pages is None:
                # no metadata: stop when a short page comes back
                if len(elements) < page_size:
                    return
            elif page + 1 >= total_pages:
                if total is not None and seen != total:
                    log.warning("%s: collected %d of %d elements",
                                path, seen, total)
                return

            page += 1

03. The reconciliation itself

Now the actual question. Take the set of hosts SDDC Manager believes exist, take the set vCenter believes exist, and compare them in both directions. The interesting findings are the asymmetries.

#!/usr/bin/env python3
"""vcf_inventory_drift.py - reconcile SDDC Manager against live vCenter."""

import argparse
import logging
import ssl
import sys
from collections import defaultdict

from pyVim.connect import SmartConnect, Disconnect
from pyVmomi import vim

from vcf_session import VcfSession, VcfError

logging.basicConfig(level=logging.WARNING,
                    format="%(levelname)s %(message)s")


def sddc_inventory(vcf):
    """What SDDC Manager believes: hosts, their domain, cluster and state."""
    domains = {d["id"]: d for d in vcf.paginate("/domains")}
    clusters = {c["id"]: c for c in vcf.paginate("/clusters")}

    hosts = {}
    for h in vcf.paginate("/hosts"):
        fqdn = (h.get("fqdn") or "").lower()
        if not fqdn:
            continue
        cid = (h.get("cluster") or {}).get("id")
        did = (h.get("domain") or {}).get("id")
        hosts[fqdn] = {
            "id": h.get("id"),
            "status": h.get("status"),
            "storage": h.get("storageType"),
            "version": h.get("esxiVersion") or h.get("version"),
            "cluster": (clusters.get(cid) or {}).get("name"),
            "domain": (domains.get(did) or {}).get("name"),
        }
    return domains, clusters, hosts


def vcenter_inventory(host, user, pwd):
    """What vCenter believes: connected hosts, build, cluster, maintenance."""
    ctx = ssl._create_unverified_context()
    si = SmartConnect(host=host, user=user, pwd=pwd, sslContext=ctx)
    try:
        content = si.RetrieveContent()
        view = content.viewManager.CreateContainerView(
            content.rootFolder, [vim.HostSystem], True)
        out = {}
        for h in view.view:
            parent = h.parent
            out[h.name.lower()] = {
                "state": h.runtime.connectionState,
                "maintenance": h.runtime.inMaintenanceMode,
                "build": h.config.product.build if h.config else None,
                "version": h.config.product.version if h.config else None,
                "cluster": parent.name if isinstance(
                    parent, vim.ClusterComputeResource) else None,
            }
        view.Destroy()
        return out
    finally:
        Disconnect(si)


def reconcile(sddc_hosts, vc_hosts):
    findings = defaultdict(list)

    only_sddc = set(sddc_hosts) - set(vc_hosts)
    only_vc = set(vc_hosts) - set(sddc_hosts)
    both = set(sddc_hosts) & set(vc_hosts)

    for fqdn in sorted(only_sddc):
        findings["ORPHAN_IN_SDDC"].append(
            f"{fqdn} is in SDDC Manager inventory "
            f"(status={sddc_hosts[fqdn]['status']}) but absent from vCenter")

    for fqdn in sorted(only_vc):
        findings["UNMANAGED_IN_VC"].append(
            f"{fqdn} exists in vCenter "
            f"(cluster={vc_hosts[fqdn]['cluster']}) but SDDC Manager "
            "has no record of it")

    for fqdn in sorted(both):
        s, v = sddc_hosts[fqdn], vc_hosts[fqdn]

        if s["cluster"] and v["cluster"] and s["cluster"] != v["cluster"]:
            findings["CLUSTER_MISMATCH"].append(
                f"{fqdn}: SDDC Manager says {s['cluster']}, "
                f"vCenter says {v['cluster']}")

        if v["state"] != "connected":
            findings["HOST_NOT_CONNECTED"].append(
                f"{fqdn}: vCenter reports {v['state']}")

        if v["maintenance"]:
            findings["IN_MAINTENANCE"].append(
                f"{fqdn}: still in maintenance mode")

        if s["version"] and v["version"] and not \
                str(s["version"]).startswith(str(v["version"])):
            findings["VERSION_MISMATCH"].append(
                f"{fqdn}: SDDC Manager recorded {s['version']}, "
                f"host is running {v['version']} build {v['build']}")

    return findings


def stuck_tasks(vcf, hours=24):
    """In-progress tasks older than N hours block later workflows."""
    out = []
    for t in vcf.paginate("/tasks"):
        if t.get("status") in ("IN_PROGRESS", "Pending"):
            out.append(f"{t.get('name')} [{t.get('id')}] "
                       f"status={t.get('status')} "
                       f"started={t.get('creationTimestamp')}")
        elif t.get("status") == "Failed":
            out.append(f"FAILED: {t.get('name')} [{t.get('id')}]")
    return out


def main():
    p = argparse.ArgumentParser()
    p.add_argument("--sddc", required=True)
    p.add_argument("--sddc-user", required=True)
    p.add_argument("--sddc-pass", required=True)
    p.add_argument("--vcenter", required=True)
    p.add_argument("--vc-user", required=True)
    p.add_argument("--vc-pass", required=True)
    p.add_argument("-v", "--verbose", action="store_true")
    a = p.parse_args()

    if a.verbose:
        logging.getLogger("vcf").setLevel(logging.DEBUG)

    try:
        vcf = VcfSession(a.sddc, a.sddc_user, a.sddc_pass)
    except VcfError as e:
        sys.exit(f"cannot reach SDDC Manager: {e}")

    domains, clusters, sddc_hosts = sddc_inventory(vcf)
    vc_hosts = vcenter_inventory(a.vcenter, a.vc_user, a.vc_pass)

    print(f"SDDC Manager : {len(domains)} domains, "
          f"{len(clusters)} clusters, {len(sddc_hosts)} hosts")
    print(f"vCenter      : {len(vc_hosts)} hosts\n")

    findings = reconcile(sddc_hosts, vc_hosts)

    if not findings:
        print("Inventories agree.")
    for kind in sorted(findings):
        print(f"[{kind}]")
        for line in findings[kind]:
            print(f"    {line}")
        print()

    tasks = stuck_tasks(vcf)
    if tasks:
        print("[TASKS] in-progress or failed, these block later workflows")
        for t in tasks:
            print(f"    {t}")

    return 2 if (findings or tasks) else 0


if __name__ == "__main__":
    sys.exit(main())

04. What each finding actually means

FindingUsual causeWhat it breaks laterAction
ORPHAN_IN_SDDCHost removed from vCenter directly, or a decommission that failed after the vCenter stepUpgrade prechecks fail trying to contact a host that is gone. Capacity reporting overstates the fleetComplete the decommission through SDDC Manager rather than deleting again in vCenter
UNMANAGED_IN_VCHost added straight into a VCF-managed cluster in the vSphere ClientThe host receives no lifecycle updates and is invisible to VCF workflows. On a vSAN cluster it can affect the cluster while VCF has no record of itRemove it from the cluster and commission it properly, or accept it is out of band and document it
CLUSTER_MISMATCHHost moved between clusters in vCenterWorkload domain operations target the wrong cluster. This is the one that causes genuinely surprising failuresMove it back, then perform the move through VCF
VERSION_MISMATCHHost patched out of bandUpgrade planning is computed from stale data. The host may be outside the BOM entirelyRe-run inventory sync, and confirm the build against your VCF BOM
HOST_NOT_CONNECTEDGenuine outage, or a certificate trust failureEverythingIf several appeared at once after a certificate change, see part 8
TASKSA workflow that never completedA stuck task can hold a lock that makes the next operation fail with an unrelated messageResolve or explicitly fail the task before starting anything else

The general rule behind every row: make the change through SDDC Manager, or SDDC Manager will not know about it. The vSphere Client will happily let you move, patch or remove a VCF-managed host, and nothing warns you at the time. The cost arrives at the next upgrade.

05. PowerShell, same reconciliation

#requires -Version 7.0
#requires -Modules VMware.PowerCLI
<#
  vcf-inventory-drift.ps1
  Reconciles SDDC Manager inventory against live vCenter state.
  Handles pagination and token expiry, which the obvious version does not.
#>

param(
    [Parameter(Mandatory)][string]$SddcManager,
    [Parameter(Mandatory)][pscredential]$SddcCredential,
    [Parameter(Mandatory)][string]$VCenter,
    [Parameter(Mandatory)][pscredential]$VcCredential
)

$ErrorActionPreference = 'Stop'
$script:base = "https://$SddcManager/v1"
$script:token = $null
$script:refresh = $null
$skip = @{ SkipCertificateCheck = $true }

function Connect-Vcf {
    $body = @{
        username = $SddcCredential.UserName
        password = $SddcCredential.GetNetworkCredential().Password
    } | ConvertTo-Json
    $r = Invoke-RestMethod -Method Post -Uri "$script:base/tokens" `
            -Body $body -ContentType 'application/json' @skip
    $script:token   = $r.accessToken
    $script:refresh = if ($r.refreshToken -is [string]) { $r.refreshToken }
                      else { $r.refreshToken.id }
}

function Invoke-Vcf {
    param([string]$Path)
    $hdr = @{ Authorization = "Bearer $script:token" }
    try {
        Invoke-RestMethod -Method Get -Uri "$script:base$Path" -Headers $hdr @skip
    }
    catch {
        if ($_.Exception.Response.StatusCode.value__ -eq 401) {
            Connect-Vcf   # token aged out mid-run
            $hdr = @{ Authorization = "Bearer $script:token" }
            Invoke-RestMethod -Method Get -Uri "$script:base$Path" -Headers $hdr @skip
        }
        else { throw }
    }
}

function Get-VcfAll {
    # exhausts every page rather than returning page zero
    param([string]$Path, [int]$PageSize = 100)
    $page = 0
    do {
        $sep = if ($Path -match '\?') { '&' } else { '?' }
        $r = Invoke-Vcf "$Path$sep`pageNumber=$page&pageSize=$PageSize"
        if ($r -is [array]) { $r; return }
        if ($null -eq $r.elements) { $r; return }
        $r.elements
        $total = $r.pageMetadata.totalPages
        $page++
    } while ($total -and $page -lt $total)
}

Connect-Vcf

$domains  = @{}; Get-VcfAll '/domains'  | ForEach-Object { $domains[$_.id]  = $_ }
$clusters = @{}; Get-VcfAll '/clusters' | ForEach-Object { $clusters[$_.id] = $_ }

$sddc = @{}
foreach ($h in (Get-VcfAll '/hosts')) {
    if (-not $h.fqdn) { continue }
    $sddc[$h.fqdn.ToLower()] = [pscustomobject]@{
        Status  = $h.status
        Storage = $h.storageType
        Version = $h.esxiVersion
        Cluster = $clusters[$h.cluster.id].name
        Domain  = $domains[$h.domain.id].name
    }
}

Connect-VIServer -Server $VCenter -Credential $VcCredential | Out-Null
try {
    $vc = @{}
    foreach ($h in Get-VMHost) {
        $vc[$h.Name.ToLower()] = [pscustomobject]@{
            State       = $h.ConnectionState
            Maintenance = ($h.ConnectionState -eq 'Maintenance')
            Version     = $h.Version
            Build       = $h.Build
            Cluster     = (Get-Cluster -VMHost $h -ErrorAction SilentlyContinue).Name
        }
    }
}
finally { Disconnect-VIServer -Confirm:$false -ErrorAction SilentlyContinue }

"SDDC Manager : {0} domains, {1} clusters, {2} hosts" -f `
    $domains.Count, $clusters.Count, $sddc.Count
"vCenter      : {0} hosts`n" -f $vc.Count

$issues = 0

foreach ($f in ($sddc.Keys | Where-Object { $_ -notin $vc.Keys } | Sort-Object)) {
    Write-Host "[ORPHAN_IN_SDDC]   $f  (status=$($sddc[$f].Status))" -ForegroundColor Red
    $issues++
}
foreach ($f in ($vc.Keys | Where-Object { $_ -notin $sddc.Keys } | Sort-Object)) {
    Write-Host "[UNMANAGED_IN_VC]  $f  (cluster=$($vc[$f].Cluster))" -ForegroundColor Red
    $issues++
}
foreach ($f in ($sddc.Keys | Where-Object { $_ -in $vc.Keys } | Sort-Object)) {
    $s = $sddc[$f]; $v = $vc[$f]
    if ($s.Cluster -and $v.Cluster -and $s.Cluster -ne $v.Cluster) {
        Write-Host "[CLUSTER_MISMATCH] $f  SDDC=$($s.Cluster) vCenter=$($v.Cluster)" -ForegroundColor Yellow
        $issues++
    }
    if ($v.State -ne 'Connected') {
        Write-Host "[NOT_CONNECTED]    $f  $($v.State)" -ForegroundColor Yellow
        $issues++
    }
    if ($s.Version -and $v.Version -and -not $s.Version.StartsWith($v.Version)) {
        Write-Host "[VERSION_MISMATCH] $f  recorded=$($s.Version) actual=$($v.Version) build $($v.Build)" -ForegroundColor Yellow
        $issues++
    }
}

$open = @(Get-VcfAll '/tasks' | Where-Object { $_.status -in 'IN_PROGRESS','Pending','Failed' })
foreach ($t in $open) {
    Write-Host "[TASK] $($t.status): $($t.name) [$($t.id)]" -ForegroundColor Magenta
    $issues++
}

Write-Host "`nFindings: $issues"
if ($issues) { exit 2 }

06. Running it usefully

Both scripts exit 2 on findings and 0 on a clean run, so they drop straight into a pipeline or a cron job that only mails you when something is wrong. Run it weekly, and run it immediately before any upgrade. The prechecks you are about to run will consult the same inventory, and it is considerably cheaper to find the drift now than to find it when the upgrade aborts at 40 percent.

Next in the series: per-VM latency from the Tintri VMstore API, and the four-way split that ends most storage arguments in a single call.

Response field names vary across VCF releases, particularly esxiVersion, storageType and the shape of refreshToken. Dump one host object and confirm before scheduling. Read only credentials are sufficient on both sides. Nothing here is official guidance.

Leave a Reply

Discover more from VMwareBlogs

Subscribe now to keep reading and get access to the full archive.

Continue reading