Sunil Mohan Adapa 5a15665b41
upgrades: Split the main dist upgrade code
- To make it easier to test and read.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
2025-03-10 18:20:38 -04:00

301 lines
9.7 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""Perform distribution upgrade."""
import contextlib
import logging
import pathlib
import subprocess
import time
from typing import Generator
from plinth import action_utils
from plinth.modules import snapshot as snapshot_module
from . import utils
SOURCES_LIST = '/etc/apt/sources.list'
DIST_UPGRADE_OBSOLETE_PACKAGES: list[str] = []
DIST_UPGRADE_PACKAGES_WITH_PROMPTS = [
'bind9', 'firewalld', 'janus', 'minetest-server', 'minidlna',
'mumble-server', 'radicale', 'roundcube-core', 'tt-rss'
]
DIST_UPGRADE_PRE_DEBCONF_SELECTIONS: list[str] = [
# Tell grub-pc to continue without installing grub again.
'grub-pc grub-pc/install_devices_empty boolean true'
]
dist_upgrade_flag = pathlib.Path(
'/var/lib/freedombox/dist-upgrade-in-progress')
def check(test_upgrade=False) -> tuple[bool, str]:
"""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.
If test_upgrade is True, also check for upgrade to testing.
Return (boolean, string) indicating if the upgrade is ready, and a reason
if not.
"""
if dist_upgrade_flag.exists():
return (True, 'found-previous')
from plinth.modules.upgrades import get_current_release
release, dist = get_current_release()
if release in ['unstable', 'testing', 'n/a']:
return (False, f'already-{release}')
check_dists = ['stable']
if test_upgrade:
check_dists.append('testing')
codename = None
for check_dist in check_dists:
url = utils.RELEASE_FILE_URL.format(check_dist)
command = ['curl', '--silent', '--location', '--fail', url]
protocol = utils.get_http_protocol()
if protocol == 'tor+http':
command.insert(0, 'torsocks')
logging.info('Package download over Tor is enabled.')
try:
output = subprocess.check_output(command).decode()
except (subprocess.CalledProcessError, FileNotFoundError):
logging.warning('Error while checking for new %s release',
check_dist)
else:
for line in output.split('\n'):
if line.startswith('Codename:'):
codename = line.split()[1]
if not codename:
return (False, 'codename-not-found')
if codename == dist:
return (False, f'already-{dist}')
if not utils.check_auto():
return (False, 'upgrades-not-enabled')
if check_dist == 'testing' and not test_upgrade:
return (False, 'test-not-set')
if not utils.is_sufficient_free_space():
return (False, 'not-enough-free-space')
logging.info('Upgrading from %s to %s...', dist, codename)
with open(SOURCES_LIST, 'r', encoding='utf-8') as sources_list:
lines = sources_list.readlines()
with open(SOURCES_LIST, 'w', encoding='utf-8') as sources_list:
for line in lines:
# E.g. replace 'bullseye' with 'bookworm'.
new_line = line.replace(dist, codename)
if check_dist == 'testing':
# E.g. replace 'stable' with 'bookworm'.
new_line = new_line.replace('stable', codename)
sources_list.write(new_line)
logging.info('Dist upgrade in progress. Setting flag.')
dist_upgrade_flag.touch(mode=0o660)
return (True, 'started-dist-upgrade')
@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():
print('Snapshots are not supported, skipping taking a snapshot.',
flush=True)
yield
return
reenable = False
try:
print('Taking a snapshot before dist upgrade...', flush=True)
subprocess.run([
'/usr/share/plinth/actions/actions', 'snapshot', 'create',
'--no-args'
], check=True)
aug = snapshot_module.load_augeas()
if snapshot_module.is_apt_snapshots_enabled(aug):
print('Disabling apt snapshots during dist upgrade...', flush=True)
subprocess.run([
'/usr/share/plinth/actions/actions',
'snapshot',
'disable_apt_snapshot',
], input='{"args": ["yes"], "kwargs": {}}'.encode(), check=True)
reenable = True
else:
print('Apt snapshots already disabled.', flush=True)
yield
finally:
if reenable:
print('Re-enabling apt snapshots...', flush=True)
subprocess.run([
'/usr/share/plinth/actions/actions', 'snapshot',
'disable_apt_snapshot'
], input='{"args": ["no"], "kwargs": {}}'.encode(), check=True)
else:
print('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.
print('Stopping quassel service during dist upgrade...', flush=True)
with action_utils.service_ensure_stopped('quasselcore'):
yield
print('Re-enabling quassel service if previously enabled...',
flush=True)
@contextlib.contextmanager
def _apt_hold_packages():
"""Apt hold some packages during dist upgrade."""
packages = DIST_UPGRADE_PACKAGES_WITH_PROMPTS
packages_string = ', '.join(packages)
# Hold freedombox package during entire dist upgrade.
print('Holding freedombox package...', flush=True)
with action_utils.apt_hold_freedombox():
# Hold packages known to have conffile prompts. FreedomBox service
# will handle their upgrade later.
print(f'Holding packages with conffile prompts: {packages_string}...',
flush=True)
with action_utils.apt_hold(packages):
yield
print(
'Releasing holds on packages with conffile prompts: '
f'{packages_string}...', flush=True)
print('Releasing hold on freedombox package...')
def _debconf_set_selections() -> None:
"""Pre-set debconf selections if they are needed for dist upgrade."""
if DIST_UPGRADE_PRE_DEBCONF_SELECTIONS:
print(
f'Setting debconf selections: '
f'{DIST_UPGRADE_PRE_DEBCONF_SELECTIONS}', flush=True)
action_utils.debconf_set_selections(
DIST_UPGRADE_PRE_DEBCONF_SELECTIONS)
def _packages_remove_obsolete() -> None:
"""Remove obsolete packages.
These may prevent other packages from upgrading.
"""
if DIST_UPGRADE_OBSOLETE_PACKAGES:
print(f'Removing packages: {DIST_UPGRADE_OBSOLETE_PACKAGES}...',
flush=True)
action_utils.run_apt_command(['remove'] +
DIST_UPGRADE_OBSOLETE_PACKAGES)
def _apt_update():
"""Run 'apt update'."""
print('Updating Apt cache...', flush=True)
action_utils.run_apt_command(['update'])
def _apt_autoremove():
"""Run 'apt autoremove'."""
print('Running apt autoremove...', flush=True)
action_utils.run_apt_command(['autoremove'])
def _apt_full_upgrade():
"""Run and check if apt upgrade was successful."""
print('Running apt full-upgrade...', flush=True)
returncode = action_utils.run_apt_command(['full-upgrade'])
if returncode:
raise RuntimeError(
'Apt full-upgrade was not successful. Distribution upgrade '
'will be retried at a later time.')
def _unattended_upgrades_run():
"""Run unattended-upgrade once more.
To handle upgrading the freedombox package.
"""
print('Running unattended-upgrade...', flush=True)
subprocess.run(['unattended-upgrade', '--verbose'], check=False)
def _freedombox_restart():
"""Restart FreedomBox service.
To ensure it is using the latest dependencies.
"""
print('Restarting FreedomBox service...', flush=True)
action_utils.service_restart('plinth')
def _wait():
"""Wait for 10 minutes before performing remaining actions."""
print('Waiting for 10 minutes...', flush=True)
time.sleep(10 * 60)
def _flag_remove():
"""Remove the flag that mark that dist upgrade is running."""
print('Dist upgrade complete. Removing flag.', flush=True)
if dist_upgrade_flag.exists():
dist_upgrade_flag.unlink()
def perform():
"""Perform upgrade to next release of Debian."""
with _snapshot_run_and_disable(), \
_services_disable(), \
_apt_hold_packages():
_apt_update()
_debconf_set_selections()
_packages_remove_obsolete()
_apt_full_upgrade()
_apt_autoremove()
_unattended_upgrades_run()
_freedombox_restart()
_wait()
_apt_update()
_flag_remove()
def start_service():
"""Create dist upgrade service and start it."""
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()
args = [
'--unit=freedombox-dist-upgrade',
'--description=Upgrade to new stable Debian release',
'--property=KillMode=process',
'--property=TimeoutSec=12hr',
]
subprocess.run(['systemd-run'] + args + [
'systemd-inhibit', '/usr/share/plinth/actions/actions', 'upgrades',
'dist_upgrade', '--no-args'
], check=True)