Test Failed
Push — master ( 7cfc0c...4c3161 )
by Nicolas
04:02
created

glances.plugins.containers   F

Complexity

Total Complexity 101

Size/Duplication

Total Lines 576
Duplicated Lines 3.99 %

Importance

Changes 0
Metric Value
eloc 356
dl 23
loc 576
rs 2
c 0
b 0
f 0
wmc 101

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complexity

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like glances.plugins.containers often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
#
2
# This file is part of Glances.
3
#
4
# SPDX-FileCopyrightText: 2022 Nicolas Hennion <[email protected]>
5
#
6
# SPDX-License-Identifier: LGPL-3.0-only
7
#
8
9
"""Containers plugin."""
10
11
from copy import deepcopy
12
from functools import partial, reduce
13
from itertools import chain
14
from typing import Any, Optional
15
16
from glances.globals import nativestr
17
from glances.logger import logger
18
from glances.plugins.containers.engines import ContainersExtension
19
from glances.plugins.containers.engines.docker import DockerExtension, disable_plugin_docker
20
from glances.plugins.containers.engines.podman import PodmanExtension, disable_plugin_podman
21
from glances.plugins.plugin.model import GlancesPluginModel
22
from glances.processes import glances_processes
23
from glances.processes import sort_stats as sort_stats_processes
24
25
# Fields description
26
# description: human readable description
27
# short_name: shortname to use un UI
28
# unit: unit type
29
# rate: is it a rate ? If yes, // by time_since_update when displayed,
30
# min_symbol: Auto unit should be used if value > than 1 'X' (K, M, G)...
31
fields_description = {
32
    'name': {
33
        'description': 'Container name',
34
    },
35
    'id': {
36
        'description': 'Container ID',
37
    },
38
    'image': {
39
        'description': 'Container image',
40
    },
41
    'status': {
42
        'description': 'Container status',
43
    },
44
    'created': {
45
        'description': 'Container creation date',
46
    },
47
    'command': {
48
        'description': 'Container command',
49
    },
50
    'cpu_percent': {
51
        'description': 'Container CPU consumption',
52
        'unit': 'percent',
53
    },
54
    'memory_inactive_file': {
55
        'description': 'Container memory inactive file',
56
        'unit': 'byte',
57
    },
58
    'memory_limit': {
59
        'description': 'Container memory limit',
60
        'unit': 'byte',
61
    },
62
    'memory_usage': {
63
        'description': 'Container memory usage',
64
        'unit': 'byte',
65
    },
66
    'io_rx': {
67
        'description': 'Container IO bytes read rate',
68
        'unit': 'bytepersecond',
69
    },
70
    'io_wx': {
71
        'description': 'Container IO bytes write rate',
72
        'unit': 'bytepersecond',
73
    },
74
    'network_rx': {
75
        'description': 'Container network RX bitrate',
76
        'unit': 'bitpersecond',
77
    },
78
    'network_tx': {
79
        'description': 'Container network TX bitrate',
80
        'unit': 'bitpersecond',
81
    },
82
    'uptime': {
83
        'description': 'Container uptime',
84
    },
85
    'engine': {
86
        'description': 'Container engine (Docker and Podman are currently supported)',
87
    },
88
    'pod_name': {
89
        'description': 'Pod name (only with Podman)',
90
    },
91
    'pod_id': {
92
        'description': 'Pod ID (only with Podman)',
93
    },
94
}
95
96
# Define the items history list (list of items to add to history)
97
# TODO: For the moment limited to the CPU. Had to change the graph exports
98
#       method to display one graph per container.
99
# items_history_list = [{'name': 'cpu_percent',
100
#                        'description': 'Container CPU consumption in %',
101
#                        'y_unit': '%'},
102
#                       {'name': 'memory_usage',
103
#                        'description': 'Container memory usage in bytes',
104
#                        'y_unit': 'B'},
105
#                       {'name': 'network_rx',
106
#                        'description': 'Container network RX bitrate in bits per second',
107
#                        'y_unit': 'bps'},
108
#                       {'name': 'network_tx',
109
#                        'description': 'Container network TX bitrate in bits per second',
110
#                        'y_unit': 'bps'},
111
#                       {'name': 'io_r',
112
#                        'description': 'Container IO bytes read per second',
113
#                        'y_unit': 'Bps'},
114
#                       {'name': 'io_w',
115
#                        'description': 'Container IO bytes write per second',
116
#                        'y_unit': 'Bps'}]
117
items_history_list = [{'name': 'cpu_percent', 'description': 'Container CPU consumption in %', 'y_unit': '%'}]
118
119
# List of key to remove before export
120
export_exclude_list = ['cpu', 'io', 'memory', 'network']
121
122
# Sort dictionary for human
123
sort_for_human = {
124
    'io_counters': 'disk IO',
125
    'cpu_percent': 'CPU consumption',
126
    'memory_usage': 'memory consumption',
127
    'cpu_times': 'uptime',
128
    'name': 'container name',
129
    None: 'None',
130
}
131
132
133
class ContainersPlugin(GlancesPluginModel):
134
    """Glances Docker plugin.
135
136
    stats is a dict: {'version': {...}, 'containers': [{}, {}]}
137
    """
