~usn-tool/usn-tool/trunk

« back to all changes in this revision

Viewing changes to usn.py

  • Committer: Steve Beattie
  • Date: 2019-02-19 07:48:48 UTC
  • Revision ID: sbeattie@ubuntu.com-20190219074848-2hmbpko59tlrzeav
The usn-tool repository has been converted to git.

To get the converted repository, please use:
  git clone https://git.launchpad.net/usn-tool

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
#!/usr/bin/env python
2
 
from __future__ import print_function
3
 
 
4
 
from ConfigParser import ConfigParser
5
 
from optparse import OptionParser
6
 
from datetime import datetime, date
7
 
from string import Template
8
 
import cPickle
9
 
import pprint
10
 
import time
11
 
import sys
12
 
import os
13
 
import collections
14
 
 
15
 
import genshi.template
16
 
import yaml
17
 
 
18
 
 
19
 
DATABASE_FILENAME = "~/.usn.db"
20
 
CONFIG_FILENAME = "~/.usn.conf"
21
 
 
22
 
 
23
 
class Error(Exception):
24
 
    """Error class for application errors."""
25
 
 
26
 
 
27
 
class Var(str):
28
 
    """Marker for variables in paths."""
29
 
 
30
 
 
31
 
class USNMap(dict):
32
 
 
33
 
    def __init__(self, restrictions={}):
34
 
        self._restrictions = restrictions
35
 
        self._variables = {}
36
 
 
37
 
    def process_option(self, option, name, value, parser, path=None,
38
 
                       append=False, numeric=False):
39
 
        if value == "-":
40
 
            value = sys.stdin.read()
41
 
        name = name.lstrip("-")
42
 
        options = self._restrictions.get(name)
43
 
        if options and value not in options:
44
 
            raise Error("option --%s doesn't accept '%s' "
45
 
                        "according to your restrictions" % (name, value))
46
 
        self._variables[name] = value
47
 
        if numeric:
48
 
            value = int(value)
49
 
        if path is not None:
50
 
            path = list(path)
51
 
            for i in range(len(path)):
52
 
                elem = path[i]
53
 
                if isinstance(elem, Var):
54
 
                    if elem not in self._variables:
55
 
                        raise Error("option --%s is needed before --%s" %
56
 
                                    (elem, name))
57
 
                    path[i] = self._variables[elem]
58
 
            map = self
59
 
            for elem in path[:-1]:
60
 
                map = map.setdefault(elem, {})
61
 
            last_elem = path[-1]
62
 
            if append:
63
 
                if last_elem in map:
64
 
                    map[last_elem].append(value)
65
 
                else:
66
 
                    map[last_elem] = [value]
67
 
            else:
68
 
                map[last_elem] = value
69
 
 
70
 
 
71
 
def parse_options(argv):
72
 
    parser = OptionParser()
73
 
    parser.add_option("--show", action="store_true",
74
 
                      help="Show raw data for the given USN")
75
 
    parser.add_option("--show-cves", action="store_true",
76
 
                      help="Show CVEs for the given USN")
77
 
    parser.add_option("--show-sources", action="store_true",
78
 
                      help="Show source packages for the given USN")
79
 
    parser.add_option("--show-urls", action="store_true",
80
 
                      help="Show URLs for all updated files for the given USN")
81
 
    parser.add_option("--show-description", action="store_true",
82
 
                      help="Show description used in the given USN")
83
 
    parser.add_option("--show-title", action="store_true",
84
 
                      help="Show title used in the given USN")
85
 
    parser.add_option("--show-summary", action="store_true",
86
 
                      help="Show summary used in the given USN")
87
 
    parser.add_option("--show-action", action="store_true",
88
 
                      help="Show action used in the given USN")
89
 
    parser.add_option("--list", action="store_true",
90
 
                      help="Show list of known USNs")
91
 
    parser.add_option("--list-graph", action="store_true",
92
 
                      help="Show count of known USNs per month")
93
 
    parser.add_option("--export", action="store_true",
94
 
                      help="Show all USNs or selected one into YAML format")
95
 
    parser.add_option("--import", action="store_true", dest="import_",
96
 
                      help="Replace USNs with provided YAML data")
