FreedomBox/plinth/modules/upgrades/tests/test_distupgrade.py
Sunil Mohan Adapa e9f21b6ae1
distupgrade: Use new configuration file instead of halting upgrade
Closes: #2509

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.

Tests:

- Install GNOME and edit the configuration file
/etc/fwupd/remotes.d/lvfs-testing.conf. Upgrade to Trixie. Distribution upgrade
was successful. Notice that the configuration file was force upgraded. Log shows
that new configuration file was installed as requested. Running 'apt -f install'
shows that there are not apt fixes pending.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
2025-04-11 12:01:34 -04:00

440 lines
17 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Test various part of the dist upgrade process.
"""
import re
import subprocess
from datetime import datetime as datetime_original
from datetime import timezone
from unittest.mock import call, patch
import pytest
from plinth.modules.upgrades import distupgrade
# pylint: disable=protected-access
@patch('subprocess.run')
def test_apt_run(run):
"""Test that running apt command logs properly."""
run.return_value.returncode = 0
args = ['command', 'arg1', 'arg2']
distupgrade._apt_run(args)
assert run.call_args.args == \
(['apt-get', '--assume-yes', '--quiet=2'] + args,)
assert not run.call_args.kwargs['stdout']
run.return_value.returncode = 10
with pytest.raises(RuntimeError):
distupgrade._apt_run(args)
def test_sources_list_update(tmp_path):
"""Test that updating a sources file works."""
original = '''
# This is a comment with 'bookworm' in it.
deb http://deb.debian.org/debian bookworm main non-free-firmware
deb-src http://deb.debian.org/debian bookworm main non-free-firmware
deb http://deb.debian.org/debian bookworm-updates main non-free-firmware
deb-src http://deb.debian.org/debian bookworm-updates main non-free-firmware
deb http://security.debian.org/debian-security/ bookworm-security main non-free-firmware
deb-src http://security.debian.org/debian-security/ bookworm-security main non-free-firmware
deb https://deb.debian.org/debian other main
deb https://deb.debian.org/debian bookwormish main
''' # noqa: E501
modified = '''
# This is a comment with 'bookworm' in it.
deb http://deb.debian.org/debian trixie main non-free-firmware
deb-src http://deb.debian.org/debian trixie main non-free-firmware
deb http://deb.debian.org/debian trixie-updates main non-free-firmware
deb-src http://deb.debian.org/debian trixie-updates main non-free-firmware
deb http://security.debian.org/debian-security/ trixie-security main non-free-firmware
deb-src http://security.debian.org/debian-security/ trixie-security main non-free-firmware
deb https://deb.debian.org/debian other main
deb https://deb.debian.org/debian bookwormish main
''' # noqa: E501
sources_list = tmp_path / 'sources.list'
temp_sources_list = tmp_path / 'sources.list.fbx-dist-upgrade'
module = 'plinth.modules.upgrades.distupgrade'
with patch(f'{module}.sources_list', sources_list), \
patch(f'{module}.temp_sources_list', temp_sources_list):
sources_list.write_text(original)
distupgrade._sources_list_update('bookworm', 'trixie')
assert temp_sources_list.read_text() == modified
original = re.sub(r'bookworm([ -])', r'stable\1', original)
sources_list.write_text(original)
distupgrade._sources_list_update('bookworm', 'trixie')
assert temp_sources_list.read_text() == modified
def test_get_sources_list_codename(tmp_path):
"""Test retrieving codename from sources.list file."""
list1 = '''
deb http://deb.debian.org/debian bookworm main non-free-firmware
deb-src http://deb.debian.org/debian bookworm main non-free-firmware
deb http://deb.debian.org/debian bookworm-updates main non-free-firmware
deb-src http://deb.debian.org/debian bookworm-updates main non-free-firmware
deb http://security.debian.org/debian-security/ bookworm-security main non-free-firmware
deb-src http://security.debian.org/debian-security/ bookworm-security main non-free-firmware
''' # noqa: E501
list2 = '''
deb http://deb.debian.org/debian stable main non-free-firmware
deb-src http://deb.debian.org/debian stable main non-free-firmware
deb http://deb.debian.org/debian bookworm-updates main non-free-firmware
deb-src http://deb.debian.org/debian bookworm-updates main non-free-firmware
deb http://security.debian.org/debian-security/ bookworm-security main non-free-firmware
deb-src http://security.debian.org/debian-security/ bookworm-security main non-free-firmware
''' # noqa: E501
sources_list = tmp_path / 'sources.list'
module = 'plinth.modules.upgrades.distupgrade'
with patch(f'{module}.sources_list', sources_list):
sources_list.write_text(list1)
assert distupgrade._get_sources_list_codename() == 'bookworm'
sources_list.write_text(list2)
assert distupgrade._get_sources_list_codename() is None
@patch('datetime.datetime')
@patch('plinth.modules.upgrades.get_current_release')
@patch('plinth.modules.upgrades.distupgrade._get_sources_list_codename')
@patch('plinth.action_utils.service_is_running')
@patch('plinth.modules.upgrades.utils.is_sufficient_free_space')
@patch('plinth.modules.upgrades.is_dist_upgrade_enabled')
@patch('plinth.modules.upgrades.utils.check_auto')
def test_get_status(check_auto, is_dist_upgrade_enabled,
is_sufficient_free_space, service_is_running,
get_sources_list_codename, get_current_release, datetime):
"""Test getting status of distribution upgrade."""
# All checks fail, sources.list has 'testing' value
check_auto.return_value = False
is_dist_upgrade_enabled.return_value = False
is_sufficient_free_space.return_value = False
service_is_running.return_value = True
get_sources_list_codename.return_value = 'testing'
status = distupgrade.get_status()
assert not status['updates_enabled']
assert not status['dist_upgrade_enabled']
assert not status['has_free_space']
assert status['running']
assert status['current_codename'] == 'testing'
assert status['current_version'] is None
assert status['current_release_date'] is None
assert status['next_codename'] is None
assert status['next_version'] is None
assert status['next_release_date'] is None
assert status['next_action'] is None
# sources.list has mixed values
get_sources_list_codename.return_value = None
status = distupgrade.get_status()
assert status['current_codename'] is None
assert status['current_version'] is None
# sources.list has 'unstable' value
get_sources_list_codename.return_value = 'unstable'
status = distupgrade.get_status()
assert status['current_codename'] == 'unstable'
assert status['current_version'] is None
# sources.list has an unknown value
get_sources_list_codename.return_value = 'x-invalid'
get_current_release.return_value = (None, None)
status = distupgrade.get_status()
assert status['current_codename'] == 'x-invalid'
assert status['current_version'] is None
# sources.list has 'stable'
get_sources_list_codename.return_value = 'stable'
get_current_release.return_value = (None, 'bookworm')
status = distupgrade.get_status()
assert status['current_codename'] == 'bookworm'
assert status['current_version'] == 12
# All checks pass, next release not yet available
check_auto.return_value = True
is_dist_upgrade_enabled.return_value = True
is_sufficient_free_space.return_value = True
service_is_running.return_value = False
get_sources_list_codename.return_value = 'bookworm'
get_current_release.return_value = (None, 'bookworm')
datetime.now.return_value = datetime_original(2024, 8, 10,
tzinfo=timezone.utc)
status = distupgrade.get_status()
assert status['updates_enabled']
assert status['dist_upgrade_enabled']
assert status['has_free_space']
assert not status['running']
assert status['current_codename'] == 'bookworm'
assert status['current_version'] == 12
current_date = datetime_original(2023, 6, 10, tzinfo=timezone.utc)
assert status['current_release_date'] == current_date
assert status['next_codename'] == 'trixie'
assert status['next_version'] == 13
next_date = datetime_original(2025, 8, 20, tzinfo=timezone.utc)
assert status['next_release_date'] == next_date
assert status['next_action'] == 'manual'
# Distribution upgrade interrupted
get_current_release.return_value = (None, 'trixie')
status = distupgrade.get_status()
assert status['next_action'] == 'continue'
# Less than 30 days after release
get_current_release.return_value = (None, 'bookworm')
datetime.now.return_value = datetime_original(2025, 8, 30,
tzinfo=timezone.utc)
status = distupgrade.get_status()
assert status['next_action'] == 'wait_or_manual'
# More than 30 days after release
datetime.now.return_value = datetime_original(2025, 9, 30,
tzinfo=timezone.utc)
status = distupgrade.get_status()
assert status['next_action'] == 'ready'
# Next release date not available
get_sources_list_codename.return_value = 'trixie'
assert distupgrade.get_status()['next_action'] is None
# Automatic updates not enabled
get_sources_list_codename.return_value = 'bookworm'
check_auto.return_value = False
assert distupgrade.get_status()['next_action'] is None
# Distribution updates not enabled
check_auto.return_value = True
is_dist_upgrade_enabled.return_value = False
assert distupgrade.get_status()['next_action'] is None
# Not enough free space
is_dist_upgrade_enabled.return_value = True
is_sufficient_free_space.return_value = False
assert distupgrade.get_status()['next_action'] is None
# Distribution upgrade running
is_sufficient_free_space.return_value = True
service_is_running.return_value = True
assert distupgrade.get_status()['next_action'] is None
@patch('subprocess.run')
@patch('plinth.modules.snapshot.is_apt_snapshots_enabled')
@patch('plinth.modules.snapshot.is_supported')
def test_snapshot_run_and_disable(is_supported, is_apt_snapshots_enabled, run):
"""Test taking a snapshot."""
is_supported.return_value = False
with distupgrade._snapshot_run_and_disable():
run.assert_not_called()
run.assert_not_called()
is_supported.return_value = True
is_apt_snapshots_enabled.return_value = False
with distupgrade._snapshot_run_and_disable():
assert run.call_args_list == [
call(['snapper', 'create', '--description', 'before dist-upgrade'],
check=True)
]
run.reset_mock()
run.assert_not_called()
is_supported.return_value = True
is_apt_snapshots_enabled.return_value = True
with distupgrade._snapshot_run_and_disable():
assert run.call_args_list == [
call(['snapper', 'create', '--description', 'before dist-upgrade'],
check=True),
call([
'/usr/share/plinth/actions/actions', 'snapshot',
'disable_apt_snapshot'
], input=b'{"args": ["yes"], "kwargs": {}}', check=True)
]
run.reset_mock()
assert run.call_args_list == [
call([
'/usr/share/plinth/actions/actions', 'snapshot',
'disable_apt_snapshot'
], input=b'{"args": ["no"], "kwargs": {}}', check=True)
]
@patch('plinth.action_utils.service_enable')
@patch('plinth.action_utils.service_disable')
@patch('plinth.action_utils.service_is_running')
def test_services_disable(service_is_running, service_disable, service_enable):
"""Test that disabling services works."""
service_is_running.return_value = False
with distupgrade._services_disable():
service_disable.assert_not_called()
service_enable.assert_not_called()
service_is_running.return_value = True
with distupgrade._services_disable():
service_disable.call_args_list = [call('quasselcore')]
service_enable.call_args_list = [call('quasselcore')]
@patch('subprocess.run')
@patch('subprocess.check_call')
@patch('subprocess.check_output')
def test_apt_hold_packages(check_output, check_call, run, tmp_path):
"""Test that holding apt packages works."""
hold_flag = tmp_path / 'flag'
run.return_value.returncode = 0
with patch('plinth.action_utils.apt_hold_flag', hold_flag), \
patch('plinth.modules.upgrades.distupgrade.PACKAGES_WITH_PROMPTS',
['package1', 'package2']):
check_output.return_value = False
with distupgrade._apt_hold_packages():
assert hold_flag.exists()
assert hold_flag.stat().st_mode & 0o117 == 0
expected_call = [call(['apt-mark', 'hold', 'freedombox'])]
assert check_call.call_args_list == expected_call
expected_calls = [
call(['apt-mark', 'hold', 'package1'], check=False),
call(['apt-mark', 'hold', 'package2'], check=False)
]
assert run.call_args_list == expected_calls
check_call.reset_mock()
run.reset_mock()
expected_call = [
call(['apt-mark', 'unhold', 'freedombox'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False)
]
assert run.call_args_list == expected_call
expected_calls = [
call(['apt-mark', 'unhold', 'package1']),
call(['apt-mark', 'unhold', 'package2'])
]
assert check_call.call_args_list == expected_calls
@patch('plinth.action_utils.debconf_set_selections')
def test_debconf_set_selections(debconf_set_selections):
"""Test that setting debconf selections works."""
selections = 'plinth.modules.upgrades.distupgrade.PRE_DEBCONF_SELECTIONS'
with patch(selections, []):
distupgrade._debconf_set_selections()
debconf_set_selections.assert_not_called()
with patch(selections, ['selection1', 'selection2']):
distupgrade._debconf_set_selections()
debconf_set_selections.assert_called_with(['selection1', 'selection2'])
distupgrade._debconf_set_selections()
debconf_set_selections.assert_called_with(
['grub-pc grub-pc/install_devices_empty boolean true'])
@patch('plinth.modules.upgrades.distupgrade._apt_run')
def test_packages_remove_obsolete(apt_run):
"""Test that obsolete packages are removed."""
distupgrade._packages_remove_obsolete()
apt_run.assert_not_called() # No obsolete package to remove currently.
with patch('plinth.modules.upgrades.distupgrade.OBSOLETE_PACKAGES',
['tt-rss', 'searx']):
distupgrade._packages_remove_obsolete()
apt_run.assert_called_with(['remove', 'tt-rss', 'searx'])
@patch('plinth.modules.upgrades.distupgrade._apt_run')
def test_apt_update(apt_run):
"""Test that apt update works."""
distupgrade._apt_update()
apt_run.assert_called_with(['update'])
@patch('plinth.modules.upgrades.distupgrade._apt_run')
@patch('subprocess.run')
def test_apt_fix(run, apt_run):
"""Test that apt fixes work."""
distupgrade._apt_fix()
assert run.call_args_list == [
call(['dpkg', '--configure', '-a'], check=False)
]
assert apt_run.call_args_list == [call(['--fix-broken', 'install'])]
@patch('plinth.modules.upgrades.distupgrade._apt_run')
def test_apt_autoremove(apt_run):
"""Test that apt autoremove works."""
distupgrade._apt_autoremove()
apt_run.assert_called_with(['autoremove'])
@patch('plinth.modules.upgrades.distupgrade._apt_run')
def test_apt_full_upgrade(apt_run):
"""Test that apt full upgrade works."""
apt_run.return_value = 0
distupgrade._apt_full_upgrade()
apt_run.assert_called_with(
['full-upgrade', '-o', 'Dpkg::Options::=--force-confnew'])
@patch('subprocess.run')
def test_unatteneded_upgrades_run(run):
"""Test that running unattended upgrades works."""
distupgrade._unattended_upgrades_run()
run.assert_called_with(['unattended-upgrade', '--verbose'], check=False)
@patch('plinth.action_utils.service_restart')
def test_freedombox_restart(service_restart):
"""Test that restarting freedombox service works."""
distupgrade._freedombox_restart()
service_restart.assert_called_with('plinth')
@patch('subprocess.run')
def test_trigger_on_complete(run):
"""Test triggering post completion process."""
distupgrade._trigger_on_complete()
run.assert_called_with([
'systemd-run', '--unit=freedombox-dist-upgrade-on-complete',
'--description=Finish up upgrade to new stable Debian release',
'/usr/share/plinth/actions/actions', 'upgrades',
'dist_upgrade_on_complete', '--no-args'
], check=True)
def test_on_complete(tmp_path):
"""Test that /etc/apt/sources.list is committed."""
sources_list = tmp_path / 'sources.list'
sources_list.write_text('before')
temp_sources_list = tmp_path / 'sources.list.fbx-dist-upgrade'
temp_sources_list.write_text('after')
module = 'plinth.modules.upgrades.distupgrade'
with patch(f'{module}.sources_list', sources_list), \
patch(f'{module}.temp_sources_list', temp_sources_list):
distupgrade.on_complete()
assert sources_list.read_text() == 'after'
assert not temp_sources_list.exists()