138
139
    def __init__(self, args=None, config=None):
140
        """Init the plugin."""
141
        super().__init__(
142
            args=args, config=config, items_history_list=items_history_list, fields_description=fields_description
143
        )
144
145
        # The plugin can be disabled using: args.disable_docker
146
        self.args = args
147
148
        # Default config keys
149
        self.config = config
150
151
        # We want to display the stat in the curse interface
152
        self.display_curse = True
153
154
        self.watchers: dict[str, ContainersExtension] = {}
155
156
        # Init the Docker API
157
        if not disable_plugin_docker:
158
            self.watchers['docker'] = DockerExtension()
159
160
        # Init the Podman API
161
        if not disable_plugin_podman:
162
            self.watchers['podman'] = PodmanExtension(podman_sock=self._podman_sock())
163
164
        # Sort key
165
        self.sort_key = None
166
167
        # Set the key's list be disabled in order to only display specific attribute in the container list
168
        self.disable_stats = self.get_conf_value('disable_stats')
169
170
        # Force a first update because we need two update to have the first stat
171
        self.update()
172
        self.refresh_timer.set(0)
173
174
    def _podman_sock(self) -> str:
175
        """Return the podman sock.
176
        Could be desfined in the [docker] section thanks to the podman_sock option.
177
        Default value: unix:///run/user/1000/podman/podman.sock
178
        """
179
        conf_podman_sock = self.get_conf_value('podman_sock')
180
        if not conf_podman_sock:
181
            return "unix:///run/user/1000/podman/podman.sock"
182
        return conf_podman_sock[0]
183
184
    def exit(self) -> None:
185
        """Overwrite the exit method to close threads."""
186
        for watcher in self.watchers.values():
187
            watcher.stop()
188
189
        # Call the father class
190
        super().exit()
191
192
    def get_key(self) -> str:
193
        """Return the key of the list."""
194
        return 'name'
195
196
    def get_export(self) -> list[dict]:
197
        """Overwrite the default export method.
198
199
        - Only exports containers
200
        - The key is the first container name
201
        """
202
        try:
203
            ret = deepcopy(self.stats)
204
        except KeyError as e:
205
            logger.debug(f"docker plugin - Docker export error {e}")
206
            ret = []
207
208
        # Remove fields uses to compute rate
209
        for container in ret:
210
            for i in export_exclude_list:
211
                container.pop(i)
212
213
        return ret
214
215
    def _all_tag(self) -> bool:
216
        """Return the all tag of the Glances/Docker configuration file.
217
218
        # By default, Glances only display running containers
219
        # Set the following key to True to display all containers
220
        all=True
221
        """
222
        all_tag = self.get_conf_value('all')
223
        if not all_tag:
224
            return False
225
        return all_tag[0].lower() == 'true'
226
227
    @GlancesPluginModel._check_decorator
228
    @GlancesPluginModel._log_result_decorator
229
    def update(self) -> list[dict]:
230
        """Update Docker and podman stats using the input method."""
231
        # Connection should be ok
232
        if not self.watchers:
233
            return self.get_init_value()
234
235
        if self.input_method != 'local':
236
            return self.get_init_value()
237
238
        def is_key_in_container_and_hidden(container):
239
            return (key := container.get('key')) in container and self.is_hide(nativestr(container.get(key)))
240
241
        def add_engine_into_container(engine, container):
242
            return container | {"engine": engine}
243
244
        def get_containers_from_updated_watcher(watcher):
245
            _, containers = watcher.update(all_tag=self._all_tag())
246
            return containers
247
248
        # Update stats
249
        stats = list(
250
            chain.from_iterable(
251
                (
252
                    add_engine_into_container(engine, container)
253
                    for container in get_containers_from_updated_watcher(watcher)
254
                    if not is_key_in_container_and_hidden(container)
255
                )
256
                for engine, watcher in self.watchers.items()
257
            )
258
        )
