More updates.
[invirt/packages/invirt-web.git] / templates / main.py
1 #!/usr/bin/python
2
3 import sys
4 import cgi
5 import os
6 import string
7 import subprocess
8 import re
9 import time
10 import cPickle
11 import base64
12 import sha
13 import hmac
14 import datetime
15
16 sys.stderr = sys.stdout
17 sys.path.append('/home/ecprice/.local/lib/python2.5/site-packages')
18
19 from Cheetah.Template import Template
20 from sipb_xen_database import *
21 import random
22
23 class MyException(Exception):
24     pass
25
26 def helppopup(subj):
27     return '<span class="helplink"><a href="help?subject='+subj+'&amp;simple=true" target="_blank" onclick="return helppopup(\''+subj+'\')">(?)</a></span>'
28
29
30 global_dict = {}
31 global_dict['helppopup'] = helppopup
32
33
34 # ... and stolen from xend/uuid.py
35 def randomUUID():
36     """Generate a random UUID."""
37
38     return [ random.randint(0, 255) for _ in range(0, 16) ]
39
40 def uuidToString(u):
41     return "-".join(["%02x" * 4, "%02x" * 2, "%02x" * 2, "%02x" * 2,
42                      "%02x" * 6]) % tuple(u)
43
44 MAX_MEMORY_TOTAL = 512
45 MAX_MEMORY_SINGLE = 256
46 MIN_MEMORY_SINGLE = 16
47 MAX_DISK_TOTAL = 50
48 MAX_DISK_SINGLE = 50
49 MIN_DISK_SINGLE = 0.1
50 MAX_VMS_TOTAL = 10
51 MAX_VMS_ACTIVE = 4
52
53 def getMachinesOwner(owner):
54     return Machine.select_by(owner=owner)
55
56 def maxMemory(user, machine=None, on=None):
57     machines = getMachinesOwner(user.username)
58     if on is None:
59         on = getUptimes(machines)
60     active_machines = [x for x in machines if on[x]]
61     mem_usage = sum([x.memory for x in active_machines if x != machine])
62     return min(MAX_MEMORY_SINGLE, MAX_MEMORY_TOTAL-mem_usage)
63
64 def maxDisk(user, machine=None):
65     machines = getMachinesOwner(user.username)
66     disk_usage = sum([sum([y.size for y in x.disks])
67                       for x in machines if x != machine])
68     return min(MAX_DISK_SINGLE, MAX_DISK_TOTAL-disk_usage/1024.)
69
70 def canAddVm(user, on=None):
71     machines = getMachinesOwner(user.username)
72     if on is None:
73         on = getUptimes(machines)
74     active_machines = [x for x in machines if on[x]]
75     return (len(machines) < MAX_VMS_TOTAL and
76             len(active_machines) < MAX_VMS_ACTIVE)
77
78 def haveAccess(user, machine):
79     if user.username == 'moo':
80         return True
81     return machine.owner == user.username
82
83 def error(op, user, fields, err):
84     d = dict(op=op, user=user, errorMessage=str(err))
85     print Template(file='error.tmpl', searchList=[d, global_dict]);
86
87 def validMachineName(name):
88     """Check that name is valid for a machine name"""
89     if not name:
90         return False
91     charset = string.ascii_letters + string.digits + '-_'
92     if name[0] in '-_' or len(name) > 22:
93         return False
94     for x in name:
95         if x not in charset:
96             return False
97     return True
98
99 def kinit(username = 'tabbott/extra', keytab = '/etc/tabbott.keytab'):
100     """Kinit with a given username and keytab"""
101
102     p = subprocess.Popen(['kinit', "-k", "-t", keytab, username],
103                          stderr=subprocess.PIPE)
104     e = p.wait()
105     if e:
106         raise MyException("Error %s in kinit: %s" % (e, p.stderr.read()))
107
108 def checkKinit():
109     """If we lack tickets, kinit."""
110     p = subprocess.Popen(['klist', '-s'])
111     if p.wait():
112         kinit()
113
114 def remctl(*args, **kws):
115     """Perform a remctl and return the output.
116
117     kinits if necessary, and outputs errors to stderr.
118     """
119     checkKinit()
120     p = subprocess.Popen(['remctl', 'black-mesa.mit.edu']
121                          + list(args),
122                          stdout=subprocess.PIPE,
123                          stderr=subprocess.PIPE)
124     if kws.get('err'):
125         p.wait()
126         return p.stdout.read(), p.stderr.read()
127     if p.wait():
128         raise MyException('ERROR on remctl %s: %s' %
129                           (args, p.stderr.read()))
130     return p.stdout.read()
131
132 def makeDisks():
133     """Update the lvm partitions to include all disks in the database."""
134     remctl('web', 'lvcreate')
135
136 def bootMachine(machine, cdtype):
137     """Boot a machine with a given boot CD.
138
139     If cdtype is None, give no boot cd.  Otherwise, it is the string
140     id of the CD (e.g. 'gutsy_i386')
141     """
142     if cdtype is not None:
143         remctl('web', 'vmboot', machine.name,
144                cdtype)
145     else:
146         remctl('web', 'vmboot', machine.name)
147
148 def registerMachine(machine):
149     """Register a machine to be controlled by the web interface"""
150     remctl('web', 'register', machine.name)
151
152 def unregisterMachine(machine):
153     """Unregister a machine to not be controlled by the web interface"""
154     remctl('web', 'unregister', machine.name)
155
156 def parseStatus(s):
157     """Parse a status string into nested tuples of strings.
158
159     s = output of xm list --long <machine_name>
160     """
161     values = re.split('([()])', s)
162     stack = [[]]
163     for v in values[2:-2]: #remove initial and final '()'
164         if not v:
165             continue
166         v = v.strip()
167         if v == '(':
168             stack.append([])
169         elif v == ')':
170             if len(stack[-1]) == 1:
171                 stack[-1].append('')
172             stack[-2].append(stack[-1])
173             stack.pop()
174         else:
175             if not v:
176                 continue
177             stack[-1].extend(v.split())
178     return stack[-1]
179
180 def getUptimes(machines):
181     """Return a dictionary mapping machine names to uptime strings"""
182     value_string = remctl('web', 'listvms')
183     lines = value_string.splitlines()
184     d = {}
185     for line in lines[1:]:
186         lst = line.split()
187         name, id = lst[:2]
188         uptime = ' '.join(lst[2:])
189         d[name] = uptime
190     ans = {}
191     for m in machines:
192         ans[m] = d.get(m.name)
193     return ans
194
195 def statusInfo(machine):
196     """Return the status list for a given machine.
197
198     Gets and parses xm list --long
199     """
200     value_string, err_string = remctl('list-long', machine.name, err=True)
201     if 'Unknown command' in err_string:
202         raise MyException("ERROR in remctl list-long %s is not registered" % (machine.name,))
203     elif 'does not exist' in err_string:
204         return None
205     elif err_string:
206         raise MyException("ERROR in remctl list-long %s:  %s" % (machine.name, err_string))
207     status = parseStatus(value_string)
208     return status
209
210 def hasVnc(status):
211     """Does the machine with a given status list support VNC?"""
212     if status is None:
213         return False
214     for l in status:
215         if l[0] == 'device' and l[1][0] == 'vfb':
216             d = dict(l[1][1:])
217             return 'location' in d
218     return False
219
220 def createVm(user, name, memory, disk, is_hvm, cdrom):
221     """Create a VM and put it in the database"""
222     # put stuff in the table
223     transaction = ctx.current.create_transaction()
224     try:
225         if memory > maxMemory(user):
226             raise MyException("Too much memory requested")
227         if disk > maxDisk(user) * 1024:
228             raise MyException("Too much disk requested")
229         if not canAddVm(user):
230             raise MyException("Too many VMs requested")
231         res = meta.engine.execute('select nextval(\'"machines_machine_id_seq"\')')
232         id = res.fetchone()[0]
233         machine = Machine()
234         machine.machine_id = id
235         machine.name = name
236         machine.memory = memory
237         machine.owner = user.username
238         machine.contact = user.email
239         machine.uuid = uuidToString(randomUUID())
240         machine.boot_off_cd = True
241         machine_type = Type.get_by(hvm=is_hvm)
242         machine.type_id = machine_type.type_id
243         ctx.current.save(machine)
244         disk = Disk(machine.machine_id, 
245                     'hda', disk)
246         open = NIC.select_by(machine_id=None)
247         if not open: #No IPs left!
248             return "No IP addresses left!  Contact sipb-xen-dev@mit.edu"
249         nic = open[0]
250         nic.machine_id = machine.machine_id
251         nic.hostname = name
252         ctx.current.save(nic)    
253         ctx.current.save(disk)
254         transaction.commit()
255     except:
256         transaction.rollback()
257         raise
258     registerMachine(machine)
259     makeDisks()
260     # tell it to boot with cdrom
261     bootMachine(machine, cdrom)
262
263     return machine
264
265 def validMemory(user, memory, machine=None):
266     try:
267         memory = int(memory)
268         if memory < MIN_MEMORY_SINGLE:
269             raise ValueError
270     except ValueError:
271         raise MyException("Invalid memory amount; must be at least %s MB" %
272                           MIN_MEMORY_SINGLE)
273     if memory > maxMemory(user, machine):
274         raise MyException("Too much memory requested")
275     return memory
276
277 def validDisk(user, disk, machine=None):
278     try:
279         disk = float(disk)
280         if disk > maxDisk(user, machine):
281             raise MyException("Too much disk requested")
282         disk = int(disk * 1024)
283         if disk < MIN_DISK_SINGLE * 1024:
284             raise ValueError
285     except ValueError:
286         raise MyException("Invalid disk amount; minimum is %s GB" %
287                           MIN_DISK_SINGLE)
288     return disk
289
290 def create(user, fields):
291     name = fields.getfirst('name')
292     if not validMachineName(name):
293         raise MyException("Invalid name '%s'" % name)
294     name = user.username + '_' + name.lower()
295
296     if Machine.get_by(name=name):
297         raise MyException("A machine named '%s' already exists" % name)
298     
299     memory = fields.getfirst('memory')
300     memory = validMemory(user, memory)
301     
302     disk = fields.getfirst('disk')
303     disk = validDisk(user, disk)
304
305     vm_type = fields.getfirst('vmtype')
306     if vm_type not in ('hvm', 'paravm'):
307         raise MyException("Invalid vm type '%s'"  % vm_type)    
308     is_hvm = (vm_type == 'hvm')
309
310     cdrom = fields.getfirst('cdrom')
311     if cdrom is not None and not CDROM.get(cdrom):
312         raise MyException("Invalid cdrom type '%s'" % cdrom)    
313     
314     machine = createVm(user, name, memory, disk, is_hvm, cdrom)
315     if isinstance(machine, basestring):
316         raise MyException(machine)
317     d = dict(user=user,
318              machine=machine)
319     print Template(file='create.tmpl',
320                    searchList=[d, global_dict]);
321
322 def listVms(user, fields):
323     machines = [m for m in Machine.select() if haveAccess(user, m)]    
324     on = {}
325     has_vnc = {}
326     uptimes = getUptimes(machines)
327     on = uptimes
328     for m in machines:
329         if not on[m]:
330             has_vnc[m] = 'Off'
331         elif m.type.hvm:
332             has_vnc[m] = True
333         else:
334             has_vnc[m] = "ParaVM"+helppopup("paravm_console")
335     #     for m in machines:
336     #         status = statusInfo(m)
337     #         on[m.name] = status is not None
338     #         has_vnc[m.name] = hasVnc(status)
339     max_mem=maxMemory(user, on=on)
340     max_disk=maxDisk(user)
341     d = dict(user=user,
342              can_add_vm=canAddVm(user, on=on),
343              max_mem=max_mem,
344              max_disk=max_disk,
345              default_mem=max_mem,
346              default_disk=min(4.0, max_disk),
347              machines=machines,
348              has_vnc=has_vnc,
349              uptimes=uptimes,
350              cdroms=CDROM.select())
351     print Template(file='list.tmpl', searchList=[d, global_dict])
352
353 def testMachineId(user, machineId, exists=True):
354     if machineId is None:
355         raise MyException("No machine ID specified")
356     try:
357         machineId = int(machineId)
358     except ValueError:
359         raise MyException("Invalid machine ID '%s'" % machineId)
360     machine = Machine.get(machineId)
361     if exists and machine is None:
362         raise MyException("No such machine ID '%s'" % machineId)
363     if not haveAccess(user, machine):
364         raise MyException("No access to machine ID '%s'" % machineId)
365     return machine
366
367 def vnc(user, fields):
368     """VNC applet page.
369
370     Note that due to same-domain restrictions, the applet connects to
371     the webserver, which needs to forward those requests to the xen
372     server.  The Xen server runs another proxy that (1) authenticates
373     and (2) finds the correct port for the VM.
374
375     You might want iptables like:
376
377     -t nat -A PREROUTING -s ! 18.181.0.60 -i eth1 -p tcp -m tcp --dport 10003 -j DNAT --to-destination 18.181.0.60:10003 
378     -t nat -A POSTROUTING -d 18.181.0.60 -o eth1 -p tcp -m tcp --dport 10003 -j SNAT --to-source 18.187.7.142 
379     -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp --dport 10003 -j ACCEPT
380     """
381     machine = testMachineId(user, fields.getfirst('machine_id'))
382     #XXX fix
383     
384     TOKEN_KEY = "0M6W0U1IXexThi5idy8mnkqPKEq1LtEnlK/pZSn0cDrN"
385
386     data = {}
387     data["user"] = user.username
388     data["machine"]=machine.name
389     data["expires"]=time.time()+(5*60)
390     pickledData = cPickle.dumps(data)
391     m = hmac.new(TOKEN_KEY, digestmod=sha)
392     m.update(pickledData)
393     token = {'data': pickledData, 'digest': m.digest()}
394     token = cPickle.dumps(token)
395     token = base64.urlsafe_b64encode(token)
396     
397     d = dict(user=user,
398              machine=machine,
399              hostname=os.environ.get('SERVER_NAME', 'localhost'),
400              authtoken=token)
401     print Template(file='vnc.tmpl',
402                    searchList=[d, global_dict])
403
404 def getNicInfo(data_dict, machine):
405     data_dict['num_nics'] = len(machine.nics)
406     nic_fields_template = [('nic%s_hostname', 'NIC %s hostname'),
407                            ('nic%s_mac', 'NIC %s MAC Addr'),
408                            ('nic%s_ip', 'NIC %s IP'),
409                            ]
410     nic_fields = []
411     for i in range(len(machine.nics)):
412         nic_fields.extend([(x % i, y % i) for x, y in nic_fields_template])
413         data_dict['nic%s_hostname' % i] = machine.nics[i].hostname + '.servers.csail.mit.edu'
414         data_dict['nic%s_mac' % i] = machine.nics[i].mac_addr
415         data_dict['nic%s_ip' % i] = machine.nics[i].ip
416     if len(machine.nics) == 1:
417         nic_fields = [(x, y.replace('NIC 0 ', '')) for x, y in nic_fields]
418     return nic_fields
419
420 def getDiskInfo(data_dict, machine):
421     data_dict['num_disks'] = len(machine.disks)
422     disk_fields_template = [('%s_size', '%s size')]
423     disk_fields = []
424     for disk in machine.disks:
425         name = disk.guest_device_name
426         disk_fields.extend([(x % name, y % name) for x, y in disk_fields_template])
427         data_dict['%s_size' % name] = "%0.1f GB" % (disk.size / 1024.)
428     return disk_fields
429
430 def deleteVM(machine):
431     transaction = ctx.current.create_transaction()
432     delete_disk_pairs = [(machine.name, d.guest_device_name) for d in machine.disks]
433     try:
434         for nic in machine.nics:
435             nic.machine_id = None
436             nic.hostname = None
437             ctx.current.save(nic)
438         for disk in machine.disks:
439             ctx.current.delete(disk)
440         ctx.current.delete(machine)
441         transaction.commit()
442     except:
443         transaction.rollback()
444         raise
445     for mname, dname in delete_disk_pairs:
446         remctl('web', 'lvremove', mname, dname)
447     unregisterMachine(machine)
448
449 def command(user, fields):
450     print time.time()-start_time
451     machine = testMachineId(user, fields.getfirst('machine_id'))
452     action = fields.getfirst('action')
453     cdrom = fields.getfirst('cdrom')
454     print time.time()-start_time
455     if cdrom is not None and not CDROM.get(cdrom):
456         raise MyException("Invalid cdrom type '%s'" % cdrom)    
457     if action not in ('Reboot', 'Power on', 'Power off', 'Shutdown', 'Delete VM'):
458         raise MyException("Invalid action '%s'" % action)
459     if action == 'Reboot':
460         if cdrom is not None:
461             remctl('reboot', machine.name, cdrom)
462         else:
463             remctl('reboot', machine.name)
464     elif action == 'Power on':
465         if maxMemory(user) < machine.memory:
466             raise MyException("You don't have enough free RAM quota")
467         bootMachine(machine, cdrom)
468     elif action == 'Power off':
469         remctl('destroy', machine.name)
470     elif action == 'Shutdown':
471         remctl('shutdown', machine.name)
472     elif action == 'Delete VM':
473         deleteVM(machine)
474     print time.time()-start_time
475
476     d = dict(user=user,
477              command=action,
478              machine=machine)
479     print Template(file="command.tmpl", searchList=[d, global_dict])
480         
481 def modify(user, fields):
482     machine = testMachineId(user, fields.getfirst('machine_id'))
483     
484 def help(user, fields):
485     simple = fields.getfirst('simple')
486     subjects = fields.getlist('subject')
487     
488     mapping = dict(paravm_console="""
489 ParaVM machines do not support console access over VNC.  To access
490 these machines, you either need to boot with a liveCD and ssh in or
491 hope that the sipb-xen maintainers add support for serial consoles.""",
492                    hvm_paravm="""
493 HVM machines use the virtualization features of the processor, while
494 ParaVM machines use Xen's emulation of virtualization features.  You
495 want an HVM virtualized machine.""",
496                    cpu_weight="""Don't ask us!  We're as mystified as you are.""")
497     
498     d = dict(user=user,
499              simple=simple,
500              subjects=subjects,
501              mapping=mapping)
502     
503     print Template(file="help.tmpl", searchList=[d, global_dict])
504     
505
506 def info(user, fields):
507     machine = testMachineId(user, fields.getfirst('machine_id'))
508     status = statusInfo(machine)
509     has_vnc = hasVnc(status)
510     if status is None:
511         main_status = dict(name=machine.name,
512                            memory=str(machine.memory))
513     else:
514         main_status = dict(status[1:])
515     start_time = float(main_status.get('start_time', 0))
516     uptime = datetime.timedelta(seconds=int(time.time()-start_time))
517     cpu_time_float = float(main_status.get('cpu_time', 0))
518     cputime = datetime.timedelta(seconds=int(cpu_time_float))
519     display_fields = """name uptime memory state cpu_weight on_reboot 
520      on_poweroff on_crash on_xend_start on_xend_stop bootloader""".split()
521     display_fields = [('name', 'Name'),
522                       ('owner', 'Owner'),
523                       ('contact', 'Contact'),
524                       ('type', 'Type'),
525                       'NIC_INFO',
526                       ('uptime', 'uptime'),
527                       ('cputime', 'CPU usage'),
528                       ('memory', 'RAM'),
529                       'DISK_INFO',
530                       ('state', 'state (xen format)'),
531                       ('cpu_weight', 'CPU weight'+helppopup('cpu_weight')),
532                       ('on_reboot', 'Action on VM reboot'),
533                       ('on_poweroff', 'Action on VM poweroff'),
534                       ('on_crash', 'Action on VM crash'),
535                       ('on_xend_start', 'Action on Xen start'),
536                       ('on_xend_stop', 'Action on Xen stop'),
537                       ('bootloader', 'Bootloader options'),
538                       ]
539     fields = []
540     machine_info = {}
541     machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
542     machine_info['owner'] = machine.owner
543     machine_info['contact'] = machine.contact
544
545     nic_fields = getNicInfo(machine_info, machine)
546     nic_point = display_fields.index('NIC_INFO')
547     display_fields = display_fields[:nic_point] + nic_fields + display_fields[nic_point+1:]
548
549     disk_fields = getDiskInfo(machine_info, machine)
550     disk_point = display_fields.index('DISK_INFO')
551     display_fields = display_fields[:disk_point] + disk_fields + display_fields[disk_point+1:]
552     
553     main_status['memory'] += ' MB'
554     for field, disp in display_fields:
555         if field in ('uptime', 'cputime'):
556             fields.append((disp, locals()[field]))
557         elif field in main_status:
558             fields.append((disp, main_status[field]))
559         elif field in machine_info:
560             fields.append((disp, machine_info[field]))
561         else:
562             pass
563             #fields.append((disp, None))
564     max_mem = maxMemory(user, machine)
565     max_disk = maxDisk(user, machine)
566     d = dict(user=user,
567              cdroms=CDROM.select(),
568              on=status is not None,
569              machine=machine,
570              has_vnc=has_vnc,
571              uptime=str(uptime),
572              ram=machine.memory,
573              max_mem=max_mem,
574              max_disk=max_disk,
575              fields = fields)
576     print Template(file='info.tmpl',
577                    searchList=[d, global_dict])
578
579 mapping = dict(list=listVms,
580                vnc=vnc,
581                command=command,
582                modify=modify,
583                info=info,
584                create=create,
585                help=help)
586
587 if __name__ == '__main__':
588     start_time = time.time()
589     fields = cgi.FieldStorage()
590     class User:
591         username = "moo"
592         email = 'moo@cow.com'
593     u = User()
594     if 'SSL_CLIENT_S_DN_Email' in os.environ:
595         username = os.environ[ 'SSL_CLIENT_S_DN_Email'].split("@")[0]
596         u.username = username
597         u.email = os.environ[ 'SSL_CLIENT_S_DN_Email']
598     else:
599         u.username = 'moo'
600         u.email = 'nobody'
601     connect('postgres://sipb-xen@sipb-xen-dev/sipb_xen')
602     operation = os.environ.get('PATH_INFO', '')
603     #print 'Content-Type: text/plain\n'
604     #print operation
605     if not operation:
606         print "Status: 301 Moved Permanently"
607         print 'Location: ' + os.environ['SCRIPT_NAME']+'/\n'
608         sys.exit(0)
609     print 'Content-Type: text/html\n'
610
611     if operation.startswith('/'):
612         operation = operation[1:]
613     if not operation:
614         operation = 'list'
615     
616     fun = mapping.get(operation, 
617                       lambda u, e:
618                           error(operation, u, e,
619                                 "Invalid operation '%s'" % operation))
620     if fun not in (help, ):
621         connect('postgres://sipb-xen@sipb-xen-dev/sipb_xen')
622     try:
623         fun(u, fields)
624     except MyException, err:
625         error(operation, u, fields, err)