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
1 change: 1 addition & 0 deletions packaging/leapp-repository.spec
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ done;
- CLOS-4518: Fix systemd timers that replace a cron job on the new system being left disabled after the upgrade
- CLOS-6809: Fix the upgrade being blocked on servers running MySQL Governor with MariaDB 11.4 or 11.8, where the installed database version was reported in a form that the cl-mysql repository check did not recognise
- Determine the database module to enable from the configured cl-mysql repository, so that newly released CloudLinux MySQL and MariaDB versions are upgraded correctly without waiting for a Leapp update
- CLOS-7025: Fix CageFS users no longer entering the cage through 'su' after the upgrade, by reinstalling the CageFS hooks on the first boot: they cannot be installed from inside the upgrade transaction, where cagefsctl is unable to run

* Thu Aug 06 2026 Roman Prilipskii <rprilipskii@cloudlinux.com> - 0.20.0-10.cloudlinux
- CLOS-2132: Warn before the upgrade when the PostgreSQL configuration would keep the database from starting on the new system, and spell out the migration steps to run afterwards
Expand Down
31 changes: 11 additions & 20 deletions repos/system_upgrade/cloudlinux/actors/updatecagefs/actor.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import os

from leapp.actors import Actor
from leapp.libraries.stdlib import run, CalledProcessError
from leapp.reporting import Report, create_report
from leapp.tags import FirstBootPhaseTag, IPUWorkflowTag
from leapp.libraries.actor import updatecagefs
from leapp.libraries.common.cllaunch import run_on_cloudlinux
from leapp.reporting import Report
from leapp.tags import FirstBootPhaseTag, IPUWorkflowTag


class UpdateCagefs(Actor):
"""
Force update of cagefs.
Reinstall the CageFS hooks and force an update of cagefs.

cagefs should reflect massive changes in system made in previous phases.

cagefs should reflect massive changes in system made in previous phases
The hooks need reinstalling because CageFS installs them from its own
%posttrans scriptlet and cagefsctl cannot run inside the upgrade
transaction, which on Plesk leaves CageFS users unable to enter the cage
through 'su'. See the actor library for the detail.
"""

name = 'update_cagefs'
Expand All @@ -21,16 +24,4 @@ class UpdateCagefs(Actor):

@run_on_cloudlinux
def process(self):
if os.path.exists('/usr/sbin/cagefsctl'):
try:
run(['/usr/sbin/cagefsctl', '--force-update'], checked=True)
self.log.info('cagefs update was successful')
except CalledProcessError as e:
# cagefsctl prints errors in stdout
self.log.error(e.stdout)
self.log.error(
'Command "cagefsctl --force-update" finished with exit code {}, '
'the filesystem inside cagefs may be out-of-date.\n'
'Check cagefsctl output above and in /var/log/cagefs-update.log, '
'rerun "cagefsctl --force-update" after fixing the issues.'.format(e.exit_code)
)
updatecagefs.process()
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import os

from leapp import reporting
from leapp.libraries.stdlib import api, CalledProcessError, run

CAGEFSCTL = '/usr/sbin/cagefsctl'
PAM_SU_CONFIG = '/etc/pam.d/su'


def _pam_lve_configured():
"""
Whether /etc/pam.d/su carries a pam_lve session line.

Same shape as the check cldiag runs through
clcommon.clconfpars.parse_pam_lve_config(): the first non-comment line
whose third field is pam_lve.so. Parsed here rather than imported, so that
a first-boot actor does not depend on the CloudLinux venv being healthy.

An unreadable config counts as not configured - it cannot be claimed as
configured on evidence nobody has.
"""
try:
with open(PAM_SU_CONFIG) as f:
for line in f:
if line.startswith('#'):
continue
fields = line.split()
if len(fields) >= 3 and fields[2] == 'pam_lve.so':
return True
except (IOError, OSError):
return False
return False


def _run_cagefsctl(option):
"""
Run one cagefsctl subcommand, logging whatever went wrong.

A cagefsctl that is missing or not executable - the shape of leapp's own
zero-byte /sbin/new-kernel-pkg stub - raises OSError out of run() rather
than CalledProcessError. Neither may abort this actor and cost the steps
that follow.

:return: True when the command ran and exited zero
:rtype: bool
"""
try:
run([CAGEFSCTL, option], checked=True)
return True
except CalledProcessError as e:
# cagefsctl prints errors in stdout
api.current_logger().error(e.stdout)
api.current_logger().error(
'Command "cagefsctl {}" finished with exit code {}.'.format(option, e.exit_code)
)
except OSError as e:
api.current_logger().error(
'Command "cagefsctl {}" could not be executed: {}'.format(option, e)
)
return False