259
260
        # Sort and update the stats
261
        # @TODO: Have a look because sort did not work for the moment (need memory stats ?)
262
        self.sort_key, self.stats = sort_docker_stats(stats)
263
        return self.stats
264
265
    @staticmethod
266
    def memory_usage_no_cache(mem: dict[str, float]) -> float:
267
        """Return the 'real' memory usage by removing inactive_file to usage"""
268
        # Ref: https://github.com/docker/docker-py/issues/3210
269
        return mem['usage'] - (mem['inactive_file'] if 'inactive_file' in mem else 0)
270
271
    def update_views(self) -> bool:
272
        """Update stats views."""
273
        # Call the father's method
274
        super().update_views()
275
276
        if not self.stats:
277
            return False
278
279
        # Add specifics information
280
        # Alert
281
        for i in self.stats:
282
            # Init the views for the current container (key = container name)
283
            self.views[i[self.get_key()]] = {'cpu': {}, 'mem': {}}
284
            # CPU alert
285
            if 'cpu' in i and 'total' in i['cpu']:
286
                # Looking for specific CPU container threshold in the conf file
287
                alert = self.get_alert(i['cpu']['total'], header=i['name'] + '_cpu', action_key=i['name'])
288
                if alert == 'DEFAULT':
289
                    # Not found ? Get back to default CPU threshold value
290
                    alert = self.get_alert(i['cpu']['total'], header='cpu')
291
                if 'cpu' in self.views[i[self.get_key()]]:
292
                    self.views[i[self.get_key()]]['cpu']['decoration'] = alert
293
            # MEM alert
294
            if 'memory' in i and 'usage' in i['memory']:
295
                # Looking for specific MEM container threshold in the conf file
296
                alert = self.get_alert(
297
                    self.memory_usage_no_cache(i['memory']),
298
                    maximum=i['memory']['limit'],
299
                    header=i['name'] + '_mem',
300
                    action_key=i['name'],
301
                )
302
                if alert == 'DEFAULT':
303
                    # Not found ? Get back to default MEM threshold value
304
                    alert = self.get_alert(
305
                        self.memory_usage_no_cache(i['memory']), maximum=i['memory']['limit'], header='mem'
306
                    )
307
                if 'mem' in self.views[i[self.get_key()]]:
308
                    self.views[i[self.get_key()]]['mem']['decoration'] = alert
309
310
        # Display Engine and Pod name ?
311
        show_pod_name = False
312
        if any(ct.get("pod_name") for ct in self.stats):
313
            show_pod_name = True
314
        self.views['show_pod_name'] = show_pod_name
315
        show_engine_name = False
316
        if len({ct["engine"] for ct in self.stats}) > 1:
317
            show_engine_name = True
318
        self.views['show_engine_name'] = show_engine_name
319
320
        return True
321
322
    def build_title(self, ret):
323
        msg = '{}'.format('CONTAINERS')
324
        ret.append(self.curse_add_line(msg, "TITLE"))
325
        if len(self.stats) > 1:
326
            msg = f' {len(self.stats)}'
327
            ret.append(self.curse_add_line(msg))
328
            msg = f' sorted by {sort_for_human[self.sort_key]}'
329
            ret.append(self.curse_add_line(msg))
330
        if not self.views['show_engine_name']:
331
            msg = f' (served by {self.stats[0].get("engine", "")})'
332
        ret.append(self.curse_add_line(msg))
333
        ret.append(self.curse_new_line())
334
        return ret
335
336
    def maybe_add_engine_name_or_pod_line(self, ret):
337
        if self.views['show_engine_name']:
338
            ret = self.add_msg_to_line(ret, ' {:{width}}'.format('Engine', width=6))
339
        if self.views['show_pod_name']:
340
            ret = self.add_msg_to_line(ret, ' {:{width}}'.format('Pod', width=12))
341
342
        return ret
343
344
    def maybe_add_engine_name_or_pod_name(self, ret, container):
345
        ret.append(self.curse_new_line())
346
        if self.views['show_engine_name']:
347
            ret.append(self.curse_add_line(' {:{width}}'.format(container["engine"], width=6)))
348
        if self.views['show_pod_name']:
349
            ret.append(self.curse_add_line(' {:{width}}'.format(container.get("pod_id", "-"), width=12)))
350
351
        return ret
