~ubuntu-core-dev/update-manager/main

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
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
#!/usr/bin/python3

import apt
import argparse
import distro_info
import json
import os
import sys
import gettext
import shutil
import subprocess

from UpdateManager.Core.utils import get_dist

from datetime import datetime
from textwrap import wrap
from urllib.error import URLError, HTTPError
from urllib.request import urlopen

# TODO make DEBUG an environmental variable
DEBUG = False
UA_STATUS_FILE = "/var/lib/ubuntu-advantage/status.json"


class PatchStats:
    """Tracks overall patch status

    The relationship between archives enabled and whether a patch is eligible
    for receiving updates is non-trivial. We track here all the important
    buckets a package can be in:

        - Whether it is set to expire with no ESM coverage
        - Whether it is in an archive covered by ESM
        - Whether it received LTS patches
        - whether it received ESM patches

    We also track the total packages covered and uncovered, and for the
    uncovered packages, we track where they originate from.

    The Ubuntu main archive receives patches for 5 years.
    Canonical-owned archives (excluding partner) receive patches for 10 years.
        patches for 10 years.
    """
    def __init__(self):
        # TODO no-update FIPS is never patched
        self.pkgs_uncovered_fips = set()

        # list of package names available in ESM
        self.pkgs_updated_in_esmi = set()
        self.pkgs_updated_in_esma = set()

        self.pkgs_mr = set()
        self.pkgs_um = set()
        self.pkgs_unavailable = set()
        self.pkgs_thirdparty = set()
        # the bin of unknowns
        self.pkgs_uncategorized = set()


def print_debug(s):
    if DEBUG:
        print(s)


def whats_in_esm(url):
    pkgs = set()
    # return a set of package names in an esm archive
    try:
        response = urlopen(url)
    except (URLError, HTTPError):
        print_debug('failed to load: %s' % url)
        return pkgs
    try:
        content = response.read().decode('utf-8')
    except IOError:
        print('failed to read data at: %s' % url)
        sys.exit(1)
    for line in content.split('\n'):
        if not line.startswith('Package:'):
            continue
        else:
            pkg = line.split(': ')[1]
            pkgs.add(pkg)
    return pkgs


def get_ua_status():
    """Return dict of active ua status information from ubuntu-advantage-tools

    Prefer to obtain status information from cache on disk to avoid costly
    roundtrips to contracts.canonical.com to check on available services.

    Fallback to call ua status --format=json.

    If there are errors running: ua status --format=json or if the status on
    disk is unparseable, return an empty dict.
    """
    if os.path.exists(UA_STATUS_FILE):
        with open(UA_STATUS_FILE, "r") as stream:
            status = stream.read()
    else:
        try:
            status = subprocess.check_output(
                ['ua', 'status', '--format=json']
            ).decode()
        except subprocess.CalledProcessError as e:
            print_debug('failed to run ua status: %s' % e)
            return {}
    try:
        return json.loads(status)
    except json.decoder.JSONDecodeError as e:
        print_debug('failed to parse JSON from ua status output: %s' % e)
        return {}


def is_ua_service_enabled(service_name: str) -> bool:
    """Check to see if named esm service is enabled.

    :return: True if UA status reports service as enabled.
    """
    status = get_ua_status()
    for service in status.get("services", []):
        if service["name"] == service_name:
            # Machines unattached to UA will not provide service 'status' key.
            return service.get("status") == "enabled"
    return False


def trim_archive(archive):
    return archive.split("-")[-1]


def trim_site(host):
    # *.ec2.archive.ubuntu.com -> archive.ubuntu.com
    if host.endswith("archive.ubuntu.com"):
        return "archive.ubuntu.com"
    return host


def mirror_list():
    m_file = '/usr/share/ubuntu-release-upgrader/mirrors.cfg'
    if not os.path.exists(m_file):
        print("Official mirror list not found.")
    with open(m_file) as f:
        items = [x.strip() for x in f]
    mirrors = [s.split('//')[1].split('/')[0] for s in items
               if not s.startswith("#") and not s == ""]
    # ddebs.ubuntu.com isn't in mirrors.cfg for every release
    mirrors.append('ddebs.ubuntu.com')
    return mirrors


def origins_for(ver: apt.package.Version) -> str:
    s = []
    for origin in ver.origins:
        if not origin.site:
            # When the package is installed, site is empty, archive/component
            # are "now/now"
            continue
        site = trim_site(origin.site)
        s.append("%s %s/%s" % (site, origin.archive, origin.component))
    return ",".join(s)


def print_wrapped(str):
    print("\n".join(wrap(str, break_on_hyphens=False)))


def print_thirdparty_count():
    print(gettext.dngettext("update-manager",
                            "%s package is from a third party",
                            "%s packages are from third parties",
                            len(pkgstats.pkgs_thirdparty)) %
          "{:>{width}}".format(len(pkgstats.pkgs_thirdparty), width=width))


def print_unavailable_count():
    print(gettext.dngettext("update-manager",
                            "%s package is no longer available for "
                            "download",
                            "%s packages are no longer available for "
                            "download",
                            len(pkgstats.pkgs_unavailable)) %
          "{:>{width}}".format(len(pkgstats.pkgs_unavailable), width=width))


def parse_options():
    '''Parse command line arguments.

    Return parser
    '''
    parser = argparse.ArgumentParser(
        description='Return information about security support for packages')
    parser.add_argument('--thirdparty', action='store_true')
    parser.add_argument('--unavailable', action='store_true')
    return parser


