Use RESTful URLs for commands
[invirt/packages/invirt-web.git] / code / main.py
index 2875c16..d938450 100755 (executable)
@@ -12,8 +12,9 @@ import simplejson
 import sys
 import time
 import urllib
+import socket
+import cherrypy
 from StringIO import StringIO
-
 def revertStandardError():
     """Move stderr to stdout, and return the contents of the old stderr."""
     errio = sys.stderr
@@ -44,6 +45,178 @@ from invirt.database import Machine, CDROM, session, connect, MachineAccess, Typ
 from invirt.config import structs as config
 from invirt.common import InvalidInput, CodeError
 
+from view import View
+
+class InvirtWeb(View):
+    def __init__(self):
+        super(self.__class__,self).__init__()
+        connect()
+        self._cp_config['tools.require_login.on'] = True
+        self._cp_config['tools.mako.imports'] = ['from invirt.config import structs as config',
+                                                 'from invirt import database']
+
+
+    @cherrypy.expose
+    @cherrypy.tools.mako(filename="/list.mako")
+    def list(self):
+        """Handler for list requests."""
+        checkpoint.checkpoint('Getting list dict')
+        d = getListDict(cherrypy.request.login, cherrypy.request.state)
+        checkpoint.checkpoint('Got list dict')
+        return d
+    index=list
+
+    @cherrypy.expose
+    @cherrypy.tools.mako(filename="/help.mako")
+    def help(self, subject=None, simple=False):
+        """Handler for help messages."""
+
+        help_mapping = {
+            'Autoinstalls': """
+The autoinstaller builds a minimal Debian or Ubuntu system to run as a
+ParaVM.  You can access the resulting system by logging into the <a
+href="help?simple=true&subject=ParaVM+Console">serial console server</a>
+with your Kerberos tickets; there is no root password so sshd will
+refuse login.</p>
+
+<p>Under the covers, the autoinstaller uses our own patched version of
+xen-create-image, which is a tool based on debootstrap.  If you log
+into the serial console while the install is running, you can watch
+it.
+""",
+            'ParaVM Console': """
+ParaVM machines do not support local console access over VNC.  To
+access the serial console of these machines, you can SSH with Kerberos
+to %s, using the name of the machine as your
+username.""" % config.console.hostname,
+            'HVM/ParaVM': """
+HVM machines use the virtualization features of the processor, while
+ParaVM machines rely on a modified kernel to communicate directly with
+the hypervisor.  HVMs support boot CDs of any operating system, and
+the VNC console applet.  The three-minute autoinstaller produces
+ParaVMs.  ParaVMs typically are more efficient, and always support the
+<a href="help?subject=ParaVM+Console">console server</a>.</p>
+
+<p>More details are <a
+href="https://xvm.scripts.mit.edu/wiki/Paravirtualization">on the
+wiki</a>, including steps to prepare an HVM guest to boot as a ParaVM
+(which you can skip by using the autoinstaller to begin with.)</p>
+
+<p>We recommend using a ParaVM when possible and an HVM when necessary.
+""",
+            'CPU Weight': """
+Don't ask us!  We're as mystified as you are.""",
+            'Owner': """
+The owner field is used to determine <a
+href="help?subject=Quotas">quotas</a>.  It must be the name of a
+locker that you are an AFS administrator of.  In particular, you or an
+AFS group you are a member of must have AFS rlidwka bits on the
+locker.  You can check who administers the LOCKER locker using the
+commands 'attach LOCKER; fs la /mit/LOCKER' on Athena.)  See also <a
+href="help?subject=Administrator">administrator</a>.""",
+            'Administrator': """
+The administrator field determines who can access the console and
+power on and off the machine.  This can be either a user or a moira
+group.""",
+            'Quotas': """
+Quotas are determined on a per-locker basis.  Each locker may have a
+maximum of 512 mebibytes of active ram, 50 gibibytes of disk, and 4
+active machines.""",
+            'Console': """
+<strong>Framebuffer:</strong> At a Linux boot prompt in your VM, try
+setting <tt>fb=false</tt> to disable the framebuffer.  If you don't,
+your machine will run just fine, but the applet's display of the
+console will suffer artifacts.
+""",
+            'Windows': """
+<strong>Windows Vista:</strong> The Vista image is licensed for all MIT students and will automatically activate off the network; see <a href="/static/msca-email.txt">the licensing confirmation e-mail</a> for details. The installer requires 512 MiB RAM and at least 7.5 GiB disk space (15 GiB or more recommended).<br>
+<strong>Windows XP:</strong> This is the volume license CD image. You will need your own volume license key to complete the install. We do not have these available for the general MIT community; ask your department if they have one.
+"""
+            }
+
+        if not subject:
+            subject = sorted(help_mapping.keys())
+        if not isinstance(subject, list):
+            subject = [subject]
+
+        return dict(simple=simple,
+                    subjects=subject,
+                    mapping=help_mapping)
+    help._cp_config['tools.require_login.on'] = False
+
+    @cherrypy.expose
+    @cherrypy.tools.mako(filename="/helloworld.mako")
+    def helloworld(self, **kwargs):
+        return {'request': cherrypy.request, 'kwargs': kwargs}
+    helloworld._cp_config['tools.require_login.on'] = False
+
+    class MachineView(View):
+        # This is hairy. Fix when CherryPy 3.2 is out. (rename to
+        # _cp_dispatch, and parse the argument as a list instead of
+        # string
+
+        def __getattr__(self, name):
+            try:
+                machine_id = int(name)
+                cherrypy.request.params['machine_id'] = machine_id
+                return self
+            except ValueError:
+                return None
+
+        @cherrypy.expose
+        @cherrypy.tools.mako(filename="/info.mako")
+        def info(self, machine_id):
+            """Handler for info on a single VM."""
+            machine = validation.Validate(cherrypy.request.login, cherrypy.request.state, machine_id=machine_id).machine
+            d = infoDict(cherrypy.request.login, cherrypy.request.state, machine)
+            checkpoint.checkpoint('Got infodict')
+            return d
+        index = info
+
+        @cherrypy.expose
+        @cherrypy.tools.mako(filename="/vnc.mako")
+        def vnc(self, machine_id):
+            """VNC applet page.
+
+            Note that due to same-domain restrictions, the applet connects to
+            the webserver, which needs to forward those requests to the xen
+            server.  The Xen server runs another proxy that (1) authenticates
+            and (2) finds the correct port for the VM.
+
+            You might want iptables like:
+
+            -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
+            -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
+            -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp \
+            --dport 10003 -j ACCEPT
+
+            Remember to enable iptables!
+            echo 1 > /proc/sys/net/ipv4/ip_forward
+            """
+            machine = validation.Validate(cherrypy.request.login, cherrypy.request.state, machine_id=machine_id).machine
+
+            token = controls.vnctoken(machine)
+            host = controls.listHost(machine)
+            if host:
+                port = 10003 + [h.hostname for h in config.hosts].index(host)
+            else:
+                port = 5900 # dummy
+
+            status = controls.statusInfo(machine)
+            has_vnc = hasVnc(status)
+
+            d = dict(on=status,
+                     has_vnc=has_vnc,
+                     machine=machine,
+                     hostname=cherrypy.request.local.name,
+                     port=port,
+                     authtoken=token)
+            return d
+
+    machine = MachineView()
+
 def pathSplit(path):
     if path.startswith('/'):
         path = path[1:]
