#!/usr/bin/env python3
"""
    KubeDiagrams main script.
"""

import argparse
import copy
import importlib
import json
import os
import traceback
from pprint import pprint
import sys
import yaml
from diagrams import Diagram, Edge, Cluster
from diagrams.aws.enablement import ManagedServices
from diagrams.custom import Custom

# According to https://github.com/yaml/pyyaml/issues/89, PyYAML raises
# yaml.constructor.ConstructorError: could not determine a constructor for
#   the tag 'tag:yaml.org,2002:value'
# when loading a string equals to the unquoted value '='.
# A proposed fix is
yaml.SafeLoader.yaml_implicit_resolvers.pop("=")

# Maximum length for diagram node labels
MAX_NODE_LABEL_LENGTH = 16

def query_path(data, path, default=None):
    """
        Query YAML data.
    """
    paths = path.split(".")
    for p in paths[:-1]:
        data = data.get(p)
        if data is None:
            return default
    data = data.get(paths[-1])
    if data is None:
        return default
    return data

# Directory where this script is.
DIRNAME = os.path.dirname(__file__)

# Load configuration.
config = {}
with open(DIRNAME + "/kube-diagrams.yaml", encoding="utf-8") as f:
    config = yaml.safe_load(f) # load YAML config file

# Get edge config.
def get_edge_config(edge_kind):
    """
        Get the configuration for a diagram edge kind.
    """
    edge_config = config.get("edges", {}).get(edge_kind)
    if edge_config is None:
        print(f"Error: {edge_kind} edge configuration not found!")
    return edge_config if edge_config else {}

def report(kind, resource, path, msg, end):
    """
        Report.
    """
    rm = f"[{kind}] " \
        + query_path(resource, "kind", "NO-KIND") + ":" \
        + query_path(resource, "metadata.name", "NO-NAME")
    if path is not None:
        rm += f":{path}"
    rm += f" - {msg}{end}"
    # GOOD: it is fine to log data that is not sensitive
    print(rm)

def info(resource, path, msg):
    """
        Report an information message.
    """
    report("Info", resource, path, msg, '.')

def warning(resource, path, msg):
    """
        Report a warning message.
    """
    report("Warning", resource, path, msg, '!')

def error(resource, path, msg):
    """
        Report an error message.
    """
    report("Error", resource, path, msg, '!')

# Get config associated to a resource.
already_warned_node_configs = set()
def get_node_config(resource):
    """
        Get the configuration for a Kubernetes resource.
    """
    resource_type = get_type(resource)
    node_config = config.get("nodes", {}).get(resource_type)
    while isinstance(node_config, str):
        if node_config not in already_warned_node_configs:
            warning(resource, None, f"{resource_type} used instead of {node_config}")
            already_warned_node_configs.add(node_config)
        node_config = config.get("nodes", {}).get(node_config)
    if node_config is None:
        if resource_type not in already_warned_node_configs:
            warning(resource, None, f"{resource_type} node configuration undefined")
            already_warned_node_configs.add(resource_type)
    return node_config if node_config else {}

def get_type(resource):
    """
        Get the type of a Kubernetes resource, i.e., "kind/apiVersion".
    """
    return query_path(resource, "kind", "kind-NOT-SET") + "/" \
            + query_path(resource, "apiVersion", "apiVersion-NOT-SET")

def get_name(resource):
    """
        Get the name of a Kubernetes resource.
    """
    name = query_path(resource, "metadata.name")
    if name is None:
        warning(resource, "metadata.name", "Not set or set to null")
        name = "NO-NAME"
    return name

def get_namespace(resource):
    """
        Get the namespace of a Kubernetes resource.
        If not set then return the default namespace.
    """
    return query_path(resource, "metadata.namespace", config.get("default_namespace", "default"))

