diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a136798 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.txt +*.json +*.yaml +*.pdf +*.gv +examples +dist +poetry.lock diff --git a/LICENSE b/LICENSE deleted file mode 100644 index cbaddde..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2021 Peter Gasper - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md index d238729..2b58201 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,59 @@ -# kubesurveyor -Good enough Kubernetes namespace visualization tool +# Kubesurveyor + +Good enough Kubernetes namespace visualization tool. +No provisioning to a cluster required, only Kubernetes API is scrapped. + + + +## Installation +``` +sudo apt-get install graphviz +pip install kubesurveyor +``` + +## Usage + +Export path to a custom certification authority, if you use one for your Kubernetes cluster API +``` +export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt +``` + +Alternatively, ignore K8S API certificate errors using `--insecure` or `-k` +``` +kubesurveyor --insecure +``` + +Show `` namespace as a `dot` language graph, using currently active K8S config context +``` +kubesurveyor +``` + +Specify context to be used, if there are multiple in the K8S config file +``` +kubesurveyor --context +``` + +Dump crawled namespace data to a `YAML` format for later processing +``` +kubesurveyor --context --save > namespace.yaml +``` + +Load from `YAML` file, show as `dot` language graph +``` +cat namespace.yaml | kubesurveyor --load +``` + +Load from `YAML` file and render as `png` visualization to a current working directory +``` +cat namespace.yaml | kubesurveyor --load --out png +``` + +If you want to generate architecture image from `dot` definition by hand, use `dot` directly +``` +dot -Tpng k8s.dot > k8s.png +``` + +Limitations: + - unconnected pods, services are not shown + - could have problems with deployments created by Tiller + - number of replicas is not tracked diff --git a/kubesurveyor.jpg b/kubesurveyor.jpg new file mode 100644 index 0000000..2dedd8f Binary files /dev/null and b/kubesurveyor.jpg differ diff --git a/kubesurveyor/__init__.py b/kubesurveyor/__init__.py new file mode 100644 index 0000000..e5a0d9b --- /dev/null +++ b/kubesurveyor/__init__.py @@ -0,0 +1 @@ +#!/usr/bin/env python3 diff --git a/kubesurveyor/main.py b/kubesurveyor/main.py new file mode 100644 index 0000000..f1d026d --- /dev/null +++ b/kubesurveyor/main.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 + +__author__ = "Peter Gasper" +__version__ = "1.0.0" +__license__ = "MIT" + +import os +import sys +import yaml +import argparse +from graphviz import Digraph +from kubernetes import client, config + +ns = { + "namespace": "", + "version": __version__, # program version + "ingress": {}, # fqdn to service mapping + "service": {}, # service to deployment - 1 to 1 mapping + "pod": {}, # pod to container - 1 to N mapping +} + + +def get_pods(client, namespace): + # name, ports + v1 = client.CoreV1Api() + ret = v1.list_namespaced_pod(namespace=namespace) + for pod in ret.items: + pod_name = "" + containers = [] + for container in pod.spec.containers: + # collect container name and ports used + ports = [] + try: + for port in container.ports: + port = port.to_dict() + # store name, and the actual port in case name is not used + if "name" in port: + if port["name"] is not None: + ports.append(port["name"]) + if "container_port" in port: + ports.append(port["container_port"]) + except TypeError: + # container does not have ports + pass + containers.append([container.name, ports]) + if "job-name" in pod.metadata.labels: + # skip cron-jobs + continue + # beware, if elif order matters ! + if "app" in pod.metadata.labels: + pod_name = pod.metadata.labels["app"] + elif "statefulset.kubernetes.io/pod-name" in pod.metadata.labels: + pod_name = pod.metadata.labels["statefulset.kubernetes.io/pod-name"] + elif "app.kubernetes.io/name" in pod.metadata.labels: + pod_name = pod.metadata.labels["app.kubernetes.io/name"] + else: + continue + # safe to a global variable + if pod_name not in ns["pod"]: + ns["pod"][pod_name] = {} + for container in containers: + # { "": {"": ["web", "8080", ""]}} + ns["pod"][pod_name][container[0]] = container[1] + + +def get_services(client, namespace): + v1 = client.CoreV1Api() + ret = v1.list_namespaced_service(namespace=namespace) + for service in ret.items: + selector = {} + ports = [] + if service.spec.selector: + selector = service.spec.selector # .to_dict() + else: + # what if there are no selectors? + # Could be a service for some external deployment + continue + for port in service.spec.ports: + port = port.to_dict() + if "target_port" in port: + ports.append(port["target_port"]) + # safe to a global variable + if service.metadata.name not in ns["service"]: + ns["service"][service.metadata.name] = {} + ns["service"][service.metadata.name]["ports"] = ports + ns["service"][service.metadata.name]["selector"] = selector + + +def get_ingresses(client, namespace): + v1betaExt = client.ExtensionsV1beta1Api() + ret = v1betaExt.list_namespaced_ingress(namespace=namespace) + for ingress in ret.items: + ns["ingress"][ingress.metadata.name] = {} + for rule in ingress.spec.rules: # list + rule = rule.to_dict() + ns["ingress"][ingress.metadata.name][rule["host"]] = {} + for k in rule.keys(): + # get associated rules + if k == "host": + # no rule here + continue + else: + ns["ingress"][ingress.metadata.name][rule["host"]][k] = rule[k] + + +def visualize(): + dot_root = Digraph( + name="Kubernetes Namespace visualisation", + comment="namespace view", + strict="true", + ) + # graph attributes + dot_root.graph_attr["label"] = ns["namespace"] + " namespace" + # dot_root.graph_attr["pad"] = "1" + dot_root.graph_attr["rankdir"] = "LR" + dot_root.graph_attr["ranksep"] = "5.2 equally" + dot_root.graph_attr["fontsize"] = "12" + dot_root.graph_attr["fontname"] = "Sans-Serif" + dot_root.graph_attr["nodesep"] = "0.3" + # dot_root.graph_attr["splines"] = "spline" + dot_root.graph_attr["concentrate"] = "true" + # node attributes + dot_root.node_attr["shape"] = "box" + # colors modified from the command line + dot_root.graph_attr["bgcolor"] = "black" # background + dot_root.graph_attr["fontcolor"] = "white" # text + dot_root.graph_attr["color"] = "white" # for drawings + dot_root.node_attr["fontcolor"] = "white" # text + dot_root.node_attr["color"] = "white" # for drawings + dot_root.node_attr["pencolor"] = "white" # cluster bounding box + dot_root.edge_attr["fontcolor"] = "white" # text + dot_root.edge_attr["color"] = "white" # for drawings + dot_root.edge_attr["pencolor"] = "white" # cluster bounding box + # fillcolor, labelfontcolor - defaults to fontcolor + dot_root.edge_attr["headport"] = "w" + dot_root.edge_attr["tailport"] = "e" + + # ingress, service & pod subgraphs + common_node_attrs = {"shape": "box"} + dot_ingress = Digraph(name="ingress", node_attr={"arrowType": "empty"}) + dot_service = Digraph(name="clusterservice") + dot_service.attr(label="Services", pad="1") + dot_pods = Digraph(name="clusterpods") + dot_pods.attr(label="Pods", pad="1") + + # lets track existing items + existing_pods = {} + # Create "ingress to service" nodes and edges + for i in ns["ingress"].keys(): + for host in ns["ingress"][i].keys(): + # show just the hostname in dot graph + dot_ingress.node(host) + for proto in ns["ingress"][i][host].keys(): + # get associated rules + for rule in ns["ingress"][i][host][proto]: + for path in ns["ingress"][i][host][proto][rule]: + service_name = path["backend"]["service_name"] + # put service to dot + dot_service.node(service_name) + # edge between the ingress and the service + # let's use hostname instead of ingress label + dot_root.edge(host, service_name, label=proto) + # make sure each name is unique in dot + index = 0 + # Create "service to container" nodes and edges + for pod, containers in ns["pod"].items(): + # we wants to have unique names + index = index + 1 + # if pod does not exist in dot, create it once + if pod not in existing_pods: + # we don't have this pod yet + dot_pod = Digraph( + name="cluster" + pod + str(index), + graph_attr={"label": pod}, + ) + existing_pods[pod] = str(index) + # get only container names, not the ports + container_names = list(containers.keys()) + # does any service refer this pod or its containers ? + for ( + container_name, + container_ports, + ) in containers.items(): + for service_name in ns["service"].keys(): + # we are using strict=true so duplicates does not concern us that much + dot_service.node(service_name) + # get pod and ports service is connecting to + pod_selector = list(ns["service"][service_name]["selector"].values()) + ports = ns["service"][service_name]["ports"] + # check for common element + if (pod in pod_selector) or (set(pod_selector) & set(container_names)): + dot_pod.node( + container_name + existing_pods[pod], + container_name, + ) + # FIXME, we are checking just the first service port + if ports[0] in container_ports: + # lets create an edge between the service and a container + # do not forget to use unique pod index for the edge + dot_root.edge( + service_name, + container_name + existing_pods[pod], + label=proto, + ) + dot_pods.subgraph(dot_pod) + # push the subgraphs to the dot_root + dot_root.subgraph(dot_ingress) + dot_root.subgraph(dot_service) + dot_root.subgraph(dot_pods) + return dot_root + + +def ns_to_yaml(): + """Dump namespace dictionary in YAML format.""" + print(yaml.dump(ns, default_flow_style=False), file=sys.stdout) + + +def yaml_to_ns(input_file): + """Load YAML from file to a global variable.""" + global ns + ns = yaml.load(input_file, Loader=yaml.BaseLoader) + + +def get_all(client, namespace): + # TODO check if namespace is available with the current context + get_services(client, namespace) + get_pods(client, namespace) + get_ingresses(client, namespace) + + +def main(args): + # https://github.com/kubernetes-client/python/issues/1131#issuecomment-749452174 + if args.context: + try: + config.load_kube_config(context=args.context) + except config.config_exception.ConfigException: + print( + "Error: Context %s not found in the kube-config file." % args.context, + file=sys.stdout, + ) + return + else: + # load everything from .kube/config + config.load_kube_config() + cc = client.Configuration.get_default_copy() + # do not validate SSL certificate + if args.insecure: + cc.verify_ssl = False + # disable "Adding certificate verification is strongly advised" warnings + import urllib3 + + urllib3.disable_warnings() + # FIXME use proper CA if available + # cc.ssl_ca_cert = os.environ.get("REQUESTS_CA_BUNDLE") + client.Configuration.set_default(cc) + namespace = args.namespace + ns["namespace"] = namespace + input_file = None + # decide if we are loading from an actual cluster or existing yaml file + if not args.load: + # we are not loading from a yaml file + get_all(client, namespace) + else: + # load yaml file loaded via stdin to a `ns` global var + input_file = sys.stdin + yaml_to_ns(input_file) + # decide if we are visualising or storing internal state to a file + if args.save: + # show crawled yaml instead of visualization + ns_to_yaml() + else: + viz_dot = visualize() + if args.out == "dot": + # return only dot file + print(viz_dot, file=sys.stdout) + if args.out == "png": + # this one will return png and dot file + viz_dot.format = "png" + # dot_root.view() + viz_dot.render(filename=namespace) + + +def parse_args(): + parser = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description="""\ + Kubesurveyor: Good enough Kubernetes namespace visualization tool. + + Examples: + # Show '' namespace as a 'dot' language graph, using currently active K8S config context + kubesurveyor + + # ignore K8S API certificate errors + kubesurveyor --insecure + + # Specify context to be used + kubesurveyor --context + + # Dump crawled namespace data to a YAML format for later processing + kubesurveyor --context --save > namespace.yaml + + # Load from YAML file, show as 'dot' language graph + cat namespace.yaml | kubesurveyor --load + + # Load from `YAML` file and render as `png` visualization to a current working directory + cat namespace.yaml | kubesurveyor --load --out png + """, + ) + + parser.add_argument( + "--version", + action="version", + version="%(prog)s (version {version})".format(version=__version__), + ) + + # Required positional argument + parser.add_argument( + "namespace", + default="default", + help="The Kubernetes namespace (default 'default').", + ) + parser.add_argument( + "-c", + "--context", + action="store", + dest="context", + help="Kubernetes config file context.", + ) + parser.add_argument( + "-o", + "--out", + action="store", + dest="out", + default="dot", + help="Visualisation format. ['dot', 'png'] (default 'dot').", + ) + + # arguments related to an internals + parser.add_argument( + "-s", + "--save", + action="store_true", + default=False, + help="Save crawled namespace as YAML for later processing.", + ) + parser.add_argument( + "-l", + "--load", + action="store_true", + default=False, + help="Load namespace from YAML for visualisation.", + ) + parser.add_argument( + "-k", + "--insecure", + action="store_true", + default=False, + help="Do not verify cluster SSL certificate.", + ) + + args = parser.parse_args() + main(args) + + +if __name__ == "__main__": + parse_args() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d6b0768 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[tool.poetry] +name = "kubesurveyor" +version = "1.0.0" +description = "Good enough Kubernetes namespace visualization tool" +authors = ["Peter Gasper "] +license = "MIT" +readme = "README.md" +homepage = "https://github.com/viralpoetry/kubesurveyor" +repository = "https://github.com/viralpoetry/kubesurveyor" +keywords = ["kubernetes", "graphviz", "visualisation"] +include = [ + "LICENSE", +] +classifiers = [ + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering :: Visualization", +] + +[tool.poetry.dependencies] +python = "^3.8" +PyYAML = "^5.4.1" +graphviz = "^0.16" +kubernetes = "^12.0.1" + +[tool.poetry.scripts] +kubesurveyor = "kubesurveyor.main:parse_args" + +[tool.poetry.dev-dependencies] + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api"