#!/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 .
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.
//dmesg. (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)