def create_diagram_node(resource):
    """
        Create a diagram node from a Kubernetes resource.
    """
    # Format node label
    node_label = get_name(resource)
    if len(node_label) > MAX_NODE_LABEL_LENGTH:
        def split_node_label(node_label, separator):
            parts = node_label.split(separator)
            node_label = parts[0]
            current_length = len(node_label)
            for part in parts[1:]:
                if current_length + len(part) >= MAX_NODE_LABEL_LENGTH:
                    node_label += "\n"
                    current_length = 0
                node_label += separator
                if len(part) >= MAX_NODE_LABEL_LENGTH:
                    node_label += part[:MAX_NODE_LABEL_LENGTH]
                    node_label += "\n"
                    node_label += part[MAX_NODE_LABEL_LENGTH:]
                    current_length = len(part[MAX_NODE_LABEL_LENGTH:])
                else:
                    node_label += part
                    current_length = current_length + 1 + len(part)
            return node_label
        for separator in [":", "-", "."]:
            if separator in node_label:
                node_label = split_node_label(node_label, separator)
                break
    # Format node tooltip
    tooltip = f"kind: {resource.get('kind')}\n" \
            + f"apiVersion: {resource.get('apiVersion')}\n" \
            + "metadata:\n" \
            + f"  name: {get_name(resource)}\n" \
            + "..."
    # Search diagram node class
    diagram_node_class = ManagedServices # default diagram node class
    node_config = get_node_config(resource)
    if node_config is not None: # node config found
        custom_icon = node_config.get("custom_icon")
        if custom_icon is not None: # custom_icon defined
            return Custom(
                node_label,
                os.path.abspath(custom_icon.replace("$KD", DIRNAME)),
                tooltip=tooltip
            )
        # Get diagram node class name
        diagram_node_classname = node_config.get("diagram_node_classname")
        if diagram_node_classname is not None: # classname defined
            # Import Diagrams node class module
            idx = diagram_node_classname.rfind('.')
            if idx != -1:
                module = importlib.import_module(diagram_node_classname[:idx])
                # Get diagram node class
                diagram_node_class = getattr(module, diagram_node_classname[idx+1:])
    # Create a diagram node
    return diagram_node_class(node_label, tooltip=tooltip)

class ResourceCluster:
    """
        Hierarchical clustering of Kubernetes resources.
    """
    def __init__(self, name):
        """
            Initialize a ResourceCluster instance.
        """
        self.name = name
        self.resources = {} # dict<str, resource>
        self.clusters = {} # dict<str, Cluster>
        self.graph_attr = { "tooltip": name }

    def get_or_create_cluster(self, name):
        """
            Get or create a sub cluster.
        """
        cluster = self.clusters.get(name)
        if cluster is None:
            cluster = ResourceCluster(name)
            self.clusters[name] = cluster
        return cluster

    def display(self, ident=0):
        """
            Display sub clusters and Kubernetes resources recursively.
        """
        for rid, _ in self.resources.items():
            print("  " * ident, f"- Resource {rid}")
        for cid, sub_cluster in self.clusters.items():
            print("  " * ident, f"- {cid}")
            sub_cluster.display(ident + 1)