97
 
    parser.add_option("--template", action="store", metavar="FILENAME",
98
 
                      help="Render the USN, or all USNs if none specified, using the given template")
99
 
    parser.add_option("--output-dir", action="store", metavar="DIRECTORY",
100
 
                      help="Write template generated USN file(s) to the given directory (only valid with --template)")
101
 
    parser.add_option("--output-ext", action="store", metavar="EXTENSION",
102
 
                      help="Write template generated USN file(s) with the given filename extension (only valid with --output-dir)")
103
 
    parser.add_option("--db", action="store", metavar="FILENAME",
104
 
                      help="Use specified database file")
105
 
 
106
 
    usn_map = USNMap(get_restrictions())
107
 
 
108
 
    def register_option(name, help=None, metavar=None, **kwargs):
109
 
        parser.add_option(name, action="callback", type="string", help=help,
110
 
                          metavar=metavar, callback=usn_map.process_option,
111
 
                          callback_kwargs=kwargs)
112
 
 
113
 
    register_option("--title", path=("title",),
114
 
                    help="The shortest description for the USN")
115
 
    register_option("--summary", path=("summary",),
116
 
                    help="Normal description for the USN")
117
 
    register_option("--description", metavar="TEXT", path=("description",),
118
 
                    help="Textual description for the USN")
119
 
    register_option("--action", metavar="TEXT", path=("action",),
120
 
                    help="Explanation of what should be done")
121
 
    register_option("--issue-summary", metavar="TEXT", path=("isummary",),
122
 
                    help="High level explanation of issues")
123
 
    register_option("--timestamp", path=("timestamp",), numeric=True,
124
 
                    help="Timestamp when the USN was released "
125
 
                         "(defaults to now)")
126
 
    register_option("--cve", path=("cves",), append=True,
127
 
                    help="Link USN with the given CVE")
128
 
    register_option("--release", metavar="NAME",
129
 
                    help="Set Ubuntu release for options requiring it.")
130
 
    register_option("--arch",
131
 
                    help="Set architecture for options requiring it.")
132
 
    register_option("--package", metavar="NAME",
133
 
                    help="Set package name for options requiring it.")
134
 
    register_option("--binary-version", metavar="VER",
135
 
                    path=("releases", Var("release"), "binaries",
136
 
                          Var("package"), "version"),
137
 
                    help="Set binary package version with fix on the "
138
 
                         "given release")
139
 
    register_option("--source-version", metavar="VER",
140
 
                    path=("releases", Var("release"), "sources",
141
 
                          Var("package"), "version"),
142
 
                    help="Set source package version with fix on the "
143
 
                         "given release")
144
 
    register_option("--source-description", metavar="DESC",
145
 
                    path=("releases", Var("release"), "sources",
146
 
                          Var("package"), "description"),
147
 
                    help="Set source package description with fix on the "
148
 
                         "given release")
149
 
    register_option("--url", metavar="URL",
150
 
                    help="Add an URL linked to the current "
151
 
                         "release/architecture")
152
 
    register_option("--url-size", metavar="URL", numeric=True,
153
 
                    path=("releases", Var("release"), "archs", Var("arch"),
154
 
                          "urls", Var("url"), "size"),
155
 
                    help="Set size of the file in the previously provided URL")
156
 
    register_option("--url-md5", metavar="URL",
157
 
                    path=("releases", Var("release"), "archs", Var("arch"),
158
 
                          "urls", Var("url"), "md5"),
159
 
                    help="Set MD5 of the file in the previoulsy provided URL")
160
 
 
161
 
    options, args = parser.parse_args(argv)
162
 
 
163
 
    if len(args) == 1:
164
 
        options.usn = args[0]
165
 
    elif args:
166
 
        raise Error("incorrect number of arguments provided")
167
 
    else:
168
 
        options.usn = None
169
 
 
170
 
    if usn_map and not options.usn:
171
 
        raise Error("no USN identifier provided")
172
 
    else:
173
 
        options.usn_map = usn_map.copy()
174
 
 
175
 
    if options.output_dir and not options.template:
176
 
        parser.error("a template must be specified with an output directory")
177
 
    elif options.output_ext and not options.output_dir:
178
 
        parser.error("an output directory must be specified with an output extension")
