A monster checkin, with a variety of changes to the web
[invirt/packages/invirt-web.git] / templates / 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 getafsgroups
9 import hmac
10 import os
11 import random
12 import re
13 import sha
14 import simplejson
15 import string
16 import subprocess
17 import sys
18 import time
19 from StringIO import StringIO
20
21
22 def revertStandardError():
23     """Move stderr to stdout, and return the contents of the old stderr."""
24     errio = sys.stderr
25     if not isinstance(errio, StringIO):
26         return None
27     sys.stderr = sys.stdout
28     errio.seek(0)
29     return errio.read()
30
31 def printError():
32     """Revert stderr to stdout, and print the contents of stderr"""
33     if isinstance(sys.stderr, StringIO):
34         print revertStandardError()
35
36 if __name__ == '__main__':
37     import atexit
38     atexit.register(printError)
39     sys.stderr = StringIO()
40
41 sys.path.append('/home/ecprice/.local/lib/python2.5/site-packages')
42
43 from Cheetah.Template import Template
44 from sipb_xen_database import *
45
46 class MyException(Exception):
47     """Base class for my exceptions"""
48     pass
49
50 class InvalidInput(MyException):
51     """Exception for user-provided input is invalid but maybe in good faith.
52
53     This would include setting memory to negative (which might be a
54     typo) but not setting an invalid boot CD (which requires bypassing
55     the select box).
56     """
57     def __init__(self, err_field, err_value, expl=None):
58         MyException.__init__(self, expl)
59         self.err_field = err_field
60         self.err_value = err_value
61
62 class CodeError(MyException):
63     """Exception for internal errors or bad faith input."""
64     pass
65
66 def helppopup(subj):
67     """Return HTML code for a (?) link to a specified help topic"""
68     return ('<span class="helplink"><a href="help?subject=' + subj + 
69             '&amp;simple=true" target="_blank" ' + 
70             'onclick="return helppopup(\'' + subj + '\')">(?)</a></span>')
71
72 class Global(object):
73     """Global state of the system, to avoid duplicate remctls to get state"""
74     def __init__(self, user):
75         self.user = user
76
77     def __get_uptimes(self):
78         if not hasattr(self, '_uptimes'):
79             self._uptimes = getUptimes(Machine.select())
80         return self._uptimes
81     uptimes = property(__get_uptimes)
82
83     def clear(self):
84         """Clear the state so future accesses reload it."""
85         for attr in ('_uptimes', ):
86             if hasattr(self, attr):
87                 delattr(self, attr)
88
89 g = None
90
91 class User:
92     """User class (sort of useless, I admit)"""
93     def __init__(self, username, email):
94         self.username = username
95         self.email = email
96
97 def makeErrorPre(old, addition):
98     if addition is None:
99         return
100     if old:
101         return old[:-6]  + '\n----\n' + str(addition) + '</pre>'
102     else:
103         return '<p>STDERR:</p><pre>' + str(addition) + '</pre>'
104
105 Template.helppopup = staticmethod(helppopup)
106 Template.err = None
107
108 class JsonDict:
109     """Class to store a dictionary that will be converted to JSON"""
110     def __init__(self, **kws):
111         self.data = kws
112         if 'err' in kws:
113             err = kws['err']
114             del kws['err']
115             self.addError(err)
116
117     def __str__(self):
118         return simplejson.dumps(self.data)
119
120     def addError(self, text):
121         """Add stderr text to be displayed on the website."""
122         self.data['err'] = \
123             makeErrorPre(self.data.get('err'), text)
124
125 class Defaults:
126     """Class to store default values for fields."""
127     memory = 256
128     disk = 4.0
129     cdrom = ''
130     name = ''
131     vmtype = 'hvm'
132     def __init__(self, max_memory=None, max_disk=None, **kws):
133         if max_memory is not None:
134             self.memory = min(self.memory, max_memory)
135         if max_disk is not None:
136             self.max_disk = min(self.disk, max_disk)
137         for key in kws:
138             setattr(self, key, kws[key])
139
140
141
142 default_headers = {'Content-Type': 'text/html'}
143
144 # ... and stolen from xend/uuid.py
145 def randomUUID():
146     """Generate a random UUID."""
147
148     return [ random.randint(0, 255) for _ in range(0, 16) ]
149
150 def uuidToString(u):
151     """Turn a numeric UUID to a hyphen-seperated one."""
152     return "-".join(["%02x" * 4, "%02x" * 2, "%02x" * 2, "%02x" * 2,
153                      "%02x" * 6]) % tuple(u)
154
155 MAX_MEMORY_TOTAL = 512
156 MAX_MEMORY_SINGLE = 256
157 MIN_MEMORY_SINGLE = 16
158 MAX_DISK_TOTAL = 50
159 MAX_DISK_SINGLE = 50
160 MIN_DISK_SINGLE = 0.1
161 MAX_VMS_TOTAL = 10
162 MAX_VMS_ACTIVE = 4
163
164 def getMachinesByOwner(user, machine=None):
165     """Return the machines owned by the same as a machine.
166     
167     If the machine is None, return the machines owned by the same
168     user.
169     """
170     if machine:
171         owner = machine.owner
172     else:
173         owner = user.username
174     return Machine.select_by(owner=owner)
175
176 def maxMemory(user, machine=None, on=True):
177     """Return the maximum memory for a machine or a user.
178
179     If machine is None, return the memory available for a new 
180     machine.  Else, return the maximum that machine can have.
181
182     on is whether the machine should be turned on.  If false, the max
183     memory for the machine to change to, if it is left off, is
184     returned.
185     """
186     if not on:
187         return MAX_MEMORY_SINGLE
188     machines = getMachinesByOwner(user, machine)
189     active_machines = [x for x in machines if g.uptimes[x]]
190     mem_usage = sum([x.memory for x in active_machines if x != machine])
191     return min(MAX_MEMORY_SINGLE, MAX_MEMORY_TOTAL-mem_usage)
192
193 def maxDisk(user, machine=None):
194     machines = getMachinesByOwner(user, machine)
195     disk_usage = sum([sum([y.size for y in x.disks])
196                       for x in machines if x != machine])
197     return min(MAX_DISK_SINGLE, MAX_DISK_TOTAL-disk_usage/1024.)
198
199 def cantAddVm(user):
200     machines = getMachinesByOwner(user)
201     active_machines = [x for x in machines if g.uptimes[x]]
202     if len(machines) >= MAX_VMS_TOTAL:
203         return 'You have too many VMs to create a new one.'
204     if len(active_machines) >= MAX_VMS_ACTIVE:
205         return ('You already have the maximum number of VMs turned on.  '
206                 'To create more, turn one off.')
207     return False
208
209 def haveAccess(user, machine):
210     """Return whether a user has adminstrative access to a machine"""
211     if user.username == 'moo':
212         return True
213     if user.username in (machine.administrator, machine.owner):
214         return True
215     if getafsgroups.checkAfsGroup(user.username, machine.administrator, 
216                                   'athena.mit.edu'): #XXX Cell?
217         return True
218     if getafsgroups.checkLockerOwner(user.username, machine.owner):
219         return True
220     return owns(user, machine)
221
222 def owns(user, machine):
223     """Return whether a user owns a machine"""
224     if user.username == 'moo':
225         return True
226     return getafsgroups.checkLockerOwner(user.username, machine.owner)
227
228 def error(op, user, fields, err, emsg):
229     """Print an error page when a CodeError occurs"""
230     d = dict(op=op, user=user, errorMessage=str(err),
231              stderr=emsg)
232     return Template(file='error.tmpl', searchList=[d]);
233
234 def invalidInput(op, user, fields, err, emsg):
235     """Print an error page when an InvalidInput exception occurs"""
236     d = dict(op=op, user=user, err_field=err.err_field,
237              err_value=str(err.err_value), stderr=emsg,
238              errorMessage=str(err))
239     return Template(file='invalid.tmpl', searchList=[d]);
240
241 def validMachineName(name):
242     """Check that name is valid for a machine name"""
243     if not name:
244         return False
245     charset = string.ascii_letters + string.digits + '-_'
246     if name[0] in '-_' or len(name) > 22:
247         return False
248     for x in name:
249         if x not in charset:
250             return False
251     return True
252
253 def kinit(username = 'tabbott/extra', keytab = '/etc/tabbott.keytab'):
254     """Kinit with a given username and keytab"""
255
256     p = subprocess.Popen(['kinit', "-k", "-t", keytab, username],
257                          stderr=subprocess.PIPE)
258     e = p.wait()
259     if e:
260         raise CodeError("Error %s in kinit: %s" % (e, p.stderr.read()))
261
262 def checkKinit():
263     """If we lack tickets, kinit."""
264     p = subprocess.Popen(['klist', '-s'])
265     if p.wait():
266         kinit()
267
268 def remctl(*args, **kws):
269     """Perform a remctl and return the output.
270
271     kinits if necessary, and outputs errors to stderr.
272     """
273     checkKinit()
274     p = subprocess.Popen(['remctl', 'black-mesa.mit.edu']
275                          + list(args),
276                          stdout=subprocess.PIPE,
277                          stderr=subprocess.PIPE)
278     v = p.wait()
279     if kws.get('err'):
280         return p.stdout.read(), p.stderr.read()
281     if v:
282         print >> sys.stderr, 'Error', v, 'on remctl', args, ':'
283         print >> sys.stderr, p.stderr.read()
284         raise CodeError('ERROR on remctl')
285     return p.stdout.read()
286
287 def lvcreate(machine, disk):
288     """Create a single disk for a machine"""
289     remctl('web', 'lvcreate', machine.name,
290            disk.guest_device_name, str(disk.size))
291     
292 def makeDisks(machine):
293     """Update the lvm partitions to add a disk."""
294     for disk in machine.disks:
295         lvcreate(machine, disk)
296
297 def bootMachine(machine, cdtype):
298     """Boot a machine with a given boot CD.
299
300     If cdtype is None, give no boot cd.  Otherwise, it is the string
301     id of the CD (e.g. 'gutsy_i386')
302     """
303     if cdtype is not None:
304         remctl('control', machine.name, 'create', 
305                cdtype)
306     else:
307         remctl('control', machine.name, 'create')
308
309 def registerMachine(machine):
310     """Register a machine to be controlled by the web interface"""
311     remctl('web', 'register', machine.name)
312
313 def unregisterMachine(machine):
314     """Unregister a machine to not be controlled by the web interface"""
315     remctl('web', 'unregister', machine.name)
316
317 def parseStatus(s):
318     """Parse a status string into nested tuples of strings.
319
320     s = output of xm list --long <machine_name>
321     """
322     values = re.split('([()])', s)
323     stack = [[]]
324     for v in values[2:-2]: #remove initial and final '()'
325         if not v:
326             continue
327         v = v.strip()
328         if v == '(':
329             stack.append([])
330         elif v == ')':
331             if len(stack[-1]) == 1:
332                 stack[-1].append('')
333             stack[-2].append(stack[-1])
334             stack.pop()
335         else:
336             if not v:
337                 continue
338             stack[-1].extend(v.split())
339     return stack[-1]
340
341 def getUptimes(machines=None):
342     """Return a dictionary mapping machine names to uptime strings"""
343     value_string = remctl('web', 'listvms')
344     lines = value_string.splitlines()
345     d = {}
346     for line in lines:
347         lst = line.split()
348         name, id = lst[:2]
349         uptime = ' '.join(lst[2:])
350         d[name] = uptime
351     ans = {}
352     for m in machines:
353         ans[m] = d.get(m.name)
354     return ans
355
356 def statusInfo(machine):
357     """Return the status list for a given machine.
358
359     Gets and parses xm list --long
360     """
361     value_string, err_string = remctl('control', machine.name, 'list-long', 
362                                       err=True)
363     if 'Unknown command' in err_string:
364         raise CodeError("ERROR in remctl list-long %s is not registered" % 
365                         (machine.name,))
366     elif 'does not exist' in err_string:
367         return None
368     elif err_string:
369         raise CodeError("ERROR in remctl list-long %s:  %s" % 
370                         (machine.name, err_string))
371     status = parseStatus(value_string)
372     return status
373
374 def hasVnc(status):
375     """Does the machine with a given status list support VNC?"""
376     if status is None:
377         return False
378     for l in status:
379         if l[0] == 'device' and l[1][0] == 'vfb':
380             d = dict(l[1][1:])
381             return 'location' in d
382     return False
383
384 def createVm(user, name, memory, disk, is_hvm, cdrom):
385     """Create a VM and put it in the database"""
386     # put stuff in the table
387     transaction = ctx.current.create_transaction()
388     try:
389         if memory > maxMemory(user):
390             raise InvalidInput('memory', memory,
391                                "Max %s" % maxMemory(user))
392         if disk > maxDisk(user) * 1024:
393             raise InvalidInput('disk', disk,
394                                "Max %s" % maxDisk(user))
395         reason = cantAddVm(user)
396         if reason:
397             raise InvalidInput('create', True, reason)
398         res = meta.engine.execute('select nextval('
399                                   '\'"machines_machine_id_seq"\')')
400         id = res.fetchone()[0]
401         machine = Machine()
402         machine.machine_id = id
403         machine.name = name
404         machine.memory = memory
405         machine.owner = user.username
406         machine.administrator = user.username
407         machine.contact = user.email
408         machine.uuid = uuidToString(randomUUID())
409         machine.boot_off_cd = True
410         machine_type = Type.get_by(hvm=is_hvm)
411         machine.type_id = machine_type.type_id
412         ctx.current.save(machine)
413         disk = Disk(machine.machine_id, 
414                     'hda', disk)
415         open_nics = NIC.select_by(machine_id=None)
416         if not open_nics: #No IPs left!
417             raise CodeError("No IP addresses left!  "
418                             "Contact sipb-xen-dev@mit.edu")
419         nic = open_nics[0]
420         nic.machine_id = machine.machine_id
421         nic.hostname = name
422         ctx.current.save(nic)    
423         ctx.current.save(disk)
424         transaction.commit()
425     except:
426         transaction.rollback()
427         raise
428     registerMachine(machine)
429     makeDisks(machine)
430     # tell it to boot with cdrom
431     bootMachine(machine, cdrom)
432
433     return machine
434
435 def validMemory(user, memory, machine=None, on=True):
436     """Parse and validate limits for memory for a given user and machine.
437
438     on is whether the memory must be valid after the machine is
439     switched on.
440     """
441     try:
442         memory = int(memory)
443         if memory < MIN_MEMORY_SINGLE:
444             raise ValueError
445     except ValueError:
446         raise InvalidInput('memory', memory, 
447                            "Minimum %s MB" % MIN_MEMORY_SINGLE)
448     if memory > maxMemory(user, machine, on):
449         raise InvalidInput('memory', memory,
450                            'Maximum %s MB' % maxMemory(user, machine))
451     return memory
452
453 def validDisk(user, disk, machine=None):
454     """Parse and validate limits for disk for a given user and machine."""
455     try:
456         disk = float(disk)
457         if disk > maxDisk(user, machine):
458             raise InvalidInput('disk', disk,
459                                "Maximum %s G" % maxDisk(user, machine))
460         disk = int(disk * 1024)
461         if disk < MIN_DISK_SINGLE * 1024:
462             raise ValueError
463     except ValueError:
464         raise InvalidInput('disk', disk,
465                            "Minimum %s GB" % MIN_DISK_SINGLE)
466     return disk
467
468 def parseCreate(user, fields):
469     name = fields.getfirst('name')
470     if not validMachineName(name):
471         raise InvalidInput('name', name, 'You must provide a machine name.')
472     name = name.lower()
473
474     if Machine.get_by(name=name):
475         raise InvalidInput('name', name,
476                            "Name already exists.")
477     
478     memory = fields.getfirst('memory')
479     memory = validMemory(user, memory, on=True)
480     
481     disk = fields.getfirst('disk')
482     disk = validDisk(user, disk)
483
484     vm_type = fields.getfirst('vmtype')
485     if vm_type not in ('hvm', 'paravm'):
486         raise CodeError("Invalid vm type '%s'"  % vm_type)    
487     is_hvm = (vm_type == 'hvm')
488
489     cdrom = fields.getfirst('cdrom')
490     if cdrom is not None and not CDROM.get(cdrom):
491         raise CodeError("Invalid cdrom type '%s'" % cdrom)
492     return dict(user=user, name=name, memory=memory, disk=disk,
493                 is_hvm=is_hvm, cdrom=cdrom)
494
495 def create(user, fields):
496     """Handler for create requests."""
497     js = fields.getfirst('js')
498     try:
499         parsed_fields = parseCreate(user, fields)
500         machine = createVm(**parsed_fields)
501     except InvalidInput, err:
502         if not js:
503             raise
504     else:
505         err = None
506         if not js:
507             d = dict(user=user,
508                      machine=machine)
509             return Template(file='create.tmpl', searchList=[d])
510     g.clear() #Changed global state
511     d = getListDict(user)
512     d['err'] = err
513     if err:
514         for field in fields.keys():
515             setattr(d['defaults'], field, fields.getfirst(field))
516     else:
517         d['new_machine'] = parsed_fields['name']
518     t = Template(file='list.tmpl', searchList=[d])
519     return JsonDict(createtable=t.createTable(),
520                     machinelist=t.machineList(d['machines']))
521
522
523 def getListDict(user):
524     machines = [m for m in Machine.select() if haveAccess(user, m)]    
525     on = {}
526     has_vnc = {}
527     on = g.uptimes
528     for m in machines:
529         m.uptime = g.uptimes.get(m)
530         if not on[m]:
531             has_vnc[m] = 'Off'
532         elif m.type.hvm:
533             has_vnc[m] = True
534         else:
535             has_vnc[m] = "ParaVM"+helppopup("paravm_console")
536     #     for m in machines:
537     #         status = statusInfo(m)
538     #         on[m.name] = status is not None
539     #         has_vnc[m.name] = hasVnc(status)
540     max_memory = maxMemory(user)
541     max_disk = maxDisk(user)
542     defaults = Defaults(max_memory=max_memory,
543                         max_disk=max_disk,
544                         cdrom='gutsy-i386')
545     d = dict(user=user,
546              cant_add_vm=cantAddVm(user),
547              max_memory=max_memory,
548              max_disk=max_disk,
549              defaults=defaults,
550              machines=machines,
551              has_vnc=has_vnc,
552              uptimes=g.uptimes,
553              cdroms=CDROM.select())
554     return d
555
556 def listVms(user, fields):
557     """Handler for list requests."""
558     d = getListDict(user)
559     t = Template(file='list.tmpl', searchList=[d])
560     js = fields.getfirst('js')
561     if not js:
562         return t
563     if js == 'machinelist':
564         return t.machineList(d['machines'])
565     elif js.startswith('machinerow-'):
566         request_machine_id = int(js.split('-')[1])
567         m = [x for x in d['machines'] if x.id == request_machine_id]
568         return t.machineRow(m)
569     elif js == 'createtable':
570         return t.createTable()
571             
572 def testMachineId(user, machineId, exists=True):
573     """Parse, validate and check authorization for a given machineId.
574
575     If exists is False, don't check that it exists.
576     """
577     if machineId is None:
578         raise CodeError("No machine ID specified")
579     try:
580         machineId = int(machineId)
581     except ValueError:
582         raise CodeError("Invalid machine ID '%s'" % machineId)
583     machine = Machine.get(machineId)
584     if exists and machine is None:
585         raise CodeError("No such machine ID '%s'" % machineId)
586     if machine is not None and not haveAccess(user, machine):
587         raise CodeError("No access to machine ID '%s'" % machineId)
588     return machine
589
590 def vnc(user, fields):
591     """VNC applet page.
592
593     Note that due to same-domain restrictions, the applet connects to
594     the webserver, which needs to forward those requests to the xen
595     server.  The Xen server runs another proxy that (1) authenticates
596     and (2) finds the correct port for the VM.
597
598     You might want iptables like:
599
600     -t nat -A PREROUTING -s ! 18.181.0.60 -i eth1 -p tcp -m tcp \
601       --dport 10003 -j DNAT --to-destination 18.181.0.60:10003 
602     -t nat -A POSTROUTING -d 18.181.0.60 -o eth1 -p tcp -m tcp \
603       --dport 10003 -j SNAT --to-source 18.187.7.142 
604     -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp \
605       --dport 10003 -j ACCEPT
606
607     Remember to enable iptables!
608     echo 1 > /proc/sys/net/ipv4/ip_forward
609     """
610     machine = testMachineId(user, fields.getfirst('machine_id'))
611     
612     TOKEN_KEY = "0M6W0U1IXexThi5idy8mnkqPKEq1LtEnlK/pZSn0cDrN"
613
614     data = {}
615     data["user"] = user.username
616     data["machine"] = machine.name
617     data["expires"] = time.time()+(5*60)
618     pickled_data = cPickle.dumps(data)
619     m = hmac.new(TOKEN_KEY, digestmod=sha)
620     m.update(pickled_data)
621     token = {'data': pickled_data, 'digest': m.digest()}
622     token = cPickle.dumps(token)
623     token = base64.urlsafe_b64encode(token)
624     
625     status = statusInfo(machine)
626     has_vnc = hasVnc(status)
627     
628     d = dict(user=user,
629              on=status,
630              has_vnc=has_vnc,
631              machine=machine,
632              hostname=os.environ.get('SERVER_NAME', 'localhost'),
633              authtoken=token)
634     return Template(file='vnc.tmpl', searchList=[d])
635
636 def getNicInfo(data_dict, machine):
637     """Helper function for info, get data on nics for a machine.
638
639     Modifies data_dict to include the relevant data, and returns a list
640     of (key, name) pairs to display "name: data_dict[key]" to the user.
641     """
642     data_dict['num_nics'] = len(machine.nics)
643     nic_fields_template = [('nic%s_hostname', 'NIC %s hostname'),
644                            ('nic%s_mac', 'NIC %s MAC Addr'),
645                            ('nic%s_ip', 'NIC %s IP'),
646                            ]
647     nic_fields = []
648     for i in range(len(machine.nics)):
649         nic_fields.extend([(x % i, y % i) for x, y in nic_fields_template])
650         data_dict['nic%s_hostname' % i] = (machine.nics[i].hostname + 
651                                            '.servers.csail.mit.edu')
652         data_dict['nic%s_mac' % i] = machine.nics[i].mac_addr
653         data_dict['nic%s_ip' % i] = machine.nics[i].ip
654     if len(machine.nics) == 1:
655         nic_fields = [(x, y.replace('NIC 0 ', '')) for x, y in nic_fields]
656     return nic_fields
657
658 def getDiskInfo(data_dict, machine):
659     """Helper function for info, get data on disks for a machine.
660
661     Modifies data_dict to include the relevant data, and returns a list
662     of (key, name) pairs to display "name: data_dict[key]" to the user.
663     """
664     data_dict['num_disks'] = len(machine.disks)
665     disk_fields_template = [('%s_size', '%s size')]
666     disk_fields = []
667     for disk in machine.disks:
668         name = disk.guest_device_name
669         disk_fields.extend([(x % name, y % name) for x, y in 
670                             disk_fields_template])
671         data_dict['%s_size' % name] = "%0.1f GB" % (disk.size / 1024.)
672     return disk_fields
673
674 def deleteVM(machine):
675     """Delete a VM."""
676     remctl('control', machine.name, 'destroy', err=True)
677     transaction = ctx.current.create_transaction()
678     delete_disk_pairs = [(machine.name, d.guest_device_name) 
679                          for d in machine.disks]
680     try:
681         for nic in machine.nics:
682             nic.machine_id = None
683             nic.hostname = None
684             ctx.current.save(nic)
685         for disk in machine.disks:
686             ctx.current.delete(disk)
687         ctx.current.delete(machine)
688         transaction.commit()
689     except:
690         transaction.rollback()
691         raise
692     for mname, dname in delete_disk_pairs:
693         remctl('web', 'lvremove', mname, dname)
694     unregisterMachine(machine)
695
696 def commandResult(user, fields):
697     print >> sys.stderr, time.time()-start_time
698     machine = testMachineId(user, fields.getfirst('machine_id'))
699     action = fields.getfirst('action')
700     cdrom = fields.getfirst('cdrom')
701     print >> sys.stderr, time.time()-start_time
702     if cdrom is not None and not CDROM.get(cdrom):
703         raise CodeError("Invalid cdrom type '%s'" % cdrom)    
704     if action not in ('Reboot', 'Power on', 'Power off', 'Shutdown', 
705                       'Delete VM'):
706         raise CodeError("Invalid action '%s'" % action)
707     if action == 'Reboot':
708         if cdrom is not None:
709             out, err = remctl('control', machine.name, 'reboot', cdrom,
710                               err=True)
711         else:
712             out, err = remctl('control', machine.name, 'reboot',
713                               err=True)
714         if err:
715             if re.match("Error: Domain '.*' does not exist.", err):
716                 raise InvalidInput("action", "reboot", 
717                                    "Machine is not on")
718             else:
719                 print >> sys.stderr, 'Error on reboot:'
720                 print >> sys.stderr, err
721                 raise CodeError('ERROR on remctl')
722                 
723     elif action == 'Power on':
724         if maxMemory(user) < machine.memory:
725             raise InvalidInput('action', 'Power on',
726                                "You don't have enough free RAM quota "
727                                "to turn on this machine.")
728         bootMachine(machine, cdrom)
729     elif action == 'Power off':
730         out, err = remctl('control', machine.name, 'destroy', err=True)
731         if err:
732             if re.match("Error: Domain '.*' does not exist.", err):
733                 raise InvalidInput("action", "Power off", 
734                                    "Machine is not on.")
735             else:
736                 print >> sys.stderr, 'Error on power off:'
737                 print >> sys.stderr, err
738                 raise CodeError('ERROR on remctl')
739     elif action == 'Shutdown':
740         out, err = remctl('control', machine.name, 'shutdown', err=True)
741         if err:
742             if re.match("Error: Domain '.*' does not exist.", err):
743                 raise InvalidInput("action", "Shutdown", 
744                                    "Machine is not on.")
745             else:
746                 print >> sys.stderr, 'Error on Shutdown:'
747                 print >> sys.stderr, err
748                 raise CodeError('ERROR on remctl')
749     elif action == 'Delete VM':
750         deleteVM(machine)
751     print >> sys.stderr, time.time()-start_time
752
753     d = dict(user=user,
754              command=action,
755              machine=machine)
756     return d
757
758 def command(user, fields):
759     """Handler for running commands like boot and delete on a VM."""
760     js = fields.getfirst('js')
761     try:
762         d = commandResult(user, fields)
763     except InvalidInput, err:
764         if not js:
765             raise
766         result = None
767     else:
768         err = None
769         result = 'Success!'
770         if not js:
771             return Template(file='command.tmpl', searchList=[d])
772     if js == 'list':
773         g.clear() #Changed global state
774         d = getListDict(user)
775         t = Template(file='list.tmpl', searchList=[d])
776         return JsonDict(createtable=t.createTable(),
777                         machinelist=t.machineList(d['machines']),
778                         result=result,
779                         err=err)
780     elif js == 'info':
781         machine = testMachineId(user, fields.getfirst('machine_id'))
782         d = infoDict(user, machine)
783         t = Template(file='info.tmpl', searchList=[d])
784         return JsonDict(info=t.infoTable(),
785                         commands=t.commands(),
786                         modify=t.modifyForm(),
787                         result=result,
788                         err=err)
789     else:
790         raise InvalidInput('js', js, 'Not a known js type.')
791
792 def testAdmin(user, admin, machine):
793     if admin in (None, machine.administrator):
794         return None
795     if admin == user.username:
796         return admin
797     if getafsgroups.checkAfsGroup(user.username, admin, 'athena.mit.edu'):
798         return admin
799     if getafsgroups.checkAfsGroup(user.username, 'system:'+admin,
800                                   'athena.mit.edu'):
801         return 'system:'+admin
802     raise InvalidInput('administrator', admin, 
803                        'You must control the group you move it to.')
804     
805 def testOwner(user, owner, machine):
806     if owner in (None, machine.owner):
807         return None
808     value = getafsgroups.checkLockerOwner(user.username, owner, verbose=True)
809     if value == True:
810         return owner
811     raise InvalidInput('owner', owner, value)
812
813 def testContact(user, contact, machine=None):
814     if contact in (None, machine.contact):
815         return None
816     if not re.match("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$", contact, re.I):
817         raise InvalidInput('contact', contact, "Not a valid email.")
818     return contact
819
820 def testDisk(user, disksize, machine=None):
821     return disksize
822
823 def testName(user, name, machine=None):
824     if name in (None, machine.name):
825         return None
826     if not Machine.select_by(name=name):
827         return name
828     raise InvalidInput('name', name, "Name is already taken.")
829
830 def testHostname(user, hostname, machine):
831     for nic in machine.nics:
832         if hostname == nic.hostname:
833             return hostname
834     # check if doesn't already exist
835     if NIC.select_by(hostname=hostname):
836         raise InvalidInput('hostname', hostname,
837                            "Already exists")
838     if not re.match("^[A-Z0-9-]{1,22}$", hostname, re.I):
839         raise InvalidInput('hostname', hostname, "Not a valid hostname; "
840                            "must only use number, letters, and dashes.")
841     return hostname
842
843 def modifyDict(user, fields):
844     olddisk = {}
845     transaction = ctx.current.create_transaction()
846     try:
847         machine = testMachineId(user, fields.getfirst('machine_id'))
848         owner = testOwner(user, fields.getfirst('owner'), machine)
849         admin = testAdmin(user, fields.getfirst('administrator'), machine)
850         contact = testContact(user, fields.getfirst('contact'), machine)
851         hostname = testHostname(owner, fields.getfirst('hostname'), machine)
852         name = testName(user, fields.getfirst('name'), machine)
853         oldname = machine.name
854         command = "modify"
855
856         memory = fields.getfirst('memory')
857         if memory is not None:
858             memory = validMemory(user, memory, machine, on=False)
859             machine.memory = memory
860  
861         disksize = testDisk(user, fields.getfirst('disk'))
862         if disksize is not None:
863             disksize = validDisk(user, disksize, machine)
864             disk = machine.disks[0]
865             if disk.size != disksize:
866                 olddisk[disk.guest_device_name] = disksize
867                 disk.size = disksize
868                 ctx.current.save(disk)
869         
870         # XXX first NIC gets hostname on change?  
871         # Interface doesn't support more.
872         for nic in machine.nics[:1]:
873             nic.hostname = hostname
874             ctx.current.save(nic)
875
876         if owner is not None:
877             machine.owner = owner
878         if name is not None:
879             machine.name = name
880         if admin is not None:
881             machine.administrator = admin
882         if contact is not None:
883             machine.contact = contact
884             
885         ctx.current.save(machine)
886         transaction.commit()
887     except:
888         transaction.rollback()
889         raise
890     for diskname in olddisk:
891         remctl("web", "lvresize", oldname, diskname, str(olddisk[diskname]))
892     if name is not None:
893         for disk in machine.disks:
894             remctl("web", "lvrename", oldname, disk.guest_device_name, name)
895         remctl("web", "moveregister", oldname, name)
896     return dict(user=user,
897                 command=command,
898                 machine=machine)
899     
900 def modify(user, fields):
901     """Handler for modifying attributes of a machine."""
902     js = fields.getfirst('js')
903     try:
904         modify_dict = modifyDict(user, fields)
905     except InvalidInput, err:
906         if not js:
907             raise
908         result = ''
909         machine = testMachineId(user, fields.getfirst('machine_id'))
910     else:
911         machine = modify_dict['machine']
912         result='Success!'
913         err = None
914         if not js:
915             return Template(file='command.tmpl', searchList=[modify_dict])
916     info_dict = infoDict(user, machine)
917     info_dict['err'] = err
918     if err:
919         for field in fields.keys():
920             setattr(info_dict['defaults'], field, fields.getfirst(field))
921     t = Template(file='info.tmpl', searchList=[info_dict])
922     return JsonDict(info=t.infoTable(),
923                     commands=t.commands(),
924                     modify=t.modifyForm(),
925                     result=result,
926                     err=err)
927     
928
929 def helpHandler(user, fields):
930     """Handler for help messages."""
931     simple = fields.getfirst('simple')
932     subjects = fields.getlist('subject')
933     
934     help_mapping = dict(paravm_console="""
935 ParaVM machines do not support console access over VNC.  To access
936 these machines, you either need to boot with a liveCD and ssh in or
937 hope that the sipb-xen maintainers add support for serial consoles.""",
938                         hvm_paravm="""
939 HVM machines use the virtualization features of the processor, while
940 ParaVM machines use Xen's emulation of virtualization features.  You
941 want an HVM virtualized machine.""",
942                         cpu_weight="""
943 Don't ask us!  We're as mystified as you are.""",
944                         owner="""
945 The owner field is used to determine <a
946 href="help?subject=quotas">quotas</a>.  It must be the name of a
947 locker that you are an AFS administrator of.  In particular, you or an
948 AFS group you are a member of must have AFS rlidwka bits on the
949 locker.  You can check see who administers the LOCKER locker using the
950 command 'fs la /mit/LOCKER' on Athena.)  See also <a
951 href="help?subject=administrator">administrator</a>.""",
952                         administrator="""
953 The administrator field determines who can access the console and
954 power on and off the machine.  This can be either a user or a moira
955 group.""",
956                         quotas="""
957 Quotas are determined on a per-locker basis.  Each quota may have a
958 maximum of 512 megabytes of active ram, 50 gigabytes of disk, and 4
959 active machines."""
960                    )
961     
962     if not subjects:
963         subjects = sorted(help_mapping.keys())
964         
965     d = dict(user=user,
966              simple=simple,
967              subjects=subjects,
968              mapping=help_mapping)
969     
970     return Template(file="help.tmpl", searchList=[d])
971     
972
973 def badOperation(u, e):
974     raise CodeError("Unknown operation")
975
976 def infoDict(user, machine):
977     status = statusInfo(machine)
978     has_vnc = hasVnc(status)
979     if status is None:
980         main_status = dict(name=machine.name,
981                            memory=str(machine.memory))
982         uptime = None
983         cputime = None
984     else:
985         main_status = dict(status[1:])
986         start_time = float(main_status.get('start_time', 0))
987         uptime = datetime.timedelta(seconds=int(time.time()-start_time))
988         cpu_time_float = float(main_status.get('cpu_time', 0))
989         cputime = datetime.timedelta(seconds=int(cpu_time_float))
990     display_fields = """name uptime memory state cpu_weight on_reboot 
991      on_poweroff on_crash on_xend_start on_xend_stop bootloader""".split()
992     display_fields = [('name', 'Name'),
993                       ('owner', 'Owner'),
994                       ('administrator', 'Administrator'),
995                       ('contact', 'Contact'),
996                       ('type', 'Type'),
997                       'NIC_INFO',
998                       ('uptime', 'uptime'),
999                       ('cputime', 'CPU usage'),
1000                       ('memory', 'RAM'),
1001                       'DISK_INFO',
1002                       ('state', 'state (xen format)'),
1003                       ('cpu_weight', 'CPU weight'+helppopup('cpu_weight')),
1004                       ('on_reboot', 'Action on VM reboot'),
1005                       ('on_poweroff', 'Action on VM poweroff'),
1006                       ('on_crash', 'Action on VM crash'),
1007                       ('on_xend_start', 'Action on Xen start'),
1008                       ('on_xend_stop', 'Action on Xen stop'),
1009                       ('bootloader', 'Bootloader options'),
1010                       ]
1011     fields = []
1012     machine_info = {}
1013     machine_info['name'] = machine.name
1014     machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
1015     machine_info['owner'] = machine.owner
1016     machine_info['administrator'] = machine.administrator
1017     machine_info['contact'] = machine.contact
1018
1019     nic_fields = getNicInfo(machine_info, machine)
1020     nic_point = display_fields.index('NIC_INFO')
1021     display_fields = (display_fields[:nic_point] + nic_fields + 
1022                       display_fields[nic_point+1:])
1023
1024     disk_fields = getDiskInfo(machine_info, machine)
1025     disk_point = display_fields.index('DISK_INFO')
1026     display_fields = (display_fields[:disk_point] + disk_fields + 
1027                       display_fields[disk_point+1:])
1028     
1029     main_status['memory'] += ' MB'
1030     for field, disp in display_fields:
1031         if field in ('uptime', 'cputime') and locals()[field] is not None:
1032             fields.append((disp, locals()[field]))
1033         elif field in machine_info:
1034             fields.append((disp, machine_info[field]))
1035         elif field in main_status:
1036             fields.append((disp, main_status[field]))
1037         else:
1038             pass
1039             #fields.append((disp, None))
1040     max_mem = maxMemory(user, machine)
1041     max_disk = maxDisk(user, machine)
1042     defaults=Defaults()
1043     for name in 'machine_id name administrator owner memory contact'.split():
1044         setattr(defaults, name, getattr(machine, name))
1045     if machine.nics:
1046         defaults.hostname = machine.nics[0].hostname
1047     defaults.disk = "%0.2f" % (machine.disks[0].size/1024.)
1048     d = dict(user=user,
1049              cdroms=CDROM.select(),
1050              on=status is not None,
1051              machine=machine,
1052              defaults=defaults,
1053              has_vnc=has_vnc,
1054              uptime=str(uptime),
1055              ram=machine.memory,
1056              max_mem=max_mem,
1057              max_disk=max_disk,
1058              owner_help=helppopup("owner"),
1059              fields = fields)
1060     return d
1061
1062 def info(user, fields):
1063     """Handler for info on a single VM."""
1064     machine = testMachineId(user, fields.getfirst('machine_id'))
1065     d = infoDict(user, machine)
1066     return Template(file='info.tmpl', searchList=[d])
1067
1068 mapping = dict(list=listVms,
1069                vnc=vnc,
1070                command=command,
1071                modify=modify,
1072                info=info,
1073                create=create,
1074                help=helpHandler)
1075
1076 def printHeaders(headers):
1077     for key, value in headers.iteritems():
1078         print '%s: %s' % (key, value)
1079     print
1080
1081
1082 def getUser():
1083     """Return the current user based on the SSL environment variables"""
1084     if 'SSL_CLIENT_S_DN_Email' in os.environ:
1085         username = os.environ['SSL_CLIENT_S_DN_Email'].split("@")[0]
1086         return User(username, os.environ['SSL_CLIENT_S_DN_Email'])
1087     else:
1088         return User('moo', 'nobody')
1089
1090 if __name__ == '__main__':
1091     start_time = time.time()
1092     fields = cgi.FieldStorage()
1093     u = getUser()
1094     g = Global(u)
1095     operation = os.environ.get('PATH_INFO', '')
1096     if not operation:
1097         print "Status: 301 Moved Permanently"
1098         print 'Location: ' + os.environ['SCRIPT_NAME']+'/\n'
1099         sys.exit(0)
1100
1101     if operation.startswith('/'):
1102         operation = operation[1:]
1103     if not operation:
1104         operation = 'list'
1105
1106
1107
1108     fun = mapping.get(operation, badOperation)
1109
1110     if fun not in (helpHandler, ):
1111         connect('postgres://sipb-xen@sipb-xen-dev.mit.edu/sipb_xen')
1112     try:
1113         output = fun(u, fields)
1114
1115         headers = dict(default_headers)
1116         if isinstance(output, tuple):
1117             new_headers, output = output
1118             headers.update(new_headers)
1119
1120         e = revertStandardError()
1121         if e:
1122             output.addError(e)
1123         printHeaders(headers)
1124         print output
1125     except Exception, err:
1126         if not fields.has_key('js'):
1127             if isinstance(err, CodeError):
1128                 print 'Content-Type: text/html\n'
1129                 e = revertStandardError()
1130                 print error(operation, u, fields, err, e)
1131                 sys.exit(1)
1132             if isinstance(err, InvalidInput):
1133                 print 'Content-Type: text/html\n'
1134                 e = revertStandardError()
1135                 print invalidInput(operation, u, fields, err, e)
1136                 sys.exit(1)
1137         print 'Content-Type: text/plain\n'
1138         print 'Uh-oh!  We experienced an error.'
1139         print 'Please email sipb-xen@mit.edu with the contents of this page.'
1140         print '----'
1141         e = revertStandardError()
1142         print e
1143         print '----'
1144         raise