if __name__ == "__main__":
    # Prefer redirecting to 'pro security-status' if it exists
    if shutil.which("/usr/bin/pro"):
        print("This command has been replaced with 'pro security-status'.")
        subprocess.run(["/usr/bin/pro", "security-status"])
        sys.exit(0)

    # gettext
    APP = "update-manager"
    DIR = "/usr/share/locale"
    gettext.bindtextdomain(APP, DIR)
    gettext.textdomain(APP)

    parser = parse_options()
    args = parser.parse_args()

    esm_site = "esm.ubuntu.com"

    try:
        dpkg = subprocess.check_output(['dpkg', '--print-architecture'])
        arch = dpkg.decode().strip()
    except subprocess.CalledProcessError:
        print("failed getting dpkg architecture")
        sys.exit(1)

    cache = apt.Cache()
    pkgstats = PatchStats()
    codename = get_dist()
    di = distro_info.UbuntuDistroInfo()
    lts = di.is_lts(codename)
    release_expired = True
    if codename in di.supported():
        release_expired = False
    # distro-info-data in Ubuntu 16.04 LTS does not have eol-esm data
    if codename != 'xenial':
        eol_data = [(r.eol, r.eol_esm)
                    for r in di._releases if r.series == codename][0]
    elif codename == 'xenial':
        eol_data = (datetime.strptime('2021-04-21', '%Y-%m-%d'),
                    datetime.strptime('2024-04-21', '%Y-%m-%d'))
    eol = eol_data[0]
    eol_esm = eol_data[1]

    all_origins = set()
    origins_by_package = {}
    official_mirrors = mirror_list()

    # N.B. only the security pocket is checked because this tool displays
    # information about security updates
    esm_url = \
        'https://%s/%s/ubuntu/dists/%s-%s-%s/main/binary-%s/Packages'
    pkgs_in_esma = whats_in_esm(esm_url %
                                (esm_site, 'apps', codename, 'apps',
                                 'security', arch))
    pkgs_in_esmi = whats_in_esm(esm_url %
                                (esm_site, 'infra', codename, 'infra',
                                 'security', arch))

    for pkg in cache:
        pkgname = pkg.name

        downloadable = True
        if not pkg.is_installed:
            continue
        if not pkg.candidate or not pkg.candidate.downloadable:
            downloadable = False
        pkg_sites = []
        origins_by_package[pkgname] = set()

        for ver in pkg.versions:
            # Loop through origins and store all of them. The idea here is that
            # we don't care where the installed package comes from, provided
            # there is at least one repository we identify as being
            # security-assured under either LTS or ESM.
            for origin in ver.origins:
                # TODO: in order to handle FIPS and other archives which have
                # root-level path names, we'll need to loop over ver.uris
                # instead
                if not origin.site:
                    continue
                site = trim_site(origin.site)
                archive = origin.archive
                component = origin.component
                origin = origin.origin
                official_mirror = False
                thirdparty = True
                # thirdparty providers like dl.google.com don't set "Origin"
                if origin != "Ubuntu":
                    thirdparty = False
                if site in official_mirrors:
                    site = "official_mirror"
                if "MY_MIRROR" in os.environ:
                    if site in os.environ["MY_MIRROR"]:
                        site = "official_mirror"
                t = (site, archive, component, thirdparty)
                if not site:
                    continue
                all_origins.add(t)
                origins_by_package[pkgname].add(t)

            if DEBUG:
                pkg_sites.append("%s %s/%s" %
                                 (site, archive, component))

        print_debug("available versions for %s" % pkgname)
        print_debug(",".join(pkg_sites))

    # This tracks suites we care about. Sadly, it appears that the way apt
    # stores origins truncates away the path that comes after the
    # domainname in the site portion, or maybe I am just clueless, but
    # there's no way to tell FIPS apart from ESM, for instance.
    # See 00REPOS.txt for examples

    # 2020-03-18 ver.filename has the path so why is that no good?

    # TODO Need to handle:
    #   MAAS, lxd, juju PPAs
    #   other PPAs
    #   other repos

    # TODO handle partner.c.c

    # main and restricted from release, -updates, -proposed, or -security
    # pockets
    suite_main = ("official_mirror", codename, "main", True)
    suite_main_updates = ("official_mirror", codename + "-updates",
                          "main", True)
    suite_main_security = ("official_mirror", codename + "-security",
                           "main", True)
    suite_main_proposed = ("official_mirror", codename + "-proposed",
                           "main", True)

    suite_restricted = ("official_mirror", codename, "restricted",
                        True)
    suite_restricted_updates = ("official_mirror",
                                codename + "-updates",
                                "restricted", True)
    suite_restricted_security = ("official_mirror",
                                 codename + "-security",
                                 "restricted", True)
    suite_restricted_proposed = ("official_mirror",
                                 codename + "-proposed",
                                 "restricted", True)

    # universe and multiverse from release, -updates, -proposed, or -security
    # pockets
    suite_universe = ("official_mirror", codename, "universe", True)
    suite_universe_updates = ("official_mirror", codename + "-updates",
                              "universe", True)
    suite_universe_security = ("official_mirror",
                               codename + "-security",
                               "universe", True)
    suite_universe_proposed = ("official_mirror",
                               codename + "-proposed",
                               "universe", True)

    suite_multiverse = ("official_mirror", codename, "multiverse",
                        True)
    suite_multiverse_updates = ("official_mirror",
                                codename + "-updates",
                                "multiverse", True)
    suite_multiverse_security = ("official_mirror",
                                 codename + "-security",
                                 "multiverse", True)
    suite_multiverse_proposed = ("official_mirror",
                                 codename + "-proposed",
                                 "multiverse", True)

    # packages from the esm respositories
    # N.B. Origin: Ubuntu is not set for esm
    suite_esm_main = (esm_site, "%s-infra-updates" % codename,
                      "main")
    suite_esm_main_security = (esm_site,
                               "%s-infra-security" % codename, "main")
    suite_esm_universe = (esm_site,
                          "%s-apps-updates" % codename, "main")
    suite_esm_universe_security = (esm_site,
                                   "%s-apps-security" % codename,
                                   "main")

    esm_infra_enabled = is_ua_service_enabled("esm-infra")
    esm_apps_enabled = is_ua_service_enabled("esm-apps")
    ua_attached = get_ua_status().get("attached", False)

    # Now do the final loop through
    for pkg in cache:
        if not pkg.is_installed:
            continue
        if not pkg.candidate or not pkg.candidate.downloadable:
            pkgstats.pkgs_unavailable.add(pkg.name)
            continue
        pkgname = pkg.name
        pkg_origins = origins_by_package[pkgname]

        # This set of is_* booleans tracks specific situations we care about in
        # the logic below; for instance, if the package has a main origin, or
        # if the esm repos are enabled.

        # Some packages get added in -updates and don't exist in the release
        # pocket e.g. ubuntu-advantage-tools and libdrm-updates. To be safe all
        # pockets are allowed.
        is_mr_pkg_origin = (suite_main in pkg_origins) or \
                           (suite_main_updates in pkg_origins) or \
                           (suite_main_security in pkg_origins) or \
                           (suite_main_proposed in pkg_origins) or \
                           (suite_restricted in pkg_origins) or \
                           (suite_restricted_updates in pkg_origins) or \
                           (suite_restricted_security in pkg_origins) or \
                           (suite_restricted_proposed in pkg_origins)
        is_um_pkg_origin = (suite_universe in pkg_origins) or \
                           (suite_universe_updates in pkg_origins) or \
                           (suite_universe_security in pkg_origins) or \
                           (suite_universe_proposed in pkg_origins) or \
                           (suite_multiverse in pkg_origins) or \
                           (suite_multiverse_updates in pkg_origins) or \
                           (suite_multiverse_security in pkg_origins) or \
                           (suite_multiverse_proposed in pkg_origins)

        is_esm_infra_pkg_origin = (suite_esm_main in pkg_origins) or \
                                  (suite_esm_main_security in pkg_origins)
        is_esm_apps_pkg_origin = (suite_esm_universe in pkg_origins) or \
                                 (suite_esm_universe_security in pkg_origins)

        # A third party one won't appear in any of the above origins
        if not is_mr_pkg_origin and not is_um_pkg_origin \
                and not is_esm_infra_pkg_origin and not is_esm_apps_pkg_origin:
            pkgstats.pkgs_thirdparty.add(pkgname)

        if False:  # TODO package has ESM fips origin
            # TODO package has ESM fips-updates origin: OK
            # If user has enabled FIPS, but not updates, BAD, but need some
            # thought on how to display it, as it can't be patched at all
            pass
        elif is_mr_pkg_origin:
            pkgstats.pkgs_mr.add(pkgname)
        elif is_um_pkg_origin:
            pkgstats.pkgs_um.add(pkgname)
        else:
            # TODO print information about packages in this category if in
            # debugging mode
            pkgstats.pkgs_uncategorized.add(pkgname)

        # Check to see if the package is available in esm-infra or esm-apps
        # and add it to the right pkgstats category
        # NB: apps is ordered first for testing the hello package which is both
        # in esmi and esma
        if pkgname in pkgs_in_esma:
            pkgstats.pkgs_updated_in_esma.add(pkgname)
        elif pkgname in pkgs_in_esmi:
            pkgstats.pkgs_updated_in_esmi.add(pkgname)

    total_packages = (len(pkgstats.pkgs_mr) +
                      len(pkgstats.pkgs_um) +
                      len(pkgstats.pkgs_thirdparty) +
                      len(pkgstats.pkgs_unavailable))
    width = len(str(total_packages))
    print("%s packages installed, of which:" %
          "{:>{width}}".format(total_packages, width=width))

    # filters first as they provide less information
    if args.thirdparty:
        if pkgstats.pkgs_thirdparty:
            pkgs_thirdparty = sorted(p for p in pkgstats.pkgs_thirdparty)
            print_thirdparty_count()
            print_wrapped(' '.join(pkgs_thirdparty))
            msg = ("Packages from third parties are not provided by the "
                   "official Ubuntu archive, for example packages from "
                   "Personal Package Archives in Launchpad.")
            print("")
            print_wrapped(msg)
            print("")
            print_wrapped("Run 'apt-cache policy %s' to learn more about "
                          "that package." % pkgs_thirdparty[0])
            sys.exit(0)
        else:
            print_wrapped("You have no packages installed from a third party.")
            sys.exit(0)
    if args.unavailable:
        if pkgstats.pkgs_unavailable:
            pkgs_unavailable = sorted(p for p in pkgstats.pkgs_unavailable)
            print_unavailable_count()
            print_wrapped(' '.join(pkgs_unavailable))
            msg = ("Packages that are not available for download "
                   "may be left over from a previous release of "
                   "Ubuntu, may have been installed directly from "
                   "a .deb file, or are from a source which has "
                   "been disabled.")
            print("")
            print_wrapped(msg)
            print("")
            print_wrapped("Run 'apt-cache show %s' to learn more about "
                          "that package." % pkgs_unavailable[0])
            sys.exit(0)
        else:
            print_wrapped("You have no packages installed that are no longer "
                          "available.")
            sys.exit(0)
    # Only show LTS patches and expiration notices if the release is not
    # yet expired; showing LTS patches would give a false sense of
    # security.
    if not release_expired:
        print("%s receive package updates%s until %d/%d" %
              ("{:>{width}}".format(len(pkgstats.pkgs_mr),
                                    width=width),
               " with LTS" if lts else "",
               eol.month, eol.year))
    elif release_expired and lts:
        receive_text = "could receive"
        if esm_infra_enabled:
            if len(pkgstats.pkgs_mr) == 1:
                receive_text = "is receiving"
            else:
                receive_text = "are receiving"
        print("%s %s security updates with ESM Infra "
              "until %d/%d" %
              ("{:>{width}}".format(len(pkgstats.pkgs_mr),
                                    width=width),
               receive_text, eol_esm.month, eol_esm.year))
    if lts and pkgstats.pkgs_um and is_ua_service_enabled("esm-apps"):
        if len(pkgstats.pkgs_um) == 1:
            receive_text = "is receiving"
        else:
            receive_text = "are receiving"
        print("%s %s security updates with ESM Apps "
              "until %d/%d" %
              ("{:>{width}}".format(len(pkgstats.pkgs_um),
                                    width=width),
               receive_text, eol_esm.month, eol_esm.year))
    if pkgstats.pkgs_thirdparty:
        print_thirdparty_count()
    if pkgstats.pkgs_unavailable:
        print_unavailable_count()
    # print the detail messages after the count of packages
    if pkgstats.pkgs_thirdparty:
        msg = ("Packages from third parties are not provided by the "
               "official Ubuntu archive, for example packages from "
               "Personal Package Archives in Launchpad.")
        print("")
        print_wrapped(msg)
        action = ("For more information on the packages, run "
                  "'ubuntu-security-status --thirdparty'.")
        print_wrapped(action)
    if pkgstats.pkgs_unavailable:
        msg = ("Packages that are not available for download "
               "may be left over from a previous release of "
               "Ubuntu, may have been installed directly from "
               "a .deb file, or are from a source which has "
               "been disabled.")
        print("")
        print_wrapped(msg)
        action = ("For more information on the packages, run "
                  "'ubuntu-security-status --unavailable'.")
        print_wrapped(action)
    # print the ESM calls to action last
    if lts and not esm_infra_enabled:
        if release_expired and pkgstats.pkgs_mr:
            pkgs_updated_in_esmi = pkgstats.pkgs_updated_in_esmi
            print("")
            msg = gettext.dngettext(
                "update-manager",
                "Enable Extended Security "
                "Maintenance (ESM Infra) to "
                "get %i security update (so far) ",
                "Enable Extended Security "
                "Maintenance (ESM Infra) to "
                "get %i security updates (so far) ",
                len(pkgs_updated_in_esmi)) % len(pkgs_updated_in_esmi)
            msg += gettext.dngettext(
                "update-manager",
                "and enable coverage of %i "
                "package.",
                "and enable coverage of %i "
                "packages.",
                len(pkgstats.pkgs_mr)) % len(pkgstats.pkgs_mr)
            print_wrapped(msg)
            if ua_attached:
                print("\nEnable ESM Infra with: ua enable esm-infra")

    if lts and not ua_attached:
        print("\nThis machine is not attached to an Ubuntu Advantage "
              "subscription.\nSee https://ubuntu.com/advantage")