class EdgesContext(list):
    """
        Context provided to edges configuration scripts.
    """
    def __init__(self, rid, resource):
        """
            Initialize an edge context.
        """
        list.__init__(self)
        self.rid = rid
        self.resource = resource
        self.namespace = get_namespace(resource)

    def info(self, path, msg):
        """
            Report an information message.
        """
        info(self.resource, path, msg)

    def warning(self, path, msg):
        """
            Report a warning message.
        """
        warning(self.resource, path, msg)

    def error(self, path, msg):
        """
            Report an error message.
        """
        error(self.resource, path, msg)

    def add_edge(self, path, edge):
        """
            Add an edge.
        """
        edge_kind = edge[-1]
        if isinstance(edge_kind, str):
            edge_kind = dict(get_edge_config(edge_kind))
        edge_kind["tooltip"] = path
        edge[-1] = edge_kind
        self.append(edge)

    def add_edge_to_rid(self, path, rid, edge_kind):
        """
            Add an edge to a resource identifier.
        """
        if rid in resources:
            self.add_edge(path, [rid, edge_kind])
        else:
            if rid in config["cluster-resources"]:
                self.info(path, f"'{rid}' provided by Kubernetes cluster")
            else:
                self.warning(path, f"'{rid}' undefined")

    def add_edge_to(self, path, name, namespace, kind, api_version, edge_kind):
        """
            Add an edge to a resource.
        """
        if name == ".":
            name = query_path(self.resource, path)
        if name is None:
            return
        if name == "":
            self.warning(path, "Set to \"\"")
            return
        if namespace is not None:
            rid = f"{name}/{namespace}/{kind}/{api_version}"
        else:
            rid = f"{name}/{kind}/{api_version}"
        if rid in resources:
            self.add_edge(path, [rid, edge_kind])
        else:
            if rid in config["cluster-resources"]:
                self.info(path, f"{kind} '{name}' provided by Kubernetes cluster")
            elif not config.get("nodes", {}).get(f"{kind}/{api_version}", {}).get("show", True):
                self.info(path, f"{kind} '{name}' hidden")
            else:
                self.warning(path, f"{kind} '{name}' undefined")

    def add_resource(self, path):
        """
            Add a reference edge to a resource.
        """
        target = query_path(self.resource, path)
        self.add_edge_to(
            path,
            target['name'],
            self.namespace,
            target['kind'],
            target['apiVersion'],
            "REFERENCE-UP"
        )

    def add_resources(self, path, name_path, namespace_path, kind, api_version):
        """
            Add reference edges to a set of resources.
        """
        for ridx, resource in enumerate(query_path(self.resource, path, [])):
            self.add_edge_to(
                f"{path}[{ridx}]",
                resource[name_path],
                resource.get(namespace_path, self.namespace),
                kind,
                api_version,
                "REFERENCE"
            )

    def add_all_resources_matching_labels(
            self,
            kind,
            path,
            match_labels=None,
            data=None,
            resource_labels_path="metadata.labels",
            edge_kind="SELECTOR"
    ):
        """
            Add selector egdes to all resources matching given labels.
        """
        if data is None:
            data = self.resource
        if match_labels is None:
            match_labels = query_path(data, path)
        if match_labels is None:
            return False
        resource_found = False
        for rid, resource in resources.items():
            if resource.get("kind") != kind:
                continue
            labels = query_path(resource, resource_labels_path, {})
            if not isinstance(labels, dict):
                continue # skip these labels because this is not a dictionary.
            found = True
            for sk, sv in match_labels.items():
                if labels.get(sk) != sv:
                    found = False
                    break
            if found:
                self.add_edge_to_rid(path, rid, edge_kind)
                resource_found = True
        return resource_found

    def add_all_volume_resources(self, path):
        """
            All edges for resources referenced into volumes.
        """
        def add_volumes(volumes, path):
            for idx, volume in enumerate(volumes):
                if "configMap" in volume:
                    config_map = volume["configMap"]
                    if config_map is None:
                        continue # skip this config map
                    config_name = config_map.get("name")
                    config_map_id = f"{config_name}/{self.namespace}/ConfigMap/v1"
                    if config_map.get("optional") is True:
                        if config_map_id not in resources:
                            self.warning(
                                f"{path}[{idx}].configMap",
                                f"ConfigMap '{config_name}' undefined but optional"
                            )
                            continue # skip it
                    self.add_edge_to(
                        f"{path}[{idx}].configMap",
                        config_name,
                        self.namespace,
                        "ConfigMap",
                        "v1",
                        "REFERENCE"
                    )
                elif "secret" in volume:
                    secret = volume["secret"]
                    if secret is None:
                        continue # skip this secret
                    secret_name = secret.get("secretName")
                    if secret_name is None:
                        # Warning: jenkins chart uses 'name' instead of 'secretName'!
                        secret_name = secret.get("name")
                    secret_id = f"{secret_name}/{self.namespace}/Secret/v1"
                    if secret.get("optional") is True:
                        if secret_id not in resources:
                            self.warning(
                                f"{path}[{idx}].secret",
                                f"Secret '{secret_name}' undefined but optional"
                            )
                            continue # skip it
                    self.add_edge_to(
                        f"{path}[{idx}].secret",
                        secret_name,
                        self.namespace,
                        "Secret",
                        "v1",
                        "REFERENCE"
                    )
                elif "persistentVolumeClaim" in volume:
                    self.add_edge_to(
                        f"{path}[{idx}].persistentVolumeClaim",
                        query_path(volume, "persistentVolumeClaim.claimName"),
                        self.namespace,
                        "PersistentVolumeClaim",
                        "v1",
                        "REFERENCE"
                    )
                elif "projected" in volume:
                    volumes = query_path(volume, "projected.sources")
                    if volumes is not None:
                        add_volumes(volumes, path + "projected.sources")

        volumes = query_path(self.resource, path)
        if volumes is not None:
            add_volumes(volumes, path)

    def add_containers_env_value_from(self, path):
        """
            Add edges to resources referenced from containers.env.valueFrom.
        """
        containers = query_path(self.resource, path)
        if containers is None:
            return
        target_resources = set()
        for container in containers:
            for env in query_path(container, "env", []):
                config_map_key_ref_name = query_path(env, "valueFrom.configMapKeyRef.name")
                if config_map_key_ref_name is not None:
                    target_resources.add(
                        f"{config_map_key_ref_name}/{self.namespace}/ConfigMap/v1"
                    )
                secret_key_ref_name = query_path(env, "valueFrom.secretKeyRef.name")
                if secret_key_ref_name is not None:
                    secret_id = f"{secret_key_ref_name}/{self.namespace}/Secret/v1"
                    if query_path(env, "valueFrom.secretKeyRef.optional") is True:
                        if secret_id not in resources:
                            self.warning(
                                path,
                                f"Secret '{secret_key_ref_name}' undefined but optional"
                            )
                            continue # skip it
                    target_resources.add(
                        secret_id
                    )
        for target_resource in target_resources:
            self.add_edge_to_rid(path, target_resource, "REFERENCE")

    def add_volume_claim_templates(self, path):
        """
            Add reference edges to volume claim templates.
        """
        for idx, vct in enumerate(query_path(self.resource, path, [])):
            self.add_edge_to(
                f"{path}[{idx}]",
                vct['metadata']['name'],
                self.namespace,
                "PersistentVolumeClaim",
                "v1",
                "REFERENCE"
            )

    def add_wait_for_services(self, path):
        """
            Add dependence edges between worload resources.
        """
        for _, ic in enumerate(query_path(self.resource, path, [])):
            if ic.get("name") == "wait-for-services":
                for arg in query_path(ic, "args", []):
                    if arg.startswith("-service="):
                        sn = arg[len("-service="):]
                        self.add_edge_to(
                            path,
                            sn,
                            self.namespace,
                            "Service",
                            "v1",
                            "DEPENDENCE"
                        )

    def add_service_account(self, path):
        """
            Add a reference edge to a service account resource.
        """
        pod_spec = query_path(self.resource, path)
        if pod_spec is None:
            return
        service_account_name = pod_spec.get("serviceAccountName")
        if service_account_name is None:
            service_account_name = pod_spec.get("serviceAccount")
            if service_account_name is not None:
                self.warning(f"{path}.serviceAccount", "Deprecated")
        self.add_edge_to(
            f"{path}.serviceAccountName",
            service_account_name,
            self.namespace,
            "ServiceAccount",
            "v1",
            "REFERENCE"
        )

    def add_role(self, path):
        """
            Add a reference edge to a role resource.
        """
        role_ref = self.resource.get(path)
        if role_ref is None:
            return
        api_group = query_path(role_ref, "apiGroup", "rbac.authorization.k8s.io")
        if api_group == "":
            self.warning(path, "Set to \"\"")
            api_group = "rbac.authorization.k8s.io"
        if role_ref.get("kind") == "Role":
            namespace = self.namespace
        else:
            namespace = None
        self.add_edge_to(
            f"{path}.apiGroup",
            role_ref["name"],
            namespace,
            role_ref["kind"],
            f"{api_group}/v1",
            "REFERENCE"
        )

    def add_subjects(self):
        """
            Add reference edges for subject resources.
        """
        for idx, subject in enumerate(query_path(self.resource, "subjects", [])):
            namespace = subject.get("namespace")
            if namespace is None and subject.get("kind") == "ServiceAccount":
                namespace = get_namespace(self.resource)
            if namespace is not None:
                api_version = query_path(subject, "apiGroup", "v1")
                if api_version == "":
                    api_version = "v1"
            else:
                api_group = query_path(subject, "apiGroup", "rbac.authorization.k8s.io")
                if api_group == "":
                    self.warning(f"subjects[{idx}].apiGroup", "Set to \"\"")
                    api_group = "rbac.authorization.k8s.io"
                api_version = f"{api_group}/v1"
            self.add_edge_to(
                f"subjects[{idx}]",
                subject['name'],
                namespace,
                subject['kind'],
                api_version,
                "REFERENCE-UP"
            )

    def add_service(self, path, data=None):
        """
            Add a reference edge to a service resource.
        """
        if data is None:
            data = self.resource
        name = query_path(data, path)
        self.add_edge_to(
            path,
            name,
            self.namespace,
            "Service",
            "v1",
            "REFERENCE"
        )

    def get_owned_resources(self, owner_resource):
        """
            Get owned resources.
        """
        result = []
        uid = query_path(owner_resource, "metadata.uid")
        for _, resource in resources.items():
            for owner_reference in query_path(resource, "metadata.ownerReferences", []):
                if owner_reference.get("uid") == uid:
                    result.append(resource)
        return result

    def add_owned_resources(self):
        """
            Add owner edges to owned resources.
        """
        for resource in self.get_owned_resources(self.resource):
            if get_node_config(resource).get("scope") == "Namespaced":
                namespace = get_namespace(resource)
            else:
                namespace = None
            self.add_edge_to(
                "owns",
                resource['metadata']['name'],
                namespace,
                resource['kind'],
                resource['apiVersion'],
                "OWNER"
            )

    def add_ingress_and_egress_rules(self):
        """
            Add selector edges for ingress and egress rules.
        """
        selected_nodes = [e[0] for e in self]
        for _, ingress_rule in enumerate(query_path(self.resource, "spec.ingress", [])):
            for _, ingress_from in enumerate(query_path(ingress_rule, "from", [])):
                if "podSelector" not in ingress_from:
                    continue # skip this ingress_from item
                current_index = len(self)
                self.add_all_workload_resources(
                    "spec.ingress[{ridx}].from[{fidx}]",
                    query_path(ingress_from, "podSelector.matchLabels"),
                    edge_kind="INVISIBLE"
                )
                ports = [
                    f"{str(port.get('port'))}/{str(port.get('protocol'))}" \
                    for port in query_path(ingress_rule, "ports", [])
                ]
                edge = {
                    **get_edge_config("COMMUNICATION"),
                    "headlabel": f"ingress\n{', '.join(ports)}",
                    "fontsize": "10"
                }
                for rid_from in [e[0] for e in self[current_index:]]:
                    for rid_to in selected_nodes:
                        self.add_edge("spec.ingress", [rid_from, rid_to, edge])

        for egress_rule in query_path(self.resource, "spec.egress", []):
            for egress_to in query_path(egress_rule, "to", []):
                if "podSelector" not in egress_to:
                    continue # skip this egress_to item
                current_index = len(self)
                self.add_all_workload_resources(
                    "spec.egress",
                    query_path(egress_to, "podSelector.matchLabels"),
                    edge_kind="INVISIBLE"
                )
                ports = [
                    f"{str(port.get('port'))}/{str(port.get('protocol'))}" \
                    for port in query_path(egress_rule, "ports", [])
                ]
                edge = {
                    **get_edge_config("COMMUNICATION"),
                    "taillabel": f"egress\n{', '.join(ports)}",
                    "fontsize": "10"
                }
                for rid_to in [e[0] for e in self[current_index:]]:
                    for rid_from in selected_nodes:
                        self.add_edge("spec.egress", [rid_from, rid_to, edge])

    def add_webhooks(self):
        """
            Add reference edges for webhooks.
        """
        for idx, webhook in enumerate(query_path(self.resource, "webhooks", [])):
            service = query_path(webhook, "clientConfig.service")
            if service is not None:
                self.add_edge_to(
                    f"webhooks[{idx}].clientConfig.service",
                    service['name'],
                    service['namespace'],
                    "Service",
                    "v1",
                    "REFERENCE"
                )

    def add_all_workload_resources(
        self,
        path,
        selector=None,
        default_selector=None,
        edge_kind="SELECTOR"
    ):
        """
            Add selector edges to all workload resources.
        """
        if selector is None:
            selector = query_path(self.resource, path, default_selector)
            if selector is None:
                return
        build_in_workload_resources = {
            "Pod": "metadata.labels",
            "PodTemplate": "template.metadata.labels",
            "Deployment": "spec.template.metadata.labels",
            "ReplicaSet": "spec.template.metadata.labels",
            "ReplicationController": "spec.template.metadata.labels",
            "StatefulSet": "spec.template.metadata.labels",
            "DaemonSet": "spec.template.metadata.labels",
            "Job": "spec.template.metadata.labels",
        }
        resource_not_found = True
        for kind, label in build_in_workload_resources.items():
            if self.add_all_resources_matching_labels(
                    kind,
                    path,
                    selector,
                    resource_labels_path=label,
                    edge_kind=edge_kind):
                resource_not_found = False
                if default_selector is None:
                    break
        if resource_not_found:
            self.warning(path, f"No workload resource matches metadata labels {selector}")

    def add_edges_for_service(self):
        """
            Add edges from a service resource to workload resources,
            endpoint slices and endpoints.
        """
        labels = []
        for service_port in query_path(self.resource, "spec.ports", []):
            port = service_port.get('port')
            target_port = service_port.get('targetPort')
            protocol = service_port.get('protocol', 'TCP')
            if target_port is None or target_port == port:
                label = f"{port}/{protocol}"
            else:
                label = f"{port}->{target_port}/{protocol}"
            labels.append(label)
        edge = {
            **get_edge_config("SELECTOR"),
            "xlabel": '\n'.join(labels),
            "fontsize": "10"
        }
        self.add_all_workload_resources("spec.selector", edge_kind=edge)
        self.add_all_resources_matching_labels(
                "EndpointSlice",
                "endpoint_slice",
                {
                    "kubernetes.io/service-name":
                        query_path(self.resource, "metadata.name")
                }
        )
        rid = f"{query_path(self.resource, 'metadata.name')}" \
                f"/{get_namespace(self.resource)}/Endpoints/v1"
        if rid in resources:
            self.add_edge("owns", [rid, "OWNER"])

    def add_networks(self, path):
        """
            Add edges from a workload resource to its referenced
            NetworkAttachmentDefinition resources.
        """
        annotations = query_path(self.resource, path)
        if annotations is not None:
            networks = annotations.get("k8s.v1.cni.cncf.io/networks")
            if networks is not None:
                try:
                    networks = json.loads(networks)
                except json.decoder.JSONDecodeError:
                    self.warning(f"{path}:k8s.v1.cni.cncf.io/networks",
                            f"Invalid JSON '{networks}'")
                    return
                for nidx, network in enumerate(networks):
                    self.add_edge_to(
                        f"{path}:k8s.v1.cni.cncf.io/networks[{nidx}]",
                        network['name'],
                        get_namespace(self.resource),
                        "NetworkAttachmentDefinition",
                        "k8s.cni.cncf.io/v1",
                        "REFERENCE"
                    )

    def add_priority_class(self, path):
        """
            Add a reference edge to a priority class resource.
        """
        self.add_edge_to(
            path,
            ".",
            None,
            "PriorityClass",
            "scheduling.k8s.io/v1",
            "REFERENCE"
        )

