File: __init__.py

package info (click to toggle)
freedombox 26.3
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 83,092 kB
  • sloc: python: 48,542; javascript: 1,730; xml: 481; makefile: 290; sh: 137; php: 32
file content (404 lines) | stat: -rw-r--r-- 14,732 bytes parent folder | download | duplicates (2)
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