|
| 1 | +""" |
| 2 | +Detect a target-repository state where the available kernel's minor version |
| 3 | +is greater than the available `cloudlinux-release`'s minor version. |
| 4 | +
|
| 5 | +This is the gradual-rollout-leak shape behind CLOS-3716 (ZD 268790): the CLN |
| 6 | +package channel `cloudlinux-x86_64-server-9` resolves to the latest minor |
| 7 | +available to a system's gradual-rollout slot, and a new minor's kernel is |
| 8 | +typically promoted ahead of the rest of the userland. During an in-flight |
| 9 | +rollout, elevate can therefore pull a 9.7 kernel onto a 9.6 userland; the |
| 10 | +matching kmod-lve is built per kernel minor, so the new default-boot kernel |
| 11 | +fails to load kmodlve and breaks LVE. |
| 12 | +
|
| 13 | +The actor consuming this library inhibits at Checks phase whenever the |
| 14 | +target repositories expose this mismatch, so the upgrade refuses to proceed |
| 15 | +while rollout content is in flight. This is defense in depth - it catches |
| 16 | +the leak regardless of which CL repo (CLN channel, no-auth fallback, etc.) |
| 17 | +ends up serving the newer-minor kernel. |
| 18 | +""" |
| 19 | + |
| 20 | +import re |
| 21 | + |
| 22 | +from leapp import reporting |
| 23 | +from leapp.libraries.common.config.version import get_target_major_version |
| 24 | +from leapp.libraries.stdlib import CalledProcessError, api, run |
| 25 | + |
| 26 | + |
| 27 | +# CloudLinux/RHEL dist-tag with minor version: e.g. |
| 28 | +# 611.5.1.el9_7 -> (9, 7) |
| 29 | +# 570.12.1.el9_6.x86_64 -> (9, 6) |
| 30 | +# 1.0-1.el8_10 -> (8, 10) |
| 31 | +# 1.0-1.el8_6.cloudlinux -> (8, 6) |
| 32 | +_DIST_MINOR_RE = re.compile(r'\.el(\d+)_(\d+)(?:\.|$)') |
| 33 | + |
| 34 | +# cloudlinux-release versions are "<major>.<minor>" - e.g. "9.6", "8.10". |
| 35 | +_RELEASE_MINOR_RE = re.compile(r'^(\d+)\.(\d+)(?:\.|$)') |
| 36 | + |
| 37 | + |
| 38 | +def parse_kernel_minor(release_str, target_major): |
| 39 | + """Return the minor version embedded in a kernel RPM release string, |
| 40 | + or None if not present or not for the target major. |
| 41 | +
|
| 42 | + The target-major filter is critical: the target userspace's repoquery |
| 43 | + returns packages from both source (e.g. CL8) and target (e.g. CL9) repos, |
| 44 | + and dist-tagged minors must not be conflated across majors |
| 45 | + (e.g. `el8_10` minor 10 is meaningless when comparing against an |
| 46 | + `el9_*` kernel). |
| 47 | + """ |
| 48 | + if not release_str: |
| 49 | + return None |
| 50 | + m = _DIST_MINOR_RE.search(release_str) |
| 51 | + if not m: |
| 52 | + return None |
| 53 | + if int(m.group(1)) != int(target_major): |
| 54 | + return None |
| 55 | + return int(m.group(2)) |
| 56 | + |
| 57 | + |
| 58 | +def parse_release_minor(version_str, target_major): |
| 59 | + """Return the minor version from a cloudlinux-release RPM version field, |
| 60 | + or None if not present or not for the target major. |
| 61 | +
|
| 62 | + See `parse_kernel_minor` for why the major-filter matters. |
| 63 | + """ |
| 64 | + if not version_str: |
| 65 | + return None |
| 66 | + m = _RELEASE_MINOR_RE.match(version_str) |
| 67 | + if not m: |
| 68 | + return None |
| 69 | + if int(m.group(1)) != int(target_major): |
| 70 | + return None |
| 71 | + return int(m.group(2)) |
| 72 | + |
| 73 | + |
| 74 | +def _repoquery(installroot, pkg): |
| 75 | + """Query the target userspace container for available versions of `pkg`. |
| 76 | +
|
| 77 | + Returns a list of (version, release) tuples. Empty list on error or |
| 78 | + nothing available - callers treat absence as "cannot determine" rather |
| 79 | + than as evidence of safety. |
| 80 | + """ |
| 81 | + cmd = [ |
| 82 | + 'dnf', '-q', 'repoquery', |
| 83 | + '--installroot={}'.format(installroot), |
| 84 | + '--available', |
| 85 | + '--queryformat=%{version}|%{release}\n', |
| 86 | + pkg, |
| 87 | + ] |
| 88 | + try: |
| 89 | + result = run(cmd, split=False) |
| 90 | + except (OSError, CalledProcessError) as exc: |
| 91 | + api.current_logger().warning( |
| 92 | + 'repoquery for %s in %s failed: %s', pkg, installroot, exc |
| 93 | + ) |
| 94 | + return [] |
| 95 | + rows = [] |
| 96 | + for line in (result.get('stdout') or '').splitlines(): |
| 97 | + line = line.strip() |
| 98 | + if not line or '|' not in line: |
| 99 | + continue |
| 100 | + version, release = line.split('|', 1) |
| 101 | + rows.append((version, release)) |
| 102 | + return rows |
| 103 | + |
| 104 | + |
| 105 | +def _max_kernel_minor(rows, target_major): |
| 106 | + """Highest minor across kernel rows for the target major; None if no row matches.""" |
| 107 | + minors = [parse_kernel_minor(release, target_major) for _, release in rows] |
| 108 | + minors = [m for m in minors if m is not None] |
| 109 | + return max(minors) if minors else None |
| 110 | + |
| 111 | + |
| 112 | +def _max_release_minor(rows, target_major): |
| 113 | + """Highest minor across cloudlinux-release rows for the target major; None if no row matches.""" |
| 114 | + minors = [parse_release_minor(version, target_major) for version, _ in rows] |
| 115 | + minors = [m for m in minors if m is not None] |
| 116 | + return max(minors) if minors else None |
| 117 | + |
| 118 | + |
| 119 | +def _report_inhibitor(target_major, kernel_minor, release_minor): |
| 120 | + reporting.create_report([ |
| 121 | + reporting.Title( |
| 122 | + 'CloudLinux target repositories are mid gradual-rollout: kernel newer than userland' |
| 123 | + ), |
| 124 | + reporting.Summary( |
| 125 | + 'The target CloudLinux repositories currently offer a kernel from minor el{major}_{kminor}' |
| 126 | + ' while the newest available cloudlinux-release is for minor {major}.{rminor}.' |
| 127 | + ' CloudLinux gradual-rollouts typically promote the kernel ahead of the rest of the' |
| 128 | + ' userland, and running the upgrade now would install a kernel newer than the matching' |
| 129 | + ' kmod-lve and other per-kernel-minor packages. On reboot the new default-boot kernel' |
| 130 | + ' would fail to load kmodlve, breaking LVE.'.format( |
| 131 | + major=target_major, kminor=kernel_minor, rminor=release_minor, |
| 132 | + ) |
| 133 | + ), |
| 134 | + reporting.Remediation( |
| 135 | + hint='Re-run the upgrade once the new CloudLinux minor has finished its gradual rollout' |
| 136 | + ' (target repositories should then offer a cloudlinux-release matching the kernel' |
| 137 | + ' minor). Alternatively, pin the target repositories to a single, fully-released minor.' |
| 138 | + ), |
| 139 | + reporting.Severity(reporting.Severity.HIGH), |
| 140 | + reporting.Groups([ |
| 141 | + reporting.Groups.INHIBITOR, |
| 142 | + reporting.Groups.KERNEL, |
| 143 | + reporting.Groups.REPOSITORY, |
| 144 | + reporting.Groups.UPGRADE_PROCESS, |
| 145 | + ]), |
| 146 | + ]) |
| 147 | + |
| 148 | + |
| 149 | +def process(installroot, query_fn=None, target_major=None): |
| 150 | + """Inhibit when newest available kernel's minor exceeds newest available |
| 151 | + cloudlinux-release's minor in the target userspace's repositories. |
| 152 | + """ |
| 153 | + if query_fn is None: |
| 154 | + query_fn = _repoquery |
| 155 | + if target_major is None: |
| 156 | + target_major = get_target_major_version() |
| 157 | + |
| 158 | + kernel_rows = query_fn(installroot, 'kernel-core') |
| 159 | + release_rows = query_fn(installroot, 'cloudlinux-release') |
| 160 | + |
| 161 | + k_minor = _max_kernel_minor(kernel_rows, target_major) |
| 162 | + r_minor = _max_release_minor(release_rows, target_major) |
| 163 | + |
| 164 | + if k_minor is None or r_minor is None: |
| 165 | + api.current_logger().info( |
| 166 | + 'Skipping kernel-minor check: could not determine both minors' |
| 167 | + ' (kernel=%s, release=%s)', k_minor, r_minor, |
| 168 | + ) |
| 169 | + return |
| 170 | + |
| 171 | + api.current_logger().info( |
| 172 | + 'Target userspace: newest kernel-core minor=el%s_%s, newest cloudlinux-release minor=%s.%s', |
| 173 | + target_major, k_minor, target_major, r_minor, |
| 174 | + ) |
| 175 | + if k_minor > r_minor: |
| 176 | + _report_inhibitor(target_major, k_minor, r_minor) |
0 commit comments