From b190fc928d18646d7c2f32e71be105f6d3dfcc73 Mon Sep 17 00:00:00 2001 From: Urvashi Mohnani Date: Mon, 19 Feb 2024 10:11:24 -0500 Subject: [PATCH 1/2] Use new json connections file Podman has updated where it will store its system connection information to a new json format file. Add support to podman-py to read from both the new json file and old toml file giving preference to the new json file. Signed-off-by: Urvashi Mohnani --- podman/domain/config.py | 68 +++++++++++++++--- podman/tests/unit/test_config.py | 120 +++++++++++++++++++++++++++++-- 2 files changed, 176 insertions(+), 12 deletions(-) diff --git a/podman/domain/config.py b/podman/domain/config.py index 173f3724..631f4f57 100644 --- a/podman/domain/config.py +++ b/podman/domain/config.py @@ -4,6 +4,7 @@ import urllib from pathlib import Path from typing import Dict, Optional +import json import xdg.BaseDirectory @@ -48,12 +49,16 @@ def id(self): # pylint: disable=invalid-name @cached_property def url(self): """urllib.parse.ParseResult: Returns URL for service connection.""" - return urllib.parse.urlparse(self.attrs.get("uri")) + if self.attrs.get("uri"): + return urllib.parse.urlparse(self.attrs.get("uri")) + return urllib.parse.urlparse(self.attrs.get("URI")) @cached_property def identity(self): """Path: Returns Path to identity file for service connection.""" - return Path(self.attrs.get("identity")) + if self.attrs.get("identity"): + return Path(self.attrs.get("identity")) + return Path(self.attrs.get("Identity")) class PodmanConfig: @@ -62,17 +67,45 @@ class PodmanConfig: def __init__(self, path: Optional[str] = None): """Read Podman configuration from users XDG_CONFIG_HOME.""" + self.is_default = False if path is None: home = Path(xdg.BaseDirectory.xdg_config_home) - self.path = home / "containers" / "containers.conf" + self.path = home / "containers" / "podman-connections.json" + old_toml_file = home / "containers" / "containers.conf" + self.is_default = True + # this elif is only for testing purposes + elif "@@is_test@@" in path: + test_path = path.replace("@@is_test@@", '') + self.path = Path(test_path) / "podman-connections.json" + old_toml_file = Path(test_path) / "containers.conf" + self.is_default = True else: self.path = Path(path) self.attrs = {} if self.path.exists(): - with self.path.open(encoding='utf-8') as file: + try: + with open(self.path, encoding='utf-8') as file: + self.attrs = json.load(file) + except: # pylint: disable=bare-except + # if the user specifies a path, it can either be a JSON file + # or a TOML file - so try TOML next + try: + with self.path.open(encoding='utf-8') as file: + buffer = file.read() + loaded_toml = toml_loads(buffer) + self.attrs.update(loaded_toml) + except Exception as e: + raise AttributeError( + "The path given is neither a JSON nor a TOML connections file" + ) from e + + # Read the old toml file configuration + if self.is_default and old_toml_file.exists(): + with old_toml_file.open(encoding='utf-8') as file: buffer = file.read() - self.attrs = toml_loads(buffer) + loaded_toml = toml_loads(buffer) + self.attrs.update(loaded_toml) def __hash__(self) -> int: return hash(tuple(self.path.name)) @@ -98,6 +131,7 @@ def services(self): """ services: Dict[str, ServiceConnection] = {} + # read the keys of the toml file first engine = self.attrs.get("engine") if engine: destinations = engine.get("service_destinations") @@ -105,17 +139,35 @@ def services(self): connection = ServiceConnection(key, attrs=destinations[key]) services[key] = connection + # read the keys of the json file next + # this will ensure that if the new json file and the old toml file + # has a connection with the same name defined, we always pick the + # json one + connection = self.attrs.get("Connection") + if connection: + destinations = connection.get("Connections") + for key in destinations: + connection = ServiceConnection(key, attrs=destinations[key]) + services[key] = connection + return services @cached_property def active_service(self): """Optional[ServiceConnection]: Returns active connection.""" + # read the new json file format + connection = self.attrs.get("Connection") + if connection: + active = connection.get("Default") + destinations = connection.get("Connections") + return ServiceConnection(active, attrs=destinations[active]) + + # if we are here, that means there was no default in the new json file engine = self.attrs.get("engine") if engine: active = engine.get("active_service") destinations = engine.get("service_destinations") - for key in destinations: - if key == active: - return ServiceConnection(key, attrs=destinations[key]) + return ServiceConnection(active, attrs=destinations[active]) + return None diff --git a/podman/tests/unit/test_config.py b/podman/tests/unit/test_config.py index 7ecb475a..e2d61d7e 100644 --- a/podman/tests/unit/test_config.py +++ b/podman/tests/unit/test_config.py @@ -1,13 +1,85 @@ import unittest import urllib.parse +import json +import os +import tempfile from pathlib import Path from unittest import mock from unittest.mock import MagicMock - from podman.domain.config import PodmanConfig -class PodmanConfigTestCase(unittest.TestCase): +class PodmanConfigTestCaseDefault(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + + # Data to be written to the JSON file + self.data_json = """ +{ + "Connection": { + "Default": "testing_json", + "Connections": { + "testing_json": { + "URI": "ssh://qe@localhost:2222/run/podman/podman.sock", + "Identity": "/home/qe/.ssh/id_rsa" + }, + "production": { + "URI": "ssh://root@localhost:22/run/podman/podman.sock", + "Identity": "/home/root/.ssh/id_rsajson" + } + } + }, + "Farm": {} +} +""" + + # Data to be written to the TOML file + self.data_toml = """ +[containers] + log_size_max = -1 + pids_limit = 2048 + userns_size = 65536 + +[engine] + num_locks = 2048 + active_service = "testing" + stop_timeout = 10 + [engine.service_destinations] + [engine.service_destinations.production] + uri = "ssh://root@localhost:22/run/podman/podman.sock" + identity = "/home/root/.ssh/id_rsa" + [engine.service_destinations.testing] + uri = "ssh://qe@localhost:2222/run/podman/podman.sock" + identity = "/home/qe/.ssh/id_rsa" + +[network] +""" + + # Define the file path + self.path_json = os.path.join(self.temp_dir, 'podman-connections.json') + self.path_toml = os.path.join(self.temp_dir, 'containers.conf') + + # Write data to the JSON file + j_data = json.loads(self.data_json) + with open(self.path_json, 'w+') as file_json: + json.dump(j_data, file_json) + + # Write data to the TOML file + with open(self.path_toml, 'w+') as file_toml: + # toml.dump(self.data_toml, file_toml) + file_toml.write(self.data_toml) + + def test_connections(self): + config = PodmanConfig("@@is_test@@" + self.temp_dir) + + self.assertEqual(config.active_service.id, "testing_json") + + expected = urllib.parse.urlparse("ssh://qe@localhost:2222/run/podman/podman.sock") + self.assertEqual(config.active_service.url, expected) + self.assertEqual(config.services["production"].identity, Path("/home/root/.ssh/id_rsajson")) + + +class PodmanConfigTestCaseTOML(unittest.TestCase): opener = mock.mock_open( read_data=""" [containers] @@ -35,7 +107,7 @@ def setUp(self) -> None: super().setUp() def mocked_open(self, *args, **kwargs): - return PodmanConfigTestCase.opener(self, *args, **kwargs) + return PodmanConfigTestCaseTOML.opener(self, *args, **kwargs) self.mocked_open = mocked_open @@ -49,10 +121,50 @@ def test_connections(self): self.assertEqual(config.active_service.url, expected) self.assertEqual(config.services["production"].identity, Path("/home/root/.ssh/id_rsa")) - PodmanConfigTestCase.opener.assert_called_with( + PodmanConfigTestCaseTOML.opener.assert_called_with( Path("/home/developer/containers.conf"), encoding='utf-8' ) +class PodmanConfigTestCaseJSON(unittest.TestCase): + def setUp(self) -> None: + super().setUp() + + self.temp_dir = tempfile.mkdtemp() + self.data = """ +{ + "Connection": { + "Default": "testing", + "Connections": { + "testing": { + "URI": "ssh://qe@localhost:2222/run/podman/podman.sock", + "Identity": "/home/qe/.ssh/id_rsa" + }, + "production": { + "URI": "ssh://root@localhost:22/run/podman/podman.sock", + "Identity": "/home/root/.ssh/id_rsa" + } + } + }, + "Farm": {} +} +""" + + self.path = os.path.join(self.temp_dir, 'podman-connections.json') + # Write data to the JSON file + data = json.loads(self.data) + with open(self.path, 'w+') as file: + json.dump(data, file) + + def test_connections(self): + config = PodmanConfig(self.path) + + self.assertEqual(config.active_service.id, "testing") + + expected = urllib.parse.urlparse("ssh://qe@localhost:2222/run/podman/podman.sock") + self.assertEqual(config.active_service.url, expected) + self.assertEqual(config.services["production"].identity, Path("/home/root/.ssh/id_rsa")) + + if __name__ == '__main__': unittest.main() From c3413735e87e33364b1ca3d140743b2617504095 Mon Sep 17 00:00:00 2001 From: Urvashi Mohnani Date: Mon, 19 Feb 2024 11:22:13 -0500 Subject: [PATCH 2/2] Fix black format issues Signed-off-by: Urvashi Mohnani --- podman/domain/containers_create.py | 42 ++++++++------- podman/domain/images_manager.py | 10 ++-- podman/domain/ipam.py | 26 +++++---- .../integration/test_container_create.py | 22 ++++---- podman/tests/integration/utils.py | 14 ++--- podman/tests/unit/test_container.py | 54 ++++++++++--------- podman/tests/unit/test_events.py | 30 ++++++----- podman/tests/unit/test_image.py | 18 ++++--- podman/tests/unit/test_imagesmanager.py | 20 ++++--- podman/tests/unit/test_network.py | 18 ++++--- podman/tests/unit/test_networksmanager.py | 36 +++++++------ podman/tests/unit/test_podsmanager.py | 52 +++++++++--------- 12 files changed, 191 insertions(+), 151 deletions(-) diff --git a/podman/domain/containers_create.py b/podman/domain/containers_create.py index 029a6873..7a6a536b 100644 --- a/podman/domain/containers_create.py +++ b/podman/domain/containers_create.py @@ -385,20 +385,22 @@ def _render_payload(kwargs: MutableMapping[str, Any]) -> Dict[str, Any]: del args[key] # These keywords are not supported for various reasons. - unsupported_keys = set(args.keys()).intersection(( - "blkio_weight", - "blkio_weight_device", # FIXME In addition to device Major/Minor include path - "device_cgroup_rules", # FIXME Where to map for Podman API? - "device_read_bps", # FIXME In addition to device Major/Minor include path - "device_read_iops", # FIXME In addition to device Major/Minor include path - "device_requests", # FIXME In addition to device Major/Minor include path - "device_write_bps", # FIXME In addition to device Major/Minor include path - "device_write_iops", # FIXME In addition to device Major/Minor include path - "domainname", - "network_disabled", # FIXME Where to map for Podman API? - "storage_opt", # FIXME Where to map for Podman API? - "tmpfs", # FIXME Where to map for Podman API? - )) + unsupported_keys = set(args.keys()).intersection( + ( + "blkio_weight", + "blkio_weight_device", # FIXME In addition to device Major/Minor include path + "device_cgroup_rules", # FIXME Where to map for Podman API? + "device_read_bps", # FIXME In addition to device Major/Minor include path + "device_read_iops", # FIXME In addition to device Major/Minor include path + "device_requests", # FIXME In addition to device Major/Minor include path + "device_write_bps", # FIXME In addition to device Major/Minor include path + "device_write_iops", # FIXME In addition to device Major/Minor include path + "domainname", + "network_disabled", # FIXME Where to map for Podman API? + "storage_opt", # FIXME Where to map for Podman API? + "tmpfs", # FIXME Where to map for Podman API? + ) + ) if len(unsupported_keys) > 0: raise TypeError( f"""Keyword(s) '{" ,".join(unsupported_keys)}' are""" @@ -657,11 +659,13 @@ def parse_host_port(_container_port, _protocol, _host): } for item in args.pop("ulimits", []): - params["r_limits"].append({ - "type": item["Name"], - "hard": item["Hard"], - "soft": item["Soft"], - }) + params["r_limits"].append( + { + "type": item["Name"], + "hard": item["Hard"], + "soft": item["Soft"], + } + ) for item in args.pop("volumes", {}).items(): key, value = item diff --git a/podman/domain/images_manager.py b/podman/domain/images_manager.py index 3f94791c..df4e6f39 100644 --- a/podman/domain/images_manager.py +++ b/podman/domain/images_manager.py @@ -163,10 +163,12 @@ def prune( error.append(element["Err"]) else: reclaimed += element["Size"] - deleted.append({ - "Deleted": element["Id"], - "Untagged": "", - }) + deleted.append( + { + "Deleted": element["Id"], + "Untagged": "", + } + ) if len(error) > 0: raise APIError(response.url, response=response, explanation="; ".join(error)) diff --git a/podman/domain/ipam.py b/podman/domain/ipam.py index 79a4b2c5..98a78350 100644 --- a/podman/domain/ipam.py +++ b/podman/domain/ipam.py @@ -25,12 +25,14 @@ def __init__( aux_addresses: Ignored. """ super().__init__() - self.update({ - "AuxiliaryAddresses": aux_addresses, - "Gateway": gateway, - "IPRange": iprange, - "Subnet": subnet, - }) + self.update( + { + "AuxiliaryAddresses": aux_addresses, + "Gateway": gateway, + "IPRange": iprange, + "Subnet": subnet, + } + ) class IPAMConfig(dict): @@ -50,8 +52,10 @@ def __init__( options: Options to provide to the Network driver. """ super().__init__() - self.update({ - "Config": pool_configs or [], - "Driver": driver, - "Options": options or {}, - }) + self.update( + { + "Config": pool_configs or [], + "Driver": driver, + "Options": options or {}, + } + ) diff --git a/podman/tests/integration/test_container_create.py b/podman/tests/integration/test_container_create.py index f05a29ed..fdd98c77 100644 --- a/podman/tests/integration/test_container_create.py +++ b/podman/tests/integration/test_container_create.py @@ -149,10 +149,12 @@ def test_container_ports(self): self.containers.append(container) self.assertTrue( - all([ - x in port_test['expected_output'] - for x in container.attrs.get('HostConfig', {}).get('PortBindings') - ]) + all( + [ + x in port_test['expected_output'] + for x in container.attrs.get('HostConfig', {}).get('PortBindings') + ] + ) ) def test_container_healthchecks(self): @@ -241,11 +243,13 @@ def test_container_devices(self): for device in devices: path_on_host, path_in_container = device.split(':', 1) self.assertTrue( - any([ - c.get('PathOnHost') == path_on_host - and c.get('PathInContainer') == path_in_container - for c in container_devices - ]) + any( + [ + c.get('PathOnHost') == path_on_host + and c.get('PathInContainer') == path_in_container + for c in container_devices + ] + ) ) with self.subTest("Check devices in running container object"): diff --git a/podman/tests/integration/utils.py b/podman/tests/integration/utils.py index 0f1720cd..78aea63a 100644 --- a/podman/tests/integration/utils.py +++ b/podman/tests/integration/utils.py @@ -66,12 +66,14 @@ def __init__( if os.environ.get("container") == "oci": self.cmd.append("--storage-driver=vfs") - self.cmd.extend([ - "system", - "service", - f"--time={timeout}", - socket_uri, - ]) + self.cmd.extend( + [ + "system", + "service", + f"--time={timeout}", + socket_uri, + ] + ) process = subprocess.run( [podman_exe, "--version"], check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT diff --git a/podman/tests/unit/test_container.py b/podman/tests/unit/test_container.py index 4ed04cc0..5d0023e0 100644 --- a/podman/tests/unit/test_container.py +++ b/podman/tests/unit/test_container.py @@ -103,14 +103,18 @@ def test_start(self, mock): @requests_mock.Mocker() def test_stats(self, mock): - stream = [{ - "Error": None, - "Stats": [{ - "ContainerId": "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd", - "Name": "evil_ptolemy", - "CPU": 1000.0, - }], - }] + stream = [ + { + "Error": None, + "Stats": [ + { + "ContainerId": "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd", + "Name": "evil_ptolemy", + "CPU": 1000.0, + } + ], + } + ] buffer = io.StringIO() for entry in stream: buffer.write(json.JSONEncoder().encode(entry)) @@ -395,23 +399,25 @@ def test_top(self, mock): @requests_mock.Mocker() def test_top_with_streaming(self, mock): - stream = [{ - "Processes": [ - [ - 'jhonce', - '2417', - '2274', - '0', - 'Mar01', - '?', - '00:00:01', - '/usr/bin/ssh-agent /bin/sh -c exec -l /bin/bash -c "/usr/bin/gnome-session"', + stream = [ + { + "Processes": [ + [ + 'jhonce', + '2417', + '2274', + '0', + 'Mar01', + '?', + '00:00:01', + '/usr/bin/ssh-agent /bin/sh -c exec -l /bin/bash -c "/usr/bin/gnome-session"', + ], + ['jhonce', '5544', '3522', '0', 'Mar01', 'pts/1', '00:00:02', '-bash'], + ['jhonce', '6140', '3522', '0', 'Mar01', 'pts/2', '00:00:00', '-bash'], ], - ['jhonce', '5544', '3522', '0', 'Mar01', 'pts/1', '00:00:02', '-bash'], - ['jhonce', '6140', '3522', '0', 'Mar01', 'pts/2', '00:00:00', '-bash'], - ], - "Titles": ["UID", "PID", "PPID", "C", "STIME", "TTY", "TIME CMD"], - }] + "Titles": ["UID", "PID", "PPID", "C", "STIME", "TTY", "TIME CMD"], + } + ] buffer = io.StringIO() for entry in stream: diff --git a/podman/tests/unit/test_events.py b/podman/tests/unit/test_events.py index bbf391d8..2ac3a9a7 100644 --- a/podman/tests/unit/test_events.py +++ b/podman/tests/unit/test_events.py @@ -22,21 +22,23 @@ def tearDown(self) -> None: @requests_mock.Mocker() def test_list(self, mock): - stream = [{ - "Type": "pod", - "Action": "create", - "Actor": { - "ID": "", - "Attributes": { - "image": "", - "name": "", - "containerExitCode": 0, + stream = [ + { + "Type": "pod", + "Action": "create", + "Actor": { + "ID": "", + "Attributes": { + "image": "", + "name": "", + "containerExitCode": 0, + }, }, - }, - "Scope": "local", - "Time": 1615845480, - "TimeNano": 1615845480, - }] + "Scope": "local", + "Time": 1615845480, + "TimeNano": 1615845480, + } + ] buffer = io.StringIO() for item in stream: buffer.write(json.JSONEncoder().encode(item)) diff --git a/podman/tests/unit/test_image.py b/podman/tests/unit/test_image.py index f7daaabd..b5107abf 100644 --- a/podman/tests/unit/test_image.py +++ b/podman/tests/unit/test_image.py @@ -51,14 +51,16 @@ def test_history(self, mock): adapter = mock.get( tests.LIBPOD_URL + "/images/326dd9d7add24646a325e8eaa82125294027db2332e49c5828d96312c5d773ab/history", - json=[{ - "Id": "326dd9d7add24646a325e8eaa82125294027db2332e49c5828d96312c5d773ab", - "Comment": "", - "Created": 1614208404, - "CreatedBy": "2021-02-24T23:13:24+00:00", - "Tags": ["latest"], - "Size": 1024, - }], + json=[ + { + "Id": "326dd9d7add24646a325e8eaa82125294027db2332e49c5828d96312c5d773ab", + "Comment": "", + "Created": 1614208404, + "CreatedBy": "2021-02-24T23:13:24+00:00", + "Tags": ["latest"], + "Size": 1024, + } + ], ) image = Image(attrs=FIRST_IMAGE, client=self.client.api) diff --git a/podman/tests/unit/test_imagesmanager.py b/podman/tests/unit/test_imagesmanager.py index 1403af27..6de8910f 100644 --- a/podman/tests/unit/test_imagesmanager.py +++ b/podman/tests/unit/test_imagesmanager.py @@ -156,11 +156,13 @@ def test_prune(self, mock): """Unit test Images prune().""" mock.post( tests.LIBPOD_URL + "/images/prune", - json=[{ - "Id": "326dd9d7add24646a325e8eaa82125294027db2332e49c5828d96312c5d773ab", - "Err": None, - "Size": 1024, - }], + json=[ + { + "Id": "326dd9d7add24646a325e8eaa82125294027db2332e49c5828d96312c5d773ab", + "Err": None, + "Size": 1024, + } + ], ) results = self.client.images.prune() @@ -210,9 +212,11 @@ def test_prune_failure(self, mock): """Unit test to report error carried in response body.""" mock.post( tests.LIBPOD_URL + "/images/prune", - json=[{ - "Err": "Test prune failure in response body.", - }], + json=[ + { + "Err": "Test prune failure in response body.", + } + ], ) with self.assertRaises(APIError) as e: diff --git a/podman/tests/unit/test_network.py b/podman/tests/unit/test_network.py index dd8e1601..b5dfb06b 100644 --- a/podman/tests/unit/test_network.py +++ b/podman/tests/unit/test_network.py @@ -34,14 +34,16 @@ "driver": "bridge", "network_interface": "libpod_veth0", "created": "2022-01-28T09:18:37.491308364-07:00", - "subnets": [{ - "subnet": "10.11.12.0/24", - "gateway": "10.11.12.1", - "lease_range": { - "start_ip": "10.11.12.1", - "end_ip": "10.11.12.63", - }, - }], + "subnets": [ + { + "subnet": "10.11.12.0/24", + "gateway": "10.11.12.1", + "lease_range": { + "start_ip": "10.11.12.1", + "end_ip": "10.11.12.63", + }, + } + ], "ipv6_enabled": False, "internal": False, "dns_enabled": False, diff --git a/podman/tests/unit/test_networksmanager.py b/podman/tests/unit/test_networksmanager.py index 57ed1f20..1219bb54 100644 --- a/podman/tests/unit/test_networksmanager.py +++ b/podman/tests/unit/test_networksmanager.py @@ -56,14 +56,16 @@ "driver": "bridge", "network_interface": "libpod_veth0", "created": "2022-01-28T09:18:37.491308364-07:00", - "subnets": [{ - "subnet": "10.11.12.0/24", - "gateway": "10.11.12.1", - "lease_range": { - "start_ip": "10.11.12.1", - "end_ip": "10.11.12.63", - }, - }], + "subnets": [ + { + "subnet": "10.11.12.0/24", + "gateway": "10.11.12.1", + "lease_range": { + "start_ip": "10.11.12.1", + "end_ip": "10.11.12.63", + }, + } + ], "ipv6_enabled": False, "internal": False, "dns_enabled": False, @@ -78,14 +80,16 @@ "created": "2021-03-01T09:18:37.491308364-07:00", "driver": "bridge", "network_interface": "libpod_veth1", - "subnets": [{ - "subnet": "10.11.12.0/24", - "gateway": "10.11.12.1", - "lease_range": { - "start_ip": "10.11.12.1", - "end_ip": "10.11.12.63", - }, - }], + "subnets": [ + { + "subnet": "10.11.12.0/24", + "gateway": "10.11.12.1", + "lease_range": { + "start_ip": "10.11.12.1", + "end_ip": "10.11.12.63", + }, + } + ], "ipv6_enabled": False, "internal": False, "dns_enabled": False, diff --git a/podman/tests/unit/test_podsmanager.py b/podman/tests/unit/test_podsmanager.py index 73886cb3..4512f8e6 100644 --- a/podman/tests/unit/test_podsmanager.py +++ b/podman/tests/unit/test_podsmanager.py @@ -192,30 +192,34 @@ def test_stats_without_decode(self, mock): @requests_mock.Mocker() def test_top_with_streaming(self, mock): stream = [ - [{ - 'CPU': '2.53%', - 'MemUsage': '49.15kB / 16.71GB', - 'MemUsageBytes': '48KiB / 15.57GiB', - 'Mem': '0.00%', - 'NetIO': '7.638kB / 430B', - 'BlockIO': '-- / --', - 'PIDS': '1', - 'Pod': '1c948ab42339', - 'CID': 'd999c49a7b6c', - 'Name': '1c948ab42339-infra', - }], - [{ - 'CPU': '1.46%', - 'MemUsage': '57.23B / 16.71GB', - 'MemUsageBytes': '48KiB / 15.57GiB', - 'Mem': '0.00%', - 'NetIO': '7.638kB / 430B', - 'BlockIO': '-- / --', - 'PIDS': '1', - 'Pod': '1c948ab42339', - 'CID': 'd999c49a7b6c', - 'Name': '1c948ab42339-infra', - }], + [ + { + 'CPU': '2.53%', + 'MemUsage': '49.15kB / 16.71GB', + 'MemUsageBytes': '48KiB / 15.57GiB', + 'Mem': '0.00%', + 'NetIO': '7.638kB / 430B', + 'BlockIO': '-- / --', + 'PIDS': '1', + 'Pod': '1c948ab42339', + 'CID': 'd999c49a7b6c', + 'Name': '1c948ab42339-infra', + } + ], + [ + { + 'CPU': '1.46%', + 'MemUsage': '57.23B / 16.71GB', + 'MemUsageBytes': '48KiB / 15.57GiB', + 'Mem': '0.00%', + 'NetIO': '7.638kB / 430B', + 'BlockIO': '-- / --', + 'PIDS': '1', + 'Pod': '1c948ab42339', + 'CID': 'd999c49a7b6c', + 'Name': '1c948ab42339-infra', + } + ], ] buffer = io.StringIO()