# Parse arguments
parser = argparse.ArgumentParser(
    prog="kube-diagrams",
    description="Generate Kubernetes architecture diagrams from Kubernetes manifest files")
parser.add_argument("filename", nargs='+',
    help="the Kubernetes manifest filename to process")
parser.add_argument("-o", "--output", type=str,
    help="output diagram filename")
parser.add_argument("-f", "--format", type=str,
    help="output format, allowed formats are png (default), jpg, svg, pdf, and dot",
    default="png")
parser.add_argument("-c", "--config", type=str,
    help="custom kube-diagrams configuration file")
parser.add_argument("-v", "--verbose",
    help="verbosity, set to false by default",
    action="store_true", default=False)
parser.add_argument("--without-namespace",
    help="disable namespace cluster generation",
    action="store_true", default=False)
args = parser.parse_args()

# Process arguments.
if args.output is None:
    args.output = args.filename[0][:args.filename[0].rfind('.')]
else:
    dot_idx = args.output.rfind('.')
    if dot_idx != -1:
        args.format = args.output[dot_idx+1:]
        args.output = args.output[:dot_idx]

SUPPORTED_OUTPUT_FORMATS = ["png", "jpg", "svg", "pdf", "dot"]
SOF = "' or '".join(SUPPORTED_OUTPUT_FORMATS)
if args.format not in SUPPORTED_OUTPUT_FORMATS:
    print(f"Error: '{args.format}' output format unsupported,"
            f" use '{SOF}' instead!", file=sys.stderr)
    sys.exit(1)

