Cleaner HVM/paravm validation
[invirt/packages/invirt-web.git] / code / main.py
1 #!/usr/bin/python
2 """Main CGI script for web interface"""
3
4 import base64
5 import cPickle
6 import cgi
7 import datetime
8 import hmac
9 import os
10 import sha
11 import simplejson
12 import sys
13 import time
14 from StringIO import StringIO
15
16 def revertStandardError():
17     """Move stderr to stdout, and return the contents of the old stderr."""
18     errio = sys.stderr
19     if not isinstance(errio, StringIO):
20         return None
21     sys.stderr = sys.stdout
22     errio.seek(0)
23     return errio.read()
24
25 def printError():
26     """Revert stderr to stdout, and print the contents of stderr"""
27     if isinstance(sys.stderr, StringIO):
28         print revertStandardError()
29
30 if __name__ == '__main__':
31     import atexit
32     atexit.register(printError)
33     sys.stderr = StringIO()
34
35 sys.path.append('/home/ecprice/.local/lib/python2.5/site-packages')
36
37 import templates
38 from Cheetah.Template import Template
39 from sipb_xen_database import Machine, CDROM, ctx, connect, MachineAccess
40 import validation
41 from webcommon import InvalidInput, CodeError, g
42 import controls
43
44 class Checkpoint:
45     def __init__(self):
46         self.start_time = time.time()
47         self.checkpoints = []
48
49     def checkpoint(self, s):
50         self.checkpoints.append((s, time.time()))
51
52     def __str__(self):
53         return ('Timing info:\n%s\n' %
54                 '\n'.join(['%s: %s' % (d, t - self.start_time) for
55                            (d, t) in self.checkpoints]))
56
57 checkpoint = Checkpoint()
58
59
60 def helppopup(subj):
61     """Return HTML code for a (?) link to a specified help topic"""
62     return ('<span class="helplink"><a href="help?subject=' + subj + 
63             '&amp;simple=true" target="_blank" ' + 
64             'onclick="return helppopup(\'' + subj + '\')">(?)</a></span>')
65
66 def makeErrorPre(old, addition):
67     if addition is None:
68         return
69     if old:
70         return old[:-6]  + '\n----\n' + str(addition) + '</pre>'
71     else:
72         return '<p>STDERR:</p><pre>' + str(addition) + '</pre>'
73
74 Template.helppopup = staticmethod(helppopup)
75 Template.err = None
76
77 class JsonDict:
78     """Class to store a dictionary that will be converted to JSON"""
79     def __init__(self, **kws):
80         self.data = kws
81         if 'err' in kws:
82             err = kws['err']
83             del kws['err']
84             self.addError(err)
85
86     def __str__(self):
87         return simplejson.dumps(self.data)
88
89     def addError(self, text):
90         """Add stderr text to be displayed on the website."""
91         self.data['err'] = \
92             makeErrorPre(self.data.get('err'), text)
93
94 class Defaults:
95     """Class to store default values for fields."""
96     memory = 256
97     disk = 4.0
98     cdrom = ''
99     name = ''
100     vmtype = 'hvm'
101     def __init__(self, max_memory=None, max_disk=None, **kws):
102         if max_memory is not None:
103             self.memory = min(self.memory, max_memory)
104         if max_disk is not None:
105             self.max_disk = min(self.disk, max_disk)
106         for key in kws:
107             setattr(self, key, kws[key])
108
109
110
111 DEFAULT_HEADERS = {'Content-Type': 'text/html'}
112
113 def error(op, user, fields, err, emsg):
114     """Print an error page when a CodeError occurs"""
115     d = dict(op=op, user=user, errorMessage=str(err),
116              stderr=emsg)
117     return templates.error(searchList=[d])
118
119 def invalidInput(op, user, fields, err, emsg):
120     """Print an error page when an InvalidInput exception occurs"""
121     d = dict(op=op, user=user, err_field=err.err_field,
122              err_value=str(err.err_value), stderr=emsg,
123              errorMessage=str(err))
124     return templates.invalid(searchList=[d])
125
126 def hasVnc(status):
127     """Does the machine with a given status list support VNC?"""
128     if status is None:
129         return False
130     for l in status:
131         if l[0] == 'device' and l[1][0] == 'vfb':
132             d = dict(l[1][1:])
133             return 'location' in d
134     return False
135
136 def parseCreate(user, fields):
137     name = fields.getfirst('name')
138     if not validation.validMachineName(name):
139         raise InvalidInput('name', name, 'You must provide a machine name.  Max 22 chars, alnum plus \'-\' and \'_\'.')
140     name = name.lower()
141
142     if Machine.get_by(name=name):
143         raise InvalidInput('name', name,
144                            "Name already exists.")
145     
146     owner = validation.testOwner(user, fields.getfirst('owner'))
147
148     memory = fields.getfirst('memory')
149     memory = validation.validMemory(owner, memory, on=True)
150     
151     disk_size = fields.getfirst('disk')
152     disk_size = validation.validDisk(owner, disk_size)
153
154     vm_type = fields.getfirst('vmtype')
155     vm_type = validation.validVmType(vm_type)
156
157     cdrom = fields.getfirst('cdrom')
158     if cdrom is not None and not CDROM.get(cdrom):
159         raise CodeError("Invalid cdrom type '%s'" % cdrom)
160
161     clone_from = fields.getfirst('clone_from')
162     if clone_from and clone_from != 'ice3':
163         raise CodeError("Invalid clone image '%s'" % clone_from)
164     
165     return dict(contact=user, name=name, memory=memory, disk_size=disk_size,
166                 owner=owner, machine_type=vm_type, cdrom=cdrom, clone_from=clone_from)
167
168 def create(user, fields):
169     """Handler for create requests."""
170     try:
171         parsed_fields = parseCreate(user, fields)
172         machine = controls.createVm(**parsed_fields)
173     except InvalidInput, err:
174         pass
175     else:
176         err = None
177     g.clear() #Changed global state
178     d = getListDict(user)
179     d['err'] = err
180     if err:
181         for field in fields.keys():
182             setattr(d['defaults'], field, fields.getfirst(field))
183     else:
184         d['new_machine'] = parsed_fields['name']
185     return templates.list(searchList=[d])
186
187
188 def getListDict(user):
189     machines = g.machines
190     checkpoint.checkpoint('Got my machines')
191     on = {}
192     has_vnc = {}
193     on = g.uptimes
194     checkpoint.checkpoint('Got uptimes')
195     for m in machines:
196         m.uptime = g.uptimes.get(m)
197         if not on[m]:
198             has_vnc[m] = 'Off'
199         elif m.type.hvm:
200             has_vnc[m] = True
201         else:
202             has_vnc[m] = "ParaVM"+helppopup("paravm_console")
203     max_memory = validation.maxMemory(user)
204     max_disk = validation.maxDisk(user)
205     checkpoint.checkpoint('Got max mem/disk')
206     defaults = Defaults(max_memory=max_memory,
207                         max_disk=max_disk,
208                         owner=user,
209                         cdrom='gutsy-i386')
210     checkpoint.checkpoint('Got defaults')
211     def sortkey(machine):
212         return (machine.owner != user, machine.owner, machine.name)
213     machines = sorted(machines, key=sortkey)
214     d = dict(user=user,
215              cant_add_vm=validation.cantAddVm(user),
216              max_memory=max_memory,
217              max_disk=max_disk,
218              defaults=defaults,
219              machines=machines,
220              has_vnc=has_vnc,
221              uptimes=g.uptimes,
222              cdroms=CDROM.select())
223     return d
224
225 def listVms(user, fields):
226     """Handler for list requests."""
227     checkpoint.checkpoint('Getting list dict')
228     d = getListDict(user)
229     checkpoint.checkpoint('Got list dict')
230     return templates.list(searchList=[d])
231             
232 def vnc(user, fields):
233     """VNC applet page.
234
235     Note that due to same-domain restrictions, the applet connects to
236     the webserver, which needs to forward those requests to the xen
237     server.  The Xen server runs another proxy that (1) authenticates
238     and (2) finds the correct port for the VM.
239
240     You might want iptables like:
241
242     -t nat -A PREROUTING -s ! 18.181.0.60 -i eth1 -p tcp -m tcp \
243       --dport 10003 -j DNAT --to-destination 18.181.0.60:10003 
244     -t nat -A POSTROUTING -d 18.181.0.60 -o eth1 -p tcp -m tcp \
245       --dport 10003 -j SNAT --to-source 18.187.7.142 
246     -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp \
247       --dport 10003 -j ACCEPT
248
249     Remember to enable iptables!
250     echo 1 > /proc/sys/net/ipv4/ip_forward
251     """
252     machine = validation.testMachineId(user, fields.getfirst('machine_id'))
253     
254     TOKEN_KEY = "0M6W0U1IXexThi5idy8mnkqPKEq1LtEnlK/pZSn0cDrN"
255
256     data = {}
257     data["user"] = user
258     data["machine"] = machine.name
259     data["expires"] = time.time()+(5*60)
260     pickled_data = cPickle.dumps(data)
261     m = hmac.new(TOKEN_KEY, digestmod=sha)
262     m.update(pickled_data)
263     token = {'data': pickled_data, 'digest': m.digest()}
264     token = cPickle.dumps(token)
265     token = base64.urlsafe_b64encode(token)
266     
267     status = controls.statusInfo(machine)
268     has_vnc = hasVnc(status)
269     
270     d = dict(user=user,
271              on=status,
272              has_vnc=has_vnc,
273              machine=machine,
274              hostname=os.environ.get('SERVER_NAME', 'localhost'),
275              authtoken=token)
276     return templates.vnc(searchList=[d])
277
278 def getHostname(nic):
279     if nic.hostname and '.' in nic.hostname:
280         return nic.hostname
281     elif nic.machine:
282         return nic.machine.name + '.servers.csail.mit.edu'
283     else:
284         return None
285
286
287 def getNicInfo(data_dict, machine):
288     """Helper function for info, get data on nics for a machine.
289
290     Modifies data_dict to include the relevant data, and returns a list
291     of (key, name) pairs to display "name: data_dict[key]" to the user.
292     """
293     data_dict['num_nics'] = len(machine.nics)
294     nic_fields_template = [('nic%s_hostname', 'NIC %s Hostname'),
295                            ('nic%s_mac', 'NIC %s MAC Addr'),
296                            ('nic%s_ip', 'NIC %s IP'),
297                            ]
298     nic_fields = []
299     for i in range(len(machine.nics)):
300         nic_fields.extend([(x % i, y % i) for x, y in nic_fields_template])
301         if not i:
302             data_dict['nic%s_hostname' % i] = getHostname(machine.nics[i])
303         data_dict['nic%s_mac' % i] = machine.nics[i].mac_addr
304         data_dict['nic%s_ip' % i] = machine.nics[i].ip
305     if len(machine.nics) == 1:
306         nic_fields = [(x, y.replace('NIC 0 ', '')) for x, y in nic_fields]
307     return nic_fields
308
309 def getDiskInfo(data_dict, machine):
310     """Helper function for info, get data on disks for a machine.
311
312     Modifies data_dict to include the relevant data, and returns a list
313     of (key, name) pairs to display "name: data_dict[key]" to the user.
314     """
315     data_dict['num_disks'] = len(machine.disks)
316     disk_fields_template = [('%s_size', '%s size')]
317     disk_fields = []
318     for disk in machine.disks:
319         name = disk.guest_device_name
320         disk_fields.extend([(x % name, y % name) for x, y in 
321                             disk_fields_template])
322         data_dict['%s_size' % name] = "%0.1f GiB" % (disk.size / 1024.)
323     return disk_fields
324
325 def command(user, fields):
326     """Handler for running commands like boot and delete on a VM."""
327     back = fields.getfirst('back')
328     try:
329         d = controls.commandResult(user, fields)
330         if d['command'] == 'Delete VM':
331             back = 'list'
332     except InvalidInput, err:
333         if not back:
334             raise
335         #print >> sys.stderr, err
336         result = err
337     else:
338         result = 'Success!'
339         if not back:
340             return templates.command(searchList=[d])
341     if back == 'list':
342         g.clear() #Changed global state
343         d = getListDict(user)
344         d['result'] = result
345         return templates.list(searchList=[d])
346     elif back == 'info':
347         machine = validation.testMachineId(user, fields.getfirst('machine_id'))
348         return ({'Status': '302',
349                  'Location': '/info?machine_id=%d' % machine.machine_id},
350                 "You shouldn't see this message.")
351     else:
352         raise InvalidInput('back', back, 'Not a known back page.')
353
354 def modifyDict(user, fields):
355     olddisk = {}
356     transaction = ctx.current.create_transaction()
357     try:
358         machine = validation.testMachineId(user, fields.getfirst('machine_id'))
359         owner = validation.testOwner(user, fields.getfirst('owner'), machine)
360         admin = validation.testAdmin(user, fields.getfirst('administrator'),
361                                      machine)
362         contact = validation.testContact(user, fields.getfirst('contact'),
363                                          machine)
364         name = validation.testName(user, fields.getfirst('name'), machine)
365         oldname = machine.name
366         command = "modify"
367
368         memory = fields.getfirst('memory')
369         if memory is not None:
370             memory = validation.validMemory(user, memory, machine, on=False)
371             machine.memory = memory
372  
373         disksize = validation.testDisk(user, fields.getfirst('disk'))
374         if disksize is not None:
375             disksize = validation.validDisk(user, disksize, machine)
376             disk = machine.disks[0]
377             if disk.size != disksize:
378                 olddisk[disk.guest_device_name] = disksize
379                 disk.size = disksize
380                 ctx.current.save(disk)
381         
382         if owner is not None:
383             machine.owner = owner
384         if name is not None:
385             machine.name = name
386         if admin is not None:
387             machine.administrator = admin
388         if contact is not None:
389             machine.contact = contact
390             
391         ctx.current.save(machine)
392         transaction.commit()
393     except:
394         transaction.rollback()
395         raise
396     for diskname in olddisk:
397         controls.resizeDisk(oldname, diskname, str(olddisk[diskname]))
398     if name is not None:
399         controls.renameMachine(machine, oldname, name)
400     return dict(user=user,
401                 command=command,
402                 machine=machine)
403     
404 def modify(user, fields):
405     """Handler for modifying attributes of a machine."""
406     try:
407         modify_dict = modifyDict(user, fields)
408     except InvalidInput, err:
409         result = None
410         machine = validation.testMachineId(user, fields.getfirst('machine_id'))
411     else:
412         machine = modify_dict['machine']
413         result = 'Success!'
414         err = None
415     info_dict = infoDict(user, machine)
416     info_dict['err'] = err
417     if err:
418         for field in fields.keys():
419             setattr(info_dict['defaults'], field, fields.getfirst(field))
420     info_dict['result'] = result
421     return templates.info(searchList=[info_dict])
422     
423
424 def helpHandler(user, fields):
425     """Handler for help messages."""
426     simple = fields.getfirst('simple')
427     subjects = fields.getlist('subject')
428     
429     help_mapping = dict(paravm_console="""
430 ParaVM machines do not support local console access over VNC.  To
431 access the serial console of these machines, you can SSH with Kerberos
432 to sipb-xen-console.mit.edu, using the name of the machine as your
433 username.""",
434                         hvm_paravm="""
435 HVM machines use the virtualization features of the processor, while
436 ParaVM machines use Xen's emulation of virtualization features.  You
437 want an HVM virtualized machine.""",
438                         cpu_weight="""
439 Don't ask us!  We're as mystified as you are.""",
440                         owner="""
441 The owner field is used to determine <a
442 href="help?subject=quotas">quotas</a>.  It must be the name of a
443 locker that you are an AFS administrator of.  In particular, you or an
444 AFS group you are a member of must have AFS rlidwka bits on the
445 locker.  You can check who administers the LOCKER locker using the
446 commands 'attach LOCKER; fs la /mit/LOCKER' on Athena.)  See also <a
447 href="help?subject=administrator">administrator</a>.""",
448                         administrator="""
449 The administrator field determines who can access the console and
450 power on and off the machine.  This can be either a user or a moira
451 group.""",
452                         quotas="""
453 Quotas are determined on a per-locker basis.  Each locker may have a
454 maximum of 512 megabytes of active ram, 50 gigabytes of disk, and 4
455 active machines.""",
456                         console="""
457 <strong>Framebuffer:</strong> At a Linux boot prompt in your VM, try
458 setting <tt>fb=false</tt> to disable the framebuffer.  If you don't,
459 your machine will run just fine, but the applet's display of the
460 console will suffer artifacts.
461 """
462                    )
463     
464     if not subjects:
465         subjects = sorted(help_mapping.keys())
466         
467     d = dict(user=user,
468              simple=simple,
469              subjects=subjects,
470              mapping=help_mapping)
471     
472     return templates.help(searchList=[d])
473     
474
475 def badOperation(u, e):
476     raise CodeError("Unknown operation")
477
478 def infoDict(user, machine):
479     status = controls.statusInfo(machine)
480     checkpoint.checkpoint('Getting status info')
481     has_vnc = hasVnc(status)
482     if status is None:
483         main_status = dict(name=machine.name,
484                            memory=str(machine.memory))
485         uptime = None
486         cputime = None
487     else:
488         main_status = dict(status[1:])
489         start_time = float(main_status.get('start_time', 0))
490         uptime = datetime.timedelta(seconds=int(time.time()-start_time))
491         cpu_time_float = float(main_status.get('cpu_time', 0))
492         cputime = datetime.timedelta(seconds=int(cpu_time_float))
493     checkpoint.checkpoint('Status')
494     display_fields = """name uptime memory state cpu_weight on_reboot 
495      on_poweroff on_crash on_xend_start on_xend_stop bootloader""".split()
496     display_fields = [('name', 'Name'),
497                       ('owner', 'Owner'),
498                       ('administrator', 'Administrator'),
499                       ('contact', 'Contact'),
500                       ('type', 'Type'),
501                       'NIC_INFO',
502                       ('uptime', 'uptime'),
503                       ('cputime', 'CPU usage'),
504                       ('memory', 'RAM'),
505                       'DISK_INFO',
506                       ('state', 'state (xen format)'),
507                       ('cpu_weight', 'CPU weight'+helppopup('cpu_weight')),
508                       ('on_reboot', 'Action on VM reboot'),
509                       ('on_poweroff', 'Action on VM poweroff'),
510                       ('on_crash', 'Action on VM crash'),
511                       ('on_xend_start', 'Action on Xen start'),
512                       ('on_xend_stop', 'Action on Xen stop'),
513                       ('bootloader', 'Bootloader options'),
514                       ]
515     fields = []
516     machine_info = {}
517     machine_info['name'] = machine.name
518     machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
519     machine_info['owner'] = machine.owner
520     machine_info['administrator'] = machine.administrator
521     machine_info['contact'] = machine.contact
522
523     nic_fields = getNicInfo(machine_info, machine)
524     nic_point = display_fields.index('NIC_INFO')
525     display_fields = (display_fields[:nic_point] + nic_fields + 
526                       display_fields[nic_point+1:])
527
528     disk_fields = getDiskInfo(machine_info, machine)
529     disk_point = display_fields.index('DISK_INFO')
530     display_fields = (display_fields[:disk_point] + disk_fields + 
531                       display_fields[disk_point+1:])
532     
533     main_status['memory'] += ' MiB'
534     for field, disp in display_fields:
535         if field in ('uptime', 'cputime') and locals()[field] is not None:
536             fields.append((disp, locals()[field]))
537         elif field in machine_info:
538             fields.append((disp, machine_info[field]))
539         elif field in main_status:
540             fields.append((disp, main_status[field]))
541         else:
542             pass
543             #fields.append((disp, None))
544
545     checkpoint.checkpoint('Got fields')
546
547
548     max_mem = validation.maxMemory(user, machine, False)
549     checkpoint.checkpoint('Got mem')
550     max_disk = validation.maxDisk(user, machine)
551     defaults = Defaults()
552     for name in 'machine_id name administrator owner memory contact'.split():
553         setattr(defaults, name, getattr(machine, name))
554     defaults.disk = "%0.2f" % (machine.disks[0].size/1024.)
555     checkpoint.checkpoint('Got defaults')
556     d = dict(user=user,
557              cdroms=CDROM.select(),
558              on=status is not None,
559              machine=machine,
560              defaults=defaults,
561              has_vnc=has_vnc,
562              uptime=str(uptime),
563              ram=machine.memory,
564              max_mem=max_mem,
565              max_disk=max_disk,
566              owner_help=helppopup("owner"),
567              fields = fields)
568     return d
569
570 def info(user, fields):
571     """Handler for info on a single VM."""
572     machine = validation.testMachineId(user, fields.getfirst('machine_id'))
573     d = infoDict(user, machine)
574     checkpoint.checkpoint('Got infodict')
575     return templates.info(searchList=[d])
576
577 mapping = dict(list=listVms,
578                vnc=vnc,
579                command=command,
580                modify=modify,
581                info=info,
582                create=create,
583                help=helpHandler)
584
585 def printHeaders(headers):
586     for key, value in headers.iteritems():
587         print '%s: %s' % (key, value)
588     print
589
590
591 def getUser():
592     """Return the current user based on the SSL environment variables"""
593     username = os.environ['SSL_CLIENT_S_DN_Email'].split("@")[0]
594     return username
595
596 def main(operation, user, fields):    
597     start_time = time.time()
598     fun = mapping.get(operation, badOperation)
599
600     if fun not in (helpHandler, ):
601         connect('postgres://sipb-xen@sipb-xen-dev.mit.edu/sipb_xen')
602     try:
603         checkpoint.checkpoint('Before')
604         output = fun(u, fields)
605         checkpoint.checkpoint('After')
606
607         headers = dict(DEFAULT_HEADERS)
608         if isinstance(output, tuple):
609             new_headers, output = output
610             headers.update(new_headers)
611         e = revertStandardError()
612         if e:
613             output.addError(e)
614         printHeaders(headers)
615         output_string =  str(output)
616         checkpoint.checkpoint('output as a string')
617         print output_string
618         print '<!-- <pre>%s</pre> -->' % checkpoint
619     except Exception, err:
620         if not fields.has_key('js'):
621             if isinstance(err, CodeError):
622                 print 'Content-Type: text/html\n'
623                 e = revertStandardError()
624                 print error(operation, u, fields, err, e)
625                 sys.exit(1)
626             if isinstance(err, InvalidInput):
627                 print 'Content-Type: text/html\n'
628                 e = revertStandardError()
629                 print invalidInput(operation, u, fields, err, e)
630                 sys.exit(1)
631         print 'Content-Type: text/plain\n'
632         print 'Uh-oh!  We experienced an error.'
633         print 'Please email sipb-xen@mit.edu with the contents of this page.'
634         print '----'
635         e = revertStandardError()
636         print e
637         print '----'
638         raise
639
640 if __name__ == '__main__':
641     fields = cgi.FieldStorage()
642     u = getUser()
643     g.user = u
644     operation = os.environ.get('PATH_INFO', '')
645     if not operation:
646         print "Status: 301 Moved Permanently"
647         print 'Location: ' + os.environ['SCRIPT_NAME']+'/\n'
648         sys.exit(0)
649
650     if operation.startswith('/'):
651         operation = operation[1:]
652     if not operation:
653         operation = 'list'
654
655     if os.getenv("SIPB_XEN_PROFILE"):
656         import profile
657         profile.run('main(operation, u, fields)', 'log-'+operation)
658     else:
659         main(operation, u, fields)