Initial checkin of new API implementation
authorMark Huang <mlhuang@cs.princeton.edu>
Wed, 6 Sep 2006 15:43:11 +0000 (15:43 +0000)
committerMark Huang <mlhuang@cs.princeton.edu>
Wed, 6 Sep 2006 15:43:11 +0000 (15:43 +0000)
63 files changed:
Makefile [new file with mode: 0644]
ModPython.py [new file with mode: 0644]
PLC/.cvsignore [new file with mode: 0644]
PLC/API.py [new file with mode: 0644]
PLC/AddressTypes.py [new file with mode: 0644]
PLC/Addresses.py [new file with mode: 0644]
PLC/Auth.py [new file with mode: 0644]
PLC/BootStates.py [new file with mode: 0644]
PLC/Config.py [new file with mode: 0644]
PLC/Debug.py [new file with mode: 0644]
PLC/Faults.py [new file with mode: 0644]
PLC/Keys.py [new file with mode: 0644]
PLC/Method.py [new file with mode: 0644]
PLC/Methods/.cvsignore [new file with mode: 0644]
PLC/Methods/AdmAddNode.py [new file with mode: 0644]
PLC/Methods/AdmAddPerson.py [new file with mode: 0644]
PLC/Methods/AdmAddPersonToSite.py [new file with mode: 0644]
PLC/Methods/AdmAddSite.py [new file with mode: 0644]
PLC/Methods/AdmAuthCheck.py [new file with mode: 0644]
PLC/Methods/AdmDeleteNode.py [new file with mode: 0644]
PLC/Methods/AdmDeletePerson.py [new file with mode: 0644]
PLC/Methods/AdmDeleteSite.py [new file with mode: 0644]
PLC/Methods/AdmGetAllRoles.py [new file with mode: 0644]
PLC/Methods/AdmGetNodes.py [new file with mode: 0644]
PLC/Methods/AdmGetPersonRoles.py [new file with mode: 0644]
PLC/Methods/AdmGetPersonSites.py [new file with mode: 0644]
PLC/Methods/AdmGetPersons.py [new file with mode: 0644]
PLC/Methods/AdmGetSites.py [new file with mode: 0644]
PLC/Methods/AdmGrantRoleToPerson.py [new file with mode: 0644]
PLC/Methods/AdmIsPersonInRole.py [new file with mode: 0644]
PLC/Methods/AdmRemovePersonFromSite.py [new file with mode: 0644]
PLC/Methods/AdmRevokeRoleFromPerson.py [new file with mode: 0644]
PLC/Methods/AdmSetPersonEnabled.py [new file with mode: 0644]
PLC/Methods/AdmSetPersonPrimarySite.py [new file with mode: 0644]
PLC/Methods/AdmUpdateNode.py [new file with mode: 0644]
PLC/Methods/AdmUpdatePerson.py [new file with mode: 0644]
PLC/Methods/__init__.py [new file with mode: 0644]
PLC/Methods/system/.cvsignore [new file with mode: 0644]
PLC/Methods/system/__init__.py [new file with mode: 0644]
PLC/Methods/system/listMethods.py [new file with mode: 0644]
PLC/Methods/system/methodHelp.py [new file with mode: 0644]
PLC/Methods/system/methodSignature.py [new file with mode: 0644]
PLC/Methods/system/multicall.py [new file with mode: 0644]
PLC/NodeGroups.py [new file with mode: 0644]
PLC/NodeNetworks.py [new file with mode: 0644]
PLC/Nodes.py [new file with mode: 0644]
PLC/PCUs.py [new file with mode: 0644]
PLC/Parameter.py [new file with mode: 0644]
PLC/Persons.py [new file with mode: 0644]
PLC/PostgreSQL.py [new file with mode: 0644]
PLC/Roles.py [new file with mode: 0644]
PLC/Sites.py [new file with mode: 0644]
PLC/Slices.py [new file with mode: 0644]
PLC/Table.py [new file with mode: 0644]
PLC/__init__.py [new file with mode: 0644]
PLC/md5crypt.py [new file with mode: 0644]
Server.py [new file with mode: 0755]
Shell.py [new file with mode: 0755]
TODO [new file with mode: 0644]
doc/.cvsignore [new file with mode: 0644]
doc/DocBook.py [new file with mode: 0755]
doc/Makefile [new file with mode: 0644]
doc/PLCAPI.xml [new file with mode: 0644]

diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..b6d125a
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,50 @@
+#
+# (Re)builds Python metafiles (__init__.py) and documentation
+#
+# Mark Huang <mlhuang@cs.princeton.edu>
+# Copyright (C) 2005 The Trustees of Princeton University
+#
+# $Id: plcsh,v 1.3 2006/01/09 19:57:24 mlhuang Exp $
+#
+
+# Metafiles
+INIT := PLC/__init__.py PLC/Methods/__init__.py
+
+# Other stuff
+SUBDIRS := doc
+
+all: $(INIT) $(SUBDIRS)
+
+$(SUBDIRS): %:
+       $(MAKE) -C $@
+
+clean:
+       find . -name '*.pyc' -execdir rm -f {} +
+       rm -f $(INIT)
+       for dir in $(SUBDIRS) ; do $(MAKE) -C $$dir clean ; done
+
+# All .py files in PLC/
+PLC := $(filter-out %/__init__.py, $(wildcard PLC/*.py))
+PLC_init := all = '$(notdir $(PLC:.py=))'.split()
+
+PLC/__init__.py:
+       echo "$(PLC_init)" >$@
+
+ifneq ($(sort $(PLC_init)), $(sort $(shell cat PLC/__init__.py 2>/dev/null)))
+PLC/__init__.py: force
+endif
+
+# All .py files in PLC/Methods/ and PLC/Methods/system/
+METHODS := $(filter-out %/__init__.py, $(wildcard PLC/Methods/*.py PLC/Methods/system/*.py))
+Methods_init := methods = '$(notdir $(subst system/, system., $(METHODS:.py=)))'.split()
+
+PLC/Methods/__init__.py:
+       echo "$(Methods_init)" >$@
+
+ifneq ($(sort $(Methods_init)), $(sort $(shell cat PLC/Methods/__init__.py 2>/dev/null)))
+PLC/Methods/__init__.py: force
+endif
+
+force:
+
+.PHONY: force clean $(SUBDIRS)
diff --git a/ModPython.py b/ModPython.py
new file mode 100644 (file)
index 0000000..e5f1174
--- /dev/null
@@ -0,0 +1,59 @@
+#
+# Apache mod_python interface
+#
+# Aaron Klingaman <alk@absarokasoft.com>
+# Mark Huang <mlhuang@cs.princeton.edu>
+#
+# Copyright (C) 2004-2006 The Trustees of Princeton University
+# $Id$
+#
+
+import sys
+import traceback
+import xmlrpclib
+from mod_python import apache
+
+from PLC.Debug import log
+
+from PLC.API import PLCAPI
+api = PLCAPI()
+
+def handler(req):
+    try:
+        if req.method != "POST":
+            req.content_type = "text/html"
+            req.send_http_header()
+            req.write("""
+<html><head>
+<title>PLCAPI XML-RPC/SOAP Interface</title>
+</head><body>
+<h1>PLCAPI XML-RPC/SOAP Interface</h1>
+<p>Please use XML-RPC or SOAP to access the PLCAPI.</p>
+</body></html>
+""")
+            return apache.OK
+
+        # Read request
+        request = req.read(int(req.headers_in['content-length']))
+
+        # mod_python < 3.2: The IP address portion of remote_addr is
+        # incorrect (always 0.0.0.0) when IPv6 is enabled.
+        # http://issues.apache.org/jira/browse/MODPYTHON-64?page=all
+        (remote_ip, remote_port) = req.connection.remote_addr
+        remote_addr = (req.connection.remote_ip, remote_port)
+
+        # Handle request
+        response = api.handle(remote_addr, request)
+
+        # Write response
+        req.content_type = "text/xml"
+        req.headers_out.add("Content-length", str(len(response)))
+        req.send_http_header()
+        req.write(response)
+
+        return apache.OK
+
+    except:
+        # Log error in /var/log/httpd/(ssl_)?error_log
+        print >> log, traceback.format_exc()
+        return apache.HTTP_INTERNAL_SERVER_ERROR
diff --git a/PLC/.cvsignore b/PLC/.cvsignore
new file mode 100644 (file)
index 0000000..0d20b64
--- /dev/null
@@ -0,0 +1 @@
+*.pyc
diff --git a/PLC/API.py b/PLC/API.py
new file mode 100644 (file)
index 0000000..4b19291
--- /dev/null
@@ -0,0 +1,107 @@
+#
+# PLCAPI XML-RPC and SOAP interfaces
+#
+# Aaron Klingaman <alk@absarokasoft.com>
+# Mark Huang <mlhuang@cs.princeton.edu>
+#
+# Copyright (C) 2004-2006 The Trustees of Princeton University
+# $Id$
+#
+
+import sys
+import traceback
+
+import xmlrpclib
+
+import SOAPpy
+from SOAPpy.Parser import parseSOAPRPC
+from SOAPpy.Types import faultType
+from SOAPpy.NS import NS
+from SOAPpy.SOAPBuilder import buildSOAP
+
+from PLC.Config import Config
+from PLC.PostgreSQL import PostgreSQL
+from PLC.Faults import *
+import PLC.Methods
+
+class PLCAPI:
+    methods = PLC.Methods.methods
+
+    def __init__(self, config = "/etc/planetlab/plc_config"):
+        # Better just be documenting the API
+        if config is None:
+            return
+
+        # Load configuration
+        self.config = Config(config)
+
+        # Initialize database connection
+        if self.config.PLC_DB_TYPE == "postgresql":
+            self.db = PostgreSQL(self)
+        else:
+            raise PLCAPIError, "Unsupported database type " + config.PLC_DB_TYPE
+
+    def callable(self, method):
+        """
+        Return a new instance of the specified method.
+        """
+
+        # Look up method
+        if method not in self.methods:
+            raise PLCInvalidAPIMethod, method
+
+        # Get new instance of method
+        try:
+            classname = method.split(".")[-1]
+            module = __import__("PLC.Methods." + method, globals(), locals(), [classname])
+            return getattr(module, classname)(self)
+        except ImportError, AttributeError:
+            raise PLCInvalidAPIMethod, method
+
+    def call(self, source, method, *args):
+        """
+        Call the named method from the specified source with the
+        specified arguments.
+        """
+
+        function = self.callable(method)
+        function.source = source
+        return function(*args)
+
+    def handle(self, source, data):
+        """
+        Handle an XML-RPC or SOAP request from the specified source.
+        """
+
+        # Parse request into method name and arguments
+        try:
+            interface = xmlrpclib
+            (args, method) = xmlrpclib.loads(data)
+            methodresponse = True
+        except:
+            interface = SOAPpy
+            (r, header, body, attrs) = parseSOAPRPC(data, header = 1, body = 1, attrs = 1)
+            method = r._name
+            args = r._aslist()
+            # XXX Support named arguments
+
+        try:
+            result = self.call(source, method, *args)
+        except PLCFault, fault:
+            # Handle expected faults
+            if interface == xmlrpclib:
+                result = fault
+                methodresponse = None
+            elif interface == SOAPpy:
+                result = faultParameter(NS.ENV_T + ":Server", "Method Failed", method)
+                result._setDetail("Fault %d: %s" % (fault.faultCode, fault.faultString))
+
+        # Return result
+        if interface == xmlrpclib:
+            if not isinstance(result, PLCFault):
+                result = (result,)
+            data = xmlrpclib.dumps(result, methodresponse = True)
+        elif interface == SOAPpy:
+            data = buildSOAP(kw = {'%sResponse' % method: {'Result': result}})
+
+        return data
diff --git a/PLC/AddressTypes.py b/PLC/AddressTypes.py
new file mode 100644 (file)
index 0000000..e94f086
--- /dev/null
@@ -0,0 +1,27 @@
+#
+# Functions for interacting with the address_types table in the database
+#
+# Mark Huang <mlhuang@cs.princeton.edu>
+# Copyright (C) 2006 The Trustees of Princeton University
+#
+# $Id$
+#
+
+from PLC.Parameter import Parameter
+
+class AddressTypes(dict):
+    """
+    Representation of the address_types table in the database.
+    """
+
+    fields = {
+        'address_type_id': Parameter(int, "Address type identifier"),
+        'name': Parameter(str, "Address type name"),
+        }
+
+    def __init__(self, api):
+        sql = "SELECT address_type_id, name FROM address_types"
+
+        for row in api.db.selectall(sql):
+            self[row['address_type_id']] = row['name']
+            self[row['name']] = row['address_type_id']
diff --git a/PLC/Addresses.py b/PLC/Addresses.py
new file mode 100644 (file)
index 0000000..45b4b13
--- /dev/null
@@ -0,0 +1,47 @@
+from types import StringTypes
+
+from PLC.Faults import *
+from PLC.Parameter import Parameter
+from PLC.Debug import profile
+from PLC.Table import Row, Table
+
+class Address(Row):
+    """
+    Representation of a row in the addresses table. To use, instantiate
+    with a dict of values.
+    """
+
+    fields = {
+        'address_id': Parameter(int, "Address type"),
+        'address_type_id': Parameter(int, "Address type identifier"),
+        'address_type': Parameter(str, "Address type name"),
+        'line1': Parameter(str, "Address line 1"),
+        'line2': Parameter(str, "Address line 2"),
+        'line3': Parameter(str, "Address line 3"),
+        'city': Parameter(str, "City"),
+        'state': Parameter(str, "State or province"),
+        'postalcode': Parameter(str, "Postal code"),
+        'country': Parameter(str, "Country"),
+        }
+
+    def __init__(self, api, fields):
+        self.api = api
+        dict.__init__(fields)
+
+    def flush(self, commit = True):
+        # XXX
+        pass
+
+    def delete(self, commit = True):
+        # XXX
+        pass
+
+class Addresses(Table):
+    """
+    Representation of row(s) from the addresses table in the
+    database.
+    """
+
+    def __init__(self, api, address_id_list = None):
+        # XXX
+        pass
diff --git a/PLC/Auth.py b/PLC/Auth.py
new file mode 100644 (file)
index 0000000..2b2ea02
--- /dev/null
@@ -0,0 +1,112 @@
+#
+# PLCAPI authentication parameters
+#
+# Mark Huang <mlhuang@cs.princeton.edu>
+# Copyright (C) 2006 The Trustees of Princeton University
+#
+# $Id$
+#
+
+import crypt
+
+from PLC.Faults import *
+from PLC.Parameter import Parameter, Mixed
+from PLC.Persons import Persons
+
+class Auth(Parameter, dict):
+    """
+    Base class for all API authentication methods.
+    """
+
+    def __init__(self, auth):
+        Parameter.__init__(self, auth, "API authentication structure", False)
+        dict.__init__(auth)
+
+class NodeAuth(Auth):
+    """
+    PlanetLab version 3.x node authentication structure. Used by the
+    Boot Manager to make authenticated calls to the API based on a
+    unique node key or boot nonce value.
+    """
+
+    def __init__(self):
+        Auth.__init__(self, {
+            'AuthMethod': Parameter(str, "Authentication method to use, always 'hmac'", False),
+            'node_id': Parameter(str, "Node identifier", False),
+            'node_ip': Parameter(str, "Node primary IP address", False),
+            'value': Parameter(str, "HMAC of node key and method call", False)
+            })
+
+    def check(self, method, auth, *args):
+        # XXX Do HMAC checking
+        return True
+
+class AnonymousAuth(Auth):
+    """
+    PlanetLab version 3.x anonymous authentication structure.
+    """
+
+    def __init__(self):
+        Auth.__init__(self, {
+            'AuthMethod': Parameter(str, "Authentication method to use, always 'anonymous'", False),
+            })
+
+    def check(self, method, auth, *args):
+        # Sure, dude, whatever
+        return True
+
+class PasswordAuth(Auth):
+    """
+    PlanetLab version 3.x password authentication structure.
+    """
+
+    def __init__(self):
+        Auth.__init__(self, {
+            'AuthMethod': Parameter(str, "Authentication method to use, typically 'password'", False),
+            'Username': Parameter(str, "PlanetLab username, typically an e-mail address", False),
+            'AuthString': Parameter(str, "Authentication string, typically a password", False),
+            'Role': Parameter(str, "Role to use for this call", False)
+            })
+
+    def check(self, method, auth, *args):
+        # Method.type_check() should have checked that all of the
+        # mandatory fields were present.
+        assert auth.has_key('Username')
+
+        # Get record (must be enabled)
+        persons = Persons(method.api, [auth['Username']], enabled = True)
+        if len(persons) != 1:
+            raise PLCAuthenticationFailure, "No such account"
+
+        person = persons.values()[0]
+
+        if auth['Username'] == method.api.config.PLC_API_MAINTENANCE_USER:
+            # "Capability" authentication, whatever the hell that was
+            # supposed to mean. It really means, login as the special
+            # "maintenance user" using password authentication. Can
+            # only be used on particular machines (those in a list).
+            sources = method.api.config.PLC_API_MAINTENANCE_SOURCES.split()
+            if method.source is not None and method.source[0] not in sources:
+                raise PLCAuthenticationFailure, "Not allowed to login to maintenance account"
+
+            # Not sure why this is not stored in the DB
+            password = method.api.config.PLC_API_MAINTENANCE_PASSWORD
+
+            if auth['AuthString'] != password:
+                raise PLCAuthenticationFailure, "Maintenance account password verification failed"
+        else:
+            # Get encrypted password stored in the DB
+            password = person['password']
+
+            # Protect against blank passwords in the DB
+            if password is None or password[:12] == "" or \
+               crypt.crypt(auth['AuthString'], password[:12]) != password:
+                raise PLCAuthenticationFailure, "Password verification failed"
+
+        if auth['Role'] not in person['roles']:
+            raise PLCAuthenticationFailure, "Account does not have " + auth['Role'] + " role"
+
+        if method.roles and auth['Role'] not in method.roles:
+            raise PLCAuthenticationFailure, "Cannot call with " + auth['Role'] + "role"
+
+        method.caller = person
diff --git a/PLC/BootStates.py b/PLC/BootStates.py
new file mode 100644 (file)
index 0000000..2cc0b22
--- /dev/null
@@ -0,0 +1,25 @@
+#
+# Functions for interacting with the node_bootstates table in the database
+#
+# Mark Huang <mlhuang@cs.princeton.edu>
+# Copyright (C) 2006 The Trustees of Princeton University
+#
+# $Id$
+#
+
+from PLC.Parameter import Parameter
+
+class BootStates(list):
+    """
+    Representation of the node_bootstates table in the database.
+    """
+
+    fields = {
+        'boot_state': Parameter(int, "Node boot state"),
+        }
+
+    def __init__(self, api):
+        sql = "SELECT * FROM node_bootstates"
+        
+        for row in api.db.selectall(sql):
+            self.append(row['boot_state'])
diff --git a/PLC/Config.py b/PLC/Config.py
new file mode 100644 (file)
index 0000000..7d666b3
--- /dev/null
@@ -0,0 +1,96 @@
+#!/usr/bin/python
+#
+# PLCAPI configuration store. Supports XML-based configuration file
+# format exported by MyPLC.
+#
+# Mark Huang <mlhuang@cs.princeton.edu>
+# Copyright (C) 2004-2006 The Trustees of Princeton University
+#
+# $Id$
+#
+
+import os
+import sys
+
+from PLC.Faults import *
+from PLC.Debug import profile
+
+# If we have been checked out into a directory at the same
+# level as myplc, where plc_config.py lives. If we are in a
+# MyPLC environment, plc_config.py has already been installed
+# in site-packages.
+myplc = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + \
+        os.sep + "myplc"
+
+class Config:
+    """
+    Parse the bash/Python/PHP version of the configuration file. Very
+    fast but no type conversions.
+    """
+
+    def __init__(self, file = "/etc/planetlab/plc_config"):
+        # Load plc_config
+        try:
+            execfile(file, self.__dict__)
+        except:
+            # Try myplc directory
+            try:
+                execfile(file, self.__dict__)
+            except:
+                raise PLCAPIError("Could not find plc_config in " + \
+                                  file + ", " + \
+                                  myplc + "plc_config")
+
+class XMLConfig:
+    """
+    Parse the XML configuration file directly. Takes longer but is
+    presumably more accurate.
+    """
+
+    def __init__(self, file = "/etc/planetlab/plc_config.xml"):
+        try:
+            from plc_config import PLCConfiguration
+        except:
+            sys.path.append(myplc)
+            from plc_config import PLCConfiguration
+
+        # Load plc_config.xml
+        try:
+            cfg = PLCConfiguration(file)
+        except:
+            # Try myplc directory
+            try:
+                cfg = PLCConfiguration(myplc + os.sep + "plc_config.xml")
+            except:
+                raise PLCAPIError("Could not find plc_config.xml in " + \
+                                  file + ", " + \
+                                  myplc + "plc_config.xml")
+
+        for (category, variablelist) in cfg.variables().values():
+            for variable in variablelist.values():
+                # Try to cast each variable to an appropriate Python
+                # type.
+                if variable['type'] == "int":
+                    value = int(variable['value'])
+                elif variable['type'] == "double":
+                    value = float(variable['value'])
+                elif variable['type'] == "boolean":
+                    if variable['value'] == "true":
+                        value = True
+                    else:
+                        value = False
+                else:
+                    value = variable['value']
+
+                # Variables are split into categories such as
+                # "plc_api", "plc_db", etc. Within each category are
+                # variables such as "host", "port", etc. For backward
+                # compatibility, refer to variables by their shell
+                # names.
+                shell_name = category['id'].upper() + "_" + variable['id'].upper()
+                setattr(self, shell_name, value)
+
+if __name__ == '__main__':
+    import pprint
+    pprint = pprint.PrettyPrinter()
+    pprint.pprint(Config().__dict__.items())
diff --git a/PLC/Debug.py b/PLC/Debug.py
new file mode 100644 (file)
index 0000000..ccd7db7
--- /dev/null
@@ -0,0 +1,53 @@
+import time
+import sys
+import syslog
+
+class unbuffered:
+    """
+    Write to /var/log/httpd/error_log. See
+
+    http://www.modpython.org/FAQ/faqw.py?req=edit&file=faq02.003.htp
+    """
+
+    def write(self, data):
+        sys.stderr.write(data)
+        sys.stderr.flush()
+
+log = unbuffered()
+
+def profile(callable):
+    """
+    Prints the runtime of the specified callable. Use as a decorator, e.g.,
+
+        @profile
+        def foo(...):
+            ...
+
+    Or, equivalently,
+
+        def foo(...):
+            ...
+        foo = profile(foo)
+
+    Or inline:
+
+        result = profile(foo)(...)
+    """
+
+    def wrapper(*args, **kwds):
+        start = time.time()
+        result = callable(*args, **kwds)
+        end = time.time()
+        args = map(str, args)
+        args += ["%s = %s" % (name, str(value)) for (name, value) in kwds.items()]
+        print >> log, "%s (%s): %f s" % (callable.__name__, ", ".join(args), end - start)
+        return result
+
+    return wrapper
+
+if __name__ == "__main__":
+    @profile
+    def sleep(seconds = 1):
+        time.sleep(seconds)
+
+    sleep(1)
diff --git a/PLC/Faults.py b/PLC/Faults.py
new file mode 100644 (file)
index 0000000..1b2cdd3
--- /dev/null
@@ -0,0 +1,67 @@
+#
+# PLCAPI XML-RPC faults
+#
+# Aaron Klingaman <alk@absarokasoft.com>
+# Mark Huang <mlhuang@cs.princeton.edu>
+#
+# Copyright (C) 2004-2006 The Trustees of Princeton University
+# $Id$
+#
+
+import xmlrpclib
+
+class PLCFault(xmlrpclib.Fault):
+    def __init__(self, faultCode, faultString, extra = None):
+        if extra:
+            faultString += ": " + extra
+        xmlrpclib.Fault.__init__(self, faultCode, faultString)
+
+class PLCInvalidAPIMethod(PLCFault):
+    def __init__(self, method, role = None, extra = None):
+        faultString = "Invalid method " + method
+        if role:
+            faultString += " for role " + role
+        PLCFault.__init__(self, 100, faultString, extra)
+
+class PLCInvalidArgumentCount(PLCFault):
+    def __init__(self, got, min, max = min, extra = None):
+        if min != max:
+            expected = "%d-%d" % (min, max)
+        else:
+            expected = "%d" % min
+        faultString = "Expected %s arguments, got %d" % \
+                      (expected, got)
+        PLCFault.__init__(self, 101, faultString, extra)
+
+class PLCInvalidArgument(PLCFault):
+    def __init__(self, extra = None, name = None):
+        if name is not None:
+            faultString = "Invalid %s value" % name
+        else:
+            faultString = "Invalid argument"
+        PLCFault.__init__(self, 102, faultString, extra)
+
+class PLCAuthenticationFailure(PLCFault):
+    def __init__(self, extra = None):
+        faultString = "Failed to authenticate call"
+        PLCFault.__init__(self, 103, faultString, extra)
+
+class PLCNotImplemented(PLCFault):
+    def __init__(self, extra = None):
+        faultString = "Not fully implemented"
+        PLCFault.__init__(self, 109, faultString, extra)
+
+class PLCDBError(PLCFault):
+    def __init__(self, extra = None):
+        faultString = "Database error"
+        PLCFault.__init__(self, 106, faultString, extra)
+
+class PLCPermissionDenied(PLCFault):
+    def __init__(self, extra = None):
+        faultString = "Permission denied"
+        PLCFault.__init__(self, 108, faultString, extra)
+
+class PLCAPIError(PLCFault):
+    def __init__(self, extra = None):
+        faultString = "Internal API error"
+        PLCFault.__init__(self, 111, faultString, extra)
diff --git a/PLC/Keys.py b/PLC/Keys.py
new file mode 100644 (file)
index 0000000..175a16b
--- /dev/null
@@ -0,0 +1,42 @@
+from types import StringTypes
+
+from PLC.Faults import *
+from PLC.Parameter import Parameter
+from PLC.Debug import profile
+from PLC.Table import Row, Table
+
+class Key(Row):
+    """
+    Representation of a row in the keys table. To use, instantiate
+    with a dict of values.
+    """
+
+    fields = {
+        'key_id': Parameter(int, "Key type"),
+        'key_type': Parameter(str, "Key type"),
+        'key': Parameter(str, "Key value"),
+        'last_updated': Parameter(str, "Date and time of last update"),
+        'is_blacklisted': Parameter(str, "Key has been blacklisted and is forever unusable"),
+        }
+
+    def __init__(self, api, fields):
+        self.api = api
+        dict.__init__(fields)
+
+    def commit(self):
+        # XXX
+        pass
+
+    def delete(self):
+        # XXX
+        pass
+
+class Keys(Table):
+    """
+    Representation of row(s) from the keys table in the
+    database.
+    """
+
+    def __init__(self, api, key_id_list = None):
+        # XXX
+        pass
diff --git a/PLC/Method.py b/PLC/Method.py
new file mode 100644 (file)
index 0000000..f830776
--- /dev/null
@@ -0,0 +1,314 @@
+#
+# Base class for all PLCAPI functions
+#
+# Mark Huang <mlhuang@cs.princeton.edu>
+# Copyright (C) 2006 The Trustees of Princeton University
+#
+# $Id$
+#
+
+import xmlrpclib
+from types import *
+import textwrap
+import os
+
+from PLC.Faults import *
+from PLC.Parameter import Parameter, Mixed
+from PLC.Auth import Auth
+
+class Method:
+    """
+    Base class for all PLCAPI functions. At a minimum, all PLCAPI
+    functions must define:
+
+    roles = [list of roles]
+    accepts = [Parameter(arg1_type, arg1_doc), Parameter(arg2_type, arg2_doc), ...]
+    returns = Parameter(return_type, return_doc)
+    call(arg1, arg2, ...): method body
+
+    Argument types may be Python types (e.g., int, bool, etc.), typed
+    values (e.g., 1, True, etc.), a Parameter, or lists or
+    dictionaries of possibly mixed types, values, and/or Parameters
+    (e.g., [int, bool, ...]  or {'arg1': int, 'arg2': bool}).
+
+    Once function decorators in Python 2.4 are fully supported,
+    consider wrapping calls with accepts() and returns() functions
+    instead of performing type checking manually.
+    """
+
+    # Defaults. Could implement authentication and type checking with
+    # decorators, but they are not supported in Python 2.3 and it
+    # would be hard to generate documentation without writing a code
+    # parser.
+
+    roles = []
+    accepts = []
+    returns = bool
+    status = "current"
+
+    def call(self):
+        """
+        Method body for all PLCAPI functions. Must override.
+        """
+
+        return True
+
+    def __init__(self, api):
+        self.name = self.__class__.__name__
+        self.api = api
+
+        # Auth may set this to a Person instance (if an anonymous
+        # method, will remain None).
+        self.caller = None
+
+        # API may set this to a (addr, port) tuple if known
+        self.source = None
+
+    def __call__(self, *args):
+        """
+        Main entry point for all PLCAPI functions. Type checks
+        arguments, authenticates, and executes call().
+        """
+
+        try:
+            (min_args, max_args, defaults) = self.args()
+
+            # Check that the right number of arguments were passed in
+            if len(args) < len(min_args) or len(args) > len(max_args):
+                raise PLCInvalidArgumentCount(len(args), len(min_args), len(max_args))
+
+            for name, value, expected in zip(max_args, args, self.accepts):
+                self.type_check(name, value, expected)
+
+            # The first argument to all methods that require
+            # authentication, should be an Auth structure. The rest of the
+            # arguments to the call may also be used in the authentication
+            # check. For example, calls made by the Boot Manager are
+            # verified by comparing a hash of the message parameters to
+            # the value in the authentication structure.        
+
+            if len(self.accepts):
+                auth = None
+                if isinstance(self.accepts[0], Auth):
+                    auth = self.accepts[0]
+                elif isinstance(self.accepts[0], Mixed):
+                    for auth in self.accepts[0]:
+                        if isinstance(auth, Auth):
+                            break
+                if isinstance(auth, Auth):
+                    auth.check(self, *args)
+
+            return self.call(*args)
+
+        except PLCFault, fault:
+            # Prepend method name to expected faults
+            fault.faultString = self.name + ": " + fault.faultString
+            raise fault
+
+    def help(self, indent = "  "):
+        """
+        Text documentation for the method.
+        """
+
+        (min_args, max_args, defaults) = self.args()
+
+        text = "%s(%s) -> %s\n\n" % (self.name, ", ".join(max_args), xmlrpc_type(self.returns))
+
+        text += "Description:\n\n"
+        lines = [indent + line.strip() for line in self.__doc__.strip().split("\n")]
+        text += "\n".join(lines) + "\n\n"
+
+        text += "Allowed Roles:\n\n"
+        if not self.roles:
+            roles = ["any"]
+        else:
+            roles = self.roles
+        text += indent + ", ".join(roles) + "\n\n"
+
+        def param_text(name, param, indent, step):
+            """
+            Format a method parameter.
+            """
+
+            text = indent
+
+            # Print parameter name
+            if name:
+                param_offset = 32
+                text += name.ljust(param_offset - len(indent))
+            else:
+                param_offset = len(indent)
+
+            # Print parameter type
+            param_type = python_type(param)
+            text += xmlrpc_type(param_type) + "\n"
+
+            # Print parameter documentation right below type
+            if isinstance(param, Parameter):
+                wrapper = textwrap.TextWrapper(width = 70,
+                                               initial_indent = " " * param_offset,
+                                               subsequent_indent = " " * param_offset)
+                text += "\n".join(wrapper.wrap(param.doc)) + "\n"
+                param = param.type
+
+            text += "\n"
+
+            # Indent struct fields and mixed types
+            if isinstance(param, dict):
+                for name, subparam in param.iteritems():
+                    text += param_text(name, subparam, indent + step, step)
+            elif isinstance(param, Mixed):
+                for subparam in param:
+                    text += param_text(name, subparam, indent + step, step)
+            elif isinstance(param, (list, tuple)):
+                for subparam in param:
+                    text += param_text("", subparam, indent + step, step)
+
+            return text
+
+        text += "Parameters:\n\n"
+        for name, param in zip(max_args, self.accepts):
+            text += param_text(name, param, indent, indent)
+
+        text += "Returns:\n\n"
+        text += param_text("", self.returns, indent, indent)
+
+        return text
+
+    def args(self):
+        """
+        Returns a tuple:
+
+        ((arg1_name, arg2_name, ...),
+         (arg1_name, arg2_name, ..., optional1_name, optional2_name, ...),
+         (None, None, ..., optional1_default, optional2_default, ...))
+
+        That represents the minimum and maximum sets of arguments that
+        this function accepts and the defaults for the optional arguments.
+        """
+
+        # Inspect call. Remove self from the argument list.
+        max_args = self.call.func_code.co_varnames[1:self.call.func_code.co_argcount]
+        defaults = self.call.func_defaults
+        if defaults is None:
+            defaults = ()
+
+        min_args = max_args[0:len(max_args) - len(defaults)]
+        defaults = tuple([None for arg in min_args]) + defaults
+        
+        return (min_args, max_args, defaults)
+
+    def type_check(self, name, value, expected):
+        """
+        Checks the type of the named value against the expected type,
+        which may be a Python type, a typed value, a Parameter, a
+        Mixed type, or a list or dictionary of possibly mixed types,
+        values, Parameters, or Mixed types.
+        
+        Extraneous members of lists must be of the same type as the
+        last specified type. For example, if the expected argument
+        type is [int, bool], then [1, False] and [14, True, False,
+        True] are valid, but [1], [False, 1] and [14, True, 1] are
+        not.
+
+        Extraneous members of dictionaries are ignored.
+        """
+
+        # If any of a number of types is acceptable
+        if isinstance(expected, Mixed):
+            for item in expected:
+                try:
+                    self.type_check(name, value, item)
+                    expected = item
+                    break
+                except PLCInvalidArgument, fault:
+                    pass
+            if expected != item:
+                xmlrpc_types = [xmlrpc_type(item) for item in expected]
+                raise PLCInvalidArgument("expected %s, got %s" % \
+                                         (" or ".join(xmlrpc_types),
+                                          xmlrpc_type(type(value))),
+                                         name)
+
+        # Get actual expected type from within the Parameter structure
+        elif isinstance(expected, Parameter):
+            expected = expected.type
+
+        expected_type = python_type(expected)
+
+        # Strings are a special case. Accept either unicode or str
+        # types if a string is expected.
+        if expected_type in StringTypes and isinstance(value, StringTypes):
+            pass
+
+        # Integers and long integers are also special types. Accept
+        # either int or long types if an int or long is expected.
+        elif expected_type in (IntType, LongType) and isinstance(value, (IntType, LongType)):
+            pass
+
+        elif not isinstance(value, expected_type):
+            raise PLCInvalidArgument("expected %s, got %s" % \
+                                     (xmlrpc_type(expected_type),
+                                      xmlrpc_type(type(value))),
+                                     name)
+
+        # If a list with particular types of items is expected
+        if isinstance(expected, (list, tuple)):
+            for i in range(len(value)):
+                if i >= len(expected):
+                    i = len(expected) - 1
+                self.type_check(name + "[]", value[i], expected[i])
+
+        # If a struct with particular (or required) types of items is
+        # expected.
+        elif isinstance(expected, dict):
+            for key in value.keys():
+                if key in expected:
+                    self.type_check(name + "['%s']" % key, value[key], expected[key])
+            for key, subparam in expected.iteritems():
+                if isinstance(subparam, Parameter) and \
+                   not subparam.optional and key not in value.keys():
+                    raise PLCInvalidArgument("'%s' not specified" % key, name)
+
+def python_type(arg):
+    """
+    Returns the Python type of the specified argument, which may be a
+    Python type, a typed value, or a Parameter.
+    """
+
+    if isinstance(arg, Parameter):
+        arg = arg.type
+
+    if isinstance(arg, type):
+        return arg
+    else:
+        return type(arg)
+
+def xmlrpc_type(arg):
+    """
+    Returns the XML-RPC type of the specified argument, which may be a
+    Python type, a typed value, or a Parameter.
+    """
+
+    arg_type = python_type(arg)
+
+    if arg_type == NoneType:
+        return "nil"
+    elif arg_type == IntType or arg_type == LongType:
+        return "int"
+    elif arg_type == bool:
+        return "boolean"
+    elif arg_type == FloatType:
+        return "double"
+    elif arg_type in StringTypes:
+        return "string"
+    elif arg_type == ListType or arg_type == TupleType:
+        return "array"
+    elif arg_type == DictType:
+        return "struct"
+    elif arg_type == Mixed:
+        # Not really an XML-RPC type but return "mixed" for
+        # documentation purposes.
+        return "mixed"
+    else:
+        raise PLCAPIError, "XML-RPC cannot marshal %s objects" % arg_type
diff --git a/PLC/Methods/.cvsignore b/PLC/Methods/.cvsignore
new file mode 100644 (file)
index 0000000..0d20b64
--- /dev/null
@@ -0,0 +1 @@
+*.pyc
diff --git a/PLC/Methods/AdmAddNode.py b/PLC/Methods/AdmAddNode.py
new file mode 100644 (file)
index 0000000..bf60827
--- /dev/null
@@ -0,0 +1,71 @@
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Nodes import Node, Nodes
+from PLC.Sites import Site, Sites
+from PLC.Auth import PasswordAuth
+
+class AdmAddNode(Method):
+    """
+    Adds a new node. Any values specified in optional_vals are used,
+    otherwise defaults are used.
+
+    PIs and techs may only add nodes to their own sites. Admins may
+    add nodes to any site.
+
+    Returns the new node_id (> 0) if successful, faults otherwise.
+    """
+
+    roles = ['admin', 'pi', 'tech']
+
+    can_update = lambda (field, value): field in \
+                 ['model', 'version']
+    update_fields = dict(filter(can_update, Node.fields.items()))
+
+    accepts = [
+        PasswordAuth(),
+        Mixed(Site.fields['site_id'],
+              Site.fields['login_base']),
+        Node.fields['hostname'],
+        Node.fields['boot_state'],
+        update_fields
+        ]
+
+    returns = Parameter(int, '1 if successful')
+
+    def call(self, auth, site_id_or_login_base, hostname, boot_state, optional_vals = {}):
+        if filter(lambda field: field not in self.update_fields, optional_vals):
+            raise PLCInvalidArgument, "Invalid fields specified"
+
+        # Get site information
+        sites = Sites(self.api, [site_id_or_login_base])
+        if not sites:
+            raise PLCInvalidArgument, "No such site"
+
+        site = sites.values()[0]
+
+        # Authenticated function
+        assert self.caller is not None
+
+        # If we are not an admin, make sure that the caller is a
+        # member of the site.
+        if 'admin' not in self.caller['roles']:
+            if site['site_id'] not in self.caller['site_ids']:
+                assert self.caller['person_id'] not in site['person_ids']
+                raise PLCPermissionDenied, "Not allowed to add nodes to specified site"
+            else:
+                assert self.caller['person_id'] in site['person_ids']
+
+        node = Node(self.api, optional_vals)
+        node['hostname'] = hostname
+        node['boot_state'] = boot_state
+        node.flush()
+
+        # Now associate the node with the site
+        node_id = node['node_id']
+        nodegroup_id = site['nodegroup_id']
+        self.api.db.do("INSERT INTO nodegroup_nodes (nodegroup_id, node_id)" \
+                       " VALUES(%(nodegroup_id)d, %(node_id)d)",
+                       locals())
+
+        return node['node_id']
diff --git a/PLC/Methods/AdmAddPerson.py b/PLC/Methods/AdmAddPerson.py
new file mode 100644 (file)
index 0000000..abbf3a6
--- /dev/null
@@ -0,0 +1,43 @@
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Persons import Person, Persons
+from PLC.Auth import PasswordAuth
+
+class AdmAddPerson(Method):
+    """
+    Adds a new account. Any fields specified in optional_vals are
+    used, otherwise defaults are used.
+
+    Accounts are disabled by default. To enable an account, use
+    AdmSetPersonEnabled() or AdmUpdatePerson().
+
+    Returns the new person_id (> 0) if successful, faults otherwise.
+    """
+
+    roles = ['admin', 'pi']
+
+    can_update = lambda (field, value): field in \
+                 ['title', 'email', 'password', 'phone', 'url', 'bio']
+    update_fields = dict(filter(can_update, Person.fields.items()))
+
+    accepts = [
+        PasswordAuth(),
+        Person.fields['first_name'],
+        Person.fields['last_name'],
+        update_fields
+        ]
+
+    returns = Parameter(int, '1 if successful')
+
+    def call(self, auth, first_name, last_name, optional_vals = {}):
+        if filter(lambda field: field not in self.update_fields, optional_vals):
+            raise PLCInvalidArgument, "Invalid fields specified"
+
+        person = Person(self.api, optional_vals)
+        person['first_name'] = first_name
+        person['last_name'] = last_name
+        person['enabled'] = False
+        person.flush()
+
+        return person['person_id']
diff --git a/PLC/Methods/AdmAddPersonToSite.py b/PLC/Methods/AdmAddPersonToSite.py
new file mode 100644 (file)
index 0000000..d689dce
--- /dev/null
@@ -0,0 +1,51 @@
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Persons import Person, Persons
+from PLC.Sites import Site, Sites
+from PLC.Auth import PasswordAuth
+
+class AdmAddPersonToSite(Method):
+    """
+    Adds the specified person to the specified site. If the person is
+    already a member of the site, no errors are returned. Does not
+    change the person's primary site.
+
+    Returns 1 if successful, faults otherwise.
+    """
+
+    roles = ['admin']
+
+    accepts = [
+        PasswordAuth(),
+        Mixed(Person.fields['person_id'],
+              Person.fields['email']),
+        Mixed(Site.fields['site_id'],
+              Site.fields['login_base'])
+        ]
+
+    returns = Parameter(int, '1 if successful')
+
+    def call(self, auth, person_id_or_email, site_id_or_login_base):
+        # Get account information
+        persons = Persons(self.api, [person_id_or_email])
+        if not persons:
+            raise PLCInvalidArgument, "No such account"
+
+        person = persons.values()[0]
+
+        # Get site information
+        sites = Sites(self.api, [site_id_or_login_base])
+        if not sites:
+            raise PLCInvalidArgument, "No such site"
+
+        site = sites.values()[0]
+
+        if site['site_id'] not in person['site_ids']:
+            person_id = person['person_id']
+            site_id = site['site_id']
+            self.api.db.do("INSERT INTO person_site (person_id, site_id)" \
+                           " VALUES(%(person_id)d, %(site_id)d)",
+                           locals())
+
+        return 1
diff --git a/PLC/Methods/AdmAddSite.py b/PLC/Methods/AdmAddSite.py
new file mode 100644 (file)
index 0000000..2412481
--- /dev/null
@@ -0,0 +1,43 @@
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Sites import Site, Sites
+from PLC.Auth import PasswordAuth
+
+class AdmAddSite(Method):
+    """
+    Adds a new site, and creates a node group for that site. Any
+    fields specified in optional_vals are used, otherwise defaults are
+    used.
+
+    Returns the new site_id (> 0) if successful, faults otherwise.
+    """
+
+    roles = ['admin']
+
+    can_update = lambda (field, value): field in \
+                 ['is_public', 'latitude', 'longitude', 'url',
+                  'organization_id', 'ext_consortium_id']
+    update_fields = dict(filter(can_update, Site.fields.items()))
+
+    accepts = [
+        PasswordAuth(),
+        Site.fields['name'],
+        Site.fields['abbreviated_name'],
+        Site.fields['login_base'],
+        update_fields
+        ]
+
+    returns = Parameter(int, '1 if successful')
+
+    def call(self, auth, name, abbreviated_name, login_base, optional_vals = {}):
+        if filter(lambda field: field not in self.update_fields, optional_vals):
+            raise PLCInvalidArgument, "Invalid field specified"
+
+        site = Site(self.api, optional_vals)
+        site['name'] = name
+        site['abbreviated_name'] = abbreviated_name
+        site['login_base'] = login_base
+        site.flush()
+
+        return site['site_id']
diff --git a/PLC/Methods/AdmAuthCheck.py b/PLC/Methods/AdmAuthCheck.py
new file mode 100644 (file)
index 0000000..3b5086e
--- /dev/null
@@ -0,0 +1,16 @@
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Auth import PasswordAuth
+
+class AdmAuthCheck(Method):
+    """
+    Returns 1 if the user authenticated successfully, faults
+    otherwise.
+    """
+
+    roles = ['admin', 'pi', 'user', 'tech']
+    accepts = [PasswordAuth()]
+    returns = Parameter(int, '1 if successful')
+
+    def call(self, auth):
+        return 1
diff --git a/PLC/Methods/AdmDeleteNode.py b/PLC/Methods/AdmDeleteNode.py
new file mode 100644 (file)
index 0000000..3e308fc
--- /dev/null
@@ -0,0 +1,46 @@
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Auth import PasswordAuth
+from PLC.Nodes import Node, Nodes
+
+class AdmDeleteNode(Method):
+    """
+    Mark an existing node as deleted.
+
+    PIs and techs may only delete nodes at their own sites. Admins may
+    delete nodes at any site.
+
+    Returns 1 if successful, faults otherwise.
+    """
+
+    roles = ['admin', 'pi', 'tech']
+
+    accepts = [
+        PasswordAuth(),
+        Mixed(Node.fields['node_id'],
+              Node.fields['hostname'])
+        ]
+
+    returns = Parameter(int, '1 if successful')
+
+    def call(self, auth, node_id_or_hostname):
+        # Get account information
+        nodes = Nodes(self.api, [node_id_or_hostname])
+        if not nodes:
+            raise PLCInvalidArgument, "No such node"
+
+        node = nodes.values()[0]
+
+        # If we are not an admin, make sure that the caller is a
+        # member of the site at which the node is located.
+        if 'admin' not in self.caller['roles']:
+            # Authenticated function
+            assert self.caller is not None
+
+            if node['site_id'] not in self.caller['site_ids']:
+                raise PLCPermissionDenied, "Not allowed to delete nodes from specified site"
+
+        node.delete()
+
+        return 1
diff --git a/PLC/Methods/AdmDeletePerson.py b/PLC/Methods/AdmDeletePerson.py
new file mode 100644 (file)
index 0000000..8a4fd93
--- /dev/null
@@ -0,0 +1,45 @@
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Persons import Person, Persons
+from PLC.Auth import PasswordAuth
+
+class AdmDeletePerson(Method):
+    """
+    Mark an existing account as deleted.
+
+    Users and techs can only delete themselves. PIs can only delete
+    themselves and other non-PIs at their sites. Admins can delete
+    anyone.
+
+    Returns 1 if successful, faults otherwise.
+    """
+
+    roles = ['admin', 'pi', 'user', 'tech']
+
+    accepts = [
+        PasswordAuth(),
+        Mixed(Person.fields['person_id'],
+              Person.fields['email'])
+        ]
+
+    returns = Parameter(int, '1 if successful')
+
+    def call(self, auth, person_id_or_email):
+        # Get account information
+        persons = Persons(self.api, [person_id_or_email])
+        if not persons:
+            raise PLCInvalidArgument, "No such account"
+
+        person = persons.values()[0]
+
+        # Authenticated function
+        assert self.caller is not None
+
+        # Check if we can update this account
+        if not self.caller.can_update(person):
+            raise PLCPermissionDenied, "Not allowed to delete specified account"
+
+        person.delete()
+
+        return 1
diff --git a/PLC/Methods/AdmDeleteSite.py b/PLC/Methods/AdmDeleteSite.py
new file mode 100644 (file)
index 0000000..e8581ff
--- /dev/null
@@ -0,0 +1,39 @@
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Sites import Site, Sites
+from PLC.Persons import Person, Persons
+from PLC.Nodes import Node, Nodes
+from PLC.PCUs import PCU, PCUs
+from PLC.Auth import PasswordAuth
+
+class AdmDeleteSite(Method):
+    """
+    Mark an existing site as deleted. The accounts of people who are
+    not members of at least one other non-deleted site will also be
+    marked as deleted. Nodes, PCUs, and slices associated with the
+    site will be deleted.
+
+    Returns 1 if successful, faults otherwise.
+    """
+
+    roles = ['admin']
+
+    accepts = [
+        PasswordAuth(),
+        Mixed(Site.fields['site_id'],
+              Site.fields['login_base'])
+        ]
+
+    returns = Parameter(int, '1 if successful')
+
+    def call(self, auth, site_id_or_login_base):
+        # Get account information
+        sites = Sites(self.api, [site_id_or_login_base])
+        if not sites:
+            raise PLCInvalidArgument, "No such site"
+
+        site = sites.values()[0]
+        site.delete()
+
+        return 1
diff --git a/PLC/Methods/AdmGetAllRoles.py b/PLC/Methods/AdmGetAllRoles.py
new file mode 100644 (file)
index 0000000..7dc2d15
--- /dev/null
@@ -0,0 +1,32 @@
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter
+from PLC.Roles import Roles
+from PLC.Auth import PasswordAuth
+
+class AdmGetAllRoles(Method):
+    """
+    Return all possible roles as a struct:
+
+    {'10': 'admin', '20': 'pi', '30': 'user', '40': 'tech'}
+
+    Note that because of XML-RPC marshalling limitations, the keys to
+    this struct are string representations of the integer role
+    identifiers.
+    """
+
+    roles = ['admin', 'pi', 'user', 'tech']
+    accepts = [PasswordAuth()]
+    returns = dict
+
+    def call(self, auth, person_id_or_email_list = None, return_fields = None):
+        roles = Roles(self.api)
+
+        # Just the role_id: name mappings
+        roles = dict(filter(lambda (role_id, name): isinstance(role_id, (int, long)), \
+                            roles.items()))
+
+        # Stringify the keys!
+        keys = map(str, roles.keys())
+
+        return dict(zip(keys, roles.values()))
diff --git a/PLC/Methods/AdmGetNodes.py b/PLC/Methods/AdmGetNodes.py
new file mode 100644 (file)
index 0000000..c38b8d4
--- /dev/null
@@ -0,0 +1,68 @@
+import os
+
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Nodes import Node, Nodes
+from PLC.Auth import PasswordAuth
+
+class AdmGetNodes(Method):
+    """
+    Return an array of dictionaries containing details about the
+    specified accounts.
+
+    Admins may retrieve details about all accounts by not specifying
+    node_id_or_email_list or by specifying an empty list. Users and
+    techs may only retrieve details about themselves. PIs may retrieve
+    details about themselves and others at their sites.
+
+    If return_fields is specified, only the specified fields will be
+    returned, if set. Otherwise, the default set of fields returned is:
+
+    """
+
+    roles = ['admin', 'pi', 'user', 'tech']
+
+    accepts = [
+        PasswordAuth(),
+        [Mixed(Node.fields['node_id'],
+               Node.fields['hostname'])],
+        Parameter([str], 'List of fields to return')
+        ]
+
+    # Filter out hidden fields
+    can_return = lambda (field, value): field not in ['deleted']
+    return_fields = dict(filter(can_return, Node.all_fields.items()))
+    returns = [return_fields]
+
+    def __init__(self, *args, **kwds):
+        Method.__init__(self, *args, **kwds)
+        # Update documentation with list of default fields returned
+        self.__doc__ += os.linesep.join(Node.default_fields.keys())
+
+    def call(self, auth, node_id_or_hostname_list = None, return_fields = None):
+        # Authenticated function
+        assert self.caller is not None
+
+        # Remove admin only fields
+        if 'admin' not in self.caller['roles']:
+            for key in ['boot_nonce', 'key', 'session', 'root_person_ids']:
+                del self.return_fields[key]
+
+        # Make sure that only valid fields are specified
+        if return_fields is None:
+            return_fields = self.return_fields
+        elif filter(lambda field: field not in self.return_fields, return_fields):
+            raise PLCInvalidArgument, "Invalid return field specified"
+
+        # Get node information
+        nodes = Nodes(self.api, node_id_or_hostname_list, return_fields).values()
+
+        # Filter out undesired or None fields (XML-RPC cannot marshal
+        # None) and turn each node into a real dict.
+        valid_return_fields_only = lambda (key, value): \
+                                   key in return_fields and value is not None
+        nodes = [dict(filter(valid_return_fields_only, node.items())) \
+                 for node in nodes]
+                    
+        return nodes
diff --git a/PLC/Methods/AdmGetPersonRoles.py b/PLC/Methods/AdmGetPersonRoles.py
new file mode 100644 (file)
index 0000000..bfb7c47
--- /dev/null
@@ -0,0 +1,55 @@
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Persons import Person, Persons
+from PLC.Auth import PasswordAuth
+
+class AdmGetPersonRoles(Method):
+    """
+    Return the roles that the specified person has as a struct:
+
+    {'10': 'admin', '30': 'user', '20': 'pi', '40': 'tech'}
+
+    Admins can get the roles for any user. PIs can only get the roles
+    for members of their sites. All others may only get their own
+    roles.
+
+    Note that because of XML-RPC marshalling limitations, the keys to
+    this struct are string representations of the integer role
+    identifiers.
+    """
+
+    roles = ['admin', 'pi', 'user', 'tech']
+
+    accepts = [
+        PasswordAuth(),
+        Mixed(Person.fields['person_id'],
+              Person.fields['email'])
+        ]
+
+    returns = dict
+
+    # Stupid return type, and can get now roles through
+    # AdmGetPersons().
+    status = "useless"
+
+    def call(self, auth, person_id_or_email):
+        # Get account information
+        persons = Persons(self.api, [person_id_or_email])
+        if not persons:
+            raise PLCInvalidArgument, "No such account"
+
+        person = persons.values()[0]
+
+        # Authenticated function
+        assert self.caller is not None
+
+        # Check if we can view this account
+        if not self.caller.can_view(person):
+            raise PLCPermissionDenied, "Not allowed to view specified account"
+
+        # Stringify the keys!
+        role_ids = map(str, person['role_ids'])
+        roles = person['roles']
+
+        return dict(zip(role_ids, roles))
diff --git a/PLC/Methods/AdmGetPersonSites.py b/PLC/Methods/AdmGetPersonSites.py
new file mode 100644 (file)
index 0000000..9c82f5b
--- /dev/null
@@ -0,0 +1,40 @@
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Persons import Person, Persons
+from PLC.Sites import Site, Sites
+from PLC.Auth import PasswordAuth
+
+from PLC.Methods.AdmGetPersons import AdmGetPersons
+
+class AdmGetPersonSites(AdmGetPersons):
+    """
+    Returns the sites that the specified person is associated with as
+    an array of site identifiers.
+
+    Admins may retrieve details about anyone. Users and techs may only
+    retrieve details about themselves. PIs may retrieve details about
+    themselves and others at their sites.
+    """
+
+    roles = ['admin', 'pi', 'user', 'tech']
+
+    accepts = [
+        PasswordAuth(),
+        Mixed(Person.fields['person_id'],
+              Person.fields['email'])
+        ]
+
+    returns = [Site.fields['site_id']]
+
+    def call(self, auth, person_id_or_email):
+        persons = AdmGetPersons.call(self, auth, [person_id_or_email])
+
+        # AdmGetPersons() validates person_id_or_email
+        assert persons
+        person = persons[0]
+
+        # Filter out deleted sites
+        sites = Sites(self.api, persons['site_ids'])
+
+        return [site['site_id'] for site in sites]
diff --git a/PLC/Methods/AdmGetPersons.py b/PLC/Methods/AdmGetPersons.py
new file mode 100644 (file)
index 0000000..b42391f
--- /dev/null
@@ -0,0 +1,72 @@
+import os
+
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Persons import Person, Persons
+from PLC.Auth import PasswordAuth
+
+class AdmGetPersons(Method):
+    """
+    Return an array of dictionaries containing details about the
+    specified accounts.
+
+    Admins may retrieve details about all accounts by not specifying
+    person_id_or_email_list or by specifying an empty list. Users and
+    techs may only retrieve details about themselves. PIs may retrieve
+    details about themselves and others at their sites.
+
+    If return_fields is specified, only the specified fields will be
+    returned, if set. Otherwise, the default set of fields returned is:
+
+    """
+
+    roles = ['admin', 'pi', 'user', 'tech']
+
+    accepts = [
+        PasswordAuth(),
+        [Mixed(Person.fields['person_id'],
+               Person.fields['email'])],
+        Parameter([str], 'List of fields to return')
+        ]
+
+    # Filter out password and deleted fields
+    can_return = lambda (field, value): field not in ['password', 'deleted']
+    default_fields = dict(filter(can_return, Person.default_fields.items()))
+    return_fields = dict(filter(can_return, Person.all_fields.items()))
+    returns = [return_fields]
+
+    def __init__(self, *args, **kwds):
+        Method.__init__(self, *args, **kwds)
+        # Update documentation with list of default fields returned
+        self.__doc__ += os.linesep.join(self.default_fields.keys())
+
+    def call(self, auth, person_id_or_email_list = None, return_fields = None):
+        # Make sure that only valid fields are specified
+        if return_fields is None:
+            return_fields = self.return_fields
+        elif filter(lambda field: field not in self.return_fields, return_fields):
+            raise PLCInvalidArgument, "Invalid return field specified"
+
+        # Authenticated function
+        assert self.caller is not None
+
+        # Only admins can not specify person_id_or_email_list or
+        # specify an empty list.
+        if not person_id_or_email_list and 'admin' not in self.caller['roles']:
+            raise PLCInvalidArgument, "List of accounts to retrieve not specified"
+
+        # Get account information
+        persons = Persons(self.api, person_id_or_email_list)
+
+        # Filter out accounts that are not viewable and turn into list
+        persons = filter(self.caller.can_view, persons.values())
+
+        # Filter out undesired or None fields (XML-RPC cannot marshal
+        # None) and turn each person into a real dict.
+        valid_return_fields_only = lambda (key, value): \
+                                   key in return_fields and value is not None
+        persons = [dict(filter(valid_return_fields_only, person.items())) \
+                   for person in persons]
+                    
+        return persons
diff --git a/PLC/Methods/AdmGetSites.py b/PLC/Methods/AdmGetSites.py
new file mode 100644 (file)
index 0000000..5f74fcf
--- /dev/null
@@ -0,0 +1,55 @@
+import os
+
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Auth import PasswordAuth
+from PLC.Sites import Site, Sites
+
+class AdmGetSites(Method):
+    """
+    Return an array of structs containing details about all sites. If
+    site_id_list is specified, only the specified sites will be
+    queried.
+
+    If return_fields is specified, only the specified fields will be
+    returned, if set. Otherwise, the default set of fields returned is:
+
+    """
+
+    roles = ['admin', 'pi', 'user', 'tech']
+
+    accepts = [
+        PasswordAuth(),
+        [Mixed(Site.fields['site_id'],
+               Site.fields['login_base'])],
+        Parameter([str], 'List of fields to return')
+        ]
+
+    # Filter out deleted fields
+    can_return = lambda (field, value): field not in ['deleted']
+    return_fields = dict(filter(can_return, Site.all_fields.items()))
+    returns = [return_fields]
+
+    def __init__(self, *args, **kwds):
+        Method.__init__(self, *args, **kwds)
+        # Update documentation with list of default fields returned
+        self.__doc__ += os.linesep.join(Site.default_fields.keys())
+
+    def call(self, auth, site_id_or_login_base_list = None, return_fields = None):
+        # Make sure that only valid fields are specified
+        if return_fields is None:
+            return_fields = self.return_fields
+        elif filter(lambda field: field not in self.return_fields, return_fields):
+            raise PLCInvalidArgument, "Invalid return field specified"
+
+        # Get site information
+        sites = Sites(self.api, site_id_or_login_base_list, return_fields)
+
+        # Filter out undesired or None fields (XML-RPC cannot marshal
+        # None) and turn each site into a real dict.
+        valid_return_fields_only = lambda (key, value): \
+                                   key in return_fields and value is not None
+        sites = [dict(filter(valid_return_fields_only, site.items())) \
+                 for site in sites.values()]
+
+        return sites
diff --git a/PLC/Methods/AdmGrantRoleToPerson.py b/PLC/Methods/AdmGrantRoleToPerson.py
new file mode 100644 (file)
index 0000000..9d1cc6d
--- /dev/null
@@ -0,0 +1,60 @@
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Persons import Person, Persons
+from PLC.Auth import PasswordAuth
+from PLC.Roles import Roles
+
+class AdmGrantRoleToPerson(Method):
+    """
+    Grants the specified role to the person.
+    
+    PIs can only grant the tech and user roles to users and techs at
+    their sites. Admins can grant any role to any user.
+
+    Returns 1 if successful, faults otherwise.
+    """
+
+    roles = ['admin', 'pi']
+
+    accepts = [
+        PasswordAuth(),
+        Mixed(Person.fields['person_id'],
+              Person.fields['email']),
+        Roles.fields['role_id']
+        ]
+
+    returns = Parameter(int, '1 if successful')
+
+    def call(self, auth, person_id_or_email, role_id):
+        # Get all roles
+        roles = Roles(self.api)
+        if role_id not in roles:
+            raise PLCInvalidArgument, "Invalid role ID"
+
+        # Get account information
+        persons = Persons(self.api, [person_id_or_email])
+        if not persons:
+            raise PLCInvalidArgument, "No such account"
+
+        person = persons.values()[0]
+
+        # Authenticated function
+        assert self.caller is not None
+
+        # Check if we can update this account
+        if not self.caller.can_update(person):
+            raise PLCPermissionDenied, "Not allowed to update specified account"
+
+        # Can only grant lesser (higher) roles to others
+        if 'admin' not in self.caller['roles'] and \
+           role_id <= min(self.caller['role_ids']):
+            raise PLCInvalidArgument, "Not allowed to grant that role"
+
+        if role_id not in person['role_ids']:
+            person_id = person['person_id']
+            self.api.db.do("INSERT INTO person_roles (person_id, role_id)" \
+                           " VALUES(%(person_id)d, %(role_id)d)",
+                           locals())
+
+        return 1
diff --git a/PLC/Methods/AdmIsPersonInRole.py b/PLC/Methods/AdmIsPersonInRole.py
new file mode 100644 (file)
index 0000000..b5fc97e
--- /dev/null
@@ -0,0 +1,54 @@
+from types import StringTypes
+
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Persons import Person, Persons
+from PLC.Auth import PasswordAuth
+from PLC.Roles import Roles
+
+class AdmIsPersonInRole(Method):
+    """
+    Returns 1 if the specified account has the specified role, 0
+    otherwise. This function differs from AdmGetPersonRoles() in that
+    any authorized user can call it. It is currently restricted to
+    verifying PI roles.
+    """
+
+    roles = ['admin', 'pi', 'user', 'tech']
+
+    accepts = [
+        PasswordAuth(),
+        Mixed(Person.fields['person_id'],
+              Person.fields['email']),
+        Roles.fields['role_id']
+        ]
+
+    returns = Parameter(int, "1 if account has role, 0 otherwise")
+
+    status = "useless"
+
+    def call(self, auth, person_id_or_email, role_id):
+        # This is a totally fucked up function. I have no idea why it
+        # exists or who calls it, but here is how it is supposed to
+        # work.
+
+        # Only allow PI roles to be checked
+        roles = Roles(self.api)
+        if not roles.has_key(role_id) or roles[role_id] != "pi":
+            raise PLCInvalidArgument, "Only the PI role may be checked"
+
+        # Get account information
+        persons = Persons(self.api, [person_id_or_email])
+
+        # Rather than raise an error, and indicate whether or not
+        # the person is real, return 0.
+        if not persons:
+            return 0
+
+        person = persons.values()[0]
+
+        if role_id in person['role_ids']:
+            return 1
+
+        return 0
diff --git a/PLC/Methods/AdmRemovePersonFromSite.py b/PLC/Methods/AdmRemovePersonFromSite.py
new file mode 100644 (file)
index 0000000..c957770
--- /dev/null
@@ -0,0 +1,52 @@
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Persons import Person, Persons
+from PLC.Sites import Site, Sites
+from PLC.Auth import PasswordAuth
+
+class AdmRemovePersonFromSite(Method):
+    """
+    Removes the specified person from the specified site. If the
+    person is not a member of the specified site, no error is
+    returned.
+
+    Returns 1 if successful, faults otherwise.
+    """
+
+    roles = ['admin']
+
+    accepts = [
+        PasswordAuth(),
+        Mixed(Person.fields['person_id'],
+              Person.fields['email']),
+        Mixed(Site.fields['site_id'],
+              Site.fields['login_base'])
+        ]
+
+    returns = Parameter(int, '1 if successful')
+
+    def call(self, auth, person_id_or_email, site_id_or_login_base):
+        # Get account information
+        persons = Persons(self.api, [person_id_or_email])
+        if not persons:
+            raise PLCInvalidArgument, "No such account"
+
+        person = persons.values()[0]
+
+        # Get site information
+        sites = Sites(self.api, [site_id_or_login_base])
+        if not sites:
+            raise PLCInvalidArgument, "No such site"
+
+        site = sites.values()[0]
+
+        if site['site_id'] in person['site_ids']:
+            person_id = person['person_id']
+            site_id = site['site_id']
+            self.api.db.do("DELETE FROM person_site" \
+                           " WHERE person_id = %(person_id)d" \
+                           " AND site_id = %(site_id)d",
+                           locals())
+
+        return 1
diff --git a/PLC/Methods/AdmRevokeRoleFromPerson.py b/PLC/Methods/AdmRevokeRoleFromPerson.py
new file mode 100644 (file)
index 0000000..61dea2f
--- /dev/null
@@ -0,0 +1,61 @@
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Persons import Person, Persons
+from PLC.Auth import PasswordAuth
+from PLC.Roles import Roles
+
+class AdmRevokeRoleFromPerson(Method):
+    """
+    Revokes the specified role from the person.
+    
+    PIs can only revoke the tech and user roles from users and techs
+    at their sites. Admins can revoke any role from any user.
+
+    Returns 1 if successful, faults otherwise.
+    """
+
+    roles = ['admin', 'pi']
+
+    accepts = [
+        PasswordAuth(),
+        Mixed(Person.fields['person_id'],
+              Person.fields['email']),
+        Parameter(int, 'Role ID')
+        ]
+
+    returns = Parameter(int, '1 if successful')
+
+    def call(self, auth, person_id_or_email, role_id):
+        # Get all roles
+        roles = Roles(self.api)
+        if role_id not in roles:
+            raise PLCInvalidArgument, "Invalid role ID"
+
+        # Get account information
+        persons = Persons(self.api, [person_id_or_email])
+        if not persons:
+            raise PLCInvalidArgument, "No such account"
+
+        person = persons.values()[0]
+
+        # Authenticated function
+        assert self.caller is not None
+
+        # Check if we can update this account
+        if not self.caller.can_update(person):
+            raise PLCPermissionDenied, "Not allowed to update specified account"
+
+        # Can only revoke lesser (higher) roles from others
+        if 'admin' not in self.caller['roles'] and \
+           role_id <= min(self.caller['role_ids']):
+            raise PLCPermissionDenied, "Not allowed to revoke that role"
+
+        if role_id in person['role_ids']:
+            person_id = person['person_id']
+            self.api.db.do("DELETE FROM person_roles" \
+                           " WHERE person_id = %(person_id)d" \
+                           " AND role_id = %(role_id)d",
+                           locals())
+
+        return 1
diff --git a/PLC/Methods/AdmSetPersonEnabled.py b/PLC/Methods/AdmSetPersonEnabled.py
new file mode 100644 (file)
index 0000000..87aa923
--- /dev/null
@@ -0,0 +1,47 @@
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Persons import Person, Persons
+from PLC.Auth import PasswordAuth
+
+class AdmSetPersonEnabled(Method):
+    """
+    Enables or disables a person.
+
+    Users and techs can only update themselves. PIs can only update
+    themselves and other non-PIs at their sites. Admins can update
+    anyone.
+
+    Returns 1 if successful, faults otherwise.
+    """
+
+    roles = ['admin', 'pi']
+
+    accepts = [
+        PasswordAuth(),
+        Mixed(Person.fields['person_id'],
+              Person.fields['email']),
+        Person.fields['enabled']
+        ]
+
+    returns = Parameter(int, '1 if successful')
+
+    def call(self, auth, person_id_or_email, enabled):
+        # Get account information
+        persons = Persons(self.api, [person_id_or_email])
+        if not persons:
+            raise PLCInvalidArgument, "No such account"
+
+        person = persons.values()[0]
+
+        # Authenticated function
+        assert self.caller is not None
+
+        # Check if we can update this account
+        if not self.caller.can_update(person):
+            raise PLCPermissionDenied, "Not allowed to enable specified account"
+
+        person['enabled'] = enabled
+        person.flush()
+
+        return 1
diff --git a/PLC/Methods/AdmSetPersonPrimarySite.py b/PLC/Methods/AdmSetPersonPrimarySite.py
new file mode 100644 (file)
index 0000000..3246333
--- /dev/null
@@ -0,0 +1,64 @@
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Persons import Person, Persons
+from PLC.Sites import Site, Sites
+from PLC.Auth import PasswordAuth
+
+class AdmSetPersonPrimarySite(Method):
+    """
+    Makes the specified site the person's primary site. The person
+    must already be a member of the site.
+
+    Admins may update anyone. All others may only update themselves.
+    """
+
+    roles = ['admin', 'pi', 'user', 'tech']
+
+    accepts = [
+        PasswordAuth(),
+        Mixed(Person.fields['person_id'],
+              Person.fields['email']),
+        Mixed(Site.fields['site_id'],
+              Site.fields['login_base'])
+        ]
+
+    returns = Parameter(int, '1 if successful')
+
+    def call(self, auth, person_id_or_email, site_id_or_login_base):
+        # Get account information
+        persons = Persons(self.api, [person_id_or_email])
+        if not persons:
+            raise PLCInvalidArgument, "No such account"
+
+        person = persons.values()[0]
+
+        # Authenticated function
+        assert self.caller is not None
+
+        # Non-admins can only update their own primary site
+        if 'admin' not in self.caller['roles'] and \
+           self.caller['person_id'] != person['person_id']:
+            raise PLCPermissionDenied, "Not allowed to update specified account"
+
+        # Get site information
+        sites = Sites(self.api, [site_id_or_login_base])
+        if not sites:
+            raise PLCInvalidArgument, "No such site"
+
+        site = sites.values()[0]
+
+        if site['site_id'] not in person['site_ids']:
+            raise PLCInvalidArgument, "Not a member of the specified site"
+
+        person_id = person['person_id']
+        site_id = site['site_id']
+        self.api.db.do("UPDATE person_site SET is_primary = False" \
+                       " WHERE person_id = %(person_id)d",
+                       locals())
+        self.api.db.do("UPDATE person_site SET is_primary = True" \
+                       " WHERE person_id = %(person_id)d" \
+                       " AND site_id = %(site_id)d",
+                       locals())
+
+        return 1
diff --git a/PLC/Methods/AdmUpdateNode.py b/PLC/Methods/AdmUpdateNode.py
new file mode 100644 (file)
index 0000000..e5391af
--- /dev/null
@@ -0,0 +1,73 @@
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Nodes import Node, Nodes
+from PLC.Auth import PasswordAuth
+
+class AdmUpdateNode(Method):
+    """
+    Updates a node. Only the fields specified in update_fields are
+    updated, all other fields are left untouched.
+
+    To remove a value without setting a new one in its place (for
+    example, to remove an address from the node), specify -1 for int
+    and double fields and 'null' for string fields. hostname and
+    boot_state cannot be unset.
+    
+    PIs and techs can only update the nodes at their sites.
+
+    Returns 1 if successful, faults otherwise.
+    """
+
+    roles = ['admin', 'pi', 'tech']
+
+    can_update = lambda (field, value): field in \
+                 ['hostname', 'boot_state', 'model', 'version']
+    update_fields = dict(filter(can_update, Node.fields.items()))
+
+    accepts = [
+        PasswordAuth(),
+        Mixed(Node.fields['node_id'],
+              Node.fields['hostname']),
+        update_fields
+        ]
+
+    returns = Parameter(int, '1 if successful')
+
+    def call(self, auth, node_id_or_hostname, update_fields):
+        if filter(lambda field: field not in self.update_fields, update_fields):
+            raise PLCInvalidArgument, "Invalid field specified"
+
+        # XML-RPC cannot marshal None, so we need special values to
+        # represent "unset".
+        for key, value in update_fields.iteritems():
+            if value == -1 or value == "null":
+                if key in ['hostname', 'boot_state']:
+                    raise PLCInvalidArgument, "hostname and boot_state cannot be unset"
+                update_fields[key] = None
+
+        # Get account information
+        nodes = Nodes(self.api, [node_id_or_hostname])
+        if not nodes:
+            raise PLCInvalidArgument, "No such node"
+
+        node = nodes.values()[0]
+
+        # If we are not an admin, make sure that the caller is a
+        # member of the site at which the node is located.
+        if 'admin' not in self.caller['roles']:
+            # Authenticated function
+            assert self.caller is not None
+
+            if node['site_id'] not in self.caller['site_ids']:
+                raise PLCPermissionDenied, "Not allowed to delete nodes from specified site"
+
+        # Check if we can update this account
+        node = nodes.values()[0]
+        if not self.caller.can_update(node):
+            raise PLCPermissionDenied, "Not allowed to update specified account"
+
+        node.update(update_fields)
+        node.flush()
+
+        return 1
diff --git a/PLC/Methods/AdmUpdatePerson.py b/PLC/Methods/AdmUpdatePerson.py
new file mode 100644 (file)
index 0000000..a24ab2a
--- /dev/null
@@ -0,0 +1,68 @@
+from PLC.Faults import *
+from PLC.Method import Method
+from PLC.Parameter import Parameter, Mixed
+from PLC.Persons import Person, Persons
+from PLC.Auth import PasswordAuth
+
+class AdmUpdatePerson(Method):
+    """
+    Updates a person. Only the fields specified in update_fields are
+    updated, all other fields are left untouched.
+
+    To remove a value without setting a new one in its place (for
+    example, to remove an address from the person), specify -1 for int
+    and double fields and 'null' for string fields. first_name and
+    last_name cannot be unset.
+    
+    Users and techs can only update themselves. PIs can only update
+    themselves and other non-PIs at their sites.
+
+    Returns 1 if successful, faults otherwise.
+    """
+
+    roles = ['admin', 'pi', 'user', 'tech']
+
+    can_update = lambda (field, value): field in \
+                 ['first_name', 'last_name', 'title', 'email',
+                  'password', 'phone', 'url', 'bio', 'accepted_aup']
+    update_fields = dict(filter(can_update, Person.fields.items()))
+
+    accepts = [
+        PasswordAuth(),
+        Mixed(Person.fields['person_id'],
+              Person.fields['email']),
+        update_fields
+        ]
+
+    returns = Parameter(int, '1 if successful')
+
+    def call(self, auth, person_id_or_email, update_fields):
+        if filter(lambda field: field not in self.update_fields, update_fields):
+            raise PLCInvalidArgument, "Invalid field specified"
+
+        # XML-RPC cannot marshal None, so we need special values to
+        # represent "unset".
+        for key, value in update_fields.iteritems():
+            if value == -1 or value == "null":
+                if key in ['first_name', 'last_name']:
+                    raise PLCInvalidArgument, "first_name and last_name cannot be unset"
+                update_fields[key] = None
+
+        # Get account information
+        persons = Persons(self.api, [person_id_or_email])
+        if not persons:
+            raise PLCInvalidArgument, "No such account"
+
+        person = persons.values()[0]
+
+        # Authenticated function
+        assert self.caller is not None
+
+        # Check if we can update this account
+        if not self.caller.can_update(person):
+            raise PLCPermissionDenied, "Not allowed to update specified account"
+
+        person.update(update_fields)
+        person.flush()
+
+        return 1
diff --git a/PLC/Methods/__init__.py b/PLC/Methods/__init__.py
new file mode 100644 (file)
index 0000000..3ea8f6a
--- /dev/null
@@ -0,0 +1 @@
+methods = 'AdmAddNode AdmAddPerson AdmAddPersonToSite AdmAddSite AdmAuthCheck AdmDeleteNode AdmDeletePerson AdmDeleteSite AdmGetAllRoles AdmGetNodes AdmGetPersonRoles AdmGetPersonSites AdmGetPersons AdmGetSites AdmGrantRoleToPerson AdmIsPersonInRole AdmRemovePersonFromSite AdmRevokeRoleFromPerson AdmSetPersonEnabled AdmSetPersonPrimarySite AdmUpdateNode AdmUpdatePerson AuthenticatePrincipal  system.listMethods  system.methodHelp  system.methodSignature  system.multicall'.split()
diff --git a/PLC/Methods/system/.cvsignore b/PLC/Methods/system/.cvsignore
new file mode 100644 (file)
index 0000000..0d20b64
--- /dev/null
@@ -0,0 +1 @@
+*.pyc
diff --git a/PLC/Methods/system/__init__.py b/PLC/Methods/system/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/PLC/Methods/system/listMethods.py b/PLC/Methods/system/listMethods.py
new file mode 100644 (file)
index 0000000..c8cfa37
--- /dev/null
@@ -0,0 +1,20 @@
+from PLC.Method import Method
+from PLC.Parameter import Parameter
+import PLC.Methods
+
+class listMethods(Method):
+    """
+    This method lists all the methods that the XML-RPC server knows
+    how to dispatch.
+    """
+
+    roles = []
+    accepts = []
+    returns = Parameter(list, 'List of methods')
+
+    def __init__(self, api):
+        Method.__init__(self, api)
+        self.name = "system.listMethods"
+
+    def call(self):
+        return self.api.methods
diff --git a/PLC/Methods/system/methodHelp.py b/PLC/Methods/system/methodHelp.py
new file mode 100644 (file)
index 0000000..22a0dc1
--- /dev/null
@@ -0,0 +1,20 @@
+from PLC.Method import Method
+from PLC.Parameter import Parameter
+
+class methodHelp(Method):
+    """
+    Returns help text if defined for the method passed, otherwise
+    returns an empty string.
+    """
+
+    roles = []
+    accepts = [Parameter(str, 'Method name')]
+    returns = Parameter(str, 'Method help')
+
+    def __init__(self, api):
+        Method.__init__(self, api)
+        self.name = "system.methodHelp"
+
+    def call(self, method):
+        function = self.api.callable(method)
+        return function.help()
diff --git a/PLC/Methods/system/methodSignature.py b/PLC/Methods/system/methodSignature.py
new file mode 100644 (file)
index 0000000..4b049a1
--- /dev/null
@@ -0,0 +1,60 @@
+from PLC.Parameter import Parameter, Mixed
+from PLC.Method import Method, xmlrpc_type
+
+class methodSignature(Method):
+    """
+    Returns an array of known signatures (an array of arrays) for the
+    method name passed. If no signatures are known, returns a
+    none-array (test for type != array to detect missing signature).
+    """
+
+    roles = []
+    accepts = [Parameter(str, "Method name")]
+    returns = [Parameter([str], "Method signature")]
+
+    def __init__(self, api):
+        Method.__init__(self, api)
+        self.name = "system.methodSignature"
+
+    def possible_signatures(self, signature, arg):
+        """
+        Return a list of the possible new signatures given a current
+        signature and the next argument.
+        """
+
+        if isinstance(arg, Mixed):
+            arg_types = [xmlrpc_type(mixed_arg) for mixed_arg in arg]
+        else:
+            arg_types = [xmlrpc_type(arg)]
+
+        return [signature + [arg_type] for arg_type in arg_types]
+
+    def signatures(self, returns, args):
+        """
+        Returns a list of possible signatures given a return value and
+        a set of arguments.
+        """
+
+        signatures = [[xmlrpc_type(returns)]]
+
+        for arg in args:
+            # Create lists of possible new signatures for each current
+            # signature. Reduce the list of lists back down to a
+            # single list.
+            signatures = reduce(lambda a, b: a + b,
+                                [self.possible_signatures(signature, arg) \
+                                 for signature in signatures])
+
+        return signatures
+
+    def call(self, method):
+        function = self.api.callable(method)
+        (min_args, max_args, defaults) = function.args()
+
+        signatures = []
+
+        assert len(max_args) >= len(min_args)
+        for num_args in range(len(min_args), len(max_args) + 1):
+            signatures += self.signatures(function.returns, function.accepts[:num_args])
+
+        return signatures
diff --git a/PLC/Methods/system/multicall.py b/PLC/Methods/system/multicall.py
new file mode 100644 (file)
index 0000000..64563ef
--- /dev/null
@@ -0,0 +1,54 @@
+import sys
+import xmlrpclib
+
+from PLC.Parameter import Parameter, Mixed
+from PLC.Method import Method
+
+class multicall(Method):
+    """
+    Process an array of calls, and return an array of results. Calls
+    should be structs of the form
+
+    {'methodName': string, 'params': array}
+
+    Each result will either be a single-item array containg the result
+    value, or a struct of the form
+
+    {'faultCode': int, 'faultString': string}
+
+    This is useful when you need to make lots of small calls without
+    lots of round trips.
+    """
+
+    roles = []
+    accepts = [[{'methodName': Parameter(str, "Method name"),
+                 'params': Parameter(list, "Method arguments")}]]
+    returns = Mixed([Mixed()],
+                    {'faultCode': Parameter(int, "XML-RPC fault code"),
+                     'faultString': Parameter(int, "XML-RPC fault detail")})
+
+    def __init__(self, api):
+        Method.__init__(self, api)
+        self.name = "system.multicall"
+
+    def call(self, calls):
+        # Some error codes, borrowed from xmlrpc-c.
+        REQUEST_REFUSED_ERROR = -507
+
+        results = []
+        for call in calls:
+            try:
+                name = call['methodName']
+                params = call['params']
+                if name == 'system.multicall':
+                    errmsg = "Recursive system.multicall forbidden"
+                    raise xmlrpclib.Fault(REQUEST_REFUSED_ERROR, errmsg)
+                result = [self.api.call(self.source, name, *params)]
+            except xmlrpclib.Fault, fault:
+                result = {'faultCode': fault.faultCode,
+                          'faultString': fault.faultString}
+            except:
+                errmsg = "%s:%s" % (sys.exc_type, sys.exc_value)
+                result = {'faultCode': 1, 'faultString': errmsg}
+            results.append(result)
+        return results
diff --git a/PLC/NodeGroups.py b/PLC/NodeGroups.py
new file mode 100644 (file)
index 0000000..aff2c8f
--- /dev/null
@@ -0,0 +1,144 @@
+#
+# Functions for interacting with the nodegroups table in the database
+#
+# Mark Huang <mlhuang@cs.princeton.edu>
+# Copyright (C) 2006 The Trustees of Princeton University
+#
+# $Id$
+#
+
+from PLC.Faults import *
+from PLC.Parameter import Parameter
+from PLC.Debug import profile
+from PLC.Table import Row, Table
+
+class NodeGroup(Row):
+    """
+    Representation of a row in the nodegroups table. To use, optionally
+    instantiate with a dict of values. Update as you would a
+    dict. Commit to the database with flush().
+    """
+
+    fields = {
+        'nodegroup_id': Parameter(int, "Node group identifier"),
+        'name': Parameter(str, "Node group name"),
+        'description': Parameter(str, "Node group description"),
+        'is_custom': Parameter(bool, "Is a custom node group (i.e., is not a site node group)")
+        }
+
+    # These fields are derived from join tables and are not
+    # actually in the nodegroups table.
+    join_fields = {
+        'node_ids': Parameter([int], "List of nodes in this node group"),
+        }
+
+    def __init__(self, api, fields):
+        Row.__init__(self, fields)
+        self.api = api
+
+    def validate_name(self, name):
+        conflicts = NodeGroups(self.api, [name])
+        for nodegroup_id in conflicts:
+            if 'nodegroup_id' not in self or self['nodegroup_id'] != nodegroup_id:
+                raise PLCInvalidArgument, "Node group name already in use"
+
+    def flush(self, commit = True):
+        """
+        Flush changes back to the database.
+        """
+
+        self.validate()
+
+        # Fetch a new nodegroup_id if necessary
+        if 'nodegroup_id' not in self:
+            rows = self.api.db.selectall("SELECT NEXTVAL('nodegroups_nodegroup_id_seq') AS nodegroup_id")
+            if not rows:
+                raise PLCDBError, "Unable to fetch new nodegroup_id"
+            self['nodegroup_id'] = rows[0]['nodegroup_id']
+            insert = True
+        else:
+            insert = False
+
+        # Filter out fields that cannot be set or updated directly
+        fields = dict(filter(lambda (key, value): key in self.fields,
+                             self.items()))
+
+        # Parameterize for safety
+        keys = fields.keys()
+        values = [self.api.db.param(key, value) for (key, value) in fields.items()]
+
+        if insert:
+            # Insert new row in nodegroups table
+            sql = "INSERT INTO nodegroups (%s) VALUES (%s)" % \
+                  (", ".join(keys), ", ".join(values))
+        else:
+            # Update existing row in sites table
+            columns = ["%s = %s" % (key, value) for (key, value) in zip(keys, values)]
+            sql = "UPDATE nodegroups SET " + \
+                  ", ".join(columns) + \
+                  " WHERE nodegroup_id = %(nodegroup_id)d"
+
+        self.api.db.do(sql, fields)
+
+        if commit:
+            self.api.db.commit()
+
+    def delete(self, commit = True):
+        """
+        Delete existing nodegroup from the database.
+        """
+
+        assert 'nodegroup_id' in self
+
+        # Delete ourself
+        tables = ['nodegroup_nodes', 'override_bootscripts',
+                  'conf_assoc', 'node_root_access']
+
+        if self['is_custom']:
+            tables.append('nodegroups')
+        else:
+            # XXX Cannot delete site node groups yet
+            pass
+
+        for table in tables:
+            self.api.db.do("DELETE FROM %s" \
+                           " WHERE nodegroup_id = %(nodegroup_id)" % \
+                           table, self)
+
+        if commit:
+            self.api.db.commit()
+
+class NodeGroups(Table):
+    """
+    Representation of row(s) from the nodegroups table in the
+    database.
+    """
+
+    def __init__(self, api, nodegroup_id_or_name_list = None):
+        self.api = api
+
+        # N.B.: Node IDs returned may be deleted.
+        sql = "SELECT nodegroups.*, nodegroup_nodes.node_id" \
+              " FROM nodegroups" \
+              " LEFT JOIN nodegroup_nodes USING (nodegroup_id)"
+
+        if nodegroup_id_or_name_list:
+            # Separate the list into integers and strings
+            nodegroup_ids = filter(lambda nodegroup_id: isinstance(nodegroup_id, (int, long)),
+                                   nodegroup_id_or_name_list)
+            names = filter(lambda name: isinstance(name, StringTypes),
+                           nodegroup_id_or_name_list)
+            sql += " AND (False"
+            if nodegroup_ids:
+                sql += " OR nodegroup_id IN (%s)" % ", ".join(map(str, nodegroup_ids))
+            if names:
+                sql += " OR name IN (%s)" % ", ".join(api.db.quote(names)).lower()
+            sql += ")"
+
+        rows = self.api.db.selectall(sql)
+        for row in rows:
+            if self.has_key(row['nodegroup_id']):
+                nodegroup = self[row['nodegroup_id']]
+                nodegroup.update(row)
+            else:
+                self[row['nodegroup_id']] = NodeGroup(api, row)
diff --git a/PLC/NodeNetworks.py b/PLC/NodeNetworks.py
new file mode 100644 (file)
index 0000000..80bd620
--- /dev/null
@@ -0,0 +1,258 @@
+#
+# Functions for interacting with the nodenetworks table in the database
+#
+# Mark Huang <mlhuang@cs.princeton.edu>
+# Copyright (C) 2006 The Trustees of Princeton University
+#
+# $Id$
+#
+
+from types import StringTypes
+import socket
+import struct
+
+from PLC.Faults import *
+from PLC.Parameter import Parameter
+from PLC.Debug import profile
+from PLC.Table import Row, Table
+import PLC.Nodes
+
+def in_same_network(address1, address2, netmask):
+    """
+    Returns True if two IPv4 addresses are in the same network. Faults
+    if an address is invalid.
+    """
+
+    address1 = struct.unpack('>L', socket.inet_aton(address1))[0]
+    address2 = struct.unpack('>L', socket.inet_aton(address2))[0]
+    netmask = struct.unpack('>L', socket.inet_aton(netmask))[0]
+
+    return (address1 & netmask) == (address2 & netmask)
+
+class NodeNetwork(Row):
+    """
+    Representation of a row in the nodenetworks table. To use, optionally
+    instantiate with a dict of values. Update as you would a
+    dict. Commit to the database with flush().
+    """
+
+    fields = {
+        'nodenetwork_id': Parameter(int, "Node interface identifier"),
+        'method': Parameter(str, "Addressing method (e.g., 'static' or 'dhcp')"),
+        'type': Parameter(str, "Address type (e.g., 'ipv4')"),
+        'ip': Parameter(str, "IP address"),
+        'mac': Parameter(str, "MAC address"),
+        'gateway': Parameter(str, "IP address of primary gateway"),
+        'network': Parameter(str, "Subnet address"),
+        'broadcast': Parameter(str, "Network broadcast address"),
+        'netmask': Parameter(str, "Subnet mask"),
+        'dns1': Parameter(str, "IP address of primary DNS server"),
+        'dns2': Parameter(str, "IP address of secondary DNS server"),
+        # XXX Should be an int (bps)
+        'bwlimit': Parameter(str, "Bandwidth limit"),
+        'hostname': Parameter(str, "(Optional) Hostname"),
+        }
+
+    # These fields are derived from join tables and are not
+    # actually in the nodenetworks table.
+    join_fields = {
+        'node_id': Parameter(int, "Node associated with this interface (if any)"),
+        'is_primary': Parameter(bool, "Is the primary interface for this node"),
+        }
+
+    methods = ['static', 'dhcp', 'proxy', 'tap', 'ipmi', 'unknown']
+
+    types = ['ipv4']
+
+    bwlimits = ['-1',
+                '100kbit', '250kbit', '500kbit',
+                '1mbit', '2mbit', '5mbit',
+                '10mbit', '20mbit', '50mbit',
+                '100mbit']
+
+    def __init__(self, api, fields):
+        Row.__init__(self, fields)
+        self.api = api
+
+    def validate_method(self, method):
+        if method not in self.methods:
+            raise PLCInvalidArgument, "Invalid addressing method"
+
+    def validate_type(self, type):
+        if type not in self.types:
+            raise PLCInvalidArgument, "Invalid address type"
+
+    def validate_ip(self, ip):
+        try:
+            ip = socket.inet_ntoa(socket.inet_aton(ip))
+        except socket.error:
+            raise PLCInvalidArgument, "Invalid IP address " + ip
+
+        return ip
+
+    def validate_mac(self, mac):
+        try:
+            bytes = mac.split(":")
+            if len(bytes) < 6:
+                raise Exception
+            for i, byte in enumerate(bytes):
+                byte = int(byte, 16)
+                if byte < 0 or byte > 255:
+                    raise Exception
+                bytes[i] = "%02x" % byte
+            mac = ":".join(bytes)
+        except:
+            raise PLCInvalidArgument, "Invalid MAC address"
+
+        return mac
+
+    validate_gateway = validate_ip
+    validate_network = validate_ip
+    validate_broadcast = validate_ip
+    validate_netmask = validate_ip
+    validate_dns1 = validate_ip
+    validate_dns2 = validate_ip
+
+    def validate_bwlimit(self, bwlimit):
+        if bwlimit not in self.bwlimits:
+            raise PLCInvalidArgument, "Invalid bandwidth limit"
+
+    def validate_hostname(self, hostname):
+        # Optional
+        if not hostname:
+            return hostname
+
+        # Validate hostname, and check for conflicts with a node hostname
+        return PLC.Nodes.Node.validate_hostname(self, hostname)
+
+    def flush(self, commit = True):
+        """
+        Flush changes back to the database.
+        """
+
+        # Validate all specified fields
+        self.validate()
+
+        try:
+            method = self['method']
+            self['type']
+        except KeyError:
+            raise PLCInvalidArgument, "method and type must both be specified"
+
+        if method == "proxy" or method == "tap":
+            if 'mac' in self:
+                raise PLCInvalidArgument, "For %s method, mac should not be specified" % method
+            if 'ip' not in self:
+                raise PLCInvalidArgument, "For %s method, ip is required" % method
+            if method == "tap" and 'gateway' not in self:
+                raise PLCInvalidArgument, "For tap method, gateway is required and should be " \
+                      "the IP address of the node that proxies for this address"
+            # Should check that the proxy address is reachable, but
+            # there's no way to tell if the only primary interface is
+            # DHCP!
+
+        elif method == "static":
+            for key in ['ip', 'gateway', 'network', 'broadcast', 'netmask', 'dns1']:
+                if key not in self:
+                    raise PLCInvalidArgument, "For static method, %s is required" % key
+                locals()[key] = self[key]
+            if not in_same_network(ip, network, netmask):
+                raise PLCInvalidArgument, "IP address %s is inconsistent with network %s/%s" % \
+                      (ip, network, netmask)
+            if not in_same_network(broadcast, network, netmask):
+                raise PLCInvalidArgument, "Broadcast address %s is inconsistent with network %s/%s" % \
+                      (broadcast, network, netmask)
+            if not in_same_network(ip, gateway, netmask):
+                raise PLCInvalidArgument, "Gateway %s is not reachable from %s/%s" % \
+                      (gateway, ip, netmask)
+
+        elif method == "ipmi":
+            if 'ip' not in self:
+                raise PLCInvalidArgument, "For ipmi method, ip is required"
+
+        # Fetch a new nodenetwork_id if necessary
+        if 'nodenetwork_id' not in self:
+            rows = self.api.db.selectall("SELECT NEXTVAL('nodenetworks_nodenetwork_id_seq') AS nodenetwork_id")
+            if not rows:
+                raise PLCDBError("Unable to fetch new nodenetwork_id")
+            self['nodenetwork_id'] = rows[0]['nodenetwork_id']
+            insert = True
+        else:
+            insert = False
+
+        # Filter out fields that cannot be set or updated directly
+        fields = dict(filter(lambda (key, value): key in self.fields,
+                             self.items()))
+
+        # Parameterize for safety
+        keys = fields.keys()
+        values = [self.api.db.param(key, value) for (key, value) in fields.items()]
+
+        if insert:
+            # Insert new row in nodenetworks table
+            sql = "INSERT INTO nodenetworks (%s) VALUES (%s)" % \
+                  (", ".join(keys), ", ".join(values))
+        else:
+            # Update existing row in sites table
+            columns = ["%s = %s" % (key, value) for (key, value) in zip(keys, values)]
+            sql = "UPDATE nodenetworks SET " + \
+                  ", ".join(columns) + \
+                  " WHERE nodenetwork_id = %(nodenetwork_id)d"
+
+        self.api.db.do(sql, fields)
+
+        if commit:
+            self.api.db.commit()
+
+    def delete(self, commit = True):
+        """
+        Delete existing nodenetwork.
+        """
+
+        assert 'nodenetwork_id' in self
+
+        # Delete ourself
+        for table in ['node_nodenetworks', 'nodenetworks']:
+            self.api.db.do("DELETE FROM %s" \
+                           " WHERE nodenetwork_id = %d" % \
+                           (table, self['nodenetwork_id']))
+        
+        if commit:
+            self.api.db.commit()
+
+class NodeNetworks(Table):
+    """
+    Representation of row(s) from the nodenetworks table in the
+    database.
+    """
+
+    def __init__(self, api, nodenetwork_id_or_hostname_list = None):
+        self.api = api
+
+        # N.B.: Node IDs returned may be deleted.
+        sql = "SELECT nodenetworks.*" \
+              ", node_nodenetworks.node_id" \
+              ", node_nodenetworks.is_primary" \
+              " FROM nodenetworks" \
+              " LEFT JOIN node_nodenetworks USING (nodenetwork_id)"
+
+        if nodenetwork_id_or_hostname_list:
+            # Separate the list into integers and strings
+            nodenetwork_ids = filter(lambda nodenetwork_id: isinstance(nodenetwork_id, (int, long)),
+                                     nodenetwork_id_or_hostname_list)
+            hostnames = filter(lambda hostname: isinstance(hostname, StringTypes),
+                           nodenetwork_id_or_hostname_list)
+            sql += " WHERE (False"
+            if nodenetwork_ids:
+                sql += " OR nodenetwork_id IN (%s)" % ", ".join(map(str, nodenetwork_ids))
+            if hostnames:
+                sql += " OR hostname IN (%s)" % ", ".join(api.db.quote(hostnames)).lower()
+            sql += ")"
+
+        rows = self.api.db.selectall(sql)
+        for row in rows:
+            if self.has_key(row['nodenetwork_id']):
+                nodenetwork = self[row['nodenetwork_id']]
+                nodenetwork.update(row)
+            else:
+                self[row['nodenetwork_id']] = NodeNetwork(api, row)
diff --git a/PLC/Nodes.py b/PLC/Nodes.py
new file mode 100644 (file)
index 0000000..4c93e3d
--- /dev/null
@@ -0,0 +1,271 @@
+#
+# Functions for interacting with the nodes table in the database
+#
+# Mark Huang <mlhuang@cs.princeton.edu>
+# Copyright (C) 2006 The Trustees of Princeton University
+#
+# $Id$
+#
+
+from types import StringTypes
+import re
+
+from PLC.Faults import *
+from PLC.Parameter import Parameter
+from PLC.Debug import profile
+from PLC.Table import Row, Table
+from PLC.NodeNetworks import NodeNetwork, NodeNetworks
+from PLC.BootStates import BootStates
+
+class Node(Row):
+    """
+    Representation of a row in the nodes table. To use, optionally
+    instantiate with a dict of values. Update as you would a
+    dict. Commit to the database with flush().
+    """
+
+    fields = {
+        'node_id': Parameter(int, "Node identifier"),
+        'hostname': Parameter(str, "Fully qualified hostname"),
+        'boot_state': Parameter(str, "Boot state"),
+        'model': Parameter(str, "Make and model of the actual machine"),
+        'boot_nonce': Parameter(str, "(Admin only) Random value generated by the node at last boot"),
+        'version': Parameter(str, "Apparent Boot CD version"),
+        'ssh_rsa_key': Parameter(str, "Last known SSH host key"),
+        'date_created': Parameter(str, "Date and time when node entry was created"),
+        'deleted': Parameter(bool, "Has been deleted"),
+        'key': Parameter(str, "(Admin only) Node key"),
+        'session': Parameter(str, "(Admin only) Node session value"),
+        }
+
+    # These fields are derived from join tables and are not actually
+    # in the nodes table.
+    join_fields = {
+        'nodenetwork_ids': Parameter([int], "List of network interfaces that this node has"),
+        }
+
+    # These fields are derived from join tables and are not returned
+    # by default unless specified.
+    extra_fields = {
+        'nodegroup_ids': Parameter([int], "List of node groups that this node is in"),
+        'conf_file_ids': Parameter([int], "List of configuration files specific to this node"),
+        'root_person_ids': Parameter([int], "(Admin only) List of people who have root access to this node"),
+        # XXX Too inefficient
+        # 'slice_ids': Parameter([int], "List of slices on this node"),
+        'pcu_ids': Parameter([int], "List of PCUs that control this node"),
+        'site_id': Parameter([int], "Site at which this node is located"),
+        }
+
+    # Primary interface values
+    primary_nodenetwork_fields = dict(filter(lambda (key, value): \
+                                             key not in ['node_id', 'is_primary', 'hostname'],
+                                             NodeNetwork.fields.items()))
+
+    extra_fields.update(primary_nodenetwork_fields)
+
+    default_fields = dict(fields.items() + join_fields.items())
+    all_fields = dict(default_fields.items() + extra_fields.items())
+
+    def __init__(self, api, fields):
+        Row.__init__(self, fields)
+        self.api = api
+
+    def validate_hostname(self, hostname):
+        # 1. Each part begins and ends with a letter or number.
+        # 2. Each part except the last can contain letters, numbers, or hyphens.
+        # 3. Each part is between 1 and 64 characters, including the trailing dot.
+        # 4. At least two parts.
+        # 5. Last part can only contain between 2 and 6 letters.
+        good_hostname = r'^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+' \
+                        r'[a-z]{2,6}$'
+        if not hostname or \
+           not re.match(good_hostname, hostname, re.IGNORECASE):
+            raise PLCInvalidArgument, "Invalid hostname"
+
+        conflicts = Nodes(self.api, [hostname])
+        for node_id, node in conflicts.iteritems():
+            if not node['deleted'] and ('node_id' not in self or self['node_id'] != node_id):
+                raise PLCInvalidArgument, "Hostname already in use"
+
+        # Check for conflicts with a nodenetwork hostname
+        conflicts = NodeNetworks(self.api, [hostname])
+        for nodenetwork_id in conflicts:
+            if 'nodenetwork_ids' not in self or nodenetwork_id not in self['nodenetwork_ids']:
+                raise PLCInvalidArgument, "Hostname already in use"
+
+        return hostname
+
+    def validate_boot_state(self, boot_state):
+        if boot_state not in BootStates(self.api):
+            raise PLCInvalidArgument, "Invalid boot state"
+
+        return boot_state
+
+    def flush(self, commit = True):
+        """
+        Flush changes back to the database.
+        """
+
+        self.validate()
+
+        # Fetch a new node_id if necessary
+        if 'node_id' not in self:
+            rows = self.api.db.selectall("SELECT NEXTVAL('nodes_node_id_seq') AS node_id")
+            if not rows:
+                raise PLCDBError, "Unable to fetch new node_id"
+            self['node_id'] = rows[0]['node_id']
+            insert = True
+        else:
+            insert = False
+
+        # Filter out fields that cannot be set or updated directly
+        fields = dict(filter(lambda (key, value): key in self.fields,
+                             self.items()))
+
+        # Parameterize for safety
+        keys = fields.keys()
+        values = [self.api.db.param(key, value) for (key, value) in fields.items()]
+
+        if insert:
+            # Insert new row in nodes table
+            sql = "INSERT INTO nodes (%s) VALUES (%s)" % \
+                  (", ".join(keys), ", ".join(values))
+        else:
+            # Update existing row in nodes table
+            columns = ["%s = %s" % (key, value) for (key, value) in zip(keys, values)]
+            sql = "UPDATE nodes SET " + \
+                  ", ".join(columns) + \
+                  " WHERE node_id = %(node_id)d"
+
+        self.api.db.do(sql, fields)
+
+        if commit:
+            self.api.db.commit()
+
+    def delete(self, commit = True):
+        """
+        Delete existing node.
+        """
+
+        assert 'node_id' in self
+
+        # Delete all nodenetworks
+        nodenetworks = NodeNetworks(self.api, self['nodenetwork_ids'])
+        for nodenetwork in nodenetworks.values():
+            nodenetwork.delete(commit = False)
+
+        # Clean up miscellaneous join tables
+        for table in ['nodegroup_nodes', 'pod_hash', 'conf_assoc',
+                      'node_root_access', 'dslice03_slicenode',
+                      'pcu_ports']:
+            self.api.db.do("DELETE FROM %s" \
+                           " WHERE node_id = %d" % \
+                           (table, self['node_id']))
+
+        # Mark as deleted
+        self['deleted'] = True
+        self.flush(commit)
+
+class Nodes(Table):
+    """
+    Representation of row(s) from the nodes table in the
+    database.
+    """
+
+    def __init__(self, api, node_id_or_hostname_list = None, extra_fields = []):
+        self.api = api
+
+        sql = "SELECT nodes.*, node_nodenetworks.nodenetwork_id"
+
+        # For compatibility and convenience, support returning primary
+        # interface values directly in the Node structure.
+        extra_nodenetwork_fields = set(extra_fields).intersection(Node.primary_nodenetwork_fields)
+
+        # N.B.: Joined IDs may be marked as deleted in their primary tables
+        join_tables = {
+            # extra_field: (extra_table, extra_column, join_using)
+            'nodegroup_ids': ('nodegroup_nodes', 'nodegroup_id', 'node_id'),
+            'conf_file_ids': ('conf_assoc', 'conf_file_id', 'node_id'),
+            'root_person_ids': ('node_root_access', 'person_id AS root_person_id', 'node_id'),
+            'slice_ids': ('dslice03_slicenode', 'slice_id', 'node_id'),
+            'pcu_ids': ('pcu_ports', 'pcu_id', 'node_id'),
+            }
+
+        extra_fields = filter(join_tables.has_key, extra_fields)
+        extra_tables = ["%s USING (%s)" % \
+                        (join_tables[field][0], join_tables[field][2]) \
+                        for field in extra_fields]
+        extra_columns = ["%s.%s" % \
+                         (join_tables[field][0], join_tables[field][1]) \
+                         for field in extra_fields]
+
+        if extra_columns:
+            sql += ", " + ", ".join(extra_columns)
+
+        sql += " FROM nodes" \
+               " LEFT JOIN node_nodenetworks USING (node_id)"
+
+        if extra_tables:
+            sql += " LEFT JOIN " + " LEFT JOIN ".join(extra_tables)
+
+        sql += " WHERE deleted IS False"
+
+        if node_id_or_hostname_list:
+            # Separate the list into integers and strings
+            node_ids = filter(lambda node_id: isinstance(node_id, (int, long)),
+                              node_id_or_hostname_list)
+            hostnames = filter(lambda hostname: isinstance(hostname, StringTypes),
+                               node_id_or_hostname_list)
+            sql += " AND (False"
+            if node_ids:
+                sql += " OR node_id IN (%s)" % ", ".join(map(str, node_ids))
+            if hostnames:
+                sql += " OR hostname IN (%s)" % ", ".join(api.db.quote(hostnames)).lower()
+            sql += ")"
+
+        # So that if the node has a primary interface, it is listed
+        # first.
+        if 'nodenetwork_ids' in extra_fields:
+            sql += " ORDER BY node_nodenetworks.is_primary DESC"
+
+        rows = self.api.db.selectall(sql)
+        for row in rows:
+            if self.has_key(row['node_id']):
+                node = self[row['node_id']]
+                node.update(row)
+            else:
+                self[row['node_id']] = Node(api, row)
+
+        # XXX Should instead have a site_node join table that is
+        # magically taken care of above.
+        if rows:
+            sql = "SELECT node_id, sites.site_id FROM nodegroup_nodes" \
+                  " INNER JOIN sites USING (nodegroup_id)" \
+                  " WHERE node_id IN (%s)" % ", ".join(map(str, self.keys()))
+
+            rows = self.api.db.selectall(sql, self)
+            for row in rows:
+                assert self.has_key(row['node_id'])
+                node = self[row['node_id']]
+                node.update(row)
+
+        # Fill in optional primary interface fields for each node
+        if extra_nodenetwork_fields:
+            # More efficient to get all the nodenetworks at once
+            nodenetwork_ids = []
+            for node in self.values():
+                nodenetwork_ids += node['nodenetwork_ids']
+
+            # Remove duplicates
+            nodenetwork_ids = set(nodenetwork_ids)
+
+            # Get all nodenetwork information
+            nodenetworks = NodeNetworks(self.api, nodenetwork_ids)
+
+            for node in self.values():
+                for nodenetwork_id in node['nodenetwork_ids']:
+                    nodenetwork = nodenetworks[nodenetwork_id]
+                    if nodenetwork['is_primary']:
+                        for field in extra_nodenetwork_fields:
+                            node[field] = nodenetwork[field]
+                        break
diff --git a/PLC/PCUs.py b/PLC/PCUs.py
new file mode 100644 (file)
index 0000000..bcf9255
--- /dev/null
@@ -0,0 +1,126 @@
+#
+# Functions for interacting with the pcus table in the database
+#
+# Mark Huang <mlhuang@cs.princeton.edu>
+# Copyright (C) 2006 The Trustees of Princeton University
+#
+# $Id$
+#
+
+from PLC.Faults import *
+from PLC.Parameter import Parameter
+from PLC.Debug import profile
+from PLC.Table import Row, Table
+
+class PCU(Row):
+    """
+    Representation of a row in the pcu table. To use,
+    instantiate with a dict of values.
+    """
+
+    fields = {
+        'pcu_id': Parameter(int, "Node group identifier"),
+        'hostname': Parameter(str, "Fully qualified hostname"),
+        }
+
+    # These fields are derived from join tables and are not
+    # actually in the pcu table.
+    join_fields = {
+        'node_ids': Parameter([int], "List of nodes that this PCU controls"),
+        }
+
+    def __init__(self, api, fields):
+        Row.__init__(self, fields)
+        self.api = api
+
+    def flush(self, commit = True):
+        """
+        Commit changes back to the database.
+        """
+
+        self.validate()
+
+        # Fetch a new pcu_id if necessary
+        if 'pcu_id' not in self:
+            rows = self.api.db.selectall("SELECT NEXTVAL('pcu_pcu_id_seq') AS pcu_id")
+            if not rows:
+                raise PLCDBError, "Unable to fetch new pcu_id"
+            self['pcu_id'] = rows[0]['pcu_id']
+            insert = True
+        else:
+            insert = False
+
+        # Filter out unknown fields
+        fields = dict(filter(lambda (key, value): key in self.fields,
+                             self.items()))
+
+        # Parameterize for safety
+        keys = fields.keys()
+        values = [self.api.db.param(key, value) for (key, value) in fields.items()]
+
+        if insert:
+            # Insert new row in pcu table
+            sql = "INSERT INTO pcu (%s) VALUES (%s)" % \
+                  (", ".join(keys), ", ".join(values))
+        else:
+            # Update existing row in sites table
+            columns = ["%s = %s" % (key, value) for (key, value) in zip(keys, values)]
+            sql = "UPDATE pcu SET " + \
+                  ", ".join(columns) + \
+                  " WHERE pcu_id = %(pcu_id)d"
+
+        self.api.db.do(sql, fields)
+
+        if commit:
+            self.api.db.commit()
+
+    def delete(self, commit = True):
+        """
+        Delete existing PCU.
+        """
+
+        assert 'pcu_id' in self
+
+        # Delete ourself
+        for table in ['pcu_ports', 'pcu']:
+            self.api.db.do("DELETE FROM %s" \
+                           " WHERE nodenetwork_id = %(pcu_id)" % \
+                           table, self)
+
+        if commit:
+            self.api.db.commit()
+
+class PCUs(Table):
+    """
+    Representation of row(s) from the pcu table in the
+    database.
+    """
+
+    def __init__(self, api, pcu_id_or_hostname_list = None):
+        self.api = api
+
+        # N.B.: Node IDs returned may be deleted.
+        sql = "SELECT pcu.*, pcu_ports.node_id" \
+              " FROM pcu" \
+              " LEFT JOIN pcu_ports USING (pcu_id)"
+
+        if pcu_id_or_hostname_list:
+            # Separate the list into integers and strings
+            pcu_ids = filter(lambda pcu_id: isinstance(pcu_id, (int, long)),
+                                   pcu_id_or_hostname_list)
+            hostnames = filter(lambda hostname: isinstance(hostname, StringTypes),
+                           pcu_id_or_hostname_list)
+            sql += " AND (False"
+            if pcu_ids:
+                sql += " OR pcu_id IN (%s)" % ", ".join(map(str, pcu_ids))
+            if hostnames:
+                sql += " OR hostname IN (%s)" % ", ".join(api.db.quote(hostnames)).lower()
+            sql += ")"
+
+        rows = self.api.db.selectall(sql, locals())
+        for row in rows:
+            if self.has_key(row['pcu_id']):
+                pcu = self[row['pcu_id']]
+                pcu.update(row)
+            else:
+                self[row['pcu_id']] = PCU(api, row)
diff --git a/PLC/Parameter.py b/PLC/Parameter.py
new file mode 100644 (file)
index 0000000..1430b27
--- /dev/null
@@ -0,0 +1,31 @@
+#
+# Shared type definitions
+#
+# Mark Huang <mlhuang@cs.princeton.edu>
+# Copyright (C) 2006 The Trustees of Princeton University
+#
+# $Id$
+#
+
+class Parameter:
+    """
+    Typed value wrapper. Use in accepts and returns to document method
+    parameters. Set the optional and default attributes for
+    sub-parameters (i.e., dict fields).
+    """
+
+    def __init__(self, type, doc = "", optional = True, default = None):
+        (self.type, self.doc, self.optional, self.default) = \
+                    (type, doc, optional, default)
+
+    def __repr__(self):
+        return repr(self.type)
+
+class Mixed(tuple):
+    """
+    A list (technically, a tuple) of types. Use in accepts and returns
+    to document method parameters that may return mixed types.
+    """
+
+    def __new__(cls, *types):
+        return tuple.__new__(cls, types)
diff --git a/PLC/Persons.py b/PLC/Persons.py
new file mode 100644 (file)
index 0000000..6d4be44
--- /dev/null
@@ -0,0 +1,345 @@
+#
+# Functions for interacting with the persons table in the database
+#
+# Mark Huang <mlhuang@cs.princeton.edu>
+# Copyright (C) 2006 The Trustees of Princeton University
+#
+# $Id$
+#
+
+from types import StringTypes
+from datetime import datetime
+import md5
+import time
+from random import Random
+import re
+
+from PLC.Faults import *
+from PLC.Parameter import Parameter
+from PLC.Debug import profile
+from PLC.Table import Row, Table
+from PLC.Roles import Roles
+from PLC.Addresses import Address, Addresses
+from PLC.Keys import Key, Keys
+from PLC import md5crypt
+
+class Person(Row):
+    """
+    Representation of a row in the persons table. To use, optionally
+    instantiate with a dict of values. Update as you would a
+    dict. Commit to the database with flush().
+    """
+
+    fields = {
+        'person_id': Parameter(int, "Account identifier"),
+        'first_name': Parameter(str, "Given name"),
+        'last_name': Parameter(str, "Surname"),
+        'title': Parameter(str, "Title"),
+        'email': Parameter(str, "Primary e-mail address"),
+        'phone': Parameter(str, "Telephone number"),
+        'url': Parameter(str, "Home page"),
+        'bio': Parameter(str, "Biography"),
+        'accepted_aup': Parameter(bool, "Has accepted the AUP"),
+        'enabled': Parameter(bool, "Has been enabled"),
+        'deleted': Parameter(bool, "Has been deleted"),
+        'password': Parameter(str, "Account password in crypt() form"),
+        'last_updated': Parameter(str, "Date and time of last update"),
+        'date_created': Parameter(str, "Date and time when account was created"),
+        }
+
+    # These fields are derived from join tables and are not actually
+    # in the persons table.
+    join_fields = {
+        'role_ids': Parameter([int], "List of role identifiers"),
+        'roles': Parameter([str], "List of roles"),
+        'site_ids': Parameter([int], "List of site identifiers"),
+        }
+    
+    # These fields are derived from join tables and are not returned
+    # by default unless specified.
+    extra_fields = {
+        'address_ids': Parameter([int], "List of address identifiers"),
+        'key_ids': Parameter([int], "List of key identifiers"),
+        'slice_ids': Parameter([int], "List of slice identifiers"),
+        }
+
+    default_fields = dict(fields.items() + join_fields.items())
+    all_fields = dict(default_fields.items() + extra_fields.items())
+
+    def __init__(self, api, fields):
+        Row.__init__(self, fields)
+        self.api = api
+
+    def validate_email(self, email):
+        """
+        Validate email address. Stolen from Mailman.
+        """
+
+        invalid_email = PLCInvalidArgument("Invalid e-mail address")
+        email_badchars = r'[][()<>|;^,\200-\377]'
+
+        # Pretty minimal, cheesy check.  We could do better...
+        if not email or email.count(' ') > 0:
+            raise invalid_email
+        if re.search(email_badchars, email) or email[0] == '-':
+            raise invalid_email
+
+        email = email.lower()
+        at_sign = email.find('@')
+        if at_sign < 1:
+            raise invalid_email
+        user = email[:at_sign]
+        rest = email[at_sign+1:]
+        domain = rest.split('.')
+
+        # This means local, unqualified addresses, are no allowed
+        if not domain:
+            raise invalid_email
+        if len(domain) < 2:
+            raise invalid_email
+
+        conflicts = Persons(self.api, [email])
+        for person_id, person in conflicts.iteritems():
+            if not person['deleted'] and ('person_id' not in self or self['person_id'] != person_id):
+                raise PLCInvalidArgument, "E-mail address already in use"
+
+        return email
+
+    def validate_password(self, password):
+        """
+        Encrypt password if necessary before committing to the
+        database.
+        """
+
+        if len(password) > len(md5crypt.MAGIC) and \
+           password[0:len(md5crypt.MAGIC)] == md5crypt.MAGIC:
+            return password
+        else:
+            # Generate a somewhat unique 2 character salt string
+            salt = str(time.time()) + str(Random().random())
+            salt = md5.md5(salt).hexdigest()[:8] 
+            return md5crypt.md5crypt(password, salt)
+
+    def validate_role_ids(self, role_ids):
+        """
+        Ensure that the specified role_ids are all valid.
+        """
+
+        roles = Roles(self.api)
+        for role_id in role_ids:
+            if role_id not in roles:
+                raise PLCInvalidArgument, "No such role"
+
+        return role_ids
+
+    def validate_site_ids(self, site_ids):
+        """
+        Ensure that the specified site_ids are all valid.
+        """
+
+        sites = Sites(self.api, site_ids)
+        for site_id in site_ids:
+            if site_id not in sites:
+                raise PLCInvalidArgument, "No such site"
+
+        return site_ids
+
+    def can_update(self, person):
+        """
+        Returns true if we can update the specified person. We can
+        update a person if:
+
+        1. We are the person.
+        2. We are an admin.
+        3. We are a PI and the person is a user or tech or at
+           one of our sites.
+        """
+
+        if self['person_id'] == person['person_id']:
+            return True
+
+        if 'admin' in self['roles']:
+            return True
+
+        if 'pi' in self['roles']:
+            if set(self['site_ids']).intersection(person['site_ids']):
+                # Can update people with higher role IDs
+                return min(self['role_ids']) < min(person['role_ids'])
+
+        return False
+
+    def can_view(self, person):
+        """
+        Returns true if we can view the specified person. We can
+        view a person if:
+
+        1. We are the person.
+        2. We are an admin.
+        3. We are a PI and the person is at one of our sites.
+        """
+
+        if self.can_update(person):
+            return True
+
+        if 'pi' in self['roles']:
+            if set(self['site_ids']).intersection(person['site_ids']):
+                # Can view people with equal or higher role IDs
+                return min(self['role_ids']) <= min(person['role_ids'])
+
+        return False
+
+    def flush(self, commit = True):
+        """
+        Commit changes back to the database.
+        """
+
+        self.validate()
+
+        # Fetch a new person_id if necessary
+        if 'person_id' not in self:
+            rows = self.api.db.selectall("SELECT NEXTVAL('persons_person_id_seq') AS person_id")
+            if not rows:
+                raise PLCDBError, "Unable to fetch new person_id"
+            self['person_id'] = rows[0]['person_id']
+            insert = True
+        else:
+            insert = False
+
+        # Filter out fields that cannot be set or updated directly
+        fields = dict(filter(lambda (key, value): key in self.fields,
+                             self.items()))
+
+        # Parameterize for safety
+        keys = fields.keys()
+        values = [self.api.db.param(key, value) for (key, value) in fields.items()]
+
+        if insert:
+            # Insert new row in persons table
+            sql = "INSERT INTO persons (%s) VALUES (%s)" % \
+                  (", ".join(keys), ", ".join(values))
+        else:
+            # Update existing row in persons table
+            columns = ["%s = %s" % (key, value) for (key, value) in zip(keys, values)]
+            sql = "UPDATE persons SET " + \
+                  ", ".join(columns) + \
+                  " WHERE person_id = %(person_id)d"
+
+        self.api.db.do(sql, fields)
+
+        if commit:
+            self.api.db.commit()
+
+    def delete(self, commit = True):
+        """
+        Delete existing account.
+        """
+
+        assert 'person_id' in self
+
+        # Make sure extra fields are present
+        persons = Persons(self.api, [self['person_id']],
+                          ['address_ids', 'key_ids'])
+        assert persons
+        self.update(persons.values()[0])
+
+        # Delete all addresses
+        addresses = Addresses(self.api, self['address_ids'])
+        for address in addresses.values():
+            address.delete(commit = False)
+
+        # Delete all keys
+        keys = Keys(self.api, self['key_ids'])
+        for key in keys.values():
+            key.delete(commit = False)
+
+        # Clean up miscellaneous join tables
+        for table in ['person_roles', 'person_capabilities', 'person_site',
+                      'node_root_access', 'dslice03_sliceuser']:
+            self.api.db.do("DELETE FROM %s" \
+                           " WHERE person_id = %d" % \
+                           (table, self['person_id']))
+
+        # Mark as deleted
+        self['deleted'] = True
+        self.flush(commit)
+
+class Persons(Table):
+    """
+    Representation of row(s) from the persons table in the
+    database. Specify deleted and/or enabled to force a match on
+    whether a person is deleted and/or enabled. Default is to match on
+    non-deleted accounts.
+    """
+
+    def __init__(self, api, person_id_or_email_list = None, extra_fields = [], deleted = False, enabled = None):
+        self.api = api
+
+        role_max = Roles.role_max
+
+        # N.B.: Site IDs returned may be deleted. Persons returned are
+        # never deleted, but may not be enabled.
+        sql = "SELECT persons.*" \
+              ", roles.role_id, roles.name AS role" \
+              ", person_site.site_id" \
+
+        # N.B.: Joined IDs may be marked as deleted in their primary tables
+        join_tables = {
+            # extra_field: (extra_table, extra_column, join_using)
+            'address_ids': ('person_address', 'address_id', 'person_id'),
+            'key_ids': ('person_keys', 'key_id', 'person_id'),
+            'slice_ids': ('dslice03_sliceuser', 'slice_id', 'person_id'),
+            }
+
+        extra_fields = filter(join_tables.has_key, extra_fields)
+        extra_tables = ["%s USING (%s)" % \
+                        (join_tables[field][0], join_tables[field][2]) \
+                        for field in extra_fields]
+        extra_columns = ["%s.%s" % \
+                         (join_tables[field][0], join_tables[field][1]) \
+                         for field in extra_fields]
+
+        if extra_columns:
+            sql += ", " + ", ".join(extra_columns)
+
+        sql += " FROM persons" \
+               " LEFT JOIN person_roles USING (person_id)" \
+               " LEFT JOIN roles USING (role_id)" \
+               " LEFT JOIN person_site USING (person_id)"
+
+        if extra_tables:
+            sql += " LEFT JOIN " + " LEFT JOIN ".join(extra_tables)
+
+        # So that people with no roles have empty role_ids and roles values
+        sql += " WHERE (role_id IS NULL or role_id <= %(role_max)d)"
+
+        if deleted is not None:
+            sql += " AND deleted IS %(deleted)s"
+
+        if enabled is not None:
+            sql += " AND enabled IS %(enabled)s"
+
+        if person_id_or_email_list:
+            # Separate the list into integers and strings
+            person_ids = filter(lambda person_id: isinstance(person_id, (int, long)),
+                                person_id_or_email_list)
+            emails = filter(lambda email: isinstance(email, StringTypes),
+                            person_id_or_email_list)
+            sql += " AND (False"
+            if person_ids:
+                sql += " OR person_id IN (%s)" % ", ".join(map(str, person_ids))
+            if emails:
+                # Case insensitive e-mail address comparison
+                sql += " OR lower(email) IN (%s)" % ", ".join(api.db.quote(emails)).lower()
+            sql += ")"
+
+        # The first site_id in the site_ids list is the primary site
+        # of the user. See AdmGetPersonSites().
+        sql += " ORDER BY person_site.is_primary DESC"
+
+        rows = self.api.db.selectall(sql, locals())
+        for row in rows:
+            if self.has_key(row['person_id']):
+                person = self[row['person_id']]
+                person.update(row)
+            else:
+                self[row['person_id']] = Person(api, row)
diff --git a/PLC/PostgreSQL.py b/PLC/PostgreSQL.py
new file mode 100644 (file)
index 0000000..8376804
--- /dev/null
@@ -0,0 +1,135 @@
+#
+# PostgreSQL database interface. Sort of like DBI(3) (Database
+# independent interface for Perl).
+#
+# Mark Huang <mlhuang@cs.princeton.edu>
+# Copyright (C) 2006 The Trustees of Princeton University
+#
+# $Id$
+#
+
+import pgdb
+from types import StringTypes, NoneType
+import traceback
+import commands
+from pprint import pformat
+
+from PLC.Debug import profile, log
+from PLC.Faults import *
+
+class PostgreSQL:
+    def __init__(self, api):
+        self.api = api
+
+        # Initialize database connection
+        self.db = pgdb.connect(user = api.config.PLC_DB_USER,
+                               password = api.config.PLC_DB_PASSWORD,
+                               host = "%s:%d" % (api.config.PLC_DB_HOST, api.config.PLC_DB_PORT),
+                               database = api.config.PLC_DB_NAME)
+        self.cursor = self.db.cursor()
+
+        (self.rowcount, self.description, self.lastrowid) = \
+                        (None, None, None)
+
+    def quote(self, params):
+        """
+        Returns quoted version(s) of the specified parameter(s).
+        """
+
+        # pgdb._quote functions are good enough for general SQL quoting
+        if hasattr(params, 'has_key'):
+            params = pgdb._quoteitem(params)
+        elif isinstance(params, list) or isinstance(params, tuple):
+            params = map(pgdb._quote, params)
+        else:
+            params = pgdb._quote(params)
+
+        return params
+
+    quote = classmethod(quote)
+
+    def param(self, name, value):
+        # None is converted to the unquoted string NULL
+        if isinstance(value, NoneType):
+            conversion = "s"
+        # True and False are also converted to unquoted strings
+        elif isinstance(value, bool):
+            conversion = "s"
+        elif isinstance(value, float):
+            conversion = "f"
+        elif not isinstance(value, StringTypes):
+            conversion = "d"
+        else:
+            conversion = "s"
+
+        return '%(' + name + ')' + conversion
+
+    param = classmethod(param)
+
+    def begin_work(self):
+        # Implicit in pgdb.connect()
+        pass
+
+    def commit(self):
+        self.db.commit()
+
+    def rollback(self):
+        self.db.rollback()
+
+    def do(self, query, params = None):
+        self.execute(query, params)
+        return self.rowcount
+
+    def last_insert_id(self):
+        return self.lastrowid
+
+    def execute(self, query, params = None):
+        self.execute_array(query, (params,))
+
+    def execute_array(self, query, param_seq):
+        cursor = self.cursor
+        try:
+            cursor.executemany(query, param_seq)
+            (self.rowcount, self.description, self.lastrowid) = \
+                            (cursor.rowcount, cursor.description, cursor.lastrowid)
+        except pgdb.DatabaseError, e:
+            self.rollback()
+            uuid = commands.getoutput("uuidgen")
+            print >> log, "Database error %s:" % uuid
+            print >> log, e
+            print >> log, "Query:"
+            print >> log, query
+            print >> log, "Params:"
+            print >> log, pformat(param_seq[0])
+            raise PLCDBError("Please contact " + \
+                             self.api.config.PLC_NAME + " Support " + \
+                             "<" + self.api.config.PLC_MAIL_SUPPORT_ADDRESS + ">" + \
+                             " and reference " + uuid)
+
+    def selectall(self, query, params = None, hashref = True, key_field = None):
+        """
+        Return each row as a dictionary keyed on field name (like DBI
+        selectrow_hashref()). If key_field is specified, return rows
+        as a dictionary keyed on the specified field (like DBI
+        selectall_hashref()).
+
+        If params is specified, the specified parameters will be bound
+        to the query (see PLC.DB.parameterize() and
+        pgdb.cursor.execute()).
+        """
+
+        self.execute(query, params)
+        rows = self.cursor.fetchall()
+
+        if hashref:
+            # Return each row as a dictionary keyed on field name
+            # (like DBI selectrow_hashref()).
+            labels = [column[0] for column in self.description]
+            rows = [dict(zip(labels, row)) for row in rows]
+
+        if key_field is not None and key_field in labels:
+            # Return rows as a dictionary keyed on the specified field
+            # (like DBI selectall_hashref()).
+            return dict([(row[key_field], row) for row in rows])
+        else:
+            return rows
diff --git a/PLC/Roles.py b/PLC/Roles.py
new file mode 100644 (file)
index 0000000..9835f37
--- /dev/null
@@ -0,0 +1,32 @@
+#
+# Functions for interacting with the roles table in the database
+#
+# Mark Huang <mlhuang@cs.princeton.edu>
+# Copyright (C) 2006 The Trustees of Princeton University
+#
+# $Id$
+#
+
+from PLC.Parameter import Parameter
+
+class Roles(dict):
+    """
+    Representation of the roles table in the database.
+    """
+
+    fields = {
+        'role_id': Parameter(int, "Role identifier"),
+        'name': Parameter(str, "Role name"),
+        }
+
+    # Role IDs equal to or lower than this number are for use by real
+    # accounts. Other role IDs are used internally.
+    role_max = 500
+
+    def __init__(self, api):
+        sql = "SELECT * FROM roles" \
+              " WHERE role_id <= %d" % self.role_max
+
+        for row in api.db.selectall(sql):
+            self[row['role_id']] = row['name']
+            self[row['name']] = row['role_id']
diff --git a/PLC/Sites.py b/PLC/Sites.py
new file mode 100644 (file)
index 0000000..d3d4dcd
--- /dev/null
@@ -0,0 +1,323 @@
+from types import StringTypes
+import string
+
+from PLC.Faults import *
+from PLC.Parameter import Parameter
+from PLC.Debug import profile
+from PLC.Table import Row, Table
+from PLC.Persons import Person, Persons
+from PLC.Slices import Slice, Slices
+from PLC.PCUs import PCU, PCUs
+from PLC.Nodes import Node, Nodes
+from PLC.NodeGroups import NodeGroup, NodeGroups
+
+class Site(Row):
+    """
+    Representation of a row in the sites table. To use, optionally
+    instantiate with a dict of values. Update as you would a
+    dict. Commit to the database with flush().
+    """
+
+    fields = {
+        'site_id': Parameter(int, "Site identifier"),
+        'name': Parameter(str, "Full site name"),
+        'abbreviated_name': Parameter(str, "Abbreviated site name"),
+        'login_base': Parameter(str, "Site slice prefix"),
+        'is_public': Parameter(bool, "Publicly viewable site"),
+        'latitude': Parameter(float, "Decimal latitude of the site"),
+        'longitude': Parameter(float, "Decimal longitude of the site"),
+        'url': Parameter(str, "URL of a page that describes the site"),
+        'nodegroup_id': Parameter(int, "Identifier of the nodegroup containing the site's nodes"),
+        'organization_id': Parameter(int, "Organizational identifier if the site is part of a larger organization"),
+        'ext_consortium_id': Parameter(int, "Consortium identifier if the site is part of an external consortium"),
+        'date_created': Parameter(str, "Date and time when node entry was created"),        
+        'deleted': Parameter(bool, "Has been deleted"),
+        }
+
+    # These fields are derived from join tables and are not actually
+    # in the sites table.
+    join_fields = {
+        'max_slices': Parameter(int, "Maximum number of slices that the site is able to create"),
+        'site_share': Parameter(float, "Relative resource share for this site's slices"),
+        }        
+
+    # These fields are derived from join tables and are not returned
+    # by default unless specified.
+    extra_fields = {
+        'person_ids': Parameter([int], "List of account identifiers"),
+        'slice_ids': Parameter([int], "List of slice identifiers"),
+        'defaultattribute_ids': Parameter([int], "List of default slice attribute identifiers"),
+        'pcu_ids': Parameter([int], "List of PCU identifiers"),
+        'node_ids': Parameter([int], "List of site node identifiers"),
+        }
+
+    default_fields = dict(fields.items() + join_fields.items())
+    all_fields = dict(default_fields.items() + extra_fields.items())
+
+    # Number of slices assigned to each site at the time that the site is created
+    default_max_slices = 0
+
+    # XXX Useless, unclear what this value means
+    default_site_share = 1.0
+
+    def __init__(self, api, fields):
+        Row.__init__(self, fields)
+        self.api = api
+
+    def validate_login_base(self, login_base):
+        if len(login_base) > 20:
+            raise PLCInvalidArgument, "Login base must be <= 20 characters"
+
+        if not set(login_base).issubset(string.ascii_letters):
+            raise PLCInvalidArgument, "Login base must consist only of ASCII letters"
+
+        login_base = login_base.lower()
+        conflicts = Sites(self.api, [login_base])
+        for site_id, site in conflicts.iteritems():
+            if not site['deleted'] and ('site_id' not in self or self['site_id'] != site_id):
+                raise PLCInvalidArgument, "login_base already in use"
+
+        return login_base
+
+    def validate_latitude(self, latitude):
+        if latitude < -90.0 or latitude > 90.0:
+            raise PLCInvalidArgument, "Invalid latitude value"
+
+        if not self.has_key('longitude') or \
+           self['longitude'] is None:
+            raise PLCInvalidArgument, "Longitude must also be specified"
+
+        return latitude
+
+    def validate_longitude(self, longitude):
+        if longitude < -180.0 or longitude > 180.0:
+            raise PLCInvalidArgument, "Invalid longitude value"
+
+        if not self.has_key('latitude') or \
+           self['latitude'] is None:
+            raise PLCInvalidArgument, "Latitude must also be specified"
+
+        return longitude
+
+    def validate_nodegroup_id(self, nodegroup_id):
+        nodegroups = NodeGroups(self.api)
+        if nodegroup_id not in nodegroups:
+            raise PLCInvalidArgument, "No such nodegroup"
+
+        return nodegroup_id
+
+    def validate_organization_id(self, organization_id):
+        organizations = Organizations(self.api)
+        if role_id not in organizations:
+            raise PLCInvalidArgument, "No such organization"
+
+        return organization_id
+
+    def validate_ext_consortium_id(self, organization_id):
+        consortiums = Consortiums(self.api)
+        if consortium_id not in consortiums:
+            raise PLCInvalidArgument, "No such consortium"
+
+        return nodegroup_id
+
+    def flush(self, commit = True):
+        """
+        Flush changes back to the database.
+        """
+
+        self.validate()
+
+        try:
+            if not self['name'] or \
+               not self['abbreviated_name'] or \
+               not self['login_base']:
+                raise KeyError
+        except KeyError:
+            raise PLCInvalidArgument, "name, abbreviated_name, and login_base must all be specified"
+
+        # Fetch a new site_id if necessary
+        if 'site_id' not in self:
+            rows = self.api.db.selectall("SELECT NEXTVAL('sites_site_id_seq') AS site_id")
+            if not rows:
+                raise PLCDBError, "Unable to fetch new site_id"
+            self['site_id'] = rows[0]['site_id']
+            insert = True
+        else:
+            insert = False
+
+        # Create site node group if necessary
+        if 'nodegroup_id' not in self:
+            rows = self.api.db.selectall("SELECT NEXTVAL('nodegroups_nodegroup_id_seq') as nodegroup_id")
+            if not rows:
+                raise PLCDBError, "Unable to fetch new nodegroup_id"
+            self['nodegroup_id'] = rows[0]['nodegroup_id']
+
+            nodegroup_id = self['nodegroup_id']
+            # XXX Needs a unique name because we cannot delete site node groups yet
+            name = self['login_base'] + str(self['site_id'])
+            description = "Nodes at " + self['name']
+            is_custom = False
+            self.api.db.do("INSERT INTO nodegroups (nodegroup_id, name, description, is_custom)" \
+                           " VALUES (%(nodegroup_id)d, %(name)s, %(description)s, %(is_custom)s)",
+                           locals())
+
+        # Filter out fields that cannot be set or updated directly
+        fields = dict(filter(lambda (key, value): key in self.fields,
+                             self.items()))
+
+        # Parameterize for safety
+        keys = fields.keys()
+        values = [self.api.db.param(key, value) for (key, value) in fields.items()]
+
+        if insert:
+            # Insert new row in sites table
+            self.api.db.do("INSERT INTO sites (%s) VALUES (%s)" % \
+                           (", ".join(keys), ", ".join(values)),
+                           fields)
+
+            # Setup default slice site info
+            # XXX Will go away soon
+            self['max_slices'] = self.default_max_slices
+            self['site_share'] = self.default_site_share
+            self.api.db.do("INSERT INTO dslice03_siteinfo (site_id, max_slices, site_share)" \
+                           " VALUES (%(site_id)d, %(max_slices)d, %(site_share)f)",
+                           self)
+        else:
+            # Update default slice site info
+            # XXX Will go away soon
+            if 'max_slices' in self and 'site_share' in self:
+                self.api.db.do("UPDATE dslice03_siteinfo SET " \
+                               " max_slices = %(max_slices)d, site_share = %(site_share)f" \
+                               " WHERE site_id = %(site_id)d",
+                               self)
+
+            # Update existing row in sites table
+            columns = ["%s = %s" % (key, value) for (key, value) in zip(keys, values)]
+            self.api.db.do("UPDATE sites SET " + \
+                           ", ".join(columns) + \
+                           " WHERE site_id = %(site_id)d",
+                           fields)
+
+        if commit:
+            self.api.db.commit()
+
+    def delete(self, commit = True):
+        """
+        Delete existing site.
+        """
+
+        assert 'site_id' in self
+
+        # Make sure extra fields are present
+        sites = Sites(self.api, [self['site_id']],
+                      ['person_ids', 'slice_ids', 'pcu_ids', 'node_ids'])
+        assert sites
+        self.update(sites.values()[0])
+
+        # Delete accounts of all people at the site who are not
+        # members of at least one other non-deleted site.
+        persons = Persons(self.api, self['person_ids'])
+        for person_id, person in persons.iteritems():
+            delete = True
+
+            person_sites = Sites(self.api, person['site_ids'])
+            for person_site_id, person_site in person_sites.iteritems():
+                if person_site_id != self['site_id'] and \
+                   not person_site['deleted']:
+                    delete = False
+                    break
+
+            if delete:
+                person.delete(commit = False)
+
+        # Delete all site slices
+        slices = Slices(self.api, self['slice_ids'])
+        for slice in slices.values():
+            slice.delete(commit = False)
+
+        # Delete all site PCUs
+        pcus = PCUs(self.api, self['pcu_ids'])
+        for pcu in pcus.values():
+            pcu.delete(commit = False)
+
+        # Delete all site nodes
+        nodes = Nodes(self.api, self['node_ids'])
+        for node in nodes.values():
+            node.delete(commit = False)
+
+        # Clean up miscellaneous join tables
+        for table in ['site_authorized_subnets',
+                      'dslice03_defaultattribute',
+                      'dslice03_siteinfo']:
+            self.api.db.do("DELETE FROM %s" \
+                           " WHERE site_id = %d" % \
+                           (table, self['site_id']))
+
+        # XXX Cannot delete site node groups yet
+
+        # Mark as deleted
+        self['deleted'] = True
+        self.flush(commit)
+
+class Sites(Table):
+    """
+    Representation of row(s) from the sites table in the
+    database. Specify extra_fields to be able to view and modify extra
+    fields.
+    """
+
+    def __init__(self, api, site_id_or_login_base_list = None, extra_fields = []):
+        self.api = api
+
+        sql = "SELECT sites.*" \
+              ", dslice03_siteinfo.max_slices"
+
+        # N.B.: Joined IDs may be marked as deleted in their primary tables
+        join_tables = {
+            # extra_field: (extra_table, extra_column, join_using)
+            'person_ids': ('person_site', 'person_id', 'site_id'),
+            'slice_ids': ('dslice03_slices', 'slice_id', 'site_id'),
+            'defaultattribute_ids': ('dslice03_defaultattribute', 'defaultattribute_id', 'site_id'),
+            'pcu_ids': ('pcu', 'pcu_id', 'site_id'),
+            'node_ids': ('nodegroup_nodes', 'node_id', 'nodegroup_id'),
+            }
+
+        extra_fields = filter(join_tables.has_key, extra_fields)
+        extra_tables = ["%s USING (%s)" % \
+                        (join_tables[field][0], join_tables[field][2]) \
+                        for field in extra_fields]
+        extra_columns = ["%s.%s" % \
+                         (join_tables[field][0], join_tables[field][1]) \
+                         for field in extra_fields]
+
+        if extra_columns:
+            sql += ", " + ", ".join(extra_columns)
+
+        sql += " FROM sites" \
+               " LEFT JOIN dslice03_siteinfo USING (site_id)"
+
+        if extra_tables:
+            sql += " LEFT JOIN " + " LEFT JOIN ".join(extra_tables)
+
+        sql += " WHERE deleted IS False"
+
+        if site_id_or_login_base_list:
+            # Separate the list into integers and strings
+            site_ids = filter(lambda site_id: isinstance(site_id, (int, long)),
+                              site_id_or_login_base_list)
+            login_bases = filter(lambda login_base: isinstance(login_base, StringTypes),
+                                 site_id_or_login_base_list)
+            sql += " AND (False"
+            if site_ids:
+                sql += " OR site_id IN (%s)" % ", ".join(map(str, site_ids))
+            if login_bases:
+                sql += " OR login_base IN (%s)" % ", ".join(api.db.quote(login_bases))
+            sql += ")"
+
+        rows = self.api.db.selectall(sql)
+        for row in rows:
+            if self.has_key(row['site_id']):
+                site = self[row['site_id']]
+                site.update(row)
+            else:
+                self[row['site_id']] = Site(api, row)
diff --git a/PLC/Slices.py b/PLC/Slices.py
new file mode 100644 (file)
index 0000000..77b5b2d
--- /dev/null
@@ -0,0 +1,38 @@
+from types import StringTypes
+
+from PLC.Faults import *
+from PLC.Parameter import Parameter
+from PLC.Debug import profile
+from PLC.Table import Row, Table
+
+class Slice(Row):
+    """
+    Representation of a row in the slices table. To use, instantiate
+    with a dict of values.
+    """
+
+    fields = {
+        'slice_id': Parameter(int, "Slice type"),
+        }
+
+    def __init__(self, api, fields):
+        self.api = api
+        dict.__init__(fields)
+
+    def commit(self):
+        # XXX
+        pass
+
+    def delete(self):
+        # XXX
+        pass
+
+class Slices(Table):
+    """
+    Representation of row(s) from the slices table in the
+    database.
+    """
+
+    def __init__(self, api, slice_id_list = None):
+        # XXX
+        pass
diff --git a/PLC/Table.py b/PLC/Table.py
new file mode 100644 (file)
index 0000000..3505d41
--- /dev/null
@@ -0,0 +1,90 @@
+class Row(dict):
+    """
+    Representation of a row in a database table. To use, optionally
+    instantiate with a dict of values. Update as you would a
+    dict. Commit to the database with flush().
+    """
+
+    # Set this to a dict of the valid columns in this table. If a column
+    # name ends in 's' and the column value is set by referring to the
+    # column without the 's', it is assumed that the column values
+    # should be aggregated into lists. For example, if fields contains
+    # the column 'role_ids' and row['role_id'] is set repeatedly to
+    # different values, row['role_ids'] will contain a list of the set
+    # values.
+    fields = {}
+
+    # These fields are derived from join tables and are not actually
+    # in the sites table.
+    join_fields = {}
+
+    # These fields are derived from join tables and are not returned
+    # by default unless specified.
+    extra_fields = {}
+
+    def __init__(self, fields):
+        self.update(fields)
+        
+    def update(self, fields):
+        for key, value in fields.iteritems():
+            self.__setitem__(key, value)
+
+    def __setitem__(self, key, value):
+        """
+        Magically takes care of aggregating certain variables into
+        lists.
+        """
+
+        # All known keys
+        all_fields = self.fields.keys() + \
+                     self.join_fields.keys() + \
+                     self.extra_fields.keys()
+
+        # Aggregate into lists
+        if (key + 's') in all_fields:
+            key += 's'
+            try:
+                if value not in self[key] and value is not None:
+                    self[key].append(value)
+            except KeyError:
+                if value is None:
+                    self[key] = []
+                else:
+                    self[key] = [value]
+            return
+
+        elif key in all_fields:
+            dict.__setitem__(self, key, value)
+
+    def validate(self):
+        """
+        Validates values. Will validate a value with a custom function
+        if a function named 'validate_[key]' exists.
+        """
+
+        # Validate values before committing
+        # XXX Also truncate strings that are too long
+        for key, value in self.iteritems():
+            if value is not None and hasattr(self, 'validate_' + key):
+                validate = getattr(self, 'validate_' + key)
+                self[key] = validate(value)
+
+    def flush(self, commit = True):
+        """
+        Flush changes back to the database.
+        """
+
+        pass
+
+class Table(dict):
+    """
+    Representation of row(s) in a database table.
+    """
+
+    def flush(self, commit = True):
+        """
+        Flush changes back to the database.
+        """
+
+        for row in self.values():
+            row.flush(commit)
diff --git a/PLC/__init__.py b/PLC/__init__.py
new file mode 100644 (file)
index 0000000..f4ec5ca
--- /dev/null
@@ -0,0 +1 @@
+all = 'Addresses AddressTypes API Auth BootStates Config Debug Faults Keys md5crypt Method NodeGroups NodeNetworks Nodes Parameter PCUs Persons PostgreSQL Roles Sites Slices Table'.split()
diff --git a/PLC/md5crypt.py b/PLC/md5crypt.py
new file mode 100644 (file)
index 0000000..146eb00
--- /dev/null
@@ -0,0 +1,159 @@
+#########################################################
+# md5crypt.py
+#
+# 0423.2000 by michal wallace http://www.sabren.com/
+# based on perl's Crypt::PasswdMD5 by Luis Munoz (lem@cantv.net)
+# based on /usr/src/libcrypt/crypt.c from FreeBSD 2.2.5-RELEASE
+#
+# MANY THANKS TO
+#
+#  Carey Evans - http://home.clear.net.nz/pages/c.evans/
+#  Dennis Marti - http://users.starpower.net/marti1/
+#
+#  For the patches that got this thing working!
+#
+#########################################################
+"""md5crypt.py - Provides interoperable MD5-based crypt() function
+
+SYNOPSIS
+
+       import md5crypt.py
+
+       cryptedpassword = md5crypt.md5crypt(password, salt);
+
+DESCRIPTION
+
+unix_md5_crypt() provides a crypt()-compatible interface to the
+rather new MD5-based crypt() function found in modern operating systems.
+It's based on the implementation found on FreeBSD 2.2.[56]-RELEASE and
+contains the following license in it:
+
+ "THE BEER-WARE LICENSE" (Revision 42):
+ <phk@login.dknet.dk> wrote this file.  As long as you retain this notice you
+ can do whatever you want with this stuff. If we meet some day, and you think
+ this stuff is worth it, you can buy me a beer in return.   Poul-Henning Kamp
+
+apache_md5_crypt() provides a function compatible with Apache's
+.htpasswd files. This was contributed by Bryan Hart <bryan@eai.com>.
+
+"""
+
+MAGIC = '$1$'                  # Magic string
+ITOA64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
+
+import md5
+
+def to64 (v, n):
+    ret = ''
+    while (n - 1 >= 0):
+        n = n - 1
+       ret = ret + ITOA64[v & 0x3f]
+       v = v >> 6
+    return ret
+
+
+def apache_md5_crypt (pw, salt):
+    # change the Magic string to match the one used by Apache
+    return unix_md5_crypt(pw, salt, '$apr1$')
+
+
+def unix_md5_crypt(pw, salt, magic=None):
+    
+    if magic==None:
+        magic = MAGIC
+
+    # Take care of the magic string if present
+    if salt[:len(magic)] == magic:
+        salt = salt[len(magic):]
+        
+
+    # salt can have up to 8 characters:
+    import string
+    salt = string.split(salt, '$', 1)[0]
+    salt = salt[:8]
+
+    ctx = pw + magic + salt
+
+    final = md5.md5(pw + salt + pw).digest()
+
+    for pl in range(len(pw),0,-16):
+        if pl > 16:
+            ctx = ctx + final[:16]
+        else:
+            ctx = ctx + final[:pl]
+
+
+    # Now the 'weird' xform (??)
+
+    i = len(pw)
+    while i:
+        if i & 1:
+            ctx = ctx + chr(0)  #if ($i & 1) { $ctx->add(pack("C", 0)); }
+        else:
+            ctx = ctx + pw[0]
+        i = i >> 1
+
+    final = md5.md5(ctx).digest()
+    
+    # The following is supposed to make
+    # things run slower. 
+
+    # my question: WTF???
+
+    for i in range(1000):
+        ctx1 = ''
+        if i & 1:
+            ctx1 = ctx1 + pw
+        else:
+            ctx1 = ctx1 + final[:16]
+
+        if i % 3:
+            ctx1 = ctx1 + salt
+
+        if i % 7:
+            ctx1 = ctx1 + pw
+
+        if i & 1:
+            ctx1 = ctx1 + final[:16]
+        else:
+            ctx1 = ctx1 + pw
+            
+            
+        final = md5.md5(ctx1).digest()
+
+
+    # Final xform
+                                
+    passwd = ''
+
+    passwd = passwd + to64((int(ord(final[0])) << 16)
+                           |(int(ord(final[6])) << 8)
+                           |(int(ord(final[12]))),4)
+
+    passwd = passwd + to64((int(ord(final[1])) << 16)
+                           |(int(ord(final[7])) << 8)
+                           |(int(ord(final[13]))), 4)
+
+    passwd = passwd + to64((int(ord(final[2])) << 16)
+                           |(int(ord(final[8])) << 8)
+                           |(int(ord(final[14]))), 4)
+
+    passwd = passwd + to64((int(ord(final[3])) << 16)
+                           |(int(ord(final[9])) << 8)
+                           |(int(ord(final[15]))), 4)
+
+    passwd = passwd + to64((int(ord(final[4])) << 16)
+                           |(int(ord(final[10])) << 8)
+                           |(int(ord(final[5]))), 4)
+
+    passwd = passwd + to64((int(ord(final[11]))), 2)
+
+
+    return magic + salt + '$' + passwd
+
+
+## assign a wrapper function:
+md5crypt = unix_md5_crypt
+
+if __name__ == "__main__":
+    print unix_md5_crypt("cat", "hat")
diff --git a/Server.py b/Server.py
new file mode 100755 (executable)
index 0000000..8314281
--- /dev/null
+++ b/Server.py
@@ -0,0 +1,99 @@
+#!/usr/bin/python
+#
+# Simple standalone HTTP server for testing PLCAPI
+#
+# Mark Huang <mlhuang@cs.princeton.edu>
+# Copyright (C) 2006 The Trustees of Princeton University
+#
+# $Id$
+#
+
+import os
+import sys
+import getopt
+import traceback
+import BaseHTTPServer
+
+from PLC.API import PLCAPI
+
+class PLCAPIRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
+    """
+    Simple standalone HTTP request handler for testing PLCAPI.
+    """
+
+    def do_POST(self):
+        try:
+            # Read request
+            request = self.rfile.read(int(self.headers["Content-length"]))
+
+            # Handle request
+            response = self.server.api.handle(self.client_address, request)
+
+            # Write response
+            self.send_response(200)
+            self.wfile.write(response)
+            self.wfile.flush()
+
+            self.connection.shutdown(1)
+
+        except Exception, e:
+            # Log error
+            sys.stderr.write(traceback.format_exc())
+            sys.stderr.flush()
+
+    def do_GET(self):
+        self.send_response(200)
+        self.send_header("Content-type", 'text/html')
+        self.end_headers()
+        self.wfile.write("""
+<html><head>
+<title>PLCAPI XML-RPC/SOAP Interface</title>
+</head><body>
+<h1>PLCAPI XML-RPC/SOAP Interface</h1>
+<p>Please use XML-RPC or SOAP to access the PLCAPI.</p>
+</body></html>
+""")        
+        
+class PLCAPIServer(BaseHTTPServer.HTTPServer):
+    """
+    Simple standalone HTTP server for testing PLCAPI.
+    """
+
+    def __init__(self, addr, config):
+        self.api = PLCAPI(config)
+        self.allow_reuse_address = 1
+        BaseHTTPServer.HTTPServer.__init__(self, addr, PLCAPIRequestHandler)
+
+# Defaults
+addr = "0.0.0.0"
+port = 8000
+config = "/etc/planetlab/plc_config.xml"
+
+def usage():
+    print "Usage: %s [OPTION]..." % sys.argv[0]
+    print "Options:"
+    print "     -p PORT, --port=PORT    TCP port number to listen on (default: %d)" % port
+    print "     -f FILE, --config=FILE  PLC configuration file (default: %s)" % config
+    print "     -h, --help              This message"
+    sys.exit(1)
+
+# Get options
+try:
+    (opts, argv) = getopt.getopt(sys.argv[1:], "p:f:h", ["port=", "config=", "help"])
+except getopt.GetoptError, err:
+    print "Error: " + err.msg
+    usage()
+
+for (opt, optval) in opts:
+    if opt == "-p" or opt == "--port":
+        try:
+            port = int(optval)
+        except ValueError:
+            usage()
+    elif opt == "-f" or opt == "--config":
+        config = optval
+    elif opt == "-h" or opt == "--help":
+        usage()
+
+# Start server
+PLCAPIServer((addr, port), config).serve_forever()
diff --git a/Shell.py b/Shell.py
new file mode 100755 (executable)
index 0000000..232bf28
--- /dev/null
+++ b/Shell.py
@@ -0,0 +1,155 @@
+#!/usr/bin/python
+#
+# Interactive shell for testing PLCAPI
+#
+# Mark Huang <mlhuang@cs.princeton.edu>
+# Copyright (C) 2005 The Trustees of Princeton University
+#
+# $Id: plcsh,v 1.3 2006/01/09 19:57:24 mlhuang Exp $
+#
+
+import os, sys
+import traceback
+import getopt
+import pydoc
+
+from PLC.API import PLCAPI
+from PLC.Method import Method
+
+# Defaults
+config = "/etc/planetlab/plc_config"
+
+def usage():
+    print "Usage: %s [OPTION]..." % sys.argv[0]
+    print "Options:"
+    print "     -f, --config=FILE       PLC configuration file (default: %s)" % config
+    print "     -h, --help              This message"
+    sys.exit(1)
+
+# Get options
+try:
+    (opts, argv) = getopt.getopt(sys.argv[1:], "f:h", ["config=", "help"])
+except getopt.GetoptError, err:
+    print "Error: " + err.msg
+    usage()
+
+for (opt, optval) in opts:
+    if opt == "-f" or opt == "--config":
+        config = optval
+    elif opt == "-h" or opt == "--help":
+        usage()
+
+api = PLCAPI(config)
+
+class Dummy:
+    """
+    Dummy class to support tab completion of API methods with dots in
+    their names (e.g., system.listMethods).
+    """
+    pass
+        
+# Define all methods in the global namespace to support tab completion
+for method in api.methods:
+    paths = method.split(".")
+    if len(paths) > 1:
+        first = paths.pop(0)
+        if first not in globals():
+            globals()[first] = Dummy()
+        obj = globals()[first]
+        for path in paths:
+            if not hasattr(obj, path):
+                if path == paths[-1]:
+                    setattr(obj, path, api.callable(method))
+                else:
+                    setattr(obj, path, Dummy())
+            obj = getattr(obj, path)
+    else:
+        globals()[method] = api.callable(method)
+
+pyhelp = help
+def help(thing):
+    """
+    Override builtin help() function to support calling help(method).
+    """
+
+    if isinstance(thing, Method):
+        return pydoc.pager(thing.help())
+    else:
+        return pyhelp(thing)
+
+# If a file is specified
+if argv:
+    execfile(argv[0])
+    sys.exit(0)
+
+# Otherwise, create an interactive shell environment
+
+print "PlanetLab Central Direct API Access"
+prompt = ""
+print 'Type "system.listMethods()" or "help(method)" for more information.'
+
+# Readline and tab completion support
+import atexit
+import readline
+import rlcompleter
+
+# Load command history
+history_path = os.path.join(os.environ["HOME"], ".plcapi_history")
+try:
+    file(history_path, 'a').close()
+    readline.read_history_file(history_path)
+    atexit.register(readline.write_history_file, history_path)
+except IOError:
+    pass
+
+# Enable tab completion
+readline.parse_and_bind("tab: complete")
+
+try:
+    while True:
+        command = ""
+        while True:
+            # Get line
+            try:
+                if command == "":
+                    sep = ">>> "
+                else:
+                    sep = "... "
+                line = raw_input(prompt + sep)
+            # Ctrl-C
+            except KeyboardInterrupt:
+                command = ""
+                print
+                break
+
+            # Build up multi-line command
+            command += line
+
+            # Blank line or first line does not end in :
+            if line == "" or (command == line and line[-1] != ':'):
+                break
+
+            command += os.linesep
+
+        # Blank line
+        if command == "":
+            continue
+        # Quit
+        elif command in ["q", "quit", "exit"]:
+            break
+
+        try:
+            try:
+                # Try evaluating as an expression and printing the result
+                result = eval(command)
+                if result is not None:
+                    print result
+            except:
+                # Fall back to executing as a statement
+                exec command
+        except Exception, err:
+            traceback.print_exc()
+
+except EOFError:
+    print
+    pass
diff --git a/TODO b/TODO
new file mode 100644 (file)
index 0000000..c27899f
--- /dev/null
+++ b/TODO
@@ -0,0 +1,27 @@
+* Event logging
+  * In the current API, every call is logged and certain interesting
+    events are logged in the events table. I haven't implemented event
+    logging yet in the new API.
+
+* Tests
+  * With Shell.py, it should be easy to write a large set of tests. I've
+    thought about writing a SQLite DB backend so that MyPLC/PostgreSQL
+    doesn't have to be setup in order for the tests to be run. But there
+    are some technical limitations to SQLite. It would probably be best
+    to run the testsuite against MyPLC for now.
+
+* Authentication
+  * Need to implement node and certificate/federation authentication.
+  * Need to (re)implement "capability" (i.e. trusted host)
+    authentication. Maybe implement it in the same way as node
+    authentication.
+
+* Anonymous functions
+  * Implement anonymous functions for now for backward compatibility,
+    but get rid of them as soon as possible
+
+* Hierarchical layout
+  * Probably need to organize the functions inside PLC/Methods/
+
+* Deletion
+  * Need to come up with a sane, consistent principal deletion policy.
diff --git a/doc/.cvsignore b/doc/.cvsignore
new file mode 100644 (file)
index 0000000..f183384
--- /dev/null
@@ -0,0 +1,11 @@
+*.dvi
+*.html
+*.man
+*.ps
+*.pdf
+*.rtf
+*.tex
+*.texi
+*.txt
+*.xml.valid
+Methods.xml
diff --git a/doc/DocBook.py b/doc/DocBook.py
new file mode 100755 (executable)
index 0000000..a62ef9b
--- /dev/null
@@ -0,0 +1,222 @@
+#!/usr/bin/python
+#
+# Generates a DocBook section documenting all PLCAPI methods on
+# stdout.
+#
+# Mark Huang <mlhuang@cs.princeton.edu>
+# Copyright (C) 2006 The Trustees of Princeton University
+#
+# $Id$
+#
+
+import xml.dom.minidom
+from xml.dom.minidom import Element, Text
+import codecs
+
+from PLC.API import PLCAPI
+from PLC.Method import *
+
+api = PLCAPI(None)
+
+# xml.dom.minidom.Text.writexml adds surrounding whitespace to textual
+# data when pretty-printing. Override this behavior.
+class TrimText(Text):
+    def writexml(self, writer, indent="", addindent="", newl=""):
+        Text.writexml(self, writer, "", "", "")
+
+class TrimTextElement(Element):
+    def writexml(self, writer, indent="", addindent="", newl=""):
+        writer.write(indent)
+        Element.writexml(self, writer, "", "", "")
+        writer.write(newl)
+
+class simpleElement(TrimTextElement):
+    """<tagName>text</tagName>"""
+    def __init__(self, tagName, text = None):
+        TrimTextElement.__init__(self, tagName)
+        if text is not None:
+            t = TrimText()
+            t.data = unicode(text)
+            self.appendChild(t)
+
+class paraElement(simpleElement):
+    """<para>text</para>"""
+    def __init__(self, text = None):
+        simpleElement.__init__(self, 'para', text)
+
+class blockquoteElement(Element):
+    """<blockquote><para>text...</para><para>...text</para></blockquote>"""
+    def __init__(self, text = None):
+        Element.__init__(self, 'blockquote')
+        if text is not None:
+            # Split on blank lines
+            lines = [line.strip() for line in text.strip().split("\n")]
+            lines = "\n".join(lines)
+            paragraphs = lines.split("\n\n")
+
+            for paragraph in paragraphs:
+                self.appendChild(paraElement(paragraph))
+
+class entryElement(simpleElement):
+    """<entry>text</entry>"""
+    def __init__(self, text = None):
+        simpleElement.__init__(self, 'entry', text)
+
+class rowElement(Element):
+    """
+    <row>
+      <entry>entries[0]</entry>
+      <entry>entries[1]</entry>
+      ...
+    </row>
+    """
+
+    def __init__(self, entries = None):
+        Element.__init__(self, 'row')
+        if entries:
+            for entry in entries:
+                if isinstance(entry, entryElement):
+                    self.appendChild(entry)
+                else:
+                    self.appendChild(entryElement(entry))
+
+class informaltableElement(Element):
+    """
+    <informaltable>
+      <tgroup>
+       <thead>
+         <row>
+           <entry>label1</entry>
+           <entry>label2</entry>
+           ...
+         </row>
+       </thead>
+       <tbody>
+         <row>
+           <entry>row1value1</entry>
+           <entry>row1value2</entry>
+           ...
+         </row>
+         ...
+       </tbody>
+      </tgroup>
+    </informaltable>
+    """
+
+    def __init__(self, head = None, rows = None):
+        Element.__init__(self, 'informaltable')
+        tgroup = Element('tgroup')
+        self.appendChild(tgroup)
+        if head:
+            thead = Element('thead')
+            tgroup.appendChild(thead)
+            if isinstance(head, rowElement):
+                thead.appendChild(head)
+            else:
+                thead.appendChild(rowElement(head))
+        if rows:
+            tbody = Element('tbody')
+            tgroup.appendChild(tbody)
+            for row in rows:
+                if isinstance(row, rowElement):
+                    tbody.appendChild(row)
+                else:
+                    tobdy.appendChild(rowElement(row))
+            cols = len(thead.childNodes[0].childNodes)
+            tgroup.setAttribute('cols', str(cols))
+
+def parameters(param, name, optional, default, doc, indent, step):
+    """Format a parameter into parameter row(s)."""
+
+    rows = []
+
+    row = rowElement()
+    rows.append(row)
+
+    # Parameter name
+    entry = entryElement()
+    entry.appendChild(simpleElement('literallayout', indent + name))
+    row.appendChild(entry)
+
+    # Parameter type
+    param_type = python_type(param)
+    row.appendChild(entryElement(xmlrpc_type(param_type)))
+
+    # Whether parameter is optional
+    row.appendChild(entryElement(str(bool(optional))))
+
+    # Parameter default
+    if optional and default is not None:
+        row.appendChild(entryElement(unicode(default)))
+    else:
+        row.appendChild(entryElement())
+
+    # Parameter documentation
+    row.appendChild(entryElement(doc))
+
+    # Indent the name of each sub-parameter
+    subparams = []
+    if isinstance(param, dict):
+        subparams = param.iteritems()
+    elif isinstance(param, Mixed):
+        subparams = [(name, subparam) for subparam in param]
+    elif isinstance(param, (list, tuple)):
+        subparams = [("", subparam) for subparam in param]
+
+    for name, subparam in subparams:
+        if isinstance(subparam, Parameter):
+            (optional, default, doc) = (subparam.optional, subparam.default, subparam.doc)
+        else:
+            # All named sub-parameters are optional if not otherwise specified
+            (optional, default, doc) = (True, None, "")
+        rows += parameters(subparam, name, optional, default, doc, indent + step, step)
+
+    return rows
+
+for method in api.methods:
+    func = api.callable(method)
+    (min_args, max_args, defaults) = func.args()
+
+    section = Element('section')
+    section.setAttribute('id', func.name)
+    section.appendChild(simpleElement('title', func.name))
+
+    para = paraElement('Status:')
+    para.appendChild(blockquoteElement(func.status))
+    section.appendChild(para)
+
+    prototype = "%s (%s)" % (method, ", ".join(max_args))
+    para = paraElement('Prototype:')
+    para.appendChild(blockquoteElement(prototype))
+    section.appendChild(para)
+
+    para = paraElement('Description:')
+    para.appendChild(blockquoteElement(func.__doc__))
+    section.appendChild(para)
+
+    para = paraElement('Parameters:')
+    blockquote = blockquoteElement()
+    para.appendChild(blockquote)
+    section.appendChild(para)
+
+    head = rowElement(['Name', 'Type', 'Optional', 'Default', 'Description'])
+    rows = []
+
+    indent = "  "
+    for name, param, default in zip(max_args, func.accepts, defaults):
+        optional = name not in min_args
+        if isinstance(param, Parameter):
+            doc = param.doc
+        else:
+            doc = ""
+        rows += parameters(param, name, optional, default, doc, "", indent)
+
+    if rows:
+        informaltable = informaltableElement(head, rows)
+        informaltable.setAttribute('frame', "none")
+        informaltable.setAttribute('rules', "rows")
+        blockquote.appendChild(informaltable)
+    else:
+        blockquote.appendChild(paraElement("None"))
+
+    print section.toprettyxml(encoding = "UTF-8")
diff --git a/doc/Makefile b/doc/Makefile
new file mode 100644 (file)
index 0000000..1340f8e
--- /dev/null
@@ -0,0 +1,45 @@
+#
+# (Re)builds API documentation
+#
+# Mark Huang <mlhuang@cs.princeton.edu>
+# Copyright (C) 2006 The Trustees of Princeton University
+#
+# $Id: plcsh,v 1.3 2006/01/09 19:57:24 mlhuang Exp $
+#
+
+all: PLCAPI.pdf
+
+.PLCAPI.xml.valid: Methods.xml
+
+Methods.xml: DocBook.py ../PLC/__init__.py ../PLC/Methods/__init__.py
+       PYTHONPATH=.. python $< > $@
+
+#
+# Documentation
+#
+
+# Validate the XML
+.%.xml.valid: %.xml
+       xmllint --valid --output $@ $<
+
+# Remove the temporary output file after compilation
+.SECONDARY: .%.xml.valid
+
+# Compile it into other formats
+FORMATS := dvi html man ps pdf rtf tex texi txt
+
+DOCBOOK2FLAGS := -V biblio-number=1
+
+define docbook2
+%.$(1): %.xml .%.xml.valid
+       docbook2$(1) --nochunks $$(DOCBOOK2FLAGS) $$<
+endef
+
+$(foreach format,$(FORMATS),$(eval $(call docbook2,$(format))))
+
+clean:
+       rm -f $(patsubst %,*.%,$(FORMATS)) .*.xml.valid
+
+force:
+
+.PHONY: force clean docclean
diff --git a/doc/PLCAPI.xml b/doc/PLCAPI.xml
new file mode 100644 (file)
index 0000000..27cc43d
--- /dev/null
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE book PUBLIC "-//OASIS//DTD DocBook XML V4.3//EN"
+    "http://www.oasis-open.org/docbook/xml/4.3/docbookx.dtd" [
+<!ENTITY Methods SYSTEM "Methods.xml">
+]>
+
+<book>
+  <bookinfo>
+    <title>PlanetLab Central API Documentation</title>
+    <authorgroup>
+      <author><firstname>Aaron</firstname><surname>Klingaman</surname></author>
+      <author><firstname>Mark</firstname><surname>Huang</surname></author>
+    </authorgroup>
+  </bookinfo>
+
+  <chapter id="Methods">
+    <title>PlanetLab API Methods</title>
+    <para></para>
+
+    &Methods;
+  </chapter>
+
+</book>