import repository from arizona
[raven.git] / apps / stork / storkpackagelist.py
1 #! /usr/bin/env python
2 """
3 <Program Name>
4    storkpackagelist.py
5
6 <Started>
7    October 6, 2005
8
9 <Author>
10    Programmed by Justin Cappos.
11
12 <Purpose>
13    Routines for accessing and querying the package list.
14 """
15
16 #           [option, long option,               variable,    action,        data,     default,                            metavar,      description]
17 """arizonaconfig
18    options=[
19             ["",     "--localpackageinfo",      "localinfo", "store",       "string", "/usr/local/stork/var/packageinfo", "PACKAGEDIR", "location of local package information (default /usr/local/stork/var/packageinfo)"],
20             ["",     "--repositorypackagedir",  "pdir",      "append",      "string", None,                               "dir",        "repository name and location of packages (the switch may appear multiple times)"],
21             ["",     "--repositorypackageinfo", "repinfo",   "append",      "string", None,                               "dir",        "repository name and location of information about packages (the switch may appear multiple times)"],
22             ["",     "--repositorykey",         "repkey",    "append",      "string", None,                               None,         "repository public key expected to have signed metafile (the switch may appear multiple times)"],
23             ["",     "--repositorypackageurl",  "repurl",    "store",       "string", None,                               None,         "optional URL to use when fetching packages"],
24             ["",     "--noupdatepackageinfo",   "updatedb",  "store_false", None,     True,                               None,         "do not attempt to update the package database"]]
25             ["",     "--repository",     "repositories", "sectionstart", "string",    None,                             "name",      "start repository section"],
26             ["",     "--repositoryend",  "junk",         "sectionstop",  None,        None,                              None,        "end repository section"],
27
28    includes=[]
29 """
30
31 import os
32 import sys
33 import tempfile
34 import arizonageneral
35 import shutil
36 import arizonaconfig
37 import arizonacrypt
38 import arizonareport
39 import arizonatransfer
40 import dircache
41 import random
42 import fnmatch
43 import ravenlib.package.storkpackage
44 import storkpoison
45 import storkrepolist
46 #import storktrackusage
47
48 glo_initialized = False
49
50
51 def init(force = False):
52     global glo_initialized
53
54     if (glo_initialized) and (not force):
55         return
56
57     storkrepolist.init(force)
58
59     glo_initialized = True
60
61
62
63
64
65 def package_exists(name):
66    """
67    <Purpose>
68       Checks to see if a particular package exists in the package lists.
69
70    <Arguments>
71       name:
72               Name of the package.
73
74    <Exceptions>
75       None.
76
77    <Side Effects>
78       None.
79
80    <Returns>
81       True or False, or None on error.
82    """
83
84    if name == None:
85       return None
86    
87    # check params
88    arizonageneral.check_type_simple(name, "name", str, "storkpackagelist.package_exists")
89
90    # search for packages with the given name
91    criteria_dict = {}
92    criteria_dict['name'] = name
93    mylist = find_packages(criteria_dict)
94
95    # return True if there was at least one entry found, False otherwise
96    return len(mylist) > 0
97
98
99
100
101
102 def find_package_name(filename):
103    """
104    <Purpose>
105       Finds the correct package name for the given package filename.
106
107    <Arguments>
108       filename:
109               Filename of the package.
110
111    <Exceptions>
112       None.
113
114    <Side Effects>
115       None.
116
117    <Returns>
118       Package name, or None if not found.
119    """
120    # check params
121    arizonageneral.check_type_simple(filename, "filename", str, "storkpackagelist.find_package_name")
122
123    retlist = []
124
125    # search for packages matching the given filename   
126    criteria_dict = {}
127    criteria_dict['filename'] = filename
128    mylist = find_packages(criteria_dict)
129    
130    # go through results, and build a list of package names
131    for package in mylist:
132       retlist.append(package["name"])
133   
134    # remove any duplicate entries 
135    retlist = arizonageneral.uniq(retlist)
136
137    # return None if there weren't any results
138    if len(retlist) < 1:
139       return None
140
141    # abort if there was more than one result.  we don't know which one
142    # they want to use.      
143    if len(retlist) > 1:
144       raise TypeError, "Unable to determine package name for '" + \
145                        filename + "'.\n  Multiple matches found: " + \
146                        ", ".join(retlist) + "."
147
148    # there was exactly one result, return it   
149    return retlist[0]
150
151
152
153
154
155 # Most of the functions are going to return dictionaries (rather than huge 
156 # tuples).   The dictionary keys are the package information types (like hash,
157 # size, etc.).   The values are the corresponding values of those types for
158 # this package.
159 #
160 # The fields in the package dictionary are defined in
161 # storkpackage.get_package_metadata, but described in detail here:
162 #
163 # filename      filename as stored on disk, ex: "nano-1.2.3-1.i386.rpm"
164 # name          package name, not including version or release, ex: "nano"
165 # version       package version, not including release, ex: "1.2.3"
166 # release       package release, ex: "1"
167 # size          unpacked package size, ex: "1026407"
168 # hash          sha1 hash of the package file,
169 #               ex: "59bb5f0cd15ee45ffff24a7c45538045608c8632"
170 # provides      list of dependencies fulfilled, each in the format:
171 #               "name = version-release"
172 # requires      list of dependencies required, each in the format:
173 #               "/filename" or "name OP version[-release]", where OP is
174 #               one of: =, <, <=, >, >=, and [-release] indicates that
175 #               the release number is optional.
176 # files         list of files and directories that will be installed by
177 #               the package
178 # URL           list of URLs as provided by the creator of the metadata. See
179 #               also _URL.
180 # _valid        set to True if the metadata's hash has been validated
181 # _repo         repository that this metadata came from
182 # _metadatahash TODO description
183 # _URL          list of expected download locations for the package. _URL is
184 #               generated from URL if it is available. Otherwise a default
185 #               _URL is generated by using the URL of the repository.
186 #
187 # Note that these field names are not hardcoded anywhere in this file
188 # (with the exception of names starting with _),  so if an improper field 
189 # name is used, it will just result in an empty search result. 
190 #
191 # Any field (key) beginning with an _ (for example, _metadatahash) is not
192 # stored in the file and is reconstituted on the fly
193
194 def find_packages(criteria):
195    """
196    <Purpose>
197       This takes a dict of items and fields and returns a list of 
198       corresponding package dicts.  This is essentially a search function 
199       for packages given incomplete information
200
201    <Arguments>      
202       criteria:
203          This is a dictionary where the keys correspond to the fields that
204          need to be matched and the fields are the values that must be
205          found.   
206
207    <Exceptions>
208       TypeError is raised when given an invalid argument.
209       IOError may be raised if the package meta files are corrupted
210
211    <Side Effects>
212       None
213
214    <Returns>
215       a list of dicts corresponding to the matched packages
216    """
217    arizonageneral.check_type_simple(criteria, "criteria", dict, "find_packages")
218
219    # Find all of the packages that match this key/value pair
220    initial = True
221    for keyvalpairs in criteria.iteritems():
222      cur_filenames = __find_package_fnlist_on_one_criteria(keyvalpairs[0], keyvalpairs[1])
223      if initial:
224         initial = False
225         filenamelist = cur_filenames
226      else:
227         # If we have more than one criteria, we only want files that match
228         # all of the criteria
229         filenamelist = arizonageneral.intersect(filenamelist, cur_filenames)
230
231    # Now we build the list of package information dicts
232    metadatadicts = []
233
234    for current_fn in filenamelist:
235       metadatadicts.append(package_metadata_fn_to_dict(current_fn))
236
237    # TODO Do I want to do a "uniq" operation or combine URLs somehow?
238
239    return metadatadicts
240    
241
242
243
244
245 def __find_package_fnlist_on_one_criteria(field, value, path=None):
246    """
247    <Purpose>
248       This takes a field and a value and returns a list of corresponding 
249       package filenames.  This is essentially a search function for 
250       packages given incomplete information.
251
252    <Arguments> 
253       field:
254                 Field to be searched, ex: _metadatahash
255       value:
256                 Value we want to find in the field, ex: 3fa687d23a07cc9...
257       path:
258                 (default: None, for no restriction)
259                 Restrict search to a certain directory.     
260
261    <Exceptions>
262       TypeError is raised when given an invalid argument.
263       IOError may be raised if the package meta files are corrupted
264
265    <Side Effects>
266       None.
267
268    <Returns>
269       A list of package filenames.
270    """
271    matching_files = []
272    
273    stars = value.count("*")
274    if stars == 1:
275       if value[-1] == "*":
276          useindex = True
277          fuzzy = True
278          value2 = value[:-1]
279       else:
280          useindex = False   
281    elif stars > 1:
282       useindex = False
283    else:
284       useindex = True
285       fuzzy = False
286       value2 = value
287
288    # determine which directories to search
289    if path == None:
290       search_dirs = arizonaconfig.get_option("localpdir")
291    else:
292       search_dirs = [path]
293
294    # Handle metadatahash as a special case
295    if field == "_metadatahash":
296       for package_dir in search_dirs:
297          # Currently, each metadatahash is its own file, and all
298          # metadatahash files for a pdir are in one directory.. so what
299          # we do is look in that directory for the hash that we want.  If
300          # a file by that name (the hash) exists, then we add the filename
301          # to the matching files list
302          if os.path.exists(os.path.join(arizonaconfig.get_option("localinfo"), package_dir, value)):
303             matching_files.append(os.path.join(arizonaconfig.get_option("localinfo"), package_dir, value))
304       return matching_files
305
306    # need to search for list items differently than plain strings
307    search = arizonageneral.grep_escape("'" + value + "'", True)
308    if field == "files" or field == "provides" or field == "requires":
309       # search for [anything'value'anything]
310       search = "\\\\[.*" + search + ".*\\\\]"
311
312    # now the kitchen sink matcher...
313    for package_dir in search_dirs:
314       # does an index file exist?  if so, use it (faster!)
315       if useindex:
316          index = os.path.join(arizonaconfig.get_option("localinfo"), package_dir, field + ".index")
317          if os.path.isfile(index):
318             return __hashtree_get(index, value2, fuzzy=fuzzy)
319
320       # Grab files that match the search format: (start of line)field:(search as above)
321       out, err, status = arizonageneral.popen5("grep -rl ^" + field + ":" + search + " " + \
322                          os.path.join(arizonaconfig.get_option("localinfo"), package_dir))
323
324       # add all of the files to this list
325       for line in out:
326          matching_files.append(line.rstrip("\n"))
327
328    return matching_files
329
330
331
332
333
334 def __hashtree_get(filename, key, fuzzy=False, offset=False):
335    """
336    <Purpose>
337       Searches a hashtree file for the specified key and returns either 
338       its associated data as a list or its byte offset in the index file.
339       
340       Hashtree file entries are one line each, and are in the following 
341       format:
342       key\tdata[\t...]\n
343
344    <Arguments>      
345       filename:
346          The hashtree file to search.  For this algorithm to work, the 
347          keys must be in sorted order, and the sort based on order of 
348          ASCII values.
349       key:
350          The key to look for.
351       fuzzy:
352          (default: False)
353          Find the first item that starts with the given key.  
354          Note: only returns a single match of the key, so this is not
355                equivalent to a wildcard match.
356       offset:
357          (default: False)
358          If True, returns the byte offset to the beginning of the line 
359          where the key was found, rather than returning the data values.
360
361    <Exceptions>
362       TODO
363
364    <Side Effects>
365       None
366
367    <Returns>
368       offset is True:
369          Returns the byte offset of the beginning of the line where the
370          key was found:
371       offset is False:
372          Returns a list of strings containing the data items.   
373    """
374    if offset:
375       data = -1
376    else:   
377       data = []
378
379    # open the file for binary reading
380    try:
381       f = open(filename, "rb")
382    except IOError:
383       return data
384
385    low = 0
386    high = os.stat(filename).st_size - 1
387
388    # traverse tree
389    pos = 0
390    last = -1
391    while True:   
392       # parse line  
393       truepos = f.tell() 
394       line = f.readline().split("\t")
395       if pos == last or not line[0]:
396          break 
397       last = pos   
398     
399       # found key?
400       if key == line[0] or (fuzzy and line[0].startswith(key)):
401          if offset:
402             data = truepos
403          else:
404             # remove trailing newline from last data item
405             line[-1] = line[-1].rstrip("\n")
406       
407             # return only the data 
408             data = line[1:]
409          break
410    
411       # didn't find key, determine which direction to look
412       if key < line[0]:
413          # key is smaller than what we found
414          high = pos
415          pos = (pos + low) / 2
416       else:
417          # key is larger than what we found
418          low = pos
419          pos = (pos + high) / 2
420    
421       # go to the branch
422       f.seek(pos)
423       
424       # we probably ended up in the middle of a line, find the beginning
425       # of the next line
426       f.readline()   
427    
428    # done, close the file and return results
429    f.close()
430    return data
431
432
433
434
435
436 def __append_index(field, metafile, outputfile, tempdir="/tmp"):
437    """
438    <Purpose>
439       Reads a metadata file and adds any new information it provides to
440       the index file.
441
442    <Arguments>      
443       field:
444          The metadata field to build the index for (ex: "provides")
445       metafile:
446          The metadata file to be read.
447       outputfile:
448          The index file to add to.
449       tempdir:
450          Which directory to use for the writing of temporary data files.      
451
452    <Exceptions>
453       TODO
454
455    <Side Effects>
456       None
457
458    <Returns>
459       None.
460    """
461    # read dictionary entry for the field we are looking for  
462    entry = package_metadata_fn_to_dict(metafile)[field]
463    
464    # convert a string to a list of a single string 
465    # for example the "filename" field won't be a list but "provides" will
466    if not isinstance(entry, list):
467       entry = [entry]
468
469    # examine each entry in the list
470    for item in entry:
471       # does the entry already exist?
472       metafilepos = __hashtree_get(outputfile, item, offset=True)
473       
474       if metafilepos < 0:
475          # can just create our new line
476          newline = [metafile]
477       else:
478          # yes.. need to extract the line
479          tempfile = os.path.join(tempdir, str(random.random()) + "__append_index")
480          fin = open(outputfile, "rb")
481          fout = open(tempfile, "w")
482          newline = []
483          while True:
484             pos = fin.tell()
485             line = fin.readline()
486             if line == "":
487                break
488             if pos == metafilepos:
489                # found the line, extract it
490                newline = line.rstrip("\n").split("\t")[1:]
491             else:   
492                # writes all lines of the original file except the one
493                # we're extracting
494                fout.write(line)
495          fout.close()
496          fin.close()
497          
498          # replace original file with our new file 
499          os.remove(outputfile)
500          os.rename(tempfile, outputfile)
501          
502          # add our filename to the entry
503          newline.append(metafile)
504          newline = arizonageneral.uniq(newline)
505          
506       # open the index file
507       f = open(outputfile, "a")
508    
509       # write the new line
510       f.write(item + "\t" + "\t".join(newline) + "\n")
511
512       # close the file
513       f.close()       
514       
515       # sort the file
516       tempfile = os.path.join(tempdir, str(random.random()) + "__append_index")
517       os.system("LC_ALL=C sort " + outputfile + " >" + tempfile)
518       os.remove(outputfile)
519       os.rename(tempfile, outputfile)
520              
521
522
523
524
525 def __build_index(field, path):
526    """
527    <Purpose>
528       Builds an index for the metadata in a particular directory.
529
530    <Arguments>      
531       field:
532          The metadata field to build the index for (ex: "provides")
533       path:
534          The directory where the metadata files are located.
535          Also, the directory where the index file will be written.  
536          It will be named "path/<field>.index"
537
538    <Exceptions>
539       TODO
540       
541    <Side Effects>
542       None
543
544    <Returns>
545       None.
546    """
547    outputfile = os.path.join(path, field + ".index")
548    
549    # remove an existing index 
550    if os.path.isfile(outputfile):
551       os.remove(outputfile)
552
553    # building the index takes a while, so set up a progress indicator
554    if arizonareport.get_verbosity() > 1:
555       width = arizonareport.console_size()[1]
556       if width == None:
557          width = 70
558    else:
559       width = 0
560    import download_indicator
561    prog_indicator_module = download_indicator
562    prog_indicator_module.set_width(width)
563
564    filenames = dircache.listdir(path)
565    n = len(filenames)
566    for i, metahash in enumerate(filenames):
567       metafile = os.path.join(path, metahash)
568       
569       # listdir returns subdirectories too, skip them
570       if not os.path.isfile(metafile):
571          continue
572
573       # add the items in this metahash to the index
574       __append_index(field, metafile, outputfile)
575
576       # update progress indicator
577       prog_indicator_module.download_indicator(i + 1, 1, n)
578
579    arizonareport.send_out(2, "")
580
581
582
583
584 def package_get_default_url(repo_dict, filename, pack):
585    """
586    <Purpose>
587       Generates the default URL of a package in the case that a packages does
588       not already have a _URL entry.
589
590      takes a filename such as:
591         /usr/local/stork/var/packageinfo/quadrus.cs.arizona.edu_PlanetLab_V3_dist/3fa687d23a07c...
592      and changes it to:
593         quadrus.cs.arizona.edu/PlanetLab/V3/dist/3fa687d23a07c...
594    <Arguments>
595       filename - the filename of the package
596       pack - the package dictionary; '_URL' will be added to it
597    """
598
599    # make sure the package knows what repository it belongs to
600    assert('_repo' in pack)
601
602    # build a URL from the packageinfo filename that the repository gave
603    # us.
604
605    # remove repository local dir part
606    URL = filename[len(pack['_repo']['localdir']):]
607
608    # remove any leading or trailing /'s
609    URL = URL.strip("/")
610
611    # change _'s into /'s
612    URL = URL.replace("_", "/")
613
614    # At this point, URL can look like one of two things:
615    #   a) hostname/dir/dir/dir/dir...
616    #   b) /dir/dir/dir...
617    # The former was done by older versions of the stork repository that encoded
618    # the hostname into the metadata tarball name. The latter should be done
619    # by newer versions of the repository. In either case, the correct behavior
620    # is to ignore this hostname, and use the actual hostname of the repository
621    # from the repo_dict.
622
623    baseUrl = repo_dict['repurl']
624    slashPos = URL.find("/")
625    if slashPos >= 0:
626         URL = baseUrl + URL[slashPos:]
627    else:
628         # this probably should never happen
629         URL = baseUrl + "/" + URL
630
631    # concatenate the filename onto the end, so we have a complete URL
632    URL = os.path.join(URL, pack['filename'])
633
634    pack['_URL'] = [URL]
635
636    return pack
637
638
639
640
641
642 def load_package_metadata_fn(filename):
643    """
644    <Purpose>
645       Loads metadata from a .metadata file or a repository packageinfo metadata
646       file.
647
648    <Arguments>
649       filename - the filename of the metadata file
650
651    <Returns>
652       dictionary containing the metadata entries
653    """
654    arizonageneral.check_type_simple(filename, "filename", str, "package_metadata_fn_to_dict")
655
656    if not arizonageneral.valid_fn(filename):
657       raise IOError, (2, "No such file or directory '" + filename + "' in package_metadata_fn_to_dict")
658
659    ret_dict = {}
660    f = file(filename)
661    for line in f:
662       (key, value) = line.split(':', 1)
663       # This is what converts lists, etc. to and from the correct types
664       ret_dict[key] = eval(value)
665    f.close()
666
667    return ret_dict
668
669
670
671
672
673 def package_metadata_fn_to_dict(filename):
674    """
675    <Purpose>
676       This takes a metadata filename for a package and returns a dict
677
678    <Arguments>
679       filename:
680          The metadata file that should be retrieved
681
682    <Exceptions>
683       TypeError is raised when given an invalid argument.
684       IOError may be raised if the package meta files are corrupted
685
686    <Side Effects>
687       None
688
689    <Returns>
690       A package dict for this metadata file.
691    """
692    arizonageneral.check_type_simple(filename, "filename", str, "package_metadata_fn_to_dict")
693
694    ret_dict = load_package_metadata_fn(filename)
695
696    # add the metadata hash as a field
697    ret_dict['_metadatahash'] = os.path.basename(filename)
698
699    # figure out which repository contains the file. We need to know this so
700    # we can strip the repository's local dir out of the filename to make the
701    # url.
702    for repo in storkrepolist.get_repo_list():
703       repo_dir = repo['localdir']
704       if filename.startswith(repo_dir):
705          ret_dict['_repo'] = repo
706          break
707    else:
708       # we couldn't find the repository that this filename belonged to
709       # this should not happen
710       arizonareport.send_error(0, "ERROR: " + str(filename) + " does not belong to any repository")
711       raise TypeError, "Unable to find repository for " + str(filename)
712
713    # if the package already contains a URL field, then assign _URL=URL.
714    # Otherwise, we'll generate _URL.
715    if 'URL' in ret_dict:
716       ret_dict['_URL'] = ret_dict['URL']
717    else:
718       package_get_default_url(ret_dict['_repo'], filename, ret_dict)
719
720    return ret_dict
721
722
723
724 def package_metadata_dict_get_canonical_hash(metadict):
725    """
726    <Purpose>
727       Generate a canonical metadata hash for a package.
728
729       When a user creates a .metadata file that contains his own URL, the
730       metadatahash that is generated includes that URL entry. When stork
731       downloads a package and extracts the metadata, there is no URL field, and
732       therefore the hashes don't match.
733
734       This function takes a metadata and generates the hash that the metadata
735       would have with the URL entry removed, so that we can compare it to
736       downloaded packages.
737
738    <Arguments>
739       metadict - dictionary containing metadata
740    """
741    # let's assume that we're always dealing with metadicts that include a
742    # _metadata hash field (loaded by package_metadata_fn_to_dict)
743    assert('_metadatahash' in metadict)
744
745    # check for the easy case first -- no URL entry in metadict, and the hash is
746    # already computed -- so we can just return what we already have.
747    if not ('URL' in metadict):
748       return metadict['_metadatahash']
749
750    # since we are going to be recomputing the metahash, we need to ensure that
751    # this metadict is valid, and was not forged.
752    if not ravenlib.package.storkpackage.package_metadata_dict_validate(metadict):
753        # the metadict's precomputed metahash is invalid. Report the error to
754        # the user.
755        arizonareport.send_error(1, "invalid metahash detected: " + str(metadict['filename']) + " " + str(metahash) + " " + str(metadict['_metadatahash']))
756
757        # return the (possibly forged) _metadatahash. This is what we would have
758        # used had there been no URL entry to remove.
759        return metadict['_metadatahash']
760
761    # remove the URL field from the metadict. Make a copy first, so we don't
762    # damage the original.
763    metadict = metadict.copy()
764    del metadict['URL']
765
766    # compute a new metahash
767    metahash = ravenlib.package.storkpackage.package_metadata_dict_get_hash(metadict)
768
769    return metahash
770
771
772
773
774
775 """
776 TODO remove references to these functions
777 storkbuild:
778 field_list = ['name', 'version', 'release', 'size', 'hash', 'file', 'requires', 'provides', 'files']
779
780 storkbuild:
781 field_sep = '|'
782
783 storkquery:
784 def cull_database(items, fields):
785    ""'Returns a string that may cull the database of unnecessary entries.
786       Not clever yet... examine before using""'
787    if len(items) != len(fields):
788       arizonareport.send_error(1, "Internal error, fields and items of differing length in cull_database")
789       sys.exit(1)
790
791    data = []
792    for num in range(len(field_list)):
793       data.append('.*')
794    for num in range(len(items)):
795       data[get_field(fields[num])] = items[num]
796
797    ans = 'grep "^' + data[0]
798                                                                                 
799    for dataitem in data[1:]:
800       ans += (field_sep + dataitem)
801                                                                                 
802    ans += '$"'
803    return ans
804
805 storkquery:
806 def get_db_field(string,field):
807    ""'Returns a named field from a string
808       ""'
809    the_list = string.split(field_sep)
810    return the_list[get_field(field)]
811
812 storkquery: 
813 def package_cache_name(pkg_db):
814    ""'
815    <Purpose>
816       Returns the local cache file name associated with a package 
817       repository.
818    ""'
819    pos = pkg_db.find("://")
820    if pos == -1:
821       return arizonaconfig.get_option("dbdir") + "/" + pkg_db.replace("|", "_").replace("/", "_")
822    else:
823       return arizonaconfig.get_option("dbdir") + "/" + pkg_db[pos + 3:].replace("|", "_").replace("/", "_")
824 """
825