if args.config is not None:
    with open(args.config, encoding="utf-8") as f:
        custom_config = yaml.safe_load(f) # load custom config file
        if custom_config: # not empty file
            if "default_namespace" in custom_config:
                config["default_namespace"] = custom_config["default_namespace"]
            if custom_config.get("edges"):
                config["edges"].update(custom_config["edges"])
            if custom_config.get("clusters"):
                for cluster_custom_config in custom_config["clusters"]:
                    cluster_label = cluster_custom_config.get("label")
                    for config_cluster in config["clusters"]:
                        if config_cluster.get("label") == cluster_label:
                            config_cluster.update(cluster_custom_config)
                            cluster_label = None
                            break
                    if cluster_label is not None:
                        config["clusters"].append(cluster_custom_config)
            if custom_config.get("nodes"):
                for k, v in custom_config["nodes"].items():
                    previous = config["nodes"].get(k)
                    if previous is None:
                        config["nodes"][k] = v
                    else:
                        previous.update(v)
            if "diagram" in custom_config:
                config["diagram"] = custom_config["diagram"]

# Load the Kubernetes manifest file.
resources = {} # a map of all resources
resource_cluster = ResourceCluster("ROOT") # Clustering resources

def process_resource(resource):
    """
        Process a resource.
    """
    cluster = resource_cluster
    name = get_name(resource)
    if get_node_config(resource).get("scope") == "Cluster":
        rid = name + "/" + get_type(resource)
        resources[rid] = resource
        if not(args.without_namespace) and resource.get("kind") == "Namespace":
            cluster = resource_cluster.get_or_create_cluster(f"Namespace: {name}")
    else: # scope = Namespaced
        rid = name + "/" + get_namespace(resource) + "/" + get_type(resource)
        resources[rid] = resource
        if not args.without_namespace:
            cluster = resource_cluster.get_or_create_cluster(
                        f"Namespace: {get_namespace(resource)}"
                    )

    def process_clusters(cluster, resource, clusters):
        """
            Process a resource.
        """
        for cluster_config in clusters:
            if cluster_config.get("show", True) is False:
                continue # skip this cluster
            if "label" in cluster_config:
                label = cluster_config["label"]
                labels = query_path(resource, "metadata.labels")
                if isinstance(labels, dict) and label in labels:
                    cluster_config_title = cluster_config['title']
                    if cluster_config_title.find("{") == -1:
                        metadata_label_value = labels[label]
                        if metadata_label_value is not None:
                            cluster_name = f"{cluster_config_title}: {metadata_label_value}"
                        else:
                            cluster_name = cluster_config_title
                    else:
                        cluster_name = cluster_config_title.format(labels[label])
                    cluster = cluster.get_or_create_cluster(cluster_name)
                    cluster.graph_attr.update(cluster_config.get("graph_attr", {}))
                    clusters.remove(cluster_config)
                    return process_clusters(cluster, resource, clusters)
        return cluster

    cluster = process_clusters(
                cluster,
                resource,
                copy.deepcopy(config.get("clusters", []))
            )
    cluster.resources[rid] = resource
    nodes_script = get_node_config(resource).get("nodes")
    if nodes_script is not None:
        nodes = []
        try:
            # pylint: disable-next=exec-used
            exec(nodes_script)
        except Exception as exc:
            print("Error:", type(exc), ":", exc.args)
            traceback.print_exc()
            print("Nodes script:\n", nodes_script)
            print("Resource:")
            pprint(resource)
            raise
        for node in nodes:
            process_resource(node)

