import repository from arizona
[raven.git] / apps / mprepo / repobackend.py
1 import glob
2 import os
3 import sha
4 import shutil
5 import tempfile
6 import time
7 import arizonacrypt
8 import arizonageneral
9 import arizonareport
10 import ravenlib.package.storkpackage
11
12 import repoconfig
13 import repometafileupdate
14 import repopublish
15 import repoclassify
16
17 class RepoError(Exception):
18    def __init__(self, msg=None, file=None):
19       self.msg = msg
20       self.file = file
21       self.args = msg
22
23 class RepoValidateError(RepoError): pass
24
25 class RepoNoSliceNameError(RepoError): pass
26
27 def get_file_hash(fn):
28     """ get the SHA-1 hash of a file
29     """
30
31     s = sha.new()
32     f = open(fn,"rb")
33     buf = f.read(64*1024)
34     while buf:
35         s.update(buf)
36         buf = f.read(64*1024)
37
38     return s.hexdigest()
39
40 # TODO: some of this should be arizonaconfig variables
41
42 """
43     File information is stored in a dictionary. This dictionary is updated as
44     files progress through various repository operations. The dictionary
45     contains the following information:
46        type:
47           type of the file: package|trustedpackage|...
48        desiredname:
49           the name that the user would like this file to be called
50        slice:
51           the slice this file applies to. Only necessary for legacy files
52           (unsigned conf and pubkeys)
53        srcname:
54           the source name of the file, where it was deposited by the
55           front end
56        destname:
57           the destination name of the file, where it should be eventually
58           copied to
59        name:
60           the current name of the file, equal to either srcname or destname,
61           depending on whether the file has been moved to its final destination
62        folder:
63           the folder where the file should be placed, for example,
64           "PlanetLab/V3/Testing"
65        destdir:
66           the destination directory of the file
67        legacy:
68           set to True if file is a legacy file that should be named after a
69           slice
70        metafile: (packages only)
71           filename of a file containing the metadata
72        metahash: (packages only)
73           the sha-1 hash of the contents of the metafile
74        metadatafolder: (packages only)
75           the folder where the metadata should be stored. For example,
76           "stork_repository_cs_arizona_edu_packages_planetlab_v3_testing"
77        metdatadestdir: (packages only)
78           the destination directory where the metadata should be stored.
79 """
80
81
82 def save_file(file):
83    """
84    <Purpose>
85       Saves a file
86
87    <Arguments>
88       file:
89          a file dictionary specifying information about the file
90
91    <Side Effects>
92       The file is copied from 'srcname' to the save directory.
93
94    <Returns>
95       None.
96    """
97    srcname = file['srcname']
98    desiredname = file['desiredname']
99
100    hash = get_file_hash(srcname)
101    now = time.time()
102
103    arizonageneral.makedirs_existok(repoconfig.save_dir)
104
105    dest = os.path.join(repoconfig.save_dir, desiredname + "." + hash + ".%d") % (now)
106
107    arizonareport.send_out(1, "save_file: copying " + srcname + " to " + dest)
108
109    shutil.copy(srcname, dest)
110
111
112
113
114
115 def validate(file):
116    """
117    <Purpose>
118       Verifies that a file is of the correct type
119
120    <Arguments>
121       file:
122          a file dictionary specifying information about the file
123
124    <Exceptions>
125       RepoValidateError if the file is of the wrong type
126
127    <Returns>
128       None.
129    """
130    assert("type" in file)
131    assert("name" in file)
132
133    arizonareport.send_out(1, "validate: validating " + file['name'])
134
135    if file['type'] == "package":
136       # XXX: should check and see if it is an rpm/tarball
137       return
138
139    fileTypeUncheckable = False
140    fileType = repoclassify.signedFileType(file['name'])
141
142    if file['type'] == "pubkey":
143       if fileType == "unsigned":
144          file['legacy'] = True
145          # XXX should we make sure it is a public key
146          return
147
148    if file['type'] == "conf":
149       # we have two types of conf files to check: unsigned and signed
150       if fileType == "unsigned":
151          # it must be an unsigned config file
152          file['legacy'] = True
153          return
154       elif fileType == "other":
155          # there is no easy way for us to distinguish a conf file from an
156          # arbitrary file, so we'll assume if the file is "other" than it
157          # is a conf file.
158          fileTypeUncheckable = True
159
160    if file['type'] == "pacman":
161       if fileType == "other":
162          # active pacman files cannot be distinguished by looking at them,
163          # since they may be any arbitray script
164          fileTypeUncheckable = True
165
166    if not fileTypeUncheckable:
167       if fileType != file['type']:
168          raise RepoValidateError(msg="signed file type didn't match " + fileType + " != " + file['type'], file = file)
169
170    arizonareport.send_out(1, "validate: verifying signature of " + file['name'])
171
172    try:
173       val = arizonacrypt.XML_validate_file(file['name'], real_fn = file['desiredname'])
174    except TypeError, e:
175       raise RepoValidateError(msg="signed file didn't validate: " + str(e), file = file)
176
177    if val['expired']:
178       raise RepoValidateError(msg="signed file is expired", file=file)
179
180    file['key'] = val['key']
181    file['timestamp'] = val['timestamp']
182
183
184 def load_metadata(filename):
185    """
186    <Purpose>
187       Loads metadata from a .metadata file or a repository packageinfo metadata
188       file.
189
190       XXX This is copied from storkpackagelist:load_package_metadata_fn. It
191       didn't seem wise to create a dependency between the repository and
192       storkpackagelist.
193
194    <Arguments>
195       filename - the filename of the metadata file
196
197    <Returns>
198       dictionary containing the metadata entries
199    """
200    ret_dict = {}
201    f = file(filename)
202    for line in f:
203       (key, value) = line.split(':', 1)
204       # This is what converts lists, etc. to and from the correct types
205       ret_dict[key] = eval(value)
206    f.close()
207
208    return ret_dict
209
210
211
212
213
214 def extract_metadata(file):
215    """
216    <Purpose>
217       Extracts metadata from a package
218
219    <Arguments>
220       file:
221          a file dictionary specifying information about the file
222
223    <Side Effects>
224       file['metadata'] and file['metahash'] are updated
225       a disk file is created on disk (probably in /tmp) that contains the metadata
226       file['metafile'] is set to the name of the temp file
227
228    <Returns>
229       None.
230    """
231    assert("name" in file)
232    assert(file['type'] == "package" or file['type'] == "tarball")
233
234    arizonareport.send_out(1, "extract_metadata: extracting metadata from " + file['name'])
235
236    if file['desiredname'].endswith(".metadata"):
237        # if the user has uploaded a metadata file, then generate the metadata
238        # dictionary by reading the contents of the file.
239
240        metadata = load_metadata(file['name'])
241    else:
242        # smbaker: the storktar package manager makes extensive use of the filename
243        # when creating packageinfo and extracting provides from the tarball. Rather
244        # than clutter up the interface to the package modules, lets just create a
245        # symlink and trick storktar into thinking the filename is what the user
246        # wants it to be.
247
248        tempdir = tempfile.mkdtemp(prefix="extract_metadata")
249        tempname = os.path.join(tempdir, os.path.basename(file['desiredname']))
250        os.symlink(file['name'], tempname)
251
252        try:
253            metadata = ravenlib.package.storkpackage.get_package_metadata(tempname,
254                                                         original_filename = file['desiredname'])
255        finally:
256            # remove the symlink and temp dir that we created
257            os.remove(tempname)
258            os.rmdir(tempdir)
259
260    metahash, metafile = ravenlib.package.storkpackage.package_metadata_dict_get_hash(metadata, True)
261
262    file['metadata'] = metadata
263    file['metahash'] = metahash
264    file['metafile'] = metafile
265
266
267
268
269 def determine_destination(file):
270    """
271    <Purpose>
272       Determines where a file should be stored
273
274    <Arguments>
275       file:
276          a file dictionary specifying information about the file
277
278    <Side Effects>
279       file['folder'] and file['destdir'] are updated
280       If the file is a package, then metadata may be stored on disk in /tmp
281
282    <Exceptions>
283       RepoError thrown if file type is not understood
284
285    <Returns>
286       None.
287    """
288    assert("type" in file)
289
290    ftype = file['type']
291
292    arizonareport.send_out(1, "determine_destination: " + file['name'])
293
294    if ftype == "package":
295       extract_metadata(file)
296       file['folder'] = "PlanetLab/V3/Testing"
297       file['destdir'] = os.path.join(repoconfig.package_dir, os.path.join(file['folder'], file['metahash']))
298       file['metadatafolder'] = os.path.join(os.path.join(repoconfig.hostname, "packages"), file['folder']).replace("/","_")
299       file['metadatadestdir'] = os.path.join(repoconfig.metadata_dir, file['metadatafolder'])
300    elif ftype == "trustedpackage":
301       file['folder'] = "tpfiles"
302       file['destdir'] = os.path.join(repoconfig.user_dir, file['folder'])
303    elif ftype == "pacman":
304       file['folder'] = "pacman"
305       file['destdir'] = os.path.join(repoconfig.user_dir, file['folder'])
306    elif ftype == "pubkey":
307       file['folder'] = "pubkeys"
308       file['destdir'] = os.path.join(repoconfig.user_dir, file['folder'])
309    elif ftype == "conf":
310       file['folder'] = "conf"
311       file['destdir'] = os.path.join(repoconfig.user_dir, file['folder'])
312    else:
313       raise RepoError(msg = "Unable to determine dest dir", file = file)
314
315    # legacy files need to be renamed after the slice they were uploaded for
316    if file.get("legacy"):
317       if not ('slice' in file):
318          raise RepoNoSliceNameError(msg = "No slice name specified for legacy file", file = file)
319
320       if file['type'] == "conf":
321          file['desiredname'] = file['slice'] + '.stork.conf'
322       elif file['type'] == "pubkey":
323          file['desiredname'] = file['slice'] + '.publickey'
324       else:
325          raise RepoError(msg = "Bad type for legacy file", file = file)
326
327       arizonareport.send_out(1, "determine_destination: desiredname set to " + file['desiredname'])
328
329    arizonareport.send_out(1, "determine_destination: destdir = " + file.get('destdir', 'None'))
330
331
332
333
334 def validate_newer(file):
335    """
336    <Purpose>
337       Verifies that a file is newer than an existing copy
338
339    <Arguments>
340       file:
341          a file dictionary specifying information about the file
342
343    <Side Effects>
344       None.
345
346    <Exceptions>
347       RepoError thrown if file is older than existing copy
348
349    <Returns>
350       None.
351    """
352    # TODO: make sure that file['srcname'] has a newer timestamp than
353    #       file['destdir']+os.path.name(file['name'])
354    return
355
356
357
358
359 def store_metadata(file):
360    """
361    <Purpose>
362       Store metadata for a package
363
364    <Arguments>
365       file:
366          a file dictionary specifying information about the file
367
368    <Side Effects>
369       metadata is moved from file['metafile'] to permanent location
370       file['metafile'] is updated with new location of metadata file
371
372    <Exceptions>
373       None.
374
375    <Returns>
376       None.
377    """
378    assert("metafile" in file)
379    assert("metahash" in file)
380    assert("metadatadestdir" in file)
381    assert(file['type'] == "package")
382
383    destdir = file['metadatadestdir']
384    destname = os.path.join(destdir, file['metahash'])
385
386    arizonareport.send_out(1, "store_metadata: copy from " + file['metafile'] + " to " + destname)
387
388    arizonageneral.makedirs_existok(destdir)
389    shutil.copy(file['metafile'], destname)
390
391    file['metafile'] = destname
392
393
394
395
396
397 def store(file):
398    """
399    <Purpose>
400       Store a file in it's final location
401
402    <Arguments>
403       file:
404          a file dictionary specifying information about the file
405
406    <Side Effects>
407       file is moved from file['srcname'] to it's final location
408       file['name'] is updated
409
410    <Exceptions>
411       None.
412
413    <Returns>
414       None.
415    """
416    assert("name" in file)
417    assert("type" in file)
418    assert("destdir" in file)
419
420    srcname = file['name']
421    destname = os.path.join(file['destdir'], file['desiredname'])
422
423    arizonareport.send_out(1, "store: copy from " + srcname + " to " + destname)
424
425    arizonageneral.makedirs_existok(file['destdir'])
426    shutil.copy(srcname, destname)
427
428    os.chmod(destname, 0644)
429
430    file['name'] = destname
431
432
433
434
435 def determine_tarball(file):
436    """
437    <Purpose>
438       Determines the tarball that should be built for the file.
439       If file is a package, then tarball is built for the metadata.
440
441    <Arguments>
442       file:
443          a file dictionary specifying information about the file
444
445    <Side Effects>
446       None.
447
448    <Exceptions>
449       None.
450
451    <Returns>
452       A dictionary describing the tarball that should be created.
453    """
454
455    arizonareport.send_out(1, "determine_tarball")
456
457    if file['type'] == "package":
458       tarball_src = file['metadatadestdir']
459       tarball_name = file['metadatafolder'] + ".tar.bz2"
460    elif file['type'] == "pubkey":
461       # we do not create tarballs for public keys
462       return None
463    else:
464       tarball_src = file['destdir']
465       tarball_name = file['folder'] + ".tar.bz2"
466
467    tarball_dict = {"name": os.path.join(repoconfig.tarball_dir, tarball_name),
468                    "desiredname": tarball_name,
469                    "type": "tarball",
470                    "tarball_src": tarball_src,
471                    "put_hash_in_name": True}
472
473    return tarball_dict
474
475 def determine_tarball_metafiles(tarball):
476    # every tarball goes into the main metafile
477    tarball_metafiles = ["metafile"]
478
479    # config files also go into confmetafile
480    if (tarball["desiredname"] == "conf.tar.bz2"):
481        tarball_metafiles.append("confmetafile")
482
483    tarball["metafiles"] = tarball_metafiles
484
485
486 def build_tarball(tarball):
487    """
488    <Purpose>
489       Builds a tarball
490
491    <Arguments>
492       tarball:
493          a file dictionary specifying information about the tarball
494
495    <Side Effects>
496       Tarball is created
497
498    <Exceptions>
499       None.
500
501    <Returns>
502       None.
503    """
504    arizonageneral.makedirs_existok(repoconfig.tarball_dir)
505
506    put_hash_in_name = tarball.get("put_hash_in_name", False)
507    tarball_name = os.path.basename(tarball['name'])
508    tarball_dir = os.path.dirname(tarball['name'])
509    tarball_src = tarball['tarball_src']
510
511    assert(tarball_dir)
512    assert(tarball_src)
513
514    tarball_src_parent = os.path.dirname(tarball_src)
515    tarball_src_dir = os.path.basename(tarball_src)
516
517    assert(tarball_src_parent)
518    assert(tarball_src_dir)
519
520    # XXX: unsafe; fix me
521    tmpfile = tempfile.mktemp()
522
523    arizonareport.send_out(1, "build_tarball: creating tarball " + tarball_name + " from " + tarball_src + " in " + tarball_dir)
524
525    tar_cmd  = "/bin/tar -C " + tarball_src_parent + " -jcf " + tmpfile + " " + tarball_src_dir + "/"
526
527    arizonareport.send_out(1, "build_tarball: executing " + tar_cmd)
528
529    os.system(tar_cmd)
530
531    # remove old tarballs
532    for fn in glob.glob(tarball['name'].replace(".tar.bz2","*.tar.bz2")):
533        os.remove( fn )
534
535    symlink_name = tarball['name'].replace(".tar.bz2","-latest" + ".tar.bz2")
536
537    if put_hash_in_name:
538        hash = ravenlib.package.storkpackage.get_package_metadata_hash(tmpfile)
539        tarball['name'] = tarball['name'].replace(".tar.bz2","-" + hash + ".tar.bz2")
540        if "desiredname" in tarball:
541            tarball['desiredname'] = tarball['desiredname'].replace(".tar.bz2","-" + hash + ".tar.bz2")
542
543    # rename the temp file to the destination filename
544    shutil.move(tmpfile, tarball['name'])
545
546    # create a tarball-latest.tar.bz2 symlink
547    if os.path.exists(symlink_name):
548        os.remove(symlink_name)
549    os.symlink(tarball['name'], symlink_name)
550
551    # for coblitz, make the file look like it is 15 minutes old
552    time_yesterday = time.time() - 15*60      # 24*60*60
553    os.utime(tarball['name'], (time_yesterday, time_yesterday))
554
555
556 def process_tarballs(tarballs, discard_existing_metafile=False):
557    arizonareport.send_out(1, "process_tarballs: building tarballs")
558    for tarball in tarballs:
559       build_tarball(tarball)
560       determine_tarball_metafiles(tarball)
561
562    # build a list of metafiles that will be published
563    metafiles = []
564    for tarball in tarballs:
565       for metafile in tarball["metafiles"]:
566           if not (metafile in metafiles):
567               metafiles.append(metafile)
568
569    # for each metafile, update it
570    for metafile in metafiles:
571        # build a list of tarball names for this particular metafile
572        tarball_names = [os.path.basename(tarball['name']) for tarball in tarballs if (metafile in tarball["metafiles"])]
573
574        arizonareport.send_out(1, "process_tarballs: updating metafile: " + metafile)
575        metafilename = repometafileupdate.metafileUpdate(tarball_names,
576                                                         repoconfig.tarball_dir,
577                                                         metafile,
578                                                         repoconfig.privatekey_fn,
579                                                         discard_existing=discard_existing_metafile)
580
581    arizonareport.send_out(1, "process_tarballs: publishing tarballs")
582    for tarball in tarballs:
583       repopublish.publish(tarball)
584
585    arizonareport.send_out(1, "process_tarballs: signalling update event")
586    event_filename = os.path.join(repoconfig.repo_var_dir, "event_signal")
587
588    # create a file to signal the event; the event daemon will pick it up
589    arizonareport.send_out(1, "created file " + event_filename)
590    event_file = open(event_filename, "w")
591    event_file.close()
592
593
594
595 def grab_repo_lock():
596    complained = False
597
598    while True:
599       lock = arizonageneral.mutex_lock("repo", repoconfig.repo_var_dir)
600       if lock:
601           break
602
603       if not complained:
604          complained = True
605          arizonareport.send_out(1, "grab_repo_lock: Another copy of the repository is using the lock.")
606          arizonareport.send_out(1, "grab_repo_lock: Checking for free lock in 15-second intervals")
607
608       time.sleep(15)
609
610    return lock
611
612
613
614
615 def process_uploads(files):
616    """
617    <Purpose>
618       Process a newly uploaded file
619
620    <Arguments>
621       files:
622          a list of file dictionaries specifying information about the files.
623          The following fields must be filled in:
624             file['srcname']: current location of the file
625             file['type']: type file file: package|trustedpackage
626          optional:
627             file['desiredname']: specifies what the user would like the file to
628                                  be called
629
630    <Side Effects>
631       File is moved to it's permanent location
632       A backup copy is made in the save directory
633       If file is a package, then metadata is created and moved
634
635    <Exceptions>
636       RepoError
637
638    <Returns>
639       None.
640    """
641    # a dictionary of the tarballs to be built. this way, we can build the
642    # tarballs once, for a whole list of files.
643    tarballs = {}
644
645    # mutex used to ensure only one person messing with the repo at a time
646    lock = None
647
648    try:
649       # grab a mutex to ensure only one person using the repository at a time
650       # TODO: we might be able to push this down a bit futher, to maximise
651       #   overlapping operations
652       lock = grab_repo_lock()
653
654       for file in files:
655          assert("srcname" in file)
656
657          if not "type" in file:
658             file['type'] = repoclassify.getFiletype(file['srcname'])
659
660          arizonareport.send_out(1, "process_upload: processing " + file['srcname'] +
661                                    " of type " + file['type'])
662
663          file['name'] = file['srcname']
664
665          # if user didn't specify what he wants the file called, then assume that
666          # he wants the same name as the srcname
667          if not 'desiredname' in file:
668              file['desiredname'] = os.path.basename(file['name'])
669
670          # save the file for later (why?)
671          save_file(file)
672
673          # make sure that the file is valid
674          validate(file)
675
676          # figure out where it should go
677          determine_destination(file)
678
679          # if it is a timestamped file, then make sure that it is newer than the
680          # existing file
681          validate_newer(file)
682
683          # store the file in its destination
684          store(file)
685
686          # if this is a package, then we also need to store the metadata and
687          # publish it to bittorrent
688          if file['type'] == "package":
689             store_metadata(file)
690             repopublish.publish(file)
691
692          # figure out which tarball to build
693          tarball = determine_tarball(file)
694
695          # add the tarball to the list of tarballs we will have to build
696          if tarball:
697             if not (tarball['name'] in tarballs):
698                tarballs[tarball['name']] = tarball
699
700       # build a list of tarballs to update, and process them
701       tarball_list = [tarballs[key] for key in tarballs]
702       if tarball_list:
703          process_tarballs(tarball_list)
704
705       arizonareport.send_out(1, "process_upload: completed successfully")
706
707    finally:
708       if lock:
709           arizonageneral.mutex_unlock(lock)
710
711       # TODO: cleanup any temporary files (metadata, etc)
712       arizonareport.send_out(1, "process_upload: returning")
713
714
715 def rebuild_metadata_tarballs():
716    tarballs = {}
717
718    lock = None
719
720    try:
721       lock = grab_repo_lock()
722
723       tarballs = {}
724
725       print "adding user-upload tarballs"
726       for ul_name in ("tpfiles", "pacman", "conf"):
727           tarball_name = ul_name + ".tar.bz2"
728           tarball_src = os.path.join(repoconfig.user_dir, ul_name)
729           tarball_dict = {"name": os.path.join(repoconfig.tarball_dir, tarball_name),
730                           "desiredname": tarball_name,
731                           "type": "tarball",
732                           "tarball_src": tarball_src,
733                           "put_hash_in_name": True}
734           print "adding: ", str(tarball_dict)
735           tarballs[tarball_name] = tarball_dict
736
737       print "adding package metadata tarballs"
738       for metadata_name in os.listdir(repoconfig.metadata_dir):
739           if ("~" in metadata_name) or metadata_name.startswith("."):
740               # reject backup files and things that start with "."
741               continue
742
743           tarball_name = metadata_name + ".tar.bz2"
744           tarball_src = os.path.join(repoconfig.metadata_dir, metadata_name)
745           tarball_dict = {"name": os.path.join(repoconfig.tarball_dir, tarball_name),
746                           "desiredname": tarball_name,
747                           "type": "tarball",
748                           "tarball_src": tarball_src,
749                           "put_hash_in_name": True}
750           print "adding: ", str(tarball_dict)
751           tarballs[tarball_name] = tarball_dict
752
753       # build a list of tarballs to update, and process them
754       tarball_list = [tarballs[key] for key in tarballs]
755       if tarball_list:
756          process_tarballs(tarball_list, discard_existing_metafile=True)
757
758    finally:
759       if lock:
760           arizonageneral.mutex_unlock(lock)