Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from leapp.actors import Actor
from leapp.libraries.actor import enablemigratedtimers
from leapp.libraries.common.cllaunch import run_on_cloudlinux
from leapp.models import SystemdTimersInfoSource
from leapp.reporting import Report
from leapp.tags import FirstBootPhaseTag, IPUWorkflowTag


class EnableMigratedTimers(Actor):
"""
Enable systemd timers that the upgrade left disabled despite a vendor preset.

Some packages replace a cron job with a systemd timer across major versions.
logrotate is the canonical case (EL8 ships /etc/cron.daily/logrotate, EL9
ships logrotate.timer with preset 'enable'), and mdadm does the same with
/etc/cron.d/raid-check and raid-check.timer. Because the package is upgraded
rather than freshly installed, its %systemd_post scriptlet does not apply the
timer's preset, and leapp's systemd state transition only covers '.service'
units. The timer is therefore left disabled, silently stopping its work - no
logs are rotated, no RAID consistency check is ever run.

Only timers absent from the source system are considered, so a timer the
administrator disabled deliberately is never re-enabled.
"""

name = 'enable_migrated_timers'
consumes = (SystemdTimersInfoSource,)
produces = (Report,)
tags = (FirstBootPhaseTag, IPUWorkflowTag)

@run_on_cloudlinux
def process(self):
enablemigratedtimers.process()
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from leapp import reporting
from leapp.libraries.common import systemd
from leapp.libraries.stdlib import api, CalledProcessError, run
from leapp.models import SystemdTimersInfoSource

FMT_LIST_SEPARATOR = '\n - '

_LIST_TIMERS_CMD = ['systemctl', 'list-unit-files', '--type=timer', '--all', '--plain', '--no-legend']


def _get_target_timer_states():
"""
Get the state of every systemd timer unit file on the upgraded system.

:return: Dictionary mapping timer unit names to their state
:rtype: dict[str, str]
"""
states = {}
for entry in run(_LIST_TIMERS_CMD, split=True)['stdout']:
columns = entry.split()
if len(columns) >= 2:
states[columns[0]] = columns[1]
return states


def _timers_to_enable(source_timers, target_states, presets):
"""
Pick the timers that the upgrade left disabled against their vendor preset.

A timer qualifies only when it does not exist on the source system. Such a
timer was never visible to the administrator, so a 'disabled' state cannot
express an explicit choice - it can only be the upgrade failing to apply the
vendor preset. Timers that existed on the source keep whatever state the
regular systemd state transition gave them.

:return: Sorted names of the timers to enable
:rtype: list[str]
"""
return sorted(
name for name, state in target_states.items()
if name not in source_timers and state == 'disabled' and presets.get(name) == 'enable'
)


def process():
source_info = next(api.consume(SystemdTimersInfoSource), None)
if source_info is None:
# Without the source inventory a new timer cannot be told apart from one
# the administrator disabled on purpose, so nothing may be touched.
api.current_logger().warning(
'No SystemdTimersInfoSource message found, skipping the systemd timer check.'
)
return

source_timers = set(source_info.timers)
target_states = _get_target_timer_states()
presets = systemd.get_system_unit_presets('.timer')

enabled = []
for unit in _timers_to_enable(source_timers, target_states, presets):
try:
run(['systemctl', 'enable', '--now', unit])
except CalledProcessError as err:
api.current_logger().warning(
'Failed to enable systemd timer {}: {}'.format(unit, err)
)
continue
enabled.append(unit)

if not enabled:
return

reporting.create_report([
reporting.Title('Enabled systemd timers left disabled by the upgrade'),
reporting.Summary(
'The following systemd timers are new on the upgraded system and are'
' enabled by vendor preset, but were left disabled by the upgrade:'
' the owning package already existed on the source system, so its'
' scriptlet did not apply the preset, and leapp does not transition'
' non-service units. Leapp has enabled and started them to restore'
' the behavior of a freshly installed system (e.g. logrotate.timer'
' rotates system logs and raid-check.timer runs the weekly software'
' RAID consistency check; left disabled they fail silently):{}{}'.format(
FMT_LIST_SEPARATOR, FMT_LIST_SEPARATOR.join(enabled)
)
),
reporting.Severity(reporting.Severity.INFO),
reporting.Groups([reporting.Groups.POST, reporting.Groups.SERVICES]),
])
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import pytest

from leapp import reporting
from leapp.libraries.actor import enablemigratedtimers
from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked, logger_mocked
from leapp.libraries.stdlib import api, CalledProcessError
from leapp.models import SystemdTimersInfoSource


