From: Mark Huang Date: Wed, 6 Sep 2006 15:43:11 +0000 (+0000) Subject: Initial checkin of new API implementation X-Git-Tag: pycurl-7_13_1~796 X-Git-Url: http://git.onelab.eu/?p=plcapi.git;a=commitdiff_plain;h=24d16d18acab3da7bccc3e09df4927e9cf2d3246 Initial checkin of new API implementation --- diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b6d125a --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +# +# (Re)builds Python metafiles (__init__.py) and documentation +# +# Mark Huang +# 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 index 0000000..e5f1174 --- /dev/null +++ b/ModPython.py @@ -0,0 +1,59 @@ +# +# Apache mod_python interface +# +# Aaron Klingaman +# Mark Huang +# +# 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(""" + +PLCAPI XML-RPC/SOAP Interface + +

PLCAPI XML-RPC/SOAP Interface

+

Please use XML-RPC or SOAP to access the PLCAPI.

+ +""") + 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 index 0000000..0d20b64 --- /dev/null +++ b/PLC/.cvsignore @@ -0,0 +1 @@ +*.pyc diff --git a/PLC/API.py b/PLC/API.py new file mode 100644 index 0000000..4b19291 --- /dev/null +++ b/PLC/API.py @@ -0,0 +1,107 @@ +# +# PLCAPI XML-RPC and SOAP interfaces +# +# Aaron Klingaman +# Mark Huang +# +# 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 index 0000000..e94f086 --- /dev/null +++ b/PLC/AddressTypes.py @@ -0,0 +1,27 @@ +# +# Functions for interacting with the address_types table in the database +# +# Mark Huang +# 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 index 0000000..45b4b13 --- /dev/null +++ b/PLC/Addresses.py @@ -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 index 0000000..2b2ea02 --- /dev/null +++ b/PLC/Auth.py @@ -0,0 +1,112 @@ +# +# PLCAPI authentication parameters +# +# Mark Huang +# 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 index 0000000..2cc0b22 --- /dev/null +++ b/PLC/BootStates.py @@ -0,0 +1,25 @@ +# +# Functions for interacting with the node_bootstates table in the database +# +# Mark Huang +# 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 index 0000000..7d666b3 --- /dev/null +++ b/PLC/Config.py @@ -0,0 +1,96 @@ +#!/usr/bin/python +# +# PLCAPI configuration store. Supports XML-based configuration file +# format exported by MyPLC. +# +# Mark Huang +# 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 index 0000000..ccd7db7 --- /dev/null +++ b/PLC/Debug.py @@ -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 index 0000000..1b2cdd3 --- /dev/null +++ b/PLC/Faults.py @@ -0,0 +1,67 @@ +# +# PLCAPI XML-RPC faults +# +# Aaron Klingaman +# Mark Huang +# +# 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 index 0000000..175a16b --- /dev/null +++ b/PLC/Keys.py @@ -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 index 0000000..f830776 --- /dev/null +++ b/PLC/Method.py @@ -0,0 +1,314 @@ +# +# Base class for all PLCAPI functions +# +# Mark Huang +# 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 index 0000000..0d20b64 --- /dev/null +++ b/PLC/Methods/.cvsignore @@ -0,0 +1 @@ +*.pyc diff --git a/PLC/Methods/AdmAddNode.py b/PLC/Methods/AdmAddNode.py new file mode 100644 index 0000000..bf60827 --- /dev/null +++ b/PLC/Methods/AdmAddNode.py @@ -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 index 0000000..abbf3a6 --- /dev/null +++ b/PLC/Methods/AdmAddPerson.py @@ -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 index 0000000..d689dce --- /dev/null +++ b/PLC/Methods/AdmAddPersonToSite.py @@ -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 index 0000000..2412481 --- /dev/null +++ b/PLC/Methods/AdmAddSite.py @@ -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 index 0000000..3b5086e --- /dev/null +++ b/PLC/Methods/AdmAuthCheck.py @@ -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 index 0000000..3e308fc --- /dev/null +++ b/PLC/Methods/AdmDeleteNode.py @@ -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 index 0000000..8a4fd93 --- /dev/null +++ b/PLC/Methods/AdmDeletePerson.py @@ -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 index 0000000..e8581ff --- /dev/null +++ b/PLC/Methods/AdmDeleteSite.py @@ -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 index 0000000..7dc2d15 --- /dev/null +++ b/PLC/Methods/AdmGetAllRoles.py @@ -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 index 0000000..c38b8d4 --- /dev/null +++ b/PLC/Methods/AdmGetNodes.py @@ -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 index 0000000..bfb7c47 --- /dev/null +++ b/PLC/Methods/AdmGetPersonRoles.py @@ -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 index 0000000..9c82f5b --- /dev/null +++ b/PLC/Methods/AdmGetPersonSites.py @@ -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 index 0000000..b42391f --- /dev/null +++ b/PLC/Methods/AdmGetPersons.py @@ -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 index 0000000..5f74fcf --- /dev/null +++ b/PLC/Methods/AdmGetSites.py @@ -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 index 0000000..9d1cc6d --- /dev/null +++ b/PLC/Methods/AdmGrantRoleToPerson.py @@ -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 index 0000000..b5fc97e --- /dev/null +++ b/PLC/Methods/AdmIsPersonInRole.py @@ -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 index 0000000..c957770 --- /dev/null +++ b/PLC/Methods/AdmRemovePersonFromSite.py @@ -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 index 0000000..61dea2f --- /dev/null +++ b/PLC/Methods/AdmRevokeRoleFromPerson.py @@ -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 index 0000000..87aa923 --- /dev/null +++ b/PLC/Methods/AdmSetPersonEnabled.py @@ -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 index 0000000..3246333 --- /dev/null +++ b/PLC/Methods/AdmSetPersonPrimarySite.py @@ -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 index 0000000..e5391af --- /dev/null +++ b/PLC/Methods/AdmUpdateNode.py @@ -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 index 0000000..a24ab2a --- /dev/null +++ b/PLC/Methods/AdmUpdatePerson.py @@ -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 index 0000000..3ea8f6a --- /dev/null +++ b/PLC/Methods/__init__.py @@ -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 index 0000000..0d20b64 --- /dev/null +++ b/PLC/Methods/system/.cvsignore @@ -0,0 +1 @@ +*.pyc diff --git a/PLC/Methods/system/__init__.py b/PLC/Methods/system/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/PLC/Methods/system/listMethods.py b/PLC/Methods/system/listMethods.py new file mode 100644 index 0000000..c8cfa37 --- /dev/null +++ b/PLC/Methods/system/listMethods.py @@ -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 index 0000000..22a0dc1 --- /dev/null +++ b/PLC/Methods/system/methodHelp.py @@ -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 index 0000000..4b049a1 --- /dev/null +++ b/PLC/Methods/system/methodSignature.py @@ -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 index 0000000..64563ef --- /dev/null +++ b/PLC/Methods/system/multicall.py @@ -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 index 0000000..aff2c8f --- /dev/null +++ b/PLC/NodeGroups.py @@ -0,0 +1,144 @@ +# +# Functions for interacting with the nodegroups table in the database +# +# Mark Huang +# 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 index 0000000..80bd620 --- /dev/null +++ b/PLC/NodeNetworks.py @@ -0,0 +1,258 @@ +# +# Functions for interacting with the nodenetworks table in the database +# +# Mark Huang +# 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 index 0000000..4c93e3d --- /dev/null +++ b/PLC/Nodes.py @@ -0,0 +1,271 @@ +# +# Functions for interacting with the nodes table in the database +# +# Mark Huang +# 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 index 0000000..bcf9255 --- /dev/null +++ b/PLC/PCUs.py @@ -0,0 +1,126 @@ +# +# Functions for interacting with the pcus table in the database +# +# Mark Huang +# 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 index 0000000..1430b27 --- /dev/null +++ b/PLC/Parameter.py @@ -0,0 +1,31 @@ +# +# Shared type definitions +# +# Mark Huang +# 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 index 0000000..6d4be44 --- /dev/null +++ b/PLC/Persons.py @@ -0,0 +1,345 @@ +# +# Functions for interacting with the persons table in the database +# +# Mark Huang +# 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 index 0000000..8376804 --- /dev/null +++ b/PLC/PostgreSQL.py @@ -0,0 +1,135 @@ +# +# PostgreSQL database interface. Sort of like DBI(3) (Database +# independent interface for Perl). +# +# Mark Huang +# 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 index 0000000..9835f37 --- /dev/null +++ b/PLC/Roles.py @@ -0,0 +1,32 @@ +# +# Functions for interacting with the roles table in the database +# +# Mark Huang +# 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 index 0000000..d3d4dcd --- /dev/null +++ b/PLC/Sites.py @@ -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 index 0000000..77b5b2d --- /dev/null +++ b/PLC/Slices.py @@ -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 index 0000000..3505d41 --- /dev/null +++ b/PLC/Table.py @@ -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 index 0000000..f4ec5ca --- /dev/null +++ b/PLC/__init__.py @@ -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 index 0000000..146eb00 --- /dev/null +++ b/PLC/md5crypt.py @@ -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): + 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 . + +""" + +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 index 0000000..8314281 --- /dev/null +++ b/Server.py @@ -0,0 +1,99 @@ +#!/usr/bin/python +# +# Simple standalone HTTP server for testing PLCAPI +# +# Mark Huang +# 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(""" + +PLCAPI XML-RPC/SOAP Interface + +