179
 
 
180
 
    return options
181
 
 
182
 
 
183
 
def load_database(filename):
184
 
    filename = os.path.expanduser(filename)
185
 
    if not os.path.isfile(filename):
186
 
        return {}
187
 
    with open(filename) as f:
188
 
        database = cPickle.load(f)
189
 
    return database
190
 
 
191
 
 
192
 
def save_database(database, filename):
193
 
    # Make sure we don't destroy the existing database when saving the
194
 
    # new one and we run out of disk space or hit some other error.
195
 
    orig = os.path.expanduser(filename)
196
 
    name = orig + ".saving"
197
 
    with open(name, "w") as f:
198
 
        cPickle.dump(database, f, -1)
199
 
    os.rename(name, orig)
200
 
 
201
 
 
202
 
def get_restrictions():
203
 
    filename = os.path.expanduser(CONFIG_FILENAME)
204
 
    if not os.path.isfile(filename):
205
 
        return {}
206
 
    parser = ConfigParser()
207
 
    parser.read(filename)
208
 
    restrictions = {}
209
 
    for option in parser.options("restrictions"):
210
 
        restrictions[option] = set(parser.get("restrictions", option).split())
211
 
    return restrictions
212
 
 
213
 
 
214
 
def get_codename_to_version_dict(usn_timestamp):
215
 
    codename_to_version = collections.OrderedDict([
216
 
                        ('cosmic',      '18.10'),
217
 
                        ('bionic',      '18.04 LTS'),
218
 
                        ('artful',      '17.10'),
219
 
                        ('zesty',       '17.04'),
220
 
                        ('yakkety',     '16.10'),
221
 
                        ('xenial',      '16.04 LTS'),
222
 
                        ('wily',        '15.10'),
223
 
                        ('vivid',       '15.04'),
224
 
                        ('utopic',      '14.10'),
225
 
                        ('trusty',      '14.04 LTS'),
226
 
                        ('saucy',       '13.10'),
227
 
                        ('raring',      '13.04'),
228
 
                        ('quantal',     '12.10'),
229
 
                        ('precise',     '12.04 LTS'),
230
 
                        ('oneiric',     '11.10'),
231
 
                        ('natty',       '11.04'),
232
 
                        ('maverick',    '10.10'),
233
 
                        ('lucid',       '10.04 LTS'),
234
 
                        ('karmic',      '9.10'),
235
 
                        ('jaunty',      '9.04'),
236
 
                        ('intrepid',    '8.10'),
237
 
                        ('hardy',       '8.04 LTS'),
238
 
                        ('gutsy',       '7.10'),
239
 
                        ('feisty',      '7.04'),
240
 
                        ('edgy',        '6.10'),
241
 
                        ('dapper',      '6.06 LTS'),
242
 
                        ('breezy',      '5.10'),
243
 
                        ('hoary',       '5.04'),
244
 
                        ('warty',       '4.10'),
245
 
                      ])
246
 
 
247
 
    # Set ESM status by comparing against the day after the release went EoL.
248
 
    # For example, 12.04 LTS went EoL on 2017-04-28 so all USNs issued on or
249
 
    # after 2017-04-29 use 12.04 ESM as the Ubuntu version.
250
 
    if usn_timestamp >= datetime(2017, 4, 29):
251
 
        codename_to_version['precise'] = '12.04 ESM'
252
 
 
253
 
    return codename_to_version
254
 
 
255
 
 
256
 
def is_esm_version(version):
257
 
    return version.endswith(' ESM')
258
 
 
259
 
 
260
 
def get_package_descriptions(releases, codename_to_version):
261
 
    descriptions = collections.OrderedDict([])
262
 
 
263
 
    for codename in codename_to_version.keys():
264
 
        if codename in releases and 'sources' in releases[codename]:
265
 
            for name in sorted(releases[codename]['sources']):
266
 
                description = releases[codename]['sources'][name].get('description', '')
267
 
 
268
 
                if name not in descriptions or (not descriptions[name] and description):
269
 
                    descriptions[name] = description
270
 
 
271
 
    return descriptions
272
 
 
273
 
 
274
 
def build_source_links(releases, codename_to_version):
275
 
    source_links = []
276
 
 
277
 
    for codename, version in codename_to_version.iteritems():