class _RunMocked(object):
"""
Fake leapp `run` that answers `systemctl list-unit-files --type=timer` with
the configured target timer states and records every command issued.
`enable --now` can be made to fail.
"""

def __init__(self, target_states=None, enable_raises=False):
self.target_states = target_states or {}
self.enable_raises = enable_raises
self.commands = []

def __call__(self, cmd, split=False, checked=True):
self.commands.append(cmd)
if cmd[:3] == ['systemctl', 'list-unit-files', '--type=timer']:
return {'stdout': ['{} {}'.format(n, s) for n, s in sorted(self.target_states.items())]}
if cmd[:3] == ['systemctl', 'enable', '--now']:
if self.enable_raises:
raise CalledProcessError('boom', cmd, {})
return {'stdout': []}
return {'stdout': []}


def _setup(monkeypatch, run_mock, presets, source_timers=None, no_source_msg=False):
msgs = [] if no_source_msg else [SystemdTimersInfoSource(timers=source_timers or [])]
monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=msgs))
monkeypatch.setattr(api, 'current_logger', logger_mocked())
monkeypatch.setattr(enablemigratedtimers, 'run', run_mock)
monkeypatch.setattr(
enablemigratedtimers.systemd, 'get_system_unit_presets', lambda suffix: presets
)
reports = create_report_mocked()
monkeypatch.setattr(reporting, 'create_report', reports)
return reports


def _enabled_units(run_mock):
return [c[3] for c in run_mock.commands if c[:3] == ['systemctl', 'enable', '--now']]


def _reported_units(reports):
"""
Pull the listed units out of the report summary.

The summary prose names logrotate.timer and raid-check.timer as examples, so
a plain substring check would pass regardless of what was actually enabled.
Only the bulleted list reflects the units this run touched.
"""
summary = reports.report_fields['summary']
return [
line.strip() for line in summary.split(enablemigratedtimers.FMT_LIST_SEPARATOR)[1:]
]


def test_new_preset_enabled_timer_is_enabled_and_reported(monkeypatch):
run_mock = _RunMocked(target_states={'logrotate.timer': 'disabled'})
reports = _setup(monkeypatch, run_mock, presets={'logrotate.timer': 'enable'}, source_timers=[])

enablemigratedtimers.process()

assert _enabled_units(run_mock) == ['logrotate.timer']
assert reports.called == 1
assert _reported_units(reports) == ['logrotate.timer']


def test_raid_check_timer_is_enabled_alongside_logrotate(monkeypatch):
"""
mdadm migrates /etc/cron.d/raid-check to raid-check.timer on EL8->EL9 exactly
like logrotate does, so the weekly RAID consistency scrub silently stops. Both
must be re-enabled by a single run.
"""
run_mock = _RunMocked(target_states={
'logrotate.timer': 'disabled',
'raid-check.timer': 'disabled',
})
reports = _setup(
monkeypatch,
run_mock,
presets={'logrotate.timer': 'enable', 'raid-check.timer': 'enable'},
source_timers=['dnf-makecache.timer'],
)

enablemigratedtimers.process()

assert sorted(_enabled_units(run_mock)) == ['logrotate.timer', 'raid-check.timer']
assert reports.called == 1
assert _reported_units(reports) == ['logrotate.timer', 'raid-check.timer']


def test_timer_present_on_source_is_left_alone(monkeypatch):
"""
A timer that existed on the source system may have been disabled deliberately
by the administrator, so its state must never be overridden.
"""
run_mock = _RunMocked(target_states={'dnf-makecache.timer': 'disabled'})
reports = _setup(
monkeypatch,
run_mock,
presets={'dnf-makecache.timer': 'enable'},
source_timers=['dnf-makecache.timer'],
)

enablemigratedtimers.process()

assert _enabled_units(run_mock) == []
assert reports.called == 0


@pytest.mark.parametrize('preset', ['disable', None])
def test_new_timer_without_enable_preset_is_left_alone(monkeypatch, preset):
run_mock = _RunMocked(target_states={'rear.timer': 'disabled'})
presets = {} if preset is None else {'rear.timer': preset}
reports = _setup(monkeypatch, run_mock, presets=presets, source_timers=[])

enablemigratedtimers.process()

assert _enabled_units(run_mock) == []
assert reports.called == 0


