CLOS-4518: Reenable systemd timers left disabled by cron-to-timer migrations - #69
Conversation
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>
Codex review — Request changesI found two blocking regressions and one major correctness gap. 1. Blocking: target-only handling defeats the libvirt exclusion
Please carry an explicit ignored-name set into target filtering (or otherwise exclude these target units) and add a full-process regression test. Relevant code · Original exclusion commit 2. Blocking: preset ordering differs from systemd
Please match the ordering and override rules from 3. Major: template-timer preset instances are lost
Please preserve the template's actual suffix, include preset-declared instances in candidate selection, and add an end-to-end timer-template test. Validation
— Codex review |
The CL8+ libvirt exclusion works by removing the libvirtd/virt*d names from the source inventory, on the reasoning that "if a service is not present on the source system it's not handled either way". That reasoning depended on the absent-on-source guard removed in the preceding commit: with target-only units now retained and resolved against the target preset, a popped name reads as a unit new on the target, and the CL9 preset "enable virtqemud.service" puts it straight into to_enable. That reinstates the invalid monolithic/modular libvirt combination the exclusion exists to prevent. Distinguish "ignored" from "absent": _filter_ignored_services() now returns the names it excludes, and _filter_irrelevant_services() drops those target units as well. Also fix the comment that documented the old, now-wrong reasoning. Found in review of PR cloudlinux#69. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_parse_preset_entry() hardcoded '.service' when expanding a preset entry that names template instances, so "enable backup@.timer daily" yielded backup@daily.service. Harmless while only service presets were consumed; now that presets are read per unit type, such an entry is attributed to the wrong type and dropped by the type filter. Derive the suffix from the matched template unit file instead. No EL8->EL9 preset currently declares template instances for a non-service unit, so this is a latent fix rather than a behavior change on any supported upgrade path. Found in review of PR cloudlinux#69. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Finding 1 is a real regression in this PR. Fixed, along with 3. 2 left as is, with reasoning below. 1. Libvirt exclusion defeated - fixedReproduced before fixing: Fix stops conflating ignored with absent: Added the full-process regression test. 3. Template-instance unit type - fixedConfirmed: Now derived from the matched template file via Note that no EL8-EL9 preset declares template instances for a non-service unit - on a stock CL9 the only Did not take the second half of that finding (include preset-declared instances in candidate selection). Template instances have no unit file, so they are inherently outside a The model docstring already states instances are out of scope. 2. Preset ordering - real, but deliberately outside of this PRThe mechanism is correct, including The reason it was left outside of scope: this PR doesn't introduce it, and it isn't specific to timers. Both helpers share the same discovery and parsing - deliberately, so override and first-match-wins semantics stay identical. Every existing service enable/disable decision already runs on this ordering. Correcting it only inside the new helper would leave services and timers disagreeing about the same preset files, which is worse than the current consistent behavior. I'll be filing a separate PR for this one. |
The CL8+ libvirt exclusion works by removing the libvirtd/virt*d names from the source inventory, on the reasoning that "if a service is not present on the source system it's not handled either way". That reasoning depended on the absent-on-source guard removed in the preceding commit: with target-only units now retained and resolved against the target preset, a popped name reads as a unit new on the target, and the CL9 preset "enable virtqemud.service" puts it straight into to_enable. That reinstates the invalid monolithic/modular libvirt combination the exclusion exists to prevent. Distinguish "ignored" from "absent": _filter_ignored_services() now returns the names it excludes, and _filter_irrelevant_services() drops those target units as well. Also fix the comment that documented the old, now-wrong reasoning. Found in review of PR #69. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem
After a CL8-CL9 upgrade,
logrotate.timeris left disabled despite a vendor preset ofenable, so logrotate never runs and logs grow unbounded.Reported by a customer:
/var/log/messagesandmodsec_audit.logreached substantial sizes.Opening investigation suspected
.rpmnew/.rpmsavecron 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:
logrotateships/etc/cron.daily/logrotateand no timer. EL9 drops the cron file and shipslogrotate.timer, vendor presetenable.logrotateis an RPM upgrade, so its%systemd_postscriptlet — guarded by[ $1 -eq 1 ]— does not apply thetimer's preset. Presets are only applied on a fresh install.
common/libraries/systemd.pyscans with_SYSTEMCTL_CMD_OPTIONS = ['--type=service', ...], andget_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:
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 disablealways 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
mdadminstalled:logrotate.timerraid-check.timermdcheck_start/mdcheck_continue/mdmonitor-oneshotmlocate-updatedb.timerThe 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.