mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-01-21 07:55:00 +00:00
- Janus is currently not installable in Trixie because Janus was temporarily removed during the release process of Trixie. - Installing it from unstable, despite the instability is better than keeping the app unavailable. Users have reported using the app. Tests: - Restarting the service after applying the patch leads to setup for upgrades app to run. Apt preferences for janus packages are set. App is shown as available. It can be installed. Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org> Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
405 lines
14 KiB
Python
405 lines
14 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
"""FreedomBox app for upgrades."""
|
|
|
|
import datetime
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
|
|
from aptsources import sourceslist
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.utils.translation import gettext_noop
|
|
|
|
import plinth
|
|
from plinth import action_utils
|
|
from plinth import app as app_module
|
|
from plinth import cfg, glib, kvstore, menu, package
|
|
from plinth.config import DropinConfigs
|
|
from plinth.daemon import RelatedDaemon
|
|
from plinth.diagnostic_check import DiagnosticCheck, Result
|
|
from plinth.modules.backups.components import BackupRestore
|
|
from plinth.package import Packages
|
|
|
|
from . import distupgrade, manifest, privileged, utils
|
|
|
|
first_boot_steps = [
|
|
{
|
|
'id': 'backports_wizard',
|
|
'url': 'upgrades:backports-firstboot',
|
|
'order': 5,
|
|
},
|
|
]
|
|
|
|
_description = [
|
|
_('Check for and apply the latest software and security updates.'),
|
|
_('Updates are run at 06:00 everyday according to local time zone. Set '
|
|
'your time zone in Date & Time app. Apps are restarted after update '
|
|
'causing them to be unavailable briefly. If system reboot is deemed '
|
|
'necessary, it is done automatically at 02:00 causing all apps to be '
|
|
'unavailable briefly.')
|
|
]
|
|
|
|
BACKPORTS_REQUESTED_KEY = 'upgrades_backports_requested'
|
|
|
|
DIST_UPGRADE_ENABLED_KEY = 'upgrades_dist_upgrade_enabled'
|
|
|
|
DIST_UPGRADE_RUN_HOUR = 6 # 06:00 (morning)
|
|
|
|
PKG_HOLD_DIAG_CHECK_ID = 'upgrades-package-holds'
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class UpgradesApp(app_module.App):
|
|
"""FreedomBox app for software upgrades."""
|
|
|
|
app_id = 'upgrades'
|
|
|
|
_version = 21
|
|
|
|
can_be_disabled = False
|
|
|
|
def __init__(self) -> None:
|
|
"""Create components for the app."""
|
|
super().__init__()
|
|
|
|
info = app_module.Info(app_id=self.app_id, version=self._version,
|
|
is_essential=True, name=_('Software Update'),
|
|
icon='fa-refresh', description=_description,
|
|
manual_page='Upgrades', tags=manifest.tags)
|
|
self.add(info)
|
|
|
|
menu_item = menu.Menu('menu-upgrades', info.name, info.icon, info.tags,
|
|
'upgrades:index',
|
|
parent_url_name='system:system', order=50)
|
|
self.add(menu_item)
|
|
|
|
packages = Packages('packages-upgrades',
|
|
['unattended-upgrades', 'needrestart'])
|
|
self.add(packages)
|
|
|
|
dropin_configs = DropinConfigs('dropin-configs-upgrades', [
|
|
'/etc/apt/apt.conf.d/20freedombox',
|
|
'/etc/apt/apt.conf.d/20freedombox-allow-release-info-change',
|
|
'/etc/apt/apt.conf.d/60unattended-upgrades',
|
|
'/etc/needrestart/conf.d/freedombox.conf',
|
|
])
|
|
self.add(dropin_configs)
|
|
|
|
daemon = RelatedDaemon('related-daemon-upgrades',
|
|
'freedombox-dist-upgrade')
|
|
self.add(daemon)
|
|
|
|
backup_restore = BackupRestore('backup-restore-upgrades',
|
|
**manifest.backup)
|
|
self.add(backup_restore)
|
|
|
|
def post_init(self):
|
|
"""Perform post initialization operations."""
|
|
self._show_new_release_notification()
|
|
|
|
# Check every day if backports becomes available, then configure it if
|
|
# selected by user.
|
|
glib.schedule(24 * 3600, setup_repositories)
|
|
|
|
# Check every day if new stable release becomes available and if we
|
|
# waited enough, then perform dist-upgrade if updates are enabled.
|
|
glib.schedule(3600, check_dist_upgrade)
|
|
|
|
def _show_new_release_notification(self):
|
|
"""When upgraded to new release, show a notification."""
|
|
from plinth.notification import Notification
|
|
try:
|
|
note = Notification.get('upgrades-new-release')
|
|
if note.data['version'] == plinth.__version__:
|
|
# User already has notification for update to this version. It
|
|
# may be dismissed or not yet dismissed
|
|
return
|
|
|
|
# User currently has a notification for an older version, update.
|
|
dismiss = False
|
|
except KeyError:
|
|
# Don't show notification for the first version user runs, create
|
|
# but don't show it.
|
|
dismiss = True
|
|
|
|
data = {
|
|
'version': plinth.__version__,
|
|
'app_name': 'translate:' + gettext_noop('Software Update'),
|
|
'app_icon': 'fa-refresh'
|
|
}
|
|
title = gettext_noop('FreedomBox Updated')
|
|
note = Notification.update_or_create(
|
|
id='upgrades-new-release', app_id='upgrades', severity='info',
|
|
title=title, body_template='upgrades-new-release.html', data=data,
|
|
group='admin')
|
|
note.dismiss(should_dismiss=dismiss)
|
|
|
|
def _show_first_manual_update_notification(self):
|
|
"""After first setup, show notification to manually run updates."""
|
|
from plinth.notification import Notification
|
|
title = gettext_noop('Run software update manually')
|
|
message = gettext_noop(
|
|
'Automatic software update runs daily by default. For the first '
|
|
'time, manually run it now.')
|
|
data = {
|
|
'app_name': 'translate:' + gettext_noop('Software Update'),
|
|
'app_icon': 'fa-refresh'
|
|
}
|
|
actions = [{
|
|
'type': 'link',
|
|
'class': 'primary',
|
|
'text': gettext_noop('Go to {app_name}'),
|
|
'url': 'upgrades:index'
|
|
}, {
|
|
'type': 'dismiss'
|
|
}]
|
|
Notification.update_or_create(id='upgrades-first-manual-update',
|
|
app_id='upgrades', severity='info',
|
|
title=title, message=message,
|
|
actions=actions, data=data,
|
|
group='admin', dismissed=False)
|
|
|
|
def setup(self, old_version):
|
|
"""Install and configure the app."""
|
|
super().setup(old_version)
|
|
|
|
# Enable automatic upgrades but only on first install
|
|
if not old_version and not cfg.develop:
|
|
privileged.enable_auto()
|
|
|
|
# Request user to run manual update as a one time activity
|
|
if not old_version:
|
|
self._show_first_manual_update_notification()
|
|
|
|
# Update apt preferences whenever on first install and on version
|
|
# increment.
|
|
privileged.setup()
|
|
|
|
# When upgrading from a version without first boot wizard for
|
|
# backports, assume backports have been requested.
|
|
if old_version and old_version < 7:
|
|
set_backports_requested(can_activate_backports())
|
|
|
|
# Enable dist upgrade for new installs, and once when upgrading
|
|
# from version without flag.
|
|
if not old_version or old_version < 8:
|
|
set_dist_upgrade_enabled(can_enable_dist_upgrade())
|
|
|
|
# Try to setup apt repositories, if needed, if possible, on first
|
|
# install and on version increment.
|
|
setup_repositories(None)
|
|
|
|
def diagnose(self) -> list[DiagnosticCheck]:
|
|
"""Run diagnostics and return the results."""
|
|
results = super().diagnose()
|
|
results.append(_diagnose_held_packages())
|
|
return results
|
|
|
|
def repair(self, failed_checks: list) -> bool:
|
|
"""Handle repair for custom diagnostic."""
|
|
remaining_checks = []
|
|
for check in failed_checks:
|
|
if check.check_id == PKG_HOLD_DIAG_CHECK_ID:
|
|
privileged.release_held_packages()
|
|
else:
|
|
remaining_checks.append(check)
|
|
|
|
return super().repair(remaining_checks)
|
|
|
|
|
|
def setup_repositories(_):
|
|
"""Setup apt repositories for backports."""
|
|
if is_backports_requested():
|
|
privileged.activate_backports(cfg.develop)
|
|
privileged.activate_unstable()
|
|
|
|
|
|
def check_dist_upgrade(_):
|
|
"""Check for upgrade to new stable release."""
|
|
# Run once a day at a desired hour even when triggered every hour. There is
|
|
# a small chance that this won't run in a given day.
|
|
now = datetime.datetime.now() # Local timezone
|
|
if now.hour != DIST_UPGRADE_RUN_HOUR:
|
|
return
|
|
|
|
if is_dist_upgrade_enabled():
|
|
status = distupgrade.get_status()
|
|
starting = status['next_action'] in ('continue', 'ready')
|
|
dist_upgrade_show_notification(status, starting)
|
|
if starting:
|
|
logger.info('Starting distribution upgrade - %s', status)
|
|
privileged.start_dist_upgrade()
|
|
else:
|
|
logger.info('Not ready for distribution upgrade - %s', status)
|
|
|
|
|
|
def dist_upgrade_show_notification(status: dict, starting: bool):
|
|
"""Show various notifications regarding distribution upgrade.
|
|
|
|
- Show a notification 60 days, 30 days, 1 week, and 1 day before
|
|
distribution upgrade. If a notification is dismissed for any of these
|
|
periods don't show again until new period starts. Override any previous
|
|
notification.
|
|
|
|
- Show a notification just before the distribution upgrade showing that the
|
|
process has started. Override any previous notification.
|
|
|
|
- Show a notification after the distribution upgrade is completed that it
|
|
is done. Override any previous notification. Keep this until it is 60
|
|
days before next distribution upgrade. If user dismisses the
|
|
notification, don't show it again.
|
|
"""
|
|
from plinth.notification import Notification
|
|
|
|
try:
|
|
note = Notification.get('upgrades-dist-upgrade')
|
|
data = note.data
|
|
except KeyError:
|
|
data = {}
|
|
|
|
in_days = None
|
|
if status['next_action_date']:
|
|
in_days = (status['next_action_date'] -
|
|
datetime.datetime.now(tz=datetime.timezone.utc))
|
|
|
|
if in_days is None or in_days > datetime.timedelta(days=60):
|
|
for_days = None
|
|
elif in_days > datetime.timedelta(days=30):
|
|
for_days = 60 # 60 day notification
|
|
elif in_days > datetime.timedelta(days=7):
|
|
for_days = 30 # 30 day notification
|
|
elif in_days > datetime.timedelta(days=1):
|
|
for_days = 7 # 1 week notification
|
|
else:
|
|
for_days = 1 # 1 day notification, or overdue notification
|
|
|
|
if status['running']:
|
|
# Do nothing while the distribution upgrade is running.
|
|
return
|
|
|
|
state = 'starting' if starting else 'waiting'
|
|
if (not for_days and status['current_codename']
|
|
and data.get('next_codename') == status['current_codename']):
|
|
# Previously shown notification's codename is current codename.
|
|
# Distribution upgrade was successfully completed.
|
|
state = 'done'
|
|
|
|
if not status['next_action'] and state != 'done':
|
|
# There is no upgrade available, don't show any notification.
|
|
return
|
|
|
|
if not for_days and data.get('state') == 'done':
|
|
# Don't remove notification showing upgrade is complete until next
|
|
# distribution upgrade is coming up in 2 months or sooner.
|
|
return
|
|
|
|
if not for_days and state == 'waiting':
|
|
# More than 60 days to next distribution update. Don't show
|
|
# notification.
|
|
return
|
|
|
|
if (for_days == data.get('for_days') and state == data.get('state')
|
|
and status['next_codename'] == data.get('next_codename')):
|
|
# If the notification was shown for same distribution codename, same
|
|
# duration, and same state, then don't show it again.
|
|
return
|
|
|
|
data = {
|
|
'app_name': 'translate:' + gettext_noop('Software Update'),
|
|
'app_icon': 'fa-refresh',
|
|
'current_codename': status['current_codename'],
|
|
'current_version': status['current_version'],
|
|
'next_codename': status['next_codename'],
|
|
'next_version': status['next_version'],
|
|
'state': state,
|
|
'for_days': for_days,
|
|
'in_days': in_days.days if in_days else None,
|
|
}
|
|
title = gettext_noop('Distribution Update')
|
|
note = Notification.update_or_create(
|
|
id='upgrades-dist-upgrade', app_id='upgrades', severity='info',
|
|
title=title, body_template='upgrades-dist-upgrade-notification.html',
|
|
data=data, group='admin')
|
|
note.dismiss(should_dismiss=False)
|
|
|
|
|
|
def is_backports_requested():
|
|
"""Return whether user has chosen to activate backports."""
|
|
return kvstore.get_default(BACKPORTS_REQUESTED_KEY, False)
|
|
|
|
|
|
def set_backports_requested(requested):
|
|
"""Set whether user has chosen to activate backports."""
|
|
kvstore.set(BACKPORTS_REQUESTED_KEY, requested)
|
|
logger.info('Backports requested - %s', requested)
|
|
|
|
|
|
def is_dist_upgrade_enabled():
|
|
"""Return whether user has enabled dist upgrade."""
|
|
return kvstore.get_default(DIST_UPGRADE_ENABLED_KEY, False)
|
|
|
|
|
|
def set_dist_upgrade_enabled(enabled=True):
|
|
"""Set whether user has enabled dist upgrade."""
|
|
kvstore.set(DIST_UPGRADE_ENABLED_KEY, enabled)
|
|
logger.info('Distribution upgrade configured - %s', enabled)
|
|
|
|
|
|
def is_backports_enabled():
|
|
"""Return whether backports are enabled in the system configuration."""
|
|
return os.path.exists(privileged.BACKPORTS_SOURCES_LIST)
|
|
|
|
|
|
def get_current_release():
|
|
"""Return current release and codename as a tuple."""
|
|
output = action_utils.run(
|
|
['lsb_release', '--release', '--codename', '--short'],
|
|
check=True).stdout.decode().strip()
|
|
lines = output.split('\n')
|
|
return lines[0], lines[1]
|
|
|
|
|
|
def is_backports_current():
|
|
"""Return whether backports are enabled for the current release."""
|
|
if not is_backports_enabled():
|
|
return False
|
|
|
|
_, dist = utils.get_current_release()
|
|
dist += '-backports'
|
|
sources = sourceslist.SourcesList()
|
|
for source in sources:
|
|
if source.dist == dist:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def can_activate_backports():
|
|
"""Return whether backports can be activated."""
|
|
if cfg.develop:
|
|
return True
|
|
|
|
return not utils.is_distribution_unstable()
|
|
|
|
|
|
def can_enable_dist_upgrade():
|
|
"""Return whether dist upgrade can be enabled."""
|
|
return not utils.is_distribution_rolling()
|
|
|
|
|
|
def _diagnose_held_packages():
|
|
"""Check if any packages have holds."""
|
|
check = DiagnosticCheck(PKG_HOLD_DIAG_CHECK_ID,
|
|
gettext_noop('Check for package holds'),
|
|
Result.NOT_DONE)
|
|
if (package.is_package_manager_busy()
|
|
or action_utils.service_is_running('freedombox-dist-upgrade')):
|
|
check.result = Result.SKIPPED
|
|
return check
|
|
|
|
output = subprocess.check_output(['apt-mark', 'showhold']).decode().strip()
|
|
held_packages = output.split()
|
|
check.result = Result.FAILED if held_packages else Result.PASSED
|
|
return check
|