#! /usr/bin/env python3
# Copyright 2020-2023 VMware, Inc.  All rights reserved. -- VMware Confidential
# Description: Provides class EdgeClusterCleaner
# Disabled: True
#
#  edge_cluster_cleaner.py
#

import argparse
from datetime import datetime, timedelta
import getpass
import json
import logging
import os
import pprint
import re
import subprocess
import sys
import tempfile
import textwrap
import time
import traceback
import urllib.parse

from cleaner_version import CLEANER_VERSION
from vcf_utils import EdgeClusterConstants
from vcf_utils import SddcMgrRest
from vcf_utils import UtilLog
from vcf_utils import VcfCreds
from vcf_utils import VcConnection

from pyVmomi import vim


logger = logging.getLogger(__name__)


class EdgeClusterCleaner:
    """Find & remove NSX-T edge cluster config artifacts from a VCF workload.
    """

    DELETE_RETRY_PAUSE_SECS = 3

    POLL_RETRY_PAUSE_SECS = 5

    MAX_WAIT_DURATION_SECS = 120

    RUN_WARNING = textwrap.dedent("""\
        Before running the Edge Cluster Deletion script ensure that:
        
        * This script only removes the Edge Cluster and logical routers deployed
          through the VCF Edge Cluster Creation workflow.
          Any additional services, interfaces or segments created/deployed on top of
          the VCF edge cluster deployment should either be deleted or unlinked.
        
        * Any name changes made to the fabric or logical entities deployed by the VCF
          Edge Cluster workflow should be reverted to their original names.
        
        * Any and all WCP and related components deployed on top of the Edge Cluster
          deployed by the VCF Edge Cluster Creation workflow should be deleted through
          the vCenter.

        * Any federated tier-0 or tier-1 logical routers hosted on the Edge Cluster
          are removed by using the NSX-T global manager.
        
        Failure to take these actions before running the script may result in failures
        which may require manual intervention on NSX-T manager and SDDC Manager to
        clean up the remnants.
        """)  # noqa

    # name of a zero-byte file created to show user accepted the warning
    # prompt at least once
    RUN_WARNING_ACCEPTED_MARKER_FILE = "~/.sddc_edge_removal_warning_accepted"

    def _getArgs(self):
        default_user = os.getenv("SDDC_SSO_USERNAME")
        default_pass = os.getenv("SDDC_SSO_PASSWORD")
        default_mgr_addr = os.getenv("SDDC_MANAGER_ADDR", default="10.0.0.4")
        prog_usage_name = os.getenv("SDDC_EDGE_REMOVER_USAGE_NAME",
                                    sys.argv[0])

        self.prog_usage_name = prog_usage_name

        parser = argparse.ArgumentParser(
            description=("Remove VCF edge cluster deployment(s) from a "
                         "workload"),
            prog=prog_usage_name)

        parser.add_argument(
            '--cluster', '-c', required=False,
            help='Name of edge cluster to remove')
        parser.add_argument(
            '--force-cluster', required=False, action='store_true',
            help=('Allow removal of an edge cluster whose VCF workload '
                  'association is not in VCF inventory'))
        parser.add_argument(
            '--workload', '-w', required=False,
            help='Name of VCF workload from which we remove edge cluster(s)')
        parser.add_argument(
            '--user', '-u', required=False, default=default_user,
            help='Name of single-signon admin user to authenticate as')
        parser.add_argument(
            '--password', '-p', required=False, default=default_pass,
            help='Password for specified user')
        parser.add_argument(
            '--skip-warning', '-s', required=False, action='store_true',
            help=('Skip warning message and confirmation. Ignored unless the '
                  'warning confirmation prompt has been accepted at least '
                  'once.'))
        parser.add_argument(
            '--no-log', required=False, action='store_true',
            help=("By default, we log cleaning runs to /tmp. Specify this "
                  "switch to only write to the console and not to a log "
                  "file."))
        parser.add_argument(
            '--dryrun', '-d', required=False, action='store_true',
            help='Compute and report but do not apply changes')
        parser.add_argument(
            '--verbose', '-v', required=False, action='store_true',
            help='Provide extra output detail during operation')
        # If not in an SDDC Manager VM, address of the one to use -- requires
        # password-free ssh access to the VM as user "vcf".
        parser.add_argument(
            '--manager-addr', '-m', required=False, default=default_mgr_addr,
            help=argparse.SUPPRESS)

        self.args = parser.parse_args()

        if not self.args.user:
            logger.error("Username value is required, please specify "
                         "the --user option.")
            self._stopNow(quiet=True)

    def _checkClusterArgs(self):
        """Verify that user-supplied cluster args are acceptable.
        """

        vcfAllEdgeClusters = self._getAllEdgeClustersInventory()

        def _getClustName(clust):
            return clust["name"]

        self.vcfAllEdgeClustersMap = (
            {cl["name"]: cl for cl in vcfAllEdgeClusters})

        if not self.args.cluster:
            logger.error("Please specify an Edge cluster with --cluster")
            logger.error("--cluster may select one of the following Edge "
                         "clusters:")
            logger.error("  %s" % self.getEdgeClusterNames())
            return False

        vcfAllWorkloadDomains = self._getAllWldsInventory()
        vcfAllWldsMap = {wld["name"]: wld for wld in vcfAllWorkloadDomains}

        # if user specified a workload, make sure it exists:
        if self.args.workload and self.args.workload not in vcfAllWldsMap:
            logger.error("Unknown workload name '%s'" %
                         self.args.workload)
            logger.error("Workloads are: %s" %
                         ", ".join(vcfAllWldsMap.keys()))
            return False

        # User specified a particular edge cluster name. Verify that it is in
        # VCF inventory.
        if self.args.cluster not in self.vcfAllEdgeClustersMap:
            logger.error("Unknown edge cluster name. Please choose one of:")
            logger.error("  %s" % self.getEdgeClusterNames())
            return False

        # got a more or less viable edge cluster, save its ID
        self.edgeClusterVcfId = (
            self.vcfAllEdgeClustersMap[self.args.cluster]["id"])

        # Edge cluster is in our inventory. If it doesn't have VC cluster
        # info, then ensure that both --workload and --force-cluster are
        # also specified.
        edgeClustVcClusterIds = (
            self.vcfAllEdgeClustersMap[self.args.cluster]["clusterIds"])
        if not edgeClustVcClusterIds:
            # no VC cluster, and thus no WLD affiliation. Require both of the
            # --workload and --force-cluster options as well.
            # (This is for bug 2543467.)
            if (not self.args.workload) or (not self.args.force_cluster):
                logger.error("Inventory data for cluster %s" %
                             self.args.cluster)
                logger.error("does not include its workload domain info.")
                logger.error("Please provide the correct workload name using "
                             "--workload")
                logger.error("and also provide the --force-cluster option to "
                             "confirm.")
                logger.error("Workload name may be one of these values:")
                logger.error("  %s" % ", ".join(vcfAllWldsMap.keys()))
                return False

            self.workloadName = self.args.workload
            self.workloadId = vcfAllWldsMap[self.workloadName]["id"]

        else:
            # edge cluster has VC cluster(s) listed.

            # If --workload is also
            # given, verify that they agree.
            if self.args.workload:
                wldVcClusterIds = self._getWorkloadVcClusterIds(
                    vcfAllWldsMap[self.args.workload]["id"])
                # (checking a single VC cluster is sufficient, since workloads
                #  never share clusters)
                if edgeClustVcClusterIds[0] not in wldVcClusterIds:
                    logger.error(
                        "Edge cluster %s is not part of workload %s" %
                        (self.args.cluster, self.args.workload))
                    return False

                self.workloadName = self.args.workload
            else:
                # Infer workload name from edge cluster's own VC cluster(s).
                # (checking a single VC cluster is sufficient, since workloads
                #  never share clusters)
                inferredWldName = None
                for wld in vcfAllWorkloadDomains:
                    wldVcClusterIds = self._getWorkloadVcClusterIds(wld["id"])
                    if edgeClustVcClusterIds[0] in wldVcClusterIds:
                        inferredWldName = wld["name"]
                        break

                if not inferredWldName:
                    logger.error("Edge cluster %s's VC cluster %s" %
                                 (self.args.cluster,
                                  edgeClustVcClusterIds[0]))
                    logger.error("Not found in any workload.")
                    logger.error("Please contact your support representative.")
                    return False

                self.workloadName = inferredWldName

            self.workloadId = vcfAllWldsMap[self.workloadName]["id"]

        return True

    def getOptQuotedName(self, name):
        """Return input name, adding surrounding quotes iff the name
        includes any spaces.
        """
        if ' ' in name:
            return f"'{name}'"

        return name

    def getEdgeClusterNames(self):
        """Return a list of known edge cluster names. The list is all in a
        single line (no line breaks), but any names including spaces are
        also quoted.
        """
        quotedNames = []
        for name in self.vcfAllEdgeClustersMap.keys():
            quotedNames.append(self.getOptQuotedName(name))

        return ", ".join(quotedNames)

    def isDryRun(self):
        """Use this instead of self.args directly in order to minimize the
        risks of typos."""
        return self.args.dryrun

    def _stopNow(self, quiet=False):
        """Display a message and exit the program right now.
        """
        if not quiet:
            logger.error("Cleaner is stopping now.")
        if self.logFilename:
            logger.warning("Log written to %s" % self.logFilename)
        sys.exit(1)

    def _nsxtRelToAbsUrl(self, relUrl):
        slashDelim = "" if relUrl.startswith("/") else "/"
        absUrl = ("https://" + self.creds.nsxtCred.ipAddr + slashDelim +
                  urllib.parse.quote(relUrl))
        return absUrl

    def _nsxtBaseCurlCmd(self):
        baseCmd = ["curl", "-s", "--insecure",
                   "-u", "%s:%s" % (self.creds.nsxtCred.username,
                                    self.creds.nsxtCred.password),
                   "-H", "Accept:application/json",
                   "-H", "Content-Type:application/json"]

        """
        if self.args.verbose:
            #BUGBUG - the following will likely corrupt console output
            #  so that get-like operations don't provide a valid json
            #  response.
            baseCmd.append("--verbose")  #NOPUSH
        """

        return baseCmd

    def _nsxtGet(self, relUrl, errorOk=False, errorQuiet=False):
        """Return JSON results, parsed to a dict, from querying the specified URL
        relative URL on the NSX-T Manager.

        errorOk : bool
            If True, tolerate request errors which would normally be fatal.
        errorQuiet : bool
            If True, and errorOk is True, don't even log errors.
        """

        if not errorOk:
            errorQuiet = False

        # NB: we don't know what REST libraries are available in all our
        #     target platforms (SDDC Mgr, or any other host with password-free
        #     access to SDDC Mgr) so we use curl for REST operations.

        absUrl = self._nsxtRelToAbsUrl(relUrl)

        cmd = self._nsxtBaseCurlCmd() + [absUrl]

        try:
            outText = subprocess.check_output(cmd)
            outJson = json.loads(outText)
        except Exception:
            if not errorQuiet:
                logger.error("Failed to query %s" % relUrl)
            if not errorOk:
                self._stopNow()
            return None

        if (('httpStatus' in outJson) and
                (outJson['httpStatus'] == 'NOT_FOUND')):
            return None

        if ('error_code' in outJson) or ('error_message' in outJson):
            if not errorQuiet:
                pp = pprint.PrettyPrinter()
                logger.error("Error during NSX-T query of %s" % relUrl)
                logger.error("Reported by NSX-T manager at %s:" %
                             self.creds.nsxtCred.ipAddr)
                logger.error(pp.pformat(outJson))

            if errorOk:
                # return a dummy, empty result
                outJson = {}
            else:
                traceback.print_stack()
                self._stopNow()

        return outJson

    def _nsxtGetList(self, relUrl, filterLambda=None,
                     errorOk=False, errorQuiet=False):
        """Same as get(), but returns just the results of a list operation.
        If present, we use the caller-supplied lambda to filter the list to
        only those entries which match the lambda's filter condition.

        errorOk : bool
            If True, tolerate request errors which would normally be fatal.
        errorQuiet : bool
            If True, and errorOk is True, don't even log errors.
        """
        response = self._nsxtGet(relUrl, errorOk=errorOk,
                                 errorQuiet=errorQuiet)
        if "results" in response:
            listOut = response["results"]
        else:
            listOut = []

        if filterLambda:
            listOut = list(filter(filterLambda, listOut))

        return listOut

    def _nsxtAwaitListEmpty(self, relUrl, filterLambda=None, timeoutSecs=60,
                            errorOk=False):
        """Wait until _nsxtGetList() returns an empty list using our own
        input args. Throw a timeout if the list doesn't become empty before
        the specified max timeout elapses.

        errorOk : bool
            If True, tolerate request errors which would normally be fatal.
        """
        timeoutTime = datetime.now() + timedelta(seconds=timeoutSecs)
        curList = self._nsxtGetList(relUrl, filterLambda, errorOk=errorOk)
        while (curList and (datetime.now() < timeoutTime)):
            time.sleep(EdgeClusterCleaner.POLL_RETRY_PAUSE_SECS)
            curList = self._nsxtGetList(relUrl, filterLambda)
            # put dry-run check here so we at least enter this loop
            if self.isDryRun():
                logger.warning("In dry-run, so not waiting for empty list")
                curList = None

        if curList:
            raise TimeoutError("List never became empty")

        return

    def _nsxtPut(self, relUrl, newValue, label=None):
        """Write supplied newValue dict as new value of given URL
        using a PATCH operation, which NSBU appear to favor in general.
        """

        absUrl = self._nsxtRelToAbsUrl(relUrl)

        # spin out newValue as a json tmp file, and add it as an @ argument
        # in the curl command.
        tmpDescr, tmpPathname = tempfile.mkstemp()
        with open(tmpDescr, 'w') as f:
            json.dump(newValue, f)

        putCmd = self._nsxtBaseCurlCmd() + ["-X", "PUT",
                                            "-d", "@" + tmpPathname,
                                            absUrl]

        if not label:
            label = relUrl

        if self.args.verbose:
            verb = "Would patch" if self.isDryRun() else "Patching"
            logger.warning("%s %s to:" % (verb, label))
            pp = pprint.PrettyPrinter()
            logger.warning(pp.pformat(newValue))
            logger.warning("using cmd: %s" % putCmd)

        if self.isDryRun():
            pass
            logger.warning("Would modify: " + label)
        else:
            subprocess.check_call(putCmd,
                                  stdout=subprocess.DEVNULL,
                                  stderr=subprocess.DEVNULL)

            logger.warning("Modified: " + label)

        os.remove(tmpPathname)

        return

    def _nsxtDelete(self, relUrl, label=None, timeoutSecs=30):
        absUrl = self._nsxtRelToAbsUrl(relUrl)

        delCmd = self._nsxtBaseCurlCmd() + ["-X", "DELETE", absUrl]

        if not label:
            label = relUrl

        if self.isDryRun():
            logger.warning("Would delete: " + label)
        else:
            # NB: Policy delete never seems to fail, even when the target
            #     doesn't exist.  But we do either policy or imperative.
            timeoutTime = datetime.now() + timedelta(seconds=timeoutSecs)
            item = None
            while (datetime.now() < timeoutTime):
                subprocess.check_call(delCmd,
                                      stdout=subprocess.DEVNULL,
                                      stderr=subprocess.DEVNULL)

                delOutput = subprocess.check_output(delCmd)
                try:
                    delResult = json.loads(delOutput)
                    if "httpStatus" in delResult:
                        httpStatus = delResult["httpStatus"]
                        logger.warning("httpStatus = %s" % httpStatus)
                        if httpStatus == "BAD_REQUEST":
                            logger.error("Delete attempt failed for %s %s:" %
                                         (label, relUrl))
                            pp = pprint.PrettyPrinter()
                            logger.error(pp.pformat(delResult))
                            self._stopNow()
                except Exception:
                    # no delete output, so rely on following presence
                    # check instead
                    pass

                # now see if it is really deleted
                item = self._nsxtGet(relUrl)
                if not item:
                    # all gone, we're done.
                    break

                time.sleep(EdgeClusterCleaner.DELETE_RETRY_PAUSE_SECS)

            if item:
                pp = pprint.PrettyPrinter()
                itemLabel = label if label else ""
                raise TimeoutError(
                    "Timed out waiting for delete of %s %s [%s]" %
                    (itemLabel, relUrl, self._nsxtRelToAbsUrl(relUrl)))

            logger.warning("Deleted: " + label)

    def _cleanNsxtList(self, relListUrl, filterLambda=None, timeoutSecs=60,
                       itemTypeName=None, secondKeyValueFieldId=None,
                       secondKeyExistenceCheckerName=None, errorOk=False):
        """
        Given a relative NSX-T URL which will return a list of items,
        retrieve said list and then use delete to remove each item.
        We assume the usual NSX-T convention of each list item having
        an "id" value to get its key.

        This method is not recursive, so you must directly remove any
        child lists from each item before calling this for your top-level
        list of items.

        relListUrl : String
            Relative URL used to obtain list of items to delete.
        filterLambda : lambda
            If present, used to select list items to be removed.
            Other items are undisturbed.
        timeoutSecs : int
            After removal we double-check the list to see that all selected
            items were in fact removed. Some deletes are asynchronous, so
            this might take a while. This timeout is an upper bound on how
            long we'll wait before raising a TimeoutError.
        itemTypeName : String
            Explicit type name to use in messages, optional.
        secondKeyValueFieldId : String
            ID strict of some field in each list item which is used as part of
            a secondary deletion check.
        secondKeyExistenceCheckerName : method
            Name of an instance method of our class, which is supplied the
            secondKeyValueFieldId's identified (pre-deletion) value.
            This method is called until it returns False or until we time out
            & get tired of waiting.
        errorOk : bool
            If True, tolerate request errors which would normally be fatal.
        """

        cleanListStartTime = datetime.now()

        items = self._nsxtGetList(relListUrl, filterLambda=filterLambda,
                                  errorOk=errorOk)
        for item in items:
            itemId = item["id"]
            itemUrl = relListUrl + "/" + itemId

            label = item["display_name"]
            if itemTypeName:
                label = itemTypeName + " " + label

            # cache second key value, if requested
            if secondKeyValueFieldId:
                secondKeyValue = item[secondKeyValueFieldId]
            else:
                secondKeyValue = None

            self._nsxtDelete(itemUrl, label=label)

            if secondKeyExistenceCheckerName:
                # asked to watch for this, so check until the caller-supplied
                # supplemental test says the value is really deleted.
                secondKeyStartTime = datetime.now()
                secondKeyTimeoutTime = (secondKeyStartTime +
                                        timedelta(seconds=timeoutSecs))
                secondKeyChecker = getattr(self, secondKeyExistenceCheckerName)

                while datetime.now() < secondKeyTimeoutTime:
                    if not secondKeyChecker(secondKeyValue):
                        break
                    time.sleep(4)
                    if self.isDryRun():
                        break

                secondKeyElapsed = datetime.now() - secondKeyStartTime
                logger.warning("Second key wait of %s (%s) took %d seconds" %
                               (label, secondKeyValue,
                                secondKeyElapsed.seconds))

        self._nsxtAwaitListEmpty(relListUrl, filterLambda=filterLambda,
                                 timeoutSecs=timeoutSecs, errorOk=errorOk)

        cleanListElapsed = datetime.now() - cleanListStartTime
        logger.warning("List clean of %s took %d seconds" %
                       (relListUrl, cleanListElapsed.seconds))

        return

    def _anyWorkflowLocksClaimed(self):
        """Return True if SDDC Manager has any workflow locks asserted now,
        False otherwise.

        This is intended to let us avoid running at the same time as any
        SDDC Manager workflow, in order to minimize risk of conflicts in
        inventory data updates.
        """
        queryUrl = "http://localhost/locks"
        workflowLocks = self.sddcMgrRest.runSddcMgrRestGet(queryUrl)
        if workflowLocks:
            return True

        return False

    def _unlinkTier1Segments(self, tier1RelUrl):
        """Find segments linked to specified tier-1 gateway,
        and both shut down and isolate each such segment.

        The hope is that this is sufficient to unencumber the
        tier-1 gateway so that it may be deleted without first
        having to remove whatever tenant VMs may be connected
        to the now-isolated segments.
        """

        # grab "policy path" of our target tier-1 gateway
        tier1Detail = self._nsxtGet(tier1RelUrl)
        tier1Path = tier1Detail["path"]
        tier1Name = tier1Detail["display_name"]

        relSegListUrl = EdgeClusterConstants.REL_SEGMENT_LIST_URL
        segments = self._nsxtGetList(relSegListUrl)
        segsIsolated = 0

        for seg in segments:
            if seg.get("connectivity_path", "") == tier1Path:
                segName = seg["display_name"]
                logger.warning("Attempting to isolate segment %s linked to "
                               "tier-1 %s" % (segName, tier1Name))
                segUrl = relSegListUrl + "/" + seg["id"]

                seg["admin_state"] = "DOWN"

                # we remove connectivity_path entirely, since this is what
                # setting the seg UI to "isolated" does:
                seg.pop("connectivity_path", None)

                self._nsxtPut(segUrl, seg, label=segName)

                segsIsolated += 1

        if segsIsolated:
            logger.warning("%s segment(s) isolated from tier-1 %s"
                           % (segsIsolated, tier1Name))
        else:
            logger.warning("Tier-1 gw %s has no linked segments to isolate"
                           % tier1Name)

        return

    def _cleanTier1NatRules(self, tier1RelUrl):
        # For now, we only do the USER segment, not sure if we should do
        # INTERNAL and DEFAULT segment types, and they are empty in sample
        # (apparently WCP-deployed) testbed anyway.
        self._cleanNsxtList(tier1RelUrl + "/nat/USER/nat-rules",
                            itemTypeName="tier-1 NAT rules")
        return

    def _cleanDependentLBVirtualServers(self, lbPath):
        """Nuke all virtual servers which point back to the specified
        load balancer path.
        """

        relLbVirtServListUrl = "/policy/api/v1/infra/lb-virtual-servers"

        pathFilterLambda = (
            lambda item:
                (item["lb_service_path"] == lbPath))

        self._cleanNsxtList(relLbVirtServListUrl,
                            itemTypeName="lb virtual server",
                            filterLambda=pathFilterLambda)

        return

    def _cleanDependentLoadBalancer(self, connectivityPath):
        """Given the connectivity path of some object, find and remove all
        load balancer instances which connect to that path.
        """

        relLbListUrl = "/policy/api/v1/infra/lb-services"

        pathFilterLambda = (
            lambda item:
                (item["resource_type"] == "LBService" and
                 item["connectivity_path"] == connectivityPath))

        lbList = self._nsxtGetList(relLbListUrl,
                                   filterLambda=pathFilterLambda)

        for loadBal in lbList:
            lbRelUrl = relLbListUrl + "/" + loadBal["id"]
            lbPath = loadBal["path"]

            self._cleanDependentLBVirtualServers(lbPath)

            self._nsxtDelete(lbRelUrl, label="load balancer")

        self._nsxtAwaitListEmpty(relLbListUrl, timeoutSecs=60,
                                 filterLambda=pathFilterLambda)

        return

    def _failIfAnyTier1sProtected(self, tier1s):
        """
        Check if any tier-1s in the supplied list are _not_ unprotected.
        If so, bail out since we can't fully clean up all of the tier-1s.
        (WCP deploys with one tier-1 protected, one unprotected.)

        If we are going to fail, we attempt to determine whether the
        tier-1s are protected by NCP or otherwise, and tailor our error
        message accordingly.
        """
        protectedTier1Names = set()
        wcpTier1Names = set()

        for tier1 in tier1s:
            if tier1["id"] not in self.unprotectedT1Ids:
                protectedTier1Names.add(tier1["display_name"])

                # if tier-1 has any tags with scope beginning "ncp/"
                # then we assume it is a WCP tier-1.
                if "tags" in tier1:
                    for tag in tier1["tags"]:
                        if tag["scope"].startswith("ncp/"):
                            wcpTier1Names.add(tier1["display_name"])
                            break

        if wcpTier1Names:
            logger.error("Cluster includes WCP tier-1 gateways: %s" %
                         wcpTier1Names)
            logger.error("Please use WCP tools to remove the cluster.")
            self._stopNow()

        if protectedTier1Names:
            logger.error("Cluster includes protected tier-1 gateways: %s" %
                         protectedTier1Names)
            logger.error("The cluster cannot be removed by this script.")
            self._stopNow()

        return

    def _cleanTier1s(self, tier1s, tier0PathFilter):
        """Clean all tier-1 gateways in the supplied list.
        All such tier-1s have their child locales and tier-1 instances
        removed.

        tier1s : list of tier-1s to remove
        tier0PathFilter : method, taking an NSX-T tier-1 record, which
            returns True if the tier-1 refers to a standard tier-0 path.
        """
        logger.warning("doing tier-1 gateway cleanup..")

        baseRelUrl = "/policy/api/v1/infra/tier-1s"

        for tier1 in tier1s:
            tier1RelUrl = baseRelUrl + "/" + tier1["id"]
            tier1Path = tier1["path"]

            self._unlinkTier1Segments(tier1RelUrl)

            self._cleanTier1NatRules(tier1RelUrl)

            self._cleanDependentLoadBalancer(tier1Path)

            self._cleanNsxtList(tier1RelUrl + "/locale-services",
                                itemTypeName="tier-1 locale")
            self._nsxtDelete(
                tier1RelUrl,
                label=self.getOptQuotedName(tier1["display_name"]))

        self._nsxtAwaitListEmpty(baseRelUrl, timeoutSecs=60,
                                 filterLambda=tier0PathFilter)

        # give a little pause to let tier-1 settle into oblivion before
        # returning to initiate parent tier-0 deletion.
        time.sleep(10)

        logger.warning("finished tier-1 gateway cleanup.")

    def _listAndCheckTier1s(self, tier0PathFilter):
        """
        Collect the list of all (non-federated) tier-1s which connect
        to the specified tier-0. If any of these tier-1s are write-
        protected, abort the run with a suitable error message.

        Otherwise, return the list of tier-1s to our caller.

        tier0PathFilter : method, taking an NSX-T tier-1 record, which
            returns True if the tier-1 refers to a standard tier-0 path.
        """

        baseRelUrl = "/policy/api/v1/infra/tier-1s"

        tier1s = self._nsxtGetList(baseRelUrl,
                                   filterLambda=tier0PathFilter)

        # Bail out if any tier-1s citing tier0RelPath are protected
        # against deletion:
        self._failIfAnyTier1sProtected(tier1s)

        return tier1s

    def _cleanTier0(self, tier0LrId, clusterId):
        """
        Clean specified tier-0 gateway, its child locales, and their child
        interfaces and BGP neighbors.

        We also remove any tier-1 gateways connected to this tier-0.

        Since this is the first method to have adequate insight into how the
        edge cluster's logical networking looks, we also check that it is
        safe to remove logical networking associated with this tier-0.
        If not we abort the cleaner run. This works because we are the
        first step in removing an edge cluster.

        tier0LrId - Logical router ID for this tier-0
        clusterId - NSX-T ID of edge cluster being cleaned.
        """
        logger.warning("doing tier-0 gateway cleanup..")

        baseRelUrl = EdgeClusterConstants.REL_TIER0_LIST_URL

        NSXT_TIER1_TIER0_PATH_PROP = (
            EdgeClusterConstants.NSXT_TIER1_TIER0_PATH_PROP)

        tier0LrRelUrl = "/api/v1/logical-routers/" + tier0LrId

        tier0Lr = self._nsxtGet(tier0LrRelUrl)
        tier0LrName = tier0Lr.get("display_name", "???")

        # tier0Lr should have a tag indicating its tier0 policy path.
        # Saves otherwise doing a search, and hopefully it is protected
        # from direct editing (seems to be the case for policy-created
        # imperative objects).
        tier0RelUrl = None
        for tag in tier0Lr["tags"]:
            if tag["scope"] == "policyPath":
                tier0RelPath = tag["tag"]
                tier0RelUrl = "/policy/api/v1/" + tier0RelPath.strip("/")
                logger.warning("  tier-0 relative path, from its LR: %s" %
                               tier0RelPath)
                logger.warning("  tier-0 policy path, from its LR: %s" %
                               tier0RelUrl)
                break

        tier0Id = tier0RelUrl.split("/")[-1]
        logger.warning("tier-0 ID: %s" % tier0Id)

        if tier0Id in self.federatedT1T0IdRefs:
            logger.error("Tier-0 router is referenced by one or more "
                         "federated tier-1 gateways.")
            logger.error("Please remove federated logical networking which "
                         "uses this cluster before")
            logger.error("attempting cluster removal.")
            self._stopNow()

        if tier0Id in self.federatedT0Ids:
            logger.error("Tier-0 router is federated, please remove "
                         "federated logical networking which")
            logger.error("uses this cluster before attempting cluster "
                         "removal.")
            self._stopNow()

        # common filter used in _listAndCheckTier1s and _cleanTier1s
        def tier0PathFilter(tier1):
            return ((NSXT_TIER1_TIER0_PATH_PROP in tier1) and
                    (tier1[NSXT_TIER1_TIER0_PATH_PROP] == tier0RelPath))

        # Find tier-1s connected to this tier-0. Stops the run if any of
        # these tier-1s are write-protected, since that would block our
        # cleanup. (WCP does this.)
        tier1s = self._listAndCheckTier1s(tier0PathFilter)

        # still here, so time to mark this tier-0's cluster as deleting
        self._markEdgeClusterDeactivating(clusterId)

        # clean up all tier-1 gateways which uplink to this tier-0

        self._cleanTier1s(tier1s, tier0PathFilter)

        # now delete our tier-0's own child resources:

        locales = self._nsxtGetList(tier0RelUrl + "/locale-services")
        for locale in locales:
            localeRelUrl = (tier0RelUrl + "/locale-services/" +
                            locale["id"])

            self._cleanNsxtList(localeRelUrl + "/bgp/neighbors",
                                itemTypeName="BGP peer", errorOk=True)

            # cache t0 uplink segment paths for discovery-based cleanup
            interfacesRelUrl = localeRelUrl + "/interfaces"
            self._collectNsxChildPropertySet(interfacesRelUrl, "segment_path",
                                             self.t0SegPaths)

            # Do everything except the INTERVRF "interface": NSX won't let
            # us remove it, but it cleans it up on its own.
            # NB: the "type" attribute was only introduced in ~ NSX 3.1.3,
            #     so we must be able tolerate its absence.
            nonVrfFilterLambda = (
                lambda intf:
                    ("INTERVRF" != intf.get("type")))

            self._cleanNsxtList(interfacesRelUrl,
                                itemTypeName="interface",
                                filterLambda=nonVrfFilterLambda)

            # In case OSPF has been (manually) configured: areas are
            # referenced by interfaces so must be removed after them.
            self._cleanNsxtList(localeRelUrl + "/ospf/areas",
                                itemTypeName="OSPF area", errorOk=True)

            self._nsxtDelete(localeRelUrl,
                             label=("tier-0 locale " +
                                    locale["display_name"]))
        self._nsxtDelete(tier0RelUrl,
                         label=("tier-0 %s (%s)" %
                                (self.getOptQuotedName(tier0LrName),
                                 tier0Id)))

        def tier0IdFilter(tier0):
            return tier0["id"] == tier0Id

        self._nsxtAwaitListEmpty(baseRelUrl, timeoutSecs=60,
                                 filterLambda=tier0IdFilter)

    def _getTier0LrIdsForCluster(self, cluster):
        """Find all tier-0 logical routers which are hosted by the specified
        edge cluster, and return a list of their IDs.
        """

        logicalRouterTier0ListUrl = (
            "/api/v1/logical-routers")

        clusterId = cluster["id"]

        def lrClusterTier0Filter(lr):
            return (("edge_cluster_id" in lr) and
                    (lr["edge_cluster_id"] == clusterId) and
                    (lr["router_type"] == "TIER0"))

        tier0Lrs = self._nsxtGetList(logicalRouterTier0ListUrl,
                                     filterLambda=lrClusterTier0Filter)

        def _idMapper(tier0Lr):
            return tier0Lr["id"]

        ids = [_idMapper(tier0Lr) for tier0Lr in tier0Lrs]

        return ids

    def _getClusterHAProfileInfo(self, cluster):
        """Given an edge cluster instance, find the HA profile instance
        cited by that cluster. We return a pair of values: the profile's
        relative URL and its name.

        If there is none (is that even possible?), or if the configured
        profile is the system default, then we return None since it can't
        be deleted.
        """

        profileId = None
        relProfileUrl = None
        profileName = None

        profileId = self._getEdgeClusterProfileId(cluster)

        if profileId:
            relProfileUrl = (
                EdgeClusterConstants.REL_CLUSTER_PROFILE_LIST_URL + "/" +
                profileId)
            profileInstance = self._nsxtGet(relProfileUrl)

            if (not profileInstance) or profileInstance["_system_owned"]:
                if not profileInstance:
                    logger.warning(
                        ("Could not access HA profile %s for Edge cluster: "
                         "skipping profile removal") % profileId)

                # something we can't delete, so pretend it's not there
                relProfileUrl = None
            else:
                # Got a profile instance which is not system-owned, but we need to see
                # if it is used by multiple Edge clusters. If so, we can't remove it now.
                # We do this check against NSX, since NSX is what will disallow removal
                # if the profile is still in use: doesn't matter if the remaining consumer(s)
                # are VCF-managed or not.
                profConsumers = self._getTargetProfIdUsers(profileId)

                profileName = profileInstance["display_name"]

                if len(profConsumers) >= 2:
                    logger.warning(
                        (f"Not removing Edge cluster profile {profileName}: "
                         f"it is shared by Edge clusters {profConsumers}"))
                    profileName = None
                    relProfileUrl = None

        return relProfileUrl, profileName

    def _getTargetProfIdUsers(self, targetProfId):
        """
        Given an Edge cluster profile ID, return a list with the name of each
        Edge cluster using this profile.
        """
        edgeClusters = self._getEdgeClustersInNsxMgr()

        profConsumers = []
        for clust in edgeClusters:
            clustProfId = self._getEdgeClusterProfileId(clust)
            if clustProfId == targetProfId:
                profConsumers.append(clust.get("display_name", "???"))

        return profConsumers

    def _getEdgeClusterProfileId(self, cluster):
        profileId = None
        for binding in cluster.get("cluster_profile_bindings", []):
            if binding["resource_type"] == "EdgeHighAvailabilityProfile":
                profileId = binding["profile_id"]
                # (we assume at most one HA profile per cluster)
                break

        return profileId

    def _cleanEdgeCluster(self, cluster):
        """For a given edge cluster, find and remove any hosted tier-0/tier-1
        logical routers and their paraphernalia. Then delete the edge cluster
        as well.

        cluster : NSX-T description of edge cluster to remove."""

        tier0LrIdsForCluster = self._getTier0LrIdsForCluster(cluster)

        for tier0LrId in tier0LrIdsForCluster:
            # (tier-0 cleaner removes dependent tier-1s too)
            self._cleanTier0(tier0LrId, cluster["id"])

        if tier0LrIdsForCluster and not self.isDryRun():
            # some lag between when we mark the tier-0(s) deleted and when
            # they really are. So give it a little while to prevent barely
            # extant tier-0s from blocking edge cluster deletion.
            time.sleep(10)

        # save profile pointer, if any, since it can't be nuked until after
        # the cluster which uses it.
        haProfileRelUrl, haProfileName = self._getClusterHAProfileInfo(cluster)

        relClustUrl = (EdgeClusterConstants.REL_CLUSTER_LIST_URL + "/" +
                       cluster["id"])

        self._nsxtDelete(
            relClustUrl,
            label=("cluster " +
                   self.getOptQuotedName(cluster["display_name"])))

        if haProfileRelUrl:
            self._nsxtDelete(haProfileRelUrl,
                             label=("HA profile %s" % haProfileName))

        return

    def _edgeClustersToNodeNames(self, edgeClusters):
        """Given a list of edge cluster inventory entries, find the edge node
        VMs for each, and return a list of their display names.
        """

        transportNodeIds = []

        for cluster in edgeClusters:
            if "members" in cluster:
                for member in cluster["members"]:
                    transportNodeIds.append(member["transport_node_id"])

        myEdgeNodeDisplayNames = []

        # now convert from transport node IDs to display names, which
        # should exactly match our target edge node display names
        allNodes = self._nsxtGetList("/api/v1/transport-nodes")
        for node in allNodes:
            if node["id"] in transportNodeIds:
                myEdgeNodeDisplayNames.append(node["display_name"])

        return myEdgeNodeDisplayNames

    def _cleanEdgeClusters(self):
        """Remove edge clusters and their dependent tier-0 / tier-1 routers
        and their dependents.

        We operate on either a single specified edge cluster, or all clusters
        known to be in a particular workload.
        """
        relListUrl = EdgeClusterConstants.REL_CLUSTER_LIST_URL

        logger.warning("doing edge cluster cleanup..")

        if self.clusterPrefixFilterLambda:
            # we're limiting ourselves to deleting only one cluster, so we
            # must keep track of cluster member nodes, which can't otherwise
            # be inferred.
            clusterNameFilterLambda = (
                lambda cluster:
                    (cluster["display_name"] == self.args.cluster))

            clusters = self._nsxtGetList(relListUrl,
                                         filterLambda=clusterNameFilterLambda)

            self.edgeNodeDisplayNames = self._edgeClustersToNodeNames(clusters)

            if not self.edgeNodeDisplayNames:
                # no edge cluster, so couldn't infer edge node names.
                logger.warning("*** No matching cluster found for %s, so no "
                               "edge nodes removed ***" % self.args.cluster)

                # set something unique to avoid matching all edge node names:
                self.edgeNodeDisplayNames.append("*** NO MATCH xyzzy ***")

        else:
            # --all-clusters requested.
            # We have a check elsewhere on self.creds.wldsSupportedByOurNsxt
            # so that we know our NSX-T Manager is not supporting any other
            # workloads. This makes it safe to simply zap whatever edges
            # we find in our NSX-T Manager's configuration.

            clusters = self._nsxtGetList(relListUrl)
            clusterNameFilterLambda = None

            self.edgeNodeDisplayNames = self._edgeClustersToNodeNames(clusters)

        # Save dictionary which maps from NSX-T edge cluster ID
        # to the corresponding VCF edge cluster inventory entry.
        # In a damaged / partial configuration, this map may be sparse.
        self._formMapFromNsxtClusterIdToVcfInventory(clusters)

        # Save some instance info so we can cleanly fail deletion attempts
        # when a cluster is hosting federated networking or is protected
        # by WCP or other deployment types.
        self._collectTierInfo()

        for cluster in clusters:
            self._cleanEdgeCluster(cluster)

        # make sure all targeted cluster(s) are really gone:
        self._nsxtAwaitListEmpty(
            relListUrl, filterLambda=clusterNameFilterLambda,
            timeoutSecs=EdgeClusterCleaner.MAX_WAIT_DURATION_SECS)

        logger.warning("finished edge cluster cleanup.")

    def _collectTierInfo(self):
        """
        Collect various tidbits of info about existing tier-0 and tier-1
        components, to help us recognize specialty configurations like
        federation and WCP.

        We save the info in our instance for later reference.
        """

        relFedT0ListUrl = EdgeClusterConstants.REL_FED_TIER0_LIST_URL
        relFedT1ListUrl = EdgeClusterConstants.REL_FED_TIER1_LIST_URL
        relLocalT1ListUrl = EdgeClusterConstants.REL_TIER1_LIST_URL

        # (If there is no GM / federation, the federated queries will fail
        # and _nsxtGetList() returns an empty list.)
        fedT0s = self._nsxtGetList(relFedT0ListUrl, errorOk=True,
                                   errorQuiet=True)
        fedT1s = self._nsxtGetList(relFedT1ListUrl, errorOk=True,
                                   errorQuiet=True)
        localT1s = self._nsxtGetList(relLocalT1ListUrl)

        self.federatedT0Ids = [t0["id"] for t0 in fedT0s]

        # List of tier-0 IDs referenced by federated tier-1 gateways.
        # Since the raw tier0_path value's format will differ depending on
        # whether the tier is federated or not, we save just the tier-0's
        # simple ID.

        self.federatedT1T0IdRefs = []
        for t1 in fedT1s:
            t0Path = t1.get("tier0_path")  # might not connect to a Tier-0
            if t0Path:
                self.federatedT1T0IdRefs.append(t0Path)

        unprotFlag = EdgeClusterConstants.NSXT_PROT_UNPROTECTED
        localUnprotT1Ids = (
            [t1["id"] for t1 in localT1s if (t1["_protection"] == unprotFlag)])
        fedUnprotT1Ids = (
            [t1["id"] for t1 in fedT1s if (t1["_protection"] == unprotFlag)])

        self.unprotectedT1Ids = localUnprotT1Ids + fedUnprotT1Ids

        logger.warning("unprotectedT1Ids = %s" % self.unprotectedT1Ids)

        return

    def _waitForEdgeNodeVmNotRunningInVcenter(self, vCenterEdgeVm):
        """
        Wait until state of the supplied vCenter view of a VM has gone
        to some state other than "running". This has been observed to take
        16 seconds after the NSX-T node deletion request returns.

        vCenterEdgeVm : vim.VirtualMachine
            A pyVMOMI view of the VM we wait for.  May be None, in which
            case we simply don't wait.
        """
        logger.warning("Node VM deletion done per NSX-T, now wait "
                       "for vCenter to see node shut down")
        postNsxtVmWaitTimeStart = datetime.now()
        if vCenterEdgeVm:
            while True:
                try:
                    vCenterEdgeVm.Reload()
                    if vCenterEdgeVm.guest.guestState == "running":
                        logger.warning("  VM still in running state")
                        time.sleep(EdgeClusterCleaner.POLL_RETRY_PAUSE_SECS)
                except Exception:
                    # can't be queried, so assume VM is no longer running
                    break

                if self.isDryRun():
                    # we'll never see the VM stop, so this code is as
                    # exercised as we can do.
                    break

        postNsxtVmWaitTimeElapsed = datetime.now() - postNsxtVmWaitTimeStart
        logger.warning("  VM exited running state, after %d seconds"
                       % postNsxtVmWaitTimeElapsed.seconds)

        return

    def _waitForEdgeNodeVmNotPresentInVcenter(self, vCenterEdgeVm,
                                              timeoutSecs):
        """
        Wait until state of the supplied vCenter view of a VM has gone to
        "unreadable", meaning we fault when we try to reload it.
        This is taken as a proxy for the VM being removed from vCenter.

        vCenterEdgeVm : vim.VirtualMachine
            A pyVMOMI view of the VM we wait for.  May be None, in which
            case we simply don't wait.
        """
        logger.warning("Node VM deletion done per NSX-T, now wait "
                       "for vCenter to see node removed")
        postNsxtVmWaitTimeStart = datetime.now()
        waitEndTime = datetime.now() + timedelta(timeoutSecs)

        if vCenterEdgeVm:
            while (datetime.now() < waitEndTime):
                try:
                    vCenterEdgeVm.Reload()
                    logger.warning("  VM still in '%s' state" %
                                   vCenterEdgeVm.guest.guestState)
                    time.sleep(EdgeClusterCleaner.POLL_RETRY_PAUSE_SECS)
                except Exception:
                    # can't be queried, so assume VM is gone from VC
                    break

                if self.isDryRun():
                    # we'll never see the VM vanish, so this code is as
                    # exercised as we can do.
                    break

            if (datetime.now() >= waitEndTime):
                logger.error("Timed out waiting for edge node VM %s"
                             "to vanish from vCenter" % "xx")
            elif not self.isDryRun():
                postNsxtVmWaitTimeElapsed = (datetime.now() -
                                             postNsxtVmWaitTimeStart)
                logger.warning("  VM gone from vCenter, after %d seconds"
                               % postNsxtVmWaitTimeElapsed.seconds)

        return

    def _cleanEdgeNodes(self):
        """Nuke all transport nodes of the edge-node type.
        """
        relListUrl = "/api/v1/transport-nodes"

        # We blast only edge nodes. Default is all edge nodes unless
        # a non-empty list of display names is present. In the latter
        # case, we nuke only the nodes named in the list.
        edgeNodeFilter = (
            lambda node:
                ((node["node_deployment_info"]["resource_type"] ==
                  "EdgeNode") and
                 ((not self.edgeNodeDisplayNames) or
                  (node["display_name"] in self.edgeNodeDisplayNames))))

        logger.warning("doing edge node cleanup..")

        # NB: we don't use self._cleanNsxtList() here since we want to also
        #     check that each node VM has terminated as seen from vCenter.

        cleanListStartTime = datetime.now()

        # grab all VMs as seen from vCenter
        vms = self.vcConn.get_vc_objects([vim.VirtualMachine])

        nodes = self._nsxtGetList(relListUrl, filterLambda=edgeNodeFilter)
        for node in nodes:
            nodeId = node["id"]
            nodeUrl = relListUrl + "/" + nodeId

            label = node["display_name"]

            # collect this node's data network portgroup mob IDs:
            deployCfg = node["node_deployment_info"]["deployment_config"]
            vmDeployCfg = deployCfg["vm_deployment_config"]
            self.edgePortgroupMobIds.update(vmDeployCfg["data_network_ids"])

            self.edgeResourcePoolMobIds.add(vmDeployCfg["compute_id"])

            displayName = node["display_name"]
            # handle truncation of edge node name in vCenter view
            displayNameShort = displayName.split(".")[0]
            vmThis = None
            for vm in vms:
                try:
                    vmConfigNameShort = vm.config.name.split(".")[0]
                    if vmConfigNameShort == displayNameShort:
                        vmThis = vm
                        break
                except Exception:
                    # this VM list entry is toast, so try next one
                    pass

            self.edgeNodeVmUUIDs.add(vm.config.instanceUuid)

            self._nsxtDelete(
                nodeUrl, label=label,
                timeoutSecs=EdgeClusterCleaner.MAX_WAIT_DURATION_SECS)

            # unbounded -- however long it needs to take.
            self._waitForEdgeNodeVmNotRunningInVcenter(vmThis)

            self._waitForEdgeNodeVmNotPresentInVcenter(
                vmThis, timeoutSecs=EdgeClusterCleaner.MAX_WAIT_DURATION_SECS)

        self._nsxtAwaitListEmpty(relListUrl, filterLambda=edgeNodeFilter,
                                 timeoutSecs=60)

        cleanListElapsed = datetime.now() - cleanListStartTime
        logger.warning("List clean of %s took %d seconds" %
                       (relListUrl, cleanListElapsed.seconds))

        logger.warning("finished edge node cleanup.")

        return

    def _logicalSwitchExists(self, logicalSwitchId):
        """Helper method passed through to _cleanNsxtList() when cleaning
        up NSX-T segments, to allow it to check for each segment whether
        the corresponding logical switch has been removed.

        We return True if the specified logical switch still exists,
        else False.

        logicalSwitchId : String
            ID of logical switch instance to test for existence.
        """
        relUrl = "/api/v1/logical-switches/" + logicalSwitchId
        item = self._nsxtGet(relUrl)
        if item:
            logger.warning("   logicalSwitch %s still found" % logicalSwitchId)
            return True
        logger.warning("   logicalSwitch not found")
        return False

    def _collectNsxChildPropertySet(self, childListRelUrl, childPropName,
                                    childPropSet):
        """
        Given a URL which can return a list of child objects, the name of a
        property found in those children, and an output set, add the value
        of each such property to the set.
        """

        children = self._nsxtGetList(childListRelUrl, filterLambda=None,
                                     errorOk=True, errorQuiet=True)

        logger.info("From " + childListRelUrl + " listed " +
                    str(len(children)) + " entries")

        for child in children:
            propVal = child.get(childPropName)
            if propVal:
                childPropSet.add(propVal)
                if self.args.verbose:
                    dispName = child.get("display_name", "??")
                    logger.info("Found item " + dispName + ", " +
                                childPropName + "=" + propVal)

        return

    def _formRegex(self, oldForm, newSuffix):
        # Only match old-form names when no cluster prefix constraint is given
        if oldForm and not self.clusterPrefixFilterLambda:
            oldRegex = r"(?:" + oldForm + r")|"
        else:
            oldRegex = r""

        return (oldRegex +
                r"(?:" + self.edgeNamePrefix + newSuffix + r")")

    def _cleanSegments(self):
        """Remove all uplink segments created as part of edge deployment.
        Other NSX-T segments are left undisturbed.
        """
        relListUrl = EdgeClusterConstants.REL_SEGMENT_LIST_URL

        # new suffix supports current (K1+) form
        #   VCF-edge_myCluster1b_segment_uplink2_2082
        # and recent pre-K1 form
        #   VCF-edge_myCluster1a_segment_2081
        segRegex = self._formRegex(
            oldForm=r"[Ss]egment_vlan_\d+_named_teaming",
            newSuffix=r"_segment(_uplink\d)?_\d+")

        edgeSegFilter = (
            lambda seg:
                ((seg["path"] in self.t0SegPaths) or
                 re.match(segRegex, seg["display_name"])))

        # As with edge nodes, we need to do a secondary check to verify full
        # deletion. In this case, of the segment's underlying logical switch.
        # NB: final logical-switch removal seems to reliably take well over
        #     one minute, so set our timeout to four minutes.
        self._cleanNsxtList(
            relListUrl, filterLambda=edgeSegFilter, timeoutSecs=240,
            secondKeyValueFieldId="unique_id",
            secondKeyExistenceCheckerName='_logicalSwitchExists')

        return

    def _cleanTransportZones(self):
        """Remove transport zone(s) created during edge deployment.
        Other transport zones are left as-is.
        """
        relListUrl = "/api/v1/transport-zones"

        edgeTzRegex = self._formRegex(
            oldForm=r"sfo01-w-edge-uplink-tz",
            newSuffix=r"_uplink-tz")

        edgeTzFilter = (
            lambda tz:
                (re.match(edgeTzRegex, tz["display_name"])))

        logger.warning("Cleaning up transport zone(s):")
        self._cleanNsxtList(relListUrl, filterLambda=edgeTzFilter,
                            timeoutSecs=60)

        return

    def _cleanUplinkProfiles(self):
        """Remove uplink profiles created during edge deployment.
        Other profiles are left as-is.
        """
        relListUrl = "/api/v1/host-switch-profiles"

        uplinkProfileRegex = self._formRegex(
            oldForm=r"edge-uplink-profile-\d+",
            newSuffix=r"_uplink-profile-\d+")

        edgeProfFilter = (
            lambda prof:
                ((prof["resource_type"] == "UplinkHostSwitchProfile") and
                 re.match(uplinkProfileRegex, prof["display_name"])))

        self._cleanNsxtList(relListUrl, filterLambda=edgeProfFilter,
                            timeoutSecs=60)

        return

    def _removePortgroupFromVcfInventoryVds(self, pgName, vdsId):
        """
        Given the name of a portgroup, and the VCF inventory ID of a VDS,
        we update the VDS' inventory record to remove the named portgroup
        if it is present.
        """
        queryUrl = "http://localhost/inventory/vds?id=%s" % vdsId
        vds = self.sddcMgrRest.runSddcMgrRestGet(queryUrl)[0]
        portgroups = vds["portGroups"]

        newPgs = [pg for pg in portgroups if (pg["name"] != pgName)]
        if len(newPgs) != len(portgroups):
            vds["portGroups"] = newPgs

            if self.isDryRun():
                logger.warning("Would remove PG %s from VDS %s" %
                               (pgName, vds["name"]))
            else:
                logger.warning("Removing PG %s from VDS %s" %
                               (pgName, vds["name"]))
                putUrl = "http://localhost/inventory/vds/%s" % vdsId
                self.sddcMgrRest.runSddcMgrRestPutString(putUrl,
                                                         json.dumps(vds))

        return

    def _removePortgroupFromVcfInventory(self, pgName):
        """Given the name of a portgroup, remove it from our WLD's main
        VCF inventory record.

        Instead of doing a bunch of PG names in bulk, we do them one at
        a time as portgroups are removed from VC. This way if one PG
        removal hits an error, only those actually removed will also be
        removed from VCF inventory.
        """
        wldInv = self._getTargetWldInventory()
        vdses = wldInv["vds"]
        for vds in vdses:
            # only attempt PG removal if we find it in WLD's copy of VDS
            portgroups = vds["portGroups"]
            for pg in portgroups:
                if pg["name"] == pgName:
                    self._removePortgroupFromVcfInventoryVds(pgName,
                                                             vds["id"])
                    break

        return

    def _getUnusedVmMgmtPgKeys(self):
        """
        Get the list of VM management port groups which are unused and
        suitable for deletion. A VM management port group will be
        considered for deletion if it meets all the following conditions:

        1. It belongs to a VDS which belongs to a vSphere cluster which
        the edge cluster being deleted resides
        2. It does not belong to a VDS which belongs to the default
        cluster of the management domain
        3. It is not connected to any VMs
        4. It is not a part of a domain which is of VxRail SKU
        """
        unusedVmMgmtPgKeys = set()

        ecVcfClusterIds = self.vcfAllEdgeClustersMap[self.args.cluster]["clusterIds"]
        domainInventory = self._getTargetWldInventory()

        if len(domainInventory["vxManagers"]) > 0:
            logger.info("Skip delete VM management port groups for VxRail SKU")
            return unusedVmMgmtPgKeys

        domainVds = domainInventory["vds"]
        domainIsMgmt = domainInventory["domain"]["type"] == "MANAGEMENT"
        domainDefaultClusterVdsIds = list(filter(lambda cluster: cluster["isDefault"],
                                                 domainInventory["clusters"]))[0]["vdsIds"]

        # Search all VDS of the target domain for VM management port group(s) to delete
        for vcf_vds in domainVds:
            logger.info("Searching VDS " + vcf_vds["name"] + " / " + vcf_vds["id"] +
                        " for VM management port groups to be deleted...")

            # Ensure VDS meets basic requirements for VM management port group deletion
            if not any(clusterId in vcf_vds["clusterIds"] for clusterId in ecVcfClusterIds):
                logger.info("Skipping VDS because the edge nodes being deleted are not on this VDS")
                continue
            if domainIsMgmt and vcf_vds["id"] in domainDefaultClusterVdsIds:
                logger.info("Skipping VDS because it is the default cluster of the " +
                            "management domain")
                continue

            # Ensure VM management port group actually exists on the VDS
            vcf_vm_mgmt = None
            for pg in vcf_vds["portGroups"]:
                if pg["transportType"] == "VM_MANAGEMENT":
                    vcf_vm_mgmt = pg
                    break
            if not vcf_vm_mgmt:
                logger.info("Skipping VDS because it does not contain VM management port group")
                continue

            # If VM management port group exists, query the VDS in vCenter and then
            # find the VM management port group in its list of port groups. This is
            # done over searching in the list of all port groups in case different
            # VDSes on the same domain contain port groups with the same names.
            vc_vds = self.vcConn.get_distributed_virtual_switch_by_name(vcf_vds["name"])[0]
            vc_vm_mgmt = None
            for pg in vc_vds.portgroup:
                if pg.name == vcf_vm_mgmt["name"]:
                    vc_vm_mgmt = pg
                    break
            if vc_vm_mgmt is None:
                logger.error("Could not find VCF VM management portgroup " + vcf_vm_mgmt["name"] +
                             " in vCenter VDS " + vcf_vds["name"])
                self._stopNow()

            # Finally, ensure the port group is not connected to any VMs. For dry run, check against
            # the list of VMs excluding edge node VMs which should be deleted by the time we
            # reach this step.
            vms = vc_vm_mgmt.vm
            if self.isDryRun():
                logger.info("Filtering Edge node VMs out of port group VM list for dry run...")
                vms = list(filter(lambda vm: vm.config.instanceUuid not in self.edgeNodeVmUUIDs,
                                  vms))

            if vms:
                vm_names = ", ".join(list(map(lambda vm: vm.name, vms)))
                logger.info("Skipping VDS because VM management port group is " +
                            "still connected to VMs " + vm_names)
                continue
            else:
                unusedVmMgmtPgKeys.add(vc_vm_mgmt.key)
                logger.info("Adding VM management port group " + vcf_vm_mgmt["name"] + " with key "
                            + vc_vm_mgmt.key + " to list of port groups to delete")

        return unusedVmMgmtPgKeys

    def _cleanPortgroups(self):
        """Find all portgroups in our vCenter and nuke those which
        match our expected name template.
        """

        externalPortgroupRegex = self._formRegex(
            oldForm=r"SDDC-DPortGroup-External-\d+",
            newSuffix=r"_PG-.*?External-\d+")

        # Note that there is an "alternative" portgroup name format used by
        #   CreateTrunkVlanPortGroupParamsAction.createPortGroupSpec()
        # which doesn't include the portgroup's edge cluster name. So we must
        # primarily rely on inferring portgroup identity from the connections
        # found in our target edge node VMs.

        logger.warning("Cleaning up portgroups:")

        portgroups = self.vcConn.get_portgroups()
        unusedVmMgmtPgKeys = self._getUnusedVmMgmtPgKeys()

        for pg in portgroups:
            if ((pg.key in self.edgePortgroupMobIds) or
                    (pg.key in unusedVmMgmtPgKeys) or
                    re.match(externalPortgroupRegex, pg.name)):
                pgName = pg.name
                self.vcConn.delete_object(pg, dryRun=self.isDryRun(),
                                          itemTypeName="portgroup")

                # VC delete went through, so remove from VCF WLD inventory
                self._removePortgroupFromVcfInventory(pgName)

        # NB: delete_object() waits on VC task completion, so unlike NSX-T
        #     we shouldn't need to separately await portgroup deletion.
        return

    def _cleanResourcePools(self):
        """Find all resource pools in our vCenter and nuke those which
        match our expected name template.
        """

        externalResourcePoolRegex = self._formRegex(
            oldForm=None,
            newSuffix=r"_ResourcePool_.+")

        logger.warning("Cleaning up resource pool:")

        resourcePools = self.vcConn.get_resource_pools()

        for rp in resourcePools:
            if ((rp._moId in self.edgeResourcePoolMobIds) or
                    re.match(externalResourcePoolRegex, rp.name)):
                if (self.args.verbose and
                        rp._moId in self.edgeResourcePoolMobIds):
                    logger.info("ResourcePool " + rp.name + " matched by " +
                                rp._moId)

                self.vcConn.delete_object(rp, dryRun=self.isDryRun(),
                                          itemTypeName="resource pool")

        # NB: delete_object() waits on VC task completion, so unlike NSX-T
        #     we shouldn't need to separately await resource pool deletion.
        return

    def _formMapFromNsxtClusterIdToVcfInventory(self, clusters):
        """
        Given a list of NSX-T-returned edge cluster descriptors, create
        a dictionary mapping from NSX-T edge cluster ID to the matching
        VCF edge cluster inventory record. This map may be sparse, if
        partial deployments have been done or if the user has renamed
        edge clusters in an older deployment (pre-nsxtClusterId).

        We save the created dict in our instance data for later user.
        """
        # get all the candidate inventory records which we might map to
        invToRemove = self._getEdgeClustersInventoryToRemove()

        # In newer releases, VCF edge cluster inventory has an nsxtClusterId
        # property. But this is not present in earlier releases' inventory,
        # so we also use a fallback of match VCF inventory and NSX-T edge
        # cluster records by name iff needed.

        nsxtIdToInv = {}
        nameToInv = {}

        # first pass to collect maps from name to inv and (if avail) id to inv:
        for inv in invToRemove:
            nameToInv[inv["name"]] = inv
            if EdgeClusterConstants.EDGE_CLUST_INV_NSXTID_PROP in inv:
                nsxtId = inv[EdgeClusterConstants.EDGE_CLUST_INV_NSXTID_PROP]
                nsxtIdToInv[nsxtId] = inv

        # take one more pass to attempt to fill in missing IDs using names
        for nsxtClust in clusters:
            nsxtId = nsxtClust["id"]
            if nsxtId not in nsxtIdToInv:
                # attempt inventory/NSX-T correlation using cluster name.
                # Will fail if cluster has been renamed.
                nsxtName = nsxtClust["display_name"]
                if nsxtName in nameToInv:
                    nsxtIdToInv[nsxtId] = nameToInv[nsxtName]

        self.nsxtClusterIdToVcfInventory = nsxtIdToInv

        return

    def _getAllEdgeClustersInventory(self):
        """Get VCF inventory of all known edge clusters. This includes
        edge clusters from all workload domains, and also those which
        have no WLD affiliation noted in inventory.
        """
        queryUrl = EdgeClusterConstants.VCF_EDGE_CLUST_INV_LIST_URL
        return self.sddcMgrRest.runSddcMgrRestGet(queryUrl)

    def _getEdgeClustersInventoryToRemove(self):
        """Get VCF inventory of all edge clusters to be removed.
        Note that this uses one of two different queries, depending on
        whether the user specified a particular cluster to remove, or
        "all" edge clusters for a given workload.
        workload domain.

        We always return a list of edge cluster inventory entries,
        even if only a single edge cluster is selected by the user.
        """
        if not self.args.cluster:
            # all edge clusters which VCF knows to be in selected workload:
            queryUrl = (
                "http://localhost/inventory/domains/%s/nsxt-edgeclusters" %
                self.workloadId)
            return self.sddcMgrRest.runSddcMgrRestGet(queryUrl)

        # user wants a specific edge cluster, we've already saved its id:
        queryUrl = ("%s?id=%s" %
                    (EdgeClusterConstants.VCF_EDGE_CLUST_INV_LIST_URL,
                     self.edgeClusterVcfId))

        edgeClusters = self.sddcMgrRest.runSddcMgrRestGet(queryUrl)
        return edgeClusters

    def _getAllWldsInventory(self):
        """Get all workload domain records from VCF inventory.
        """
        queryUrl = "http://localhost/inventory/domains"
        return self.sddcMgrRest.runSddcMgrRestGet(queryUrl)

    def _getTargetWldInventory(self):
        """Return latest full inventory record for the workload domain
        which we are removing edge cluster(s) from.
        """
        queryUrl = ("http://localhost/inventory/domains/%s/inventory" %
                    self.workloadId)
        return self.sddcMgrRest.runSddcMgrRestGet(queryUrl)

    def _getWorkloadVcClusterIds(self, workloadId):
        """Obtain the given workload's detailed inventory record, and
        return the VCF IDs of all the VC clusters used by this workload.
        """
        queryUrl = ("http://localhost/inventory/domains/%s/inventory" %
                    workloadId)
        wlInfo = self.sddcMgrRest.runSddcMgrRestGet(queryUrl)
        wlVcClIds = [cl["id"] for cl in wlInfo["clusters"]]
        return wlVcClIds

    def _cleanVcfEdgeCredsOld(self, edgeClust):
        """
        Given an edge cluster inventory record, remove credentials info for
        each of its edge nodes from VCF inventory. This uses the old-style
        firewalled API.

        edgeClust : An edge cluster's VCF inventory record
        """

        for node in edgeClust["nsxtEdgeNodes"]:
            nodeId = node["id"]

            credQuery = ("http://localhost/credentials-service/"
                         "credentials?entityId=%s" % nodeId)
            nodeCreds = self.sddcMgrRest.runSddcMgrRestGet(credQuery)

            for cred in nodeCreds:
                delUrl = ("http://localhost/credentials-service/"
                          "credentials/%s" % cred["id"])

                # seems best not to draw attention to this step with logging

                if not self.isDryRun():
                    self.sddcMgrRest.runSddcMgrRestDelete(delUrl)

        return

    def _cleanVcfEdgeCredsNew(self, edgeClust):
        """
        Given an edge cluster inventory record, remove credentials info for
        each of its edge nodes from VCF inventory.

        edgeClust : An edge cluster's VCF inventory record
        """

        if not edgeClust["nsxtEdgeNodes"]:
            # no known edge nodes which we should clean up creds for
            return

        edgeClusterNodes = edgeClust["nsxtEdgeNodes"]

        for node in edgeClusterNodes:
            nodeId = node["id"]
            nodeFqdn = node["hostName"]

            authToken = self.creds.authToken
            credQuery = ("https://localhost/v1/system/credentials?entityId=%s"
                         % nodeId)
            nodeCreds = self.sddcMgrRest.runSddcMgrRestGet(credQuery,
                                                           authToken=authToken)

            logger.warning("For edge node %s," % nodeFqdn)
            for cred in nodeCreds:
                username = cred["username"]
                delUrl = ("https://localhost/v1/system/credentials/%s" %
                          cred["id"])

                if self.isDryRun():
                    prefix = "Would remove"
                else:
                    prefix = "Remove"
                logger.warning("  %s credential for user %s" %
                               (prefix, username))
                if not self.isDryRun():
                    self.sddcMgrRest.runSddcMgrRestDelete(delUrl,
                                                          authToken=authToken)

        return

    def _cleanVcfEdgeCreds(self, edgeClust):
        """
        Given an edge cluster inventory record, remove credentials info for
        each of its edge nodes from VCF inventory.

        edgeClust : An edge cluster's VCF inventory record
        """

        if not edgeClust["nsxtEdgeNodes"]:
            # no known edge nodes which we should clean up creds for
            clustName = self.getOptQuotedName(edgeClust["name"])
            logger.warning(f"No known edge nodes in {clustName} to clean up "
                           "credentials for")
            return

        edgeClusterNodes = edgeClust["nsxtEdgeNodes"]
        # do a test probe of the old API to see if it works
        node1 = edgeClusterNodes[0]
        node1Id = node1["id"]
        oldCredQuery = ("http://localhost/credentials-service/"
                        "credentials?entityId=%s" % node1Id)
        node1Cred = self.sddcMgrRest.runSddcMgrRestGet(oldCredQuery,
                                                       skipRaiseForStatus=True)
        if (("errorCode" not in node1Cred)):
            # assume old API is available so use it
            self._cleanVcfEdgeCredsOld(edgeClust)
        else:
            # new API only, so use it
            self._cleanVcfEdgeCredsNew(edgeClust)

        return

    def _cleanVcfInventory(self):
        """Clean edge-cluster related entries from SDDC Manager's inventory.
        """
        logger.warning("Cleaning edge cluster(s) from VCF inventory..")

        vcfEdgeClusters = self._getEdgeClustersInventoryToRemove()

        baseClustUrl = (
            EdgeClusterConstants.VCF_EDGE_CLUST_INV_LIST_URL)

        for clust in vcfEdgeClusters:
            clustName = clust["name"]

            # remove cluster's edge node credentials from inventory
            self._cleanVcfEdgeCreds(clust)

            if self.isDryRun():
                logger.warning("Would remove VCF inventory for edge cluster " +
                               self.getOptQuotedName(clustName))
            else:
                delUrl = baseClustUrl + "/" + clust["id"]
                logger.warning("Deleting VCF inventory for edge cluster " +
                               self.getOptQuotedName(clustName))
                self.sddcMgrRest.runSddcMgrRestDelete(delUrl)

        return

    def _markEdgeClusterDeactivating(self, clusterId):
        """
        In VCF inventory, mark the specified edge cluster which we're about
        to remove as in the "DEACTIVATING" state. This might be a no-op if
        we find no corresponding VCF inventory record, but that would be
        unusual.

        This is expected to be run as our first step, before any actual
        removal has taken place.

        clusterId - NSX-T ID of edge cluster being cleaned.
        """

        if clusterId in self.nsxtClusterIdToVcfInventory:
            clusterInv = self.nsxtClusterIdToVcfInventory[clusterId]
            if (clusterInv["status"] !=
                    EdgeClusterConstants.
                    VCF_EDGE_CLUST_INV_STATUS_DEACTIVATING):

                clusterInv["status"] = (
                    EdgeClusterConstants.
                    VCF_EDGE_CLUST_INV_STATUS_DEACTIVATING)

                clusterInvJsonStr = json.dumps(clusterInv)

                clustInvPutUrl = (
                    "%s/%s" %
                    (EdgeClusterConstants.VCF_EDGE_CLUST_INV_LIST_URL,
                     clusterInv["id"]))

                if not self.isDryRun():
                    self.sddcMgrRest.runSddcMgrRestPutString(
                        clustInvPutUrl, clusterInvJsonStr)
                    logger.warning("Marked edge cluster %s as deactivating" %
                                   clusterId)
                else:
                    logger.warning(
                        "Would mark edge cluster %s as deactivating" %
                        clusterId)

            else:
                logger.warning(
                    "Edge cluster %s already marked as deactivating" %
                    clusterId)

        else:
            logger.warning("NSX-T edge cluster ID %s not found in VCF "
                           "inventory, so not marking it" % clusterId)

        return

    def _confirmWarning(self):
        """Issue warning message and prompt to user to continue or quit.
        If desired, after the message has been viewed at least once the
        user may suppress further prompts by using the --skip-warning
        option.
        """

        if self.isDryRun():
            # only squawk once a real run is attempted
            return True

        markerPathname = os.path.expanduser(
            EdgeClusterCleaner.RUN_WARNING_ACCEPTED_MARKER_FILE)

        if self.args.skip_warning and os.path.isfile(markerPathname):
            return True

        print("")
        print(EdgeClusterCleaner.RUN_WARNING)
        while True:
            answer = input("Do you wish to proceed (y/n)? ")
            answerLower = answer.lower()
            if answerLower in ["y", "yes"]:
                if not self.isDryRun():
                    # mark prompt as accepted once
                    with open(markerPathname, "w+"):
                        pass

                return True

            if answerLower in ["n", "no"]:
                return False

            # anything else, ask again.

    def removeEdgeCluster(self):

        if self._anyWorkflowLocksClaimed():
            logger.error("\n")
            logger.error("A workflow appears to be running in SDDC Manager.")
            logger.error("Please wait until no workflows are running before")
            logger.error("starting this script.")
            self._stopNow()

        if not self._confirmWarning():
            self._stopNow()

        try:
            # tier 0/1 cleanup is now done inside edge cluster cleanup
            # self._cleanTier1()
            # self._cleanTier0()
            self._cleanEdgeClusters()
            self._cleanEdgeNodes()
            self._cleanSegments()
            self._cleanTransportZones()
            self._cleanUplinkProfiles()
            self._cleanPortgroups()
            self._cleanResourcePools()
            self._cleanVcfInventory()

            logger.warning("finished cleanup of edge cluster %s" %
                           self.getOptQuotedName(self.args.cluster))

        except Exception as e:
            UtilLog.logException(e)
            logger.error("! Did not complete cleanup of Edge cluster %s" %
                         self.getOptQuotedName(self.args.cluster))

        if self.logFilename:
            logger.warning("Log written to %s" % self.logFilename)
        return

    def _getEdgeClustersInNsxMgr(self):
        relListUrl = EdgeClusterConstants.REL_CLUSTER_LIST_URL
        return self._nsxtGetList(relListUrl)

    def _getEdgeClusterNamesInWorkload(self):
        edgeClusters = self._getEdgeClustersInNsxMgr()

        def mapper(clust):
            clust["display_name"]

        edgeClustNames = (
            [mapper(clust) for clust in edgeClusters])

        return edgeClustNames

    def __init__(self):
        logger.warning("VCF Edge cluster removal tool, version " +
                       CLEANER_VERSION)

        # following are set properly by _checkClusterArgs():
        self.workloadName = None
        self.workloadId = None
        self.logFilename = None

        # set by _checkClusterArgs() iff self.args.cluster is present.
        self.edgeClusterVcfId = None

        self._getArgs()

        if not self.args.no_log:
            # as file prefix, might use basename(self.prog_usage_name).
            # But for now we use a standard prefix:
            logPrefix = "edge_cluster_cleaner"

            if self.args.verbose:
                logLevel = logging.DEBUG
            else:
                logLevel = logging.INFO

            # use default logging dir
            logDir = None

            self.logFilename = UtilLog.setupLogging(logDir, logPrefix,
                                                    logging.DEBUG,
                                                    consoleLogLevel=logLevel)

            logger.warning("Logging to %s" % self.logFilename)

            # make sure cleaner version is also recorded in log
            logger.debug("VCF Edge cluster removal tool, version " +
                         CLEANER_VERSION)

        self.sddcMgrRest = SddcMgrRest(sddcMgrAddr=self.args.manager_addr)

        ssoPassword = self.args.password
        if not ssoPassword:
            logger.warning("Please enter password for user " + self.args.user)
            ssoPassword = getpass.getpass()

        #  (needs self.sddcMgrRest)
        if not self._checkClusterArgs():
            self._stopNow(quiet=True)

        if (not self.workloadName) or (not self.workloadId):
            logger.error("Internal error: workload params not set")
            self._stopNow(quiet=True)

        self.edgeNamePrefix = "VCF-edge_"
        self.clusterPrefixFilterLambda = None
        if self.args.cluster:
            self.edgeNamePrefix += self.args.cluster
            self.clusterPrefixFilterLambda = (
                lambda item:
                    (item["display_name"].startswith(self.edgeNamePrefix)))
        else:
            self.edgeNamePrefix += ".+?"

        # unless otherwise determined during cluster deletion, always
        # attempt to nuke all edge nodes:
        self.edgeNodeDisplayNames = []

        # as we delete edge nodes, we collect the mob IDs of their
        # "data network" portgroup connections. These portgroups sometimes
        # have names which cannot be matched against edge cluster name,
        # which we would otherwise use to find portgroups to remove.
        self.edgePortgroupMobIds = set()

        self.edgeResourcePoolMobIds = set()

        self.edgeNodeVmUUIDs = set()

        # as we delete Tier-0 interfaces, collect the paths of the segments
        # they are connected to, since name matching isn't always reliable.
        self.t0SegPaths = set()

        if self.isDryRun():
            logger.warning("Performing a dry-run, not actually removing any "
                           "edge clusters\n")

        logger.warning("Getting credentials from SDDC Manager..")
        self.creds = VcfCreds(workload=self.workloadName,
                              username=self.args.user,
                              password=ssoPassword,
                              sddcMgrAddr=self.args.manager_addr)
        logger.warning("Credential retrieval completed.")

        if (self.creds.wldsSupportedByOurNsxt > 1) and not self.args.cluster:
            logger.error("Workload's NSX-T Manager is shared with %s other "
                         "workload(s)." %
                         (self.creds.wldsSupportedByOurNsxt - 1))

            edgeClusterNames = self._getEdgeClusterNamesInWorkload()

            logger.error("Found %s clusters: %s" % (len(edgeClusterNames),
                                                    edgeClusterNames))

            logger.error(
                "To avoid ambiguity, please use the --cluster option.")
            self._stopNow(quiet=True)

        self.vcConn = VcConnection(self.creds.vcCred.ipAddr,
                                   self.creds.vcCred.username,
                                   self.creds.vcCred.password)
        logger.warning("Connection established to vCenter at %s" %
                       self.creds.vcCred.ipAddr)

        return


if __name__ == '__main__':
    cleaner = EdgeClusterCleaner()

    cleaner.removeEdgeCluster()