for filename in args.filename:
    try:
        with open(0 if filename == '-' else filename, encoding="utf-8") as f:
            print(f"Load {'from stdin' if filename == '-' else filename}...")
            for yaml_data in yaml.safe_load_all(f): # load YAML file
                if yaml_data is None:
                    continue # Skip empty YAML content
                if yaml_data.get("kind") == "List":
                    for r in yaml_data["items"]:
                        process_resource(r)
                else:
                    process_resource(yaml_data)
    except FileNotFoundError:
        print(f"Error: file '{filename}' not found!")
        if len(args.filename) == 1:
            sys.exit(1)
    except yaml.scanner.ScannerError as error:
        print("Error: " + str(error).replace('\n', ' ') + "!")
    except yaml.constructor.ConstructorError as error:
        print("Error: " + str(error).replace('\n', ' ') + "!")

# Print loaded Kubernetes resources
if args.verbose:
    print("Loaded Kubernetes resources:")
    resource_cluster.display()

def process_edges():
    """
        Process diagram edges.
    """
    for resource_id, resource in resources.items():
        if not get_node_config(resource).get("show", True):
            continue # Skip this hidden resource.
        edges = EdgesContext(resource_id, resource)
        # execute config code to generate edges
        code_to_exec = get_node_config(resource).get("edges")
        if code_to_exec:
            try:
                # pylint: disable-next=exec-used
                exec(code_to_exec)
            except Exception as exc:
                print("Error:", type(exc), ":", exc.args)
                traceback.print_exc()
                print("Edges script:\n", code_to_exec)
                print("Resource:")
                pprint(resource)
                raise
        # generate diagram edges
        for eidx, edge in enumerate(edges):
            if len(edge) == 2:
                edges[eidx] = [resource_id, edge[0], edge[1]]
        for edge in edges:
            edge_from = edge[0]
            edge_to = edge[1]
            edge_name = edge[2]
            try:
                if isinstance(edge_name, dict):
                    edge_configuration = edge_name
                else:
                    edge_configuration = get_edge_config(edge_name)
                if edge_configuration.get("direction") == "up":
                    _ = diagram_nodes[edge_to] \
                        << Edge(**edge_configuration) \
                        << diagram_nodes[edge_from]
                else:
                    _ = diagram_nodes[edge_from] \
                        >> Edge(**edge_configuration) \
                        >> diagram_nodes[edge_to]
            except KeyError as ke:
                if edge_to in config["cluster-resources"]:
                    info(resource, None, f"Referenced {edge_to} resource is provided"
                            " by K8s clusters.")
                    continue # skip this edge as the resource is provided by K8s clusters.
                error(resource, None, f"{ke} resource not found")

