upgrades: Revamp distribution upgrade UI

Closes: #2090

- Create a new page for distribution upgrade.

- If distribution upgrade is running show its status here without any other UI.

- Show various conditions for not allowing distribution upgrades.
  - Automatic updates disabled
  - Distribution updates disabled
  - Not enough free space.
  - Unknown or mixed distribution in sources.list.

- If distribution upgrade was interrupted, show that information here and allow
triggering distribution upgrade again. This is detected by noticing that
codename in base-files is higher than one detected in sources.list.

- If the user is not testing/unstable, show a message and don't allow
triggering.

- If next stable has not been released, don't auto-upgrade but allow manual
upgrade. Show special warnings.

- If next stable has been released but only recently, don't auto-upgrade but
allow manual upgrade.

- If next stable has been released and it has been 30 days, allow auto-upgrade
and manual upgrade.

- Seek confirmation before triggering manual upgrade. Provide appropriate
advice.

- Rely on hard-coded list of releases and their release dates instead of
querying the server.

Tests:

- When automatic updates or distribution updates are disabled, an alert message
is shown distribution upgrade page. If both are disabled, both messages show up
in the alert. The start distribution upgrade button is disabled. Clicking on the
button does not work.

- Reducing the available free disk space will cause alert message to show up and
start upgrade button to be disabled.

- When the distribution in /etc/apt/sources.list is mixed or unknown, an alert
message is shown. the start distribution upgrade button is disabled.

- When the distribution in /etc/apt/sources.list is testing or unstable, an
alert message is shown "You are on a rolling release distribution...". the start
distribution upgrade button is disabled. The current distribution is
'None (testing)' or 'None (unstable)'. Next stable distribution is Unknown.

- If get_current_release is hard-coded to return (None, 'trixie'). Then a
message is show in the distribution update page 'A previous run of distribution
update may have been interrupted. Please re-run the distribution update.' A
'Continue Distribution Update' button is shown in warning color. The button
takes to confirm page where the confirm button is shown in blue and is enabled.

- On a bookworm VM, visiting the page shows the message "You are on the latest
stable distribution...". Upgrade button shows in red. Clicking it takes to
confirmation page. The page shows a warning alert and red confirmation button.

- Setting the clock to '2025-08-21' shows the message "A new stable distribution
is available. Your FreedomBox will be update automatically in 4 weeks...".
Upgrade button shows in blue. Clicking it takes to confirmation page. The page
does show warning. The button is in blue.

- Setting the clock to '2025-09-30' shows the message "A new status distribution
is available. Your FreedomBox will be updated automatically soon...". Upgrade
button shows in blue. Clicking it takes to confirmation page. The page does show
warning. The button is in blue.

- Clicking the confirmation button starts the distribution upgrade process. This
distribution upgrade page is shown. The page shows spinner with a message and no
other UI. Page is refreshed every 3 seconds. When the distribution upgrade
process is completed, the page shows the current status.

- Killing the apt-get process during distribution upgrade stop the page refresh.
The page shows that process was interrupted and also continuation. Clicking on
the confirmation button resumes the distribution upgrade process.

- After distribution upgrade, the page shows the current distribution and next
distribution properly. There is not release date for the next distribution. A
message shows: "Next stable distribution is not available yet."

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2025-03-24 14:02:50 -07:00 committed by James Valleroy
parent 71b15203be
commit 22b30da8de
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
9 changed files with 571 additions and 201 deletions

View File