def _reinstall_hooks():
"""
Reinstall the CageFS hooks, which the upgrade transaction could not.

CageFS installs its hooks from its own %posttrans scriptlet, and does it in
two steps: the CageFS control panel plugin runs first and, on Plesk,
deliberately strips the pam_lve.so and pam_sulve.so lines from
/etc/pam.d/su, then the 'cagefsctl --hook-install' that follows is what puts
the pam_lve.so line back. Inside the upgrade transaction cagefsctl cannot
run at all - the system is not booted with systemd as PID 1 and the process
cannot look itself up in /proc, so cagefsctl fails on startup while setting
up its logging - which leaves only the stripping half of that pair in
effect. CageFS users then stop entering the cage through 'su', and cldiag
reports the pam_lve configuration as missing from /etc/pam.d/su.

The first boot is a fully booted system, where cagefsctl works, so running
the same command here installs what the package intended. It is idempotent:
where the hooks are intact it rewrites the very same configuration.

The exit code cannot be trusted to tell us whether that worked. cagefsctl
calls HooksInstall() and then exits zero unconditionally, and the pam edit
inside it swallows IOError and OSError into a printed message - so an
immutable or read-only /etc/pam.d/su leaves CageFS users uncaged behind a
clean exit code. Check the resulting configuration instead, and report it,
because a silent loss of confinement on a successful-looking upgrade is
not something to leave in a debug log.
"""
_run_cagefsctl('--hook-install')

if _pam_lve_configured():
api.current_logger().info('cagefs hooks were reinstalled successfully')
return

api.current_logger().error(
'The pam_lve configuration is still missing from {} after running '
'"cagefsctl --hook-install", so cagefs users will not enter the cage '
'through "su".\n'
'Check cagefsctl output above, and whether {} is writable, then rerun '
'"cagefsctl --hook-install".'.format(PAM_SU_CONFIG, PAM_SU_CONFIG)
)
reporting.create_report([
reporting.Title('CageFS users may not enter the cage through "su"'),
reporting.Summary(
'The pam_lve configuration is missing from {}, so a CageFS user who'
' enters a shell through "su" gets an uncaged one. CageFS installs'
' that line from its own %posttrans scriptlet, which cannot run'
' inside the upgrade transaction, and reinstalling the hooks on this'
' boot did not restore it either. Note that'
' "cagefsctl --hook-install" exits zero even when the edit fails, so'
' its output may look clean.\n'
'Restore the configuration by running "cagefsctl --hook-install" and'
' confirm it with "cldiag --all" or "cagefsctl --sanity-check".'
.format(PAM_SU_CONFIG)
),
reporting.Severity(reporting.Severity.HIGH),
reporting.Groups([reporting.Groups.POST, reporting.Groups.SECURITY]),
])


def _force_update():
if _run_cagefsctl('--force-update'):
api.current_logger().info('cagefs update was successful')
return

api.current_logger().error(
'The filesystem inside cagefs may be out-of-date.\n'
'Check cagefsctl output above and in /var/log/cagefs-update.log, '
'rerun "cagefsctl --force-update" after fixing the issues.'
)


def process():
if not os.path.exists(CAGEFSCTL):
return

# Scope: CageFS's %posttrans makes 25 cagefsctl calls and this restores two
# of them. --force-update rebuilds the skeleton and the jails and covers
# none of the rest - --setup-cl-selector, --update-wrappers,
# --reconfigure-cagefs, --isolates-regenerate, --sync-proxy-commands and
# the others are separate option handlers that nothing in the update path
# reaches. They are lost to the same crash, and the fix for that belongs in
# clcommon, where it restores all 25 at once: the transaction upgrades the
# CloudLinux venv and cllib early (around step 1.5k of 7k) and cagefs's
# %posttrans runs last, so the copy that crashes is the target system's, and
# a fixed cllib in the target repositories is enough - the source system's
# does not come into it. Until that ships, this covers the one call whose
# absence silently stops confining tenants.
#
# The hooks first: the cage rebuild copies /etc from the host system, so the
# cages get the corrected /etc/pam.d files rather than the stripped ones.
_reinstall_hooks()
_force_update()
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@

import pytest

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

HOOK_INSTALL = [updatecagefs.CAGEFSCTL, '--hook-install']
FORCE_UPDATE = [updatecagefs.CAGEFSCTL, '--force-update']

PAM_SU_WITH_LVE = """#%PAM-1.0
auth\t\trequired\tpam_env.so
session\t\tinclude\t\tsystem-auth
session required pam_lve.so 500 1
"""
PAM_SU_STOCK = """#%PAM-1.0
auth\t\trequired\tpam_env.so
session\t\tinclude\t\tsystem-auth
"""


class _RunMocked(object):
"""
Fake leapp `run` that records every cagefsctl invocation and can be told to
fail for a chosen set of commands, with either exception `run` documents.
"""

def __init__(self, failing=(), exc=CalledProcessError):
self.failing = [list(cmd) for cmd in failing]
self.exc = exc
self.commands = []

def __call__(self, cmd, checked=True):
self.commands.append(cmd)
if cmd in self.failing:
if self.exc is OSError:
raise OSError(2, 'No such file or directory', cmd[0])
raise CalledProcessError(
'boom', cmd, {'exit_code': 1, 'stdout': 'cagefsctl output', 'stderr': ''}
)
return {'stdout': ''}