def create_nodes(cluster):
    """
        Create diagram nodes and clusters recursively.
    """
    for rid, resource in cluster.resources.items():
        if get_node_config(resource).get("show", True):
            diagram_nodes[rid] = create_diagram_node(resource)
    for cid, sub_cluster in cluster.clusters.items():
        with Cluster(cid, graph_attr=sub_cluster.graph_attr):
            create_nodes(sub_cluster)

def create_custom_node(node_id, node_def):
    """
        Create a custom node.
    """
    diagram_node_class = ManagedServices # default diagram node class
    # Get diagram node class name
    diagram_node_classname = node_def.get("type")
    if diagram_node_classname is not None: # classname defined
        # Import Diagrams node class module
        idx = diagram_node_classname.rfind('.')
        if idx != -1:
            module = importlib.import_module(diagram_node_classname[:idx])
            # Get diagram node class
            diagram_node_class = getattr(module, diagram_node_classname[idx+1:])
    node_label = node_def.get("name","")
    diagram_nodes[node_id] = diagram_node_class(node_label, tooltip=node_label)

def create_custom_cluster(cluster_id, cluster_def):
    """
        Create a custom cluster.
    """
    cluster_name = query_path(cluster_def, "name")
    with Cluster(cluster_name, graph_attr={"tooltip":cluster_name}):
        create_custom_clusters_nodes(cluster_id, cluster_def)

