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