├── .coveragerc ├── .dockerignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.rst ├── setup.py └── src └── kubetop ├── __init__.py ├── _metadata.py ├── _runmany.py ├── _runonce.py ├── _script.py ├── _textrenderer.py ├── _topdata.py ├── _twistmain.py └── test ├── __init__.py ├── test_script.py ├── test_textrenderer.py └── test_topdata.py /.coveragerc: -------------------------------------------------------------------------------- 1 | # See http://coverage.readthedocs.io/en/coverage-4.3.4/config.html#run 2 | [run] 3 | # We want branch coverage measurement (we want as much measurement as possible). 4 | branch = True 5 | 6 | # This identifies the packages for which to measure coverage. 7 | source = kubetop 8 | 9 | # See http://coverage.readthedocs.io/en/coverage-4.3.4/config.html#paths 10 | [paths] 11 | 12 | # Together with a call to `coverage combine .coverage` this causes kubetop's 13 | # source files to appear as "src/kubetop" in the coverage report. This is not 14 | # only nicer for a person to look at, it makes codecov happier (because 15 | # src/kubetop/... are the paths *it* sees for our source code). 16 | source = 17 | src/kubetop 18 | /home/travis/virtualenv/python2.7.9/lib/python2.7/site-packages/kubetop 19 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .hypothesis 2 | _trial_temp 3 | build 4 | dist 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # 2 | # This is the Travis-CI configuration. 3 | # 4 | 5 | language: "python" 6 | 7 | # This is how you get container-based environments on Travis-CI. And 8 | # container-based environments are how you get fast test runs. 9 | sudo: false 10 | 11 | # Only build master; for "push" builds, this is when the branch pushed 12 | # to is master, for "pr" builds, this is when the merge base of the PR 13 | # is master. 14 | branches: 15 | only: 16 | - "master" 17 | 18 | matrix: 19 | include: 20 | - python: 2.7 21 | - python: 3.6 22 | 23 | cache: 24 | directories: 25 | # Cache the pip download cache across runs to avoid having to 26 | # repeatedly download packages over the network. 27 | - "$HOME/.cache/pip" 28 | 29 | install: 30 | - "pip install --upgrade pip setuptools wheel coverage codecov pyflakes" 31 | - "pip install .[dev]" 32 | 33 | script: 34 | - "pyflakes src" 35 | - "python -Wdefault::DeprecationWarning -m coverage run --rcfile=${PWD}/.coveragerc -m twisted.trial kubetop" 36 | # See .coveragerc for an explanation of this step. 37 | - "python -m coverage combine .coverage" 38 | 39 | after_script: 40 | - "codecov" 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the smallest Python-capable base image that's easily available. 2 | FROM python:2-alpine 3 | 4 | MAINTAINER Jean-Paul Calderone 5 | 6 | # Set up a nice place to dump our source code. 7 | WORKDIR /app 8 | 9 | # Get the necessary build dependencies. 10 | # These all get leaked into the container which is a shame. 11 | # Oh well. 12 | RUN apk add --no-cache build-base libffi-dev openssl-dev 13 | 14 | # Get all of the source into the image. 15 | COPY . . 16 | 17 | # Install it and all its dependencies. 18 | RUN pip install --no-cache-dir . 19 | 20 | # Set the kubetop cli program as the entrypoint. 21 | # This lets arguments be passed by the user without too much trouble. 22 | # Just add them to the end of the `docker run ...` command. 23 | ENTRYPOINT ["kubetop"] 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | kubetop 2 | ======= 3 | 4 | .. image:: http://img.shields.io/pypi/v/kubetop.svg 5 | :target: https://pypi.python.org/pypi/kubetop 6 | :alt: PyPI Package 7 | 8 | .. image:: https://travis-ci.org/LeastAuthority/kubetop.svg 9 | :target: https://travis-ci.org/LeastAuthority/kubetop 10 | :alt: CI status 11 | 12 | .. image:: https://codecov.io/github/LeastAuthority/kubetop/coverage.svg 13 | :target: https://codecov.io/github/LeastAuthority/kubetop 14 | :alt: Coverage 15 | 16 | What is this? 17 | ------------- 18 | 19 | kubetop is a top(1)-like tool for `Kubernetes`_. 20 | 21 | **NOTE** kubetop does not work against recent versions of Kubernetes anymore due to Kubernetes API changes. This is not expected to change in the near future. Patches welcome. 22 | 23 | Usage Sample 24 | ------------ 25 | 26 | .. code-block:: sh 27 | 28 | $ kubetop 29 | 30 | Output Sample 31 | ------------- 32 | 33 | .. code-block:: 34 | 35 | kubetop - 13:02:57 36 | Node 0 CPU% 9.80 MEM% 57.97 ( 2 GiB/ 4 GiB) POD% 7.27 ( 8/110) Ready 37 | Node 1 CPU% 21.20 MEM% 59.36 ( 2 GiB/ 4 GiB) POD% 3.64 ( 4/110) Ready 38 | Node 2 CPU% 99.90 MEM% 58.11 ( 2 GiB/ 4 GiB) POD% 7.27 ( 8/110) Ready 39 | Pods: 20 total 0 running 0 terminating 0 pending 40 | POD (CONTAINER) %CPU MEM %MEM 41 | s4-infrastructure-3073578190-2k2vw 75.5 782.05 MiB 20.76 42 | (subscription-converger) 72.7 459.11 MiB 43 | (grid-router) 2.7 98.07 MiB 44 | (web) 0.1 67.61 MiB 45 | (subscription-manager) 0.0 91.62 MiB 46 | (foolscap-log-gatherer) 0.0 21.98 MiB 47 | (flapp) 0.0 21.46 MiB 48 | (wormhole-relay) 0.0 22.19 MiB 49 | 50 | Installing 51 | ---------- 52 | 53 | Pip / Pipsi 54 | ~~~~~~~~~~~ 55 | 56 | Python 2.7 is required to run kubetop. 57 | 58 | To install the latest version of kubetop using `pip`_ or `pipsi`_:: 59 | 60 | $ pipsi install kubetop 61 | 62 | Docker 63 | ~~~~~~ 64 | 65 | A Docker image containing a kubetop installation is also available. 66 | You can run it like this:: 67 | 68 | $ docker run -it --rm --volume ~/.kube/:/root/.kube/:ro exarkun/kubetop 69 | 70 | Testing 71 | ------- 72 | 73 | kubetop uses pyunit-style tests. 74 | After installing the development dependencies, you can run the test suite with trial:: 75 | 76 | $ pip install kubetop[dev] 77 | $ trial kubetop 78 | 79 | Version 80 | ------- 81 | 82 | kubetop uses the `CalVer`_ versioning convention. 83 | The first three segments of a kubetop version tell you the year (two digit), month, and day that version was released. 84 | The fourth segment of a kubetop version is a bugfix release counter. 85 | It is present if a new release is made that diffs from a previous release only by including one or more bug fixes. 86 | For each bug fix release, the fourth segment is incremented. 87 | 88 | License 89 | ------- 90 | 91 | txkube is open source software released under the MIT License. 92 | See the LICENSE file for more details. 93 | 94 | 95 | .. _Kubernetes: https://kubernetes.io/ 96 | .. _CalVer: http://calver.org/ 97 | .. _pip: https://pip.pypa.io/en/stable/ 98 | .. _pipsi: https://pypi.python.org/pypi/pipsi 99 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright Least Authority Enterprises. 2 | # See LICENSE for details. 3 | 4 | from setuptools import find_packages, setup 5 | 6 | _metadata = {} 7 | with open("src/kubetop/_metadata.py") as f: 8 | exec(f.read(), _metadata) 9 | 10 | setup( 11 | name="kubetop", 12 | version=_metadata["version_string"], 13 | description="A top(1)-like tool for Kubernetes.", 14 | author="kubetop Developers", 15 | url="https://github.com/leastauthority/kubetop", 16 | license="MIT", 17 | zip_safe=False, 18 | package_dir={"": "src"}, 19 | packages=find_packages(where="src"), 20 | install_requires=[ 21 | "bitmath", 22 | "attrs>=17.4.0", 23 | "pyyaml", 24 | "twisted[tls]>=17.9.0", 25 | "treq", 26 | "txkube>=0.3.0", 27 | ], 28 | extras_require={ 29 | "dev": [ 30 | "hypothesis", 31 | ], 32 | }, 33 | entry_points={ 34 | "console_scripts": [ 35 | "kubetop = kubetop._script:main", 36 | ], 37 | }, 38 | ) 39 | -------------------------------------------------------------------------------- /src/kubetop/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Least Authority Enterprises. 2 | # See LICENSE for details. 3 | -------------------------------------------------------------------------------- /src/kubetop/_metadata.py: -------------------------------------------------------------------------------- 1 | 2 | version_tuple = (17, 4, 17, 1) 3 | version_string = ".".join(list(map(str, version_tuple)) + ["dev0"]) 4 | -------------------------------------------------------------------------------- /src/kubetop/_runmany.py: -------------------------------------------------------------------------------- 1 | # Copyright Least Authority Enterprises. 2 | # See LICENSE for details. 3 | 4 | """ 5 | Integration between the per-frame kubetop rendering and the Twisted 6 | startup/shutdown interfaces. 7 | 8 | Theory of Operation 9 | =================== 10 | 11 | #. Glue kubetop rendering object together with a time-based trigger (for periodic re-rendering). 12 | #. Wrap the trigger up in an ``IService`` which can be started and stopped. 13 | #. Deliver rendering success/failure to the main object for reporting and exit. 14 | """ 15 | 16 | from twisted.internet.defer import inlineCallbacks 17 | from twisted.internet.task import deferLater 18 | 19 | from ._runonce import run_once_service 20 | 21 | @inlineCallbacks 22 | def _iterate(reactor, intervals, f): 23 | """ 24 | Run a function repeatedly. 25 | 26 | :param reactor: See ``run_many_service``. 27 | 28 | :return Deferred: A deferred which fires when ``f`` fails or when 29 | ``intervals`` is exhausted. 30 | """ 31 | while True: 32 | before = reactor.seconds() 33 | yield f() 34 | after = reactor.seconds() 35 | try: 36 | interval = next(intervals) 37 | except StopIteration: 38 | break 39 | delay = max(0, interval - (after - before)) 40 | yield deferLater(reactor, delay, lambda: None) 41 | 42 | 43 | 44 | def run_many_service(main, reactor, f, intervals): 45 | """ 46 | Create a service to run a function repeatedly. 47 | 48 | :param TwistMain main: The main entrypoint object. 49 | 50 | :param reactor: The IReactorTime & IReactorCore provider to use for 51 | delaying subsequent iterations. 52 | 53 | :param intervals: An iterable of numbers giving the delay between 54 | subsequent invocations of the function. 55 | 56 | :param f: A zero-argument callable to repeatedly call. 57 | 58 | :return IService: A service which will run the function until the interval 59 | iterator is exhausted. 60 | """ 61 | def run_many(): 62 | return _iterate(reactor, intervals, f) 63 | 64 | return run_once_service(main, reactor, run_many) 65 | -------------------------------------------------------------------------------- /src/kubetop/_runonce.py: -------------------------------------------------------------------------------- 1 | # Copyright Least Authority Enterprises. 2 | # See LICENSE for details. 3 | 4 | """ 5 | Glue a one-shot function to Twisted's application system. 6 | """ 7 | 8 | from twisted.python.log import err 9 | from twisted.application.service import Service 10 | from twisted.internet.defer import maybeDeferred 11 | 12 | 13 | class _RunOnceService(Service): 14 | def startService(self): 15 | Service.startService(self) 16 | # Services can get started before the reactor has started. This 17 | # complicates things. Get rid of all that complexity but not running 18 | # any interesting application code until the reactor has actually 19 | # started. 20 | self.reactor.callWhenRunning(self._run_and_stop) 21 | 22 | 23 | def _run_and_stop(self): 24 | d = maybeDeferred(self.f) 25 | d.addErrback(self._failed) 26 | d.addCallback(lambda ignored: self.main.exit()) 27 | 28 | 29 | def _failed(self, reason): 30 | err(reason) 31 | self.main.exit(reason) 32 | 33 | 34 | def run_once_service(main, reactor, f): 35 | """ 36 | Create a service to run a function once. 37 | 38 | :param TwistMain main: The main entrypoint object. 39 | 40 | :param IReactorCore reactor: Only call ``f`` after this reactor has 41 | started up. 42 | 43 | :param f: A zero-argument callable to repeatedly call. 44 | 45 | :return IService: A service which will run the function and then exit 46 | ``main`` loop. 47 | """ 48 | s = _RunOnceService() 49 | s.main = main 50 | s.reactor = reactor 51 | s.f = f 52 | return s 53 | -------------------------------------------------------------------------------- /src/kubetop/_script.py: -------------------------------------------------------------------------------- 1 | # Copyright Least Authority Enterprises. 2 | # See LICENSE for details. 3 | 4 | """ 5 | The command-line interface. 6 | 7 | Theory of Operation 8 | =================== 9 | 10 | #. Convert command line arguments to structured configuration, supplying defaults where necessary. 11 | #. Construct the top-level kubetop service from the configuration. 12 | #. Run the Twisted reactor. 13 | """ 14 | 15 | from sys import __stdout__ as outfile 16 | 17 | from yaml import safe_load 18 | 19 | from itertools import repeat 20 | from os.path import expanduser 21 | import os 22 | 23 | from twisted.python.usage import Options 24 | from twisted.python.filepath import FilePath 25 | 26 | from ._twistmain import TwistMain 27 | from ._runmany import run_many_service 28 | from ._textrenderer import Sink, kubetop 29 | 30 | DEFAULT_CONFIG = os.getenv('KUBECONFIG', "~/.kube/config") 31 | DEFAULT_CONFIG_FILE_PATH = FilePath(expanduser(DEFAULT_CONFIG)) 32 | 33 | def current_context(config_path): 34 | with config_path.open() as cfg: 35 | return safe_load(cfg)[u"current-context"] 36 | 37 | 38 | class KubetopOptions(Options): 39 | optParameters = [ 40 | ("config", None, DEFAULT_CONFIG, "The path to the kubectl config to use."), 41 | ("context", None, None, "The kubectl context to use. If not set, this will default to the 'current-context' of the 'config'."), 42 | ("interval", None, 3.0, "The number of seconds between iterations.", float), 43 | ("iterations", None, None, "The number of iterations to perform.", int), 44 | ] 45 | 46 | def postOptions(self): 47 | # Calculate the context as a post action instead of setting a default value in optParameters since 48 | # kubetop should use/show the context of any overridden 'config' 49 | self['context'] = current_context(FilePath(expanduser(self['config']))) 50 | 51 | 52 | 53 | def fixed_intervals(interval, iterations): 54 | if iterations is None: 55 | return repeat(interval) 56 | return repeat(interval, iterations) 57 | 58 | 59 | 60 | def makeService(main, options): 61 | from twisted.internet import reactor 62 | 63 | # _topdata imports txkube and treq, both of which import 64 | # twisted.web.client, which imports the reactor, which installs a default. 65 | # That breaks TwistMain unless we delay it until makeService is called. 66 | from ._topdata import make_source 67 | 68 | f = lambda: kubetop(reactor, s, Sink.from_file(outfile)) 69 | 70 | s = make_source(reactor, FilePath(expanduser(options["config"])), options["context"]) 71 | return run_many_service( 72 | main, reactor, f, 73 | fixed_intervals(options["interval"], options["iterations"]), 74 | ) 75 | 76 | 77 | main = TwistMain(KubetopOptions, makeService) 78 | -------------------------------------------------------------------------------- /src/kubetop/_textrenderer.py: -------------------------------------------------------------------------------- 1 | # Copyright Least Authority Enterprises. 2 | # See LICENSE for details. 3 | 4 | """ 5 | View implementation. 6 | 7 | Theory of Operation 8 | =================== 9 | 10 | #. Combine a data source with a text-emitting renderer function and a text 11 | output-capable target (e.g. a file descriptor). 12 | """ 13 | 14 | from __future__ import unicode_literals, division 15 | 16 | from struct import pack, unpack 17 | from termios import TIOCGWINSZ 18 | from fcntl import ioctl 19 | 20 | from twisted.internet.defer import gatherResults 21 | from twisted.python.compat import unicode 22 | 23 | from datetime import datetime 24 | 25 | from bitmath import Byte 26 | 27 | import attr 28 | from attr import validators 29 | 30 | COLUMNS = [ 31 | (20, "POD"), 32 | (26, "(CONTAINER)"), 33 | (12, "%CPU"), 34 | (12, "MEM"), 35 | (7, "%MEM") 36 | ] 37 | 38 | 39 | def kubetop(reactor, datasource, datasink): 40 | return gatherResults([ 41 | datasource.nodes(), datasource.pods(), 42 | ]).addCallback(_render_kubetop, datasink, reactor) 43 | 44 | 45 | 46 | @attr.s 47 | class Size(object): 48 | rows = attr.ib() 49 | columns = attr.ib() 50 | xpixels = attr.ib() 51 | ypixels = attr.ib() 52 | 53 | 54 | 55 | def terminal_size(terminal_fd): 56 | s = pack('HHHH', 0, 0, 0, 0) 57 | t = ioctl(terminal_fd, TIOCGWINSZ, s) 58 | return Size(*unpack('HHHH', t)) 59 | 60 | 61 | 62 | @attr.s 63 | class Terminal(object): 64 | fd = attr.ib() 65 | 66 | def size(self): 67 | return terminal_size(self.fd) 68 | 69 | 70 | 71 | @attr.s 72 | class Sink(object): 73 | terminal = attr.ib() 74 | outfile = attr.ib() 75 | 76 | 77 | @classmethod 78 | def from_file(cls, outfile): 79 | return cls(Terminal(outfile.fileno()), outfile) 80 | 81 | 82 | def write(self, text): 83 | size = self.terminal.size() 84 | num_lines = size.rows 85 | truncated = "\n".join(text.splitlines()[:num_lines]) 86 | self.outfile.write(truncated) 87 | self.outfile.flush() 88 | 89 | 90 | 91 | def _render_kubetop(data, sink, reactor): 92 | sink.write(_render_pod_top(reactor, data)) 93 | 94 | 95 | def _render_row(*values): 96 | fields = [] 97 | debt = 0 98 | for value, (width, label) in zip(values, COLUMNS): 99 | field = "{}".format(value).rjust(width - max(0, debt)) 100 | debt = len(field) - width 101 | fields.append(field) 102 | return "".join(fields) + "\n" 103 | 104 | 105 | def _clear(): 106 | return "\x1b[2J\x1b[1;1H" 107 | 108 | 109 | def _render_clockline(reactor): 110 | return "kubetop - {}\n".format( 111 | datetime.fromtimestamp(reactor.seconds()).strftime("%H:%M:%S") 112 | ) 113 | 114 | 115 | def _render_pod_top(reactor, data): 116 | (node_info, pod_info) = data 117 | nodes = node_info["info"]["items"] 118 | node_usage = node_info["usage"]["items"] 119 | 120 | pods = pod_info["info"].items 121 | pod_usage = pod_info["usage"]["items"] 122 | 123 | return "".join(( 124 | _clear(), 125 | _render_clockline(reactor), 126 | _render_nodes(nodes, node_usage, pods), 127 | _render_pod_phase_counts(pods), 128 | _render_header(nodes, pods), 129 | _render_pods(pods, pod_usage, nodes), 130 | )) 131 | 132 | 133 | def _render_pod_phase_counts(pods): 134 | phases = {} 135 | for pod in pods: 136 | phases[pod.status.phase] = phases.get(pod.status.phase, 0) + 1 137 | 138 | return ( 139 | "Pods: " 140 | "{total:>8} total " 141 | "{running:>8} running " 142 | "{terminating:>8} terminating " 143 | "{pending:>8} pending\n" 144 | ).format( 145 | total=len(pods), 146 | running=phases.get("running", 0), 147 | terminating=phases.get("terminating", 0), 148 | pending=phases.get("pending", 0), 149 | ) 150 | 151 | 152 | def _render_header(nodes, pods): 153 | return _render_row(*( 154 | label 155 | for (width, label) 156 | in COLUMNS 157 | )) 158 | 159 | 160 | def _render_nodes(nodes, node_usage, pods): 161 | usage_by_name = { 162 | usage["metadata"]["name"]: usage 163 | for usage 164 | in node_usage 165 | } 166 | def pods_for_node(node): 167 | return list( 168 | pod 169 | for pod 170 | in pods 171 | if _pod_on_node(pod, node) 172 | ) 173 | 174 | return "".join( 175 | "Node {} {}\n".format( 176 | i, 177 | _render_node( 178 | node, 179 | usage_by_name[node["metadata"]["name"]], 180 | pods_for_node(node), 181 | ), 182 | ) 183 | for i, node 184 | in enumerate(nodes) 185 | ) 186 | 187 | 188 | def _render_node(node, usage, pods): 189 | # From v1.NodeStatus model documentation: 190 | # 191 | # Allocatable represents the resources of a node that are available 192 | # for scheduling. Defaults to Capacity. 193 | allocatable = node["status"]["allocatable"] 194 | 195 | cpu_max = parse_cpu(allocatable["cpu"]) 196 | cpu_used = parse_cpu(usage["usage"]["cpu"]) 197 | 198 | mem_max = parse_memory(allocatable["memory"]) 199 | mem_used = parse_memory(usage["usage"]["memory"]) 200 | 201 | pod_count = len(pods) 202 | pod_max = int(allocatable["pods"]) 203 | 204 | if any( 205 | condition["type"] == "Ready" and condition["status"] == "True" 206 | for condition 207 | in node["status"]["conditions"] 208 | ): 209 | condition = "Ready" 210 | else: 211 | condition = "NotReady" 212 | 213 | return ( 214 | "CPU% {cpu:>6.2f} " 215 | "MEM% {mem} ({mem_used}/{mem_max}) " 216 | "POD% {pod:>5.2f} ({pod_count:3}/{pod_max:3}) " 217 | "{condition}" 218 | ).format( 219 | cpu=cpu_used / cpu_max * 100, 220 | mem=mem_max.render_percentage(mem_used), 221 | mem_used=mem_used.render("4.0"), 222 | mem_max=mem_max.render("4.0"), 223 | pod=pod_count / pod_max * 100, 224 | pod_count=pod_count, 225 | pod_max=pod_max, 226 | condition=condition, 227 | ) 228 | 229 | 230 | 231 | def _pod_on_node(pod, node): 232 | return pod.status is not None and pod.status.hostIP in ( 233 | addr["address"] for addr in node["status"]["addresses"] 234 | ) 235 | 236 | 237 | 238 | class _UnknownMemory(object): 239 | def render(self): 240 | return "???" 241 | 242 | 243 | def render_percentage(self, portion): 244 | return "" 245 | 246 | 247 | 248 | @attr.s(frozen=True) 249 | class _Memory(object): 250 | amount = attr.ib(validator=validators.instance_of(Byte)) 251 | 252 | def render(self, fmt): 253 | amount = self.amount.best_prefix() 254 | return ("{:" + fmt + "f} {}").format(float(amount), amount.unit_singular) 255 | 256 | 257 | def render_percentage(self, portion): 258 | return "{:>5.2f}".format(portion.amount / self.amount * 100) 259 | 260 | 261 | 262 | @attr.s(frozen=True) 263 | class _CPU(object): 264 | # in millicpus 265 | amount = attr.ib(validator=validators.instance_of(int)) 266 | 267 | def render_percentage(self, portion): 268 | return "{:>5.1f}".format(portion.amount / self.amount * 100) 269 | 270 | 271 | def _node_allocable_memory(pod, nodes): 272 | for node in nodes: 273 | if _pod_on_node(pod, node): 274 | return parse_memory(node["status"]["allocatable"]["memory"]) 275 | return _UnknownMemory() 276 | 277 | 278 | def _render_pods(pods, pod_usage, nodes): 279 | pod_by_name = { 280 | pod.metadata.name: pod 281 | for pod 282 | in pods 283 | } 284 | pod_data = ( 285 | ( 286 | _render_pod( 287 | usage, 288 | _node_allocable_memory( 289 | pod_by_name[usage["metadata"]["name"]], 290 | nodes, 291 | ), 292 | ), 293 | _render_containers(usage["containers"]), 294 | ) 295 | for usage 296 | in sorted(pod_usage, key=_pod_stats, reverse=True) 297 | ) 298 | return "".join( 299 | rendered_pod + rendered_containers 300 | for (rendered_pod, rendered_containers) 301 | in pod_data 302 | ) 303 | 304 | 305 | def _pod_stats(pod): 306 | cpu = sum( 307 | map( 308 | parse_cpu, ( 309 | container["usage"]["cpu"] 310 | for container 311 | in pod["containers"] 312 | ), 313 | ), 0, 314 | ) 315 | mem = sum( 316 | map( 317 | lambda s: parse_memory(s).amount, ( 318 | container["usage"]["memory"] 319 | for container 320 | in pod["containers"] 321 | ), 322 | ), Byte(0), 323 | ) 324 | return (_CPU(cpu), _Memory(mem)) 325 | 326 | 327 | def _render_limited_width(s, w): 328 | if w < 3: 329 | raise ValueError("Minimum rendering width is 3") 330 | if len(s) <= w: 331 | return s 332 | return ( 333 | s[:int(round(w / 2 - 1))] + 334 | "\N{HORIZONTAL ELLIPSIS}" + 335 | s[-int(w / 2):] 336 | ) 337 | 338 | 339 | def _render_pod(pod, node_allocable_memory): 340 | cpu, mem = _pod_stats(pod) 341 | mem_percent = node_allocable_memory.render_percentage(mem) 342 | return _render_row( 343 | # Limit rendered name to combined width of the pod and container 344 | # columns. 345 | _render_limited_width(pod["metadata"]["name"], 46), 346 | "", 347 | _CPU(1000).render_percentage(cpu), 348 | mem.render("8.2"), 349 | mem_percent, 350 | ) 351 | 352 | 353 | def _render_containers(containers): 354 | return "".join(( 355 | _render_container(container) 356 | for container 357 | in sorted(containers, key=lambda c: -parse_cpu(c["usage"]["cpu"])) 358 | )) 359 | 360 | 361 | def _render_container(container): 362 | return _render_row( 363 | "", 364 | _render_limited_width("(" + container["name"] + ")", 46), 365 | _CPU(1000).render_percentage(_CPU(parse_cpu(container["usage"]["cpu"]))), 366 | parse_memory(container["usage"]["memory"]).render("8.2"), 367 | "", 368 | ) 369 | 370 | 371 | def partition(seq, pred): 372 | return ( 373 | u"".join(x for x in seq if pred(x)), 374 | u"".join(x for x in seq if not pred(x)), 375 | ) 376 | 377 | 378 | def parse_cpu(s): 379 | return parse_k8s_resource(s, default_scale=1000) 380 | 381 | 382 | def parse_memory(s): 383 | return _Memory(Byte(parse_k8s_resource(s, default_scale=1))) 384 | 385 | 386 | def parse_k8s_resource(s, default_scale): 387 | amount, suffix = partition(s, unicode.isdigit) 388 | try: 389 | scale = suffix_scale(suffix) 390 | except KeyError: 391 | scale = default_scale 392 | return int(amount) * scale 393 | 394 | 395 | def suffix_scale(suffix): 396 | return { 397 | "m": 1, 398 | "K": 2 ** 10, 399 | "Ki": 2 ** 10, 400 | "M": 2 ** 20, 401 | "Mi": 2 ** 20, 402 | "G": 2 ** 30, 403 | "Gi": 2 ** 30, 404 | "T": 2 ** 40, 405 | "Ti": 2 ** 40, 406 | "P": 2 ** 50, 407 | "Pi": 2 ** 50, 408 | "E": 2 ** 60, 409 | "Ei": 2 ** 60, 410 | }[suffix] 411 | -------------------------------------------------------------------------------- /src/kubetop/_topdata.py: -------------------------------------------------------------------------------- 1 | # Copyright Least Authority Enterprises. 2 | # See LICENSE for details. 3 | 4 | """ 5 | Retrieve resource usage data by Kubernetes Objects. 6 | 7 | Theory of Operation 8 | =================== 9 | 10 | #. Combine Kubernetes API server location with a resource collection object. 11 | #. Collect resource usage information via the Heapster service on the 12 | Kubernetes API server. 13 | """ 14 | 15 | from __future__ import unicode_literals 16 | 17 | from twisted.internet.defer import gatherResults 18 | 19 | import attr 20 | import attr.validators 21 | 22 | from treq import json_content 23 | from treq.client import HTTPClient 24 | 25 | from txkube import IKubernetes, network_kubernetes_from_context 26 | 27 | 28 | def make_source(reactor, config_path, context_name): 29 | """ 30 | Get a source of Kubernetes resource usage data. 31 | """ 32 | kubernetes = network_kubernetes_from_context( 33 | reactor, context_name, config_path 34 | ) 35 | return _Source(kubernetes=kubernetes) 36 | 37 | 38 | @attr.s(frozen=True) 39 | class _Source(object): 40 | kubernetes = attr.ib(validator=attr.validators.provides(IKubernetes)) 41 | 42 | def pods(self): 43 | base_url = self.kubernetes.base_url 44 | d = self._client() 45 | 46 | def _pods(client): 47 | return gatherResults([ 48 | self._pod_usage_from_client(client, base_url), 49 | self.kubernetes.versioned_client().addCallback(self._pod_info), 50 | ]).addCallback( 51 | lambda usage_info: { 52 | "usage": usage_info[0], 53 | "info": usage_info[1], 54 | }, 55 | ) 56 | d.addCallback(_pods) 57 | return d 58 | 59 | def nodes(self): 60 | base_url = self.kubernetes.base_url 61 | d = self._client() 62 | 63 | def _nodes(client): 64 | return gatherResults([ 65 | self._node_usage_from_client(client, base_url), 66 | self._node_info_from_client(client, base_url), 67 | ]).addCallback( 68 | lambda usage_info: { 69 | "usage": usage_info[0], 70 | "info": usage_info[1], 71 | }, 72 | ) 73 | d.addCallback(_nodes) 74 | return d 75 | 76 | def _client(self): 77 | d = self.kubernetes.versioned_client() 78 | d.addCallback(lambda client: HTTPClient(agent=client.agent)) 79 | return d 80 | 81 | def _pod_usage_from_client(self, client, base_url): 82 | d = self.kubernetes.versioned_client() 83 | d.addCallback(lambda client: client.list(client.model.v1.Namespace)) 84 | 85 | def got_namespaces(namespaces): 86 | d = gatherResults( 87 | client.get( 88 | base_url.asText() + self.pod_location(ns.metadata.name) 89 | ).addCallback( 90 | json_content 91 | ) 92 | for ns 93 | in namespaces.items 94 | ) 95 | 96 | def combine(pod_usages): 97 | result = [] 98 | for usage in pod_usages: 99 | if usage["items"] is None: 100 | continue 101 | for item in usage["items"]: 102 | result.append(item) 103 | return {"items": result} 104 | d.addCallback(combine) 105 | return d 106 | d.addCallback(got_namespaces) 107 | return d 108 | 109 | def _pod_info(self, client): 110 | return client.list(client.model.v1.Pod) 111 | 112 | def pod_location(self, namespace): 113 | # kubectl --v=11 top pods 114 | return ( 115 | "/api/v1/namespaces/kube-system/services/http:heapster:" 116 | "/proxy/apis/metrics/v1alpha1/namespaces/{namespace}/pods?" 117 | "labelSelector=" 118 | ).format(namespace=namespace) 119 | 120 | def _node_usage_from_client(self, client, base_url): 121 | d = client.get(base_url.asText() + self.node_location()) 122 | d.addCallback(json_content) 123 | return d 124 | 125 | def node_location(self): 126 | # url-hacked from pod_location... I found no docs that clearly explain 127 | # what is going on here. 128 | # https://github.com/kubernetes/heapster/blob/master/docs/model.md 129 | # provides some hints but it's documenting the actual Heapster API 130 | # which requires port-forwarding into the Heapster pod to access. 131 | return ( 132 | "/api/v1/namespaces/kube-system/services/http:heapster:" 133 | "/proxy/apis/metrics/v1alpha1/nodes" 134 | ) 135 | 136 | def _node_info_from_client(self, client, base_url): 137 | d = client.get(base_url.asText() + "/api/v1/nodes") 138 | d.addCallback(json_content) 139 | return d 140 | -------------------------------------------------------------------------------- /src/kubetop/_twistmain.py: -------------------------------------------------------------------------------- 1 | # Copyright Least Authority Enterprises. 2 | # See LICENSE for details. 3 | 4 | """ 5 | Adapter from IServiceMaker-like interface to setuptools console-entrypoint 6 | interface. 7 | 8 | Premise 9 | ======= 10 | 11 | Given: 12 | 13 | * twist is the focus of efforts to make a good client-oriented command-line 14 | driver for Twisted-based applications. 15 | * kubetop is a client-y, command-line, Twisted-based application. 16 | * Accounting for custom scripts in setup.py with setuptools is a lot harder 17 | than just using the ``console_script`` feature. 18 | 19 | Therefore: 20 | 21 | * Implement application code to the twist interface. 22 | * Build a single utility for adapting that interface to the ``console_script`` 23 | interface. 24 | 25 | Theory of Operation 26 | =================== 27 | 28 | #. Applications provide ``Options`` and ``makeService``, the main pieces of 29 | ``IServiceMaker``. 30 | #. We provide an object which can be called as a ``console_script`` 31 | entrypoint. 32 | 33 | #. That object hooks ``Options`` and ``makeService`` up to the internals of 34 | ``twist`` (which are *totally* private, sigh). 35 | """ 36 | 37 | from sys import stdout, argv 38 | from os.path import expanduser 39 | 40 | import attr 41 | 42 | from twisted.application.twist import _options 43 | from twisted.application.twist._twist import Twist 44 | 45 | @attr.s(frozen=True) 46 | class MainService(object): 47 | tapname = "kubetop" 48 | description = "kubetop" 49 | options = attr.ib() 50 | makeService = attr.ib() 51 | 52 | 53 | @attr.s 54 | class TwistMain(object): 55 | options = attr.ib() 56 | make_service = attr.ib() 57 | 58 | exit_status = 0 59 | exit_message = None 60 | 61 | def exit(self, reason=None): 62 | if reason is not None: 63 | self.exit_status = 1 64 | self.exit_message = reason.getTraceback() 65 | from twisted.internet import reactor 66 | reactor.stop() 67 | 68 | 69 | def __call__(self): 70 | _options.getPlugins = lambda iface: [ 71 | MainService(self.options, self._make_service), 72 | ] 73 | 74 | t = Twist() 75 | 76 | log_flag = u"--log-file" 77 | log_file = u"~/.kubetop.log" 78 | app_name = u"kubetop" 79 | if str is bytes: 80 | # sys.argv must be bytes Python 2 81 | log_flag = log_flag.encode("ascii") 82 | log_file = log_file.encode("ascii") 83 | app_name = app_name.encode("ascii") 84 | 85 | t.main([ 86 | argv[0], 87 | log_flag, expanduser(log_file), 88 | app_name, 89 | ] + argv[1:]) 90 | 91 | if self.exit_message: 92 | stdout.write(self.exit_message) 93 | raise SystemExit(self.exit_status) 94 | 95 | 96 | def _make_service(self, options): 97 | return self.make_service(self, options) 98 | -------------------------------------------------------------------------------- /src/kubetop/test/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Least Authority Enterprises. 2 | # See LICENSE for details. 3 | -------------------------------------------------------------------------------- /src/kubetop/test/test_script.py: -------------------------------------------------------------------------------- 1 | # Copyright Least Authority Enterprises. 2 | # See LICENSE for details. 3 | 4 | """ 5 | Tests for ``kubetop._script``. 6 | """ 7 | -------------------------------------------------------------------------------- /src/kubetop/test/test_textrenderer.py: -------------------------------------------------------------------------------- 1 | # Copyright Least Authority Enterprises. 2 | # See LICENSE for details. 3 | 4 | """ 5 | Tests for ``_textrenderer``. 6 | """ 7 | 8 | from __future__ import unicode_literals 9 | 10 | from io import StringIO as TextIO 11 | 12 | from hypothesis import given 13 | from hypothesis.strategies import integers, text 14 | 15 | from twisted.trial.unittest import TestCase 16 | 17 | from bitmath import Byte 18 | 19 | import attr 20 | 21 | from .._textrenderer import ( 22 | _render_container, _render_containers, _render_pods, 23 | _render_pod, _render_nodes, 24 | _render_limited_width, 25 | _Memory, 26 | Size, Sink, 27 | ) 28 | 29 | from txkube import v1 30 | 31 | 32 | class RenderLimitedWidthTests(TestCase): 33 | @given(integers(min_value=3), text()) 34 | def test_width(self, limit, long_text): 35 | rendered = _render_limited_width(long_text, limit) 36 | self.assertEqual(min(limit, len(long_text)), len(rendered)) 37 | 38 | 39 | @given(integers(max_value=2), text()) 40 | def test_too_narrow(self, limit, long_text): 41 | with self.assertRaises(ValueError): 42 | _render_limited_width(long_text, limit) 43 | 44 | 45 | class RenderMemoryTests(TestCase): 46 | def test_bytes(self): 47 | self.assertEqual( 48 | " 123.00 Byte", 49 | _Memory(Byte(123)).render("8.2"), 50 | ) 51 | 52 | 53 | def test_kibibytes(self): 54 | self.assertEqual( 55 | " 12.50 KiB", 56 | _Memory(Byte(1024 * 12 + 512)).render("8.2"), 57 | ) 58 | 59 | 60 | def test_mebibytes(self): 61 | self.assertEqual( 62 | " 123.25 MiB", 63 | _Memory(Byte(2 ** 20 * 123 + 2 ** 20 / 4)).render("8.2"), 64 | ) 65 | 66 | 67 | def test_gibibytes(self): 68 | self.assertEqual( 69 | " 1.05 GiB", 70 | _Memory(Byte(2 ** 30 + 2 ** 30 / 20)).render("8.2"), 71 | ) 72 | 73 | 74 | def test_tebibytes(self): 75 | self.assertEqual( 76 | " 100.00 TiB", 77 | _Memory(Byte(2 ** 40 * 100)).render("8.2"), 78 | ) 79 | 80 | 81 | def test_pebibytes(self): 82 | self.assertEqual( 83 | " 100.00 PiB", 84 | _Memory(Byte(2 ** 50 * 100)).render("8.2"), 85 | ) 86 | 87 | 88 | def test_exbibytes(self): 89 | self.assertEqual( 90 | " 100.00 EiB", 91 | _Memory(Byte(2 ** 60 * 100)).render("8.2"), 92 | ) 93 | 94 | 95 | 96 | class ContainersTests(TestCase): 97 | def test_render_one(self): 98 | container = { 99 | "name": "foo", 100 | "usage": { 101 | "cpu": "100m", 102 | "memory": "200Mi", 103 | }, 104 | } 105 | self.assertEqual( 106 | " " 107 | " (foo)" 108 | " 10.0" 109 | " 200.00 MiB" 110 | " " 111 | "\n", 112 | _render_container(container), 113 | ) 114 | 115 | 116 | def test_render_several(self): 117 | containers = [ 118 | { 119 | "name": "foo", 120 | "usage": { 121 | "cpu": "100m", 122 | "memory": "200Mi", 123 | }, 124 | }, 125 | { 126 | "name": "bar", 127 | "usage": { 128 | "cpu": "200m", 129 | "memory": "100Mi", 130 | }, 131 | }, 132 | ] 133 | lines = _render_containers(containers).splitlines() 134 | self.assertEqual( 135 | ["(bar)", "(foo)"], 136 | list(line.split()[0].strip() for line in lines), 137 | ) 138 | 139 | 140 | 141 | class PodTests(TestCase): 142 | def test_render_several(self): 143 | name = "alpha" 144 | 145 | nodes = [ 146 | v1.Node( 147 | metadata=v1.ObjectMeta( 148 | name=name, 149 | ), 150 | status=v1.NodeStatus( 151 | allocatable={ 152 | "memory": "100MiB", 153 | }, 154 | ), 155 | ), 156 | ] 157 | 158 | pods = [ 159 | v1.Pod( 160 | metadata=v1.ObjectMeta( 161 | name="foo", 162 | ), 163 | ), 164 | v1.Pod( 165 | metadata=v1.ObjectMeta( 166 | name="bar", 167 | ), 168 | ), 169 | ] 170 | 171 | pod_usage = [ 172 | { 173 | "metadata": { 174 | "name": "foo", 175 | "namespace": "default", 176 | "creationTimestamp": "2017-04-07T15:21:22Z" 177 | }, 178 | "timestamp": "2017-04-07T15:21:00Z", 179 | "window": "1m0s", 180 | "containers": [ 181 | { 182 | "name": "foo-a", 183 | "usage": { 184 | "cpu": "100m", 185 | "memory": "50Ki" 186 | } 187 | }, 188 | { 189 | "name": "foo-b", 190 | "usage": { 191 | "cpu": "200m", 192 | "memory": "150Ki" 193 | } 194 | } 195 | ] 196 | }, 197 | { 198 | "metadata": { 199 | "name": "bar", 200 | "namespace": "default", 201 | "creationTimestamp": "2017-04-07T15:21:22Z" 202 | }, 203 | "timestamp": "2017-04-07T15:21:00Z", 204 | "window": "1m0s", 205 | "containers": [ 206 | { 207 | "name": "bar-a", 208 | "usage": { 209 | "cpu": "100m", 210 | "memory": "50Ki" 211 | } 212 | }, 213 | { 214 | "name": "bar-b", 215 | "usage": { 216 | "cpu": "500m", 217 | "memory": "10Ki" 218 | } 219 | } 220 | ] 221 | }, 222 | ] 223 | lines = list( 224 | line 225 | for line 226 | in _render_pods(pods, pod_usage, nodes).splitlines() 227 | if not line.strip().startswith("(") 228 | ) 229 | self.assertEqual( 230 | ["bar", "foo"], 231 | list(line.split()[0].strip() for line in lines), 232 | ) 233 | 234 | def test_render_pod(self): 235 | pod_usage = { 236 | "metadata": { 237 | "name": "foo", 238 | "namespace": "default", 239 | "creationTimestamp": "2017-04-07T15:21:22Z" 240 | }, 241 | "timestamp": "2017-04-07T15:21:00Z", 242 | "window": "1m0s", 243 | "containers": [ 244 | { 245 | "name": "foo-a", 246 | "usage": { 247 | "cpu": "100m", 248 | "memory": "128Ki" 249 | } 250 | }, 251 | ] 252 | } 253 | fields = _render_pod(pod_usage, _Memory(Byte(1024 * 1024))).split() 254 | self.assertEqual( 255 | [u'foo', u'10.0', u'128.00', u'KiB', u'12.50'], 256 | fields, 257 | ) 258 | 259 | 260 | class NodeTests(TestCase): 261 | def test_render_several(self): 262 | node = { 263 | "kind":"Node", 264 | "apiVersion":"v1", 265 | "metadata":{ 266 | "name":"ip-172-20-86-0.ec2.internal", 267 | "selfLink":"/api/v1/nodesip-172-20-86-0.ec2.internal", 268 | "uid":"e35442ce-0e45-11e7-acfa-1272db66581c", 269 | "resourceVersion":"17592039", 270 | "creationTimestamp":"2017-03-21T14:51:38Z", 271 | "labels":{ 272 | "beta.kubernetes.io/arch":"amd64", 273 | "beta.kubernetes.io/instance-type":"m3.medium", 274 | "beta.kubernetes.io/os":"linux", 275 | "failure-domain.beta.kubernetes.io/region":"us-east-1", 276 | "failure-domain.beta.kubernetes.io/zone":"us-east-1b", 277 | "kubernetes.io/hostname":"ip-172-20-86-0.ec2.internal", 278 | }, 279 | "annotations":{ 280 | "volumes.kubernetes.io/controller-managed-attach-detach":"true", 281 | }, 282 | }, 283 | "spec":{ 284 | "podCIDR":"100.96.1.0/24", 285 | "externalID":"i-0dd2f62f32659dcb2", 286 | "providerID":"aws:///us-east-1b/i-0dd2f62f32659dcb2", 287 | }, 288 | "status":{ 289 | "capacity":{ 290 | "alpha.kubernetes.io/nvidia-gpu":"0", 291 | "cpu":"1", 292 | "memory":"3857324Ki", 293 | "pods":"110", 294 | }, 295 | "allocatable":{ 296 | "alpha.kubernetes.io/nvidia-gpu":"0", 297 | "cpu":"1", 298 | "memory":"200Ki", 299 | "pods":"110", 300 | }, 301 | "conditions":[ 302 | { 303 | "type":"OutOfDisk", 304 | "status":"False", 305 | "lastHeartbeatTime":"2017-04-07T22:56:28Z", 306 | "lastTransitionTime":"2017-03-21T14:51:38Z", 307 | "reason":"KubeletHasSufficientDisk", 308 | "message":"kubelet has sufficient disk space available", 309 | }, 310 | { 311 | "type":"MemoryPressure", 312 | "status":"False", 313 | "lastHeartbeatTime":"2017-04-07T22:56:28Z", 314 | "lastTransitionTime":"2017-03-21T14:51:38Z", 315 | "reason":"KubeletHasSufficientMemory", 316 | "message":"kubelet has sufficient memory available", 317 | }, 318 | { 319 | "type":"DiskPressure", 320 | "status":"False", 321 | "lastHeartbeatTime":"2017-04-07T22:56:28Z", 322 | "lastTransitionTime":"2017-03-21T14:51:38Z", 323 | "reason":"KubeletHasNoDiskPressure", 324 | "message":"kubelet has no disk pressure", 325 | }, 326 | { 327 | "type":"Ready", 328 | "status":"True", 329 | "lastHeartbeatTime":"2017-04-07T22:56:28Z", 330 | "lastTransitionTime":"2017-03-21T14:52:08Z", 331 | "reason":"KubeletReady", 332 | "message":"kubelet is posting ready status", 333 | }, 334 | { 335 | "type":"NetworkUnavailable", 336 | "status":"False", 337 | "lastHeartbeatTime":"2017-03-21T14:51:43Z", 338 | "lastTransitionTime":"2017-03-21T14:51:43Z", 339 | "reason":"RouteCreated", 340 | "message":"RouteController created a route", 341 | }, 342 | ], 343 | "addresses":[ 344 | {"type":"InternalIP","address":"172.20.86.0"}, 345 | {"type":"LegacyHostIP","address":"172.20.86.0"}, 346 | {"type":"ExternalIP","address":"54.82.248.124"}, 347 | {"type":"Hostname","address":"ip-172-20-86-0.ec2.internal"}, 348 | ], 349 | "daemonEndpoints":{ 350 | "kubeletEndpoint":{"Port":10250}, 351 | }, 352 | "nodeInfo":{ 353 | "machineID":"1af1476dd91d40c0952ff71f54297123", 354 | "systemUUID":"EC28F27B-6B13-F91A-79BF-DFC0EA0B0BBD", 355 | "bootID":"a4d005b1-3117-4989-8a56-37d831d66d9d", 356 | "kernelVersion":"4.4.26-k8s", 357 | "osImage":"Debian GNU/Linux 8 (jessie)", 358 | "containerRuntimeVersion":"docker://1.12.3", 359 | "kubeletVersion":"v1.5.2", 360 | "kubeProxyVersion":"v1.5.2", 361 | "operatingSystem":"linux", 362 | "architecture":"amd64", 363 | }, 364 | "images":[ 365 | { 366 | "names":[ 367 | "example.invalid/foo@sha256:1685a4543dc70cb29e5a9df4b47a09ed7d6e54c00fb50296afff65683c67e0ff", 368 | "example.invalid/foo:1941d38", 369 | ], 370 | "sizeBytes":752076313, 371 | }, 372 | ], 373 | "volumesInUse":[ 374 | "kubernetes.io/aws-ebs/vol-01b01d11a6b17e2de", 375 | "kubernetes.io/aws-ebs/aws://us-east-1b/vol-0b977509f3c44d901", 376 | "kubernetes.io/aws-ebs/aws://us-east-1b/vol-091a8106ddd94357b", 377 | "kubernetes.io/aws-ebs/vol-0e80ac26be3edd63f", 378 | ], 379 | "volumesAttached":[ 380 | {"name":"kubernetes.io/aws-ebs/vol-01b01d11a6b17e2de","devicePath":"/dev/xvdbc"}, 381 | {"name":"kubernetes.io/aws-ebs/aws://us-east-1b/vol-091a8106ddd94357b","devicePath":"/dev/xvdba"}, 382 | {"name":"kubernetes.io/aws-ebs/vol-0e80ac26be3edd63f","devicePath":"/dev/xvdbb"}, 383 | {"name":"kubernetes.io/aws-ebs/aws://us-east-1b/vol-0b977509f3c44d901","devicePath":"/dev/xvdbd"}, 384 | ], 385 | }, 386 | } 387 | 388 | usage = { 389 | "metadata": { 390 | "name": "ip-172-20-86-0.ec2.internal", 391 | "creationTimestamp": "2017-04-11T14:41:47Z" 392 | }, 393 | "timestamp": "2017-04-11T14:41:00Z", 394 | "window": "1m0s", 395 | "usage": { 396 | "cpu": "68m", 397 | "memory": "100Ki" 398 | } 399 | } 400 | 401 | pods = [ 402 | v1.Pod( 403 | status=v1.PodStatus( 404 | hostIP=node["status"]["addresses"][0]["address"], 405 | ), 406 | ), 407 | ] 408 | 409 | self.assertEqual( 410 | "Node 0 " 411 | "CPU% 6.80 " 412 | "MEM% 50.00 ( 100 KiB/ 200 KiB) " 413 | "POD% 0.91 ( 1/110) " 414 | "Ready\n", 415 | _render_nodes([node], [usage], pods), 416 | ) 417 | 418 | 419 | 420 | @attr.s 421 | class StubTerminal(object): 422 | _size = attr.ib() 423 | 424 | def size(self): 425 | return self._size 426 | 427 | 428 | 429 | class SinkTests(TestCase): 430 | def test_maximum_rows(self): 431 | """ 432 | Any single string passed to ``Sink.write`` gets limited to the number of 433 | rows available on the sink. 434 | """ 435 | size = Size(rows=3, columns=80, xpixels=3, ypixels=7) 436 | outfile = TextIO() 437 | sink = Sink( 438 | terminal=StubTerminal(size=size), outfile=outfile, 439 | ) 440 | lines = list( 441 | "Hello {}\n".format(n) 442 | for n 443 | in range(size.rows + 1) 444 | ) 445 | sink.write("".join(lines)) 446 | self.assertEqual( 447 | lines[:size.rows], 448 | list( 449 | line + "\n" for line in outfile.getvalue().splitlines() 450 | ), 451 | ) 452 | -------------------------------------------------------------------------------- /src/kubetop/test/test_topdata.py: -------------------------------------------------------------------------------- 1 | stuff = { 2 | "metadata": {}, 3 | "items": [ 4 | { 5 | "metadata": { 6 | "name": "image-building-3987116516-g6s93", 7 | "namespace": "default", 8 | "creationTimestamp": "2017-04-07T15:21:22Z" 9 | }, 10 | "timestamp": "2017-04-07T15:21:00Z", 11 | "window": "1m0s", 12 | "containers": [ 13 | { 14 | "name": "image-building", 15 | "usage": { 16 | "cpu": "0", 17 | "memory": "13656Ki" 18 | } 19 | } 20 | ] 21 | }, 22 | ], 23 | } 24 | --------------------------------------------------------------------------------