278
 
        # Don't show links for ESM releases since the links won't work
279
 
        if is_esm_version(version):
280
 
            continue
281
 
 
282
 
        if codename in releases and 'sources' in releases[codename]:
283
 
            for name in sorted(releases[codename]['sources']):
284
 
                link = 'https://launchpad.net/ubuntu/+source/%s/%s' % (name, releases[codename]['sources'][name]['version'])
285
 
                source_links.append(link)
286
 
 
287
 
    return source_links
288
 
 
289
 
 
290
 
def guess_binary_links(binary, version, sources):
291
 
    '''Guess links to the source package based on binary package and version.
292
 
 
293
 
    Keyword Arguments:
294
 
    binary -- the name of the binary
295
 
    version -- the version of the binary
296
 
    sources -- a dictionary of source package names to source package versions
297
 
 
298
 
    When successful, two different links are returned. The first is a link to
299
 
    the source package on Launchpad. The second is a link to the versioned
300
 
    source package on Launchpad. (None, None) is returned if a high quality
301
 
    cannot be made.
302
 
 
303
 
    This function was inspired by sanely_linkify() that was added by the
304
 
    following commit:
305
 
 
306
 
    revno: 43
307
 
    committer: Jamie Strandboge <jamie@canonical.com>
308
 
    message:
309
 
      first pass at new USN format
310
 
 
311
 
    It included this comment:
312
 
 
313
 
      Since there is no strict mapping of binary packages to source packages
314
 
      in the USN database, we cannot link back to the source packages for
315
 
      each binary package without adding external knowledge to this template.
316
 
      As a work-around, we can guess at the source package in the case where
317
 
      there is only 1 source package in the USN (common case), or when each
318
 
      source package has a distinct version number from every other binary
319
 
      package version number (common for multi-source USNs).  In strange
320
 
      situations, we can just fall back to an unlinkified USN report of
321
 
      packages.
322
 
    '''
323
 
    match_first = False
324
 
    source_match = None
325
 
    version_match = None
326
 
    source_link = None
327
 
    version_link = None
328
 
 
329
 
    if not sources:
330
 
        # Old USNs may not have any sources listed. We can't make any sort of a
331
 
        # guess in this situation.
332
 
        return (None, None)
333
 
    if len(sources) == 1:
334
 
        # There's a many-to-one mapping of binaries to a source package. Use
335
 
        # the only possible source package for all binaries.
336
 
        match_first = True
337
 
    elif not version:
338
 
        # There are multiple combinations of possible binary to source package
339
 
        # mappings. We can't make an educated guess if we don't have a valid
340
 
        # binary package version so don't attempt to construct a link.
341
 
        return (None, None)
342
 
 
343
 
    for source in sources:
344
 
        source_version = sources[source].get('version')
345
 
        if match_first or version == source_version:
346
 
            source_match = source
347
 
            version_match = source_version
348
 
            break
349
 
 
350
 
    if source_match:
351
 
        source_link = 'https://launchpad.net/ubuntu/+source/' + source_match
352
 
        if version_match:
353
 
            # Be certain to use the source package version rather than the
354
 
            # binary package version here or the link will be broken for
355
 
            # certain packages
356
 
            version_link = source_link + '/' + version_match
357
 
 
358
 
    return (source_link, version_link)
359
 
 
360
 
 
361
 
def insert_guessed_binary_links_for_release(release):
362
 
    '''Guess and insert links to source packages for all binary packages.
363
 
 
364
 
    Keyword Arguments:
365
 
    release -- a dict of binaries and sources for a given release
366
 
 
367
 
    See guess_binary_links() for caveats.
368
 
    '''
369
 
    for binary, details in release['binaries'].iteritems():
370
 
        (source_link, version_link) = guess_binary_links(binary,
371
 
                                                         details.get('version'),
372
 
                                                         release.get('sources'))
373
 
        release['binaries'][binary]['source_link'] = source_link
374
 
        release['binaries'][binary]['version_link'] = version_link
375
 
 
376
 
 
377
 
