From 3964168d1efcbd07480975a3cee54091348a1c0c Mon Sep 17 00:00:00 2001 From: Fabio Verboso Date: Fri, 3 Mar 2017 18:22:09 +0100 Subject: [PATCH] new API dev, new policy, keystone integration Change-Id: Id90353f5d31e848f16f36fd44935459cd8b27bb7 --- etc/iotronic/policy.json | 3 - iotronic/api/__init__.py | 38 ---- iotronic/api/acl.py | 34 --- iotronic/api/app.py | 108 +++++++--- iotronic/api/config.py | 11 +- iotronic/api/controllers/base.py | 19 +- iotronic/api/controllers/link.py | 11 +- iotronic/api/controllers/root.py | 70 ++++--- iotronic/api/controllers/v1/__init__.py | 98 +++++---- iotronic/api/controllers/v1/collection.py | 2 +- iotronic/api/controllers/v1/node.py | 242 +++++++++++++-------- iotronic/api/controllers/v1/plugin.py | 168 +++++++++++---- iotronic/api/controllers/v1/types.py | 205 ++++++++++++++---- iotronic/api/controllers/v1/utils.py | 16 ++ iotronic/api/controllers/v1/versions.py | 25 +++ iotronic/api/hooks.py | 130 +++++------- iotronic/api/middleware/__init__.py | 4 +- iotronic/api/middleware/auth_token.py | 9 +- iotronic/api/middleware/parsable_error.py | 11 +- iotronic/common/context.py | 89 +++++--- iotronic/common/exception.py | 2 +- iotronic/common/policy.py | 170 ++++++++++++++- iotronic/db/api.py | 11 +- iotronic/db/sqlalchemy/api.py | 17 +- iotronic/db/sqlalchemy/models.py | 3 +- iotronic/objects/location.py | 26 ++- iotronic/objects/node.py | 3 +- iotronic/objects/sessionwp.py | 6 +- utils/iotronic.sql | 13 +- utils/iotronic_curl_client | 243 +++++++++++++++++----- utils/zero | 14 ++ var/www/cgi-bin/iotronic/app.wsgi | 19 +- 32 files changed, 1246 insertions(+), 574 deletions(-) delete mode 100644 iotronic/api/acl.py create mode 100644 iotronic/api/controllers/v1/versions.py create mode 100644 utils/zero diff --git a/etc/iotronic/policy.json b/etc/iotronic/policy.json index f772677..2c63c08 100644 --- a/etc/iotronic/policy.json +++ b/etc/iotronic/policy.json @@ -1,5 +1,2 @@ { - "admin_api": "role:admin or role:administrator", - "show_password": "!", - "default": "rule:admin_api" } diff --git a/iotronic/api/__init__.py b/iotronic/api/__init__.py index 46a025c..e69de29 100644 --- a/iotronic/api/__init__.py +++ b/iotronic/api/__init__.py @@ -1,38 +0,0 @@ -# Copyright 2013 Hewlett-Packard Development Company, L.P. -# All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -from oslo_config import cfg - - -API_SERVICE_OPTS = [ - cfg.StrOpt('host_ip', - default='0.0.0.0', - help='The IP address on which iotronic-api listens.'), - cfg.IntOpt('port', - default=1288, - help='The TCP port on which iotronic-api listens.'), - cfg.IntOpt('max_limit', - default=1000, - help='The maximum number of items returned in a single ' - 'response from a collection resource.'), -] - - -CONF = cfg.CONF - -opt_group = cfg.OptGroup(name='api', - title='Options for the iotronic-api service') -CONF.register_group(opt_group) -CONF.register_opts(API_SERVICE_OPTS, opt_group) diff --git a/iotronic/api/acl.py b/iotronic/api/acl.py deleted file mode 100644 index 3d8c841..0000000 --- a/iotronic/api/acl.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- encoding: utf-8 -*- -# -# Copyright © 2012 New Dream Network, LLC (DreamHost) -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -"""Access Control Lists (ACL's) control access the API server.""" - -from iotronic.api.middleware import auth_token - - -def install(app, conf, public_routes): - """Install ACL check on application. - - :param app: A WSGI applicatin. - :param conf: Settings. Dict'ified and passed to keystonemiddleware - :param public_routes: The list of the routes which will be allowed to - access without authentication. - :return: The same WSGI application with ACL installed. - - """ - return auth_token.AuthTokenMiddleware(app, - conf=dict(conf), - public_api_routes=public_routes) diff --git a/iotronic/api/app.py b/iotronic/api/app.py index 44b024c..76a7e69 100644 --- a/iotronic/api/app.py +++ b/iotronic/api/app.py @@ -15,22 +15,30 @@ # License for the specific language governing permissions and limitations # under the License. -from oslo_config import cfg -import pecan -from iotronic.api import acl from iotronic.api import config +from iotronic.api.controllers import base from iotronic.api import hooks from iotronic.api import middleware +from iotronic.api.middleware import auth_token +from oslo_config import cfg +import oslo_middleware.cors as cors_middleware +import pecan +from pecan import make_app -api_opts = [ +opts = [ cfg.StrOpt( 'auth_strategy', default='keystone', - help='Authentication strategy used by iotronic-api: one of "keystone" ' - 'or "noauth". "noauth" should not be used in a production ' - 'environment because all authentication will be disabled.'), + help=('Authentication strategy used by iotronic-api: "keystone" ' + 'or "noauth". "noauth" should not be used in a production ' + 'environment because all authentication will be disabled.')), + cfg.BoolOpt('debug_tracebacks_in_api', + default=False, + help=('Return server tracebacks in the API response for any ' + 'error responses. WARNING: this is insecure ' + 'and should not be used in a production environment.')), cfg.BoolOpt( 'pecan_debug', default=False, @@ -39,8 +47,46 @@ api_opts = [ 'and should not be used in a production environment.')), ] +api_opts = [ + cfg.StrOpt('host_ip', + default='0.0.0.0', + help=('The IP address on which iotronic-api listens.')), + cfg.PortOpt('port', + default=1288, + help=('The TCP port on which iotronic-api listens.')), + cfg.IntOpt('max_limit', + default=1000, + help=('The maximum number of items returned in a single ' + 'response from a collection resource.')), + cfg.StrOpt('public_endpoint', + help=("Public URL to use when building the links to the API " + "resources." + " If None the links will be built using the request's " + "host URL. If the API is operating behind a proxy, you " + "will want to change this to represent the proxy's URL. " + "Defaults to None.")), + cfg.IntOpt('api_workers', + help=('Number of workers for OpenStack Iotronic API service. ' + 'The default is equal to the number of CPUs available ' + 'if that can be determined, else a default worker ' + 'count of 1 is returned.')), + cfg.BoolOpt('enable_ssl_api', + default=False, + help=("Enable the integrated stand-alone API to service " + "requests via HTTPS instead of HTTP. If there is a " + "front-end service performing HTTPS offloading from " + "the service, this option should be False; note, you " + "will want to change public API endpoint to represent " + "SSL termination URL with 'public_endpoint' option.")), +] + +opt_group = cfg.OptGroup(name='api', + title='Options for the iotronic-api service') + + CONF = cfg.CONF -CONF.register_opts(api_opts) +CONF.register_opts(opts,) +CONF.register_opts(api_opts, 'api') def get_pecan_config(): @@ -49,34 +95,40 @@ def get_pecan_config(): return pecan.configuration.conf_from_file(filename) -def setup_app(pecan_config=None, extra_hooks=None): +def setup_app(config=None): + app_hooks = [hooks.ConfigHook(), hooks.DBHook(), - hooks.ContextHook(pecan_config.app.acl_public_routes), + hooks.ContextHook(config.app.acl_public_routes), hooks.RPCHook(), - hooks.NoExceptionTracebackHook()] - if extra_hooks: - app_hooks.extend(extra_hooks) + hooks.NoExceptionTracebackHook(), + hooks.PublicUrlHook()] - if not pecan_config: - pecan_config = get_pecan_config() + app_conf = dict(config.app) - if pecan_config.app.enable_acl: - app_hooks.append(hooks.TrustedCallHook()) - - pecan.configuration.set_config(dict(pecan_config), overwrite=True) - - app = pecan.make_app( - pecan_config.app.root, - static_root=pecan_config.app.static_root, - debug=CONF.pecan_debug, - force_canonical=getattr(pecan_config.app, 'force_canonical', True), + app = make_app( + app_conf.pop('root'), hooks=app_hooks, + force_canonical=getattr(config.app, 'force_canonical', True), wrap_app=middleware.ParsableErrorMiddleware, + **app_conf ) - if pecan_config.app.enable_acl: - return acl.install(app, cfg.CONF, pecan_config.app.acl_public_routes) + if CONF.auth_strategy == "keystone": + app = auth_token.AuthTokenMiddleware( + app, dict(cfg.CONF), + public_api_routes=config.app.acl_public_routes) + + # Create a CORS wrapper, and attach iotronic-specific defaults that must be + # included in all CORS responses. + app = cors_middleware.CORS(app, CONF) + app.set_latent( + allow_headers=[base.Version.max_string, base.Version.min_string, + base.Version.string], + allow_methods=['GET', 'PUT', 'POST', 'DELETE', 'PATCH'], + expose_headers=[base.Version.max_string, base.Version.min_string, + base.Version.string] + ) return app @@ -86,7 +138,7 @@ class VersionSelectorApplication(object): def __init__(self): pc = get_pecan_config() pc.app.enable_acl = (CONF.auth_strategy == 'keystone') - self.v1 = setup_app(pecan_config=pc) + self.v1 = setup_app(config=pc) def __call__(self, environ, start_response): return self.v1(environ, start_response) diff --git a/iotronic/api/config.py b/iotronic/api/config.py index c1f8966..82f2222 100644 --- a/iotronic/api/config.py +++ b/iotronic/api/config.py @@ -25,18 +25,9 @@ app = { 'root': 'iotronic.api.controllers.root.RootController', 'modules': ['iotronic.api'], 'static_root': '%(confdir)s/public', - 'debug': True, - 'enable_acl': True, + 'debug': False, 'acl_public_routes': [ '/', '/v1', - '/v1/nodes/[a-z0-9\-]', - '/v1/plugins/[a-z0-9\-]', ], } - -# WSME Configurations -# See https://wsme.readthedocs.org/en/latest/integrate.html#configuration -wsme = { - 'debug': False, -} diff --git a/iotronic/api/controllers/base.py b/iotronic/api/controllers/base.py index c8f2137..69c0625 100644 --- a/iotronic/api/controllers/base.py +++ b/iotronic/api/controllers/base.py @@ -13,6 +13,7 @@ # under the License. import datetime +import functools from webob import exc import wsme @@ -50,6 +51,7 @@ class APIBase(wtypes.Base): setattr(self, k, wsme.Unset) +@functools.total_ordering class Version(object): """API Version object.""" @@ -83,7 +85,7 @@ class Version(object): :param headers: webob headers :param default_version: version to use if not specified in headers :param latest_version: version to use if latest is requested - :returns: a tupe of (major, minor) version numbers + :returns: a tuple of (major, minor) version numbers :raises: webob.HTTPNotAcceptable """ version_str = headers.get(Version.string, default_version) @@ -103,12 +105,11 @@ class Version(object): "Invalid value for %s header") % Version.string) return version - def __lt__(a, b): - if (a.major == b.major and a.minor < b.minor): - return True - return False + def __gt__(self, other): + return (self.major, self.minor) > (other.major, other.minor) - def __gt__(a, b): - if (a.major == b.major and a.minor > b.minor): - return True - return False + def __eq__(self, other): + return (self.major, self.minor) == (other.major, other.minor) + + def __ne__(self, other): + return not self.__eq__(other) diff --git a/iotronic/api/controllers/link.py b/iotronic/api/controllers/link.py index 861da3b..0e26503 100644 --- a/iotronic/api/controllers/link.py +++ b/iotronic/api/controllers/link.py @@ -21,13 +21,13 @@ from iotronic.api.controllers import base def build_url(resource, resource_args, bookmark=False, base_url=None): if base_url is None: - base_url = pecan.request.host_url + base_url = pecan.request.public_url template = '%(url)s/%(res)s' if bookmark else '%(url)s/v1/%(res)s' # FIXME(lucasagomes): I'm getting a 404 when doing a GET on # a nested resource that the URL ends with a '/'. # https://groups.google.com/forum/#!topic/pecan-dev/QfSeviLg5qs - template += '%(args)s' if resource_args.startswith('?') else '/%(args)s' + # template += '%(args)s' if resource_args.startswith('?') else '/%(args)s' return template % {'url': base_url, 'res': resource, 'args': resource_args} @@ -49,10 +49,3 @@ class Link(base.APIBase): href = build_url(resource, resource_args, bookmark=bookmark, base_url=url) return Link(href=href, rel=rel_name, type=type) - - @classmethod - def sample(cls): - sample = cls(href="http://localhost:1288/node/" - "eaaca217-e7d8-47b4-bb41-3f99f20eed89", - rel="bookmark") - return sample diff --git a/iotronic/api/controllers/root.py b/iotronic/api/controllers/root.py index 725f453..8b8ef8c 100644 --- a/iotronic/api/controllers/root.py +++ b/iotronic/api/controllers/root.py @@ -14,36 +14,55 @@ # License for the specific language governing permissions and limitations # under the License. +from iotronic.api.controllers import base +from iotronic.api.controllers import link +from iotronic.api.controllers import v1 +from iotronic.api.controllers.v1 import versions +from iotronic.api import expose import pecan from pecan import rest from wsme import types as wtypes -from iotronic.api.controllers import base -from iotronic.api.controllers import link -from iotronic.api.controllers import v1 -from iotronic.api import expose +ID_VERSION1 = 'v1' class Version(base.APIBase): - """An API version representation.""" + """An API version representation. + + This class represents an API version, including the minimum and + maximum minor versions that are supported within the major version. + """ id = wtypes.text - """The ID of the version, also acts as the release number""" + """The ID of the (major) version, also acts as the release number""" links = [link.Link] """A Link that point to a specific version of the API""" - @staticmethod - def convert(id): - version = Version() - version.id = id - version.links = [link.Link.make_link('self', pecan.request.host_url, - id, '', bookmark=True)] - return version + status = wtypes.text + """Status of the version. + One of: + * CURRENT - the latest version of API, + * SUPPORTED - supported, but not latest, version of API, + * DEPRECATED - supported, but deprecated, version of API. + """ + + version = wtypes.text + """The current, maximum supported (major.minor) version of API.""" + + min_version = wtypes.text + """Minimum supported (major.minor) version of API.""" + + def __init__(self, id, min_version, version, status='CURRENT'): + self.id = id + self.links = [link.Link.make_link('self', pecan.request.public_url, + self.id, '', bookmark=True)] + self.status = status + self.version = version + self.min_version = min_version class Root(base.APIBase): - name = wtypes.text """The name of the API""" @@ -60,19 +79,20 @@ class Root(base.APIBase): def convert(): root = Root() root.name = "OpenStack Iotronic API" - root.description = ("IoTronic is an Internet of Things resource \ - management service for OpenStack clouds.") - root.versions = [Version.convert('v1')] - root.default_version = Version.convert('v1') + root.description = ("Iotronic is an OpenStack project which aims to " + "provision baremetal machines.") + root.default_version = Version(ID_VERSION1, + versions.MIN_VERSION_STRING, + versions.MAX_VERSION_STRING) + root.versions = [root.default_version] return root class RootController(rest.RestController): - - _versions = ['v1'] + _versions = [ID_VERSION1] """All supported API versions""" - _default_version = 'v1' + _default_version = ID_VERSION1 """The default API version""" v1 = v1.Controller() @@ -86,11 +106,9 @@ class RootController(rest.RestController): @pecan.expose() def _route(self, args): - """Overrides the default routing behavior. - - It redirects the request to the default version of the iotronic API - if the version number is not specified in the url. - """ + # Overrides the default routing behavior. + # It redirects the request to the default version of the ironic API + # if the version number is not specified in the url. if args[0] and args[0] not in self._versions: args = [self._default_version] + args diff --git a/iotronic/api/controllers/v1/__init__.py b/iotronic/api/controllers/v1/__init__.py index 40e7917..3f92102 100644 --- a/iotronic/api/controllers/v1/__init__.py +++ b/iotronic/api/controllers/v1/__init__.py @@ -14,31 +14,38 @@ """ Version 1 of the Iotronic API + +Specification can be found at doc/source/webapi/v1.rst """ -from iotronic.api.controllers import base -from iotronic.api.controllers import link -from iotronic.api.controllers.v1 import node -from iotronic.api.controllers.v1 import plugin -from iotronic.api import expose -from iotronic.common.i18n import _ import pecan from pecan import rest from webob import exc from wsme import types as wtypes +from iotronic.api.controllers import base +from iotronic.api.controllers import link +from iotronic.api.controllers.v1 import plugin +# from iotronic.api.controllers.v1 import driver +# from iotronic.api.controllers.v1 import port +# from iotronic.api.controllers.v1 import portgroup +# from iotronic.api.controllers.v1 import ramdisk +# from iotronic.api.controllers.v1 import utils -BASE_VERSION = 1 +from iotronic.api.controllers.v1 import node -MIN_VER_STR = '1.0' +from iotronic.api.controllers.v1 import versions +from iotronic.api import expose +from iotronic.common.i18n import _ -MAX_VER_STR = '1.0' +BASE_VERSION = versions.BASE_VERSION - -MIN_VER = base.Version({base.Version.string: MIN_VER_STR}, - MIN_VER_STR, MAX_VER_STR) -MAX_VER = base.Version({base.Version.string: MAX_VER_STR}, - MIN_VER_STR, MAX_VER_STR) +MIN_VER = base.Version( + {base.Version.string: versions.MIN_VERSION_STRING}, + versions.MIN_VERSION_STRING, versions.MAX_VERSION_STRING) +MAX_VER = base.Version( + {base.Version.string: versions.MAX_VERSION_STRING}, + versions.MIN_VERSION_STRING, versions.MAX_VERSION_STRING) class V1(base.APIBase): @@ -53,31 +60,11 @@ class V1(base.APIBase): nodes = [link.Link] """Links to the nodes resource""" - plugins = [link.Link] - @staticmethod def convert(): v1 = V1() v1.id = "v1" - - v1.nodes = [link.Link.make_link('self', pecan.request.host_url, - 'nodes', ''), - link.Link.make_link('bookmark', - pecan.request.host_url, - 'nodes', '', - bookmark=True) - ] - - v1.plugins = [link.Link.make_link('self', pecan.request.host_url, - 'plugins', ''), - link.Link.make_link('bookmark', - pecan.request.host_url, - 'plugins', '', - bookmark=True) - ] - - ''' - v1.links = [link.Link.make_link('self', pecan.request.host_url, + v1.links = [link.Link.make_link('self', pecan.request.public_url, 'v1', '', bookmark=True), link.Link.make_link('describedby', 'http://docs.openstack.org', @@ -85,7 +72,23 @@ class V1(base.APIBase): 'api-spec-v1.html', bookmark=True, type='text/html') ] - ''' + + v1.plugins = [link.Link.make_link('self', pecan.request.public_url, + 'plugins', ''), + link.Link.make_link('bookmark', + pecan.request.public_url, + 'plugins', '', + bookmark=True) + ] + + v1.nodes = [link.Link.make_link('self', pecan.request.public_url, + 'nodes', ''), + link.Link.make_link('bookmark', + pecan.request.public_url, + 'nodes', '', + bookmark=True) + ] + return v1 @@ -110,25 +113,30 @@ class Controller(rest.RestController): raise exc.HTTPNotAcceptable(_( "Mutually exclusive versions requested. Version %(ver)s " "requested but not supported by this service. The supported " - "version range is: [%(min)s,%(max)s]." - ) % {'ver': version, 'min': MIN_VER_STR, - 'max': MAX_VER_STR}, + "version range is: [%(min)s, %(max)s].") % + {'ver': version, 'min': versions.MIN_VERSION_STRING, + 'max': versions.MAX_VERSION_STRING}, headers=headers) # ensure the minor version is within the supported range if version < MIN_VER or version > MAX_VER: raise exc.HTTPNotAcceptable(_( "Version %(ver)s was requested but the minor version is not " "supported by this service. The supported version range is: " - "[%(min)s, %(max)s].") % {'ver': version, 'min': MIN_VER_STR, - 'max': MAX_VER_STR}, headers=headers) + "[%(min)s, %(max)s].") % + {'ver': version, 'min': versions.MIN_VERSION_STRING, + 'max': versions.MAX_VERSION_STRING}, + headers=headers) @pecan.expose() def _route(self, args): - v = base.Version(pecan.request.headers, MIN_VER_STR, MAX_VER_STR) + v = base.Version(pecan.request.headers, versions.MIN_VERSION_STRING, + versions.MAX_VERSION_STRING) # Always set the min and max headers - pecan.response.headers[base.Version.min_string] = MIN_VER_STR - pecan.response.headers[base.Version.max_string] = MAX_VER_STR + pecan.response.headers[base.Version.min_string] = ( + versions.MIN_VERSION_STRING) + pecan.response.headers[base.Version.max_string] = ( + versions.MAX_VERSION_STRING) # assert that requested version is supported self._check_version(v, pecan.response.headers) @@ -138,4 +146,4 @@ class Controller(rest.RestController): return super(Controller, self)._route(args) -__all__ = (Controller) +__all__ = ('Controller',) diff --git a/iotronic/api/controllers/v1/collection.py b/iotronic/api/controllers/v1/collection.py index 747af6e..bec95bd 100644 --- a/iotronic/api/controllers/v1/collection.py +++ b/iotronic/api/controllers/v1/collection.py @@ -44,5 +44,5 @@ class Collection(base.APIBase): 'args': q_args, 'limit': limit, 'marker': self.collection[-1].uuid} - return link.Link.make_link('next', pecan.request.host_url, + return link.Link.make_link('next', pecan.request.public_url, resource_url, next_args).href diff --git a/iotronic/api/controllers/v1/node.py b/iotronic/api/controllers/v1/node.py index 6b7421f..ce7ad10 100644 --- a/iotronic/api/controllers/v1/node.py +++ b/iotronic/api/controllers/v1/node.py @@ -12,63 +12,39 @@ from iotronic.api.controllers import base +from iotronic.api.controllers import link from iotronic.api.controllers.v1 import collection from iotronic.api.controllers.v1 import location as loc from iotronic.api.controllers.v1 import types from iotronic.api.controllers.v1 import utils as api_utils from iotronic.api import expose from iotronic.common import exception +from iotronic.common import policy from iotronic import objects import pecan from pecan import rest import wsme from wsme import types as wtypes +_DEFAULT_RETURN_FIELDS = ('name', 'code', 'status', 'uuid', 'session', 'type') + class Node(base.APIBase): """API representation of a node. """ - uuid = types.uuid code = wsme.wsattr(wtypes.text) status = wsme.wsattr(wtypes.text) name = wsme.wsattr(wtypes.text) type = wsme.wsattr(wtypes.text) + owner = types.uuid session = wsme.wsattr(wtypes.text) + project = types.uuid mobile = types.boolean location = wsme.wsattr([loc.Location]) extra = types.jsontype - @staticmethod - def _convert_with_locates(node, url, expand=True, show_password=True): - - try: - session = objects.SessionWP( - {}).get_session_by_node_uuid( - node.uuid, valid=True) - node.session = session.session_id - except Exception: - pass - - if not expand: - except_list = ['name', 'code', 'status', 'uuid', 'session', 'type'] - node.unset_fields_except(except_list) - return node - - list_loc = objects.Location({}).list_by_node_id({}, node.id) - node.location = loc.Location.convert_with_list(list_loc) - - return node - - @classmethod - def convert_with_locates(cls, rpc_node, expand=True): - node = Node(**rpc_node.as_dict()) - node.id = rpc_node.id - return cls._convert_with_locates(node, pecan.request.host_url, - expand, - pecan.request.context.show_password) - def __init__(self, **kwargs): self.fields = [] fields = list(objects.Node.fields) @@ -79,6 +55,46 @@ class Node(base.APIBase): self.fields.append(k) setattr(self, k, kwargs.get(k, wtypes.Unset)) + @staticmethod + def _convert_with_links(node, url, fields=None): + node_uuid = node.uuid + if fields is not None: + node.unset_fields_except(fields) + + node.links = [link.Link.make_link('self', url, 'nodes', + node_uuid), + link.Link.make_link('bookmark', url, 'nodes', + node_uuid, bookmark=True) + ] + return node + + @classmethod + def convert_with_links(cls, rpc_node, fields=None): + node = Node(**rpc_node.as_dict()) + + try: + session = objects.SessionWP.get_session_by_node_uuid( + pecan.request.context, node.uuid) + node.session = session.session_id + except Exception: + node.session = None + + try: + list_loc = objects.Location.list_by_node_uuid( + pecan.request.context, node.uuid) + node.location = loc.Location.convert_with_list(list_loc) + except Exception: + node.location = [] + + # to enable as soon as a better session and location management + # is implemented + # if fields is not None: + # api_utils.check_for_invalid_fields(fields, node_dict) + + return cls._convert_with_links(node, + pecan.request.public_url, + fields=fields) + class NodeCollection(collection.Collection): """API representation of a collection of nodes.""" @@ -90,20 +106,23 @@ class NodeCollection(collection.Collection): self._type = 'nodes' @staticmethod - def convert_with_locates(nodes, limit, url=None, expand=False, **kwargs): + def convert_with_links(nodes, limit, url=None, fields=None, **kwargs): collection = NodeCollection() - collection.nodes = [ - Node.convert_with_locates( - n, expand) for n in nodes] + collection.nodes = [Node.convert_with_links(n, fields=fields) + for n in nodes] collection.next = collection.get_next(limit, url=url, **kwargs) return collection class NodesController(rest.RestController): - invalid_sort_key_list = ['properties'] + """REST controller for Nodes.""" - def _get_nodes_collection(self, marker, limit, sort_key, sort_dir, - expand=False, resource_url=None): + invalid_sort_key_list = ['extra', 'location'] + + def _get_nodes_collection(self, marker, limit, + sort_key, sort_dir, + project=None, + resource_url=None, fields=None): limit = api_utils.validate_limit(limit) sort_dir = api_utils.validate_sort_dir(sort_dir) @@ -115,74 +134,71 @@ class NodesController(rest.RestController): if sort_key in self.invalid_sort_key_list: raise exception.InvalidParameterValue( - _("The sort_key value %(key)s is an invalid field for " - "sorting") % {'key': sort_key}) + ("The sort_key value %(key)s is an invalid field for " + "sorting") % {'key': sort_key}) filters = {} + + # bounding the request to a project + if project and pecan.request.context.is_admin: + filters['project_id'] = project + else: + filters['project_id'] = pecan.request.context.project_id + nodes = objects.Node.list(pecan.request.context, limit, marker_obj, sort_key=sort_key, sort_dir=sort_dir, filters=filters) parameters = {'sort_key': sort_key, 'sort_dir': sort_dir} - return NodeCollection.convert_with_locates(nodes, limit, - url=resource_url, - expand=expand, - **parameters) - @expose.expose(NodeCollection, types.uuid, int, wtypes.text, wtypes.text) - def get_all(self, marker=None, limit=None, sort_key='id', - sort_dir='asc'): + return NodeCollection.convert_with_links(nodes, limit, + url=resource_url, + fields=fields, + **parameters) + + @expose.expose(Node, types.uuid_or_name, types.listtype) + def get_one(self, node_ident, fields=None): + """Retrieve information about the given node. + + :param node_ident: UUID or logical name of a node. + :param fields: Optional, a list with a specified set of fields + of the resource to be returned. + """ + + cdict = pecan.request.context.to_policy_values() + policy.authorize('iot:node:get', cdict, cdict) + + rpc_node = api_utils.get_rpc_node(node_ident) + + return Node.convert_with_links(rpc_node, fields=fields) + + @expose.expose(NodeCollection, types.uuid, int, wtypes.text, + wtypes.text, types.listtype, wtypes.text) + def get_all(self, marker=None, + limit=None, sort_key='id', sort_dir='asc', + fields=None, project=None): """Retrieve a list of nodes. :param marker: pagination marker for large data sets. :param limit: maximum number of resources to return in a single result. + This value cannot be larger than the value of max_limit + in the [api] section of the ironic configuration, or only + max_limit resources will be returned. :param sort_key: column to sort results by. Default: id. :param sort_dir: direction to sort. "asc" or "desc". Default: asc. + :param project: Optional string value to get only nodes of the project. + :param fields: Optional, a list with a specified set of fields + of the resource to be returned. """ + cdict = pecan.request.context.to_policy_values() + policy.authorize('iot:node:get', cdict, cdict) + + if fields is None: + fields = _DEFAULT_RETURN_FIELDS return self._get_nodes_collection(marker, - limit, sort_key, sort_dir) - - @expose.expose(Node, types.uuid_or_name) - def get(self, node_ident): - """Retrieve information about the given node. - - :param node_ident: UUID or logical name of a node. - """ - rpc_node = api_utils.get_rpc_node(node_ident) - node = Node(**rpc_node.as_dict()) - node.id = rpc_node.id - return Node.convert_with_locates(node) - - @expose.expose(None, types.uuid_or_name, status_code=204) - def delete(self, node_ident): - """Delete a node. - - :param node_ident: UUID or logical name of a node. - """ - rpc_node = api_utils.get_rpc_node(node_ident) - pecan.request.rpcapi.destroy_node(pecan.request.context, - rpc_node.uuid) - - @expose.expose(Node, types.uuid_or_name, body=Node, status_code=200) - def patch(self, node_ident, val_Node): - """Update a node. - - :param node_ident: UUID or logical name of a node. - :param Node: values to be changed - :return updated_node: updated_node - """ - - node = api_utils.get_rpc_node(node_ident) - val_Node = val_Node.as_dict() - for key in val_Node: - try: - node[key] = val_Node[key] - except Exception: - pass - - updated_node = pecan.request.rpcapi.update_node(pecan.request.context, - node) - return Node.convert_with_locates(updated_node) + limit, sort_key, sort_dir, + project=project, + fields=fields) @expose.expose(Node, body=Node, status_code=201) def post(self, Node): @@ -190,6 +206,10 @@ class NodesController(rest.RestController): :param Node: a Node within the request body. """ + context = pecan.request.context + cdict = context.to_policy_values() + policy.authorize('iot:node:create', cdict, cdict) + if not Node.name: raise exception.MissingParameterValue( ("Name is not specified.")) @@ -209,10 +229,52 @@ class NodesController(rest.RestController): new_Node = objects.Node(pecan.request.context, **Node.as_dict()) + new_Node.owner = pecan.request.context.user_id + new_Node.project = pecan.request.context.project_id + new_Location = objects.Location(pecan.request.context, **Node.location[0].as_dict()) new_Node = pecan.request.rpcapi.create_node(pecan.request.context, new_Node, new_Location) - return Node.convert_with_locates(new_Node) + return Node.convert_with_links(new_Node) + + @expose.expose(None, types.uuid_or_name, status_code=204) + def delete(self, node_ident): + """Delete a node. + + :param node_ident: UUID or logical name of a node. + """ + context = pecan.request.context + cdict = context.to_policy_values() + policy.authorize('iot:node:delete', cdict, cdict) + + rpc_node = api_utils.get_rpc_node(node_ident) + pecan.request.rpcapi.destroy_node(pecan.request.context, + rpc_node.uuid) + + @expose.expose(Node, types.uuid_or_name, body=Node, status_code=200) + def patch(self, node_ident, val_Node): + """Update a node. + + :param node_ident: UUID or logical name of a node. + :param Node: values to be changed + :return updated_node: updated_node + """ + + context = pecan.request.context + cdict = context.to_policy_values() + policy.authorize('iot:node:update', cdict, cdict) + + node = api_utils.get_rpc_node(node_ident) + val_Node = val_Node.as_dict() + for key in val_Node: + try: + node[key] = val_Node[key] + except Exception: + pass + + updated_node = pecan.request.rpcapi.update_node(pecan.request.context, + node) + return Node.convert_with_links(updated_node) diff --git a/iotronic/api/controllers/v1/plugin.py b/iotronic/api/controllers/v1/plugin.py index 1e77524..8ebef32 100644 --- a/iotronic/api/controllers/v1/plugin.py +++ b/iotronic/api/controllers/v1/plugin.py @@ -12,44 +12,32 @@ from iotronic.api.controllers import base +from iotronic.api.controllers import link from iotronic.api.controllers.v1 import collection from iotronic.api.controllers.v1 import types from iotronic.api.controllers.v1 import utils as api_utils from iotronic.api import expose from iotronic.common import exception +from iotronic.common import policy from iotronic import objects + import pecan from pecan import rest import wsme from wsme import types as wtypes +_DEFAULT_RETURN_FIELDS = ('name', 'uuid') + class Plugin(base.APIBase): """API representation of a plugin. """ - uuid = types.uuid name = wsme.wsattr(wtypes.text) config = wsme.wsattr(wtypes.text) extra = types.jsontype - @staticmethod - def _convert(plugin, url, expand=True, show_password=True): - if not expand: - except_list = ['name', 'code', 'status', 'uuid', 'session', 'type'] - plugin.unset_fields_except(except_list) - return plugin - return plugin - - @classmethod - def convert(cls, rpc_plugin, expand=True): - plugin = Plugin(**rpc_plugin.as_dict()) - # plugin.id = rpc_plugin.id - return cls._convert(plugin, pecan.request.host_url, - expand, - pecan.request.context.show_password) - def __init__(self, **kwargs): self.fields = [] fields = list(objects.Plugin.fields) @@ -60,6 +48,29 @@ class Plugin(base.APIBase): self.fields.append(k) setattr(self, k, kwargs.get(k, wtypes.Unset)) + @staticmethod + def _convert_with_links(plugin, url, fields=None): + plugin_uuid = plugin.uuid + if fields is not None: + plugin.unset_fields_except(fields) + + plugin.links = [link.Link.make_link('self', url, 'plugins', + plugin_uuid), + link.Link.make_link('bookmark', url, 'plugins', + plugin_uuid, bookmark=True) + ] + return plugin + + @classmethod + def convert_with_links(cls, rpc_plugin, fields=None): + plugin = Plugin(**rpc_plugin.as_dict()) + + if fields is not None: + api_utils.check_for_invalid_fields(fields, plugin.as_dict()) + + return cls._convert_with_links(plugin, pecan.request.public_url, + fields=fields) + class PluginCollection(collection.Collection): """API representation of a collection of plugins.""" @@ -71,20 +82,23 @@ class PluginCollection(collection.Collection): self._type = 'plugins' @staticmethod - def convert(plugins, limit, url=None, expand=False, **kwargs): + def convert_with_links(plugins, limit, url=None, fields=None, **kwargs): collection = PluginCollection() - collection.plugins = [ - Plugin.convert( - n, expand) for n in plugins] + collection.plugins = [Plugin.convert_with_links(n, fields=fields) + for n in plugins] collection.next = collection.get_next(limit, url=url, **kwargs) return collection class PluginsController(rest.RestController): - invalid_sort_key_list = [] + """REST controller for Plugins.""" - def _get_plugins_collection(self, marker, limit, sort_key, sort_dir, - expand=False, resource_url=None): + invalid_sort_key_list = ['extra', 'location'] + + def _get_plugins_collection(self, marker, limit, + sort_key, sort_dir, + resource_class=None, + resource_url=None, fields=None): limit = api_utils.validate_limit(limit) sort_dir = api_utils.validate_sort_dir(sort_dir) @@ -100,39 +114,66 @@ class PluginsController(rest.RestController): "sorting") % {'key': sort_key}) filters = {} + + if resource_class is not None: + filters['resource_class'] = resource_class + plugins = objects.Plugin.list(pecan.request.context, limit, marker_obj, sort_key=sort_key, sort_dir=sort_dir, filters=filters) parameters = {'sort_key': sort_key, 'sort_dir': sort_dir} - return PluginCollection.convert(plugins, limit, - url=resource_url, - expand=expand, - **parameters) - @expose.expose(PluginCollection, types.uuid, int, wtypes.text, wtypes.text) - def get_all(self, marker=None, limit=None, sort_key='id', - sort_dir='asc'): + return PluginCollection.convert_with_links(plugins, limit, + url=resource_url, + fields=fields, + **parameters) + + @expose.expose(Plugin, types.uuid_or_name, types.listtype) + def get_one(self, plugin_ident, fields=None): + """Retrieve information about the given plugin. + + :param plugin_ident: UUID or logical name of a plugin. + :param fields: Optional, a list with a specified set of fields + of the resource to be returned. + """ + cdict = pecan.request.context.to_policy_values() + policy.authorize('iot:plugin:get', cdict, cdict) + + rpc_plugin = api_utils.get_rpc_plugin(plugin_ident) + + return Plugin.convert_with_links(rpc_plugin, fields=fields) + + @expose.expose(PluginCollection, types.uuid, int, wtypes.text, + wtypes.text, types.listtype, wtypes.text) + def get_all(self, marker=None, + limit=None, sort_key='id', sort_dir='asc', + fields=None, resource_class=None): """Retrieve a list of plugins. :param marker: pagination marker for large data sets. :param limit: maximum number of resources to return in a single result. + This value cannot be larger than the value of max_limit + in the [api] section of the ironic configuration, or only + max_limit resources will be returned. :param sort_key: column to sort results by. Default: id. :param sort_dir: direction to sort. "asc" or "desc". Default: asc. + :param project: Optional string value to get only plugins + of the project. + :param resource_class: Optional string value to get only plugins with + that resource_class. + :param fields: Optional, a list with a specified set of fields + of the resource to be returned. """ + cdict = pecan.request.context.to_policy_values() + policy.authorize('iot:plugin:get', cdict, cdict) + + if fields is None: + fields = _DEFAULT_RETURN_FIELDS return self._get_plugins_collection(marker, - limit, sort_key, sort_dir) - - @expose.expose(Plugin, types.uuid_or_name) - def get(self, plugin_ident): - """Retrieve information about the given plugin. - - :param plugin_ident: UUID or logical name of a plugin. - """ - rpc_plugin = api_utils.get_rpc_plugin(plugin_ident) - plugin = Plugin(**rpc_plugin.as_dict()) - plugin.id = rpc_plugin.id - return Plugin.convert(plugin) + limit, sort_key, sort_dir, + resource_class=resource_class, + fields=fields) @expose.expose(Plugin, body=Plugin, status_code=201) def post(self, Plugin): @@ -140,6 +181,10 @@ class PluginsController(rest.RestController): :param Plugin: a Plugin within the request body. """ + context = pecan.request.context + cdict = context.to_policy_values() + policy.authorize('iot:plugin:create', cdict, cdict) + if not Plugin.name: raise exception.MissingParameterValue( ("Name is not specified.")) @@ -155,7 +200,8 @@ class PluginsController(rest.RestController): new_Plugin = pecan.request.rpcapi.create_plugin(pecan.request.context, new_Plugin) - return Plugin.convert(new_Plugin) + + return Plugin.convert_with_links(new_Plugin) @expose.expose(None, types.uuid_or_name, status_code=204) def delete(self, plugin_ident): @@ -163,10 +209,39 @@ class PluginsController(rest.RestController): :param plugin_ident: UUID or logical name of a plugin. """ + context = pecan.request.context + cdict = context.to_policy_values() + policy.authorize('iot:plugin:delete', cdict, cdict) + rpc_plugin = api_utils.get_rpc_plugin(plugin_ident) pecan.request.rpcapi.destroy_plugin(pecan.request.context, rpc_plugin.uuid) + @expose.expose(Plugin, types.uuid_or_name, body=Plugin, status_code=200) + def patch(self, plugin_ident, val_Plugin): + """Update a plugin. + + :param plugin_ident: UUID or logical name of a plugin. + :param Plugin: values to be changed + :return updated_plugin: updated_plugin + """ + + context = pecan.request.context + cdict = context.to_policy_values() + policy.authorize('iot:plugin:update', cdict, cdict) + + plugin = api_utils.get_rpc_plugin(plugin_ident) + val_Plugin = val_Plugin.as_dict() + for key in val_Plugin: + try: + plugin[key] = val_Plugin[key] + except Exception: + pass + + updated_plugin = pecan.request.rpcapi.update_plugin( + pecan.request.context, plugin) + return Plugin.convert_with_links(updated_plugin) + @expose.expose(None, types.uuid_or_name, types.uuid_or_name, status_code=200) def put(self, plugin_ident, node_ident): @@ -175,6 +250,11 @@ class PluginsController(rest.RestController): :param plugin_ident: UUID or logical name of a plugin. :param node_ident: UUID or logical name of a node. """ + + context = pecan.request.context + cdict = context.to_policy_values() + policy.authorize('iot:plugin:inject', cdict, cdict) + rpc_plugin = api_utils.get_rpc_plugin(plugin_ident) rpc_node = api_utils.get_rpc_node(node_ident) pecan.request.rpcapi.inject_plugin(pecan.request.context, diff --git a/iotronic/api/controllers/v1/types.py b/iotronic/api/controllers/v1/types.py index f435c61..30f7254 100644 --- a/iotronic/api/controllers/v1/types.py +++ b/iotronic/api/controllers/v1/types.py @@ -15,6 +15,7 @@ # License for the specific language governing permissions and limitations # under the License. +import inspect import json from oslo_utils import strutils @@ -23,6 +24,7 @@ import six import wsme from wsme import types as wtypes +from iotronic.api.controllers.v1 import utils as v1_utils from iotronic.common import exception from iotronic.common.i18n import _ from iotronic.common import utils @@ -33,11 +35,6 @@ class MacAddressType(wtypes.UserType): basetype = wtypes.text name = 'macaddress' - # FIXME(lucasagomes): When used with wsexpose decorator WSME will try - # to get the name of the type by accessing it's __name__ attribute. - # Remove this __name__ attribute once it's fixed in WSME. - # https://bugs.launchpad.net/wsme/+bug/1265590 - __name__ = name @staticmethod def validate(value): @@ -55,16 +52,11 @@ class UuidOrNameType(wtypes.UserType): basetype = wtypes.text name = 'uuid_or_name' - # FIXME(lucasagomes): When used with wsexpose decorator WSME will try - # to get the name of the type by accessing it's __name__ attribute. - # Remove this __name__ attribute once it's fixed in WSME. - # https://bugs.launchpad.net/wsme/+bug/1265590 - __name__ = name @staticmethod def validate(value): if not (uuidutils.is_uuid_like(value) - or utils.is_hostname_safe(value)): + or v1_utils.is_valid_logical_name(value)): raise exception.InvalidUuidOrName(name=value) return value @@ -80,15 +72,10 @@ class NameType(wtypes.UserType): basetype = wtypes.text name = 'name' - # FIXME(lucasagomes): When used with wsexpose decorator WSME will try - # to get the name of the type by accessing it's __name__ attribute. - # Remove this __name__ attribute once it's fixed in WSME. - # https://bugs.launchpad.net/wsme/+bug/1265590 - __name__ = name @staticmethod def validate(value): - if not utils.is_hostname_safe(value): + if not v1_utils.is_valid_logical_name(value): raise exception.InvalidName(name=value) return value @@ -104,11 +91,6 @@ class UuidType(wtypes.UserType): basetype = wtypes.text name = 'uuid' - # FIXME(lucasagomes): When used with wsexpose decorator WSME will try - # to get the name of the type by accessing it's __name__ attribute. - # Remove this __name__ attribute once it's fixed in WSME. - # https://bugs.launchpad.net/wsme/+bug/1265590 - __name__ = name @staticmethod def validate(value): @@ -128,11 +110,6 @@ class BooleanType(wtypes.UserType): basetype = wtypes.text name = 'boolean' - # FIXME(lucasagomes): When used with wsexpose decorator WSME will try - # to get the name of the type by accessing it's __name__ attribute. - # Remove this __name__ attribute once it's fixed in WSME. - # https://bugs.launchpad.net/wsme/+bug/1265590 - __name__ = name @staticmethod def validate(value): @@ -140,7 +117,7 @@ class BooleanType(wtypes.UserType): return strutils.bool_from_string(value, strict=True) except ValueError as e: # raise Invalid to return 400 (BadRequest) in the API - raise exception.Invalid(e) + raise exception.Invalid(six.text_type(e)) @staticmethod def frombasetype(value): @@ -154,11 +131,6 @@ class JsonType(wtypes.UserType): basetype = wtypes.text name = 'json' - # FIXME(lucasagomes): When used with wsexpose decorator WSME will try - # to get the name of the type by accessing it's __name__ attribute. - # Remove this __name__ attribute once it's fixed in WSME. - # https://bugs.launchpad.net/wsme/+bug/1265590 - __name__ = name def __str__(self): # These are the json serializable native types @@ -179,11 +151,37 @@ class JsonType(wtypes.UserType): return JsonType.validate(value) +class ListType(wtypes.UserType): + """A simple list type.""" + + basetype = wtypes.text + name = 'list' + + @staticmethod + def validate(value): + """Validate and convert the input to a ListType. + + :param value: A comma separated string of values + :returns: A list of unique values, whose order is not guaranteed. + """ + items = [v.strip().lower() for v in six.text_type(value).split(',')] + # filter() to remove empty items + # set() to remove duplicated items + return list(set(filter(None, items))) + + @staticmethod + def frombasetype(value): + if value is None: + return None + return ListType.validate(value) + + macaddress = MacAddressType() uuid_or_name = UuidOrNameType() name = NameType() uuid = UuidType() boolean = BooleanType() +listtype = ListType() # Can't call it 'json' because that's the name of the stdlib module jsontype = JsonType() @@ -197,6 +195,17 @@ class JsonPatchType(wtypes.Base): mandatory=True) value = wsme.wsattr(jsontype, default=wtypes.Unset) + # The class of the objects being patched. Override this in subclasses. + # Should probably be a subclass of iotronic.api.controllers.base.APIBase. + _api_base = None + + # Attributes that are not required for construction, but which may not be + # removed if set. Override in subclasses if needed. + _extra_non_removable_attrs = set() + + # Set of non-removable attributes, calculated lazily. + _non_removable_attrs = None + @staticmethod def internal_attrs(): """Returns a list of internal attributes. @@ -207,15 +216,24 @@ class JsonPatchType(wtypes.Base): """ return ['/created_at', '/id', '/links', '/updated_at', '/uuid'] - @staticmethod - def mandatory_attrs(): - """Retruns a list of mandatory attributes. - - Mandatory attributes can't be removed from the document. This - method should be overwritten by derived class. + @classmethod + def non_removable_attrs(cls): + """Returns a set of names of attributes that may not be removed. + Attributes whose 'mandatory' property is True are automatically added + to this set. To add additional attributes to the set, override the + field _extra_non_removable_attrs in subclasses, with a set of the form + {'/foo', '/bar'}. """ - return [] + if cls._non_removable_attrs is None: + cls._non_removable_attrs = cls._extra_non_removable_attrs.copy() + if cls._api_base: + fields = inspect.getmembers(cls._api_base, + lambda a: not inspect.isroutine(a)) + for name, field in fields: + if getattr(field, 'mandatory', False): + cls._non_removable_attrs.add('/%s' % name) + return cls._non_removable_attrs @staticmethod def validate(patch): @@ -224,16 +242,119 @@ class JsonPatchType(wtypes.Base): msg = _("'%s' is an internal attribute and can not be updated") raise wsme.exc.ClientSideError(msg % patch.path) - if patch.path in patch.mandatory_attrs() and patch.op == 'remove': + if patch.path in patch.non_removable_attrs() and patch.op == 'remove': msg = _("'%s' is a mandatory attribute and can not be removed") raise wsme.exc.ClientSideError(msg % patch.path) if patch.op != 'remove': if patch.value is wsme.Unset: - msg = _("'add' and 'replace' operations needs value") + msg = _("'add' and 'replace' operations need a value") raise wsme.exc.ClientSideError(msg) ret = {'path': patch.path, 'op': patch.op} if patch.value is not wsme.Unset: ret['value'] = patch.value return ret + + +class LocalLinkConnectionType(wtypes.UserType): + """A type describing local link connection.""" + + basetype = wtypes.DictType + name = 'locallinkconnection' + + mandatory_fields = {'switch_id', + 'port_id'} + valid_fields = mandatory_fields.union({'switch_info'}) + + @staticmethod + def validate(value): + """Validate and convert the input to a LocalLinkConnectionType. + + :param value: A dictionary of values to validate, switch_id is a MAC + address or an OpenFlow based datapath_id, switch_info is an + optional field. + + For example:: + + { + 'switch_id': mac_or_datapath_id(), + 'port_id': 'Ethernet3/1', + 'switch_info': 'switch1' + } + + :returns: A dictionary. + :raises: Invalid if some of the keys in the dictionary being validated + are unknown, invalid, or some required ones are missing. + """ + wtypes.DictType(wtypes.text, wtypes.text).validate(value) + + keys = set(value) + + # This is to workaround an issue when an API object is initialized from + # RPC object, in which dictionary fields that are set to None become + # empty dictionaries + if not keys: + return value + + invalid = keys - LocalLinkConnectionType.valid_fields + if invalid: + raise exception.Invalid(_('%s are invalid keys') % (invalid)) + + # Check all mandatory fields are present + missing = LocalLinkConnectionType.mandatory_fields - keys + if missing: + msg = _('Missing mandatory keys: %s') % missing + raise exception.Invalid(msg) + + # Check switch_id is either a valid mac address or + # OpenFlow datapath_id and normalize it. + try: + value['switch_id'] = utils.validate_and_normalize_mac( + value['switch_id']) + except exception.InvalidMAC: + try: + value['switch_id'] = utils.validate_and_normalize_datapath_id( + value['switch_id']) + except exception.InvalidDatapathID: + raise exception.InvalidSwitchID(switch_id=value['switch_id']) + + return value + + @staticmethod + def frombasetype(value): + if value is None: + return None + return LocalLinkConnectionType.validate(value) + +locallinkconnectiontype = LocalLinkConnectionType() + + +class VifType(JsonType): + + basetype = wtypes.text + name = 'viftype' + + mandatory_fields = {'id'} + + @staticmethod + def validate(value): + super(VifType, VifType).validate(value) + keys = set(value) + # Check all mandatory fields are present + missing = VifType.mandatory_fields - keys + if missing: + msg = _('Missing mandatory keys: %s') % ', '.join(list(missing)) + raise exception.Invalid(msg) + UuidOrNameType.validate(value['id']) + + return value + + @staticmethod + def frombasetype(value): + if value is None: + return None + return VifType.validate(value) + + +viftype = VifType() diff --git a/iotronic/api/controllers/v1/utils.py b/iotronic/api/controllers/v1/utils.py index a63bb71..4d378ac 100644 --- a/iotronic/api/controllers/v1/utils.py +++ b/iotronic/api/controllers/v1/utils.py @@ -140,3 +140,19 @@ def is_valid_name(name): :returns: True if the name is valid, False otherwise. """ return not uuidutils.is_uuid_like(name) + + +def check_for_invalid_fields(fields, object_fields): + """Check for requested non-existent fields. + + Check if the user requested non-existent fields. + + :param fields: A list of fields requested by the user + :object_fields: A list of fields supported by the object. + :raises: InvalidParameterValue if invalid fields were requested. + + """ + invalid_fields = set(fields) - set(object_fields) + if invalid_fields: + raise exception.InvalidParameterValue( + _('Field(s) "%s" are not valid') % ', '.join(invalid_fields)) diff --git a/iotronic/api/controllers/v1/versions.py b/iotronic/api/controllers/v1/versions.py new file mode 100644 index 0000000..4de266c --- /dev/null +++ b/iotronic/api/controllers/v1/versions.py @@ -0,0 +1,25 @@ +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +# This is the version 1 API +BASE_VERSION = 1 + +MINOR_1_INITIAL_VERSION = 0 +MINOR_MAX_VERSION = 0 + +# String representations of the minor and maximum versions +MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, + MINOR_1_INITIAL_VERSION) +MAX_VERSION_STRING = '{}.{}'.format(BASE_VERSION, + MINOR_MAX_VERSION) diff --git a/iotronic/api/hooks.py b/iotronic/api/hooks.py index 5659721..af8742c 100644 --- a/iotronic/api/hooks.py +++ b/iotronic/api/hooks.py @@ -15,14 +15,18 @@ # under the License. from oslo_config import cfg +from oslo_log import log from pecan import hooks -from webob import exc +from six.moves import http_client from iotronic.common import context from iotronic.common import policy - - from iotronic.conductor import rpcapi +from iotronic.db import api as dbapi + +LOG = log.getLogger(__name__) + +CHECKED_DEPRECATED_POLICY_ARGS = False class ConfigHook(hooks.PecanHook): @@ -36,31 +40,11 @@ class DBHook(hooks.PecanHook): """Attach the dbapi object to the request so controllers can get to it.""" def before(self, state): - - # state.request.dbapi = dbapi.get_instance() - pass + state.request.dbapi = dbapi.get_instance() class ContextHook(hooks.PecanHook): - """Configures a request context and attaches it to the request. - - The following HTTP request headers are used: - - X-User-Id or X-User: - Used for context.user_id. - - X-Tenant-Id or X-Tenant: - Used for context.tenant. - - X-Auth-Token: - Used for context.auth_token. - - X-Roles: - Used for setting context.is_admin flag to either True or False. - The flag is set to True, if X-Roles contains either an administrator - or admin substring. Otherwise it is set to False. - - """ + """Configures a request context and attaches it to the request.""" def __init__(self, public_api_routes): self.public_api_routes = public_api_routes @@ -69,32 +53,34 @@ class ContextHook(hooks.PecanHook): def before(self, state): headers = state.request.headers - # Do not pass any token with context for noauth mode - auth_token = (None if cfg.CONF.auth_strategy == 'noauth' else - headers.get('X-Auth-Token')) - - creds = { - 'user': headers.get('X-User') or headers.get('X-User-Id'), - 'tenant': headers.get('X-Tenant') or headers.get('X-Tenant-Id'), - 'domain_id': headers.get('X-User-Domain-Id'), - 'domain_name': headers.get('X-User-Domain-Name'), - 'auth_token': auth_token, - 'roles': headers.get('X-Roles', '').split(','), - } - - # NOTE(adam_g): We also check the previous 'admin' rule to ensure - # compat with default juno policy.json. This double check may be - # removed in L. - is_admin = (policy.enforce('admin_api', creds, creds) or - policy.enforce('admin', creds, creds)) - is_public_api = state.request.environ.get('is_public_api', False) - show_password = policy.enforce('show_password', creds, creds) - - state.request.context = context.RequestContext( - is_admin=is_admin, + is_public_api = state.request.environ.get( + 'is_public_api', False) + ctx = context.RequestContext.from_environ( + state.request.environ, is_public_api=is_public_api, - show_password=show_password, - **creds) + project_id=headers.get('X-Project-Id'), + user_id=headers.get('X-User-Id'), + ) + + # Do not pass any token with context for noauth mode + if cfg.CONF.auth_strategy == 'noauth': + ctx.auth_token = None + + creds = ctx.to_policy_values() + + is_admin = policy.check('is_admin', creds, creds) + ctx.is_admin = is_admin + + state.request.context = ctx + + def after(self, state): + if state.request.context == {}: + # An incorrect url path will not create RequestContext + return + # NOTE(lintan): RequestContext will generate a request_id if no one + # passing outside, so it always contain a request_id. + request_id = state.request.context.request_id + state.response.headers['Openstack-Request-Id'] = request_id class RPCHook(hooks.PecanHook): @@ -104,23 +90,6 @@ class RPCHook(hooks.PecanHook): state.request.rpcapi = rpcapi.ConductorAPI() -class TrustedCallHook(hooks.PecanHook): - """Verify that the user has admin rights. - - Checks whether the API call is performed against a public - resource or the user has admin privileges in the appropriate - tenant, domain or other administrative unit. - - """ - - def before(self, state): - ctx = state.request.context - if ctx.is_public_api: - return - policy.enforce('admin_api', ctx.to_dict(), ctx.to_dict(), - do_raise=True, exc=exc.HTTPForbidden) - - class NoExceptionTracebackHook(hooks.PecanHook): """Workaround rpc.common: deserialize_remote_exception. @@ -129,24 +98,26 @@ class NoExceptionTracebackHook(hooks.PecanHook): concern so this hook is aimed to cut-off traceback from the error message. """ + # NOTE(max_lobur): 'after' hook used instead of 'on_error' because # 'on_error' never fired for wsme+pecan pair. wsme @wsexpose decorator # catches and handles all the errors, so 'on_error' dedicated for unhandled # exceptions never fired. - def after(self, state): # Omit empty body. Some errors may not have body at this level yet. if not state.response.body: return # Do nothing if there is no error. - if 200 <= state.response.status_int < 400: + # Status codes in the range 200 (OK) to 399 (400 = BAD_REQUEST) are not + # an error. + if (http_client.OK <= state.response.status_int < + http_client.BAD_REQUEST): return json_body = state.response.json - # Do not remove traceback when server in debug mode (except 'Server' - # errors when 'debuginfo' will be used for traces). - if cfg.CONF.debug and json_body.get('faultcode') != 'Server': + # Do not remove traceback when traceback config is set + if cfg.CONF.debug_tracebacks_in_api: return faultstring = json_body.get('faultstring') @@ -156,6 +127,19 @@ class NoExceptionTracebackHook(hooks.PecanHook): faultstring = faultstring.split(traceback_marker, 1)[0] # Remove trailing newlines and spaces if any. json_body['faultstring'] = faultstring.rstrip() - # Replace the whole json. Cannot change original one beacause it's + # Replace the whole json. Cannot change original one because it's # generated on the fly. state.response.json = json_body + + +class PublicUrlHook(hooks.PecanHook): + """Attach the right public_url to the request. + + Attach the right public_url to the request so resources can create + links even when the API service is behind a proxy or SSL terminator. + + """ + + def before(self, state): + state.request.public_url = (cfg.CONF.api.public_endpoint or + state.request.host_url) diff --git a/iotronic/api/middleware/__init__.py b/iotronic/api/middleware/__init__.py index 022a5ab..746a609 100644 --- a/iotronic/api/middleware/__init__.py +++ b/iotronic/api/middleware/__init__.py @@ -19,5 +19,5 @@ from iotronic.api.middleware import parsable_error ParsableErrorMiddleware = parsable_error.ParsableErrorMiddleware AuthTokenMiddleware = auth_token.AuthTokenMiddleware -__all__ = (ParsableErrorMiddleware, - AuthTokenMiddleware) +__all__ = ('ParsableErrorMiddleware', + 'AuthTokenMiddleware') diff --git a/iotronic/api/middleware/auth_token.py b/iotronic/api/middleware/auth_token.py index 9c21ab1..c598d69 100644 --- a/iotronic/api/middleware/auth_token.py +++ b/iotronic/api/middleware/auth_token.py @@ -31,15 +31,16 @@ class AuthTokenMiddleware(auth_token.AuthProtocol): for public routes in the API. """ - - def __init__(self, app, conf, public_api_routes=[]): + def __init__(self, app, conf, public_api_routes=None): + api_routes = [] if public_api_routes is None else public_api_routes + self._iotronic_app = app # TODO(mrda): Remove .xml and ensure that doesn't result in a # 401 Authentication Required instead of 404 Not Found route_pattern_tpl = '%s(\.json|\.xml)?$' try: self.public_api_routes = [re.compile(route_pattern_tpl % route_tpl) - for route_tpl in public_api_routes] + for route_tpl in api_routes] except re.error as e: msg = _('Cannot compile public API routes: %s') % e @@ -58,6 +59,6 @@ class AuthTokenMiddleware(auth_token.AuthProtocol): self.public_api_routes)) if env['is_public_api']: - return self._app(env, start_response) + return self._iotronic_app(env, start_response) return super(AuthTokenMiddleware, self).__call__(env, start_response) diff --git a/iotronic/api/middleware/parsable_error.py b/iotronic/api/middleware/parsable_error.py index e02a164..d4150fc 100644 --- a/iotronic/api/middleware/parsable_error.py +++ b/iotronic/api/middleware/parsable_error.py @@ -69,14 +69,15 @@ class ParsableErrorMiddleware(object): app_iter = self.app(environ, replacement_start_response) if (state['status_code'] // 100) not in (2, 3): req = webob.Request(environ) - if (req.accept.best_match(['application/json', 'application/xml']) - == 'application/xml'): + if (req.accept.best_match( + ['application/json', + 'application/xml']) == 'application/xml'): try: # simple check xml is valid body = [et.ElementTree.tostring( - et.ElementTree.fromstring('' - + '\n'.join(app_iter) - + ''))] + et.ElementTree.fromstring('' + + '\n'.join(app_iter) + + ''))] except et.ElementTree.ParseError as err: LOG.error(_LE('Error parsing HTTP response: %s'), err) body = ['%s' % state['status_code'] diff --git a/iotronic/common/context.py b/iotronic/common/context.py index aaeffb3..a6b9759 100644 --- a/iotronic/common/context.py +++ b/iotronic/common/context.py @@ -16,37 +16,38 @@ from oslo_context import context class RequestContext(context.RequestContext): - """Extends security contexts from the OpenStack common library.""" + """Extends security contexts from the oslo.context library.""" - def __init__(self, auth_token=None, domain_id=None, domain_name=None, - user=None, tenant=None, is_admin=False, is_public_api=False, - read_only=False, show_deleted=False, request_id=None, - roles=None, show_password=True): - """Stores several additional request parameters: + def __init__(self, is_public_api=False, user_id=None, + project_id=None, **kwargs): + """Initialize the RequestContext - :param domain_id: The ID of the domain. - :param domain_name: The name of the domain. :param is_public_api: Specifies whether the request should be processed - without authentication. - :param roles: List of user's roles if any. - :param show_password: Specifies whether passwords should be masked - before sending back to API call. - + without authentication. + :param kwargs: additional arguments passed to oslo.context. """ + super(RequestContext, self).__init__(**kwargs) self.is_public_api = is_public_api - self.domain_id = domain_id - self.domain_name = domain_name - self.roles = roles or [] - self.show_password = show_password + self.project_id = project_id + self.user_id = user_id - super(RequestContext, self).__init__(auth_token=auth_token, - user=user, tenant=tenant, - is_admin=is_admin, - read_only=read_only, - show_deleted=show_deleted, - request_id=request_id) + def to_policy_values(self): + policy_values = super(RequestContext, self).to_policy_values() + + # TODO(vdrok): remove all of these apart from is_public_api and + # project_name after deprecation period + policy_values.update({ + 'user': self.user, + 'domain_id': self.user_domain, + 'domain_name': self.user_domain_name, + 'tenant': self.tenant, + 'project_name': self.project_name, + 'is_public_api': self.is_public_api, + }) + return policy_values def to_dict(self): + # TODO(vdrok): reuse the base class to_dict in Pike return {'auth_token': self.auth_token, 'user': self.user, 'tenant': self.tenant, @@ -54,14 +55,40 @@ class RequestContext(context.RequestContext): 'read_only': self.read_only, 'show_deleted': self.show_deleted, 'request_id': self.request_id, - 'domain_id': self.domain_id, + 'domain_id': self.user_domain, 'roles': self.roles, - 'domain_name': self.domain_name, - 'show_password': self.show_password, - 'is_public_api': self.is_public_api} + 'domain_name': self.user_domain_name, + 'is_public_api': self.is_public_api, + 'user_id': self.user_id, + 'project_id': self.project_id + } @classmethod - def from_dict(cls, values): - values.pop('user', None) - values.pop('tenant', None) - return cls(**values) + def from_dict(cls, values, **kwargs): + kwargs.setdefault('is_public_api', values.get('is_public_api', False)) + if 'domain_id' in values: + kwargs.setdefault('user_domain', values['domain_id']) + return super(RequestContext, RequestContext).from_dict(values, + **kwargs) + + def ensure_thread_contain_context(self): + """Ensure threading contains context + + For async/periodic tasks, the context of local thread is missing. + Set it with request context and this is useful to log the request_id + in log messages. + + """ + if context.get_current(): + return + self.update_store() + + +def get_admin_context(): + """Create an administrator context.""" + + context = RequestContext(auth_token=None, + tenant=None, + is_admin=True, + overwrite=False) + return context diff --git a/iotronic/common/exception.py b/iotronic/common/exception.py index a76af14..acbf4fc 100644 --- a/iotronic/common/exception.py +++ b/iotronic/common/exception.py @@ -405,7 +405,7 @@ class ServiceUnavailable(IotronicException): class Forbidden(IotronicException): - message = _("Requested OpenStack Images API is forbidden") + message = _("Requested Iotronic API is forbidden") class BadRequest(IotronicException): diff --git a/iotronic/common/policy.py b/iotronic/common/policy.py index 754782e..aa6c72e 100644 --- a/iotronic/common/policy.py +++ b/iotronic/common/policy.py @@ -13,26 +13,115 @@ # License for the specific language governing permissions and limitations # under the License. -"""Policy Engine For Iotronic.""" +"""Policy Engine For Ironic.""" + +import sys from oslo_concurrency import lockutils from oslo_config import cfg +from oslo_log import log from oslo_policy import policy +from iotronic.common import exception +from iotronic.common.i18n import _LW + _ENFORCER = None CONF = cfg.CONF +LOG = log.getLogger(__name__) + +default_policies = [ + # Legacy setting, don't remove. Likely to be overridden by operators who + # forget to update their policy.json configuration file. + # This gets rolled into the new "is_admin" rule below. + policy.RuleDefault('admin_api', + 'role:admin or role:administrator', + description='Legacy rule for cloud admin access'), + # is_public_api is set in the environment from AuthTokenMiddleware + policy.RuleDefault('public_api', + 'is_public_api:True', + description='Internal flag for public API routes'), + + policy.RuleDefault('is_admin', + 'rule:admin_api', + description='Full read/write API access'), + policy.RuleDefault('is_admin_iot_project', + 'role:admin_iot_project', + description='Full read/write API access'), + policy.RuleDefault('is_manager_iot_project', + 'role:manager_iot_project', + description='Full read/write API access'), + policy.RuleDefault('is_user_iot', + 'role:user_iot', + description='Full read/write API access'), +] + +# NOTE(deva): to follow policy-in-code spec, we define defaults for +# the granular policies in code, rather than in policy.json. +# All of these may be overridden by configuration, but we can +# depend on their existence throughout the code. + +node_policies = [ + policy.RuleDefault('iot:node:get', + 'rule:is_admin or rule:is_admin_iot_project ' + 'or rule:is_manager_iot_project or rule:is_user_iot', + description='Retrieve Node records'), + policy.RuleDefault('iot:node:create', + 'rule:is_admin_iot_project', + description='Create Node records'), + policy.RuleDefault('iot:node:delete', + 'rule:is_admin or rule:is_admin_iot_project ' + 'or rule:is_manager_iot_project', + description='Delete Node records'), + policy.RuleDefault('iot:node:update', + 'rule:is_admin or rule:is_admin_iot_project ' + 'or rule:is_manager_iot_project', + description='Update Node records'), + +] + +plugin_policies = [ + policy.RuleDefault('iot:plugin:get', + 'rule:is_admin or rule:is_admin_iot_project ' + 'or rule:is_manager_iot_project or rule:is_user_iot', + description='Retrieve Plugin records'), + policy.RuleDefault('iot:plugin:create', + 'rule:is_admin_iot_project', + description='Create Plugin records'), + policy.RuleDefault('iot:plugin:delete', + 'rule:is_admin or rule:is_admin_iot_project ' + 'or rule:is_manager_iot_project', + description='Delete Plugin records'), + policy.RuleDefault('iot:plugin:update', + 'rule:is_admin or rule:is_admin_iot_project ' + 'or rule:is_manager_iot_project', + description='Update Plugin records'), + policy.RuleDefault('iot:plugin:inject', + 'rule:is_admin or rule:is_admin_iot_project ' + 'or rule:is_manager_iot_project', + description='Inject Plugin records'), + +] -@lockutils.synchronized('policy_enforcer', 'iotronic-') +def list_policies(): + policies = (default_policies + + node_policies + + plugin_policies + ) + return policies + + +@lockutils.synchronized('policy_enforcer') def init_enforcer(policy_file=None, rules=None, default_rule=None, use_conf=True): """Synchronously initializes the policy enforcer :param policy_file: Custom policy file to use, if none is specified, - `CONF.policy_file` will be used. + `CONF.oslo_policy.policy_file` will be used. :param rules: Default dictionary / Rules to use. It will be considered just in the first instantiation. - :param default_rule: Default rule to use, CONF.default_rule will + :param default_rule: Default rule to use, + CONF.oslo_policy.policy_default_rule will be used if none is specified. :param use_conf: Whether to load rules from config file. @@ -42,11 +131,17 @@ def init_enforcer(policy_file=None, rules=None, if _ENFORCER: return + # NOTE(deva): Register defaults for policy-in-code here so that they are + # loaded exactly once - when this module-global is initialized. + # Defining these in the relevant API modules won't work + # because API classes lack singletons and don't use globals. _ENFORCER = policy.Enforcer(CONF, policy_file=policy_file, rules=rules, default_rule=default_rule, use_conf=use_conf) + _ENFORCER.register_defaults(list_policies()) + def get_enforcer(): """Provides access to the single instance of Policy enforcer.""" @@ -57,12 +152,79 @@ def get_enforcer(): return _ENFORCER +def get_oslo_policy_enforcer(): + # This method is for use by oslopolicy CLI scripts. Those scripts need the + # 'output-file' and 'namespace' options, but having those in sys.argv means + # loading the Ironic config options will fail as those are not expected to + # be present. So we pass in an arg list with those stripped out. + + conf_args = [] + # Start at 1 because cfg.CONF expects the equivalent of sys.argv[1:] + i = 1 + while i < len(sys.argv): + if sys.argv[i].strip('-') in ['namespace', 'output-file']: + i += 2 + continue + conf_args.append(sys.argv[i]) + i += 1 + + cfg.CONF(conf_args, project='ironic') + + return get_enforcer() + + +# NOTE(deva): We can't call these methods from within decorators because the +# 'target' and 'creds' parameter must be fetched from the call time +# context-local pecan.request magic variable, but decorators are compiled +# at module-load time. + + +def authorize(rule, target, creds, *args, **kwargs): + """A shortcut for policy.Enforcer.authorize() + + Checks authorization of a rule against the target and credentials, and + raises an exception if the rule is not defined. + Always returns true if CONF.auth_strategy == noauth. + + Beginning with the Newton cycle, this should be used in place of 'enforce'. + """ + if CONF.auth_strategy == 'noauth': + return True + enforcer = get_enforcer() + + try: + return enforcer.authorize(rule, target, creds, do_raise=True, + *args, **kwargs) + except policy.PolicyNotAuthorized: + raise exception.HTTPForbidden(resource=rule) + + +def check(rule, target, creds, *args, **kwargs): + """A shortcut for policy.Enforcer.enforce() + + Checks authorization of a rule against the target and credentials + and returns True or False. + """ + enforcer = get_enforcer() + return enforcer.enforce(rule, target, creds, *args, **kwargs) + + def enforce(rule, target, creds, do_raise=False, exc=None, *args, **kwargs): """A shortcut for policy.Enforcer.enforce() Checks authorization of a rule against the target and credentials. + Always returns true if CONF.auth_strategy == noauth. """ + # NOTE(deva): this method is obsoleted by authorize(), but retained for + # backwards compatibility in case it has been used downstream. + # It may be removed in the Pike cycle. + LOG.warning(_LW( + "Deprecation warning: calls to ironic.common.policy.enforce() " + "should be replaced with authorize(). This method may be removed " + "in a future release.")) + if CONF.auth_strategy == 'noauth': + return True enforcer = get_enforcer() return enforcer.enforce(rule, target, creds, do_raise=do_raise, exc=exc, *args, **kwargs) diff --git a/iotronic/db/api.py b/iotronic/db/api.py index 07859e1..9422e7a 100644 --- a/iotronic/db/api.py +++ b/iotronic/db/api.py @@ -129,6 +129,14 @@ class Connection(object): :returns: A node. """ + @abc.abstractmethod + def get_node_id_by_uuid(self, node_uuid): + """Return a node id. + + :param node_uuid: The uuid of a node. + # :returns: A node.id. + """ + @abc.abstractmethod def get_node_by_name(self, node_name): """Return a node. @@ -205,8 +213,7 @@ class Connection(object): """ @abc.abstractmethod - def get_session_by_node_uuid(self, filters=None, limit=None, marker=None, - sort_key=None, sort_dir=None): + def get_session_by_node_uuid(self, node_uuid, valid): """Return a Wamp session of a Node :param filters: Filters to apply. Defaults to None. diff --git a/iotronic/db/sqlalchemy/api.py b/iotronic/db/sqlalchemy/api.py index 95b78af..098ae4b 100644 --- a/iotronic/db/sqlalchemy/api.py +++ b/iotronic/db/sqlalchemy/api.py @@ -125,12 +125,9 @@ class Connection(api.Connection): def _add_nodes_filters(self, query, filters): if filters is None: filters = [] - - if 'associated' in filters: - if filters['associated']: - query = query.filter(models.Node.instance_uuid is not None) - else: - query = query.filter(models.Node.instance_uuid is None) + # + if 'project_id' in filters: + query = query.filter(models.Node.project == filters['project_id']) return query @@ -224,6 +221,13 @@ class Connection(api.Connection): except NoResultFound: raise exception.NodeNotFound(node=node_id) + def get_node_id_by_uuid(self, node_uuid): + query = model_query(models.Node.id).filter_by(uuid=node_uuid) + try: + return query.one() + except NoResultFound: + raise exception.NodeNotFound(node=node_uuid) + def get_node_by_uuid(self, node_uuid): query = model_query(models.Node).filter_by(uuid=node_uuid) try: @@ -402,6 +406,7 @@ class Connection(api.Connection): models.SessionWP).filter_by( node_uuid=node_uuid).filter_by( valid=valid) + try: return query.one() except NoResultFound: diff --git a/iotronic/db/sqlalchemy/models.py b/iotronic/db/sqlalchemy/models.py index 6f78d49..16ebf85 100644 --- a/iotronic/db/sqlalchemy/models.py +++ b/iotronic/db/sqlalchemy/models.py @@ -154,7 +154,8 @@ class Node(Base): name = Column(String(255), nullable=True) type = Column(String(255)) agent = Column(String(255), nullable=True) - session = Column(String(255), nullable=True) + owner = Column(String(36)) + project = Column(String(36)) mobile = Column(Boolean, default=False) config = Column(JSONEncodedDict) extra = Column(JSONEncodedDict) diff --git a/iotronic/objects/location.py b/iotronic/objects/location.py index c3d8729..f8f6f2a 100644 --- a/iotronic/objects/location.py +++ b/iotronic/objects/location.py @@ -108,6 +108,27 @@ class Location(base.IotronicObject): sort_dir=sort_dir) return Location._from_db_object_list(db_locations, cls, context) + @base.remotable_classmethod + def list_by_node_uuid(cls, context, node_uuid, limit=None, marker=None, + sort_key=None, sort_dir=None): + """Return a list of Location objects associated with a given node ID. + + :param context: Security context. + :param node_id: the ID of the node. + :param limit: maximum number of resources to return in a single result. + :param marker: pagination marker for large data sets. + :param sort_key: column to sort results by. + :param sort_dir: direction to sort. "asc" or "desc". + :returns: a list of :class:`Location` object. + + """ + node_id = cls.dbapi.get_node_id_by_uuid(node_uuid)[0] + db_locations = cls.dbapi.get_locations_by_node_id(node_id, limit=limit, + marker=marker, + sort_key=sort_key, + sort_dir=sort_dir) + return Location._from_db_object_list(db_locations, cls, context) + @base.remotable_classmethod def list_by_node_id(cls, context, node_id, limit=None, marker=None, sort_key=None, sort_dir=None): @@ -194,6 +215,7 @@ class Location(base.IotronicObject): """ current = self.__class__.get_by_uuid(self._context, uuid=self.uuid) for field in self.fields: - if (hasattr(self, base.get_attrname(field)) and - self[field] != current[field]): + if (hasattr( + self, base.get_attrname(field)) + and self[field] != current[field]): self[field] = current[field] diff --git a/iotronic/objects/node.py b/iotronic/objects/node.py index a19ee8d..9bdd0d5 100644 --- a/iotronic/objects/node.py +++ b/iotronic/objects/node.py @@ -36,7 +36,8 @@ class Node(base.IotronicObject): 'name': obj_utils.str_or_none, 'type': obj_utils.str_or_none, 'agent': obj_utils.str_or_none, - 'session': obj_utils.str_or_none, + 'owner': obj_utils.str_or_none, + 'project': obj_utils.str_or_none, 'mobile': bool, 'config': obj_utils.dict_or_none, 'extra': obj_utils.dict_or_none, diff --git a/iotronic/objects/sessionwp.py b/iotronic/objects/sessionwp.py index 7a702b8..1ca9283 100644 --- a/iotronic/objects/sessionwp.py +++ b/iotronic/objects/sessionwp.py @@ -89,7 +89,7 @@ class SessionWP(base.IotronicObject): return session @base.remotable_classmethod - def get_session_by_node_uuid(cls, node_uuid, valid=True, context=None): + def get_session_by_node_uuid(cls, context, node_uuid, valid=True): """Find a session based on uuid and return a :class:`SessionWP` object. :param node_uuid: the uuid of a node. @@ -209,6 +209,6 @@ class SessionWP(base.IotronicObject): current = self.__class__.get_by_uuid(self._context, uuid=self.uuid) for field in self.fields: if (hasattr( - self, base.get_attrname(field)) and - self[field] != current[field]): + self, base.get_attrname(field)) + and self[field] != current[field]): self[field] = current[field] diff --git a/utils/iotronic.sql b/utils/iotronic.sql index e86dfca..1d2003c 100644 --- a/utils/iotronic.sql +++ b/utils/iotronic.sql @@ -70,7 +70,8 @@ CREATE TABLE IF NOT EXISTS `iotronic`.`nodes` ( `name` VARCHAR(255) NULL DEFAULT NULL, `type` VARCHAR(255) NOT NULL, `agent` VARCHAR(255) NULL DEFAULT NULL, - `session` VARCHAR(255) NULL DEFAULT NULL, + `owner` VARCHAR(36) NOT NULL, + `project` VARCHAR(36) NOT NULL, `mobile` TINYINT(1) NOT NULL DEFAULT '0', `config` TEXT NULL DEFAULT NULL, `extra` TEXT NULL DEFAULT NULL, @@ -189,5 +190,11 @@ SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS; -- insert testing nodes -INSERT INTO `nodes` VALUES ('2017-02-20 10:38:26',NULL,132,'f3961f7a-c937-4359-8848-fb64aa8eeaaa','12345','registered','node','server',NULL,NULL,0,'{}','{}'),('2017-02-20 10:38:45',NULL,133,'ba1efce9-cad9-4ae1-a5d1-d90a8d203d3b','yunyun','registered','yun22','yun',NULL,NULL,0,'{}','{}'),('2017-02-20 10:39:08',NULL,134,'65f9db36-9786-4803-b66f-51dcdb60066e','test','registered','test','server',NULL,NULL,0,'{}','{}'); -INSERT INTO `locations` VALUES ('2017-02-20 10:38:26',NULL,6,'2','1','3',132),('2017-02-20 10:38:45',NULL,7,'2','1','3',133),('2017-02-20 10:39:08',NULL,8,'2','1','3',134) \ No newline at end of file +INSERT INTO `nodes` VALUES + ('2017-02-20 10:38:26',NULL,132,'f3961f7a-c937-4359-8848-fb64aa8eeaaa','12345','registered','node','server',NULL,'eee383360cc14c44b9bf21e1e003a4f3','4adfe95d49ad41398e00ecda80257d21',0,'{}','{}'), + ('2017-02-20 10:38:45',NULL,133,'ba1efce9-cad9-4ae1-a5d1-d90a8d203d3b','yunyun','registered','yun22','yun',NULL,'eee383360cc14c44b9bf21e1e003a4f3','4adfe95d49ad41398e00ecda80257d21',0,'{}','{}'), + ('2017-02-20 10:39:08',NULL,134,'65f9db36-9786-4803-b66f-51dcdb60066e','test','registered','test','server',NULL,'eee383360cc14c44b9bf21e1e003a4f3','4adfe95d49ad41398e00ecda80257d21',0,'{}','{}'); +INSERT INTO `locations` VALUES + ('2017-02-20 10:38:26',NULL,6,'2','1','3',132), + ('2017-02-20 10:38:45',NULL,7,'2','1','3',133), + ('2017-02-20 10:39:08',NULL,8,'2','1','3',134) \ No newline at end of file diff --git a/utils/iotronic_curl_client b/utils/iotronic_curl_client index ca2a00a..e390864 100755 --- a/utils/iotronic_curl_client +++ b/utils/iotronic_curl_client @@ -1,60 +1,195 @@ -#!/bin/bash +#! /usr/bin/python -HOST='localhost' -PORT='1288' -VERSION='v1' -BASE=http://$HOST:$PORT/$VERSION -function node_manager() { - case "$1" in - list) curl -sS $BASE/nodes/ | python -m json.tool - echo ""; - ;; - create) curl -sS -H "Content-Type: application/json" -X POST $BASE/nodes/ \ - -d '{"type":"'"$7"'","code":"'"$2"'","name":"'"$3"'","location":[{"latitude":"'"$4"'","longitude":"'"$5"'","altitude":"'"$6"'"}]}' | python -m json.tool - echo ""; - ;; - delete) curl -sS -X DELETE $BASE/nodes/$2 | python -m json.tool - echo ""; - ;; - show) curl -sS $BASE/nodes/$2 | python -m json.tool - echo ""; - ;; - *) echo "node list|create|delete|show" - esac -} +import sys +import requests +import json +import os -function plugin_manager() { - case "$1" in - list) curl -sS $BASE/plugins/ | python -m json.tool - echo ""; - ;; - create) echo "TBI" - echo ""; - ;; - delete) echo "TBI" - echo ""; - ;; - show) curl -sS $BASE/plugins/$2 | python -m json.tool - echo ""; - ;; - *) echo "plugin list|create|delete|show" - esac -} +token = None +iotronic_url = "http://192.168.17.102:1288/v1" -if [ $# -lt 1 ] -then -echo "USAGE: iotronic node|plugin [OPTIONS]" -exit -fi +try: + os.environ['OS_AUTH_URL'] +except Exception: + print("load the rc") + sys.exit(1) -case "$1" in -node) node_manager "${@:2}"; -echo ""; -;; -plugin) plugin_manager "${@:2}" -echo ""; -;; -*) echo "USAGE: iotronic node|plugin [OPTIONS]" -esac +url = os.environ['OS_AUTH_URL'] + "/auth/tokens" + +def get_token(user, project, psw): + payload = {"auth": { + "identity": { + "methods": ["password"], + "password": { + "user": { + "name": user, + "domain": {"id": "default"}, + "password": psw + } + } + }, + "scope": { + "project": { + "name": project, + "domain": {"id": "default"} + } + } + } + } + + headers = { + 'content-type': "application/json", + } + + response = requests.request("POST", url, data=json.dumps(payload), headers=headers) + token=response.headers.get('X-Subject-Token') + if token: + r=json.loads(response.text)['token']['roles'] + roles=[str(x['name']) for x in r] + + print(user + " in " + project + ' with roles: '+ " ".join(roles) ) + + return token + + +def node_manager(argv): + actions = ['show', 'list', 'create', 'delete', 'update'] + if argv[0] not in actions or len(argv) == 0: + print("node list|create|delete|show|update") + sys.exit() + + global iotronic_url, token + if not token: + token = get_token(os.environ['OS_USERNAME'], os.environ['OS_PROJECT_NAME'], os.environ['OS_PASSWORD']) + + # print(token) + + headers = {'content-type': "application/json", 'x-auth-token': token, } + + if argv[0] == 'list': + url = iotronic_url + "/nodes" + response = requests.request("GET", url, headers=headers) + + elif argv[0] == 'create': + code = argv[1] + name = argv[2] + lat = argv[3] + lon = argv[4] + alt = argv[5] + typ = argv[6] + + url = iotronic_url + "/nodes" + payload = { + "code": code, + "mobile": False, + "location": [ + { + "latitude": lat, + "altitude": alt, + "longitude": lon + } + ], + "type": typ, + "name": name + } + + response = requests.request("POST", url, data=json.dumps(payload), headers=headers) + + elif argv[0] == 'delete': + node = argv[1] + url = iotronic_url + "/nodes/" + node + response = requests.request("DELETE", url, headers=headers) + + elif argv[0] == 'show': + node = argv[1] + url = iotronic_url + "/nodes/" + node + response = requests.request("GET", url, headers=headers) + + elif argv[0] == 'update': + node = argv[1] + values = {} + for opt in argv[2:]: + key, val = opt.split(':') + values[key] = val + url = iotronic_url + "/nodes/" + node + payload = values + response = requests.request("PATCH", url, data=json.dumps(payload), headers=headers) + + else: + print ("node list|create|delete|show|update") + sys.exit() + + try: + print(json.dumps(response.json(), indent=2)) + except Exception: + pass + + +def plugin_manager(argv): + actions = ['show', 'list', 'create', 'delete', 'update', 'inject'] + if argv[0] not in actions or len(argv) == 0: + print("plugin list|create|delete|show|update|inject") + sys.exit() + + global iotronic_url, token + if not token: + token = get_token(os.environ['OS_USERNAME'], os.environ['OS_PROJECT_NAME'], os.environ['OS_PASSWORD']) + + headers = {'content-type': "application/json", 'x-auth-token': token, } + + if argv[0] == 'list': + url = iotronic_url + "/plugins" + response = requests.request("GET", url, headers=headers) + + elif argv[0] == 'create': + f=argv[1] + with open(f, 'r') as fil: + t = fil.read() + url = iotronic_url + "/plugins" + payload={"name": f, "config": t} + response = requests.request("POST", url, data=json.dumps(payload), headers=headers) + + elif argv[0] == 'delete': + plugin = argv[1] + url = iotronic_url + "/plugins/" + plugin + response = requests.request("DELETE", url, headers=headers) + + elif argv[0] == 'show': + plugin = argv[1] + url = iotronic_url + "/plugins/" + plugin + response = requests.request("GET", url, headers=headers) + + elif argv[0] == 'update': + plugin = argv[1] + values = {} + for opt in argv[2:]: + key, val = opt.split(':') + values[key] = val + url = iotronic_url + "/plugins/" + plugin + payload = values + response = requests.request("PATCH", url, data=json.dumps(payload), headers=headers) + + else: + print ("node list|create|delete|show|update") + sys.exit() + + try: + print(json.dumps(response.json(), indent=2)) + except Exception: + pass + + +if __name__ == "__main__": + argv = sys.argv + if len(argv) <= 2: + print("USAGE: iotronic node|plugin [OPTIONS]") + sys.exit() + + if argv[1] == 'node': + node_manager(argv[2:]) + elif argv[1] == 'plugin': + plugin_manager(argv[2:]) + else: + print("USAGE: iotronic node|plugin [OPTIONS]") diff --git a/utils/zero b/utils/zero new file mode 100644 index 0000000..85bfa59 --- /dev/null +++ b/utils/zero @@ -0,0 +1,14 @@ +from iotronic_lightningrod.plugins import Plugin +from oslo_log import log as logging + +LOG = logging.getLogger(__name__) + +# User imports + +class Worker(Plugin.Plugin): + def __init__(self, name, is_running): + super(Worker, self).__init__(name, is_running) + + def run(self): + LOG.info("Plugin process completed!") + #self.Done() \ No newline at end of file diff --git a/var/www/cgi-bin/iotronic/app.wsgi b/var/www/cgi-bin/iotronic/app.wsgi index fc3a0f0..9f3ba50 100644 --- a/var/www/cgi-bin/iotronic/app.wsgi +++ b/var/www/cgi-bin/iotronic/app.wsgi @@ -13,11 +13,24 @@ # License for the specific language governing permissions and limitations # under the License. +import sys + +from oslo_config import cfg +import oslo_i18n as i18n +from oslo_log import log + from iotronic.api import app from iotronic.common import service -import oslo_i18n -oslo_i18n.install('iotronic') -service.prepare_service([]) + +CONF = cfg.CONF + +i18n.install('iotronic') + +service.prepare_service(sys.argv) + +LOG = log.getLogger(__name__) +LOG.debug("Configuration:") +CONF.log_opt_values(LOG, log.DEBUG) application = app.VersionSelectorApplication()