import repository from arizona
[raven.git] / apps / stork / storkrepolist.py
1 #! /usr/bin/env python
2 """
3 <Program Name>
4    storkrepolist.py
5
6 <Started>
7    5-6-09
8
9 <Author>
10    Programmed by .
11
12 <Purpose>
13    Routines for maintaining the list of repositories. The metafile is examined
14    on disk, and a resulting index is built of the various files that were 
15    extracted from the metafile. 
16
17    The various find_file* functions can be used to locate a file of a particular
18    type.
19
20    This module relies on the metafile (and associated tarballs) having already
21    been downloaded by storkrepodl
22 """
23
24 #           [option, long option,               variable,    action,        data,     default,                            metavar,      description]
25 """arizonaconfig
26    options=[
27             ["",     "--localpackageinfo",      "localinfo", "store",       "string", "/usr/local/stork/var/packageinfo", "PACKAGEDIR", "location of local package information (default /usr/local/stork/var/packageinfo)"],
28             ["",     "--repositorypackagedir",  "pdir",      "append",      "string", None,                               "dir",        "repository name and location of packages (the switch may appear multiple times)"],
29             ["",     "--repositorypackageinfo", "repinfo",   "append",      "string", None,                               "dir",        "repository name and location of information about packages (the switch may appear multiple times)"],
30             ["",     "--repositorykey",         "repkey",    "append",      "string", None,                               None,         "repository public key expected to have signed metafile (the switch may appear multiple times)"],
31             ["",     "--repositorypackageurl",  "repurl",    "store",       "string", None,                               None,         "optional URL to use when fetching packages"],
32             ["",     "--repository",            "repositories", "sectionstart", "string",    None,                        "name",       "start repository section"],
33             ["",     "--repositoryend",         "junk",         "sectionstop",  None,        None,                        None,         "end repository section"],
34            ]
35
36    includes=[]
37 """
38
39 import glob
40 import os
41 import sys
42 import tempfile
43 import arizonageneral
44 import shutil
45 import arizonaconfig
46 import arizonacrypt
47 import arizonareport
48 import arizonatransfer
49 import dircache
50 import random
51 import fnmatch
52 import storkusername
53 import ravenlib.package.storkpackage
54 import storkpoison
55
56 from stat import *
57
58 # glo_repo_list: a list of repository dictionaries
59 # each dictionary contains the following entries:
60 #    'name': the name of the repository
61 #    'section': the arizonaconfig section the repository was found in
62 #    'localdir': the local dir of the repository on this node
63 #    'url': the URL to use when downloading (use None for default)
64 #    'files': a list of filenames that were downloaded from the repository
65
66 glo_repo_list = []
67 glo_initialized = False
68
69 # glo_repo_sections: a list of arizonaconfig sections that contain repository
70 # information. If it is set to [None], then that indicates a compatibility
71 # mode where arizonaconfig sections are not used.
72 glo_repo_sections = []
73
74 def init(force = False, maskList=[]):
75    """
76    <Purpose>
77       Initialize and update repository package list(s).
78    """
79    global glo_initialized
80    global glo_repo_sections
81
82    if (glo_initialized) and (not force):
83       return
84
85    glo_repo_sections = arizonaconfig.get_option("repositories")
86    if not glo_repo_sections:
87       # compatibility mode -- None causes arizonaconfig.get_option_section
88       #   to default to the same behaviour as arizonaconfig.get_option
89       glo_repo_sections = [None]
90
91    # check repinfo
92    for repo_section in glo_repo_sections:
93       if arizonaconfig.get_option_section("repinfo", repo_section) == None:
94          if repo_section:
95              arizonareport.send_error(0, "repository " + repo_section + ":")
96          arizonareport.send_error(0, "Repository package information locations must be" + \
97                                      " specified either on the command line" + \
98                                      " or in the configuration file.  See the" + \
99                                      " `--repositorypackageinfo' option.")
100          sys.exit(1)
101
102       # check to see if the user tried to override the localinfo variable for
103       # a specific repository. If so, then complain because this is not yet
104       # implemented
105       # TODO: finish this (see __find_package_fnlist_on_one_criteria)
106       if arizonaconfig.get_option_section("localinfo", repo_section) != arizonaconfig.get_option("localinfo"):
107          arizonareport.send_error(0, "cannot use localinfo variable inside repository section")
108          sys.exit(1)
109
110       configure_repositories(repo_section,
111                             arizonaconfig.get_option_section("repinfo", repo_section),
112                             arizonaconfig.get_option_section("localinfo", repo_section),
113                             maskList)
114
115    # build the list of packageinfo directories
116    build_localpdir()
117
118    # uncomment to dump info about repositories to stdout
119    # dump_repositories()
120
121    glo_initialized = True
122
123 def get_repo_list():
124    return glo_repo_list
125
126 def configure_repositories(repo_section, repo_names, local_dir, maskList=[]):
127    """
128    <Purpose>
129       Download all packageinfo, tpfile, pacman files from all repositories
130    <Arguments>
131       repo_names
132          a list of repository packageinfo names/directories (for example,
133             [quiver.cs.arizona.edu/packageinfo, nr06.cs.arizona.edu/packageinfo]
134          NOTE: this does not appear to be used. the config value of repinfo is
135             used instead.
136       local_dir
137          local directory to place repository data files in
138       update_ok
139          True if files should be downloaded
140    """
141    global glo_repo_list
142    global glo_repo_sections
143
144    glo_repo_list = []
145
146    # TODO: finish update_ok
147
148    # download from each repository in our list
149    repkeys = arizonaconfig.get_option("repkey")
150    for repo_section in glo_repo_sections:
151       for i, repo in enumerate(arizonaconfig.get_option("repinfo", repo_section)):
152          # assuming config options repkey is a list that is in the same order
153          # as the repinfo, we have a key for this repo only if we have a key
154          # at the current index
155          if repkeys and len(repkeys) > i:
156             repkey = repkeys[i]
157          else:
158             repkey = None
159          try:
160             repo_dict = configure_repository(repo, local_dir, repkey, maskList)
161             if repo_dict:
162                repo_dict['section'] = repo_section
163                glo_repo_list.append(repo_dict)
164          except IOError, e:
165             arizonareport.send_error(1,  \
166                "Warning: Failed to update repository " + str(repo) + \
167                   " Error :" + str(e))
168
169
170
171
172 def compute_repo_localdir(local_dir, repo_name):
173    # get a directory name we can use for the repository. Do this by replacing
174    # /'s with _'s.
175    repodir = repo_name
176    repodir.replace("/", "_")
177
178    # get a local directory where we will place this repos information
179    localdir = os.path.join(local_dir, repodir)
180
181    return localdir
182
183
184 def configure_repository(repo_name, local_dir, repo_key=None, maskList=[]):
185    """
186    <Purpose>
187       Download all packageinfo, tpfile, pacman files from a single repository
188    <Arguments>
189       repo_name
190          the name/directory of the repository
191       local_dir
192          local directory to place repository data files in
193       repo_key
194          The key that is expected to have signed the metafile for this repository.
195          If None, then the metafile will not be required to be signed.
196    """
197
198    # create a dict to hold this repository's information
199    repo_dict = {}
200    repo_dict['name'] = repo_name
201
202    # determine the hostname of the repository. A repo_name is usually something
203    # like "stork-repository.cs.arizona.edu/packageinfo", and the hostname is
204    # the first component of it. So look for everything up to the "/"
205    slashPos = repo_name.find("/")
206    if slashPos > 0:
207        repo_dict['hostname'] = repo_name[:slashPos]
208    else:
209        # this probably should never happen
210        repo_dict['hostname'] = repo_name
211
212    # the default URL use when accessing the repository. If no default URL is
213    # specified by the user, then we'll use the hostname.
214    baseUrl = arizonaconfig.get_option("repurl")
215    if not baseUrl:
216        baseUrl = repo_dict['hostname']
217
218    repo_dict['repurl'] = baseUrl
219
220    localdir = compute_repo_localdir(local_dir, repo_name)
221
222    # if the directory isn't there, then we can't download anything
223    if not os.path.isdir(localdir):
224       arizonareport.send_error(1, "Repository data dir does not exist: " + localdir)
225       return None
226
227    repo_dict['localdir'] = localdir
228
229    # setup paths for downloading files
230    source = repo_name
231    dest = localdir
232
233    # TODO: If a file is successfully downloaded and stork crashes before the
234    # file can be unpacked, then stork will think the file is up-to-date next
235    # time around, and it will never get unpacked.
236
237    (success, all_files) = \
238        arizonatransfer.determine_remote_files(source, dest,
239                                       hashfuncs = [ravenlib.package.storkpackage.get_package_metadata_hash, arizonatransfer.default_hashfunc],
240                                       maskList = maskList)
241
242    # we only care about the files on the repository that we have local copies
243    # of
244    existing_files = [file for file in all_files if os.path.exists(file['localfilename'])]
245    existing_file_names = [file['localfilename'] for file in existing_files]
246
247    unpack_repo_tarballs(dest, existing_file_names)
248
249    repo_dict['files'] = existing_file_names
250
251    if not success:
252       arizonareport.send_error(2, "Warning: Could not get all repository metadata, from: " + source)
253
254    arizonareport.flush_out(1)
255
256    return repo_dict
257
258
259
260 def tarball_needs_unpack(filename, flag_fn):
261     if not os.path.exists(flag_fn):
262         return True
263
264     tarball_mtime = os.stat(filename)[ST_MTIME]
265     flag_mtime = os.stat(flag_fn)[ST_MTIME]
266
267     if tarball_mtime > flag_mtime:
268         return True
269     else:
270         return False
271
272 def get_dest_name(pathname):
273    # given a tarball name that looks like /foo/bar/somefilename-hash.tar.bz2,
274    # remove the path and the -hash part.
275
276    (dir,fn) = os.path.split(pathname)
277
278    if not fn.endswith(".tar.bz2"):
279        return fn
280
281    # remove the extension
282    fn = fn[:-8]
283
284    # split the hash from the rest of the name
285    parts = fn.rsplit("-",1)
286    if len(parts)<2:
287        return fn
288
289    name_part = parts[0]
290    hash_part = parts[1]
291
292    # an sha-1 hexdigest is 40 characters long
293    if len(hash_part) != 40:
294        return fn
295
296    return name_part
297
298 def cleanup(fn, destname, extension):
299     """ remove old tarballs that were downloaded """
300     mask = os.path.join(os.path.dirname(fn), destname) + "*" + extension
301
302     for fn_del in glob.glob(mask):
303         if os.path.samefile(fn_del, fn):
304             #print "YYY", fn_del
305             pass
306         else:
307             #print "XXX", fn_del
308             os.remove(fn_del)
309
310
311
312
313
314 def unpack_repo_tarballs(destdir, filename_list):
315    # extract tarballs to obtain package information
316    for tarball in filename_list:
317       if not tarball.endswith(".tar.bz2"):
318           # if it's not a tarball, we don't know what to do with it
319           continue
320
321       tarball_destdir = os.path.join(destdir, get_dest_name(tarball))
322       flag_fn = tarball_destdir + ".unpacked_flag"
323
324       if not tarball_needs_unpack(tarball, flag_fn):
325           continue
326
327       arizonageneral.rmdir_recursive(tarball_destdir)
328
329       if not os.path.exists(tarball_destdir):
330           os.mkdir(tarball_destdir)
331
332       # XXX now that I think about it, I'm not sure I needed to go to all the
333       # trouble of computing tarball_destdir. The tarballs would have unpacked
334       # to a directory that didn't have a hash as part of the name since that's
335       # the way they were packed. Could probably use destdir instead of
336       # tarball_destdir and drop the --strip-components.
337
338       arizonareport.send_out(2, "Unpacking: " + tarball)
339       arizonageneral.popen5("tar -C " + tarball_destdir + " --strip-components 1 -jxf " + tarball)
340       # TODO check for errors
341
342       # create a flag file so that we remember that we unpacked the tarball.
343       # the contents of the file is unimportant
344       file(flag_fn, "w").write(tarball)
345
346       cleanup(tarball, get_dest_name(tarball), ".tar.bz2")
347       cleanup(tarball + ".metahash", get_dest_name(tarball), ".tar.bz2.metahash")
348
349
350
351 def dump_repositories():
352    """
353    <Purpose>
354       Dump our list of repositories to stdout.
355    """
356    global glo_repo_list
357
358    for repo in glo_repo_list:
359       print "Repository: " + repo.get("name", "unknown")
360       print "   hostname: " + repo.get("hostname", "unknown")
361       print "   base url: " + repo.get("repurl", "unknown")
362       print "   local directory: " + repo.get("localdir", "unknown")
363       print "   files: " + ",".join(repo.get("files", []))
364       print "   packageinfo: " + ",".join(repo.get("packageinfo", []))
365       print ""
366
367
368 def build_localpdir():
369    """
370    <Purpose>
371       Build the list of local packageinfo directories. This is done by looking
372       at each repository's file list, comparing them to the repository
373       directories we are interested in
374    """
375    global glo_repo_list
376
377    packageinfo_list = []
378
379    for repo in glo_repo_list:
380       # get the patterns that the user wants us to use. If nothing is
381       # specified, then assume the user wants everything
382       patterns = arizonaconfig.get_option_section("pdir", repo['section'])
383       if patterns == None:
384          patterns = ["*_packages_*"]
385
386       localdir = repo.get("localdir", "")
387       repo_packageinfo_list = []
388       for file in repo.get('files', []):
389          for pattern in patterns:
390             # if pattern is a path, convert it to a filename
391             pattern = pattern.replace("/", "_")
392             pattern = os.path.join(localdir, pattern) + ".tar.bz2"
393             if fnmatch.fnmatch(file, pattern):
394                # do the funny thing that we did when unpacking the tarballs,
395                # to remove the -hash part from directory name.
396                dir = os.path.join(os.path.dirname(file), get_dest_name(file))
397
398                # now that we have the directory, add it to the list
399                repo_packageinfo_list.append(dir)
400
401       # save the info in the repository dict for future use
402       repo['packageinfo'] = repo_packageinfo_list
403
404       # add it to the list of all packageinfo directories
405       packageinfo_list.extend(repo_packageinfo_list)
406
407    arizonaconfig.set_option("localpdir", packageinfo_list)
408
409
410
411
412
413 def find_file_ts(dir, filename_list, publickey_fn=None, publickey_string=None):
414    """
415    <Purpose>
416       Search for a file from multiple repositories
417    <Arguments>
418       dir
419          type of file to look for, currently "tpfiles" or "pacman"
420       filename
421          name of the file to look for
422       publickey_fn
423          filename containing publickey to verify correctness of file
424          if publickey_fn == None, use publickey_string instead
425       publickey_string
426          string containing publickey, if publickey_fn == None
427    <Returns>
428       a tuple (None, None, None, candidate_count) if the file cannot be found
429       a tuple (filename, repo, timestamp, candidate_count) if the file can be found. repo is the dictionary
430       for the repository that contained the file and can be used for
431       informational purposes (letting the user know where the file came from)
432    """
433    found = None
434    found_timestamp = None
435    found_repo = None
436    found_dict = None
437    found_count = 0
438
439    # check to see if the key is poisoned
440    if storkpoison.is_poisoned(file=publickey_fn, string=publickey_string):
441       arizoanreport.send_out(3, "rejecting poisoned key " + str(publickey_fn) + " " + str(publickey_string))
442       # if the key is poisoned, then don't consider it
443       return (None, None, None, 0)
444
445    for filename in filename_list:
446       for repo in glo_repo_list:
447          arizonareport.send_out(4, "[DEBUG] looking for " + filename + " on " + repo['name'])
448          # this_name is the local name of the file we are looking for in the
449          # repository's directory
450          this_name = os.path.join(os.path.join(repo['localdir'], dir), filename)
451    #      storktrackusage.add_file(this_name)
452          if os.path.exists(this_name):
453             arizonareport.send_out(4, "[DEBUG] found: " + this_name)
454             # it exists. Now get the timestamp and compare it to any other
455             # candidate that we might have found.
456             try:
457                this_dict = arizonacrypt.XML_validate_file(this_name, publickey_fn, publickey_string)
458             except TypeError: #changed from general exception
459                # if we failed to extract the timestamp, then the file is bad.
460                # perhaps is has an invalid signature, bad format, missing timestamp...
461                arizonareport.send_error(0, "Warning: Unable to validate " + this_name + " using key file " +
462                   str(publickey_fn) + " or key string " + str(publickey_string))
463                continue
464
465             this_timestamp = this_dict.get("timestamp", 0)
466
467             if storkpoison.is_poison_timestamp(this_timestamp):
468                arizonareport.send_out(0, "poisoned timestamp detected in " + str(this_name) + " for key " + str(publickey_fn) + " " + str(publickey_string))
469                storkpoison.set_poisoned(file = publickey_fn, string = publickey_string)
470                return (None, None, None, 0)
471
472             found_count = found_count + 1
473             if (found == None) or (this_timestamp > found_timestamp):
474                found = this_name
475                found_timestamp = this_timestamp
476                found_dict = this_dict
477                found_repo = repo
478
479    # check and see if the file we found is expired
480    if found_dict:
481       if found_dict.get("expired", False):
482          arizonareport.send_error(0, "File " + str(found) + " is expired")
483          return (None, None, None, 0)
484
485    return (found, found_repo, found_timestamp, found_count)
486
487
488
489
490
491 def find_file_list(dir, filename_list, publickey_fn=None, publickey_string=None):
492    # TODO: comment
493
494    # TODO: args checking
495
496    (found, found_repo, found_timestamp, found_count) = \
497        find_file_ts(dir, filename_list, publickey_fn, publickey_string)
498
499    if found:
500       arizonareport.send_out(4, "[DEBUG] " + os.path.basename(found) + " found on " +
501                                 found_repo['name'] + "(" + str(found_count) +
502                                 " candidates)")
503
504    return (found, found_repo)
505
506
507
508
509
510 def find_file_kind(dir, kind):
511    """
512    <Purpose>
513       Search for a file from multiple repositories using multiple keys
514    <Arguments>
515       dir
516          type of file to look for, currently "tpfiles" or "pacman"
517       kind:
518          suffix of file to look for. For example, "tpfile" to look for something
519          that ends in .tpfile
520    <Returns>
521       a tuple (None, None, None) if the file cannot be found
522       a tuple (filename, repo, keytuple) if the file can be found. repo is the dictionary
523       for the repository that contained the file and can be used for
524       informational purposes (letting the user know where the file came from)
525    """
526    prefixlist = storkusername.build_key_database()
527
528    try_list = []
529
530    found = None
531    found_repo = None
532    found_timestamp = None
533    found_prefix = None
534    found_filename = None
535    found_count = 0
536
537    for prefix in prefixlist:
538       # prefix["prefix"] contains the config_prefix (username.key). Put it together
539       # with the kind to get a candidate filename
540       filename = prefix["prefix"] + "." + kind
541
542       key_fn = prefix["key"].get("sslfn", None)
543       key_string = prefix["key"].get("sslstr", None)
544
545       try_list.append(filename)
546
547       # search for this filename
548       (this_found, this_found_repo, this_found_timestamp, this_found_count) = \
549          find_file_ts(dir, [filename], key_fn, key_string)
550
551       found_count = found_count + this_found_count
552
553       # if we found something and it is better than what we already have, then
554       # update our candidate
555       if (found == None) or (this_found and (this_found_timestamp > found_timestamp)):
556           found = this_found
557           found_repo = this_found_repo
558           found_timestamp = this_found_timestamp
559           found_prefix = prefix
560           found_filename = filename
561
562    # if we didn't find anything, then fall back to searching for the default,
563    # if a default username is available.
564    if not found:
565       default_prefix = storkusername.build_default_prefix()
566       if default_prefix:
567          filename = default_prefix["prefix"] + "." + kind
568
569          key_fn = default_prefix["key"].get("sslfn", None)
570          key_string = default_prefix["key"].get("sslstr", None)
571
572          try_list.append(filename)
573
574          usernames = arizonageneral.uniq([prefix["username"] for key in prefixlist])
575          if usernames:
576              arizonareport.send_out(0, "unable to find a " + kind + " file that starts with " + str(usernames))
577          arizonareport.send_out(0, "reverting default filename: " + filename)
578
579          (found, found_repo, found_timestamp, found_count) = \
580             find_file_ts(dir, [filename], key_fn, key_string)
581
582          if found:
583             found_filename = filename
584             found_prefix = default_prefix
585
586    if found:
587       arizonareport.send_out(4, "[DEBUG] " + found_filename + " found on " +
588                                 found_repo['name'] + "(" + str(found_count) +
589                                 " candidates)")
590    else:
591       arizonareport.send_error(1, "Failed to find a valid " + kind + " file. Tried the following:")
592       for fn in try_list:
593          arizonareport.send_error(1, "  " + fn)
594
595    return (found, found_repo, found_prefix)