def insert_empty_binary_links_for_release(release):
378
 
    '''Insert empty (None) links to source packages for all binary packages.
379
 
 
380
 
    Keyword Arguments:
381
 
    release -- a dict of binaries and sources for a given release
382
 
 
383
 
    This is useful for binaries of an ESM release since any guessed links would
384
 
    be dead links that do not work.
385
 
    '''
386
 
    for binary in release['binaries']:
387
 
        release['binaries'][binary]['source_link'] = None
388
 
        release['binaries'][binary]['version_link'] = None
389
 
 
390
 
 
391
 
def insert_binary_links(releases, codename_to_version):
392
 
    '''Insert links to source packages for all binary packages in all releases.
393
 
 
394
 
    Keyword Arguments:
395
 
    releases -- a dict of releases containing dicts of binaries and sources
396
 
    codename_to_version -- a mapping of Ubuntu release codenames to versions
397
 
 
398
 
    Updates the releases dict with per-binary source package links. Two
399
 
    different classes of links are inserted. The first is a link to the source
400
 
    package on Launchpad. The second is a link to the versioned source package
401
 
    on Launchpad. Some templates, such as webpage-markdown.txt, may find these
402
 
    links useful.
403
 
    '''
404
 
    for codename, version in codename_to_version.iteritems():
405
 
        if codename not in releases:
406
 
            continue
407
 
        elif 'binaries' not in releases[codename]:
408
 
            continue
409
 
        elif is_esm_version(version):
410
 
            insert_empty_binary_links_for_release(releases[codename])
411
 
        else:
412
 
            insert_guessed_binary_links_for_release(releases[codename])
413
 
 
414
 
 
415
 
def render_template(template, data):
416
 
    if template.lstrip().startswith("<") and template.rstrip().endswith(">"):
417
 
        Template = genshi.template.MarkupTemplate
418
 
        format = "xhtml"
419
 
    else:
420
 
        Template = genshi.template.NewTextTemplate
421
 
        format = "text"
422
 
    stream = Template(template).generate(**data)
423
 
    return stream.render(format)
424
 
 
425
 
 
426
 
def update_map(map, changes):
427
 
    for key, value in changes.iteritems():
428
 
        if key not in map:
429
 
            map[key] = value
430
 
        elif isinstance(value, list):
431
 
            map[key].extend(value)
432
 
        elif isinstance(value, dict):
433
 
            update_map(map[key], value)
434
 
        else:
435
 
            map[key] = value
436
 
 
437
 
 
438
 
def decode_strings(obj):
439
 
    obj_type = type(obj)
440
 
    if obj_type is str:
441
 
        return obj.decode("utf-8", "replace")
442
 
    elif obj_type is dict:
443
 
        for key, value in list(obj.iteritems()):
444
 
            obj[key] = decode_strings(value)
445
 
    elif obj_type is list:
446
 
        for i in range(len(obj)):
447
 
            obj[i] = decode_strings(obj[i])
448
 
    return obj
449
 
 
450
 
 
451
 
def usncmp(a, b):
452
 
    return cmp(int(a.split('-')[0]), int(b.split('-')[0]))
453
 
 
454
 
 
455
 
def generate_usn_from_template(usn_map, template):
456
 
    usn_timestamp = datetime.utcfromtimestamp(usn_map["timestamp"])
457
 
    codename_to_version = get_codename_to_version_dict(usn_timestamp)
458
 
 
459
 
    usn_map["timestamp"] = usn_timestamp
460
 
    usn_map["source_links"] = build_source_links(usn_map['releases'], codename_to_version)
461
 
    insert_binary_links(usn_map['releases'], codename_to_version)
462
 
    usn_map["package_descriptions"] = get_package_descriptions(usn_map['releases'], codename_to_version)
463
 
    usn_map["codename_to_version"] = codename_to_version
464
 
 
465
 
    decode_strings(usn_map)  # Genshi doesn't like non-ascii plain strings.
466
 
 
467
 
 
468
 
    # XXX when people.c.c moves to a more modern python, get rid of this
469
 
    # hack. An example of a troublemaking usn is 3583-1.
470
 
    if sys.version_info.major == 2 and sys.version_info.minor == 7 and sys.version_info.micro < 6:
471
 
        return render_template(open(template).read(), usn_map)
472
 
    else:
473
 
        return render_template(open(template).read(), usn_map).encode('utf-8')
474
 
 
475
 
 
476
 
def main(argv):
477
 
    options = parse_options(argv)
478
 
 
479
 
    # Load database
480
 
    dbpath = DATABASE_FILENAME
481
 
    if options.db:
482
 
        dbpath = options.db
483
 
    database = load_database(dbpath)
484
 
 
485
 
    if options.import_:
486
 
        data = yaml.load(sys.stdin.read())
487
 
        database.update(data)
488
 
    if options.usn_map:
489
 
        usn_map = database.setdefault(options.usn, {})
490
 
        update_map(usn_map, options.usn_map)
491
 
        usn_map["id"] = options.usn
492
 
        usn_map.setdefault("releases", {})
493
 
        usn_map.setdefault("timestamp", time.time())
494
 
    if options.usn_map or options.import_:
495
 
        save_database(database, dbpath)
496
 
    if options.template:
497
 
        if options.usn:
498
 
            if options.usn not in database:
499
 
                raise Error("USN %s not in the database" % options.usn)
500
 
 
501
 
            usns = [options.usn]
502
 
        else:
503
 
            usns = sorted(database.keys(), cmp=usncmp)
504
 
 
505
 
        for usn in usns:
506
 
            usn_text = generate_usn_from_template(database[usn], options.template)
507
 
 
508
 
            if options.output_dir:
509
 
                path = os.path.join(options.output_dir, usn)
510
 
                if options.output_ext:
511
 
                    path += ".%s" % (options.output_ext)
512
 
 
513
 
                with open(path, "w") as f:
514
 
                    f.write(usn_text)
515
 
            else:
516
 
                sys.stdout.write(usn_text)
517
 
        return
518
 
    if options.export:
519
 
        if options.usn:
520
 
            data = dict()
521
 
            for usn in options.usn.split(','):
522
 
                data[usn] = database[usn]
523
 
        else:
524
 
            data = database
525
 
        return yaml.dump(data)
526
 
    if options.show or options.show_cves or options.show_sources or options.show_urls or options.show_summary or options.show_title or options.show_action or options.show_description:
527
 
        data = database
528
 
        if options.usn:
529
 
            if options.usn not in database:
530
 
                raise Error("USN %s not in the database" % options.usn)
531
 
            else:
532
 
                data = database[options.usn]
533
 
        if options.show:
534
 
            pp = pprint.PrettyPrinter(indent=4)
535
 
            pp.pprint(data)
536
 
        if options.show_cves:
537
 
            for cve in data['cves']:
538
 
                if cve.startswith('CVE'):
539
 
                    print(cve)
540
 
        if options.show_sources:
541
 
            sources = set()
542
 
            for rel in data['releases']:
543
 
                for source in data['releases'][rel]['sources']:
544
 
                    sources.add(source)
545
 
            for source in sources:
546
 
                print(source)
547
 
        if options.show_urls:
548
 
            urls = []
549
 
            for rel in data['releases']:
550
 
                for arch in data['releases'][rel]['archs'].keys():
551
 
                    urls += [] + data['releases'][rel]['archs'][arch]['urls'].keys()
552
 
            for url in urls:
553
 
                print(url)
554
 
        if options.show_description:
555
 
            print(data['description'])
556
 
        if options.show_title:
557
 
            print(data['title'])
558
 
        if options.show_summary:
559
 
            print(data['summary'])
560
 
        if options.show_action:
561
 
            print(data['action'])
562
 
    if options.list:
563
 
        for usn in sorted(database.keys(), cmp=usncmp):
564
 
            print(usn)
565
 
    if options.list_graph:
566
 
        months = dict()
567
 
        for usn in database.keys():
568
 
            month = datetime.utcfromtimestamp(database[usn]['timestamp']).strftime('%Y-%m')
569
 
            months.setdefault(month, 0)
570
 
            months[month] += 1
571
 
        for month in sorted(months):
572
 
            print("%s\t%d" % (month, months[month]))
573
 
 
574
 
 
575
 
if __name__ == "__main__":
576
 
    try:
577
 
        output = main(sys.argv[1:])
578
 
        if output:
579
 
            print(output.encode('utf-8'))
580
 
    except Error as e:
581
 
        sys.exit("error: %s" % str(e))