Add package invirt-images for managing CDROM images
[invirt/packages/invirt-images.git] / invirt-images
1 #!/usr/bin/python
2
3 from invirt import database
4 import os
5 import subprocess
6 import random
7 import string
8 import tempfile
9 import urllib
10 import math
11 import optparse as op
12
13 class InvirtImageException(Exception):
14     pass
15
16 # verbosity = 0 means no output from the actual commands
17 # verbosity = 1 means only errors from the actual commands
18 # verbosity = 2 means all output from the actual commands
19 verbosity = 0
20
21 def getOutput():
22     global verbosity
23     return {
24         'stdout': subprocess.PIPE if verbosity < 2 else None,
25         'stderr': subprocess.PIPE if verbosity < 1 else None
26         }
27
28 def lvcreate(name, size):
29     lvc = subprocess.Popen(['lvcreate', '-L', size, '-n', name, 'xenvg'],
30                            stderr=subprocess.PIPE,
31                            stdout=getOutput()['stdout'])
32     if not lvc.wait():
33         return 0
34     stderr = lvc.stderr.read()
35     if 'already exists in volume group' in stderr:
36         return 5
37     else:
38         if verbosity > 0:
39             print stderr
40         return 6
41
42 def lvrename(dest, src):
43     lvr = subprocess.Popen(['lvrename', 'xenvg', src, dest],
44                            stderr=subprocess.PIPE,
45                            stdout=getOutput()['stdout'])
46     ret = lvr.wait()
47     if not ret:
48         return 0
49     stderr = lvr.stderr.read()
50     if 'not found in volume group' in stderr:
51         return 0
52     else:
53         if verbosity > 0:
54             print stderr
55         return ret
56
57 def lv_random(func, pattern, *args):
58     """
59     Run some LVM-related command, optionally with a random string in
60     the LV name.
61     
62     func takes an LV name plus whatever's in *args and returns the
63     return code of some LVM command, such as lvcreate or lvrename
64     
65     pattern can contain at most one '%s' pattern, which will be
66     replaced by a 6-character random string.
67     
68     If pattern contains a '%s', the script will attempt to re-run
69     itself if the error code indicates that the destination already
70     exists
71     """
72     # Keep trying until it works
73     while True:
74         rand_string = ''.join(random.choice(string.ascii_letters) \
75                                   for i in xrange(6))
76         if '%s' in pattern:
77             name = pattern % rand_string
78         else:
79             name = pattern
80         ret = func(name, *args)
81         if ret == 0:
82             return name
83         # 5 is the return code if the destination already exists
84         elif '%s' not in pattern or ret != 5:
85             raise InvirtImageException, 'E: Error running %s with args %s' % (func.__name__, args)
86
87 def lvcreate_random(pattern, size):
88     """
89     Creates an LV, optionally with a random string in the name.
90     
91     Call with a string formatting pattern with a single '%s' to use as
92     a pattern for the name of the new LV.
93     """
94     return lv_random(lvcreate, pattern, size)
95
96 def lvrename_random(src, pattern):
97     """
98     Rename an LV to a new name with a random string incorporated.
99     
100     Call with a string formatting pattern with a single '%s' to use as
101     a pattern for the name of the new LV
102     """
103     return lv_random(lvrename, pattern, src)
104
105 def fetch_image(cdrom):
106     """
107     Download a cdrom from a URI, shelling out to rsync if appropriate
108     and otherwise trying to use urllib
109     """
110     full_uri = os.path.join(cdrom.mirror.uri_prefix, cdrom.uri_suffix)
111     temp_file = tempfile.mkstemp()[1]
112     try:
113         if full_uri.startswith('rsync://'):
114             if subprocess.call(['rsync', '--no-motd', '-tLP', full_uri, temp_file],
115                                **getOutput()):
116                 raise InvirtImageException, "E: Unable to download '%s'" % full_uri
117         else:
118             # I'm not going to look for errors here, because I bet it'll
119             # throw its own exceptions
120             urllib.urlretrieve(full_uri, temp_file)
121         return temp_file
122     except:
123         os.unlink(temp_file)
124         raise
125
126 def copy_file(src, dest):
127     """
128     Copy a file from one location to another using dd
129     """
130     if subprocess.call(['dd', 'if=%s' % src, 'of=%s' % dest, 'bs=1M'],
131                        **getOutput()):
132         raise InvirtImageException, 'E: Unable to transfer %s into %s' % (src, dest)
133
134 def load_image(cdrom):
135     """
136     Update a cdrom image by downloading the latest version,
137     transferring it into an LV, moving the old LV out of the way and
138     the new LV into place
139     """
140     if cdrom.mirror_id is None:
141         return
142     temp_file = fetch_image(cdrom)
143     try:
144         cdrom_size = '%sM' % math.ceil((float(os.stat(temp_file).st_size) / (1024 * 1024)))
145         new_lv = lvcreate_random('image-new_%s_%%s' % cdrom.cdrom_id, cdrom_size)
146         copy_file(temp_file, '/dev/xenvg/%s' % new_lv)
147         lvrename_random('image_%s' % cdrom.cdrom_id, 'image-old_%s_%%s' % cdrom.cdrom_id)
148         lvrename_random(new_lv, 'image_%s' % cdrom.cdrom_id)
149         reap_images()
150     finally:
151         os.unlink(temp_file)
152
153 def reap_images():
154     """
155     Remove stale cdrom images that are no longer in use
156     
157     load_image doesn't attempt to remove the old image because it
158     might still be in use. reap_images attempts to delete any LVs
159     starting with 'image-old_', but ignores errors, in case they're
160     still being used.
161     """
162     lvm_list = subprocess.Popen(['lvs', '-o', 'lv_name', '--noheadings'],
163                                stdout=subprocess.PIPE,
164                                stdin=subprocess.PIPE)
165     lvm_list.wait()
166     
167     for lv in map(str.strip, lvm_list.stdout.read().splitlines()):
168         if lv.startswith('image-old_'):
169             subprocess.call(['lvchange', '-a', 'n', '/dev/xenvg/%s' % lv],
170                             **getOutput())
171             subprocess.call(['lvchange', '-a', 'n', '/dev/xenvg/%s' % lv],
172                             **getOutput())
173             subprocess.call(['lvchange', '-a', 'ey', '/dev/xenvg/%s' % lv],
174                             **getOutput())
175             subprocess.call(['lvremove', '--force', '/dev/xenvg/%s' % lv],
176                             **getOutput())
177
178 def main():
179     global verbosity
180     
181     database.connect()
182     
183     usage = """%prog [options] --add [--cdrom] cdrom_id description mirror_id uri_suffix
184        %prog [options] --add --mirror mirror_id uri_prefix
185
186        %prog [options] --update [short_name1 [short_name2 ...]]
187        %prog [options] --reap"""
188     
189     parser = op.OptionParser(usage=usage)
190     parser.set_defaults(verbosity=0,
191                         item='cdrom')
192     
193     parser.add_option('-a', '--add', action='store_const',
194                       dest='action', const='add',
195                       help='Add a new item to the database')
196     
197     parser.add_option('-u', '--update', action='store_const',
198                       dest='action', const='update',
199                       help='Update all cdrom images in the database with the latest version')
200     parser.add_option('-r', '--reap', action='store_const',
201                       dest='action', const='reap',
202                       help='Reap stale cdrom images that are no longer in use')
203     
204     a_group = op.OptionGroup(parser, 'Adding new items')
205     a_group.add_option('-c', '--cdrom', action='store_const',
206                        dest='item', const='cdrom',
207                        help='Add a new cdrom to the database')
208     a_group.add_option('-m', '--mirror', action='store_const',
209                        dest='item', const='mirror',
210                        help='Add a new mirror to the database')
211     parser.add_option_group(a_group)
212     
213     v_group = op.OptionGroup(parser, "Verbosity levels")
214     v_group.add_option("-q", "--quiet", action='store_const',
215                        dest='verbosity', const=0,
216                        help='Show no output from commands this script runs (default)')
217     v_group.add_option("-v", "--verbose", action='store_const',
218                        dest='verbosity', const=1,
219                        help='Show only errors from commands this script runs')
220     v_group.add_option("--noisy", action='store_const',
221                        dest='verbosity', const=2,
222                        help='Show all output from commands this script runs')
223     parser.add_option_group(v_group)
224     
225     (options, args) = parser.parse_args()
226     verbosity = options.verbosity
227     if options.action is None:
228         print parser.format_help()
229     elif options.action == 'add':
230         if options.item == 'cdrom':
231             attrs = dict(zip(('cdrom_id', 'description', 'mirror_id', 'uri_suffix'),
232                              args))
233             cdrom = database.CDROM(**attrs)
234             database.session.save(cdrom)
235             database.session.flush()
236             
237             load_image(cdrom)
238         
239         elif options.item == 'mirror':
240             attrs = dict(zip(('mirror_id', 'uri_prefix'),
241                              args))
242             mirror = database.Mirror(**attrs)
243             database.session.save(mirror)
244             database.session.flush()
245     elif options.action == 'update':
246         if len(args) > 0:
247             images = [database.CDROM.query().get(arg) for arg in args]
248         else:
249             images = database.CDROM.query().all()
250         for cdrom in images:
251             if cdrom is not None:
252                 load_image(cdrom)
253     elif options.action == 'reap':
254         reap_images()
255
256 if __name__ == '__main__':
257     main()