def create_custom_clusters_nodes(container_id, container_def):
    """
        Create custom clusters and nodes.
    """
    prefix_id = container_id + "." if container_id else ""
    for cluster_id, cluster_def in query_path(container_def, "clusters", {}).items():
        create_custom_cluster(prefix_id + cluster_id, cluster_def)
    for node_id, node_def in query_path(container_def, "nodes", {}).items():
        create_custom_node(prefix_id + node_id, node_def)
    if container_id == generate_diagram_in_cluster:
        create_nodes(resource_cluster)

# Generate diagram
generate_diagram_in_cluster = query_path(config, "diagram.generate_diagram_in_cluster")
with Diagram("", filename=args.output, show=False, direction="TB", outformat=args.format):
    # Generate diagram nodes
    diagram_nodes = {}
    create_custom_clusters_nodes(None, query_path(config, "diagram", {}))

    # Generate diagram edges
    process_edges()

    # Create custom edges
    for edge_idx, custom_edge in enumerate(query_path(config, "diagram.edges", [])):
        custom_edge_from = custom_edge.get("from")
        from_node = diagram_nodes.get(custom_edge_from)
        if from_node is None:
            print(f"Warning: {args.config}:diagram.edges[{edge_idx}].from:"
                    f" Node '{custom_edge_from}' undefined!")
        custom_edge_to = custom_edge.get("to")
        to_node = diagram_nodes.get(custom_edge_to)
        if to_node is None:
            print(f"Warning: {args.config}:diagram.edges[{edge_idx}].to:"
                    f" Node '{custom_edge_to}' undefined!")
        if from_node is not None and to_node is not None:
            edge_tooltip = f"from: {custom_edge_from}\nto: {custom_edge_to}"
            _ = from_node >> Edge(**custom_edge, tooltip=edge_tooltip) >> to_node

print(f"{args.output}.{args.format} generated.")