@@ -67,16 +240,6 @@ class Checkpoint:
 
 checkpoint = Checkpoint()
 
-def jquote(string):
-    return "'" + string.replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n') + "'"
-
-def helppopup(subj):
-    """Return HTML code for a (?) link to a specified help topic"""
-    return ('<span class="helplink"><a href="help?' +
-            cgi.escape(urllib.urlencode(dict(subject=subj, simple='true')))
-            +'" target="_blank" ' +
-            'onclick="return helppopup(' + cgi.escape(jquote(subj)) + ')">(?)</a></span>')
-
 def makeErrorPre(old, addition):
     if addition is None:
         return
@@ -87,7 +250,6 @@ def makeErrorPre(old, addition):
 
 Template.database = database
 Template.config = config
-Template.helppopup = staticmethod(helppopup)
 Template.err = None
 
 class JsonDict:
@@ -121,7 +283,7 @@ class Defaults:
         if max_memory is not None:
             self.memory = min(self.memory, max_memory)
         if max_disk is not None:
-            self.max_disk = min(self.disk, max_disk)
+            self.disk = min(self.disk, max_disk)
         for key in kws:
             setattr(self, key, kws[key])
 
@@ -150,7 +312,7 @@ def parseCreate(username, state, fields):
     kws = dict([(kw, fields.getfirst(kw)) for kw in 'name description owner memory disksize vmtype cdrom autoinstall'.split()])
     validate = validation.Validate(username, state, strict=True, **kws)
     return dict(contact=username, name=validate.name, description=validate.description, memory=validate.memory,
-                disksize=validate.disksize, owner=validate.owner, machine_type=validate.vmtype,
+                disksize=validate.disksize, owner=validate.owner, machine_type=getattr(validate, 'vmtype', Defaults.type),
                 cdrom=getattr(validate, 'cdrom', None),
                 autoinstall=getattr(validate, 'autoinstall', None))
 
