Skip to content

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

Merged
azheregelya merged 5 commits into
cloudlinux:cloudlinuxfrom
prilr:CLOS-4518-elevate-cl8-cl9-disables-logrotate
Aug 13, 2026
Merged

azheregelya merged 5 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>
@azheregelya

Copy link
Copy Markdown
Collaborator

Codex review — Request changes

I found two blocking regressions and one major correctness gap.

1. Blocking: target-only handling defeats the libvirt exclusion

_filter_ignored_services() implements the CL8+ libvirt exclusion by removing virtqemud.service and related names from services_source. The changed target filter then retains those source-absent units and reclassifies them as new. With the CL9 preset enable virtqemud.service, a disabled target is queued in to_enable, reversing the exclusion added to prevent invalid monolithic/modular libvirt combinations.

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

get_system_unit_presets() relies on _get_system_preset_files(), which sorts absolute paths, placing all /etc files before all /usr files. systemd instead resolves same-name directory overrides and then sorts retained files by basename. For /usr/.../10-vendor.preset: disable foo.timer plus /etc/.../99-local.preset: enable foo.timer, systemd selects disable; this helper returns enable, and the caller starts the timer. /run/systemd/system-preset is also omitted.

Please match the ordering and override rules from systemd.preset(5), or delegate effective-preset evaluation to systemd, with mixed-directory tests.

Preset discovery · New helper

3. Major: template-timer preset instances are lost

_parse_preset_entry() hardcodes .service when expanding template instances, so enable backup@.timer daily becomes backup@daily.service; the new .timer filter removes it. The actor also selects candidates only from list-unit-files, which may not contain an unenabled preset instance.

Please preserve the template's actual suffix, include preset-declared instances in candidate selection, and add an end-to-end timer-template test.

Parser and helper

Validation

  • 72 focused tests passed.
  • Changed-file Flake8, isort, non-ASCII lint, and git diff --check passed.
  • Security scan found no vulnerabilities.
  • The passing tests do not cover the scenarios above.

— Codex review

prilr and others added 2 commits August 11, 2026 10:00
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>
@prilr

prilr commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Finding 1 is a real regression in this PR. Fixed, along with 3. 2 left as is, with reasoning below.

1. Libvirt exclusion defeated - fixed

Reproduced before fixing:

services_source after _filter_ignored_services: {}
kept by _filter_irrelevant_services: ['virtqemud.service']
desired_states: {'virtqemud.service': 'enabled'}
to_enable: ['virtqemud.service']

Fix stops conflating ignored with absent: _filter_ignored_services() returns the excluded names, _filter_irrelevant_services() drops those target units too, and the now-false comment is corrected.

Added the full-process regression test.

3. Template-instance unit type - fixed

Confirmed: _parse_preset_entry() hardcoded .service, so enable template@.timer daily weekly produced template@daily.service / template@weekly.service and the type filter dropped them.

Now derived from the matched template file via splitext. Added a .timer template fixture and a parametrized case; also corrected the disable * and _parse_preset_files expectations that the new fixture affects.

Note that no EL8-EL9 preset declares template instances for a non-service unit - on a stock CL9 the only @ entries are getty@.service and getty@tty1.service, and there are no template-with-instance entries at all.

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 list-unit-files-based scan - covering them means enumerating preset-declared instances, which is a larger change with no current driver.

The model docstring already states instances are out of scope.

2. Preset ordering - real, but deliberately outside of this PR

The mechanism is correct, including /run/systemd/system-preset being omitted. _get_system_preset_files() sorts absolute paths, so every /etc file precedes every /usr file, whereas systemd.preset(5) sorts by basename across directories. Your example holds.

The reason it was left outside of scope: this PR doesn't introduce it, and it isn't specific to timers.

256:    preset_files = _get_system_preset_files()      # get_system_unit_presets      (new)
257:    presets = _parse_preset_files(...)
276:    preset_files = _get_system_preset_files()      # get_system_service_preset_files (existing)
277:    presets = _parse_preset_files(...)

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.

