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

« back to all changes in this revision

Viewing changes to ubuntu-security-status

  • Committer: Julian Andres Klode
  • Date: 2018-03-15 10:22:01 UTC
  • mto: This revision was merged to the branch mainline in revision 2807.
  • Revision ID: juliank@ubuntu.com-20180315102201-77hgsrk34hcbcbtl
Use HTTPS for changelogs.ubuntu.com (LP: #1744318)

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
#!/usr/bin/python3
2
 
 
3
 
import apt
4
 
import argparse
5
 
import distro_info
6
 
import json
7
 
import os
8
 
import sys
9
 
import gettext
10
 
import shutil
11
 
import subprocess
12
 
 
13
 
from UpdateManager.Core.utils import get_dist
14
 
 
15
 
from datetime import datetime
16
 
from textwrap import wrap
17
 
from urllib.error import URLError, HTTPError
18
 
from urllib.request import urlopen
19
 
 
20
 
# TODO make DEBUG an environmental variable
21
 
DEBUG = False
22
 
UA_STATUS_FILE = "/var/lib/ubuntu-advantage/status.json"
23
 
 
24
 
 
25
 
class PatchStats:
26
 
    """Tracks overall patch status
27
 
 
28
 
    The relationship between archives enabled and whether a patch is eligible
29
 
    for receiving updates is non-trivial. We track here all the important
30
 
    buckets a package can be in:
31
 
 
32
 
        - Whether it is set to expire with no ESM coverage
33
 
        - Whether it is in an archive covered by ESM
34
 
        - Whether it received LTS patches
35
 
        - whether it received ESM patches
36
 
 
37
 
    We also track the total packages covered and uncovered, and for the
38
 
    uncovered packages, we track where they originate from.
39
 
 
40
 
    The Ubuntu main archive receives patches for 5 years.
41
 
    Canonical-owned archives (excluding partner) receive patches for 10 years.
42
 
        patches for 10 years.
43
 
    """
44
 
    def __init__(self):
45
 
        # TODO no-update FIPS is never patched
46
 
        self.pkgs_uncovered_fips = set()
47
 
 
48
 
        # list of package names available in ESM
49
 
        self.pkgs_updated_in_esmi = set()
50
 
        self.pkgs_updated_in_esma = set()
51
 
 
52
 
        self.pkgs_mr = set()
53
 
        self.pkgs_um = set()
54
 
        self.pkgs_unavailable = set()
55
 
        self.pkgs_thirdparty = set()
56
 
        # the bin of unknowns
57
 
        self.pkgs_uncategorized = set()
58
 
 
59
 
 
60
 
def print_debug(s):
61
 
    if DEBUG:
62
 
        print(s)
63
 
 
64
 
 
65
 
def whats_in_esm(url):
66
 
    pkgs = set()
67
 
    # return a set of package names in an esm archive
68
 
    try:
69
 
        response = urlopen(url)
70
 
    except (URLError, HTTPError):
71
 
        print_debug('failed to load: %s' % url)
72
 
        return pkgs
73
 
    try:
74
 
        content = response.read().decode('utf-8')
75
 
    except IOError:
76
 
        print('failed to read data at: %s' % url)
77
 
        sys.exit(1)
78
 
    for line in content.split('\n'):
79
 
        if not line.startswith('Package:'):
80
 
            continue
81
 
        else:
82
 
            pkg = line.split(': ')[1]
83
 
            pkgs.add(pkg)
84
 
    return pkgs
85
 
 
86
 
 
87
 
def get_ua_status():
88
 
    """Return dict of active ua status information from ubuntu-advantage-tools
89
 
 
90
 
    Prefer to obtain status information from cache on disk to avoid costly
91
 
    roundtrips to contracts.canonical.com to check on available services.
92
 
 
93
 
    Fallback to call ua status --format=json.
94
 
 
95
 
    If there are errors running: ua status --format=json or if the status on
96
 
    disk is unparseable, return an empty dict.
97
 
    """
98
 
    if os.path.exists(UA_STATUS_FILE):
99
 
        with open(UA_STATUS_FILE, "r") as stream:
100
 
            status = stream.read()
101
 
    else:
102
 
        try:
103
 
            status = subprocess.check_output(
104
 
                ['ua', 'status', '--format=json']
105
 
            ).decode()
106
 
        except subprocess.CalledProcessError as e:
107
 
            print_debug('failed to run ua status: %s' % e)
108
 
            return {}
109
 
    try:
110
 
        return json.loads(status)
111
 
    except json.decoder.JSONDecodeError as e:
112
 
        print_debug('failed to parse JSON from ua status output: %s' % e)
113
 
        return {}
114
 
 
115
 
 
116
 
def is_ua_service_enabled(service_name: str) -> bool:
117
 
    """Check to see if named esm service is enabled.
118
 
 
119
 
    :return: True if UA status reports service as enabled.
120
 
    """
121
 
    status = get_ua_status()
122
 
    for service in status.get("services", []):
123
 
        if service["name"] == service_name:
124
 
            # Machines unattached to UA will not provide service 'status' key.
125
 
            return service.get("status") == "enabled"
126
 
    return False
127
 
 
128
 
 
129
 
def trim_archive(archive):
130
 
    return archive.split("-")[-1]
131
 
 
132
 
 
133
 
def trim_site(host):
134
 
    # *.ec2.archive.ubuntu.com -> archive.ubuntu.com
135
 
    if host.endswith("archive.ubuntu.com"):
136
 
        return "archive.ubuntu.com"
137
 
    return host
138
 
 
139
 
 
140
 
def mirror_list():
141
 
    m_file = '/usr/share/ubuntu-release-upgrader/mirrors.cfg'
142
 
    if not os.path.exists(m_file):
143
 
        print("Official mirror list not found.")
144
 
    with open(m_file) as f:
145
 
        items = [x.strip() for x in f]
146
 
    mirrors = [s.split('//')[1].split('/')[0] for s in items
147
 
               if not s.startswith("#") and not s == ""]
148
 
    # ddebs.ubuntu.com isn't in mirrors.cfg for every release
149
 
    mirrors.append('ddebs.ubuntu.com')
150
 
    return mirrors
151
 
 
152
 
 
153
 
def origins_for(ver: apt.package.Version) -> str:
154
 
    s = []
155
 
    for origin in ver.origins:
156
 
        if not origin.site:
157
 
            # When the package is installed, site is empty, archive/component
158
 
            # are "now/now"
159
 
            continue
160
 
        site = trim_site(origin.site)
161
 
        s.append("%s %s/%s" % (site, origin.archive, origin.component))
162
 
    return ",".join(s)
163
 
 
164
 
 
165
 
def print_wrapped(str):
166
 
    print("\n".join(wrap(str, break_on_hyphens=False)))
167
 
 
168
 
 
169
 
def print_thirdparty_count():
170
 
    print(gettext.dngettext("update-manager",
171
 
                            "%s package is from a third party",
172
 
                            "%s packages are from third parties",
173
 
                            len(pkgstats.pkgs_thirdparty)) %
174
 
          "{:>{width}}".format(len(pkgstats.pkgs_thirdparty), width=width))
175
 
 
176
 
 
177
 
def print_unavailable_count():
178
 
    print(gettext.dngettext("update-manager",
179
 
                            "%s package is no longer available for "
180
 
                            "download",
181
 
                            "%s packages are no longer available for "
182
 
                            "download",
183
 
                            len(pkgstats.pkgs_unavailable)) %
184
 
          "{:>{width}}".format(len(pkgstats.pkgs_unavailable), width=width))
185
 
 
186
 
 
187
 
def parse_options():
188
 
    '''Parse command line arguments.
189
 
 
190
 
    Return parser
191
 
    '''
192
 
    parser = argparse.ArgumentParser(
193
 
        description='Return information about security support for packages')
194
 
    parser.add_argument('--thirdparty', action='store_true')
195
 
    parser.add_argument('--unavailable', action='store_true')
196
 
    return parser
197
 
 
198
 
 
199
 
if __name__ == "__main__":
200
 
    # Prefer redirecting to 'pro security-status' if it exists
201
 
    if shutil.which("/usr/bin/pro"):
202
 
        print("This command has been replaced with 'pro security-status'.")
203
 
        subprocess.run(["/usr/bin/pro", "security-status"])
204
 
        sys.exit(0)
205
 
 
206
 
    # gettext
207
 
    APP = "update-manager"
208
 
    DIR = "/usr/share/locale"
209
 
    gettext.bindtextdomain(APP, DIR)
210
 
    gettext.textdomain(APP)
211
 
 
212
 
    parser = parse_options()
213
 
    args = parser.parse_args()
214
 
 
215
 
    esm_site = "esm.ubuntu.com"
216
 
 
217
 
    try:
218
 
        dpkg = subprocess.check_output(['dpkg', '--print-architecture'])
219
 
        arch = dpkg.decode().strip()
220
 
    except subprocess.CalledProcessError:
221
 
        print("failed getting dpkg architecture")
222
 
        sys.exit(1)
223
 
 
224
 
    cache = apt.Cache()
225
 
    pkgstats = PatchStats()
226
 
    codename = get_dist()
227
 
    di = distro_info.UbuntuDistroInfo()
228
 
    lts = di.is_lts(codename)
229
 
    release_expired = True
230
 
    if codename in di.supported():
231
 
        release_expired = False
232
 
    # distro-info-data in Ubuntu 16.04 LTS does not have eol-esm data
233
 
    if codename != 'xenial':
234
 
        eol_data = [(r.eol, r.eol_esm)
235
 
                    for r in di._releases if r.series == codename][0]
236
 
    elif codename == 'xenial':
237
 
        eol_data = (datetime.strptime('2021-04-21', '%Y-%m-%d'),
238
 
                    datetime.strptime('2024-04-21', '%Y-%m-%d'))
239
 
    eol = eol_data[0]
240
 
    eol_esm = eol_data[1]
241
 
 
242
 
    all_origins = set()
243
 
    origins_by_package = {}
244
 
    official_mirrors = mirror_list()
245
 
 
246
 
    # N.B. only the security pocket is checked because this tool displays
247
 
    # information about security updates
248
 
    esm_url = \
249
 
        'https://%s/%s/ubuntu/dists/%s-%s-%s/main/binary-%s/Packages'
250
 
    pkgs_in_esma = whats_in_esm(esm_url %
251
 
                                (esm_site, 'apps', codename, 'apps',
252
 
                                 'security', arch))
253
 
    pkgs_in_esmi = whats_in_esm(esm_url %
254
 
                                (esm_site, 'infra', codename, 'infra',
255
 
                                 'security', arch))
256
 
 
257
 
    for pkg in cache:
258
 
        pkgname = pkg.name
259
 
 
260
 
        downloadable = True
261
 
        if not pkg.is_installed:
262
 
            continue
263
 
        if not pkg.candidate or not pkg.candidate.downloadable:
264
 
            downloadable = False
265
 
        pkg_sites = []
266
 
        origins_by_package[pkgname] = set()
267
 
 
268
 
        for ver in pkg.versions:
269
 
            # Loop through origins and store all of them. The idea here is that
270
 
            # we don't care where the installed package comes from, provided
271
 
            # there is at least one repository we identify as being
272
 
            # security-assured under either LTS or ESM.
273
 
            for origin in ver.origins:
274
 
                # TODO: in order to handle FIPS and other archives which have
275
 
                # root-level path names, we'll need to loop over ver.uris
276
 
                # instead
277
 
                if not origin.site:
278
 
                    continue
279
 
                site = trim_site(origin.site)
280
 
                archive = origin.archive
281
 
                component = origin.component
282
 
                origin = origin.origin
283
 
                official_mirror = False
284
 
                thirdparty = True
285
 
                # thirdparty providers like dl.google.com don't set "Origin"
286
 
                if origin != "Ubuntu":
287
 
                    thirdparty = False
288
 
                if site in official_mirrors:
289
 
                    site = "official_mirror"
290
 
                if "MY_MIRROR" in os.environ:
291
 
                    if site in os.environ["MY_MIRROR"]:
292
 
                        site = "official_mirror"
293
 
                t = (site, archive, component, thirdparty)
294
 
                if not site:
295
 
                    continue
296
 
                all_origins.add(t)
297
 
                origins_by_package[pkgname].add(t)
298
 
 
299
 
            if DEBUG:
300
 
                pkg_sites.append("%s %s/%s" %
301
 
                                 (site, archive, component))
302
 
 
303
 
        print_debug("available versions for %s" % pkgname)
304
 
        print_debug(",".join(pkg_sites))
305
 
 
306
 
    # This tracks suites we care about. Sadly, it appears that the way apt
307
 
    # stores origins truncates away the path that comes after the
308
 
    # domainname in the site portion, or maybe I am just clueless, but
309
 
    # there's no way to tell FIPS apart from ESM, for instance.
310
 
    # See 00REPOS.txt for examples
311
 
 
312
 
    # 2020-03-18 ver.filename has the path so why is that no good?
313
 
 
314
 
    # TODO Need to handle:
315
 
    #   MAAS, lxd, juju PPAs
316
 
    #   other PPAs
317
 
    #   other repos
318
 
 
319
 
    # TODO handle partner.c.c
320
 
 
321
 
    # main and restricted from release, -updates, -proposed, or -security
322
 
    # pockets
323
 
    suite_main = ("official_mirror", codename, "main", True)
324
 
    suite_main_updates = ("official_mirror", codename + "-updates",
325
 
                          "main", True)
326
 
    suite_main_security = ("official_mirror", codename + "-security",
327
 
                           "main", True)
328
 
    suite_main_proposed = ("official_mirror", codename + "-proposed",
329
 
                           "main", True)
330
 
 
331
 
    suite_restricted = ("official_mirror", codename, "restricted",
332
 
                        True)
333
 
    suite_restricted_updates = ("official_mirror",
334
 
                                codename + "-updates",
335
 
                                "restricted", True)
336
 
    suite_restricted_security = ("official_mirror",
337
 
                                 codename + "-security",
338
 
                                 "restricted", True)
339
 
    suite_restricted_proposed = ("official_mirror",
340
 
                                 codename + "-proposed",
341
 
                                 "restricted", True)
342
 
 
343
 
    # universe and multiverse from release, -updates, -proposed, or -security
344
 
    # pockets
345
 
    suite_universe = ("official_mirror", codename, "universe", True)
346
 
    suite_universe_updates = ("official_mirror", codename + "-updates",
347
 
                              "universe", True)
348
 
    suite_universe_security = ("official_mirror",
349
 
                               codename + "-security",
350
 
                               "universe", True)
351
 
    suite_universe_proposed = ("official_mirror",
352
 
                               codename + "-proposed",
353
 
                               "universe", True)
354
 
 
355
 
    suite_multiverse = ("official_mirror", codename, "multiverse",
356
 
                        True)
357
 
    suite_multiverse_updates = ("official_mirror",
358
 
                                codename + "-updates",
359
 
                                "multiverse", True)
360
 
    suite_multiverse_security = ("official_mirror",
361
 
                                 codename + "-security",
362
 
                                 "multiverse", True)
363
 
    suite_multiverse_proposed = ("official_mirror",
364
 
                                 codename + "-proposed",
365
 
                                 "multiverse", True)
366
 
 
367
 
    # packages from the esm respositories
368
 
    # N.B. Origin: Ubuntu is not set for esm
369
 
    suite_esm_main = (esm_site, "%s-infra-updates" % codename,
370
 
                      "main")
371
 
    suite_esm_main_security = (esm_site,
372
 
                               "%s-infra-security" % codename, "main")
373
 
    suite_esm_universe = (esm_site,
374
 
                          "%s-apps-updates" % codename, "main")
375
 
    suite_esm_universe_security = (esm_site,
376
 
                                   "%s-apps-security" % codename,
377
 
                                   "main")
378
 
 
379
 
    esm_infra_enabled = is_ua_service_enabled("esm-infra")
380
 
    esm_apps_enabled = is_ua_service_enabled("esm-apps")
381
 
    ua_attached = get_ua_status().get("attached", False)
382
 
 
383
 
    # Now do the final loop through
384
 
    for pkg in cache:
385
 
        if not pkg.is_installed:
386
 
            continue
387
 
        if not pkg.candidate or not pkg.candidate.downloadable:
388
 
            pkgstats.pkgs_unavailable.add(pkg.name)
389
 
            continue
390
 
        pkgname = pkg.name
391
 
        pkg_origins = origins_by_package[pkgname]
392
 
 
393
 
        # This set of is_* booleans tracks specific situations we care about in
394
 
        # the logic below; for instance, if the package has a main origin, or
395
 
        # if the esm repos are enabled.
396
 
 
397
 
        # Some packages get added in -updates and don't exist in the release
398
 
        # pocket e.g. ubuntu-advantage-tools and libdrm-updates. To be safe all
399
 
        # pockets are allowed.
400
 
        is_mr_pkg_origin = (suite_main in pkg_origins) or \
401
 
                           (suite_main_updates in pkg_origins) or \
402
 
                           (suite_main_security in pkg_origins) or \
403
 
                           (suite_main_proposed in pkg_origins) or \
404
 
                           (suite_restricted in pkg_origins) or \
405
 
                           (suite_restricted_updates in pkg_origins) or \
406
 
                           (suite_restricted_security in pkg_origins) or \
407
 
                           (suite_restricted_proposed in pkg_origins)
408
 
        is_um_pkg_origin = (suite_universe in pkg_origins) or \
409
 
                           (suite_universe_updates in pkg_origins) or \
410
 
                           (suite_universe_security in pkg_origins) or \
411
 
                           (suite_universe_proposed in pkg_origins) or \
412
 
                           (suite_multiverse in pkg_origins) or \
413
 
                           (suite_multiverse_updates in pkg_origins) or \
414
 
                           (suite_multiverse_security in pkg_origins) or \
415
 
                           (suite_multiverse_proposed in pkg_origins)
416
 
 
417
 
        is_esm_infra_pkg_origin = (suite_esm_main in pkg_origins) or \
418
 
                                  (suite_esm_main_security in pkg_origins)
419
 
        is_esm_apps_pkg_origin = (suite_esm_universe in pkg_origins) or \
420
 
                                 (suite_esm_universe_security in pkg_origins)
421
 
 
422
 
        # A third party one won't appear in any of the above origins
423
 
        if not is_mr_pkg_origin and not is_um_pkg_origin \
424
 
                and not is_esm_infra_pkg_origin and not is_esm_apps_pkg_origin:
425
 
            pkgstats.pkgs_thirdparty.add(pkgname)
426
 
 
427
 
        if False:  # TODO package has ESM fips origin
428
 
            # TODO package has ESM fips-updates origin: OK
429
 
            # If user has enabled FIPS, but not updates, BAD, but need some
430
 
            # thought on how to display it, as it can't be patched at all
431
 
            pass
432
 
        elif is_mr_pkg_origin:
433
 
            pkgstats.pkgs_mr.add(pkgname)
434
 
        elif is_um_pkg_origin:
435
 
            pkgstats.pkgs_um.add(pkgname)
436
 
        else:
437
 
            # TODO print information about packages in this category if in
438
 
            # debugging mode
439
 
            pkgstats.pkgs_uncategorized.add(pkgname)
440
 
 
441
 
        # Check to see if the package is available in esm-infra or esm-apps
442
 
        # and add it to the right pkgstats category
443
 
        # NB: apps is ordered first for testing the hello package which is both
444
 
        # in esmi and esma
445
 
        if pkgname in pkgs_in_esma:
446
 
            pkgstats.pkgs_updated_in_esma.add(pkgname)
447
 
        elif pkgname in pkgs_in_esmi:
448
 
            pkgstats.pkgs_updated_in_esmi.add(pkgname)
449
 
 
450
 
    total_packages = (len(pkgstats.pkgs_mr) +
451
 
                      len(pkgstats.pkgs_um) +
452
 
                      len(pkgstats.pkgs_thirdparty) +
453
 
                      len(pkgstats.pkgs_unavailable))
454
 
    width = len(str(total_packages))
455
 
    print("%s packages installed, of which:" %
456
 
          "{:>{width}}".format(total_packages, width=width))
457
 
 
458
 
    # filters first as they provide less information
459
 
    if args.thirdparty:
460
 
        if pkgstats.pkgs_thirdparty:
461
 
            pkgs_thirdparty = sorted(p for p in pkgstats.pkgs_thirdparty)
462
 
            print_thirdparty_count()
463
 
            print_wrapped(' '.join(pkgs_thirdparty))
464
 
            msg = ("Packages from third parties are not provided by the "
465
 
                   "official Ubuntu archive, for example packages from "
466
 
                   "Personal Package Archives in Launchpad.")
467
 
            print("")
468
 
            print_wrapped(msg)
469
 
            print("")
470
 
            print_wrapped("Run 'apt-cache policy %s' to learn more about "
471
 
                          "that package." % pkgs_thirdparty[0])
472
 
            sys.exit(0)
473
 
        else:
474
 
            print_wrapped("You have no packages installed from a third party.")
475
 
            sys.exit(0)
476
 
    if args.unavailable:
477
 
        if pkgstats.pkgs_unavailable:
478
 
            pkgs_unavailable = sorted(p for p in pkgstats.pkgs_unavailable)
479
 
            print_unavailable_count()
480
 
            print_wrapped(' '.join(pkgs_unavailable))
481
 
            msg = ("Packages that are not available for download "
482
 
                   "may be left over from a previous release of "
483
 
                   "Ubuntu, may have been installed directly from "
484
 
                   "a .deb file, or are from a source which has "
485
 
                   "been disabled.")
486
 
            print("")
487
 
            print_wrapped(msg)
488
 
            print("")
489
 
            print_wrapped("Run 'apt-cache show %s' to learn more about "
490
 
                          "that package." % pkgs_unavailable[0])
491
 
            sys.exit(0)
492
 
        else:
493
 
            print_wrapped("You have no packages installed that are no longer "
494
 
                          "available.")
495
 
            sys.exit(0)
496
 
    # Only show LTS patches and expiration notices if the release is not
497
 
    # yet expired; showing LTS patches would give a false sense of
498
 
    # security.
499
 
    if not release_expired:
500
 
        print("%s receive package updates%s until %d/%d" %
501
 
              ("{:>{width}}".format(len(pkgstats.pkgs_mr),
502
 
                                    width=width),
503
 
               " with LTS" if lts else "",
504
 
               eol.month, eol.year))
505
 
    elif release_expired and lts:
506
 
        receive_text = "could receive"
507
 
        if esm_infra_enabled:
508
 
            if len(pkgstats.pkgs_mr) == 1:
509
 
                receive_text = "is receiving"
510
 
            else:
511
 
                receive_text = "are receiving"
512
 
        print("%s %s security updates with ESM Infra "
513
 
              "until %d/%d" %
514
 
              ("{:>{width}}".format(len(pkgstats.pkgs_mr),
515
 
                                    width=width),
516
 
               receive_text, eol_esm.month, eol_esm.year))
517
 
    if lts and pkgstats.pkgs_um and is_ua_service_enabled("esm-apps"):
518
 
        if len(pkgstats.pkgs_um) == 1:
519
 
            receive_text = "is receiving"
520
 
        else:
521
 
            receive_text = "are receiving"
522
 
        print("%s %s security updates with ESM Apps "
523
 
              "until %d/%d" %
524
 
              ("{:>{width}}".format(len(pkgstats.pkgs_um),
525
 
                                    width=width),
526
 
               receive_text, eol_esm.month, eol_esm.year))
527
 
    if pkgstats.pkgs_thirdparty:
528
 
        print_thirdparty_count()
529
 
    if pkgstats.pkgs_unavailable:
530
 
        print_unavailable_count()
531
 
    # print the detail messages after the count of packages
532
 
    if pkgstats.pkgs_thirdparty:
533
 
        msg = ("Packages from third parties are not provided by the "
534
 
               "official Ubuntu archive, for example packages from "
535
 
               "Personal Package Archives in Launchpad.")
536
 
        print("")
537
 
        print_wrapped(msg)
538
 
        action = ("For more information on the packages, run "
539
 
                  "'ubuntu-security-status --thirdparty'.")
540
 
        print_wrapped(action)
541
 
    if pkgstats.pkgs_unavailable:
542
 
        msg = ("Packages that are not available for download "
543
 
               "may be left over from a previous release of "
544
 
               "Ubuntu, may have been installed directly from "
545
 
               "a .deb file, or are from a source which has "
546
 
               "been disabled.")
547
 
        print("")
548
 
        print_wrapped(msg)
549
 
        action = ("For more information on the packages, run "
550
 
                  "'ubuntu-security-status --unavailable'.")
551
 
        print_wrapped(action)
552
 
    # print the ESM calls to action last
553
 
    if lts and not esm_infra_enabled:
554
 
        if release_expired and pkgstats.pkgs_mr:
555
 
            pkgs_updated_in_esmi = pkgstats.pkgs_updated_in_esmi
556
 
            print("")
557
 
            msg = gettext.dngettext(
558
 
                "update-manager",
559
 
                "Enable Extended Security "
560
 
                "Maintenance (ESM Infra) to "
561
 
                "get %i security update (so far) ",
562
 
                "Enable Extended Security "
563
 
                "Maintenance (ESM Infra) to "
564
 
                "get %i security updates (so far) ",
565
 
                len(pkgs_updated_in_esmi)) % len(pkgs_updated_in_esmi)
566
 
            msg += gettext.dngettext(
567
 
                "update-manager",
568
 
                "and enable coverage of %i "
569
 
                "package.",
570
 
                "and enable coverage of %i "
571
 
                "packages.",
572
 
                len(pkgstats.pkgs_mr)) % len(pkgstats.pkgs_mr)
573
 
            print_wrapped(msg)
574
 
            if ua_attached:
575
 
                print("\nEnable ESM Infra with: ua enable esm-infra")
576
 
 
577
 
    if lts and not ua_attached:
578
 
        print("\nThis machine is not attached to an Ubuntu Advantage "
579
 
              "subscription.\nSee https://ubuntu.com/advantage")