1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404
|
# 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
|