@pytest.mark.parametrize('state', ['enabled', 'static', 'masked', 'generated'])
def test_new_timer_not_in_disabled_state_is_left_alone(monkeypatch, state):
run_mock = _RunMocked(target_states={'logrotate.timer': state})
reports = _setup(monkeypatch, run_mock, presets={'logrotate.timer': 'enable'}, source_timers=[])

enablemigratedtimers.process()

assert _enabled_units(run_mock) == []
assert reports.called == 0


def test_missing_source_message_is_a_no_op(monkeypatch):
"""
Without the source inventory there is no way to tell a new timer from one the
administrator disabled, so nothing may be touched.
"""
run_mock = _RunMocked(target_states={'logrotate.timer': 'disabled'})
reports = _setup(
monkeypatch, run_mock, presets={'logrotate.timer': 'enable'}, no_source_msg=True
)

enablemigratedtimers.process()

assert _enabled_units(run_mock) == []
assert reports.called == 0
assert api.current_logger().warnmsg


def test_enable_failure_is_swallowed_and_not_reported(monkeypatch):
run_mock = _RunMocked(target_states={'logrotate.timer': 'disabled'}, enable_raises=True)
reports = _setup(monkeypatch, run_mock, presets={'logrotate.timer': 'enable'}, source_timers=[])

enablemigratedtimers.process()

assert _enabled_units(run_mock) == ['logrotate.timer']
# enabling failed, so nothing should be reported as enabled
assert reports.called == 0


def test_one_failure_does_not_block_the_other_timer(monkeypatch):
class _PartialFailRun(_RunMocked):
def __call__(self, cmd, split=False, checked=True):
if cmd[:3] == ['systemctl', 'enable', '--now'] and cmd[3] == 'logrotate.timer':
self.commands.append(cmd)
raise CalledProcessError('boom', cmd, {})
return super(_PartialFailRun, self).__call__(cmd, split=split, checked=checked)

run_mock = _PartialFailRun(target_states={
'logrotate.timer': 'disabled',
'raid-check.timer': 'disabled',
})
reports = _setup(
monkeypatch,
run_mock,
presets={'logrotate.timer': 'enable', 'raid-check.timer': 'enable'},
source_timers=[],
)

enablemigratedtimers.process()

assert reports.called == 1
assert _reported_units(reports) == ['raid-check.timer']


def test_no_timers_on_target_is_a_no_op(monkeypatch):
run_mock = _RunMocked(target_states={})
reports = _setup(monkeypatch, run_mock, presets={}, source_timers=[])

enablemigratedtimers.process()

assert _enabled_units(run_mock) == []
assert reports.called == 0
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from leapp.actors import Actor
from leapp.exceptions import StopActorExecutionError
from leapp.libraries.actor import scansystemdtimerssource
from leapp.libraries.common.cllaunch import run_on_cloudlinux
from leapp.libraries.stdlib import CalledProcessError
from leapp.models import SystemdTimersInfoSource
from leapp.tags import FactsPhaseTag, IPUWorkflowTag


class ScanSystemdTimersSource(Actor):
"""
Provide the list of systemd timer unit files present on the source system.

Leapp's own systemd scan is restricted to '.service' units, so timers are
recorded nowhere else. :class:`EnableMigratedTimers` needs this inventory on
the target system to tell a timer that is new on the target apart from one
that already existed on the source, whose state the administrator may have
chosen deliberately.
"""

name = 'scan_systemd_timers_source'
consumes = ()
produces = (SystemdTimersInfoSource,)
tags = (FactsPhaseTag, IPUWorkflowTag)

@run_on_cloudlinux
def process(self):
try:
timers = scansystemdtimerssource.get_source_timers()
except CalledProcessError as err:
raise StopActorExecutionError(
message='Cannot obtain the list of systemd timer unit files.',
details={'details': str(err), 'stderr': err.stderr}
)

self.produce(SystemdTimersInfoSource(timers=timers))
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from leapp.libraries.stdlib import run

_LIST_TIMERS_CMD = ['systemctl', 'list-unit-files', '--type=timer', '--all', '--plain', '--no-legend']


def get_source_timers():
"""
Get the names of all systemd timer unit files on the source system.

Only the unit names are read: systemd 239 (EL8) prints just the name and the
state, while newer versions add a PRESET column, and the preset of the
source system is irrelevant for this purpose anyway.

:return: Names of the timer unit files, including the '.timer' suffix
:rtype: list[str]
:raises: CalledProcessError: if the `systemctl` command fails
"""
timers = []
for entry in run(_LIST_TIMERS_CMD, split=True)['stdout']:
columns = entry.split()
if columns:
timers.append(columns[0])
return timers
Loading
Loading