@@ -195,14 +357,13 @@ def getListDict(username, state):
             elif m.type.hvm:
                 has_vnc[m] = "WTF?"
             else:
-                has_vnc[m] = "ParaVM"+helppopup("ParaVM Console")
+                has_vnc[m] = "ParaVM"
     max_memory = validation.maxMemory(username, state)
     max_disk = validation.maxDisk(username)
     checkpoint.checkpoint('Got max mem/disk')
     defaults = Defaults(max_memory=max_memory,
                         max_disk=max_disk,
-                        owner=username,
-                        cdrom='gutsy-i386')
+                        owner=username)
     checkpoint.checkpoint('Got defaults')
     def sortkey(machine):
         return (machine.owner != username, machine.owner, machine.name)
@@ -217,66 +378,21 @@ def getListDict(username, state):
              can_clone=can_clone)
     return d
 
-def listVms(username, state, path, fields):
-    """Handler for list requests."""
-    checkpoint.checkpoint('Getting list dict')
-    d = getListDict(username, state)
-    checkpoint.checkpoint('Got list dict')
-    return templates.list(searchList=[d])
-
-def vnc(username, state, path, fields):
-    """VNC applet page.
-
-    Note that due to same-domain restrictions, the applet connects to
-    the webserver, which needs to forward those requests to the xen
-    server.  The Xen server runs another proxy that (1) authenticates
-    and (2) finds the correct port for the VM.
-
-    You might want iptables like:
-
-    -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
-    -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
-    -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp \
-      --dport 10003 -j ACCEPT
-
-    Remember to enable iptables!
-    echo 1 > /proc/sys/net/ipv4/ip_forward
-    """
-    machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
-
-    token = controls.vnctoken(machine)
-    host = controls.listHost(machine)
-    if host:
-        port = 10003 + [h.hostname for h in config.hosts].index(host)
-    else:
-        port = 5900 # dummy
-
-    status = controls.statusInfo(machine)
-    has_vnc = hasVnc(status)
-
-    d = dict(user=username,
-             on=status,
-             has_vnc=has_vnc,
-             machine=machine,
-             hostname=state.environ.get('SERVER_NAME', 'localhost'),
-             port=port,
-             authtoken=token)
-    return templates.vnc(searchList=[d])
-
 def getHostname(nic):
     """Find the hostname associated with a NIC.
 
     XXX this should be merged with the similar logic in DNS and DHCP.
     """
-    if nic.hostname and '.' in nic.hostname:
-        return nic.hostname
+    if nic.hostname:
+        hostname = nic.hostname
     elif nic.machine:
-        return nic.machine.name + '.' + config.dns.domains[0]
+        hostname = nic.machine.name
     else:
         return None
-
+    if '.' in hostname:
+        return hostname
+    else:
+        return hostname + '.' + config.dns.domains[0]
 
 def getNicInfo(data_dict, machine):
     """Helper function for info, get data on nics for a machine.
@@ -292,8 +408,7 @@ def getNicInfo(data_dict, machine):
     nic_fields = []
     for i in range(len(machine.nics)):
         nic_fields.extend([(x % i, y % i) for x, y in nic_fields_template])
-        if not i:
-            data_dict['nic%s_hostname' % i] = getHostname(machine.nics[i])
+        data_dict['nic%s_hostname' % i] = getHostname(machine.nics[i])
         data_dict['nic%s_mac' % i] = machine.nics[i].mac_addr
         data_dict['nic%s_ip' % i] = machine.nics[i].ip
     if len(machine.nics) == 1:
@@ -378,6 +493,9 @@ def modifyDict(username, state, fields):
             update_acl = True
         if hasattr(validate, 'name'):
             machine.name = validate.name
+            for n in machine.nics:
+                if n.hostname == oldname:
+                    n.hostname = validate.name
         if hasattr(validate, 'description'):
             machine.description = validate.description
         if hasattr(validate, 'admin') and validate.admin != machine.administrator:
@@ -420,73 +538,6 @@ def modify(username, state, path, fields):
     info_dict['result'] = result
     return templates.info(searchList=[info_dict])
 
-
-def helpHandler(username, state, path, fields):
-    """Handler for help messages."""
-    simple = fields.getfirst('simple')
-    subjects = fields.getlist('subject')
-
-    help_mapping = {
-                    'Autoinstalls': """
-The autoinstaller builds a minimal Debian or Ubuntu system to run as a
-ParaVM.  You can access the resulting system by logging into the <a
-href="help?simple=true&subject=ParaVM+Console">serial console server</a>
-with your Kerberos tickets; there is no root password and no sshd.</p>
-
-<p>Under the covers the autoinstaller uses our own patched version of
-xen-create-image, which is a tool based on debootstrap.  If you're
-quick in logging into the serial console, you can see it running.
-""",
-                    'ParaVM Console': """
-ParaVM machines do not support local console access over VNC.  To
-access the serial console of these machines, you can SSH with Kerberos
-to %s, using the name of the machine as your
-username.""" % config.console.hostname,
-                    'HVM/ParaVM': """
-HVM machines use the virtualization features of the processor, while
-ParaVM machines use Xen's emulation of virtualization features.  You
-want an HVM virtualized machine.""",
-                    'CPU Weight': """
-Don't ask us!  We're as mystified as you are.""",
-                    'Owner': """
-The owner field is used to determine <a
-href="help?subject=Quotas">quotas</a>.  It must be the name of a
-locker that you are an AFS administrator of.  In particular, you or an
-AFS group you are a member of must have AFS rlidwka bits on the
-locker.  You can check who administers the LOCKER locker using the
-commands 'attach LOCKER; fs la /mit/LOCKER' on Athena.)  See also <a
-href="help?subject=Administrator">administrator</a>.""",
-                    'Administrator': """
-The administrator field determines who can access the console and
-power on and off the machine.  This can be either a user or a moira
-group.""",
-                    'Quotas': """
-Quotas are determined on a per-locker basis.  Each locker may have a
-maximum of 512 megabytes of active ram, 50 gigabytes of disk, and 4
-active machines.""",
-                    'Console': """
-<strong>Framebuffer:</strong> At a Linux boot prompt in your VM, try
-setting <tt>fb=false</tt> to disable the framebuffer.  If you don't,
-your machine will run just fine, but the applet's display of the
-console will suffer artifacts.
-""",
-                    'Windows': """
-<strong>Windows Vista:</strong> The Vista image is licensed for all MIT students and will automatically activate off the network; see <a href="/static/msca-email.txt">the licensing confirmation e-mail</a> for details. The installer req    uires 512 MB RAM and at least 7.5 GB disk space (15 GB or more recommended).<br>
-<strong>Windows XP:</strong> This is the volume license CD image. You will need your own volume license key to complete the install. We do not have these available for the general MIT community; ask your department if they have one.
-"""
-                    }
-
-    if not subjects:
-        subjects = sorted(help_mapping.keys())
-
-    d = dict(user=username,
-             simple=simple,
-             subjects=subjects,
-             mapping=help_mapping)
-
-    return templates.help(searchList=[d])
-
-
 def badOperation(u, s, p, e):
     """Function called when accessing an unknown URI."""
     return ({'Status': '404 Not Found'}, 'Invalid operation.')
@@ -509,8 +560,6 @@ def infoDict(username, state, machine):
         cpu_time_float = float(main_status.get('cpu_time', 0))
         cputime = datetime.timedelta(seconds=int(cpu_time_float))
     checkpoint.checkpoint('Status')
-    display_fields = """name uptime memory state cpu_weight on_reboot 
-     on_poweroff on_crash on_xend_start on_xend_stop bootloader""".split()
     display_fields = [('name', 'Name'),
                       ('description', 'Description'),
                       ('owner', 'Owner'),
@@ -524,13 +573,6 @@ def infoDict(username, state, machine):
                       ('memory', 'RAM'),
                       'DISK_INFO',
                       ('state', 'state (xen format)'),
-                      ('cpu_weight', 'CPU weight'+helppopup('CPU Weight')),
-                      ('on_reboot', 'Action on VM reboot'),
-                      ('on_poweroff', 'Action on VM poweroff'),
-                      ('on_crash', 'Action on VM crash'),
-                      ('on_xend_start', 'Action on Xen start'),
-                      ('on_xend_stop', 'Action on Xen stop'),
-                      ('bootloader', 'Bootloader options'),
                       ]
     fields = []
     machine_info = {}
@@ -584,29 +626,22 @@ def infoDict(username, state, machine):
              ram=machine.memory,
              max_mem=max_mem,
              max_disk=max_disk,
-             owner_help=helppopup("Owner"),
              fields = fields)
     return d
 
-def info(username, state, path, fields):
-    """Handler for info on a single VM."""
-    machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
-    d = infoDict(username, state, machine)
-    checkpoint.checkpoint('Got infodict')
-    return templates.info(searchList=[d])
-
 def unauthFront(_, _2, _3, fields):
     """Information for unauth'd users."""
