Sunil Mohan Adapa 7f608cd570
*: Collect output for all privileged sub-processes
- Now that we have a mechanism for properly collecting, transmitting, and display
the stdout and stderr. There is no reason not to collect all of the stdin and
stderr.

- Also, the stdin/stderr=subprocess.PIPE is redundant and prevents the output
from getting collected for debugging. So, remove it.

Tests:

- Ran functional tests on backups, calibre, ejabberd, email, gitweb, ikiwiki,
infinoted, kiwix, mediawiki, mumble, nextcloud,, openvpn, samba, wireguard,
zoph. 2-3 issues were found but did not seem like new errors.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
2025-09-29 16:58:57 +03:00

431 lines
15 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""Perform distribution upgrade."""
import contextlib
import datetime
import logging
import pathlib
from datetime import timezone
from typing import Generator
import augeas
from plinth import action_utils
from plinth.modules import snapshot as snapshot_module
from . import utils
logger = logging.getLogger(__name__)
OBSOLETE_PACKAGES: list[str] = []
PACKAGES_WITH_PROMPTS = ['firewalld', 'minidlna', 'radicale', 'bind9']
PRE_DEBCONF_SELECTIONS: list[str] = [
# Tell grub-pc to continue without installing grub again.
'grub-pc grub-pc/install_devices_empty boolean true'
]
sources_list = pathlib.Path('/etc/apt/sources.list')
temp_sources_list = pathlib.Path('/etc/apt/sources.list.fbx-dist-upgrade')
wait_period_after_release = datetime.timedelta(days=30)
distribution_info: dict = {
'bullseye': {
'version': 11,
'next': 'bookworm',
'release_date': datetime.datetime(2021, 8, 14, tzinfo=timezone.utc),
},
'bookworm': {
'version': 12,
'next': 'trixie',
'release_date': datetime.datetime(2023, 6, 10, tzinfo=timezone.utc),
},
'trixie': {
'version': 13,
'next': 'forky',
'release_date': datetime.datetime(2025, 8, 9, tzinfo=timezone.utc),
},
'forky': {
'version': 14,
'next': 'duke',
'release_date': None
},
'duke': {
'version': 15,
'next': None,
'release_date': None
},
'testing': {
'version': None,
'next': None,
'release_date': None
},
'unstable': {
'version': None,
'next': None,
'release_date': None
}
}
def _apt_run(arguments: list[str]):
"""Run an apt command and ensure that output is written to stdout."""
returncode = action_utils.run_apt_command(arguments)
if returncode:
raise RuntimeError(
f'Apt command failed with return code: {returncode}')
def _sources_list_update(old_codename: str, new_codename: str):
"""Change the distribution in /etc/apt/sources.list."""
logger.info('Upgrading from %s to %s...', old_codename, new_codename)
aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
augeas.Augeas.NO_MODL_AUTOLOAD)
aug.transform('aptsources', str(sources_list))
aug.set('/augeas/context', '/files' + str(sources_list))
aug.set('/augeas/save', 'newfile') # Save to a new file
aug.load()
for match_ in aug.match('*'):
dist_path = match_ + '/distribution'
dist = aug.get(dist_path)
if dist in (old_codename, 'stable'):
aug.set(dist_path, new_codename)
elif dist and (dist.startswith(old_codename + '-')
or dist.startswith('stable' + '-')):
new_value = new_codename + '-' + dist.partition('-')[2]
aug.set(dist_path, new_value)
aug.save()
aug_path = sources_list.with_suffix('.list.augnew')
aug_path.rename(temp_sources_list)
def get_status() -> dict[str, bool | str | None]:
"""Check if a distribution upgrade be performed.
Check for new stable release, if updates are enabled, and if there is
enough free space for the dist upgrade.
Various outcomes:
- Unattended upgrades are not enabled.
- Distribution upgrades are not enabled.
- Not enough free space on the disk to perform dist upgrade.
- Dist upgrade already running.
- Codename in base-files package more recent than codename in sources.list.
Previous run of dist upgrade was interrupted.
- Could not determine the distribution. Mixed or unknown distribution.
- On testing/unstable rolling distributions. Nothing to do.
- On latest stable, no dist upgrade is available. Can upgrade to testing
(with codename).
- On old stable, waiting for cool-off period before upgrade. Manual upgrade
possible.
- On old stable, ready to do dist upgrade. Manual upgrade possible.
"""
from plinth.modules import upgrades
updates_enabled = utils.check_auto()
dist_upgrade_enabled = upgrades.is_dist_upgrade_enabled()
has_free_space = utils.is_sufficient_free_space()
running = action_utils.service_is_running('freedombox-dist-upgrade')
current_codename = utils.get_sources_list_codename()
status = {
'updates_enabled': updates_enabled,
'dist_upgrade_enabled': dist_upgrade_enabled,
'has_free_space': has_free_space,
'running': running,
'current_codename': current_codename,
'current_version': None,
'current_release_date': None,
'next_codename': None,
'next_version': None,
'next_release_date': None,
'next_action': None,
'next_action_date': None
}
if current_codename in (None, 'testing', 'unstable'):
return status
_, base_files_codename = utils.get_current_release()
if current_codename == 'stable':
current_codename = base_files_codename
if current_codename not in distribution_info:
return status
current_version = distribution_info[current_codename]['version']
current_release_date = distribution_info[current_codename]['release_date']
next_codename = distribution_info[current_codename]['next']
next_version = None
next_release_date = None
if next_codename:
next_version = distribution_info[next_codename]['version']
next_release_date = distribution_info[next_codename]['release_date']
next_action = None
now = datetime.datetime.now(tz=timezone.utc)
next_action_date = None
if next_release_date:
next_action_date = next_release_date + wait_period_after_release
if running:
next_action = None
elif base_files_codename == next_codename:
next_action = 'continue' # Previous run was interrupted
elif (not next_release_date or not updates_enabled
or not dist_upgrade_enabled or not has_free_space):
next_action = None
elif now >= next_action_date: # type: ignore
next_action = 'ready'
elif now < next_release_date:
next_action = 'manual'
else:
next_action = 'wait_or_manual'
status.update({
'current_codename': current_codename,
'current_version': current_version,
'current_release_date': current_release_date,
'next_codename': next_codename,
'next_version': next_version,
'next_release_date': next_release_date,
'next_action': next_action,
'next_action_date': next_action_date
})
return status
@contextlib.contextmanager
def _snapshot_run_and_disable() -> Generator[None, None, None]:
"""Take a snapshot if supported and enabled, then disable snapshots.
Snapshots shall be re-enabled, if originally enabled, on exiting this
context manager..
"""
if not snapshot_module.is_supported():
logger.info('Snapshots are not supported, skipping taking a snapshot.')
yield
return
reenable = False
try:
logger.info('Taking a snapshot before dist upgrade...')
command = ['snapper', 'create', '--description', 'before dist-upgrade']
action_utils.run(command, check=True)
aug = snapshot_module.load_augeas()
if snapshot_module.is_apt_snapshots_enabled(aug):
logger.info('Disabling apt snapshots during dist upgrade...')
action_utils.run([
'/usr/bin/freedombox-cmd',
'snapshot',
'disable_apt_snapshot',
], input='{"args": ["yes"], "kwargs": {}}'.encode(), check=True)
reenable = True
else:
logger.info('Apt snapshots already disabled.')
yield
finally:
if reenable:
logger.info('Re-enabling apt snapshots...')
action_utils.run([
'/usr/bin/freedombox-cmd', 'snapshot', 'disable_apt_snapshot'
], input='{"args": ["no"], "kwargs": {}}'.encode(), check=True)
else:
logger.info('Not re-enabling apt snapshots, as they were disabled '
'before dist upgrade.')
@contextlib.contextmanager
def _services_disable():
"""Disable services that are seriously impacted by the upgrade."""
# If quassel is running during dist upgrade, it may be restarted
# several times. This causes IRC users to rapidly leave/join
# channels. Stop quassel for the duration of the dist upgrade.
logger.info('Stopping quassel service during dist upgrade...')
with action_utils.service_ensure_stopped('quasselcore'):
yield
logger.info('Re-enabling quassel service if previously enabled...')
@contextlib.contextmanager
def _apt_hold_packages():
"""Apt hold some packages during dist upgrade."""
packages = PACKAGES_WITH_PROMPTS
packages_string = ', '.join(packages)
# Hold freedombox package during entire dist upgrade.
logger.info('Holding freedombox package...')
with action_utils.apt_hold_freedombox():
# Hold packages known to have conffile prompts. FreedomBox service
# will handle their upgrade later.
logger.info('Holding packages with conffile prompts: %s...',
packages_string)
with action_utils.apt_hold(packages):
yield
logger.info(
'Releasing holds on packages with conffile prompts: %s...',
packages_string)
logger.info('Releasing hold on freedombox package...')
def _debconf_set_selections() -> None:
"""Pre-set debconf selections if they are needed for dist upgrade."""
if PRE_DEBCONF_SELECTIONS:
logger.info('Setting debconf selections: %s', PRE_DEBCONF_SELECTIONS)
action_utils.debconf_set_selections(PRE_DEBCONF_SELECTIONS)
def _packages_remove_obsolete() -> None:
"""Remove obsolete packages.
These may prevent other packages from upgrading.
"""
if OBSOLETE_PACKAGES:
logger.info('Removing packages: %s...', OBSOLETE_PACKAGES)
_apt_run(['remove'] + OBSOLETE_PACKAGES)
def _apt_update():
"""Run 'apt update'."""
logger.info('Updating Apt cache...')
_apt_run(['update'])
def _apt_fix():
"""Try to fix any problems with apt/dpkg before the upgrade."""
logger.info('Fixing any broken apt/dpkg states...')
action_utils.run(['dpkg', '--configure', '-a'], check=False)
_apt_run(['--fix-broken', 'install'])
def _apt_autoremove():
"""Run 'apt autoremove'."""
logger.info('Running apt autoremove...')
_apt_run(['autoremove'])
def _apt_full_upgrade():
"""Run and check if apt upgrade was successful.
If the user has changed a configuration file of a package outside of
FreedomBox, the distribution upgrade process could face a configuration
file prompt and fail midway. When using unattended-upgrades, these packages
are not a problem as they would left untouched at an old version and the
rest of the system would be upgraded. In case of distribution upgrade,
these packages could cause the distribution upgrade to fail and leave the
system in an unusable state. Rather than halt distribution upgrade midway
due to a configuration file prompt, it is better to overwrite with the new
configuration. Backup copy of the old configuration will be available to
the user to later merge with the new configuration.
For packages managed by FreedomBox, packages with configuration file prompt
will be held back during upgrade and later carefully upgraded with merge.
These package are not subject to --force-confnew option.
"""
logger.info('Running apt full-upgrade...')
_apt_run(['full-upgrade', '-o', 'Dpkg::Options::=--force-confnew'])
def _unattended_upgrades_run():
"""Run unattended-upgrade once more.
To handle upgrading the freedombox package.
"""
logger.info('Running unattended-upgrade...')
action_utils.run(['unattended-upgrade', '--verbose'], check=False)
def _freedombox_restart():
"""Restart FreedomBox service.
To ensure it is using the latest dependencies.
"""
logger.info('Restarting FreedomBox service...')
action_utils.service_restart('plinth')
def _trigger_on_complete():
"""Trigger the on complete step in a separate service."""
# The dist-upgrade process will be run /etc/apt/sources.list file bind
# mounted on with a modified file. So, moving modified file to the original
# file will not be possible. For that, we need to launch a new process with
# a different systemd service (which does not have the bind mounts).
logger.info('Triggering on-complete to commit sources.lists')
action_utils.run([
'systemd-run', '--unit=freedombox-dist-upgrade-on-complete',
'--description=Finish up upgrade to new stable Debian release',
'/usr/bin/freedombox-cmd', 'upgrades', 'dist_upgrade_on_complete',
'--no-args'
], check=True)
def _logging_setup():
"""Log to journal via console logging.
We need to capture all console logs created by apt and other commands and
redirect them to journal. This is the default behavior when launching a
service with systemd-run.
Avoid double logging to the journal by removing the systemd journal as a
log handler..
"""
logging.getLogger(None).removeHandler('journal')
def perform():
"""Perform upgrade to next release of Debian."""
_logging_setup()
with _snapshot_run_and_disable(), \
_services_disable(), \
_apt_hold_packages():
_apt_update()
_apt_fix()
_debconf_set_selections()
_packages_remove_obsolete()
_apt_full_upgrade()
_apt_autoremove()
_unattended_upgrades_run()
_freedombox_restart()
_trigger_on_complete()
def start_service():
"""Create dist upgrade service and start it."""
# Cleanup old service
old_service_path = pathlib.Path(
'/run/systemd/system/freedombox-dist-upgrade.service')
if old_service_path.exists():
old_service_path.unlink(missing_ok=True)
action_utils.service_daemon_reload()
status = get_status()
_sources_list_update(status['current_codename'], status['next_codename'])
args = [
'--unit=freedombox-dist-upgrade',
'--description=Upgrade to new stable Debian release',
'--property=KillMode=process', '--property=TimeoutSec=72hr',
f'--property=BindPaths={temp_sources_list}:{sources_list}'
]
action_utils.run(['systemd-run'] + args + [
'systemd-inhibit', '/usr/bin/freedombox-cmd', 'upgrades',
'dist_upgrade', '--no-args'
], check=True)
def on_complete():
"""Perform cleanup operations."""
_logging_setup()
logger.info('Dist upgrade complete.')
logger.info('Committing changes to /etc/apt/sources.list')
temp_sources_list.rename(sources_list)