352
353
    def build_container_name(self, name_max_width):
354
        def build_for_this_max_length(ret, container):
355
            ret.append(
356
                self.curse_add_line(' {:{width}}'.format(container['name'][:name_max_width], width=name_max_width))
357
            )
358
359
            return ret
360
361
        return build_for_this_max_length
362
363
    def build_header(self, ret, name_max_width):
364
        ret.append(self.curse_new_line())
365
366
        ret = self.maybe_add_engine_name_or_pod_line(ret)
367
368
        if 'name' not in self.disable_stats:
369
            msg = ' {:{width}}'.format('Name', width=name_max_width)
370
            ret.append(self.curse_add_line(msg, 'SORT' if self.sort_key == 'name' else 'DEFAULT'))
371
372
        msgs = []
373
        if 'status' not in self.disable_stats:
374
            msgs.append('{:>10}'.format('Status'))
375
        if 'uptime' not in self.disable_stats:
376
            msgs.append('{:>10}'.format('Uptime'))
377
        ret = reduce(self.add_msg_to_line, msgs, ret)
378
379
        if 'cpu' not in self.disable_stats:
380
            msg = '{:>6}'.format('CPU%')
381
            ret.append(self.curse_add_line(msg, 'SORT' if self.sort_key == 'cpu_percent' else 'DEFAULT'))
382
383
        msgs = []
384
        if 'mem' not in self.disable_stats:
385
            msg = '{:>7}'.format('MEM')
386
            ret.append(self.curse_add_line(msg, 'SORT' if self.sort_key == 'memory_usage' else 'DEFAULT'))
387
            msgs.append('/{:<7}'.format('MAX'))
388
389
        if 'diskio' not in self.disable_stats:
390
            msgs.extend(['{:>7}'.format('IOR/s'), ' {:<7}'.format('IOW/s')])
391
392
        if 'networkio' not in self.disable_stats:
393
            msgs.extend(['{:>7}'.format('Rx/s'), ' {:<7}'.format('Tx/s')])
394
395
        if 'command' not in self.disable_stats:
396
            msgs.append(' {:8}'.format('Command'))
397
398
        return reduce(self.add_msg_to_line, msgs, ret)
399
400
    def add_msg_to_line(self, ret, msg):
401
        ret.append(self.curse_add_line(msg))
402
403
        return ret
404
405
    def get_max_of_container_names(self):
406
        return min(
407
            self.config.get_int_value('containers', 'max_name_size', default=20) if self.config is not None else 20,
408
            len(max(self.stats, key=lambda x: len(x['name']))['name']),
409
        )
410
411
    def build_status_name(self, ret, container):
412
        status = self.container_alert(container['status'])
413
        msg = '{:>10}'.format(container['status'][0:10])
414
        ret.append(self.curse_add_line(msg, status))
415
416
        return ret
417
418
    def build_uptime_line(self, ret, container):
419
        if container['uptime']:
420
            msg = '{:>10}'.format(container['uptime'])
421
        else:
422
            msg = '{:>10}'.format('_')
423
424
        return self.add_msg_to_line(ret, msg)
425
426
    def build_cpu_line(self, ret, container):
427
        try:
428
            msg = '{:>6.1f}'.format(container['cpu']['total'])
429
        except (KeyError, TypeError):
430
            msg = '{:>6}'.format('_')
431
        ret.append(self.curse_add_line(msg, self.get_views(item=container['name'], key='cpu', option='decoration')))
432
433
        return ret
434
435
    def build_memory_line(self, ret, container):
436
        try:
437
            msg = '{:>7}'.format(self.auto_unit(self.memory_usage_no_cache(container['memory'])))
438
        except KeyError:
439
            msg = '{:>7}'.format('_')
440
        ret.append(self.curse_add_line(msg, self.get_views(item=container['name'], key='mem', option='decoration')))
441
        try:
442
            msg = '/{:<7}'.format(self.auto_unit(container['memory']['limit']))
443
        except (KeyError, TypeError):
444
            msg = '/{:<7}'.format('_')
445
        ret.append(self.curse_add_line(msg))
446
447
        return ret
448
449
    def build_io_line(self, ret, container):
450
        unit = 'B'
451
        try:
452
            value = self.auto_unit(int(container['io_rx'])) + unit
453
            msg = f'{value:>7}'
454
        except (KeyError, TypeError):
455
            msg = '{:>7}'.format('_')
456
        ret.append(self.curse_add_line(msg))
457
        try:
458
            value = self.auto_unit(int(container['io_wx'])) + unit
459
            msg = f' {value:<7}'
460
        except (KeyError, TypeError):
461
            msg = ' {:<7}'.format('_')
462
        ret.append(self.curse_add_line(msg))
463
464
        return ret
465
466
    def build_net_line(self, args):
467
        def build_with_this_args(ret, container):
468
            if args.byte:
469
                # Bytes per second (for dummy)
470
                to_bit = 1
471
                unit = ''
472
            else:
473
                # Bits per second (for real network administrator | Default)
474
                to_bit = 8
475
                unit = 'b'
476
            try:
477
                value = self.auto_unit(int(container['network_rx'] * to_bit)) + unit
478
                msg = f'{value:>7}'
479
            except (KeyError, TypeError):
480
                msg = '{:>7}'.format('_')
481
            ret.append(self.curse_add_line(msg))
482
            try:
483
                value = self.auto_unit(int(container['network_tx'] * to_bit)) + unit
484
                msg = f' {value:<7}'
485
            except (KeyError, TypeError):
486
                msg = ' {:<7}'.format('_')
487
            ret.append(self.curse_add_line(msg))
488
489
            return ret
490
491
        return build_with_this_args
492
493
    def build_cmd_line(self, ret, container):
494
        if container['command'] is not None:
495
            msg = ' {}'.format(container['command'])
496
        else:
497
            msg = ' {}'.format('_')
498
        ret.append(self.curse_add_line(msg, splittable=True))
499
500
        return ret
501
502
    def msg_curse(self, args=None, max_width: Optional[int] = None) -> list[str]:
503
        """Return the dict to display in the curse interface."""
504
        # Init the return message
505
        init = []
506
507
        # Only process if stats exist (and non null) and display plugin enable...
508
        if any([not self.stats, len(self.stats) == 0, self.is_disabled()]):
509
            return init
510
511
        # Build the string message
512
        # Get the maximum containers name
513
        # Max size is configurable. See feature request #1723.
514
        name_max_width = self.get_max_of_container_names()
515
516
        steps = [
517
            self.build_title,
518
            partial(self.build_header, name_max_width=name_max_width),
519
            self.build_data_line(name_max_width, args),
520
        ]
521
522
        return reduce(lambda ret, step: step(ret), steps, init)
523
524
    def build_data_line(self, name_max_width, args):
525
        def build_for_this_params(ret):
526
            build_data_with_params = self.build_container_data(name_max_width, args)
527
            return reduce(build_data_with_params, self.stats, ret)
528
529
        return build_for_this_params
530
531
    def build_container_data(self, name_max_width, args):
532
        def build_with_this_params(ret, container):
533
            steps = [self.maybe_add_engine_name_or_pod_name]
534
            options = {
535
                'name': self.build_container_name(name_max_width),
536
                'status': self.build_status_name,
537
                'uptime': self.build_uptime_line,
538
                'cpu': self.build_cpu_line,
539
                'mem': self.build_memory_line,
540
                'diskio': self.build_io_line,
541
                'networkio': self.build_net_line(args),
542
                'command': self.build_cmd_line,
543
            }
544
545
            steps.extend(v for k, v in options.items() if k not in self.disable_stats)
546
            return reduce(lambda ret, step: step(ret, container), steps, ret)
547
548
        return build_with_this_params
549
550
    @staticmethod
551
    def container_alert(status: str) -> str:
552
        """Analyse the container status.
553
        One of created, restarting, running, removing, paused, exited, or dead
554
        """
555
        if status == 'running':
556
            return 'OK'
557
        if status == 'dead':
558
            return 'ERROR'
559
        if status in ['created', 'restarting', 'exited']:
560
            return 'WARNING'
561
        return 'INFO'
562
563
564
def sort_docker_stats(stats: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
565
    # Make VM sort related to process sort
566
    sort_by, sort_by_secondary = {
567
        'memory_percent': ('memory_usage', 'cpu_percent'),
568
        'name': ('name', 'cpu_percent'),
569
    }.get(glances_processes.sort_key, ('cpu_percent', 'memory_usage'))
570
571
    # Sort docker stats
572
    stats = sort_stats_processes(
573
        stats,
574
        sorted_by=sort_by,
575
        sorted_by_secondary=sort_by_secondary,
576
        # Reverse for all but name
577
        reverse=glances_processes.sort_key != 'name',
578
    )
579
580
    # Return the main sort key and the sorted stats
581
    return sort_by, stats
582