Skip to content

CLOS-4518: Reenable systemd timers left disabled by cron-to-timer migrations - #69

Open
prilr wants to merge 3 commits into
cloudlinux:cloudlinuxfrom
prilr:CLOS-4518-elevate-cl8-cl9-disables-logrotate
Open

CLOS-4518: Reenable systemd timers left disabled by cron-to-timer migrations#69
prilr wants to merge 3 commits into
cloudlinux:cloudlinuxfrom
prilr:CLOS-4518-elevate-cl8-cl9-disables-logrotate

Conversation

@prilr

@prilr prilr commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Problem

After a CL8-CL9 upgrade, logrotate.timer is left disabled despite a vendor preset of enable, so logrotate never runs and logs grow unbounded.
Reported by a customer: /var/log/messages and modsec_audit.log reached substantial sizes.

Opening investigation suspected .rpmnew/.rpmsave cron residue, but that was not relevant - the cron file is simply gone from the EL9 package, and the timer that replaced it was never enabled.

Root cause

Three facts compound:

  1. EL8 logrotate ships /etc/cron.daily/logrotate and no timer. EL9 drops the cron file and ships logrotate.timer, vendor preset enable.
  2. During an in-place upgrade logrotate is an RPM upgrade, so its %systemd_post scriptlet — guarded by [ $1 -eq 1 ] — does not apply the
    timer's preset. Presets are only applied on a fresh install.
  3. leapp's systemd state transition would be the safety net, but common/libraries/systemd.py scans with _SYSTEMCTL_CMD_OPTIONS = ['--type=service', ...], and get_system_service_preset_files() emits presets only for units ending in .service. Timers, sockets and paths are structurally invisible to the whole mechanism.

Approach

The initial approach idea used a hardcoded list of affected timers.
However, it would (obviously) miss components that we didn't write into it, so was deemed unsuitable to handling various customer machine configurations. This implementation uses a rule instead of an enumeration:

Enable a timer only if it is absent on the source system, disabled on the target, and its target vendor preset is enable.

A timer that did not exist on the source cannot have been disabled by the administrator, so applying the target preset is assumed safe - it reproduces what a fresh install would have done.

This is the same reasoning the service-state transition already applies to units new on the target.

Timers that did exist on the source keep whatever state the normal transition gave them, so a deliberate systemctl disable always survives the upgrade. If the source inventory message is missing, the actor does nothing and logs a warning, rather than guessing.

Test run

Full CL8-CL9 upgrade on a no-panel VM with mdadm installed:

Timer Source (CL8) After upgrade
logrotate.timer absent enabled + active, next run 00:00 OK
raid-check.timer absent enabled + active, next run Sun 01:00 OK
mdcheck_start / mdcheck_continue / mdmonitor-oneshot present, disabled still disabled OK state respected
mlocate-updatedb.timer present, enabled still enabled OK, same

The third row is of interest: a naive "enable every disabled preset-enabled timer" implementation would have wrongly flipped those three.

The upgrade itself was clean - CL9.8 final state, 3000+ packages, no errors.

prilr and others added 3 commits August 7, 2026 17:10
The systemd service-state transition only re-applied source states to units
that existed on the source system; units new on the target were dropped by
_filter_irrelevant_services. Such units then relied solely on the package
%systemd_post scriptlet to apply their vendor preset, which only runs on a
fresh install (not when an existing package gains a new unit on upgrade), so
preset-enabled units could end up disabled after the upgrade.

Keep target-only units in the filter and, when a unit is absent on the source,
honor the target vendor preset (enable if preset is "enable"), replicating what
a fresh install would do. Guard _get_newly_enabled against the now-possible
missing source entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
get_system_service_preset_files() filters preset entries down to '.service'
units and returns SystemdServicePreset models. Vendor presets matter for other
unit types too - timers in particular - and there is no model for those.

Add a small companion that takes a unit-file suffix and returns a plain
{unit: 'enable'|'disable'} mapping. It reuses the same preset discovery and
parsing, so preset-file override and first-match-wins semantics stay identical.
Pure addition: no existing caller changes behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
When a package that already exists on the source system gains a systemd timer
on the target, the timer ends up disabled after an in-place upgrade even though
its vendor preset says 'enable': the %systemd_post scriptlet applies presets
only on a fresh install ($1 -eq 1), and leapp's systemd state transition scans
'--type=service' only, so non-service units are never transitioned.

Two packages migrate a cron job to a preset-enabled timer across EL8->EL9, and
both fail silently:
  - logrotate: /etc/cron.daily/logrotate -> logrotate.timer. Nothing rotates
    logs; they grow until the disk fills (ZD 284537, ~9.4 GB reclaimed).
  - mdadm: /etc/cron.d/raid-check -> raid-check.timer. The weekly software RAID
    consistency check never runs, so latent sector errors accumulate undetected
    and surface as an unrecoverable read error during an array rebuild.

Rather than curate a list of affected timers, record the source system's timer
inventory during the Facts phase and, on first boot, enable only timers that are
absent from it, disabled, and preset-enabled. A timer that did not exist on the
source cannot have been disabled by the administrator, so applying the target
preset is safe by construction - the same rule the service-state transition uses
for units new on the target. Timers present on the source keep their state, so a
deliberate 'systemctl disable' always survives the upgrade. Without the source
inventory the actor does nothing rather than guess.

Validated CL8 -> CL9.8 on a no-panel VM with mdadm installed: logrotate.timer
and raid-check.timer both enabled and active afterwards (next runs scheduled),
while mdcheck_start/mdcheck_continue/mdmonitor-oneshot - present but disabled on
the source - were correctly left untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant