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