def _setup(monkeypatch, tmp_path, run_mock, cagefsctl_present=True, pam_su=PAM_SU_WITH_LVE):
"""
Point the library at a real /etc/pam.d/su stand-in, so the pam check runs
for real rather than being stubbed out.
"""
monkeypatch.setattr(api, 'current_actor', CurrentActorMocked())
monkeypatch.setattr(api, 'current_logger', logger_mocked())
monkeypatch.setattr(updatecagefs, 'run', run_mock)
monkeypatch.setattr(
updatecagefs.os.path, 'exists',
lambda path: cagefsctl_present if path == updatecagefs.CAGEFSCTL else False
)
if pam_su is not None:
pam_file = tmp_path / 'su'
pam_file.write_text(pam_su)
monkeypatch.setattr(updatecagefs, 'PAM_SU_CONFIG', str(pam_file))
else:
monkeypatch.setattr(updatecagefs, 'PAM_SU_CONFIG', str(tmp_path / 'absent'))
reports = create_report_mocked()
monkeypatch.setattr(reporting, 'create_report', reports)
return reports


def test_hooks_are_reinstalled_before_the_cage_rebuild(monkeypatch, tmp_path):
"""
CageFS reinstalls its PAM hooks from its own %posttrans scriptlet, but
cagefsctl cannot run inside the upgrade transaction, so on Plesk the
pam_lve.so line that the CageFS plugin strips from /etc/pam.d/su is never
put back and CageFS users stop entering the cage through `su`.

The hooks have to be reinstalled before the cage rebuild, so that the
corrected /etc/pam.d files are what gets copied into the cages.
"""
run_mock = _RunMocked()
reports = _setup(monkeypatch, tmp_path, run_mock)

updatecagefs.process()

assert run_mock.commands == [HOOK_INSTALL, FORCE_UPDATE]
assert reports.called == 0


def test_nothing_runs_without_cagefsctl(monkeypatch, tmp_path):
run_mock = _RunMocked()
reports = _setup(monkeypatch, tmp_path, run_mock, cagefsctl_present=False)

updatecagefs.process()

assert not run_mock.commands
assert reports.called == 0


@pytest.mark.parametrize('exc', [CalledProcessError, OSError])
def test_failed_hook_install_does_not_skip_the_cage_rebuild(monkeypatch, tmp_path, exc):
"""
The cage rebuild reflects the whole upgrade and must not be lost because
the far smaller hook reinstall failed. A cagefsctl that exists but is not
executable - the shape of leapp's own zero-byte new-kernel-pkg stub - makes
`run` raise OSError rather than CalledProcessError, and that must not abort
the actor either.
"""
run_mock = _RunMocked(failing=[HOOK_INSTALL], exc=exc)
_setup(monkeypatch, tmp_path, run_mock, pam_su=PAM_SU_STOCK)

updatecagefs.process()

assert run_mock.commands == [HOOK_INSTALL, FORCE_UPDATE]


@pytest.mark.parametrize('exc', [CalledProcessError, OSError])
def test_a_failing_cage_rebuild_is_survived_and_logged(monkeypatch, tmp_path, exc):
run_mock = _RunMocked(failing=[FORCE_UPDATE], exc=exc)
_setup(monkeypatch, tmp_path, run_mock)

updatecagefs.process()

errors = '\n'.join(api.current_logger().errmsg)
assert '--force-update' in errors


def test_silent_hook_install_failure_is_caught_by_the_outcome_check(monkeypatch, tmp_path):
"""
`cagefsctl --hook-install` exits zero whatever happens: cagefsctl calls
HooksInstall() and then sys.exit(0) unconditionally, and configure_pam_lve
swallows IOError/OSError into a printed message. So an immutable or
read-only /etc/pam.d/su leaves CageFS users uncaged with a clean exit code.
Only checking the resulting configuration catches that.
"""
run_mock = _RunMocked()
reports = _setup(monkeypatch, tmp_path, run_mock, pam_su=PAM_SU_STOCK)

updatecagefs.process()

assert run_mock.commands == [HOOK_INSTALL, FORCE_UPDATE]
assert reports.called == 1
assert 'su' in reports.report_fields['title']
assert reporting.Severity.HIGH == reports.report_fields['severity']
errors = '\n'.join(api.current_logger().errmsg)
assert '--hook-install' in errors


def test_commented_out_pam_lve_line_does_not_count_as_configured(monkeypatch, tmp_path):
run_mock = _RunMocked()
reports = _setup(
monkeypatch, tmp_path, run_mock,
pam_su=PAM_SU_STOCK + '#session required pam_lve.so 500 1\n'
)

updatecagefs.process()

assert reports.called == 1


def test_unreadable_pam_config_is_reported_rather_than_assumed_fine(monkeypatch, tmp_path):
run_mock = _RunMocked()
reports = _setup(monkeypatch, tmp_path, run_mock, pam_su=None)

updatecagefs.process()

assert reports.called == 1
Loading