summaryrefslogtreecommitdiff
path: root/src/op_mode/kdump.py
blob: 560650bdfc7552e9697ac263f677b553f16986ce (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
#!/usr/bin/env python3
#
# Copyright (C) VyOS Inc.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 or later as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import os
import sys
from datetime import datetime

import vyos.opmode
from tabulate import tabulate
from vyos.configquery import ConfigTreeQuery
from vyos.defaults import systemd_services
from vyos.utils.convert import bytes_to_human
from vyos.utils.kernel import get_kernel_boot_arg
from vyos.utils.kernel import is_crash_kernel_loaded
from vyos.utils.kernel import get_crash_kernel_size
from vyos.utils.process import is_systemd_service_active

# systemd unit managed by kdump-tools that loads the capture kernel
# and handles vmcore saving after a panic
KDUMP_SERVICE = systemd_services['kdump']

# Base path to boot files related to kdump
KDUMP_LIB_PATH = '/var/lib/kdump'

DEFAULT_TIME_FORMAT = '%Y-%m-%d %H:%M:%S'

conf = ConfigTreeQuery()
base = ['system', 'option', 'kdump']


def _get_config() -> dict:
    """Return the effective kdump config dict, or {} when unconfigured"""

    if not conf.exists(base):
        return {}

    return conf.get_config_dict(
        base, key_mangling=('-', '_'), get_first_key=True, with_recursive_defaults=True
    )


def _read_link(symlink_path: str) -> str | None:
    """Read the destination of the symbolic link"""
    try:
        destination_path = os.readlink(symlink_path)
    except OSError:
        return None

    # os.readlink() may return a relative target.
    # Interpret it relative to the link directory
    if not os.path.isabs(destination_path):
        destination_path = os.path.join(os.path.dirname(symlink_path), destination_path)

    destination_path = os.path.realpath(destination_path)
    return destination_path if os.path.isfile(destination_path) else None


def _list_dumps(dump_path: str) -> list[dict]:
    """Return a sorted list of vmcore entries found under *dump_path*.

    kdump-tools stores each capture in its own timestamped sub-directory
    using the layout:

        <dump_path>/<YYYYMMDDHHMM>/dump.<YYYYMMDDHHMM>
        <dump_path>/<YYYYMMDDHHMM>/dmesg.<YYYYMMDDHHMM>  (optional)

    The reported size is the combined size of both files when the dmesg
    companion file is present.
    """

    entries = []
    if not os.path.isdir(dump_path):
        return entries

    # Each sub-directory name is the timestamp of the crash event
    for name in sorted(os.listdir(dump_path)):
        sub = os.path.join(dump_path, name)
        if not os.path.isdir(sub):
            continue

        dump = os.path.join(sub, f'dump.{name}')
        dmesg = os.path.join(sub, f'dmesg.{name}')

        if os.path.isfile(dump):
            stat_dump = os.stat(dump)
            stat_dmesg = os.stat(dmesg) if os.path.isfile(dmesg) else None
            full_size = stat_dump.st_size + (stat_dmesg.st_size if stat_dmesg else 0)

            entry = {
                'directory': sub,
                'size_bytes': full_size,
                'size_human': bytes_to_human(full_size),
                'modify_time': stat_dump.st_mtime,
            }

            entries.append(entry)

    return entries


def _get_raw_status() -> dict:
    """Collect all kdump runtime and configuration data into a single dict"""

    cfg = _get_config()

    memory = cfg.get('memory')
    dump_path = cfg.get('dump_path')
    dumps = _list_dumps(dump_path) if dump_path else []
    last_dump = dumps[-1] if dumps else None

    return {
        'configured': bool(cfg),
        'service_active': is_systemd_service_active(KDUMP_SERVICE),
        'crash_kernel_loaded': is_crash_kernel_loaded(),
        'crash_kernel_size': get_crash_kernel_size(),
        'cmdline_value': get_kernel_boot_arg('crashkernel') or '',
        'vmlinuz_path': _read_link(f'{KDUMP_LIB_PATH}/vmlinuz'),
        'initrd_path': _read_link(f'{KDUMP_LIB_PATH}/initrd.img'),
        'memory': memory,
        'dump_path': dump_path,
        'last_dump': last_dump,
    }


def _get_raw_dumps() -> list[dict]:
    cfg = _get_config()
    dump_path = cfg.get('dump_path')
    return _list_dumps(dump_path) if dump_path else []


def _verify_config():
    """Raise UnconfiguredSubsystem when kdump has not been configured"""

    cfg = _get_config()
    if not cfg:
        conf_command = ' '.join(['set'] + base)
        raise vyos.opmode.UnconfiguredSubsystem(
            f'kdump is not configured - use "{conf_command}" to enable it.'
        )


def _format_status(data: dict) -> str:
    """Render the kdump status dict as a human-readable plain-text table"""

    header = 'Kernel crash dump (kdump) status'

    cfg_str = 'configured' if data['configured'] else 'not configured'
    svc_str = 'active' if data['service_active'] else 'inactive'
    loaded_str = 'loaded' if data['crash_kernel_loaded'] else 'not loaded'

    rows = [
        ('Configuration', cfg_str),
        ('Systemd service', svc_str),
        ('Crash kernel', loaded_str),
    ]

    if data['crash_kernel_loaded']:
        # Show reserved memory size and the boot argument that produced it

        raw = data['crash_kernel_size']
        rows.append(('Reserved memory', bytes_to_human(raw)))

        rows.append(("Boot 'crashkernel' value", data['cmdline_value']))
    else:
        # Capture kernel is not loaded and memory reservation requires a reboot
        rows.append(('Reserved memory', 'none (reboot required to reserve memory)'))

    if data['configured']:
        rows.append(('Memory parameter', data['memory']))
        rows.append(('Directory to save dumps', data['dump_path']))

        # Display the latest date of created dump
        last_dump_date = 'none'
        if data['last_dump']:
            dt = datetime.fromtimestamp(data['last_dump']['modify_time'])
            last_dump_date = dt.strftime(DEFAULT_TIME_FORMAT)
        rows.append(('Last dump date', last_dump_date))

    # Show `initrd.img` and `vmlinuz` status which are critical
    # for a successful boot process
    rows.append(('Initial RAM disk image', data['initrd_path'] or 'none'))
    rows.append(('Linux kernel executable file', data['vmlinuz_path'] or 'none'))

    # Expand each row to a three-column tuple so tabulate can align the
    # separator colon independently of the label and value columns
    rows = [(header, ':', value) for header, value in rows]
    table = tabulate(rows, tablefmt='plain')

    return f'{header}\n\n{table}'


def _format_dumps(entries: list[dict]) -> str:
    """Render the crash dump list as a human-readable table"""

    if not entries:
        return 'No kernel crash dumps recorded'

    headers = ['DIRECTORY', 'SIZE', 'TIME']
    rows = []

    for entry in entries:
        dt = datetime.fromtimestamp(entry['modify_time'])
        row = (
            entry['directory'],
            entry['size_human'],
            dt.strftime(DEFAULT_TIME_FORMAT),
        )
        rows.append(row)

    # Append a blank separator row followed by an aggregate totals row
    total = sum(entry['size_bytes'] for entry in entries)
    rows.append(('', '', ''))
    rows.append((f'Total: {len(entries)} dump(s)', bytes_to_human(total), ''))

    return tabulate(rows, headers, tablefmt='simple')


def show_status(raw: bool):
    """Show kdump service status and configuration summary"""

    _verify_config()

    data = _get_raw_status()
    return data if raw else _format_status(data)


def show_dumps(raw: bool):
    """Show recorded kernel crash dumps"""

    _verify_config()

    data = _get_raw_dumps()
    return data if raw else _format_dumps(data)


if __name__ == '__main__':
    try:
        res = vyos.opmode.run(sys.modules[__name__])
        if res:
            print(res)
    except (ValueError, vyos.opmode.Error) as e:
        print(e)
        sys.exit(1)