mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-01-21 07:55:00 +00:00
- 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>
431 lines
15 KiB
Python
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)
|