From 22b30da8ded85a3bb8b1e83b95b3cef0ebed1539 Mon Sep 17 00:00:00 2001
From: Sunil Mohan Adapa
Date: Mon, 24 Mar 2025 14:02:50 -0700
Subject: [PATCH] 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
Reviewed-by: James Valleroy
---
plinth/modules/upgrades/__init__.py | 70 +-----
plinth/modules/upgrades/distupgrade.py | 188 ++++++++++++----
plinth/modules/upgrades/privileged.py | 4 +-
.../upgrades-dist-upgrade-confirm.html | 69 ++++++
.../templates/upgrades-dist-upgrade.html | 162 ++++++++++++++
.../templates/upgrades_configure.html | 23 +-
.../upgrades/tests/test_distupgrade.py | 200 +++++++++++++-----
plinth/modules/upgrades/urls.py | 11 +-
plinth/modules/upgrades/views.py | 45 +++-
9 files changed, 571 insertions(+), 201 deletions(-)
create mode 100644 plinth/modules/upgrades/templates/upgrades-dist-upgrade-confirm.html
create mode 100644 plinth/modules/upgrades/templates/upgrades-dist-upgrade.html
diff --git a/plinth/modules/upgrades/__init__.py b/plinth/modules/upgrades/__init__.py
index 737371419..624c1b9a4 100644
--- a/plinth/modules/upgrades/__init__.py
+++ b/plinth/modules/upgrades/__init__.py
@@ -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,
diff --git a/plinth/modules/upgrades/distupgrade.py b/plinth/modules/upgrades/distupgrade.py
index 7f62205c4..576a9213a 100644
--- a/plinth/modules/upgrades/distupgrade.py
+++ b/plinth/modules/upgrades/distupgrade.py
@@ -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',
diff --git a/plinth/modules/upgrades/privileged.py b/plinth/modules/upgrades/privileged.py
index 63737c4f0..cedc432c6 100644
--- a/plinth/modules/upgrades/privileged.py
+++ b/plinth/modules/upgrades/privileged.py
@@ -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
diff --git a/plinth/modules/upgrades/templates/upgrades-dist-upgrade-confirm.html b/plinth/modules/upgrades/templates/upgrades-dist-upgrade-confirm.html
new file mode 100644
index 000000000..28a88fdf1
--- /dev/null
+++ b/plinth/modules/upgrades/templates/upgrades-dist-upgrade-confirm.html
@@ -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 %}
+ {% trans "Confirm Distribution Update?" %}
+
+ {% if status.next_action == "manual" %}
+
+
+
+ {% trans "Caution:" %}
+
+
+
+ {% 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 %}
+
+
+ {% endif %}
+
+
+ -
+ {% blocktrans trimmed %}
+ Take a full backup of all apps and data before performing a distribution
+ update.
+ {% endblocktrans %}
+
+ -
+ {% blocktrans trimmed %}
+ The process will take several hours. Most apps will be unavailable
+ during this time.
+ {% endblocktrans %}
+
+ -
+ {% blocktrans trimmed %}
+ Don't interrupt the process by shutting down or interrupting power to
+ the machine.
+ {% endblocktrans %}
+
+ -
+ {% blocktrans trimmed %}
+ If the process is interrupted, you should be able to continue it.
+ {% endblocktrans %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/plinth/modules/upgrades/templates/upgrades-dist-upgrade.html b/plinth/modules/upgrades/templates/upgrades-dist-upgrade.html
new file mode 100644
index 000000000..f2a8170b7
--- /dev/null
+++ b/plinth/modules/upgrades/templates/upgrades-dist-upgrade.html
@@ -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 %}
+ {% trans "Distribution Update" %}
+
+ {% if status.running %}
+
+
+
+
+
+ {% blocktrans trimmed %}
+ Distribution update is currently running. This operation may take
+ several hours. Most apps will be unavailable during this period.
+ {% endblocktrans %}
+
+
+ {% else %}
+ {% if not status.updates_enabled or not status.dist_upgrade_enabled or not status.has_free_space or not status.current_codename %}
+
+
+
+ {% trans "Caution:" %}
+
+
+
+ {% 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 %}
+
+
+ {% endif %}
+
+
+ {% trans "Current Distribution:" %}
+ {% 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 %}
+
+ {% if status.current_release_date %}
+ {% blocktrans trimmed with date=status.current_release_date|date %}
+ Released: {{ date }}.
+ {% endblocktrans %}
+ {% endif %}
+
+
+
+ {% trans "Next Stable Distribution:" %}
+ {% if not status.next_codename %}
+ {% trans "Unknown" %}
+ {% else %}
+ {{ status.next_version }} ({{ status.next_codename }})
+ {% endif %}
+
+ {% if status.next_release_date %}
+ {% blocktrans trimmed with date=status.next_release_date|date %}
+ Likely release: {{ date }}.
+ {% endblocktrans %}
+ {% endif %}
+
+
+
+ {% 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 %}
+
+
+
+ {% if status.next_action == "ready" or status.next_action == "wait_or_manual" %}
+
+ {% trans "Start Distribution Update" %}
+
+ {% elif status.next_action == "continue" %}
+
+ {% trans "Continue Distribution Update" %}
+
+ {% elif status.next_action == "manual" %}
+
+ {% trans "Start Distribution Update (for testing)" %}
+
+ {% else %}
+
+ {% trans "Start Distribution Update" %}
+
+ {% endif %}
+
+ {% endif %}
+{% endblock %}
diff --git a/plinth/modules/upgrades/templates/upgrades_configure.html b/plinth/modules/upgrades/templates/upgrades_configure.html
index 8ce839b43..a78eef30e 100644
--- a/plinth/modules/upgrades/templates/upgrades_configure.html
+++ b/plinth/modules/upgrades/templates/upgrades_configure.html
@@ -10,6 +10,11 @@
{% block status %}
{{ block.super}} {% comment %} To extend instead of overwrite {% endcomment %}
+
+
{% trans "Status" %}
@@ -138,22 +143,4 @@
{% endif %}
-
- {% if can_test_dist_upgrade %}
-
{% trans "Test Distribution Upgrade" %}
-
- {% blocktrans trimmed %}
- This will attempt to upgrade the system from stable to
- testing. It is meant only for development use.
- {% endblocktrans %}
-
-
-
-
- {% endif %}
{% endblock %}
diff --git a/plinth/modules/upgrades/tests/test_distupgrade.py b/plinth/modules/upgrades/tests/test_distupgrade.py
index ae86908c2..ed3ed8934 100644
--- a/plinth/modules/upgrades/tests/test_distupgrade.py
+++ b/plinth/modules/upgrades/tests/test_distupgrade.py
@@ -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')
diff --git a/plinth/modules/upgrades/urls.py b/plinth/modules/upgrades/urls.py
index 15955bd91..6eabe7bd9 100644
--- a/plinth/modules/upgrades/urls.py
+++ b/plinth/modules/upgrades/urls.py
@@ -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'),
]
diff --git a/plinth/modules/upgrades/views.py b/plinth/modules/upgrades/views.py
index 7488827b6..7fa4c67c8 100644
--- a/plinth/modules/upgrades/views.py
+++ b/plinth/modules/upgrades/views.py
@@ -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'))