PLCAPI XML-RPC/SOAP Interface

+

Please use XML-RPC or SOAP to access the PLCAPI.

+ +""") + +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 index 0000000..232bf28 --- /dev/null +++ b/Shell.py @@ -0,0 +1,155 @@ +#!/usr/bin/python +# +# Interactive shell for testing PLCAPI +# +# Mark Huang +# 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 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 index 0000000..f183384 --- /dev/null +++ b/doc/.cvsignore @@ -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 index 0000000..a62ef9b --- /dev/null +++ b/doc/DocBook.py @@ -0,0 +1,222 @@ +#!/usr/bin/python +# +# Generates a DocBook section documenting all PLCAPI methods on +# stdout. +# +# Mark Huang +# 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): + """text""" + 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): + """text""" + def __init__(self, text = None): + simpleElement.__init__(self, 'para', text) + +class blockquoteElement(Element): + """
text......text
""" + 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): + """text""" + def __init__(self, text = None): + simpleElement.__init__(self, 'entry', text) + +class rowElement(Element): + """ + + entries[0] + entries[1] + ... + + """ + + 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): + """ + + + + + label1 + label2 + ... + + + + + row1value1 + row1value2 + ... + + ... + + + + """ + + 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 index 0000000..1340f8e --- /dev/null +++ b/doc/Makefile @@ -0,0 +1,45 @@ +# +# (Re)builds API documentation +# +# Mark Huang +# 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 index 0000000..27cc43d --- /dev/null +++ b/doc/PLCAPI.xml @@ -0,0 +1,23 @@ + + +]> + + + + PlanetLab Central API Documentation + + AaronKlingaman + MarkHuang + + + + + PlanetLab API Methods + + + &Methods; + + +