@azheregelya azheregelya self-assigned this Aug 13, 2026
@azheregelya
azheregelya merged commit b3d4fd5 into cloudlinux:cloudlinux Aug 13, 2026
1 check passed
azheregelya pushed a commit that referenced this pull request Aug 13, 2026
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>
prilr added a commit to prilr/leapp-repository that referenced this pull request Sep 9, 2026
…sults

The single upstreamable-changes.md would not scale: each entry needs the
technical shape of the upstream version, the evidence that upstream does not
already cover it, and the rebase action, which is several paragraphs per
change. Move to docs/upstreamable/ with one file per change and an index.

Then sweep the fork's whole commit log for candidates we had never assessed:
294 commits from upstream base 52f3a15 (2024-08-20) to 54d7d17, checked
against oamg/main 65ca51d (0.25.0) and AlmaLinux/almalinux-ng-0.24.0.

New candidates:
- CLOS-3465: inhibit on _netdev in fstab without nofail. Extends an
  upstream-owned actor along the axis it already documents; _netdev appears
  nowhere in either upstream tree.
- restore-repository-states (cloudlinux#69): put back repo enabled/disabled state that
  RPM updates clobber during the transaction. No equivalent upstream.
- repomaputils-bom: AlmaLinux still ships the UTF-8 BOM we removed.
- to-keep-excluded-from-to-upgrade: filed as a question, not a patch. Whether
  to_keep should suppress upgrades or only removals is genuinely ambiguous
  upstream, and the answer decides bug fix versus behaviour change.

Closed with evidence:
- vendors-and-to-reinstall: our to_reinstall mechanism was merged into
  AlmaLinux via PR AlmaLinux#114 (2024-06-20). Documents the route that works, and
  that oamg still lacks it.
- CLOS-2816: upstream capped disk image size at 1TB in cef2825 (2024-10-25),
  seven months before our PC_FILESIZEBITS version. Ours becomes unreachable
  dead code after a rebase, with no conflict to announce it.
- dnfconfig-error-details: upstream did the same thing better in bcc445b
  (2026-04-24), 15 months after ours. We had it first and never offered it,
  which is the cost this list exists to avoid.
- el7toel8-retired-upstream: upstream deleted the el7toel8 repo (b6e84f7,
  2025-06-04), so anything EL7-only has no upstream home at all. Closes a
  class, including a real Red Hat postgresql-upgrade defect (CLOS-2132).
- CLOS-2610: unchanged verdict, moved to its own file.

Also records a sweep watermark and the screened-out commits with reasons, so
the next sweep starts at 54d7d17 instead of repeating this one, and documents
the two traps that cost time here: oamg has moved modules behind shims (a
path-based check reads as "upstream lacks this" when it does not), and a plain
diff against upstream mixes our changes with upstream's later work.

Upstreamable: common/checkmountoptions - _netdev without nofail
Upstreamable: common/dnfconfig - restore repo enabled state after upgrade
Upstreamable: AlmaLinux repomaputils.py - strip UTF-8 BOM
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Worked-On: cl-aiworkspaces
prilr added a commit to prilr/leapp-repository that referenced this pull request Sep 9, 2026
Three entries out of CLOS-4518 and the review of PR cloudlinux#69, plus the two rebase
hazards they expose.

The timer-migration entry is the load-bearing one: upstream already solved the
narrow case in oamg PR 1501 (el8toel9/actors/enablelogrotatetimer, RHEL-17361),
that actor is absent here but present in AlmaLinux/almalinux-ng-0.24.0, and it is
unconditional where ours is deliberately not. So the next rebase imports something
that silently overrides our administrator-intent rule as a clean add - exactly the
class of thing this directory exists to catch, since git will not flag it.

The preset-file-ordering entry carries no fix of ours. It is recorded because the
analysis cost real effort and the conclusion - that the fix cannot be scoped to one
caller without making services and timers disagree - is what makes it upstream's
change rather than a fork patch.

Not moving the sweep watermark: these came out of one ticket, not a fresh sweep of
the post-54d7d176 delta.

Upstreamable: common/ - cron-to-timer preset migration, replacing el8toel9/enablelogrotatetimer
Upstreamable: common/libraries/systemd.py - preset template instances expand to .service
Upstreamable: common/libraries/systemd.py - preset files ordered by path, not filename
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Worked-On: cl-aiworkspaces
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.

2 participants