@ -19,7 +19,7 @@ from plinth.diagnostic_check import DiagnosticCheck, Result
from plinth.modules.backups.components import BackupRestore from plinth.modules.backups.components import BackupRestore
from plinth.package import Packages from plinth.package import Packages
from . import manifest, privileged from . import distupgrade, manifest, privileged
first_boot_steps = [ first_boot_steps = [
{ {
@ -214,58 +214,11 @@ def setup_repositories(_):
def check_dist_upgrade(_): def check_dist_upgrade(_):
"""Check for upgrade to new stable release.""" """Check for upgrade to new stable release."""
if is_dist_upgrade_enabled(): if is_dist_upgrade_enabled():
try_start_dist_upgrade() status = distupgrade.get_status()
if status['next_action'] in ('continue', 'ready'):
privileged.start_dist_upgrade()
def try_start_dist_upgrade(test=False): else:
"""Try to start dist upgrade.""" logger.info('Not ready for distribution upgrade - %s', status)
from plinth.notification import Notification
try:
privileged.start_dist_upgrade(test, _log_error=False)
except RuntimeError as exception:
reason = exception.args[0]
else:
logger.info('Started dist upgrade.')
title = gettext_noop('Distribution update started')
message = gettext_noop(
'Started update to next stable release. This may take a long '
'time to complete.')
Notification.update_or_create(id='upgrades-dist-upgrade-started',
app_id='upgrades', severity='info',
title=title, message=message, actions=[{
'type': 'dismiss'
}], group='admin')
return
if 'found-previous' in reason:
logger.info(
'Found previous dist-upgrade. If it was interrupted, it will '
'be restarted.')
elif 'already-' in reason:
logger.info('Skip dist upgrade: System is already up-to-date.')
elif 'codename-not-found' in reason:
logger.warning('Skip dist upgrade: Codename not found in release '
'file.')
elif 'upgrades-not-enabled' in reason:
logger.info('Skip dist upgrade: Automatic updates are not enabled.')
elif 'test-not-set' in reason:
logger.info('Skip dist upgrade: --test is not set.')
elif 'not-enough-free-space' in reason:
logger.warning('Skip dist upgrade: Not enough free space in /.')
title = gettext_noop('Could not start distribution update')
message = gettext_noop(
'There is not enough free space in the root partition to '
'start the distribution update. Please ensure at least 5 GB '
'is free. Distribution update will be retried after 24 hours,'
' if enabled.')
Notification.update_or_create(id='upgrades-dist-upgrade-free-space',
app_id='upgrades', severity='warning',
title=title, message=message, actions=[{
'type': 'dismiss'
}], group='admin')
else:
logger.warning('Unhandled result of start-dist-upgrade: %s', reason)
def is_backports_requested(): def is_backports_requested():
@ -335,17 +288,6 @@ def can_enable_dist_upgrade():
return release not in ['unstable', 'testing', 'n/a'] return release not in ['unstable', 'testing', 'n/a']
def can_test_dist_upgrade():
"""Return whether dist upgrade can be tested."""
return can_enable_dist_upgrade() and cfg.develop
def test_dist_upgrade():
"""Test dist-upgrade from stable to testing."""
if can_test_dist_upgrade():
try_start_dist_upgrade(test=True)
def _diagnose_held_packages(): def _diagnose_held_packages():
"""Check if any packages have holds.""" """Check if any packages have holds."""
check = DiagnosticCheck(PKG_HOLD_DIAG_CHECK_ID, check = DiagnosticCheck(PKG_HOLD_DIAG_CHECK_ID,

View File

@ -2,10 +2,12 @@
"""Perform distribution upgrade.""" """Perform distribution upgrade."""
import contextlib import contextlib
import datetime
import logging import logging
import pathlib import pathlib
import subprocess import subprocess
import time import time
from datetime import timezone
from typing import Generator from typing import Generator
import augeas import augeas
@ -29,6 +31,46 @@ PRE_DEBCONF_SELECTIONS: list[str] = [
sources_list = pathlib.Path('/etc/apt/sources.list') sources_list = pathlib.Path('/etc/apt/sources.list')
temp_sources_list = pathlib.Path('/etc/apt/sources.list.fbx-dist-upgrade') 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, 20, 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], enable_triggers: bool = False): def _apt_run(arguments: list[str], enable_triggers: bool = False):
"""Run an apt command and ensure that output is written to stdout.""" """Run an apt command and ensure that output is written to stdout."""
@ -62,65 +104,122 @@ def _sources_list_update(old_codename: str, new_codename: str):
aug_path.rename(temp_sources_list) aug_path.rename(temp_sources_list)
def _get_new_codename(test_upgrade: bool) -> str | None: def _get_sources_list_codename() -> str | None:
"""Return the codename for the next release.""" """Return the codename set in the /etc/apt/sources.list file."""
release_file_dist = 'stable' aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
if test_upgrade: augeas.Augeas.NO_MODL_AUTOLOAD)
release_file_dist = 'testing' aug.transform('aptsources', str(sources_list))
aug.set('/augeas/context', '/files' + str(sources_list))
aug.load()
url = utils.RELEASE_FILE_URL.format(release_file_dist) dists = set()
command = ['curl', '--silent', '--location', '--fail', url] for match_ in aug.match('*'):
protocol = utils.get_http_protocol() dist = aug.get(match_ + '/distribution')
if protocol == 'tor+http': dist = dist.removesuffix('-updates')
command.insert(0, 'torsocks') dist = dist.removesuffix('-security')
logging.info('Package download over Tor is enabled.') dists.add(dist)
try: if len(dists) != 1:
output = subprocess.check_output(command).decode() return None
except (subprocess.CalledProcessError, FileNotFoundError):
logging.warning('Error while checking for new %s release',
release_file_dist)
else:
for line in output.split('\n'):
if line.startswith('Codename:'):
return line.split()[1]
return None return dists.pop()
def _check(test_upgrade: bool = False) -> tuple[str, str]: def get_status() -> dict[str, bool | str | None]:
"""Check if a distribution upgrade be performed. """Check if a distribution upgrade be performed.
Check for new stable release, if updates are enabled, and if there is Check for new stable release, if updates are enabled, and if there is
enough free space for the dist upgrade. enough free space for the dist upgrade.
If test_upgrade is True, also check for upgrade to testing. 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.
Return (boolean, string) indicating if the upgrade is ready, and a reason
if not.
""" """
if not utils.check_auto(): from plinth.modules import upgrades
raise RuntimeError('upgrades-not-enabled') 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')
if not utils.is_sufficient_free_space(): current_codename = _get_sources_list_codename()
raise RuntimeError('not-enough-free-space') 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 action_utils.service_is_running('freedombox-dist-upgrade'): if current_codename in (None, 'testing', 'unstable'):
raise RuntimeError('found-previous') return status
from plinth.modules.upgrades import get_current_release _, base_files_codename = upgrades.get_current_release()
release, old_codename = get_current_release() if current_codename == 'stable':
if release in ['unstable', 'testing', 'n/a']: current_codename = base_files_codename
raise RuntimeError(f'already-{release}')
new_codename = _get_new_codename(test_upgrade) if current_codename not in distribution_info:
if not new_codename: return status
raise RuntimeError('codename-not-found')
if new_codename == old_codename: current_version = distribution_info[current_codename]['version']
raise RuntimeError(f'already-{old_codename}') 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']
return old_codename, new_codename 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 @contextlib.contextmanager
@ -319,7 +418,7 @@ def perform():
_trigger_on_complete() _trigger_on_complete()
def start_service(test_upgrade: bool): def start_service():
"""Create dist upgrade service and start it.""" """Create dist upgrade service and start it."""
# Cleanup old service # Cleanup old service
old_service_path = pathlib.Path( old_service_path = pathlib.Path(
@ -328,9 +427,8 @@ def start_service(test_upgrade: bool):
old_service_path.unlink(missing_ok=True) old_service_path.unlink(missing_ok=True)
action_utils.service_daemon_reload() action_utils.service_daemon_reload()
old_codename, new_codename = _check(test_upgrade) status = get_status()
_sources_list_update(status['current_codename'], status['next_codename'])
_sources_list_update(old_codename, new_codename)
args = [ args = [
'--unit=freedombox-dist-upgrade', '--unit=freedombox-dist-upgrade',

View File

@ -236,7 +236,7 @@ def activate_backports(develop: bool = False):
@privileged @privileged
def start_dist_upgrade(test: bool = False): def start_dist_upgrade():
"""Start dist upgrade process. """Start dist upgrade process.
Check if a new stable release is available, and start dist-upgrade process Check if a new stable release is available, and start dist-upgrade process
@ -244,7 +244,7 @@ def start_dist_upgrade(test: bool = False):
""" """
_release_held_freedombox() _release_held_freedombox()
distupgrade.start_service(test) distupgrade.start_service()
@privileged @privileged

View File

@ -0,0 +1,69 @@
{% extends "base.html" %}
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load bootstrap %}
{% load i18n %}
{% load static %}
{% block content %}
<h3>{% trans "Confirm Distribution Update?" %}</h3>
{% if status.next_action == "manual" %}
<div class="alert alert-danger d-flex align-items-center" role="alert">
<div class="me-2">
<span class="fa fa-exclamation-triangle" aria-hidden="true"></span>
<span class="visually-hidden">{% trans "Caution:" %}</span>
</div>
<div>
{% blocktrans trimmed %}
You are about to update to the next distribution version before it has
been released. Proceed only if you wish to help with beta testing
of {{ box_name }} functionality.
{% endblocktrans %}
</div>
</div>
{% endif %}
<ul>
<li>
{% blocktrans trimmed %}
Take a full backup of all apps and data before performing a distribution
update.
{% endblocktrans %}
</li>
<li>
{% blocktrans trimmed %}
The process will take several hours. Most apps will be unavailable
during this time.
{% endblocktrans %}
</li>
<li>
{% blocktrans trimmed %}
Don't interrupt the process by shutting down or interrupting power to
the machine.
{% endblocktrans %}
</li>
<li>
{% blocktrans trimmed %}
If the process is interrupted, you should be able to continue it.
{% endblocktrans %}
</li>
</ul>
<p>
<form class="form form-dist-upgrade" method="post">
{% csrf_token %}
<input type="submit"
{% if status.next_action != "manual" %}
class="btn btn-primary"
{% else %}
class="btn btn-danger"
{% endif %}
value="{% trans "Confirm & Start Distribution Update" %}"/>
</form>
</p>
{% endblock %}

View File

@ -0,0 +1,162 @@
{% extends "base.html" %}
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load bootstrap %}
{% load i18n %}
{% load static %}
{% block content %}
<h3>{% trans "Distribution Update" %}</h3>
{% if status.running %}
<div class="upgrades-dist-upgrade clearfix">
<div class="upgrades-dist-upgrade-running-icon pull-left">
<span class="fa fa-refresh fa-spin fa-3x fa-pull-left text-info"></span>
</div>
<p>
{% blocktrans trimmed %}
Distribution update is currently running. This operation may take
several hours. Most apps will be unavailable during this period.
{% endblocktrans %}
</p>
</div>
{% else %}
{% if not status.updates_enabled or not status.dist_upgrade_enabled or not status.has_free_space or not status.current_codename %}
<div class="alert alert-warning d-flex align-items-center" role="alert">
<div class="me-2">
<span class="fa fa-exclamation-triangle" aria-hidden="true"></span>
<span class="visually-hidden">{% trans "Caution:" %}</span>
</div>
<div>
{% if not status.updates_enabled %}
{% trans "Automatic updates are disabled." %}
{% endif %}
{% if not status.dist_upgrade_enabled %}
{% trans "Distribution upgrades are disabled." %}
{% endif %}
{% if not status.has_free_space %}
{% blocktrans trimmed %}
You need to have at least 5 GB of free space available on primary disk
to perform a distribution update.
{% endblocktrans %}
{% endif %}
{% if not status.current_codename %}
{% trans "Your current distribution is mixed or not understood." %}
{% endif %}
</div>
</div>
{% endif %}
<p>
{% trans "Current Distribution:" %} <strong>
{% if not status.current_codename %}
{% trans "Unknown or mixed" %}
{% elif status.current_codename == "testing" and status.current_codename == "unstable" %}
{{ status.current_codename }}
({% trans "Rolling release distribution" %})
{% else %}
{{ status.current_version }}
({{ status.current_codename }})
{% endif %}
</strong>
{% if status.current_release_date %}
{% blocktrans trimmed with date=status.current_release_date|date %}
Released: {{ date }}.
{% endblocktrans %}
{% endif %}
</p>
<p>
{% trans "Next Stable Distribution:" %} <strong>
{% if not status.next_codename %}
{% trans "Unknown" %}
{% else %}
{{ status.next_version }} ({{ status.next_codename }})
{% endif %}
</strong>
{% if status.next_release_date %}
{% blocktrans trimmed with date=status.next_release_date|date %}
Likely release: {{ date }}.
{% endblocktrans %}
{% endif %}
</p>
<p>
{% if status.next_codename and not status.next_release_date %}
{% blocktrans trimmed %}
Next stable distribution is not available yet.
{% endblocktrans %}
{% endif %}
{% if status.current_codename == "testing" or status.current_codename == "unstable" %}
{% blocktrans trimmed %}
You are on a rolling release distribution. No distribution update
is necessary. Thank you for helping test the {{ box_name }} project.
Please report any problems you notice.
{% endblocktrans %}
{% endif %}
{% if status.next_action == "continue" %}
{% blocktrans trimmed %}
A previous run of distribution update may have been interrupted. Please
re-run the distribution update.
{% endblocktrans %}
{% endif %}
{% if status.next_action == "wait_or_manual" %}
{% blocktrans trimmed with period=status.next_action_date|timeuntil %}
A new stable distribution is available. Your {{ box_name }} will be
updated automatically in {{ period }}. You may choose to update
manually now, if you wish.
{% endblocktrans %}
{% endif %}
{% if status.next_action == "ready" %}
{% blocktrans trimmed %}
A new stable distribution is available. Your {{ box_name }} will be
updated automatically soon. You may choose to update manually now, if
you wish.
{% endblocktrans %}
{% endif %}
{% if status.next_action == "manual" %}
{% blocktrans trimmed %}
You are on the latest stable distribution. This is recommended.
However, if you wish to help beta test {{ box_name }} functionality,
you may update to next distribution manually. This setup may
experience occational app failures until the next stable release.
{% endblocktrans %}
{% endif %}
</p>
<p>
{% if status.next_action == "ready" or status.next_action == "wait_or_manual" %}
<a role="button" class="btn btn-primary"
href="{% url 'upgrades:dist-upgrade-confirm' %}">
{% trans "Start Distribution Update" %}
</a>
{% elif status.next_action == "continue" %}
<a role="button" class="btn btn-warning"
href="{% url 'upgrades:dist-upgrade-confirm' %}">
{% trans "Continue Distribution Update" %}
</a>
{% elif status.next_action == "manual" %}
<a role="button" class="btn btn-danger"
href="{% url 'upgrades:dist-upgrade-confirm' %}">
{% trans "Start Distribution Update (for testing)" %}
</a>
{% else %}
<a role="button" class="btn btn-primary disabled"
href="{% url 'upgrades:dist-upgrade-confirm' %}">
{% trans "Start Distribution Update" %}
</a>
{% endif %}
</p>
{% endif %}
{% endblock %}

View File

@ -10,6 +10,11 @@
{% block status %} {% block status %}
{{ block.super}} {% comment %} To extend instead of overwrite {% endcomment %} {{ block.super}} {% comment %} To extend instead of overwrite {% endcomment %}
<div class="btn-toolbar">
<a class="btn btn-default btn-md" href="{% url 'upgrades:dist-upgrade' %}">
{% trans "Distribution Update" %}</a>
</div>
<h3>{% trans "Status" %}</h3> <h3>{% trans "Status" %}</h3>
<div class="upgrades-status-frame clearfix"> <div class="upgrades-status-frame clearfix">
<div class="upgrade-status-icon pull-left"> <div class="upgrade-status-icon pull-left">
@ -138,22 +143,4 @@
</div> </div>
</p> </p>
{% endif %} {% endif %}
{% if can_test_dist_upgrade %}
<h3>{% trans "Test Distribution Upgrade" %}</h3>
<p>
{% blocktrans trimmed %}
This will attempt to upgrade the system from stable to
testing. <strong>It is meant only for development use.</strong>
{% endblocktrans %}
</p>
<div class="btn-toolbar">
<form class="form" method="post"
action="{% url 'upgrades:test-dist-upgrade' %}">
{% csrf_token %}
<input type="submit" class="btn btn-danger"
value="{% trans "Test distribution upgrade now" %}"/>
</form>
</div>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -5,6 +5,8 @@ Test various part of the dist upgrade process.
import re import re
import subprocess import subprocess
from datetime import datetime as datetime_original
from datetime import timezone
from unittest.mock import call, patch from unittest.mock import call, patch
import pytest import pytest
@ -74,75 +76,161 @@ deb https://deb.debian.org/debian bookwormish main
assert temp_sources_list.read_text() == modified assert temp_sources_list.read_text() == modified
@patch('plinth.modules.upgrades.utils.get_http_protocol') def test_get_sources_list_codename(tmp_path):
@patch('subprocess.check_output') """Test retrieving codename from sources.list file."""
def test_get_new_codename(check_output, get_http_protocol): list1 = '''
"""Test that getting a new distro codename works.""" deb http://deb.debian.org/debian bookworm main non-free-firmware
get_http_protocol.return_value = 'http' deb-src http://deb.debian.org/debian bookworm main non-free-firmware
check_output.return_value = b'''
Suite: testing
Codename: trixie
Description: Debian Testing distribution
'''
assert distupgrade._get_new_codename(False) == 'trixie'
check_output.assert_called_with([
'curl', '--silent', '--location', '--fail',
'https://deb.debian.org/debian/dists/stable/Release'
])
assert distupgrade._get_new_codename(True) == 'trixie' deb http://deb.debian.org/debian bookworm-updates main non-free-firmware
check_output.assert_called_with([ deb-src http://deb.debian.org/debian bookworm-updates main non-free-firmware
'curl', '--silent', '--location', '--fail',
'https://deb.debian.org/debian/dists/testing/Release'
])
check_output.side_effect = FileNotFoundError('curl not found') deb http://security.debian.org/debian-security/ bookworm-security main non-free-firmware
assert not distupgrade._get_new_codename(True) 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('plinth.modules.upgrades.distupgrade._get_new_codename') @patch('datetime.datetime')
@patch('plinth.modules.upgrades.get_current_release') @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.action_utils.service_is_running')
@patch('plinth.modules.upgrades.utils.is_sufficient_free_space') @patch('plinth.modules.upgrades.utils.is_sufficient_free_space')
@patch('plinth.modules.upgrades.is_dist_upgrade_enabled')
@patch('plinth.modules.upgrades.utils.check_auto') @patch('plinth.modules.upgrades.utils.check_auto')
def test_check(check_auto, is_sufficient_free_space, service_is_running, def test_get_status(check_auto, is_dist_upgrade_enabled,
get_current_release, get_new_codename): is_sufficient_free_space, service_is_running,
"""Test checking for available dist upgrade.""" 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 check_auto.return_value = False
with pytest.raises(RuntimeError, match='upgrades-not-enabled'): is_dist_upgrade_enabled.return_value = False
distupgrade._check()
check_auto.return_value = True
is_sufficient_free_space.return_value = False is_sufficient_free_space.return_value = False
with pytest.raises(RuntimeError, match='not-enough-free-space'): service_is_running.return_value = True
distupgrade._check() 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 is_sufficient_free_space.return_value = True
service_is_running.return_value = True service_is_running.return_value = True
with pytest.raises(RuntimeError, match='found-previous'): assert distupgrade.get_status()['next_action'] is None
distupgrade._check()
service_is_running.return_value = False
for release in ['unstable', 'testing', 'n/a']:
get_current_release.return_value = (release, release)
with pytest.raises(RuntimeError, match=f'already-{release}'):
distupgrade._check()
get_current_release.return_value = ('12', 'bookworm')
get_new_codename.return_value = None
with pytest.raises(RuntimeError, match='codename-not-found'):
distupgrade._check()
get_new_codename.assert_called_with(False)
distupgrade._check(True)
get_new_codename.assert_called_with(True)
get_new_codename.return_value = 'bookworm'
with pytest.raises(RuntimeError, match='already-bookworm'):
distupgrade._check()
get_new_codename.return_value = 'trixie'
assert distupgrade._check() == ('bookworm', 'trixie')
@patch('subprocess.run') @patch('subprocess.run')

View File

@ -1,7 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
""" """URLs for the upgrades module."""
URLs for the upgrades module
"""
from django.urls import re_path from django.urls import re_path
@ -16,6 +14,9 @@ urlpatterns = [
views.BackportsFirstbootView.as_view(), views.BackportsFirstbootView.as_view(),
name='backports-firstboot'), name='backports-firstboot'),
re_path(r'^sys/upgrades/upgrade/$', views.upgrade, name='upgrade'), re_path(r'^sys/upgrades/upgrade/$', views.upgrade, name='upgrade'),
re_path(r'^sys/upgrades/test-dist-upgrade/$', views.test_dist_upgrade, re_path(r'^sys/upgrades/dist-upgrade/$', views.DistUpgradeView.as_view(),
name='test-dist-upgrade'), name='dist-upgrade'),
re_path(r'^sys/upgrades/dist-upgrade/confirm/$',
views.DistUpgradeConfirmView.as_view(),
name='dist-upgrade-confirm'),
] ]

View File

@ -9,6 +9,7 @@ from django.http import HttpResponseRedirect
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import TemplateView
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from plinth import __version__ from plinth import __version__
@ -16,7 +17,7 @@ from plinth.modules import first_boot, upgrades
from plinth.privileged import packages as packages_privileged from plinth.privileged import packages as packages_privileged
from plinth.views import AppView, messages_error from plinth.views import AppView, messages_error
from . import privileged from . import distupgrade, privileged
from .forms import BackportsFirstbootForm, ConfigureForm from .forms import BackportsFirstbootForm, ConfigureForm
@ -47,7 +48,6 @@ class UpgradesConfigurationView(AppView):
context['version'] = __version__ context['version'] = __version__
context['new_version'] = is_newer_version_available() context['new_version'] = is_newer_version_available()
context['os_release'] = get_os_release() context['os_release'] = get_os_release()
context['can_test_dist_upgrade'] = upgrades.can_test_dist_upgrade()
return context return context
def form_valid(self, form): def form_valid(self, form):
@ -84,6 +84,38 @@ class UpgradesConfigurationView(AppView):
return super().form_valid(form) return super().form_valid(form)
class DistUpgradeView(TemplateView):
"""View to show status of distribution upgrade."""
template_name = 'upgrades-dist-upgrade.html'
def get_context_data(self, **kwargs):
"""Return additional context for rendering the template."""
context = super().get_context_data(**kwargs)
context['status'] = distupgrade.get_status()
context['refresh_page_sec'] = None
if context['status']['running']:
context['refresh_page_sec'] = 3
return context
class DistUpgradeConfirmView(TemplateView):
"""View to confirm and trigger trigger distribution upgrade."""
template_name = 'upgrades-dist-upgrade-confirm.html'
def get_context_data(self, **kwargs):
"""Return additional context for rendering the template."""
context = super().get_context_data(**kwargs)
context['status'] = distupgrade.get_status()
return context
def post(self, request):
"""Start the distribution upgrade process."""
privileged.start_dist_upgrade()
messages.success(request, _('Started distribution update.'))
return redirect(reverse_lazy('upgrades:dist-upgrade'))
def is_newer_version_available(): def is_newer_version_available():
"""Return whether a newer Freedombox version is available.""" """Return whether a newer Freedombox version is available."""
cache = Cache() cache = Cache()
@ -167,12 +199,3 @@ class BackportsFirstbootView(FormView):
upgrades.setup_repositories(None) upgrades.setup_repositories(None)
first_boot.mark_step_done('backports_wizard') first_boot.mark_step_done('backports_wizard')
return super().form_valid(form) return super().form_valid(form)
def test_dist_upgrade(request):
"""Test dist-upgrade from stable to testing."""
if request.method == 'POST':
upgrades.test_dist_upgrade()
messages.success(request, _('Starting distribution upgrade test.'))
return redirect(reverse_lazy('upgrades:index'))