-    return templates.unauth(searchList=[{'simple' : True}])
+    return templates.unauth(searchList=[{'simple' : True, 
+            'hostname' : socket.getfqdn()}])
 
 def admin(username, state, path, fields):
     if path == '':
         return ({'Status': '303 See Other',
                  'Location': 'admin/'},
                 "You shouldn't see this message.")
-    if not username in getAfsGroupMembers(config.web.adminacl, 'athena.mit.edu'):
+    if not username in getAfsGroupMembers(config.adminacl, 'athena.mit.edu'):
         raise InvalidInput('username', username,
-                           'Not in admin group %s.' % config.web.adminacl)
+                           'Not in admin group %s.' % config.adminacl)
     newstate = State(username, isadmin=True)
     newstate.environ = state.environ
     return handler(username, newstate, path, fields)
@@ -615,13 +650,10 @@ def throwError(_, __, ___, ____):
     """Throw an error, to test the error-tracing mechanisms."""
     raise RuntimeError("test of the emergency broadcast system")
 
-mapping = dict(list=listVms,
-               vnc=vnc,
+mapping = dict(
                command=command,
                modify=modify,
-               info=info,
                create=create,
-               help=helpHandler,
                unauth=unauthFront,
                admin=admin,
                overlord=admin,
@@ -643,7 +675,8 @@ Subject: %s
 
 %s
 """ % (to, config.web.hostname, subject, body)
-    p = subprocess.Popen(['/usr/sbin/sendmail', to], stdin=subprocess.PIPE)
+    p = subprocess.Popen(['/usr/sbin/sendmail', '-f', to, to],
+                         stdin=subprocess.PIPE)
     p.stdin.write(mail)
     p.stdin.close()
     p.wait()
@@ -660,21 +693,6 @@ def show_error(op, username, fields, err, emsg, traceback):
     d['details'] = details
     return templates.error(searchList=[d])
 
-def getUser(environ):
-    """Return the current user based on the SSL environment variables"""
-    user = environ.get('REMOTE_USER')
-    if user is None:
-        return
-    
-    if environ.get('AUTH_TYPE') == 'Negotiate':
-        # Convert the krb5 principal into a krb4 username
-        if not user.endswith('@%s' % config.authn[0].realm):
-            return
-        else:
-            return user.split('@')[0].replace('/', '.')
-    else:
-        return user
-
 def handler(username, state, path, fields):
     operation, path = pathSplit(path)
     if not operation: