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.package import Packages
from . import manifest, privileged
from . import distupgrade, manifest, privileged
first_boot_steps = [
{
@ -214,58 +214,11 @@ def setup_repositories(_):
def check_dist_upgrade(_):
"""Check for upgrade to new stable release."""
if is_dist_upgrade_enabled():
try_start_dist_upgrade()
def try_start_dist_upgrade(test=False):
"""Try to start dist upgrade."""
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)
status = distupgrade.get_status()
if status['next_action'] in ('continue', 'ready'):
privileged.start_dist_upgrade()
else:
logger.info('Not ready for distribution upgrade - %s', status)
def is_backports_requested():
@ -335,17 +288,6 @@ def can_enable_dist_upgrade():
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():
"""Check if any packages have holds."""
check = DiagnosticCheck(PKG_HOLD_DIAG_CHECK_ID,

View File

@ -2,10 +2,12 @@
"""Perform distribution upgrade."""
import contextlib
import datetime
import logging
import pathlib
import subprocess
import time
from datetime import timezone
from typing import Generator
import augeas
@ -29,6 +31,46 @@ PRE_DEBCONF_SELECTIONS: list[str] = [
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, 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):
"""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)
def _get_new_codename(test_upgrade: bool) -> str | None:
"""Return the codename for the next release."""
release_file_dist = 'stable'
if test_upgrade:
release_file_dist = 'testing'
def _get_sources_list_codename() -> str | None:
"""Return the codename set in the /etc/apt/sources.list file."""
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.load()
url = utils.RELEASE_FILE_URL.format(release_file_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.')
dists = set()
for match_ in aug.match('*'):
dist = aug.get(match_ + '/distribution')
dist = dist.removesuffix('-updates')
dist = dist.removesuffix('-security')
dists.add(dist)
try:
output = subprocess.check_output(command).decode()
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]
if len(dists) != 1:
return None
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 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.
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():
raise RuntimeError('upgrades-not-enabled')
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')
if not utils.is_sufficient_free_space():
raise RuntimeError('not-enough-free-space')
current_codename = _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 action_utils.service_is_running('freedombox-dist-upgrade'):
raise RuntimeError('found-previous')
if current_codename in (None, 'testing', 'unstable'):
return status
from plinth.modules.upgrades import get_current_release
release, old_codename = get_current_release()
if release in ['unstable', 'testing', 'n/a']:
raise RuntimeError(f'already-{release}')
_, base_files_codename = upgrades.get_current_release()
if current_codename == 'stable':
current_codename = base_files_codename
new_codename = _get_new_codename(test_upgrade)
if not new_codename:
raise RuntimeError('codename-not-found')
if current_codename not in distribution_info:
return status
if new_codename == old_codename:
raise RuntimeError(f'already-{old_codename}')
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']
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
@ -319,7 +418,7 @@ def perform():
_trigger_on_complete()
def start_service(test_upgrade: bool):
def start_service():
"""Create dist upgrade service and start it."""
# Cleanup old service
old_service_path = pathlib.Path(
@ -328,9 +427,8 @@ def start_service(test_upgrade: bool):
old_service_path.unlink(missing_ok=True)
action_utils.service_daemon_reload()
old_codename, new_codename = _check(test_upgrade)
_sources_list_update(old_codename, new_codename)
status = get_status()
_sources_list_update(status['current_codename'], status['next_codename'])
args = [
'--unit=freedombox-dist-upgrade',

View File

@ -236,7 +236,7 @@ def activate_backports(develop: bool = False):
@privileged
def start_dist_upgrade(test: bool = False):
def start_dist_upgrade():
"""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()
distupgrade.start_service(test)
distupgrade.start_service()
@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.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>
<div class="upgrades-status-frame clearfix">
<div class="upgrade-status-icon pull-left">
@ -138,22 +143,4 @@
</div>
</p>
{% 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 %}

View File

@ -5,6 +5,8 @@ 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
@ -74,75 +76,161 @@ deb https://deb.debian.org/debian bookwormish main
assert temp_sources_list.read_text() == modified
@patch('plinth.modules.upgrades.utils.get_http_protocol')
@patch('subprocess.check_output')
def test_get_new_codename(check_output, get_http_protocol):
"""Test that getting a new distro codename works."""
get_http_protocol.return_value = 'http'
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'
])
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
assert distupgrade._get_new_codename(True) == 'trixie'
check_output.assert_called_with([
'curl', '--silent', '--location', '--fail',
'https://deb.debian.org/debian/dists/testing/Release'
])
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
check_output.side_effect = FileNotFoundError('curl not found')
assert not distupgrade._get_new_codename(True)
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('plinth.modules.upgrades.distupgrade._get_new_codename')
@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_check(check_auto, is_sufficient_free_space, service_is_running,
get_current_release, get_new_codename):
"""Test checking for available dist upgrade."""
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
with pytest.raises(RuntimeError, match='upgrades-not-enabled'):
distupgrade._check()
check_auto.return_value = True
is_dist_upgrade_enabled.return_value = False
is_sufficient_free_space.return_value = False
with pytest.raises(RuntimeError, match='not-enough-free-space'):
distupgrade._check()
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
with pytest.raises(RuntimeError, match='found-previous'):
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')
assert distupgrade.get_status()['next_action'] is None
@patch('subprocess.run')

View File

@ -1,7 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
URLs for the upgrades module
"""
"""URLs for the upgrades module."""
from django.urls import re_path
@ -16,6 +14,9 @@ urlpatterns = [
views.BackportsFirstbootView.as_view(),
name='backports-firstboot'),
re_path(r'^sys/upgrades/upgrade/$', views.upgrade, name='upgrade'),
re_path(r'^sys/upgrades/test-dist-upgrade/$', views.test_dist_upgrade,
name='test-dist-upgrade'),
re_path(r'^sys/upgrades/dist-upgrade/$', views.DistUpgradeView.as_view(),
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.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import TemplateView
from django.views.generic.edit import FormView
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.views import AppView, messages_error
from . import privileged
from . import distupgrade, privileged
from .forms import BackportsFirstbootForm, ConfigureForm
@ -47,7 +48,6 @@ class UpgradesConfigurationView(AppView):
context['version'] = __version__
context['new_version'] = is_newer_version_available()
context['os_release'] = get_os_release()
context['can_test_dist_upgrade'] = upgrades.can_test_dist_upgrade()
return context
def form_valid(self, form):
@ -84,6 +84,38 @@ class UpgradesConfigurationView(AppView):
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():
"""Return whether a newer Freedombox version is available."""
cache = Cache()
@ -167,12 +199,3 @@ class BackportsFirstbootView(FormView):
upgrades.setup_repositories(None)
first_boot.mark_step_done('backports_wizard')
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'))