This commit is contained in:
root 2015-10-12 16:10:50 +02:00
parent 8a8af3ca84
commit 68cbf4cee0
126 changed files with 24042 additions and 0 deletions

49
bin/iotronic-conductor Executable file
View File

@ -0,0 +1,49 @@
#!/usr/bin/env python
# Copyright 2011 OpenStack LLC.
# 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.
"""
Iotronic Conductor
"""
import sys
from oslo_config import cfg
from iotronic.common import service as iotronic_service
from iotronic.openstack.common import service
CONF = cfg.CONF
if __name__ == '__main__':
iotronic_service.prepare_service(sys.argv)
mgr = iotronic_service.RPCService(CONF.host,
'iotronic.conductor.manager',
'ConductorManager')
launcher = service.launch(mgr)
launcher.wait()
'''
try:
Conductor()
pass
except RuntimeError, e:
sys.exit("ERROR: %s" % e)
'''

4
build.sh Executable file
View File

@ -0,0 +1,4 @@
python setup.py build; python setup.py install; systemctl restart httpd;
rm -rf build
rm -rf iotronic.egg-info
rm -rf dist

38
etc/apache2/iotronic.conf Normal file
View File

@ -0,0 +1,38 @@
# 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 an example Apache2 configuration file for using the
# Ironic API through mod_wsgi. This version assumes you are
# running devstack to configure the software.
Listen 1288
<VirtualHost *:1288>
WSGIDaemonProcess iotronic
#user=root group=root threads=10 display-name=%{GROUP}
WSGIScriptAlias / /etc/iotronic/app.wsgi
#SetEnv APACHE_RUN_USER stack
#SetEnv APACHE_RUN_GROUP stack
WSGIProcessGroup iotronic
ErrorLog /var/log/httpd/iotronic_error.log
LogLevel debug
CustomLog /var/log/httpd/iotronic_access.log combined
<Directory /etc/iotronic>
WSGIProcessGroup iotronic
WSGIApplicationGroup %{GLOBAL}
AllowOverride All
Require all granted
</Directory>
</VirtualHost>

29
etc/iotronic/app.wsgi Normal file
View File

@ -0,0 +1,29 @@
# -*- mode: python -*-
# -*- encoding: utf-8 -*-
#
# 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 iotronic.api import app
from iotronic.common import service
from oslo import i18n
#from oslo_config import cfg
#cfg.CONF(project='iotronic')
i18n.install('iotronic')
service.prepare_service([])
application = app.VersionSelectorApplication()
'''

View File

@ -0,0 +1,25 @@
[DEFAULT]
transport_url=rabbit://root:0penstack@iotctrl:5672/
debug=True
verbose=False
#
# Options defined in ironic.api.app
#
# Authentication strategy used by ironic-api: one of
# "keystone" or "noauth". "noauth" should not be used in a
# production environment because all authentication will be
# disabled. (string value)
auth_strategy=noauth
# Enable pecan debug mode. WARNING: this is insecure and
# should not be used in a production environment. (boolean
# value)
#pecan_debug=false
[database]
connection = mysql://iotronic:0penstack@localhost/iotronic

File diff suppressed because it is too large Load Diff

5
etc/iotronic/policy.json Normal file
View File

@ -0,0 +1,5 @@
{
"admin_api": "role:admin or role:administrator",
"show_password": "!",
"default": "rule:admin_api"
}

17
infopackages Normal file
View File

@ -0,0 +1,17 @@
yum install mariadb mariadb-server MySQL-python
yum install rabbitmq-server
yum install httpd mod_wsgi memcached python-memcached
yum install gcc python-devel pip
pip install eventlet
yum install python-oslo-config
pip install pecan
pip install keystonemiddleware
yum install python-oslo-log
yum install python-oslo-concurrency
pip install paramiko
yum install python-oslo-policy
yum install python-wsme
yum install python-oslo-policy
yum install python-oslo-messaging
yum install python-oslo-db
pip install jsonpatch

22
iotronic/__init__.py Normal file
View File

@ -0,0 +1,22 @@
# Copyright (c) 2014 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.
import os
os.environ['EVENTLET_NO_GREENDNS'] = 'yes'
import eventlet
eventlet.monkey_patch(os=False)

38
iotronic/api/__init__.py Normal file
View File

@ -0,0 +1,38 @@
# 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)

34
iotronic/api/acl.py Normal file
View File

@ -0,0 +1,34 @@
# -*- 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)

88
iotronic/api/app.py Normal file
View File

@ -0,0 +1,88 @@
# -*- encoding: utf-8 -*-
# Copyright © 2012 New Dream Network, LLC (DreamHost)
# 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
import pecan
from iotronic.api import acl
from iotronic.api import config
from iotronic.api import hooks
from iotronic.api import middleware
api_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.'),
cfg.BoolOpt('pecan_debug',
default=False,
help=('Enable pecan debug mode. WARNING: this is insecure '
'and should not be used in a production environment.')),
]
CONF = cfg.CONF
CONF.register_opts(api_opts)
def get_pecan_config():
# Set up the pecan configuration
filename = config.__file__.replace('.pyc', '.py')
return pecan.configuration.conf_from_file(filename)
def setup_app(pecan_config=None, extra_hooks=None):
app_hooks = [hooks.ConfigHook(),
hooks.DBHook(),
hooks.ContextHook(pecan_config.app.acl_public_routes),
hooks.RPCHook(),
hooks.NoExceptionTracebackHook()]
if extra_hooks:
app_hooks.extend(extra_hooks)
if not pecan_config:
pecan_config = get_pecan_config()
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),
hooks=app_hooks,
wrap_app=middleware.ParsableErrorMiddleware,
)
if pecan_config.app.enable_acl:
return acl.install(app, cfg.CONF, pecan_config.app.acl_public_routes)
return app
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)
def __call__(self, environ, start_response):
return self.v1(environ, start_response)

43
iotronic/api/config.py Normal file
View File

@ -0,0 +1,43 @@
# 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.
# Server Specific Configurations
# See https://pecan.readthedocs.org/en/latest/configuration.html#server-configuration # noqa
server = {
'port': '1288',
'host': '0.0.0.0'
}
# Pecan Application Configurations
# See https://pecan.readthedocs.org/en/latest/configuration.html#application-configuration # noqa
app = {
'root': 'iotronic.api.controllers.root.RootController',
'modules': ['iotronic.api'],
'static_root': '%(confdir)s/public',
'debug': True,
'enable_acl': True,
'acl_public_routes': [
'/',
'/v1',
#'/v1/drivers/[a-z_]*/vendor_passthru/lookup',
'/v1/nodes/[a-z0-9\-]+/vendor_passthru/heartbeat',
'/v1/boards/[a-z0-9\-]',
],
}
# WSME Configurations
# See https://wsme.readthedocs.org/en/latest/integrate.html#configuration
wsme = {
'debug': False,
}

View File

View File

@ -0,0 +1,114 @@
# 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.
import datetime
from webob import exc
import wsme
from wsme import types as wtypes
from iotronic.common.i18n import _
class APIBase(wtypes.Base):
created_at = wsme.wsattr(datetime.datetime, readonly=True)
"""The time in UTC at which the object is created"""
updated_at = wsme.wsattr(datetime.datetime, readonly=True)
"""The time in UTC at which the object is updated"""
def as_dict(self):
"""Render this object as a dict of its fields."""
return dict((k, getattr(self, k))
for k in self.fields
if hasattr(self, k) and
getattr(self, k) != wsme.Unset)
def unset_fields_except(self, except_list=None):
"""Unset fields so they don't appear in the message body.
:param except_list: A list of fields that won't be touched.
"""
if except_list is None:
except_list = []
for k in self.as_dict():
if k not in except_list:
setattr(self, k, wsme.Unset)
class Version(object):
"""API Version object."""
string = 'X-OpenStack-Iotronic-API-Version'
"""HTTP Header string carrying the requested version"""
min_string = 'X-OpenStack-Iotronic-API-Minimum-Version'
"""HTTP response header"""
max_string = 'X-OpenStack-Iotronic-API-Maximum-Version'
"""HTTP response header"""
def __init__(self, headers, default_version, latest_version):
"""Create an API Version object from the supplied headers.
: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
:raises: webob.HTTPNotAcceptable
"""
(self.major, self.minor) = Version.parse_headers(headers,
default_version, latest_version)
def __repr__(self):
return '%s.%s' % (self.major, self.minor)
@staticmethod
def parse_headers(headers, default_version, latest_version):
"""Determine the API version requested based on the headers supplied.
: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
:raises: webob.HTTPNotAcceptable
"""
version_str = headers.get(Version.string, default_version)
if version_str.lower() == 'latest':
parse_str = latest_version
else:
parse_str = version_str
try:
version = tuple(int(i) for i in parse_str.split('.'))
except ValueError:
version = ()
if len(version) != 2:
raise exc.HTTPNotAcceptable(_(
"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__(a, b):
if (a.major == b.major and a.minor > b.minor):
return True
return False

View File

@ -0,0 +1,58 @@
# Copyright 2013 Red Hat, Inc.
# 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.
import pecan
from wsme import types as wtypes
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
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'
return template % {'url': base_url, 'res': resource, 'args': resource_args}
class Link(base.APIBase):
"""A link representation."""
href = wtypes.text
"""The url of a link."""
rel = wtypes.text
"""The name of a link."""
type = wtypes.text
"""Indicates the type of document/link."""
@staticmethod
def make_link(rel_name, url, resource, resource_args,
bookmark=False, type=wtypes.Unset):
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:6385/chassis/"
"eaaca217-e7d8-47b4-bb41-3f99f20eed89",
rel="bookmark")
return sample

View File

@ -0,0 +1,97 @@
# -*- 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.
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
class Version(base.APIBase):
"""An API version representation."""
id = wtypes.text
"""The ID of the 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
class Root(base.APIBase):
name = wtypes.text
"""The name of the API"""
description = wtypes.text
"""Some information about this API"""
versions = [Version]
"""Links to all the versions available in this API"""
default_version = Version
"""A link to the default version of the API"""
@staticmethod
def convert():
root = Root()
root.name = "OpenStack Iotronic API"
root.description = ("Iotronic is an OpenStack project which aims to "
"provision baremetal machines.")
root.versions = [Version.convert('v1')]
root.default_version = Version.convert('v1')
return root
class RootController(rest.RestController):
_versions = ['v1']
"""All supported API versions"""
_default_version = 'v1'
"""The default API version"""
v1 = v1.Controller()
@expose.expose(Root)
def get(self):
# NOTE: The reason why convert() it's being called for every
# request is because we need to get the host url from
# the request object to make the links.
return Root.convert()
@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.
"""
if args[0] and args[0] not in self._versions:
args = [self._default_version] + args
return super(RootController, self)._route(args)

View File

@ -0,0 +1,208 @@
# 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.
"""
Version 1 of the Iotronic API
"""
import pecan
from pecan import rest
from webob import exc
from wsme import types as wtypes
from iotronic.api.controllers import link
from iotronic.api.controllers.v1 import board
'''
#from iotronic.api.controllers.v1 import chassis
#from iotronic.api.controllers.v1 import driver
from iotronic.api.controllers.v1 import node
#from iotronic.api.controllers.v1 import port
from iotronic.api.controllers.v1 import board
'''
from iotronic.api.controllers import base
from iotronic.api import expose
from iotronic.common.i18n import _
BASE_VERSION = 1
MIN_VER_STR = '1.0'
MAX_VER_STR = '1.0'
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)
'''
class MediaType(base.APIBase):
"""A media type representation."""
base = wtypes.text
type = wtypes.text
def __init__(self, base, type):
self.base = base
self.type = type
'''
class V1(base.APIBase):
"""The representation of the version 1 of the API."""
id = wtypes.text
"""The ID of the version, also acts as the release number"""
#media_types = [MediaType]
"""An array of supported media types for this version"""
#links = [link.Link]
"""Links that point to a specific URL for this version and documentation"""
#chassis = [link.Link]
"""Links to the chassis resource"""
#nodes = [link.Link]
"""Links to the nodes resource"""
boards = [link.Link]
"""Links to the nodes resource"""
#ports = [link.Link]
"""Links to the ports resource"""
#drivers = [link.Link]
"""Links to the drivers resource"""
@staticmethod
def convert():
v1 = V1()
v1.id = "v1"
v1.boards = [link.Link.make_link('self', pecan.request.host_url,
'nodes', ''),
link.Link.make_link('bookmark',
pecan.request.host_url,
'nodes', '',
bookmark=True)
]
'''
v1.links = [link.Link.make_link('self', pecan.request.host_url,
'v1', '', bookmark=True),
link.Link.make_link('describedby',
'http://docs.openstack.org',
'developer/iotronic/dev',
'api-spec-v1.html',
bookmark=True, type='text/html')
]
v1.media_types = [MediaType('application/json',
'application/vnd.openstack.iotronic.v1+json')]
v1.chassis = [link.Link.make_link('self', pecan.request.host_url,
'chassis', ''),
link.Link.make_link('bookmark',
pecan.request.host_url,
'chassis', '',
bookmark=True)
]
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.ports = [link.Link.make_link('self', pecan.request.host_url,
'ports', ''),
link.Link.make_link('bookmark',
pecan.request.host_url,
'ports', '',
bookmark=True)
]
v1.drivers = [link.Link.make_link('self', pecan.request.host_url,
'drivers', ''),
link.Link.make_link('bookmark',
pecan.request.host_url,
'drivers', '',
bookmark=True)
]
'''
return v1
class Controller(rest.RestController):
"""Version 1 API controller root."""
boards = board.BoardsController()
#nodes = node.NodesController()
#ports = port.PortsController()
#chassis = chassis.ChassisController()
#drivers = driver.DriversController()
#boards= board.BoardsController()
@expose.expose(V1)
def get(self):
# NOTE: The reason why convert() it's being called for every
# request is because we need to get the host url from
# the request object to make the links.
return V1.convert()
def _check_version(self, version, headers=None):
if headers is None:
headers = {}
# ensure that major version in the URL matches the header
if version.major != BASE_VERSION:
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}, 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)
@pecan.expose()
def _route(self, args):
v = base.Version(pecan.request.headers, MIN_VER_STR, MAX_VER_STR)
# 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
# assert that requested version is supported
self._check_version(v, pecan.response.headers)
pecan.response.headers[base.Version.string] = str(v)
pecan.request.version = v
return super(Controller, self)._route(args)
__all__ = (Controller)

View File

@ -0,0 +1,207 @@
# 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.
"""
Version 1 of the Iotronic API
"""
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 chassis
#from iotronic.api.controllers.v1 import driver
from iotronic.api.controllers.v1 import node
from iotronic.api.controllers.v1 import board
#from iotronic.api.controllers.v1 import port
from iotronic.api import expose
from iotronic.common.i18n import _
BASE_VERSION = 1
# NOTE(deva): v1.0 is reserved to indicate Juno's API, but is not presently
# supported by the API service. All changes between Juno and the
# point where we added microversioning are considered backwards-
# compatible, but are not specifically discoverable at this time.
#
# The v1.1 version indicates this "initial" version as being
# different from Juno (v1.0), and includes the following changes:
#
# 827db7fe: Add Node.maintenance_reason
# 68eed82b: Add API endpoint to set/unset the node maintenance mode
# bc973889: Add sync and async support for passthru methods
# e03f443b: Vendor endpoints to support different HTTP methods
# e69e5309: Make vendor methods discoverable via the Iotronic API
# edf532db: Add logic to store the config drive passed by Nova
# v1.1: API at the point in time when microversioning support was added
MIN_VER_STR = '1.0'
# v1.2: Renamed NOSTATE ("None") to AVAILABLE ("available")
# v1.3: Add node.driver_internal_info
# v1.4: Add MANAGEABLE state
# v1.5: Add logical node names
# v1.6: Add INSPECT* states
MAX_VER_STR = '1.0'
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)
class MediaType(base.APIBase):
"""A media type representation."""
base = wtypes.text
type = wtypes.text
def __init__(self, base, type):
self.base = base
self.type = type
class V1(base.APIBase):
"""The representation of the version 1 of the API."""
id = wtypes.text
"""The ID of the version, also acts as the release number"""
media_types = [MediaType]
"""An array of supported media types for this version"""
links = [link.Link]
"""Links that point to a specific URL for this version and documentation"""
#chassis = [link.Link]
"""Links to the chassis resource"""
nodes = [link.Link]
"""Links to the nodes resource"""
#ports = [link.Link]
"""Links to the ports resource"""
#drivers = [link.Link]
"""Links to the drivers resource"""
@staticmethod
def convert():
v1 = V1()
v1.id = "v1"
v1.links = [link.Link.make_link('self', pecan.request.host_url,
'v1', '', bookmark=True),
link.Link.make_link('describedby',
'http://docs.openstack.org',
'developer/iotronic/dev',
'api-spec-v1.html',
bookmark=True, type='text/html')
]
v1.media_types = [MediaType('application/json',
'application/vnd.openstack.iotronic.v1+json')]
'''
v1.chassis = [link.Link.make_link('self', pecan.request.host_url,
'chassis', ''),
link.Link.make_link('bookmark',
pecan.request.host_url,
'chassis', '',
bookmark=True)
]
'''
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.ports = [link.Link.make_link('self', pecan.request.host_url,
'ports', ''),
link.Link.make_link('bookmark',
pecan.request.host_url,
'ports', '',
bookmark=True)
]
v1.drivers = [link.Link.make_link('self', pecan.request.host_url,
'drivers', ''),
link.Link.make_link('bookmark',
pecan.request.host_url,
'drivers', '',
bookmark=True)
]
'''
return v1
class Controller(rest.RestController):
"""Version 1 API controller root."""
nodes = node.NodesController()
#ports = port.PortsController()
#chassis = chassis.ChassisController()
#drivers = driver.DriversController()
boards= board.BoardsController()
@expose.expose(V1)
def get(self):
# NOTE: The reason why convert() it's being called for every
# request is because we need to get the host url from
# the request object to make the links.
return V1.convert()
def _check_version(self, version, headers=None):
if headers is None:
headers = {}
# ensure that major version in the URL matches the header
if version.major != BASE_VERSION:
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}, 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)
@pecan.expose()
def _route(self, args):
v = base.Version(pecan.request.headers, MIN_VER_STR, MAX_VER_STR)
# 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
# assert that requested version is supported
self._check_version(v, pecan.response.headers)
pecan.response.headers[base.Version.string] = str(v)
pecan.request.version = v
return super(Controller, self)._route(args)
__all__ = (Controller)

View File

@ -0,0 +1,270 @@
# Copyright 2013 Red Hat, Inc.
# 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.
import datetime
import pecan
from pecan import rest
import wsme
from wsme import types as wtypes
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 node
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.i18n import _
from iotronic import objects
class ChassisPatchType(types.JsonPatchType):
pass
class Chassis(base.APIBase):
"""API representation of a chassis.
This class enforces type checking and value constraints, and converts
between the internal object model and the API representation of
a chassis.
"""
uuid = types.uuid
"""The UUID of the chassis"""
description = wtypes.text
"""The description of the chassis"""
extra = {wtypes.text: types.jsontype}
"""The metadata of the chassis"""
links = wsme.wsattr([link.Link], readonly=True)
"""A list containing a self link and associated chassis links"""
nodes = wsme.wsattr([link.Link], readonly=True)
"""Links to the collection of nodes contained in this chassis"""
def __init__(self, **kwargs):
self.fields = []
for field in objects.Chassis.fields:
# Skip fields we do not expose.
if not hasattr(self, field):
continue
self.fields.append(field)
setattr(self, field, kwargs.get(field, wtypes.Unset))
@staticmethod
def _convert_with_links(chassis, url, expand=True):
if not expand:
chassis.unset_fields_except(['uuid', 'description'])
else:
chassis.nodes = [link.Link.make_link('self',
url,
'chassis',
chassis.uuid + "/nodes"),
link.Link.make_link('bookmark',
url,
'chassis',
chassis.uuid + "/nodes",
bookmark=True)
]
chassis.links = [link.Link.make_link('self',
url,
'chassis', chassis.uuid),
link.Link.make_link('bookmark',
url,
'chassis', chassis.uuid,
bookmark=True)
]
return chassis
@classmethod
def convert_with_links(cls, rpc_chassis, expand=True):
chassis = Chassis(**rpc_chassis.as_dict())
return cls._convert_with_links(chassis, pecan.request.host_url,
expand)
@classmethod
def sample(cls, expand=True):
time = datetime.datetime(2000, 1, 1, 12, 0, 0)
sample = cls(uuid='eaaca217-e7d8-47b4-bb41-3f99f20eed89', extra={},
description='Sample chassis', created_at=time,
updated_at=time)
return cls._convert_with_links(sample, 'http://localhost:6385',
expand)
class ChassisCollection(collection.Collection):
"""API representation of a collection of chassis."""
chassis = [Chassis]
"""A list containing chassis objects"""
def __init__(self, **kwargs):
self._type = 'chassis'
@staticmethod
def convert_with_links(chassis, limit, url=None, expand=False, **kwargs):
collection = ChassisCollection()
collection.chassis = [Chassis.convert_with_links(ch, expand)
for ch in chassis]
url = url or None
collection.next = collection.get_next(limit, url=url, **kwargs)
return collection
@classmethod
def sample(cls, expand=True):
sample = cls()
sample.chassis = [Chassis.sample(expand=False)]
return sample
class ChassisController(rest.RestController):
"""REST controller for Chassis."""
nodes = node.NodesController()
"""Expose nodes as a sub-element of chassis"""
# Set the flag to indicate that the requests to this resource are
# coming from a top-level resource
nodes.from_chassis = True
_custom_actions = {
'detail': ['GET'],
}
invalid_sort_key_list = ['extra']
def _get_chassis_collection(self, marker, limit, sort_key, sort_dir,
expand=False, resource_url=None):
limit = api_utils.validate_limit(limit)
sort_dir = api_utils.validate_sort_dir(sort_dir)
marker_obj = None
if marker:
marker_obj = objects.Chassis.get_by_uuid(pecan.request.context,
marker)
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})
chassis = objects.Chassis.list(pecan.request.context, limit,
marker_obj, sort_key=sort_key,
sort_dir=sort_dir)
return ChassisCollection.convert_with_links(chassis, limit,
url=resource_url,
expand=expand,
sort_key=sort_key,
sort_dir=sort_dir)
@expose.expose(ChassisCollection, types.uuid,
int, wtypes.text, wtypes.text)
def get_all(self, marker=None, limit=None, sort_key='id', sort_dir='asc'):
"""Retrieve a list of chassis.
:param marker: pagination marker for large data sets.
:param limit: maximum number of resources to return in a single result.
:param sort_key: column to sort results by. Default: id.
:param sort_dir: direction to sort. "asc" or "desc". Default: asc.
"""
return self._get_chassis_collection(marker, limit, sort_key, sort_dir)
@expose.expose(ChassisCollection, types.uuid, int,
wtypes.text, wtypes.text)
def detail(self, marker=None, limit=None, sort_key='id', sort_dir='asc'):
"""Retrieve a list of chassis with detail.
:param marker: pagination marker for large data sets.
:param limit: maximum number of resources to return in a single result.
:param sort_key: column to sort results by. Default: id.
:param sort_dir: direction to sort. "asc" or "desc". Default: asc.
"""
# /detail should only work against collections
parent = pecan.request.path.split('/')[:-1][-1]
if parent != "chassis":
raise exception.HTTPNotFound
expand = True
resource_url = '/'.join(['chassis', 'detail'])
return self._get_chassis_collection(marker, limit, sort_key, sort_dir,
expand, resource_url)
@expose.expose(Chassis, types.uuid)
def get_one(self, chassis_uuid):
"""Retrieve information about the given chassis.
:param chassis_uuid: UUID of a chassis.
"""
rpc_chassis = objects.Chassis.get_by_uuid(pecan.request.context,
chassis_uuid)
return Chassis.convert_with_links(rpc_chassis)
@expose.expose(Chassis, body=Chassis, status_code=201)
def post(self, chassis):
"""Create a new chassis.
:param chassis: a chassis within the request body.
"""
new_chassis = objects.Chassis(pecan.request.context,
**chassis.as_dict())
new_chassis.create()
# Set the HTTP Location Header
pecan.response.location = link.build_url('chassis', new_chassis.uuid)
return Chassis.convert_with_links(new_chassis)
@wsme.validate(types.uuid, [ChassisPatchType])
@expose.expose(Chassis, types.uuid, body=[ChassisPatchType])
def patch(self, chassis_uuid, patch):
"""Update an existing chassis.
:param chassis_uuid: UUID of a chassis.
:param patch: a json PATCH document to apply to this chassis.
"""
rpc_chassis = objects.Chassis.get_by_uuid(pecan.request.context,
chassis_uuid)
try:
chassis = Chassis(**api_utils.apply_jsonpatch(
rpc_chassis.as_dict(), patch))
except api_utils.JSONPATCH_EXCEPTIONS as e:
raise exception.PatchError(patch=patch, reason=e)
# Update only the fields that have changed
for field in objects.Chassis.fields:
try:
patch_val = getattr(chassis, field)
except AttributeError:
# Ignore fields that aren't exposed in the API
continue
if patch_val == wtypes.Unset:
patch_val = None
if rpc_chassis[field] != patch_val:
rpc_chassis[field] = patch_val
rpc_chassis.save()
return Chassis.convert_with_links(rpc_chassis)
@expose.expose(None, types.uuid, status_code=204)
def delete(self, chassis_uuid):
"""Delete a chassis.
:param chassis_uuid: UUID of a chassis.
"""
rpc_chassis = objects.Chassis.get_by_uuid(pecan.request.context,
chassis_uuid)
rpc_chassis.destroy()

View File

@ -0,0 +1,48 @@
# Copyright 2013 Red Hat, Inc.
# 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.
import pecan
from wsme import types as wtypes
from iotronic.api.controllers import base
from iotronic.api.controllers import link
class Collection(base.APIBase):
next = wtypes.text
"""A link to retrieve the next subset of the collection"""
@property
def collection(self):
return getattr(self, self._type)
def has_next(self, limit):
"""Return whether collection has more items."""
return len(self.collection) and len(self.collection) == limit
def get_next(self, limit, url=None, **kwargs):
"""Return a link to the next subset of the collection."""
if not self.has_next(limit):
return wtypes.Unset
resource_url = url or self._type
q_args = ''.join(['%s=%s&' % (key, kwargs[key]) for key in kwargs])
next_args = '?%(args)slimit=%(limit)d&marker=%(marker)s' % {
'args': q_args, 'limit': limit,
'marker': self.collection[-1].uuid}
return link.Link.make_link('next', pecan.request.host_url,
resource_url, next_args).href

View File

@ -0,0 +1,210 @@
# Copyright 2013 Red Hat, Inc.
# 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.
import pecan
from pecan import rest
import wsme
from wsme import types as wtypes
from iotronic.api.controllers import base
from iotronic.api.controllers import link
from iotronic.api import expose
from iotronic.common import exception
from iotronic.common.i18n import _
# Property information for drivers:
# key = driver name;
# value = dictionary of properties of that driver:
# key = property name.
# value = description of the property.
# NOTE(rloo). This is cached for the lifetime of the API service. If one or
# more conductor services are restarted with new driver versions, the API
# service should be restarted.
_DRIVER_PROPERTIES = {}
# Vendor information for drivers:
# key = driver name;
# value = dictionary of vendor methods of that driver:
# key = method name.
# value = dictionary with the metadata of that method.
# NOTE(lucasagomes). This is cached for the lifetime of the API
# service. If one or more conductor services are restarted with new driver
# versions, the API service should be restarted.
_VENDOR_METHODS = {}
class Driver(base.APIBase):
"""API representation of a driver."""
name = wtypes.text
"""The name of the driver"""
hosts = [wtypes.text]
"""A list of active conductors that support this driver"""
links = wsme.wsattr([link.Link], readonly=True)
"""A list containing self and bookmark links"""
@staticmethod
def convert_with_links(name, hosts):
driver = Driver()
driver.name = name
driver.hosts = hosts
driver.links = [
link.Link.make_link('self',
pecan.request.host_url,
'drivers', name),
link.Link.make_link('bookmark',
pecan.request.host_url,
'drivers', name,
bookmark=True)
]
return driver
@classmethod
def sample(cls):
sample = cls(name="sample-driver",
hosts=["fake-host"])
return sample
class DriverList(base.APIBase):
"""API representation of a list of drivers."""
drivers = [Driver]
"""A list containing drivers objects"""
@staticmethod
def convert_with_links(drivers):
collection = DriverList()
collection.drivers = [
Driver.convert_with_links(dname, list(drivers[dname]))
for dname in drivers]
return collection
@classmethod
def sample(cls):
sample = cls()
sample.drivers = [Driver.sample()]
return sample
class DriverPassthruController(rest.RestController):
"""REST controller for driver passthru.
This controller allow vendors to expose cross-node functionality in the
Iotronic API. Iotronic will merely relay the message from here to the specified
driver, no introspection will be made in the message body.
"""
_custom_actions = {
'methods': ['GET']
}
@expose.expose(wtypes.text, wtypes.text)
def methods(self, driver_name):
"""Retrieve information about vendor methods of the given driver.
:param driver_name: name of the driver.
:returns: dictionary with <vendor method name>:<method metadata>
entries.
:raises: DriverNotFound if the driver name is invalid or the
driver cannot be loaded.
"""
if driver_name not in _VENDOR_METHODS:
topic = pecan.request.rpcapi.get_topic_for_driver(driver_name)
ret = pecan.request.rpcapi.get_driver_vendor_passthru_methods(
pecan.request.context, driver_name, topic=topic)
_VENDOR_METHODS[driver_name] = ret
return _VENDOR_METHODS[driver_name]
@expose.expose(wtypes.text, wtypes.text, wtypes.text,
body=wtypes.text)
def _default(self, driver_name, method, data=None):
"""Call a driver API extension.
:param driver_name: name of the driver to call.
:param method: name of the method, to be passed to the vendor
implementation.
:param data: body of data to supply to the specified method.
"""
if not method:
raise wsme.exc.ClientSideError(_("Method not specified"))
if data is None:
data = {}
http_method = pecan.request.method.upper()
topic = pecan.request.rpcapi.get_topic_for_driver(driver_name)
ret, is_async = pecan.request.rpcapi.driver_vendor_passthru(
pecan.request.context, driver_name, method,
http_method, data, topic=topic)
status_code = 202 if is_async else 200
return wsme.api.Response(ret, status_code=status_code)
class DriversController(rest.RestController):
"""REST controller for Drivers."""
vendor_passthru = DriverPassthruController()
_custom_actions = {
'properties': ['GET'],
}
@expose.expose(DriverList)
def get_all(self):
"""Retrieve a list of drivers."""
# FIXME(deva): formatting of the auto-generated REST API docs
# will break from a single-line doc string.
# This is a result of a bug in sphinxcontrib-pecanwsme
# https://github.com/dreamhost/sphinxcontrib-pecanwsme/issues/8
driver_list = pecan.request.dbapi.get_active_driver_dict()
return DriverList.convert_with_links(driver_list)
@expose.expose(Driver, wtypes.text)
def get_one(self, driver_name):
"""Retrieve a single driver."""
# NOTE(russell_h): There is no way to make this more efficient than
# retrieving a list of drivers using the current sqlalchemy schema, but
# this path must be exposed for Pecan to route any paths we might
# choose to expose below it.
driver_dict = pecan.request.dbapi.get_active_driver_dict()
for name, hosts in driver_dict.items():
if name == driver_name:
return Driver.convert_with_links(name, list(hosts))
raise exception.DriverNotFound(driver_name=driver_name)
@expose.expose(wtypes.text, wtypes.text)
def properties(self, driver_name):
"""Retrieve property information of the given driver.
:param driver_name: name of the driver.
:returns: dictionary with <property name>:<property description>
entries.
:raises: DriverNotFound (HTTP 404) if the driver name is invalid or
the driver cannot be loaded.
"""
if driver_name not in _DRIVER_PROPERTIES:
topic = pecan.request.rpcapi.get_topic_for_driver(driver_name)
properties = pecan.request.rpcapi.get_driver_properties(
pecan.request.context, driver_name, topic=topic)
_DRIVER_PROPERTIES[driver_name] = properties
return _DRIVER_PROPERTIES[driver_name]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,396 @@
# Copyright 2013 UnitedStack Inc.
# 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.
import datetime
from oslo_utils import uuidutils
import pecan
from pecan import rest
import wsme
from wsme import types as wtypes
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.i18n import _
from iotronic import objects
class PortPatchType(types.JsonPatchType):
@staticmethod
def mandatory_attrs():
return ['/address', '/node_uuid']
class Port(base.APIBase):
"""API representation of a port.
This class enforces type checking and value constraints, and converts
between the internal object model and the API representation of a port.
"""
_node_uuid = None
def _get_node_uuid(self):
return self._node_uuid
def _set_node_uuid(self, value):
if value and self._node_uuid != value:
try:
# FIXME(comstud): One should only allow UUID here, but
# there seems to be a bug in that tests are passing an
# ID. See bug #1301046 for more details.
node = objects.Node.get(pecan.request.context, value)
self._node_uuid = node.uuid
# NOTE(lucasagomes): Create the node_id attribute on-the-fly
# to satisfy the api -> rpc object
# conversion.
self.node_id = node.id
except exception.NodeNotFound as e:
# Change error code because 404 (NotFound) is inappropriate
# response for a POST request to create a Port
e.code = 400 # BadRequest
raise e
elif value == wtypes.Unset:
self._node_uuid = wtypes.Unset
uuid = types.uuid
"""Unique UUID for this port"""
address = wsme.wsattr(types.macaddress, mandatory=True)
"""MAC Address for this port"""
extra = {wtypes.text: types.jsontype}
"""This port's meta data"""
node_uuid = wsme.wsproperty(types.uuid, _get_node_uuid, _set_node_uuid,
mandatory=True)
"""The UUID of the node this port belongs to"""
links = wsme.wsattr([link.Link], readonly=True)
"""A list containing a self link and associated port links"""
def __init__(self, **kwargs):
self.fields = []
fields = list(objects.Port.fields)
# NOTE(lucasagomes): node_uuid is not part of objects.Port.fields
# because it's an API-only attribute
fields.append('node_uuid')
for field in fields:
# Skip fields we do not expose.
if not hasattr(self, field):
continue
self.fields.append(field)
setattr(self, field, kwargs.get(field, wtypes.Unset))
# NOTE(lucasagomes): node_id is an attribute created on-the-fly
# by _set_node_uuid(), it needs to be present in the fields so
# that as_dict() will contain node_id field when converting it
# before saving it in the database.
self.fields.append('node_id')
setattr(self, 'node_uuid', kwargs.get('node_id', wtypes.Unset))
@staticmethod
def _convert_with_links(port, url, expand=True):
if not expand:
port.unset_fields_except(['uuid', 'address'])
# never expose the node_id attribute
port.node_id = wtypes.Unset
port.links = [link.Link.make_link('self', url,
'ports', port.uuid),
link.Link.make_link('bookmark', url,
'ports', port.uuid,
bookmark=True)
]
return port
@classmethod
def convert_with_links(cls, rpc_port, expand=True):
port = Port(**rpc_port.as_dict())
return cls._convert_with_links(port, pecan.request.host_url, expand)
@classmethod
def sample(cls, expand=True):
sample = cls(uuid='27e3153e-d5bf-4b7e-b517-fb518e17f34c',
address='fe:54:00:77:07:d9',
extra={'foo': 'bar'},
created_at=datetime.datetime.utcnow(),
updated_at=datetime.datetime.utcnow())
# NOTE(lucasagomes): node_uuid getter() method look at the
# _node_uuid variable
sample._node_uuid = '7ae81bb3-dec3-4289-8d6c-da80bd8001ae'
return cls._convert_with_links(sample, 'http://localhost:6385', expand)
class PortCollection(collection.Collection):
"""API representation of a collection of ports."""
ports = [Port]
"""A list containing ports objects"""
def __init__(self, **kwargs):
self._type = 'ports'
@staticmethod
def convert_with_links(rpc_ports, limit, url=None, expand=False, **kwargs):
collection = PortCollection()
collection.ports = [Port.convert_with_links(p, expand)
for p in rpc_ports]
collection.next = collection.get_next(limit, url=url, **kwargs)
return collection
@classmethod
def sample(cls):
sample = cls()
sample.ports = [Port.sample(expand=False)]
return sample
class PortsController(rest.RestController):
"""REST controller for Ports."""
from_nodes = False
"""A flag to indicate if the requests to this controller are coming
from the top-level resource Nodes."""
_custom_actions = {
'detail': ['GET'],
}
invalid_sort_key_list = ['extra']
def _get_ports_collection(self, node_ident, address, marker, limit,
sort_key, sort_dir, expand=False,
resource_url=None):
if self.from_nodes and not node_ident:
raise exception.MissingParameterValue(_(
"Node identifier not specified."))
limit = api_utils.validate_limit(limit)
sort_dir = api_utils.validate_sort_dir(sort_dir)
marker_obj = None
if marker:
marker_obj = objects.Port.get_by_uuid(pecan.request.context,
marker)
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})
if node_ident:
# FIXME(comstud): Since all we need is the node ID, we can
# make this more efficient by only querying
# for that column. This will get cleaned up
# as we move to the object interface.
node = api_utils.get_rpc_node(node_ident)
ports = objects.Port.list_by_node_id(pecan.request.context,
node.id, limit, marker_obj,
sort_key=sort_key,
sort_dir=sort_dir)
elif address:
ports = self._get_ports_by_address(address)
else:
ports = objects.Port.list(pecan.request.context, limit,
marker_obj, sort_key=sort_key,
sort_dir=sort_dir)
return PortCollection.convert_with_links(ports, limit,
url=resource_url,
expand=expand,
sort_key=sort_key,
sort_dir=sort_dir)
def _get_ports_by_address(self, address):
"""Retrieve a port by its address.
:param address: MAC address of a port, to get the port which has
this MAC address.
:returns: a list with the port, or an empty list if no port is found.
"""
try:
port = objects.Port.get_by_address(pecan.request.context, address)
return [port]
except exception.PortNotFound:
return []
@expose.expose(PortCollection, types.uuid_or_name, types.uuid,
types.macaddress, types.uuid, int, wtypes.text,
wtypes.text)
def get_all(self, node=None, node_uuid=None, address=None, marker=None,
limit=None, sort_key='id', sort_dir='asc'):
"""Retrieve a list of ports.
Note that the 'node_uuid' interface is deprecated in favour
of the 'node' interface
:param node: UUID or name of a node, to get only ports for that
node.
:param node_uuid: UUID of a node, to get only ports for that
node.
:param address: MAC address of a port, to get the port which has
this MAC address.
:param marker: pagination marker for large data sets.
:param limit: maximum number of resources to return in a single result.
:param sort_key: column to sort results by. Default: id.
:param sort_dir: direction to sort. "asc" or "desc". Default: asc.
"""
if not node_uuid and node:
# We're invoking this interface using positional notation, or
# explicitly using 'node'. Try and determine which one.
# Make sure only one interface, node or node_uuid is used
if (not api_utils.allow_node_logical_names() and
not uuidutils.is_uuid_like(node)):
raise exception.NotAcceptable()
return self._get_ports_collection(node_uuid or node, address, marker,
limit, sort_key, sort_dir)
@expose.expose(PortCollection, types.uuid_or_name, types.uuid,
types.macaddress, types.uuid, int, wtypes.text,
wtypes.text)
def detail(self, node=None, node_uuid=None, address=None, marker=None,
limit=None, sort_key='id', sort_dir='asc'):
"""Retrieve a list of ports with detail.
Note that the 'node_uuid' interface is deprecated in favour
of the 'node' interface
:param node: UUID or name of a node, to get only ports for that
node.
:param node_uuid: UUID of a node, to get only ports for that
node.
:param address: MAC address of a port, to get the port which has
this MAC address.
:param marker: pagination marker for large data sets.
:param limit: maximum number of resources to return in a single result.
:param sort_key: column to sort results by. Default: id.
:param sort_dir: direction to sort. "asc" or "desc". Default: asc.
"""
if not node_uuid and node:
# We're invoking this interface using positional notation, or
# explicitly using 'node'. Try and determine which one.
# Make sure only one interface, node or node_uuid is used
if (not api_utils.allow_node_logical_names() and
not uuidutils.is_uuid_like(node)):
raise exception.NotAcceptable()
# NOTE(lucasagomes): /detail should only work against collections
parent = pecan.request.path.split('/')[:-1][-1]
if parent != "ports":
raise exception.HTTPNotFound
expand = True
resource_url = '/'.join(['ports', 'detail'])
return self._get_ports_collection(node_uuid or node, address, marker,
limit, sort_key, sort_dir, expand,
resource_url)
@expose.expose(Port, types.uuid)
def get_one(self, port_uuid):
"""Retrieve information about the given port.
:param port_uuid: UUID of a port.
"""
if self.from_nodes:
raise exception.OperationNotPermitted
rpc_port = objects.Port.get_by_uuid(pecan.request.context, port_uuid)
return Port.convert_with_links(rpc_port)
@expose.expose(Port, body=Port, status_code=201)
def post(self, port):
"""Create a new port.
:param port: a port within the request body.
"""
if self.from_nodes:
raise exception.OperationNotPermitted
new_port = objects.Port(pecan.request.context,
**port.as_dict())
new_port.create()
# Set the HTTP Location Header
pecan.response.location = link.build_url('ports', new_port.uuid)
return Port.convert_with_links(new_port)
@wsme.validate(types.uuid, [PortPatchType])
@expose.expose(Port, types.uuid, body=[PortPatchType])
def patch(self, port_uuid, patch):
"""Update an existing port.
:param port_uuid: UUID of a port.
:param patch: a json PATCH document to apply to this port.
"""
if self.from_nodes:
raise exception.OperationNotPermitted
rpc_port = objects.Port.get_by_uuid(pecan.request.context, port_uuid)
try:
port_dict = rpc_port.as_dict()
# NOTE(lucasagomes):
# 1) Remove node_id because it's an internal value and
# not present in the API object
# 2) Add node_uuid
port_dict['node_uuid'] = port_dict.pop('node_id', None)
port = Port(**api_utils.apply_jsonpatch(port_dict, patch))
except api_utils.JSONPATCH_EXCEPTIONS as e:
raise exception.PatchError(patch=patch, reason=e)
# Update only the fields that have changed
for field in objects.Port.fields:
try:
patch_val = getattr(port, field)
except AttributeError:
# Ignore fields that aren't exposed in the API
continue
if patch_val == wtypes.Unset:
patch_val = None
if rpc_port[field] != patch_val:
rpc_port[field] = patch_val
rpc_node = objects.Node.get_by_id(pecan.request.context,
rpc_port.node_id)
topic = pecan.request.rpcapi.get_topic_for(rpc_node)
new_port = pecan.request.rpcapi.update_port(
pecan.request.context, rpc_port, topic)
return Port.convert_with_links(new_port)
@expose.expose(None, types.uuid, status_code=204)
def delete(self, port_uuid):
"""Delete a port.
:param port_uuid: UUID of a port.
"""
if self.from_nodes:
raise exception.OperationNotPermitted
rpc_port = objects.Port.get_by_uuid(pecan.request.context,
port_uuid)
rpc_node = objects.Node.get_by_id(pecan.request.context,
rpc_port.node_id)
topic = pecan.request.rpcapi.get_topic_for(rpc_node)
pecan.request.rpcapi.destroy_port(pecan.request.context,
rpc_port, topic)

View File

@ -0,0 +1,34 @@
# Copyright 2013 Red Hat, Inc.
# 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 wsme import types as wtypes
from iotronic.api.controllers import base
from iotronic.api.controllers import link
class State(base.APIBase):
current = wtypes.text
"""The current state"""
target = wtypes.text
"""The user modified desired state"""
available = [wtypes.text]
"""A list of available states it is able to transition to"""
links = [link.Link]
"""A list containing a self link and associated state links"""

View File

@ -0,0 +1,239 @@
# coding: utf-8
#
# Copyright 2013 Red Hat, Inc.
# 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.
import json
from oslo_utils import strutils
from oslo_utils import uuidutils
import six
import wsme
from wsme import types as wtypes
from iotronic.common import exception
from iotronic.common.i18n import _
from iotronic.common import utils
class MacAddressType(wtypes.UserType):
"""A simple MAC address type."""
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):
return utils.validate_and_normalize_mac(value)
@staticmethod
def frombasetype(value):
if value is None:
return None
return MacAddressType.validate(value)
class UuidOrNameType(wtypes.UserType):
"""A simple UUID or logical name type."""
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)):
raise exception.InvalidUuidOrName(name=value)
return value
@staticmethod
def frombasetype(value):
if value is None:
return None
return UuidOrNameType.validate(value)
class NameType(wtypes.UserType):
"""A simple logical name type."""
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):
raise exception.InvalidName(name=value)
return value
@staticmethod
def frombasetype(value):
if value is None:
return None
return NameType.validate(value)
class UuidType(wtypes.UserType):
"""A simple UUID type."""
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):
if not uuidutils.is_uuid_like(value):
raise exception.InvalidUUID(uuid=value)
return value
@staticmethod
def frombasetype(value):
if value is None:
return None
return UuidType.validate(value)
class BooleanType(wtypes.UserType):
"""A simple boolean type."""
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):
try:
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)
@staticmethod
def frombasetype(value):
if value is None:
return None
return BooleanType.validate(value)
class JsonType(wtypes.UserType):
"""A simple JSON type."""
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
return ' | '.join(map(str, (wtypes.text, six.integer_types, float,
BooleanType, list, dict, None)))
@staticmethod
def validate(value):
try:
json.dumps(value)
except TypeError:
raise exception.Invalid(_('%s is not JSON serializable') % value)
else:
return value
@staticmethod
def frombasetype(value):
return JsonType.validate(value)
macaddress = MacAddressType()
uuid_or_name = UuidOrNameType()
name = NameType()
uuid = UuidType()
boolean = BooleanType()
# Can't call it 'json' because that's the name of the stdlib module
jsontype = JsonType()
class JsonPatchType(wtypes.Base):
"""A complex type that represents a single json-patch operation."""
path = wtypes.wsattr(wtypes.StringType(pattern='^(/[\w-]+)+$'),
mandatory=True)
op = wtypes.wsattr(wtypes.Enum(str, 'add', 'replace', 'remove'),
mandatory=True)
value = wsme.wsattr(jsontype, default=wtypes.Unset)
@staticmethod
def internal_attrs():
"""Returns a list of internal attributes.
Internal attributes can't be added, replaced or removed. This
method may be overwritten by derived class.
"""
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.
"""
return []
@staticmethod
def validate(patch):
_path = '/' + patch.path.split('/')[1]
if _path in patch.internal_attrs():
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':
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")
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

View File

@ -0,0 +1,107 @@
# Copyright 2013 Red Hat, Inc.
# 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.
import jsonpatch
from oslo_config import cfg
from oslo_utils import uuidutils
import pecan
import wsme
from iotronic.common import exception
from iotronic.common.i18n import _
from iotronic.common import utils
from iotronic import objects
CONF = cfg.CONF
JSONPATCH_EXCEPTIONS = (jsonpatch.JsonPatchException,
jsonpatch.JsonPointerException,
KeyError)
def validate_limit(limit):
if limit is None:
return CONF.api.max_limit
if limit <= 0:
raise wsme.exc.ClientSideError(_("Limit must be positive"))
return min(CONF.api.max_limit, limit)
def validate_sort_dir(sort_dir):
if sort_dir not in ['asc', 'desc']:
raise wsme.exc.ClientSideError(_("Invalid sort direction: %s. "
"Acceptable values are "
"'asc' or 'desc'") % sort_dir)
return sort_dir
def apply_jsonpatch(doc, patch):
for p in patch:
if p['op'] == 'add' and p['path'].count('/') == 1:
if p['path'].lstrip('/') not in doc:
msg = _('Adding a new attribute (%s) to the root of '
' the resource is not allowed')
raise wsme.exc.ClientSideError(msg % p['path'])
return jsonpatch.apply_patch(doc, jsonpatch.JsonPatch(patch))
def get_patch_value(patch, path):
for p in patch:
if p['path'] == path:
return p['value']
def allow_node_logical_names():
# v1.5 added logical name aliases
return pecan.request.version.minor >= 5
def get_rpc_node(node_ident):
"""Get the RPC node from the node uuid or logical name.
:param node_ident: the UUID or logical name of a node.
:returns: The RPC Node.
:raises: InvalidUuidOrName if the name or uuid provided is not valid.
:raises: NodeNotFound if the node is not found.
"""
# Check to see if the node_ident is a valid UUID. If it is, treat it
# as a UUID.
if uuidutils.is_uuid_like(node_ident):
return objects.Node.get_by_uuid(pecan.request.context, node_ident)
# We can refer to nodes by their name, if the client supports it
if allow_node_logical_names():
if utils.is_hostname_safe(node_ident):
return objects.Node.get_by_name(pecan.request.context, node_ident)
raise exception.InvalidUuidOrName(name=node_ident)
# Ensure we raise the same exception as we did for the Juno release
raise exception.NodeNotFound(node=node_ident)
def is_valid_node_name(name):
"""Determine if the provided name is a valid node name.
Check to see that the provided node name is valid, and isn't a UUID.
:param: name: the node name to check.
:returns: True if the name is valid, False otherwise.
"""
return utils.is_hostname_safe(name) and (not uuidutils.is_uuid_like(name))

View File

@ -0,0 +1,238 @@
from pecan import rest
from iotronic.api import expose
from wsme import types as wtypes
from iotronic import objects
from iotronic.api.controllers.v1 import types
from iotronic.api.controllers.v1 import collection
from iotronic.api.controllers.v1 import utils as api_utils
from iotronic.api.controllers import base
from oslo_utils import uuidutils
import wsme
import pecan
from pecan import rest
class Board(base.APIBase):
"""API representation of a board.
"""
uuid = types.uuid
name = wsme.wsattr(wtypes.text)
status = wsme.wsattr(wtypes.text)
@staticmethod
def _convert_with_links(board, url, expand=True, show_password=True):
'''
if not expand:
except_list = ['instance_uuid', 'maintenance', 'power_state',
'provision_state', 'uuid', 'name']
board.unset_fields_except(except_list)
else:
if not show_password:
board.driver_info = ast.literal_eval(strutils.mask_password(
board.driver_info,
"******"))
board.ports = [link.Link.make_link('self', url, 'boards',
board.uuid + "/ports"),
link.Link.make_link('bookmark', url, 'boards',
board.uuid + "/ports",
bookmark=True)
]
board.chassis_id = wtypes.Unset
'''
'''
board.links = [link.Link.make_link('self', url, 'boards',
board.uuid),
link.Link.make_link('bookmark', url, 'boards',
board.uuid, bookmark=True)
]
'''
return board
@classmethod
def convert_with_links(cls, rpc_board, expand=True):
board = Board(**rpc_board.as_dict())
return cls._convert_with_links(board, pecan.request.host_url,
expand,
pecan.request.context.show_password)
def __init__(self, **kwargs):
self.fields = []
fields = list(objects.Board.fields)
for k in fields:
# Skip fields we do not expose.
if not hasattr(self, k):
continue
self.fields.append(k)
setattr(self, k, kwargs.get(k, wtypes.Unset))
class BoardCollection(collection.Collection):
"""API representation of a collection of boards."""
boards = [Board]
"""A list containing boards objects"""
def __init__(self, **kwargs):
self._type = 'boards'
@staticmethod
def convert_with_links(boards, limit, url=None, expand=False, **kwargs):
collection = BoardCollection()
collection.boards = [Board.convert_with_links(n, expand) for n in boards]
collection.next = collection.get_next(limit, url=url, **kwargs)
return collection
class BoardsController(rest.RestController):
invalid_sort_key_list = ['properties']
def _get_boards_collection(self, chassis_uuid, instance_uuid, associated,
maintenance, marker, limit, sort_key, sort_dir,
expand=False, resource_url=None):
'''
if self.from_chassis and not chassis_uuid:
raise exception.MissingParameterValue(
_("Chassis id not specified."))
'''
limit = api_utils.validate_limit(limit)
sort_dir = api_utils.validate_sort_dir(sort_dir)
marker_obj = None
if marker:
marker_obj = objects.Board.get_by_uuid(pecan.request.context,
marker)
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})
if instance_uuid:
boards = self._get_boards_by_instance(instance_uuid)
else:
filters = {}
if chassis_uuid:
filters['chassis_uuid'] = chassis_uuid
if associated is not None:
filters['associated'] = associated
if maintenance is not None:
filters['maintenance'] = maintenance
boards = objects.Board.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}
if associated:
parameters['associated'] = associated
if maintenance:
parameters['maintenance'] = maintenance
return BoardCollection.convert_with_links(boards, limit,
url=resource_url,
expand=expand,
**parameters)
@expose.expose(BoardCollection, types.uuid, types.uuid, types.boolean,
types.boolean, types.uuid, int, wtypes.text, wtypes.text)
def get_all(self, chassis_uuid=None, instance_uuid=None, associated=None,
maintenance=None, marker=None, limit=None, sort_key='id',
sort_dir='asc'):
"""Retrieve a list of boards.
:param chassis_uuid: Optional UUID of a chassis, to get only boards for
that chassis.
:param instance_uuid: Optional UUID of an instance, to find the board
associated with that instance.
:param associated: Optional boolean whether to return a list of
associated or unassociated boards. May be combined
with other parameters.
:param maintenance: Optional boolean value that indicates whether
to get boards in maintenance mode ("True"), or not
in maintenance mode ("False").
:param marker: pagination marker for large data sets.
:param limit: maximum number of resources to return in a single result.
:param sort_key: column to sort results by. Default: id.
:param sort_dir: direction to sort. "asc" or "desc". Default: asc.
"""
return self._get_boards_collection(chassis_uuid, instance_uuid,
associated, maintenance, marker,
limit, sort_key, sort_dir)
@expose.expose(Board,types.uuid_or_name)
def get(self,board_ident):
"""Retrieve information about the given board.
:param node_ident: UUID or logical name of a board.
"""
rpc_board = api_utils.get_rpc_board(board_ident)
board = Board(**rpc_board.as_dict())
return board
@expose.expose(None, types.uuid_or_name, status_code=204)
def delete(self, board_ident):
"""Delete a board.
:param board_ident: UUID or logical name of a board.
"""
rpc_board = api_utils.get_rpc_board(board_ident)
try:
topic = pecan.request.rpcapi.get_topic_for(rpc_board)
except exception.NoValidHost as e:
e.code = 400
raise e
pecan.request.rpcapi.destroy_board(pecan.request.context,
rpc_board.uuid, topic)
#@expose.expose(Board, body=Board, status_code=201)
#def post(self, Board):
@expose.expose(Board, status_code=201)
def post(self):
"""Create a new Board.
:param Board: a Board within the request body.
"""
'''
if not Board.uuid:
Board.uuid = uuidutils.generate_uuid()
try:
pecan.request.rpcapi.get_topic_for(Board)
except exception.NoValidHost as e:
e.code = 400
raise e
if Board.name:
if not api_utils.allow_Board_logical_names():
raise exception.NotAcceptable()
if not api_utils.is_valid_Board_name(Board.name):
msg = _("Cannot create Board with invalid name %(name)s")
raise wsme.exc.ClientSideError(msg % {'name': Board.name},
status_code=400)
'''
#new_Board = objects.Board(pecan.request.context,
# **Board.as_dict())
#new_Board = objects.Board(pecan.request.context,
# **Board.as_dict())
#rpc_board = api_utils.get_rpc_board('a9a86ab8-ad45-455e-86c3-d8f7d892ec9d')
"""{'status': u'1', 'uuid': u'a9a86ab8-ad45-455e-86c3-d8f7d892ec9d',
'created_at': datetime.datetime(2015, 1, 30, 16, 56, tzinfo=<iso8601.iso8601.Utc object at 0x7f5b81e0dd90>),
'updated_at': None,
'reservation': None, 'id': 106, 'name': u'provaaaa'}
"""
b="{'status': '1', 'uuid': 'a9a86ab8-ad45-455e-86c3-d8f7d892ec9d', 'name': 'provaaaa'}"
board = Board(**b.as_dict())
board.uuid = uuidutils.generate_uuid()
new_Board = objects.Board(pecan.request.context,
**board.as_dict())
new_Board.create()
#pecan.response.location = link.build_url('Boards', new_Board.uuid)
return Board.convert_with_links(new_Board)

View File

@ -0,0 +1,48 @@
# Copyright 2013 Red Hat, Inc.
# 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.
import pecan
from wsme import types as wtypes
from iotronic.api.controllers import base
from iotronic.api.controllers import link
class Collection(base.APIBase):
next = wtypes.text
"""A link to retrieve the next subset of the collection"""
@property
def collection(self):
return getattr(self, self._type)
def has_next(self, limit):
"""Return whether collection has more items."""
return len(self.collection) and len(self.collection) == limit
def get_next(self, limit, url=None, **kwargs):
"""Return a link to the next subset of the collection."""
if not self.has_next(limit):
return wtypes.Unset
resource_url = url or self._type
q_args = ''.join(['%s=%s&' % (key, kwargs[key]) for key in kwargs])
next_args = '?%(args)slimit=%(limit)d&marker=%(marker)s' % {
'args': q_args, 'limit': limit,
'marker': self.collection[-1].uuid}
return link.Link.make_link('next', pecan.request.host_url,
resource_url, next_args).href

View File

@ -0,0 +1,239 @@
# coding: utf-8
#
# Copyright 2013 Red Hat, Inc.
# 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.
import json
from oslo_utils import strutils
from oslo_utils import uuidutils
import six
import wsme
from wsme import types as wtypes
from iotronic.common import exception
from iotronic.common.i18n import _
from iotronic.common import utils
class MacAddressType(wtypes.UserType):
"""A simple MAC address type."""
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):
return utils.validate_and_normalize_mac(value)
@staticmethod
def frombasetype(value):
if value is None:
return None
return MacAddressType.validate(value)
class UuidOrNameType(wtypes.UserType):
"""A simple UUID or logical name type."""
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)):
raise exception.InvalidUuidOrName(name=value)
return value
@staticmethod
def frombasetype(value):
if value is None:
return None
return UuidOrNameType.validate(value)
class NameType(wtypes.UserType):
"""A simple logical name type."""
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):
raise exception.InvalidName(name=value)
return value
@staticmethod
def frombasetype(value):
if value is None:
return None
return NameType.validate(value)
class UuidType(wtypes.UserType):
"""A simple UUID type."""
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):
if not uuidutils.is_uuid_like(value):
raise exception.InvalidUUID(uuid=value)
return value
@staticmethod
def frombasetype(value):
if value is None:
return None
return UuidType.validate(value)
class BooleanType(wtypes.UserType):
"""A simple boolean type."""
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):
try:
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)
@staticmethod
def frombasetype(value):
if value is None:
return None
return BooleanType.validate(value)
class JsonType(wtypes.UserType):
"""A simple JSON type."""
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
return ' | '.join(map(str, (wtypes.text, six.integer_types, float,
BooleanType, list, dict, None)))
@staticmethod
def validate(value):
try:
json.dumps(value)
except TypeError:
raise exception.Invalid(_('%s is not JSON serializable') % value)
else:
return value
@staticmethod
def frombasetype(value):
return JsonType.validate(value)
macaddress = MacAddressType()
uuid_or_name = UuidOrNameType()
name = NameType()
uuid = UuidType()
boolean = BooleanType()
# Can't call it 'json' because that's the name of the stdlib module
jsontype = JsonType()
class JsonPatchType(wtypes.Base):
"""A complex type that represents a single json-patch operation."""
path = wtypes.wsattr(wtypes.StringType(pattern='^(/[\w-]+)+$'),
mandatory=True)
op = wtypes.wsattr(wtypes.Enum(str, 'add', 'replace', 'remove'),
mandatory=True)
value = wsme.wsattr(jsontype, default=wtypes.Unset)
@staticmethod
def internal_attrs():
"""Returns a list of internal attributes.
Internal attributes can't be added, replaced or removed. This
method may be overwritten by derived class.
"""
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.
"""
return []
@staticmethod
def validate(patch):
_path = '/' + patch.path.split('/')[1]
if _path in patch.internal_attrs():
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':
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")
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

View File

@ -0,0 +1,131 @@
# Copyright 2013 Red Hat, Inc.
# 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.
import jsonpatch
from oslo_config import cfg
from oslo_utils import uuidutils
import pecan
import wsme
from iotronic.common import exception
from iotronic.common.i18n import _
from iotronic.common import utils
from iotronic import objects
CONF = cfg.CONF
JSONPATCH_EXCEPTIONS = (jsonpatch.JsonPatchException,
jsonpatch.JsonPointerException,
KeyError)
def validate_limit(limit):
if limit is None:
return CONF.api.max_limit
if limit <= 0:
raise wsme.exc.ClientSideError(_("Limit must be positive"))
return min(CONF.api.max_limit, limit)
def validate_sort_dir(sort_dir):
if sort_dir not in ['asc', 'desc']:
raise wsme.exc.ClientSideError(_("Invalid sort direction: %s. "
"Acceptable values are "
"'asc' or 'desc'") % sort_dir)
return sort_dir
def apply_jsonpatch(doc, patch):
for p in patch:
if p['op'] == 'add' and p['path'].count('/') == 1:
if p['path'].lstrip('/') not in doc:
msg = _('Adding a new attribute (%s) to the root of '
' the resource is not allowed')
raise wsme.exc.ClientSideError(msg % p['path'])
return jsonpatch.apply_patch(doc, jsonpatch.JsonPatch(patch))
def get_patch_value(patch, path):
for p in patch:
if p['path'] == path:
return p['value']
def allow_node_logical_names():
# v1.5 added logical name aliases
return pecan.request.version.minor >= 5
def get_rpc_node(node_ident):
"""Get the RPC node from the node uuid or logical name.
:param node_ident: the UUID or logical name of a node.
:returns: The RPC Node.
:raises: InvalidUuidOrName if the name or uuid provided is not valid.
:raises: NodeNotFound if the node is not found.
"""
# Check to see if the node_ident is a valid UUID. If it is, treat it
# as a UUID.
if uuidutils.is_uuid_like(node_ident):
return objects.Node.get_by_uuid(pecan.request.context, node_ident)
# We can refer to nodes by their name, if the client supports it
if allow_node_logical_names():
if utils.is_hostname_safe(node_ident):
return objects.Node.get_by_name(pecan.request.context, node_ident)
raise exception.InvalidUuidOrName(name=node_ident)
# Ensure we raise the same exception as we did for the Juno release
raise exception.NodeNotFound(node=node_ident)
def is_valid_node_name(name):
"""Determine if the provided name is a valid node name.
Check to see that the provided node name is valid, and isn't a UUID.
:param: name: the node name to check.
:returns: True if the name is valid, False otherwise.
"""
return utils.is_hostname_safe(name) and (not uuidutils.is_uuid_like(name))
#################### NEW
def get_rpc_board(board_ident):
"""Get the RPC board from the board uuid or logical name.
:param board_ident: the UUID or logical name of a board.
:returns: The RPC Board.
:raises: InvalidUuidOrName if the name or uuid provided is not valid.
:raises: BoardNotFound if the board is not found.
"""
# Check to see if the board_ident is a valid UUID. If it is, treat it
# as a UUID.
if uuidutils.is_uuid_like(board_ident):
return objects.Board.get_by_uuid(pecan.request.context, board_ident)
# We can refer to boards by their name, if the client supports it
if allow_board_logical_names():
if utils.is_hostname_safe(board_ident):
return objects.Board.get_by_name(pecan.request.context, board_ident)
raise exception.InvalidUuidOrName(name=board_ident)
# Ensure we raise the same exception as we did for the Juno release
raise exception.BoardNotFound(board=board_ident)

24
iotronic/api/expose.py Normal file
View File

@ -0,0 +1,24 @@
#
# Copyright 2015 Rackspace, Inc
# 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.
import wsmeext.pecan as wsme_pecan
def expose(*args, **kwargs):
"""Ensure that only JSON, and not XML, is supported."""
if 'rest_content_types' not in kwargs:
kwargs['rest_content_types'] = ('json',)
return wsme_pecan.wsexpose(*args, **kwargs)

159
iotronic/api/hooks.py Normal file
View File

@ -0,0 +1,159 @@
# -*- 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.
from oslo_config import cfg
from pecan import hooks
from webob import exc
from iotronic.common import context
from iotronic.common import policy
from iotronic.conductor import rpcapi
from iotronic.db import api as dbapi
class ConfigHook(hooks.PecanHook):
"""Attach the config object to the request so controllers can get to it."""
def before(self, state):
state.request.cfg = cfg.CONF
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
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.
"""
def __init__(self, public_api_routes):
self.public_api_routes = public_api_routes
super(ContextHook, self).__init__()
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=is_public_api,
show_password=show_password,
**creds)
class RPCHook(hooks.PecanHook):
"""Attach the rpcapi object to the request so controllers can get to it."""
def before(self, state):
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.
deserialize_remote_exception builds rpc exception traceback into error
message which is then sent to the client. Such behavior is a security
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:
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':
return
faultstring = json_body.get('faultstring')
traceback_marker = 'Traceback (most recent call last):'
if faultstring and traceback_marker in faultstring:
# Cut-off traceback.
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
# generated on the fly.
state.response.json = json_body

View File

@ -0,0 +1,23 @@
# -*- encoding: utf-8 -*-
#
# 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 iotronic.api.middleware import auth_token
from iotronic.api.middleware import parsable_error
ParsableErrorMiddleware = parsable_error.ParsableErrorMiddleware
AuthTokenMiddleware = auth_token.AuthTokenMiddleware
__all__ = (ParsableErrorMiddleware,
AuthTokenMiddleware)

View File

@ -0,0 +1,62 @@
# -*- encoding: utf-8 -*-
#
# 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.
import re
from keystonemiddleware import auth_token
from oslo_log import log
from iotronic.common import exception
from iotronic.common.i18n import _
from iotronic.common import utils
LOG = log.getLogger(__name__)
class AuthTokenMiddleware(auth_token.AuthProtocol):
"""A wrapper on Keystone auth_token middleware.
Does not perform verification of authentication tokens
for public routes in the API.
"""
def __init__(self, app, conf, public_api_routes=[]):
# 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]
except re.error as e:
msg = _('Cannot compile public API routes: %s') % e
LOG.error(msg)
raise exception.ConfigInvalid(error_msg=msg)
super(AuthTokenMiddleware, self).__init__(app, conf)
def __call__(self, env, start_response):
path = utils.safe_rstrip(env.get('PATH_INFO'), '/')
# The information whether the API call is being performed against the
# public API is required for some other components. Saving it to the
# WSGI environment is reasonable thereby.
env['is_public_api'] = any(map(lambda pattern: re.match(pattern, path),
self.public_api_routes))
if env['is_public_api']:
return self._app(env, start_response)
return super(AuthTokenMiddleware, self).__call__(env, start_response)

View File

@ -0,0 +1,94 @@
# -*- 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.
"""
Middleware to replace the plain text message body of an error
response with one formatted so the client can parse it.
Based on pecan.middleware.errordocument
"""
import json
from xml import etree as et
from oslo_log import log
import six
import webob
from iotronic.common.i18n import _
from iotronic.common.i18n import _LE
LOG = log.getLogger(__name__)
class ParsableErrorMiddleware(object):
"""Replace error body with something the client can parse."""
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
# Request for this state, modified by replace_start_response()
# and used when an error is being reported.
state = {}
def replacement_start_response(status, headers, exc_info=None):
"""Overrides the default response to make errors parsable."""
try:
status_code = int(status.split(' ')[0])
state['status_code'] = status_code
except (ValueError, TypeError): # pragma: nocover
raise Exception(_(
'ErrorDocumentMiddleware received an invalid '
'status %s') % status)
else:
if (state['status_code'] // 100) not in (2, 3):
# Remove some headers so we can replace them later
# when we have the full error message and can
# compute the length.
headers = [(h, v)
for (h, v) in headers
if h not in ('Content-Length', 'Content-Type')
]
# Save the headers in case we need to modify them.
state['headers'] = headers
return start_response(status, headers, exc_info)
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'):
try:
# simple check xml is valid
body = [et.ElementTree.tostring(
et.ElementTree.fromstring('<error_message>'
+ '\n'.join(app_iter)
+ '</error_message>'))]
except et.ElementTree.ParseError as err:
LOG.error(_LE('Error parsing HTTP response: %s'), err)
body = ['<error_message>%s' % state['status_code']
+ '</error_message>']
state['headers'].append(('Content-Type', 'application/xml'))
else:
if six.PY3:
app_iter = [i.decode('utf-8') for i in app_iter]
body = [json.dumps({'error_message': '\n'.join(app_iter)})]
if six.PY3:
body = [item.encode('utf-8') for item in body]
state['headers'].append(('Content-Type', 'application/json'))
state['headers'].append(('Content-Length', str(len(body[0]))))
else:
body = app_iter
return body

View File

View File

@ -0,0 +1,42 @@
# Copyright 2014 Red Hat, Inc.
# 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.
"""
Mapping of boot devices used when requesting the system to boot
from an alternate device.
The options presented were based on the IPMItool chassis
bootdev command. You can find the documentation at:
http://linux.die.net/man/1/ipmitool
NOTE: This module does not include all the options from ipmitool because
they don't make sense in the limited context of Iotronic right now.
"""
PXE = 'pxe'
"Boot from PXE boot"
DISK = 'disk'
"Boot from default Hard-drive"
CDROM = 'cdrom'
"Boot from CD/DVD"
BIOS = 'bios'
"Boot into BIOS setup"
SAFE = 'safe'
"Boot from default Hard-drive, request Safe Mode"

31
iotronic/common/config.py Normal file
View File

@ -0,0 +1,31 @@
# Copyright 2010 United States Government as represented by the
# Administrator of the National Aeronautics and Space Administration.
# All Rights Reserved.
# Copyright 2012 Red Hat, Inc.
#
# 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
from iotronic.common import rpc
from iotronic import version
def parse_args(argv, default_config_files=None):
rpc.set_defaults(control_exchange='iotronic')
cfg.CONF(argv[1:],
project='iotronic',
version=version.version_info.release_string(),
#version='2015.7',
default_config_files=default_config_files)
rpc.init(cfg.CONF)

View File

@ -0,0 +1,333 @@
# Copyright 2012 SINA Corporation
# Copyright 2014 Cisco Systems, Inc.
# 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.
#
"""Extracts OpenStack config option info from module(s)."""
# NOTE(GheRivero): Copied from oslo_incubator before getting removed in
# Change-Id: If15b77d31a8c615aad8fca30f6dd9928da2d08bb
from __future__ import print_function
import argparse
import imp
import os
import re
import socket
import sys
import textwrap
from oslo_config import cfg
import oslo_i18n
from oslo_utils import importutils
import six
import stevedore.named
oslo_i18n.install('iotronic')
STROPT = "StrOpt"
BOOLOPT = "BoolOpt"
INTOPT = "IntOpt"
FLOATOPT = "FloatOpt"
LISTOPT = "ListOpt"
DICTOPT = "DictOpt"
MULTISTROPT = "MultiStrOpt"
OPT_TYPES = {
STROPT: 'string value',
BOOLOPT: 'boolean value',
INTOPT: 'integer value',
FLOATOPT: 'floating point value',
LISTOPT: 'list value',
DICTOPT: 'dict value',
MULTISTROPT: 'multi valued',
}
OPTION_REGEX = re.compile(r"(%s)" % "|".join([STROPT, BOOLOPT, INTOPT,
FLOATOPT, LISTOPT, DICTOPT,
MULTISTROPT]))
PY_EXT = ".py"
BASEDIR = os.path.abspath(os.path.join(os.path.dirname(__file__),
"../../../../"))
WORDWRAP_WIDTH = 60
def raise_extension_exception(extmanager, ep, err):
raise
def generate(argv):
parser = argparse.ArgumentParser(
description='generate sample configuration file',
)
parser.add_argument('-m', dest='modules', action='append')
parser.add_argument('-l', dest='libraries', action='append')
parser.add_argument('srcfiles', nargs='*')
parsed_args = parser.parse_args(argv)
mods_by_pkg = dict()
for filepath in parsed_args.srcfiles:
pkg_name = filepath.split(os.sep)[1]
mod_str = '.'.join(['.'.join(filepath.split(os.sep)[:-1]),
os.path.basename(filepath).split('.')[0]])
mods_by_pkg.setdefault(pkg_name, list()).append(mod_str)
# NOTE(lzyeval): place top level modules before packages
pkg_names = sorted(pkg for pkg in mods_by_pkg if pkg.endswith(PY_EXT))
ext_names = sorted(pkg for pkg in mods_by_pkg if pkg not in pkg_names)
pkg_names.extend(ext_names)
# opts_by_group is a mapping of group name to an options list
# The options list is a list of (module, options) tuples
opts_by_group = {'DEFAULT': []}
if parsed_args.modules:
for module_name in parsed_args.modules:
module = _import_module(module_name)
if module:
for group, opts in _list_opts(module):
opts_by_group.setdefault(group, []).append((module_name,
opts))
# Look for entry points defined in libraries (or applications) for
# option discovery, and include their return values in the output.
#
# Each entry point should be a function returning an iterable
# of pairs with the group name (or None for the default group)
# and the list of Opt instances for that group.
if parsed_args.libraries:
loader = stevedore.named.NamedExtensionManager(
'oslo.config.opts',
names=list(set(parsed_args.libraries)),
invoke_on_load=False,
on_load_failure_callback=raise_extension_exception
)
for ext in loader:
for group, opts in ext.plugin():
opt_list = opts_by_group.setdefault(group or 'DEFAULT', [])
opt_list.append((ext.name, opts))
for pkg_name in pkg_names:
mods = mods_by_pkg.get(pkg_name)
mods.sort()
for mod_str in mods:
if mod_str.endswith('.__init__'):
mod_str = mod_str[:mod_str.rfind(".")]
mod_obj = _import_module(mod_str)
if not mod_obj:
raise RuntimeError("Unable to import module %s" % mod_str)
for group, opts in _list_opts(mod_obj):
opts_by_group.setdefault(group, []).append((mod_str, opts))
print_group_opts('DEFAULT', opts_by_group.pop('DEFAULT', []))
for group in sorted(opts_by_group.keys()):
print_group_opts(group, opts_by_group[group])
def _import_module(mod_str):
try:
if mod_str.startswith('bin.'):
imp.load_source(mod_str[4:], os.path.join('bin', mod_str[4:]))
return sys.modules[mod_str[4:]]
else:
return importutils.import_module(mod_str)
except Exception as e:
sys.stderr.write("Error importing module %s: %s\n" % (mod_str, str(e)))
return None
def _is_in_group(opt, group):
"""Check if opt is in group."""
for value in group._opts.values():
# NOTE(llu): Temporary workaround for bug #1262148, wait until
# newly released oslo.config support '==' operator.
if not(value['opt'] != opt):
return True
return False
def _guess_groups(opt):
# is it in the DEFAULT group?
if _is_in_group(opt, cfg.CONF):
return 'DEFAULT'
# what other groups is it in?
for value in cfg.CONF.values():
if isinstance(value, cfg.CONF.GroupAttr):
if _is_in_group(opt, value._group):
return value._group.name
raise RuntimeError(
"Unable to find group for option %s, "
"maybe it's defined twice in the same group?"
% opt.name
)
def _list_opts(obj):
def is_opt(o):
return (isinstance(o, cfg.Opt) and
not isinstance(o, cfg.SubCommandOpt))
opts = list()
if 'list_opts' in dir(obj):
group_opts = getattr(obj, 'list_opts')()
# NOTE(GheRivero): Options without a defined group,
# must be registered to the DEFAULT section
fixed_list = []
for section, opts in group_opts:
if not section:
section = 'DEFAULT'
fixed_list.append((section, opts))
return fixed_list
for attr_str in dir(obj):
attr_obj = getattr(obj, attr_str)
if is_opt(attr_obj):
opts.append(attr_obj)
elif (isinstance(attr_obj, list) and
all(map(lambda x: is_opt(x), attr_obj))):
opts.extend(attr_obj)
ret = {}
for opt in opts:
ret.setdefault(_guess_groups(opt), []).append(opt)
return ret.items()
def print_group_opts(group, opts_by_module):
print("[%s]" % group)
print('')
for mod, opts in opts_by_module:
print('#')
print('# Options defined in %s' % mod)
print('#')
print('')
for opt in opts:
_print_opt(opt)
print('')
def _get_my_ip():
try:
csock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
csock.connect(('8.8.8.8', 80))
(addr, port) = csock.getsockname()
csock.close()
return addr
except socket.error:
return None
def _sanitize_default(name, value):
"""Set up a reasonably sensible default for pybasedir, my_ip and host."""
hostname = socket.gethostname()
fqdn = socket.getfqdn()
if value.startswith(sys.prefix):
# NOTE(jd) Don't use os.path.join, because it is likely to think the
# second part is an absolute pathname and therefore drop the first
# part.
value = os.path.normpath("/usr/" + value[len(sys.prefix):])
elif value.startswith(BASEDIR):
return value.replace(BASEDIR, '/usr/lib/python/site-packages')
elif BASEDIR in value:
return value.replace(BASEDIR, '')
elif value == _get_my_ip():
return '10.0.0.1'
elif value in (hostname, fqdn):
if 'host' in name:
return 'iotronic'
elif value.endswith(hostname):
return value.replace(hostname, 'iotronic')
elif value.endswith(fqdn):
return value.replace(fqdn, 'iotronic')
elif value.strip() != value:
return '"%s"' % value
return value
def _print_opt(opt):
opt_name, opt_default, opt_help = opt.dest, opt.default, opt.help
if not opt_help:
sys.stderr.write('WARNING: "%s" is missing help string.\n' % opt_name)
opt_help = ""
try:
opt_type = OPTION_REGEX.search(str(type(opt))).group(0)
except (ValueError, AttributeError) as err:
sys.stderr.write("%s\n" % str(err))
sys.exit(1)
opt_help = u'%s (%s)' % (opt_help,
OPT_TYPES[opt_type])
print('#', "\n# ".join(textwrap.wrap(opt_help, WORDWRAP_WIDTH)))
if opt.deprecated_opts:
for deprecated_opt in opt.deprecated_opts:
if deprecated_opt.name:
deprecated_group = (deprecated_opt.group if
deprecated_opt.group else "DEFAULT")
print('# Deprecated group/name - [%s]/%s' %
(deprecated_group,
deprecated_opt.name))
try:
if opt_default is None:
print('#%s=<None>' % opt_name)
else:
_print_type(opt_type, opt_name, opt_default)
print('')
except Exception:
sys.stderr.write('Error in option "%s"\n' % opt_name)
sys.exit(1)
def _print_type(opt_type, opt_name, opt_default):
if opt_type == STROPT:
assert(isinstance(opt_default, six.string_types))
print('#%s=%s' % (opt_name, _sanitize_default(opt_name,
opt_default)))
elif opt_type == BOOLOPT:
assert(isinstance(opt_default, bool))
print('#%s=%s' % (opt_name, str(opt_default).lower()))
elif opt_type == INTOPT:
assert(isinstance(opt_default, int) and
not isinstance(opt_default, bool))
print('#%s=%s' % (opt_name, opt_default))
elif opt_type == FLOATOPT:
assert(isinstance(opt_default, float))
print('#%s=%s' % (opt_name, opt_default))
elif opt_type == LISTOPT:
assert(isinstance(opt_default, list))
print('#%s=%s' % (opt_name, ','.join(opt_default)))
elif opt_type == DICTOPT:
assert(isinstance(opt_default, dict))
opt_default_strlist = [str(key) + ':' + str(value)
for (key, value) in opt_default.items()]
print('#%s=%s' % (opt_name, ','.join(opt_default_strlist)))
elif opt_type == MULTISTROPT:
assert(isinstance(opt_default, list))
if not opt_default:
opt_default = ['']
for default in opt_default:
print('#%s=%s' % (opt_name, default))
def main():
generate(sys.argv[1:])
if __name__ == '__main__':
main()

View File

@ -0,0 +1,67 @@
# -*- encoding: utf-8 -*-
#
# 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_context import context
class RequestContext(context.RequestContext):
"""Extends security contexts from the OpenStack common 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:
: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.
"""
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
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_dict(self):
return {'auth_token': self.auth_token,
'user': self.user,
'tenant': self.tenant,
'is_admin': self.is_admin,
'read_only': self.read_only,
'show_deleted': self.show_deleted,
'request_id': self.request_id,
'domain_id': self.domain_id,
'roles': self.roles,
'domain_name': self.domain_name,
'show_password': self.show_password,
'is_public_api': self.is_public_api}
@classmethod
def from_dict(cls, values):
values.pop('user', None)
values.pop('tenant', None)
return cls(**values)

View File

@ -0,0 +1,100 @@
# Copyright 2014 Rackspace, Inc.
#
# 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_concurrency import lockutils
from oslo_config import cfg
import stevedore
from iotronic.common import exception
dhcp_provider_opts = [
cfg.StrOpt('dhcp_provider',
default='neutron',
help='DHCP provider to use. "neutron" uses Neutron, and '
'"none" uses a no-op provider.'
),
]
CONF = cfg.CONF
CONF.register_opts(dhcp_provider_opts, group='dhcp')
_dhcp_provider = None
EM_SEMAPHORE = 'dhcp_provider'
class DHCPFactory(object):
# NOTE(lucasagomes): Instantiate a stevedore.driver.DriverManager
# only once, the first time DHCPFactory.__init__
# is called.
_dhcp_provider = None
def __init__(self, **kwargs):
if not DHCPFactory._dhcp_provider:
DHCPFactory._set_dhcp_provider(**kwargs)
# NOTE(lucasagomes): Use lockutils to avoid a potential race in eventlet
# that might try to create two dhcp factories.
@classmethod
@lockutils.synchronized(EM_SEMAPHORE, 'iotronic-')
def _set_dhcp_provider(cls, **kwargs):
"""Initialize the dhcp provider
:raises: DHCPLoadError if the dhcp_provider cannot be loaded.
"""
# NOTE(lucasagomes): In case multiple greenthreads queue up on
# this lock before _dhcp_provider is initialized,
# prevent creation of multiple DriverManager.
if cls._dhcp_provider:
return
dhcp_provider_name = CONF.dhcp.dhcp_provider
try:
_extension_manager = stevedore.driver.DriverManager(
'iotronic.dhcp',
dhcp_provider_name,
invoke_kwds=kwargs,
invoke_on_load=True)
except Exception as e:
raise exception.DHCPLoadError(
dhcp_provider_name=dhcp_provider_name, reason=e
)
cls._dhcp_provider = _extension_manager.driver
def update_dhcp(self, task, dhcp_opts, ports=None):
"""Send or update the DHCP BOOT options for this node.
:param task: A TaskManager instance.
:param dhcp_opts: this will be a list of dicts, e.g.
::
[{'opt_name': 'bootfile-name',
'opt_value': 'pxelinux.0'},
{'opt_name': 'server-ip-address',
'opt_value': '123.123.123.456'},
{'opt_name': 'tftp-server',
'opt_value': '123.123.123.123'}]
:param ports: a list of Neutron port dicts to update DHCP options on.
If None, will get the list of ports from the Iotronic port objects.
"""
self.provider.update_dhcp_opts(task, dhcp_opts, ports)
@property
def provider(self):
return self._dhcp_provider

View File

@ -0,0 +1,211 @@
# Copyright 2014 Red Hat, Inc.
# 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.
import re
from oslo_concurrency import processutils
from oslo_config import cfg
from oslo_log import log as logging
from iotronic.common import exception
from iotronic.common.i18n import _
from iotronic.common.i18n import _LW
from iotronic.common import utils
from iotronic.openstack.common import loopingcall
opts = [
cfg.IntOpt('check_device_interval',
default=1,
help='After Iotronic has completed creating the partition table, '
'it continues to check for activity on the attached iSCSI '
'device status at this interval prior to copying the image'
' to the node, in seconds'),
cfg.IntOpt('check_device_max_retries',
default=20,
help='The maximum number of times to check that the device is '
'not accessed by another process. If the device is still '
'busy after that, the disk partitioning will be treated as'
' having failed.'),
]
CONF = cfg.CONF
opt_group = cfg.OptGroup(name='disk_partitioner',
title='Options for the disk partitioner')
CONF.register_group(opt_group)
CONF.register_opts(opts, opt_group)
LOG = logging.getLogger(__name__)
class DiskPartitioner(object):
def __init__(self, device, disk_label='msdos', alignment='optimal'):
"""A convenient wrapper around the parted tool.
:param device: The device path.
:param disk_label: The type of the partition table. Valid types are:
"bsd", "dvh", "gpt", "loop", "mac", "msdos",
"pc98", or "sun".
:param alignment: Set alignment for newly created partitions.
Valid types are: none, cylinder, minimal and
optimal.
"""
self._device = device
self._disk_label = disk_label
self._alignment = alignment
self._partitions = []
self._fuser_pids_re = re.compile(r'((\d)+\s*)+')
def _exec(self, *args):
# NOTE(lucasagomes): utils.execute() is already a wrapper on top
# of processutils.execute() which raises specific
# exceptions. It also logs any failure so we don't
# need to log it again here.
utils.execute('parted', '-a', self._alignment, '-s', self._device,
'--', 'unit', 'MiB', *args, check_exit_code=[0],
run_as_root=True)
def add_partition(self, size, part_type='primary', fs_type='',
bootable=False):
"""Add a partition.
:param size: The size of the partition in MiB.
:param part_type: The type of the partition. Valid values are:
primary, logical, or extended.
:param fs_type: The filesystem type. Valid types are: ext2, fat32,
fat16, HFS, linux-swap, NTFS, reiserfs, ufs.
If blank (''), it will create a Linux native
partition (83).
:param bootable: Boolean value; whether the partition is bootable
or not.
:returns: The partition number.
"""
self._partitions.append({'size': size,
'type': part_type,
'fs_type': fs_type,
'bootable': bootable})
return len(self._partitions)
def get_partitions(self):
"""Get the partitioning layout.
:returns: An iterator with the partition number and the
partition layout.
"""
return enumerate(self._partitions, 1)
def _wait_for_disk_to_become_available(self, retries, max_retries, pids,
stderr):
retries[0] += 1
if retries[0] > max_retries:
raise loopingcall.LoopingCallDone()
try:
# NOTE(ifarkas): fuser returns a non-zero return code if none of
# the specified files is accessed
out, err = utils.execute('fuser', self._device,
check_exit_code=[0, 1], run_as_root=True)
if not out and not err:
raise loopingcall.LoopingCallDone()
else:
if err:
stderr[0] = err
if out:
pids_match = re.search(self._fuser_pids_re, out)
pids[0] = pids_match.group()
except processutils.ProcessExecutionError as exc:
LOG.warning(_LW('Failed to check the device %(device)s with fuser:'
' %(err)s'), {'device': self._device, 'err': exc})
def commit(self):
"""Write to the disk."""
LOG.debug("Committing partitions to disk.")
cmd_args = ['mklabel', self._disk_label]
# NOTE(lucasagomes): Lead in with 1MiB to allow room for the
# partition table itself.
start = 1
for num, part in self.get_partitions():
end = start + part['size']
cmd_args.extend(['mkpart', part['type'], part['fs_type'],
str(start), str(end)])
if part['bootable']:
cmd_args.extend(['set', str(num), 'boot', 'on'])
start = end
self._exec(*cmd_args)
retries = [0]
pids = ['']
fuser_err = ['']
interval = CONF.disk_partitioner.check_device_interval
max_retries = CONF.disk_partitioner.check_device_max_retries
timer = loopingcall.FixedIntervalLoopingCall(
self._wait_for_disk_to_become_available,
retries, max_retries, pids, fuser_err)
timer.start(interval=interval).wait()
if retries[0] > max_retries:
if pids[0]:
raise exception.InstanceDeployFailure(
_('Disk partitioning failed on device %(device)s. '
'Processes with the following PIDs are holding it: '
'%(pids)s. Time out waiting for completion.')
% {'device': self._device, 'pids': pids[0]})
else:
raise exception.InstanceDeployFailure(
_('Disk partitioning failed on device %(device)s. Fuser '
'exited with "%(fuser_err)s". Time out waiting for '
'completion.')
% {'device': self._device, 'fuser_err': fuser_err[0]})
_PARTED_PRINT_RE = re.compile(r"^(\d+):([\d\.]+)MiB:"
"([\d\.]+)MiB:([\d\.]+)MiB:(\w*)::(\w*)")
def list_partitions(device):
"""Get partitions information from given device.
:param device: The device path.
:returns: list of dictionaries (one per partition) with keys:
number, start, end, size (in MiB), filesystem, flags
"""
output = utils.execute(
'parted', '-s', '-m', device, 'unit', 'MiB', 'print',
use_standard_locale=True, run_as_root=True)[0]
if isinstance(output, bytes):
output = output.decode("utf-8")
lines = [line for line in output.split('\n') if line.strip()][2:]
# Example of line: 1:1.00MiB:501MiB:500MiB:ext4::boot
fields = ('number', 'start', 'end', 'size', 'filesystem', 'flags')
result = []
for line in lines:
match = _PARTED_PRINT_RE.match(line)
if match is None:
LOG.warn(_LW("Partition information from parted for device "
"%(device)s does not match "
"expected format: %(line)s"),
dict(device=device, line=line))
continue
# Cast int fields to ints (some are floats and we round them down)
groups = [int(float(x)) if i < 4 else x
for i, x in enumerate(match.groups())]
result.append(dict(zip(fields, groups)))
return result

View File

@ -0,0 +1,144 @@
# Copyright 2013 Red Hat, Inc.
# 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_concurrency import lockutils
from oslo_config import cfg
from oslo_log import log
from stevedore import dispatch
from iotronic.common import exception
from iotronic.common.i18n import _LI
LOG = log.getLogger(__name__)
driver_opts = [
cfg.ListOpt('enabled_drivers',
default=['pxe_ipmitool'],
help='Specify the list of drivers to load during service '
'initialization. Missing drivers, or drivers which '
'fail to initialize, will prevent the conductor '
'service from starting. The option default is a '
'recommended set of production-oriented drivers. A '
'complete list of drivers present on your system may '
'be found by enumerating the "iotronic.drivers" '
'entrypoint. An example may be found in the '
'developer documentation online.'),
]
CONF = cfg.CONF
CONF.register_opts(driver_opts)
EM_SEMAPHORE = 'extension_manager'
def get_driver(driver_name):
"""Simple method to get a ref to an instance of a driver.
Driver loading is handled by the DriverFactory class. This method
conveniently wraps that class and returns the actual driver object.
:param driver_name: the name of the driver class to load
:returns: An instance of a class which implements
iotronic.drivers.base.BaseDriver
:raises: DriverNotFound if the requested driver_name could not be
found in the "iotronic.drivers" namespace.
"""
try:
factory = DriverFactory()
return factory[driver_name].obj
except KeyError:
raise exception.DriverNotFound(driver_name=driver_name)
def drivers():
"""Get all drivers as a dict name -> driver object."""
factory = DriverFactory()
return {name: factory[name].obj for name in factory.names}
class DriverFactory(object):
"""Discover, load and manage the drivers available."""
# NOTE(deva): loading the _extension_manager as a class member will break
# stevedore when it loads a driver, because the driver will
# import this file (and thus instantiate another factory).
# Instead, we instantiate a NameDispatchExtensionManager only
# once, the first time DriverFactory.__init__ is called.
_extension_manager = None
def __init__(self):
if not DriverFactory._extension_manager:
DriverFactory._init_extension_manager()
def __getitem__(self, name):
return self._extension_manager[name]
# NOTE(deva): Use lockutils to avoid a potential race in eventlet
# that might try to create two driver factories.
@classmethod
@lockutils.synchronized(EM_SEMAPHORE, 'iotronic-')
def _init_extension_manager(cls):
# NOTE(deva): In case multiple greenthreads queue up on this lock
# before _extension_manager is initialized, prevent
# creation of multiple NameDispatchExtensionManagers.
if cls._extension_manager:
return
# NOTE(deva): Drivers raise "DriverLoadError" if they are unable to be
# loaded, eg. due to missing external dependencies.
# We capture that exception, and, only if it is for an
# enabled driver, raise it from here. If enabled driver
# raises other exception type, it is wrapped in
# "DriverLoadError", providing the name of the driver that
# caused it, and raised. If the exception is for a
# non-enabled driver, we suppress it.
def _catch_driver_not_found(mgr, ep, exc):
# NOTE(deva): stevedore loads plugins *before* evaluating
# _check_func, so we need to check here, too.
if ep.name in CONF.enabled_drivers:
if not isinstance(exc, exception.DriverLoadError):
raise exception.DriverLoadError(driver=ep.name, reason=exc)
raise exc
def _check_func(ext):
return ext.name in CONF.enabled_drivers
cls._extension_manager = (
dispatch.NameDispatchExtensionManager(
'iotronic.drivers',
_check_func,
invoke_on_load=True,
on_load_failure_callback=_catch_driver_not_found))
# NOTE(deva): if we were unable to load any configured driver, perhaps
# because it is not present on the system, raise an error.
if (sorted(CONF.enabled_drivers) !=
sorted(cls._extension_manager.names())):
found = cls._extension_manager.names()
names = [n for n in CONF.enabled_drivers if n not in found]
# just in case more than one could not be found ...
names = ', '.join(names)
raise exception.DriverNotFound(driver_name=names)
LOG.info(_LI("Loaded the following drivers: %s"),
cls._extension_manager.names())
@property
def names(self):
"""The list of driver names available."""
return self._extension_manager.names()

View File

@ -0,0 +1,589 @@
# Copyright 2010 United States Government as represented by the
# Administrator of the National Aeronautics and Space Administration.
# 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.
"""Iotronic base exception handling.
Includes decorator for re-raising Iotronic-type exceptions.
SHOULD include dedicated exception logging.
"""
from oslo_config import cfg
from oslo_log import log as logging
import six
from iotronic.common.i18n import _
from iotronic.common.i18n import _LE
LOG = logging.getLogger(__name__)
exc_log_opts = [
cfg.BoolOpt('fatal_exception_format_errors',
default=False,
help='Used if there is a formatting error when generating an '
'exception message (a programming error). If True, '
'raise an exception; if False, use the unformatted '
'message.'),
]
CONF = cfg.CONF
CONF.register_opts(exc_log_opts)
def _cleanse_dict(original):
"""Strip all admin_password, new_pass, rescue_pass keys from a dict."""
return dict((k, v) for k, v in original.iteritems() if "_pass" not in k)
class IotronicException(Exception):
"""Base Iotronic Exception
To correctly use this class, inherit from it and define
a 'message' property. That message will get printf'd
with the keyword arguments provided to the constructor.
"""
message = _("An unknown exception occurred.")
code = 500
headers = {}
safe = False
def __init__(self, message=None, **kwargs):
self.kwargs = kwargs
if 'code' not in self.kwargs:
try:
self.kwargs['code'] = self.code
except AttributeError:
pass
if not message:
try:
message = self.message % kwargs
except Exception as e:
# kwargs doesn't match a variable in the message
# log the issue and the kwargs
LOG.exception(_LE('Exception in string format operation'))
for name, value in kwargs.items():
LOG.error("%s: %s" % (name, value))
if CONF.fatal_exception_format_errors:
raise e
else:
# at least get the core message out if something happened
message = self.message
super(IotronicException, self).__init__(message)
def __str__(self):
"""Encode to utf-8 then wsme api can consume it as well."""
if not six.PY3:
return unicode(self.args[0]).encode('utf-8')
return self.args[0]
def format_message(self):
if self.__class__.__name__.endswith('_Remote'):
return self.args[0]
else:
return six.text_type(self)
class NotAuthorized(IotronicException):
message = _("Not authorized.")
code = 403
class OperationNotPermitted(NotAuthorized):
message = _("Operation not permitted.")
class Invalid(IotronicException):
message = _("Unacceptable parameters.")
code = 400
class Conflict(IotronicException):
message = _('Conflict.')
code = 409
class TemporaryFailure(IotronicException):
message = _("Resource temporarily unavailable, please retry.")
code = 503
class NotAcceptable(IotronicException):
# TODO(deva): We need to set response headers in the API for this exception
message = _("Request not acceptable.")
code = 406
class InvalidState(Conflict):
message = _("Invalid resource state.")
class NodeAlreadyExists(Conflict):
message = _("A node with UUID %(uuid)s already exists.")
class MACAlreadyExists(Conflict):
message = _("A port with MAC address %(mac)s already exists.")
class ChassisAlreadyExists(Conflict):
message = _("A chassis with UUID %(uuid)s already exists.")
class PortAlreadyExists(Conflict):
message = _("A port with UUID %(uuid)s already exists.")
class InstanceAssociated(Conflict):
message = _("Instance %(instance_uuid)s is already associated with a node,"
" it cannot be associated with this other node %(node)s")
class DuplicateName(Conflict):
message = _("A node with name %(name)s already exists.")
class InvalidUUID(Invalid):
message = _("Expected a uuid but received %(uuid)s.")
class InvalidUuidOrName(Invalid):
message = _("Expected a logical name or uuid but received %(name)s.")
class InvalidName(Invalid):
message = _("Expected a logical name but received %(name)s.")
class InvalidIdentity(Invalid):
message = _("Expected an uuid or int but received %(identity)s.")
class InvalidMAC(Invalid):
message = _("Expected a MAC address but received %(mac)s.")
class InvalidStateRequested(Invalid):
message = _('The requested action "%(action)s" can not be performed '
'on node "%(node)s" while it is in state "%(state)s".')
class PatchError(Invalid):
message = _("Couldn't apply patch '%(patch)s'. Reason: %(reason)s")
class InstanceDeployFailure(IotronicException):
message = _("Failed to deploy instance: %(reason)s")
class ImageUnacceptable(IotronicException):
message = _("Image %(image_id)s is unacceptable: %(reason)s")
class ImageConvertFailed(IotronicException):
message = _("Image %(image_id)s is unacceptable: %(reason)s")
# Cannot be templated as the error syntax varies.
# msg needs to be constructed when raised.
class InvalidParameterValue(Invalid):
message = _("%(err)s")
class MissingParameterValue(InvalidParameterValue):
message = _("%(err)s")
class Duplicate(IotronicException):
message = _("Resource already exists.")
class NotFound(IotronicException):
message = _("Resource could not be found.")
code = 404
class DHCPLoadError(IotronicException):
message = _("Failed to load DHCP provider %(dhcp_provider_name)s, "
"reason: %(reason)s")
class DriverNotFound(NotFound):
message = _("Could not find the following driver(s): %(driver_name)s.")
class ImageNotFound(NotFound):
message = _("Image %(image_id)s could not be found.")
class NoValidHost(NotFound):
message = _("No valid host was found. Reason: %(reason)s")
class InstanceNotFound(NotFound):
message = _("Instance %(instance)s could not be found.")
class NodeNotFound(NotFound):
message = _("Node %(node)s could not be found.")
class NodeAssociated(InvalidState):
message = _("Node %(node)s is associated with instance %(instance)s.")
class PortNotFound(NotFound):
message = _("Port %(port)s could not be found.")
class FailedToUpdateDHCPOptOnPort(IotronicException):
message = _("Update DHCP options on port: %(port_id)s failed.")
class FailedToGetIPAddressOnPort(IotronicException):
message = _("Retrieve IP address on port: %(port_id)s failed.")
class InvalidIPv4Address(IotronicException):
message = _("Invalid IPv4 address %(ip_address)s.")
class FailedToUpdateMacOnPort(IotronicException):
message = _("Update MAC address on port: %(port_id)s failed.")
class ChassisNotFound(NotFound):
message = _("Chassis %(chassis)s could not be found.")
class NoDriversLoaded(IotronicException):
message = _("Conductor %(conductor)s cannot be started "
"because no drivers were loaded.")
class ConductorNotFound(NotFound):
message = _("Conductor %(conductor)s could not be found.")
class ConductorAlreadyRegistered(IotronicException):
message = _("Conductor %(conductor)s already registered.")
class PowerStateFailure(InvalidState):
message = _("Failed to set node power state to %(pstate)s.")
class ExclusiveLockRequired(NotAuthorized):
message = _("An exclusive lock is required, "
"but the current context has a shared lock.")
class NodeMaintenanceFailure(Invalid):
message = _("Failed to toggle maintenance-mode flag "
"for node %(node)s: %(reason)s")
class NodeConsoleNotEnabled(Invalid):
message = _("Console access is not enabled on node %(node)s")
class NodeInMaintenance(Invalid):
message = _("The %(op)s operation can't be performed on node "
"%(node)s because it's in maintenance mode.")
class ChassisNotEmpty(Invalid):
message = _("Cannot complete the requested action because chassis "
"%(chassis)s contains nodes.")
class IPMIFailure(IotronicException):
message = _("IPMI call failed: %(cmd)s.")
class AMTConnectFailure(IotronicException):
message = _("Failed to connect to AMT service.")
class AMTFailure(IotronicException):
message = _("AMT call failed: %(cmd)s.")
class MSFTOCSClientApiException(IotronicException):
message = _("MSFT OCS call failed.")
class SSHConnectFailed(IotronicException):
message = _("Failed to establish SSH connection to host %(host)s.")
class SSHCommandFailed(IotronicException):
message = _("Failed to execute command via SSH: %(cmd)s.")
class UnsupportedObjectError(IotronicException):
message = _('Unsupported object type %(objtype)s')
class OrphanedObjectError(IotronicException):
message = _('Cannot call %(method)s on orphaned %(objtype)s object')
class UnsupportedDriverExtension(Invalid):
message = _('Driver %(driver)s does not support %(extension)s '
'(disabled or not implemented).')
class IncompatibleObjectVersion(IotronicException):
message = _('Version %(objver)s of %(objname)s is not supported')
class GlanceConnectionFailed(IotronicException):
message = _("Connection to glance host %(host)s:%(port)s failed: "
"%(reason)s")
class ImageNotAuthorized(NotAuthorized):
message = _("Not authorized for image %(image_id)s.")
class InvalidImageRef(Invalid):
message = _("Invalid image href %(image_href)s.")
class ImageRefValidationFailed(IotronicException):
message = _("Validation of image href %(image_href)s failed, "
"reason: %(reason)s")
class ImageDownloadFailed(IotronicException):
message = _("Failed to download image %(image_href)s, reason: %(reason)s")
class KeystoneUnauthorized(IotronicException):
message = _("Not authorized in Keystone.")
class KeystoneFailure(IotronicException):
pass
class CatalogNotFound(IotronicException):
message = _("Service type %(service_type)s with endpoint type "
"%(endpoint_type)s not found in keystone service catalog.")
class ServiceUnavailable(IotronicException):
message = _("Connection failed")
class Forbidden(IotronicException):
message = _("Requested OpenStack Images API is forbidden")
class BadRequest(IotronicException):
pass
class InvalidEndpoint(IotronicException):
message = _("The provided endpoint is invalid")
class CommunicationError(IotronicException):
message = _("Unable to communicate with the server.")
class HTTPForbidden(Forbidden):
pass
class Unauthorized(IotronicException):
pass
class HTTPNotFound(NotFound):
pass
class ConfigNotFound(IotronicException):
message = _("Could not find config at %(path)s")
class NodeLocked(Conflict):
message = _("Node %(node)s is locked by host %(host)s, please retry "
"after the current operation is completed.")
class NodeNotLocked(Invalid):
message = _("Node %(node)s found not to be locked on release")
class NoFreeConductorWorker(TemporaryFailure):
message = _('Requested action cannot be performed due to lack of free '
'conductor workers.')
code = 503 # Service Unavailable (temporary).
class VendorPassthruException(IotronicException):
pass
class ConfigInvalid(IotronicException):
message = _("Invalid configuration file. %(error_msg)s")
class DriverLoadError(IotronicException):
message = _("Driver %(driver)s could not be loaded. Reason: %(reason)s.")
class ConsoleError(IotronicException):
pass
class NoConsolePid(ConsoleError):
message = _("Could not find pid in pid file %(pid_path)s")
class ConsoleSubprocessFailed(ConsoleError):
message = _("Console subprocess failed to start. %(error)s")
class PasswordFileFailedToCreate(IotronicException):
message = _("Failed to create the password file. %(error)s")
class IBootOperationError(IotronicException):
pass
class IloOperationError(IotronicException):
message = _("%(operation)s failed, error: %(error)s")
class IloOperationNotSupported(IotronicException):
message = _("%(operation)s not supported. error: %(error)s")
class DracRequestFailed(IotronicException):
pass
class DracClientError(DracRequestFailed):
message = _('DRAC client failed. '
'Last error (cURL error code): %(last_error)s, '
'fault string: "%(fault_string)s" '
'response_code: %(response_code)s')
class DracOperationFailed(DracRequestFailed):
message = _('DRAC operation failed. Message: %(message)s')
class DracUnexpectedReturnValue(DracRequestFailed):
message = _('DRAC operation yielded return value %(actual_return_value)s '
'that is neither error nor expected %(expected_return_value)s')
class DracPendingConfigJobExists(IotronicException):
message = _('Another job with ID %(job_id)s is already created '
'to configure %(target)s. Wait until existing job '
'is completed or is canceled')
class DracInvalidFilterDialect(IotronicException):
message = _('Invalid filter dialect \'%(invalid_filter)s\'. '
'Supported options are %(supported)s')
class FailedToGetSensorData(IotronicException):
message = _("Failed to get sensor data for node %(node)s. "
"Error: %(error)s")
class FailedToParseSensorData(IotronicException):
message = _("Failed to parse sensor data for node %(node)s. "
"Error: %(error)s")
class InsufficientDiskSpace(IotronicException):
message = _("Disk volume where '%(path)s' is located doesn't have "
"enough disk space. Required %(required)d MiB, "
"only %(actual)d MiB available space present.")
class ImageCreationFailed(IotronicException):
message = _('Creating %(image_type)s image failed: %(error)s')
class SwiftOperationError(IotronicException):
message = _("Swift operation '%(operation)s' failed: %(error)s")
class SNMPFailure(IotronicException):
message = _("SNMP operation '%(operation)s' failed: %(error)s")
class FileSystemNotSupported(IotronicException):
message = _("Failed to create a file system. "
"File system %(fs)s is not supported.")
class IRMCOperationError(IotronicException):
message = _('iRMC %(operation)s failed. Reason: %(error)s')
class VirtualBoxOperationFailed(IotronicException):
message = _("VirtualBox operation '%(operation)s' failed. "
"Error: %(error)s")
class HardwareInspectionFailure(IotronicException):
message = _("Failed to inspect hardware. Reason: %(error)s")
class NodeCleaningFailure(IotronicException):
message = _("Failed to clean node %(node)s: %(reason)s")
class PathNotFound(IotronicException):
message = _("Path %(dir)s does not exist.")
class DirectoryNotWritable(IotronicException):
message = _("Directory %(dir)s is not writable.")
#################### new
class BoardNotFound(NotFound):
message = _("Board %(board)s could not be found.")
class BoardLocked(Conflict):
message = _("Board %(board)s is locked by host %(host)s, please retry "
"after the current operation is completed.")
class BoardAssociated(InvalidState):
message = _("Board %(board)s is associated with instance %(instance)s.")

239
iotronic/common/fsm.py Normal file
View File

@ -0,0 +1,239 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2014 Yahoo! Inc. 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.
"""State machine modelling, copied from TaskFlow project.
This work will be turned into a library.
See https://github.com/harlowja/automaton
This is being used in the implementation of:
http://specs.openstack.org/openstack/iotronic-specs/specs/kilo/new-iotronic-state-machine.html
"""
from collections import OrderedDict # noqa
import six
from iotronic.common import exception as excp
from iotronic.common.i18n import _
class _Jump(object):
"""A FSM transition tracks this data while jumping."""
def __init__(self, name, on_enter, on_exit):
self.name = name
self.on_enter = on_enter
self.on_exit = on_exit
class FSM(object):
"""A finite state machine.
This class models a state machine, and expects an outside caller to
manually trigger the state changes one at a time by invoking process_event
"""
def __init__(self, start_state=None):
self._transitions = {}
self._states = OrderedDict()
self._start_state = start_state
self._target_state = None
# Note that _current is a _Jump instance
self._current = None
@property
def start_state(self):
return self._start_state
@property
def current_state(self):
if self._current is not None:
return self._current.name
return None
@property
def target_state(self):
return self._target_state
@property
def terminated(self):
"""Returns whether the state machine is in a terminal state."""
if self._current is None:
return False
return self._states[self._current.name]['terminal']
def add_state(self, state, on_enter=None, on_exit=None,
target=None, terminal=None, stable=False):
"""Adds a given state to the state machine.
The on_enter and on_exit callbacks, if provided will be expected to
take two positional parameters, these being the state being exited (for
on_exit) or the state being entered (for on_enter) and a second
parameter which is the event that is being processed that caused the
state transition.
:param stable: Use this to specify that this state is a stable/passive
state. A state must have been previously defined as
'stable' before it can be used as a 'target'
:param target: The target state for 'state' to go to. Before a state
can be used as a target it must have been previously
added and specified as 'stable'
"""
if state in self._states:
raise excp.Duplicate(_("State '%s' already defined") % state)
if on_enter is not None:
if not six.callable(on_enter):
raise ValueError(_("On enter callback must be callable"))
if on_exit is not None:
if not six.callable(on_exit):
raise ValueError(_("On exit callback must be callable"))
if target is not None and target not in self._states:
raise excp.InvalidState(_("Target state '%s' does not exist")
% target)
if target is not None and not self._states[target]['stable']:
raise excp.InvalidState(
_("Target state '%s' is not a 'stable' state") % target)
self._states[state] = {
'terminal': bool(terminal),
'reactions': {},
'on_enter': on_enter,
'on_exit': on_exit,
'target': target,
'stable': stable,
}
self._transitions[state] = OrderedDict()
def add_transition(self, start, end, event):
"""Adds an allowed transition from start -> end for the given event."""
if start not in self._states:
raise excp.NotFound(
_("Can not add a transition on event '%(event)s' that "
"starts in a undefined state '%(state)s'")
% {'event': event, 'state': start})
if end not in self._states:
raise excp.NotFound(
_("Can not add a transition on event '%(event)s' that "
"ends in a undefined state '%(state)s'")
% {'event': event, 'state': end})
self._transitions[start][event] = _Jump(end,
self._states[end]['on_enter'],
self._states[start]['on_exit'])
def process_event(self, event):
"""Trigger a state change in response to the provided event."""
current = self._current
if current is None:
raise excp.InvalidState(_("Can only process events after"
" being initialized (not before)"))
if self._states[current.name]['terminal']:
raise excp.InvalidState(
_("Can not transition from terminal "
"state '%(state)s' on event '%(event)s'")
% {'state': current.name, 'event': event})
if event not in self._transitions[current.name]:
raise excp.InvalidState(
_("Can not transition from state '%(state)s' on "
"event '%(event)s' (no defined transition)")
% {'state': current.name, 'event': event})
replacement = self._transitions[current.name][event]
if current.on_exit is not None:
current.on_exit(current.name, event)
if replacement.on_enter is not None:
replacement.on_enter(replacement.name, event)
self._current = replacement
# clear _target if we've reached it
if (self._target_state is not None and
self._target_state == replacement.name):
self._target_state = None
# if new state has a different target, update the target
if self._states[replacement.name]['target'] is not None:
self._target_state = self._states[replacement.name]['target']
def is_valid_event(self, event):
"""Check whether the event is actionable in the current state."""
current = self._current
if current is None:
return False
if self._states[current.name]['terminal']:
return False
if event not in self._transitions[current.name]:
return False
return True
def initialize(self, state=None):
"""Sets up the state machine.
sets the current state to the specified state, or start_state
if no state was specified..
"""
if state is None:
state = self._start_state
if state not in self._states:
raise excp.NotFound(_("Can not start from an undefined"
" state '%s'") % (state))
if self._states[state]['terminal']:
raise excp.InvalidState(_("Can not start from a terminal"
" state '%s'") % (state))
self._current = _Jump(state, None, None)
self._target_state = self._states[state]['target']
def copy(self, shallow=False):
"""Copies the current state machine (shallow or deep).
NOTE(harlowja): the copy will be left in an *uninitialized* state.
NOTE(harlowja): when a shallow copy is requested the copy will share
the same transition table and state table as the
source; this can be advantageous if you have a machine
and transitions + states that is defined somewhere
and want to use copies to run with (the copies have
the current state that is different between machines).
"""
c = FSM(self.start_state)
if not shallow:
for state, data in six.iteritems(self._states):
copied_data = data.copy()
copied_data['reactions'] = copied_data['reactions'].copy()
c._states[state] = copied_data
for state, data in six.iteritems(self._transitions):
c._transitions[state] = data.copy()
else:
c._transitions = self._transitions
c._states = self._states
return c
def __contains__(self, state):
"""Returns if this state exists in the machines known states."""
return state in self._states
@property
def states(self):
"""Returns a list of the state names."""
return list(six.iterkeys(self._states))
def __iter__(self):
"""Iterates over (start, event, end) transition tuples."""
for state in six.iterkeys(self._states):
for event, target in six.iteritems(self._transitions[state]):
yield (state, event, target.name)
@property
def events(self):
"""Returns how many events exist."""
c = 0
for state in six.iterkeys(self._states):
c += len(self._transitions[state])
return c

View File

@ -0,0 +1,288 @@
# Copyright 2010 OpenStack Foundation
# 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.
import functools
import logging
import os
import sys
import time
from glanceclient import client
from glanceclient import exc as glance_exc
from oslo_config import cfg
import sendfile
import six
import six.moves.urllib.parse as urlparse
from iotronic.common import exception
from iotronic.common.glance_service import service_utils
from iotronic.common.i18n import _LE
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
def _translate_image_exception(image_id, exc_value):
if isinstance(exc_value, (glance_exc.Forbidden,
glance_exc.Unauthorized)):
return exception.ImageNotAuthorized(image_id=image_id)
if isinstance(exc_value, glance_exc.NotFound):
return exception.ImageNotFound(image_id=image_id)
if isinstance(exc_value, glance_exc.BadRequest):
return exception.Invalid(exc_value)
return exc_value
def _translate_plain_exception(exc_value):
if isinstance(exc_value, (glance_exc.Forbidden,
glance_exc.Unauthorized)):
return exception.NotAuthorized(exc_value)
if isinstance(exc_value, glance_exc.NotFound):
return exception.NotFound(exc_value)
if isinstance(exc_value, glance_exc.BadRequest):
return exception.Invalid(exc_value)
return exc_value
def check_image_service(func):
"""Creates a glance client if doesn't exists and calls the function."""
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
"""Wrapper around methods calls.
:param image_href: href that describes the location of an image
"""
if self.client:
return func(self, *args, **kwargs)
image_href = kwargs.get('image_href')
(image_id, self.glance_host,
self.glance_port, use_ssl) = service_utils.parse_image_ref(image_href)
if use_ssl:
scheme = 'https'
else:
scheme = 'http'
params = {}
params['insecure'] = CONF.glance.glance_api_insecure
if CONF.glance.auth_strategy == 'keystone':
params['token'] = self.context.auth_token
endpoint = '%s://%s:%s' % (scheme, self.glance_host, self.glance_port)
self.client = client.Client(self.version,
endpoint, **params)
return func(self, *args, **kwargs)
return wrapper
class BaseImageService(object):
def __init__(self, client=None, version=1, context=None):
self.client = client
self.version = version
self.context = context
def call(self, method, *args, **kwargs):
"""Call a glance client method.
If we get a connection error,
retry the request according to CONF.glance_num_retries.
:param context: The request context, for access checks.
:param version: The requested API version.v
:param method: The method requested to be called.
:param args: A list of positional arguments for the method called
:param kwargs: A dict of keyword arguments for the method called
:raises: GlanceConnectionFailed
"""
retry_excs = (glance_exc.ServiceUnavailable,
glance_exc.InvalidEndpoint,
glance_exc.CommunicationError)
image_excs = (glance_exc.Forbidden,
glance_exc.Unauthorized,
glance_exc.NotFound,
glance_exc.BadRequest)
num_attempts = 1 + CONF.glance.glance_num_retries
for attempt in range(1, num_attempts + 1):
try:
return getattr(self.client.images, method)(*args, **kwargs)
except retry_excs as e:
host = self.glance_host
port = self.glance_port
error_msg = _LE("Error contacting glance server "
"'%(host)s:%(port)s' for '%(method)s', attempt"
" %(attempt)s of %(num_attempts)s failed.")
LOG.exception(error_msg, {'host': host,
'port': port,
'num_attempts': num_attempts,
'attempt': attempt,
'method': method})
if attempt == num_attempts:
raise exception.GlanceConnectionFailed(host=host,
port=port,
reason=str(e))
time.sleep(1)
except image_excs as e:
exc_type, exc_value, exc_trace = sys.exc_info()
if method == 'list':
new_exc = _translate_plain_exception(
exc_value)
else:
new_exc = _translate_image_exception(
args[0], exc_value)
six.reraise(type(new_exc), new_exc, exc_trace)
@check_image_service
def _detail(self, method='list', **kwargs):
"""Calls out to Glance for a list of detailed image information.
:returns: A list of dicts containing image metadata.
"""
LOG.debug("Getting a full list of images metadata from glance.")
params = service_utils.extract_query_params(kwargs, self.version)
images = self.call(method, **params)
_images = []
for image in images:
if service_utils.is_image_available(self.context, image):
_images.append(service_utils.translate_from_glance(image))
return _images
@check_image_service
def _show(self, image_href, method='get'):
"""Returns a dict with image data for the given opaque image id.
:param image_id: The opaque image identifier.
:returns: A dict containing image metadata.
:raises: ImageNotFound
"""
LOG.debug("Getting image metadata from glance. Image: %s"
% image_href)
(image_id, self.glance_host,
self.glance_port, use_ssl) = service_utils.parse_image_ref(image_href)
image = self.call(method, image_id)
if not service_utils.is_image_available(self.context, image):
raise exception.ImageNotFound(image_id=image_id)
base_image_meta = service_utils.translate_from_glance(image)
return base_image_meta
@check_image_service
def _download(self, image_id, data=None, method='data'):
"""Calls out to Glance for data and writes data.
:param image_id: The opaque image identifier.
:param data: (Optional) File object to write data to.
"""
(image_id, self.glance_host,
self.glance_port, use_ssl) = service_utils.parse_image_ref(image_id)
if (self.version == 2 and
'file' in CONF.glance.allowed_direct_url_schemes):
location = self._get_location(image_id)
url = urlparse.urlparse(location)
if url.scheme == "file":
with open(url.path, "r") as f:
filesize = os.path.getsize(f.name)
sendfile.sendfile(data.fileno(), f.fileno(), 0, filesize)
return
image_chunks = self.call(method, image_id)
if data is None:
return image_chunks
else:
for chunk in image_chunks:
data.write(chunk)
@check_image_service
def _create(self, image_meta, data=None, method='create'):
"""Store the image data and return the new image object.
:param image_meta: A dict containing image metadata
:param data: (Optional) File object to create image from.
:returns: dict -- New created image metadata
"""
sent_service_image_meta = service_utils.translate_to_glance(image_meta)
# TODO(ghe): Allow copy-from or location headers Bug #1199532
if data:
sent_service_image_meta['data'] = data
recv_service_image_meta = self.call(method, **sent_service_image_meta)
return service_utils.translate_from_glance(recv_service_image_meta)
@check_image_service
def _update(self, image_id, image_meta, data=None, method='update',
purge_props=False):
"""Modify the given image with the new data.
:param image_id: The opaque image identifier.
:param data: (Optional) File object to update data from.
:param purge_props: (Optional=False) Purge existing properties.
:returns: dict -- New created image metadata
"""
(image_id, self.glance_host,
self.glance_port, use_ssl) = service_utils.parse_image_ref(image_id)
if image_meta:
image_meta = service_utils.translate_to_glance(image_meta)
else:
image_meta = {}
if self.version == 1:
image_meta['purge_props'] = purge_props
if data:
image_meta['data'] = data
# NOTE(bcwaldon): id is not an editable field, but it is likely to be
# passed in by calling code. Let's be nice and ignore it.
image_meta.pop('id', None)
image_meta = self.call(method, image_id, **image_meta)
if self.version == 2 and data:
self.call('upload', image_id, data)
image_meta = self._show(image_id)
return image_meta
@check_image_service
def _delete(self, image_id, method='delete'):
"""Delete the given image.
:param image_id: The opaque image identifier.
:raises: ImageNotFound if the image does not exist.
:raises: NotAuthorized if the user is not an owner.
:raises: ImageNotAuthorized if the user is not authorized.
"""
(image_id, glance_host,
glance_port, use_ssl) = service_utils.parse_image_ref(image_id)
self.call(method, image_id)

View File

@ -0,0 +1,81 @@
# 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.
import abc
import six
@six.add_metaclass(abc.ABCMeta)
class ImageService(object):
"""Provides storage and retrieval of disk image objects within Glance."""
@abc.abstractmethod
def __init__(self):
"""Constructor."""
@abc.abstractmethod
def detail(self):
"""Calls out to Glance for a list of detailed image information."""
@abc.abstractmethod
def show(self, image_id):
"""Returns a dict with image data for the given opaque image id.
:param image_id: The opaque image identifier.
:returns: A dict containing image metadata.
:raises: ImageNotFound
"""
@abc.abstractmethod
def download(self, image_id, data=None):
"""Calls out to Glance for data and writes data.
:param image_id: The opaque image identifier.
:param data: (Optional) File object to write data to.
"""
@abc.abstractmethod
def create(self, image_meta, data=None):
"""Store the image data and return the new image object.
:param image_meta: A dict containing image metadata
:param data: (Optional) File object to create image from.
:returns: dict -- New created image metadata
"""
@abc.abstractmethod
def update(self, image_id,
image_meta, data=None, purge_props=False):
"""Modify the given image with the new data.
:param image_id: The opaque image identifier.
:param data: (Optional) File object to update data from.
:param purge_props: (Optional=True) Purge existing properties.
:returns: dict -- New created image metadata
"""
@abc.abstractmethod
def delete(self, image_id):
"""Delete the given image.
:param image_id: The opaque image identifier.
:raises: ImageNotFound if the image does not exist.
:raises: NotAuthorized if the user is not an owner.
:raises: ImageNotAuthorized if the user is not authorized.
"""

View File

@ -0,0 +1,247 @@
# Copyright 2012 OpenStack Foundation
# 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.
import copy
import itertools
import logging
import random
from oslo_config import cfg
from oslo_serialization import jsonutils
from oslo_utils import timeutils
from oslo_utils import uuidutils
import six
import six.moves.urllib.parse as urlparse
from iotronic.common import exception
CONF = cfg.CONF
LOG = logging.getLogger(__name__)
_GLANCE_API_SERVER = None
""" iterator that cycles (indefinitely) over glance API servers. """
def generate_glance_url():
"""Generate the URL to glance."""
return "%s://%s:%d" % (CONF.glance.glance_protocol,
CONF.glance.glance_host,
CONF.glance.glance_port)
def generate_image_url(image_ref):
"""Generate an image URL from an image_ref."""
return "%s/images/%s" % (generate_glance_url(), image_ref)
def _extract_attributes(image):
IMAGE_ATTRIBUTES = ['size', 'disk_format', 'owner',
'container_format', 'checksum', 'id',
'name', 'created_at', 'updated_at',
'deleted_at', 'deleted', 'status',
'min_disk', 'min_ram', 'is_public']
IMAGE_ATTRIBUTES_V2 = ['tags', 'visibility', 'protected',
'file', 'schema']
output = {}
for attr in IMAGE_ATTRIBUTES:
output[attr] = getattr(image, attr, None)
output['properties'] = getattr(image, 'properties', {})
if hasattr(image, 'schema') and 'v2' in image['schema']:
IMAGE_ATTRIBUTES = IMAGE_ATTRIBUTES + IMAGE_ATTRIBUTES_V2
for attr in IMAGE_ATTRIBUTES_V2:
output[attr] = getattr(image, attr, None)
output['schema'] = image['schema']
for image_property in set(image.keys()) - set(IMAGE_ATTRIBUTES):
output['properties'][image_property] = image[image_property]
return output
def _convert_timestamps_to_datetimes(image_meta):
"""Returns image with timestamp fields converted to datetime objects."""
for attr in ['created_at', 'updated_at', 'deleted_at']:
if image_meta.get(attr):
image_meta[attr] = timeutils.parse_isotime(image_meta[attr])
return image_meta
_CONVERT_PROPS = ('block_device_mapping', 'mappings')
def _convert(metadata, method):
metadata = copy.deepcopy(metadata)
properties = metadata.get('properties')
if properties:
for attr in _CONVERT_PROPS:
if attr in properties:
prop = properties[attr]
if method == 'from':
if isinstance(prop, six.string_types):
properties[attr] = jsonutils.loads(prop)
if method == 'to':
if not isinstance(prop, six.string_types):
properties[attr] = jsonutils.dumps(prop)
return metadata
def _remove_read_only(image_meta):
IMAGE_ATTRIBUTES = ['status', 'updated_at', 'created_at', 'deleted_at']
output = copy.deepcopy(image_meta)
for attr in IMAGE_ATTRIBUTES:
if attr in output:
del output[attr]
return output
def _get_api_server_iterator():
"""Return iterator over shuffled API servers.
Shuffle a list of CONF.glance.glance_api_servers and return an iterator
that will cycle through the list, looping around to the beginning if
necessary.
If CONF.glance.glance_api_servers isn't set, we fall back to using this
as the server: CONF.glance.glance_host:CONF.glance.glance_port.
:returns: iterator that cycles (indefinitely) over shuffled glance API
servers. The iterator returns tuples of (host, port, use_ssl).
"""
api_servers = []
configured_servers = (CONF.glance.glance_api_servers or
['%s:%s' % (CONF.glance.glance_host,
CONF.glance.glance_port)])
for api_server in configured_servers:
if '//' not in api_server:
api_server = '%s://%s' % (CONF.glance.glance_protocol, api_server)
url = urlparse.urlparse(api_server)
port = url.port or 80
host = url.netloc.split(':', 1)[0]
use_ssl = (url.scheme == 'https')
api_servers.append((host, port, use_ssl))
random.shuffle(api_servers)
return itertools.cycle(api_servers)
def _get_api_server():
"""Return a Glance API server.
:returns: for an API server, the tuple (host-or-IP, port, use_ssl), where
use_ssl is True to use the 'https' scheme, and False to use 'http'.
"""
global _GLANCE_API_SERVER
if not _GLANCE_API_SERVER:
_GLANCE_API_SERVER = _get_api_server_iterator()
return six.next(_GLANCE_API_SERVER)
def parse_image_ref(image_href):
"""Parse an image href into composite parts.
:param image_href: href of an image
:returns: a tuple of the form (image_id, host, port, use_ssl)
:raises ValueError
"""
if '/' not in str(image_href):
image_id = image_href
(glance_host, glance_port, use_ssl) = _get_api_server()
return (image_id, glance_host, glance_port, use_ssl)
else:
try:
url = urlparse.urlparse(image_href)
if url.scheme == 'glance':
(glance_host, glance_port, use_ssl) = _get_api_server()
image_id = image_href.split('/')[-1]
else:
glance_port = url.port or 80
glance_host = url.netloc.split(':', 1)[0]
image_id = url.path.split('/')[-1]
use_ssl = (url.scheme == 'https')
return (image_id, glance_host, glance_port, use_ssl)
except ValueError:
raise exception.InvalidImageRef(image_href=image_href)
def extract_query_params(params, version):
_params = {}
accepted_params = ('filters', 'marker', 'limit',
'sort_key', 'sort_dir')
for param in accepted_params:
if params.get(param):
_params[param] = params.get(param)
# ensure filters is a dict
_params.setdefault('filters', {})
# NOTE(vish): don't filter out private images
# NOTE(ghe): in v2, not passing any visibility doesn't filter prvate images
if version == 1:
_params['filters'].setdefault('is_public', 'none')
return _params
def translate_to_glance(image_meta):
image_meta = _convert(image_meta, 'to')
image_meta = _remove_read_only(image_meta)
return image_meta
def translate_from_glance(image):
image_meta = _extract_attributes(image)
image_meta = _convert_timestamps_to_datetimes(image_meta)
image_meta = _convert(image_meta, 'from')
return image_meta
def is_image_available(context, image):
"""Check image availability.
This check is needed in case Nova and Glance are deployed
without authentication turned on.
"""
# The presence of an auth token implies this is an authenticated
# request and we need not handle the noauth use-case.
if hasattr(context, 'auth_token') and context.auth_token:
return True
if image.is_public or context.is_admin:
return True
properties = image.properties
if context.project_id and ('owner_id' in properties):
return str(properties['owner_id']) == str(context.project_id)
if context.project_id and ('project_id' in properties):
return str(properties['project_id']) == str(context.project_id)
try:
user_id = properties['user_id']
except KeyError:
return False
return str(user_id) == str(context.user_id)
def is_glance_image(image_href):
if not isinstance(image_href, six.string_types):
return False
return (image_href.startswith('glance://') or
uuidutils.is_uuid_like(image_href))

View File

@ -0,0 +1,41 @@
# 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 iotronic.common.glance_service import base_image_service
from iotronic.common.glance_service import service
class GlanceImageService(base_image_service.BaseImageService,
service.ImageService):
def detail(self, **kwargs):
return self._detail(method='list', **kwargs)
def show(self, image_id):
return self._show(image_id, method='get')
def download(self, image_id, data=None):
return self._download(image_id, method='data', data=data)
def create(self, image_meta, data=None):
return self._create(image_meta, method='create', data=data)
def update(self, image_id, image_meta, data=None, purge_props=False):
return self._update(image_id, image_meta, data=data, method='update',
purge_props=purge_props)
def delete(self, image_id):
return self._delete(image_id, method='delete')

View File

@ -0,0 +1,231 @@
# 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
from oslo_utils import uuidutils
from swiftclient import utils as swift_utils
from iotronic.common import exception as exc
from iotronic.common.glance_service import base_image_service
from iotronic.common.glance_service import service
from iotronic.common.glance_service import service_utils
from iotronic.common.i18n import _
glance_opts = [
cfg.ListOpt('allowed_direct_url_schemes',
default=[],
help='A list of URL schemes that can be downloaded directly '
'via the direct_url. Currently supported schemes: '
'[file].'),
# To upload this key to Swift:
# swift post -m Temp-Url-Key:correcthorsebatterystaple
cfg.StrOpt('swift_temp_url_key',
help='The secret token given to Swift to allow temporary URL '
'downloads. Required for temporary URLs.',
secret=True),
cfg.IntOpt('swift_temp_url_duration',
default=1200,
help='The length of time in seconds that the temporary URL '
'will be valid for. Defaults to 20 minutes. If some '
'deploys get a 401 response code when trying to download '
'from the temporary URL, try raising this duration.'),
cfg.StrOpt('swift_endpoint_url',
help='The "endpoint" (scheme, hostname, optional port) for '
'the Swift URL of the form '
'"endpoint_url/api_version/account/container/object_id". '
'Do not include trailing "/". '
'For example, use "https://swift.example.com". '
'Required for temporary URLs.'),
cfg.StrOpt('swift_api_version',
default='v1',
help='The Swift API version to create a temporary URL for. '
'Defaults to "v1". Swift temporary URL format: '
'"endpoint_url/api_version/account/container/object_id"'),
cfg.StrOpt('swift_account',
help='The account that Glance uses to communicate with '
'Swift. The format is "AUTH_uuid". "uuid" is the '
'UUID for the account configured in the glance-api.conf. '
'Required for temporary URLs. For example: '
'"AUTH_a422b2-91f3-2f46-74b7-d7c9e8958f5d30". '
'Swift temporary URL format: '
'"endpoint_url/api_version/account/container/object_id"'),
cfg.StrOpt('swift_container',
default='glance',
help='The Swift container Glance is configured to store its '
'images in. Defaults to "glance", which is the default '
'in glance-api.conf. '
'Swift temporary URL format: '
'"endpoint_url/api_version/account/container/object_id"'),
cfg.IntOpt('swift_store_multiple_containers_seed',
default=0,
help='This should match a config by the same name in the '
'Glance configuration file. When set to 0, a '
'single-tenant store will only use one '
'container to store all images. When set to an integer '
'value between 1 and 32, a single-tenant store will use '
'multiple containers to store images, and this value '
'will determine how many containers are created.'),
]
CONF = cfg.CONF
CONF.register_opts(glance_opts, group='glance')
class GlanceImageService(base_image_service.BaseImageService,
service.ImageService):
def detail(self, **kwargs):
return self._detail(method='list', **kwargs)
def show(self, image_id):
return self._show(image_id, method='get')
def download(self, image_id, data=None):
return self._download(image_id, method='data', data=data)
def create(self, image_meta, data=None):
image_id = self._create(image_meta, method='create', data=None)['id']
return self.update(image_id, None, data)
def update(self, image_id, image_meta, data=None, purge_props=False):
# NOTE(ghe): purge_props not working until bug 1206472 solved
return self._update(image_id, image_meta, data, method='update',
purge_props=False)
def delete(self, image_id):
return self._delete(image_id, method='delete')
def swift_temp_url(self, image_info):
"""Generate a no-auth Swift temporary URL.
This function will generate the temporary Swift URL using the image
id from Glance and the config options: 'swift_endpoint_url',
'swift_api_version', 'swift_account' and 'swift_container'.
The temporary URL will be valid for 'swift_temp_url_duration' seconds.
This allows Iotronic to download a Glance image without passing around
an auth_token.
:param image_info: The return from a GET request to Glance for a
certain image_id. Should be a dictionary, with keys like 'name' and
'checksum'. See
http://docs.openstack.org/developer/glance/glanceapi.html for
examples.
:returns: A signed Swift URL from which an image can be downloaded,
without authentication.
:raises: InvalidParameterValue if Swift config options are not set
correctly.
:raises: MissingParameterValue if a required parameter is not set.
:raises: ImageUnacceptable if the image info from Glance does not
have a image ID.
"""
self._validate_temp_url_config()
if ('id' not in image_info or not
uuidutils.is_uuid_like(image_info['id'])):
raise exc.ImageUnacceptable(_(
'The given image info does not have a valid image id: %s')
% image_info)
url_fragments = {
'endpoint_url': CONF.glance.swift_endpoint_url,
'api_version': CONF.glance.swift_api_version,
'account': CONF.glance.swift_account,
'container': self._get_swift_container(image_info['id']),
'object_id': image_info['id']
}
template = '/{api_version}/{account}/{container}/{object_id}'
url_path = template.format(**url_fragments)
path = swift_utils.generate_temp_url(
path=url_path,
seconds=CONF.glance.swift_temp_url_duration,
key=CONF.glance.swift_temp_url_key,
method='GET')
return '{endpoint_url}{url_path}'.format(
endpoint_url=url_fragments['endpoint_url'], url_path=path)
def _validate_temp_url_config(self):
"""Validate the required settings for a temporary URL."""
if not CONF.glance.swift_temp_url_key:
raise exc.MissingParameterValue(_(
'Swift temporary URLs require a shared secret to be created. '
'You must provide "swift_temp_url_key" as a config option.'))
if not CONF.glance.swift_endpoint_url:
raise exc.MissingParameterValue(_(
'Swift temporary URLs require a Swift endpoint URL. '
'You must provide "swift_endpoint_url" as a config option.'))
if not CONF.glance.swift_account:
raise exc.MissingParameterValue(_(
'Swift temporary URLs require a Swift account string. '
'You must provide "swift_account" as a config option.'))
if CONF.glance.swift_temp_url_duration < 0:
raise exc.InvalidParameterValue(_(
'"swift_temp_url_duration" must be a positive integer.'))
seed_num_chars = CONF.glance.swift_store_multiple_containers_seed
if (seed_num_chars is None or seed_num_chars < 0
or seed_num_chars > 32):
raise exc.InvalidParameterValue(_(
"An integer value between 0 and 32 is required for"
" swift_store_multiple_containers_seed."))
def _get_swift_container(self, image_id):
"""Get the Swift container the image is stored in.
Code based on: https://github.com/openstack/glance_store/blob/3cd690b3
7dc9d935445aca0998e8aec34a3e3530/glance_store/
_drivers/swift/store.py#L725
Returns appropriate container name depending upon value of
``swift_store_multiple_containers_seed``. In single-container mode,
which is a seed value of 0, simply returns ``swift_container``.
In multiple-container mode, returns ``swift_container`` as the
prefix plus a suffix determined by the multiple container seed
examples:
single-container mode: 'glance'
multiple-container mode: 'glance_3a1' for image uuid 3A1xxxxxxx...
:param image_id: UUID of image
:returns: The name of the swift container the image is stored in
"""
seed_num_chars = CONF.glance.swift_store_multiple_containers_seed
if seed_num_chars > 0:
image_id = str(image_id).lower()
num_dashes = image_id[:seed_num_chars].count('-')
num_chars = seed_num_chars + num_dashes
name_suffix = image_id[:num_chars]
new_container_name = (CONF.glance.swift_container +
'_' + name_suffix)
return new_container_name
else:
return CONF.glance.swift_container
def _get_location(self, image_id):
"""Get storage URL.
Returns the direct url representing the backend storage location,
or None if this attribute is not shown by Glance.
"""
image_meta = self.call('get', image_id)
if not service_utils.is_image_available(self.context, image_meta):
raise exc.ImageNotFound(image_id=image_id)
return getattr(image_meta, 'direct_url', None)

View File

@ -0,0 +1,8 @@
set default=0
set timeout=5
set hidden_timeout_quiet=false
menuentry "boot_partition" {
linuxefi {{ linux }} {{ kernel_params }} --
initrdefi {{ initrd }}
}

View File

@ -0,0 +1,200 @@
# 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.
import bisect
import hashlib
import threading
from oslo_config import cfg
import six
from iotronic.common import exception
from iotronic.common.i18n import _
from iotronic.db import api as dbapi
hash_opts = [
cfg.IntOpt('hash_partition_exponent',
default=5,
help='Exponent to determine number of hash partitions to use '
'when distributing load across conductors. Larger values '
'will result in more even distribution of load and less '
'load when rebalancing the ring, but more memory usage. '
'Number of partitions per conductor is '
'(2^hash_partition_exponent). This determines the '
'granularity of rebalancing: given 10 hosts, and an '
'exponent of the 2, there are 40 partitions in the ring.'
'A few thousand partitions should make rebalancing '
'smooth in most cases. The default is suitable for up to '
'a few hundred conductors. Too many partitions has a CPU '
'impact.'),
cfg.IntOpt('hash_distribution_replicas',
default=1,
help='[Experimental Feature] '
'Number of hosts to map onto each hash partition. '
'Setting this to more than one will cause additional '
'conductor services to prepare deployment environments '
'and potentially allow the Iotronic cluster to recover '
'more quickly if a conductor instance is terminated.'),
]
CONF = cfg.CONF
CONF.register_opts(hash_opts)
class HashRing(object):
"""A stable hash ring.
We map item N to a host Y based on the closest lower hash:
- hash(item) -> partition
- hash(host) -> divider
- closest lower divider is the host to use
- we hash each host many times to spread load more finely
as otherwise adding a host gets (on average) 50% of the load of
just one other host assigned to it.
"""
def __init__(self, hosts, replicas=None):
"""Create a new hash ring across the specified hosts.
:param hosts: an iterable of hosts which will be mapped.
:param replicas: number of hosts to map to each hash partition,
or len(hosts), which ever is lesser.
Default: CONF.hash_distribution_replicas
"""
if replicas is None:
replicas = CONF.hash_distribution_replicas
try:
self.hosts = set(hosts)
self.replicas = replicas if replicas <= len(hosts) else len(hosts)
except TypeError:
raise exception.Invalid(
_("Invalid hosts supplied when building HashRing."))
self._host_hashes = {}
for host in hosts:
key = str(host).encode('utf8')
key_hash = hashlib.md5(key)
for p in range(2 ** CONF.hash_partition_exponent):
key_hash.update(key)
hashed_key = self._hash2int(key_hash)
self._host_hashes[hashed_key] = host
# Gather the (possibly colliding) resulting hashes into a bisectable
# list.
self._partitions = sorted(self._host_hashes.keys())
def _hash2int(self, key_hash):
"""Convert the given hash's digest to a numerical value for the ring.
:returns: An integer equivalent value of the digest.
"""
return int(key_hash.hexdigest(), 16)
def _get_partition(self, data):
try:
if six.PY3 and data is not None:
data = data.encode('utf-8')
key_hash = hashlib.md5(data)
hashed_key = self._hash2int(key_hash)
position = bisect.bisect(self._partitions, hashed_key)
return position if position < len(self._partitions) else 0
except TypeError:
raise exception.Invalid(
_("Invalid data supplied to HashRing.get_hosts."))
def get_hosts(self, data, ignore_hosts=None):
"""Get the list of hosts which the supplied data maps onto.
:param data: A string identifier to be mapped across the ring.
:param ignore_hosts: A list of hosts to skip when performing the hash.
Useful to temporarily skip down hosts without
performing a full rebalance.
Default: None.
:returns: a list of hosts.
The length of this list depends on the number of replicas
this `HashRing` was created with. It may be less than this
if ignore_hosts is not None.
"""
hosts = []
if ignore_hosts is None:
ignore_hosts = set()
else:
ignore_hosts = set(ignore_hosts)
ignore_hosts.intersection_update(self.hosts)
partition = self._get_partition(data)
for replica in range(0, self.replicas):
if len(hosts) + len(ignore_hosts) == len(self.hosts):
# prevent infinite loop - cannot allocate more fallbacks.
break
# Linear probing: partition N, then N+1 etc.
host = self._get_host(partition)
while host in hosts or host in ignore_hosts:
partition += 1
if partition >= len(self._partitions):
partition = 0
host = self._get_host(partition)
hosts.append(host)
return hosts
def _get_host(self, partition):
"""Find what host is serving a partition.
:param partition: The index of the partition in the partition map.
e.g. 0 is the first partition, 1 is the second.
:return: The host object the ring was constructed with.
"""
return self._host_hashes[self._partitions[partition]]
class HashRingManager(object):
_hash_rings = None
_lock = threading.Lock()
def __init__(self):
self.dbapi = dbapi.get_instance()
@property
def ring(self):
# Hot path, no lock
if self._hash_rings is not None:
return self._hash_rings
with self._lock:
if self._hash_rings is None:
rings = self._load_hash_rings()
self.__class__._hash_rings = rings
return self._hash_rings
def _load_hash_rings(self):
rings = {}
d2c = self.dbapi.get_active_driver_dict()
for driver_name, hosts in d2c.items():
rings[driver_name] = HashRing(hosts)
return rings
@classmethod
def reset(cls):
with cls._lock:
cls._hash_rings = None
def __getitem__(self, driver_name):
try:
return self.ring[driver_name]
except KeyError:
raise exception.DriverNotFound(
_("The driver '%s' is unknown.") % driver_name)

31
iotronic/common/i18n.py Normal file
View File

@ -0,0 +1,31 @@
# Copyright (c) 2014 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.
import oslo_i18n as i18n
_translators = i18n.TranslatorFactory(domain='iotronic')
# The primary translation function using the well-known name "_"
_ = _translators.primary
# Translators for log levels.
#
# The abbreviated names are meant to reflect the usual use of a short
# name like '_'. The "L" is for "log" and the other letter comes from
# the level.
_LI = _translators.log_info
_LW = _translators.log_warning
_LE = _translators.log_error
_LC = _translators.log_critical

View File

@ -0,0 +1,294 @@
# Copyright 2010 OpenStack Foundation
# 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.
import abc
import os
import shutil
from oslo_config import cfg
from oslo_log import log as logging
from oslo_utils import importutils
import requests
import sendfile
import six
import six.moves.urllib.parse as urlparse
from iotronic.common import exception
from iotronic.common.i18n import _
from iotronic.common import keystone
LOG = logging.getLogger(__name__)
IMAGE_CHUNK_SIZE = 1024 * 1024 # 1mb
CONF = cfg.CONF
# Import this opt early so that it is available when registering
# glance_opts below.
CONF.import_opt('my_ip', 'iotronic.netconf')
glance_opts = [
cfg.StrOpt('glance_host',
default='$my_ip',
help='Default glance hostname or IP address.'),
cfg.IntOpt('glance_port',
default=9292,
help='Default glance port.'),
cfg.StrOpt('glance_protocol',
default='http',
help='Default protocol to use when connecting to glance. '
'Set to https for SSL.'),
cfg.ListOpt('glance_api_servers',
help='A list of the glance api servers available to iotronic. '
'Prefix with https:// for SSL-based glance API servers. '
'Format is [hostname|IP]:port.'),
cfg.BoolOpt('glance_api_insecure',
default=False,
help='Allow to perform insecure SSL (https) requests to '
'glance.'),
cfg.IntOpt('glance_num_retries',
default=0,
help='Number of retries when downloading an image from '
'glance.'),
cfg.StrOpt('auth_strategy',
default='keystone',
help='Authentication strategy to use when connecting to '
'glance. Only "keystone" and "noauth" are currently '
'supported by iotronic.'),
]
CONF.register_opts(glance_opts, group='glance')
def import_versioned_module(version, submodule=None):
module = 'iotronic.common.glance_service.v%s' % version
if submodule:
module = '.'.join((module, submodule))
return importutils.try_import(module)
def GlanceImageService(client=None, version=1, context=None):
module = import_versioned_module(version, 'image_service')
service_class = getattr(module, 'GlanceImageService')
if (context is not None and CONF.glance.auth_strategy == 'keystone'
and not context.auth_token):
context.auth_token = keystone.get_admin_auth_token()
return service_class(client, version, context)
@six.add_metaclass(abc.ABCMeta)
class BaseImageService(object):
"""Provides retrieval of disk images."""
@abc.abstractmethod
def validate_href(self, image_href):
"""Validate image reference.
:param image_href: Image reference.
:raises: exception.ImageRefValidationFailed.
:returns: Information needed to further operate with an image.
"""
@abc.abstractmethod
def download(self, image_href, image_file):
"""Downloads image to specified location.
:param image_href: Image reference.
:param image_file: File object to write data to.
:raises: exception.ImageRefValidationFailed.
:raises: exception.ImageDownloadFailed.
"""
@abc.abstractmethod
def show(self, image_href):
"""Get dictionary of image properties.
:param image_href: Image reference.
:raises: exception.ImageRefValidationFailed.
:returns: dictionary of image properties.
"""
class HttpImageService(BaseImageService):
"""Provides retrieval of disk images using HTTP."""
def validate_href(self, image_href):
"""Validate HTTP image reference.
:param image_href: Image reference.
:raises: exception.ImageRefValidationFailed if HEAD request failed or
returned response code not equal to 200.
:returns: Response to HEAD request.
"""
try:
response = requests.head(image_href)
if response.status_code != 200:
raise exception.ImageRefValidationFailed(
image_href=image_href,
reason=_("Got HTTP code %s instead of 200 in response to "
"HEAD request.") % response.status_code)
except requests.RequestException as e:
raise exception.ImageRefValidationFailed(image_href=image_href,
reason=e)
return response
def download(self, image_href, image_file):
"""Downloads image to specified location.
:param image_href: Image reference.
:param image_file: File object to write data to.
:raises: exception.ImageRefValidationFailed if GET request returned
response code not equal to 200.
:raises: exception.ImageDownloadFailed if:
* IOError happened during file write;
* GET request failed.
"""
try:
response = requests.get(image_href, stream=True)
if response.status_code != 200:
raise exception.ImageRefValidationFailed(
image_href=image_href,
reason=_("Got HTTP code %s instead of 200 in response to "
"GET request.") % response.status_code)
with response.raw as input_img:
shutil.copyfileobj(input_img, image_file, IMAGE_CHUNK_SIZE)
except (requests.RequestException, IOError) as e:
raise exception.ImageDownloadFailed(image_href=image_href,
reason=e)
def show(self, image_href):
"""Get dictionary of image properties.
:param image_href: Image reference.
:raises: exception.ImageRefValidationFailed if:
* HEAD request failed;
* HEAD request returned response code not equal to 200;
* Content-Length header not found in response to HEAD request.
:returns: dictionary of image properties.
"""
response = self.validate_href(image_href)
image_size = response.headers.get('Content-Length')
if image_size is None:
raise exception.ImageRefValidationFailed(
image_href=image_href,
reason=_("Cannot determine image size as there is no "
"Content-Length header specified in response "
"to HEAD request."))
return {
'size': int(image_size),
'properties': {}
}
class FileImageService(BaseImageService):
"""Provides retrieval of disk images available locally on the conductor."""
def validate_href(self, image_href):
"""Validate local image reference.
:param image_href: Image reference.
:raises: exception.ImageRefValidationFailed if source image file
doesn't exist.
:returns: Path to image file if it exists.
"""
image_path = urlparse.urlparse(image_href).path
if not os.path.isfile(image_path):
raise exception.ImageRefValidationFailed(
image_href=image_href,
reason=_("Specified image file not found."))
return image_path
def download(self, image_href, image_file):
"""Downloads image to specified location.
:param image_href: Image reference.
:param image_file: File object to write data to.
:raises: exception.ImageRefValidationFailed if source image file
doesn't exist.
:raises: exception.ImageDownloadFailed if exceptions were raised while
writing to file or creating hard link.
"""
source_image_path = self.validate_href(image_href)
dest_image_path = image_file.name
local_device = os.stat(dest_image_path).st_dev
try:
# We should have read and write access to source file to create
# hard link to it.
if (local_device == os.stat(source_image_path).st_dev and
os.access(source_image_path, os.R_OK | os.W_OK)):
image_file.close()
os.remove(dest_image_path)
os.link(source_image_path, dest_image_path)
else:
filesize = os.path.getsize(source_image_path)
with open(source_image_path, 'rb') as input_img:
sendfile.sendfile(image_file.fileno(), input_img.fileno(),
0, filesize)
except Exception as e:
raise exception.ImageDownloadFailed(image_href=image_href,
reason=e)
def show(self, image_href):
"""Get dictionary of image properties.
:param image_href: Image reference.
:raises: exception.ImageRefValidationFailed if image file specified
doesn't exist.
:returns: dictionary of image properties.
"""
source_image_path = self.validate_href(image_href)
return {
'size': os.path.getsize(source_image_path),
'properties': {}
}
protocol_mapping = {
'http': HttpImageService,
'https': HttpImageService,
'file': FileImageService,
'glance': GlanceImageService,
}
def get_image_service(image_href, client=None, version=1, context=None):
"""Get image service instance to download the image.
:param image_href: String containing href to get image service for.
:param client: Glance client to be used for download, used only if
image_href is Glance href.
:param version: Version of Glance API to use, used only if image_href is
Glance href.
:param context: request context, used only if image_href is Glance href.
:raises: exception.ImageRefValidationFailed if no image service can
handle specified href.
:returns: Instance of an image service class that is able to download
specified image.
"""
scheme = urlparse.urlparse(image_href).scheme.lower()
try:
cls = protocol_mapping[scheme or 'glance']
except KeyError:
raise exception.ImageRefValidationFailed(
image_href=image_href,
reason=_('Image download protocol '
'%s is not supported.') % scheme
)
if cls == GlanceImageService:
return cls(client, version, context)
return cls()

577
iotronic/common/images.py Normal file
View File

@ -0,0 +1,577 @@
# Copyright 2010 United States Government as represented by the
# Administrator of the National Aeronautics and Space Administration.
# All Rights Reserved.
# Copyright (c) 2010 Citrix Systems, Inc.
#
# 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.
"""
Handling of VM disk images.
"""
import os
import shutil
import jinja2
from oslo_concurrency import processutils
from oslo_config import cfg
from oslo_log import log as logging
from iotronic.common import exception
from iotronic.common.glance_service import service_utils as glance_utils
from iotronic.common.i18n import _
from iotronic.common.i18n import _LE
from iotronic.common import image_service as service
from iotronic.common import paths
from iotronic.common import utils
from iotronic.openstack.common import fileutils
from iotronic.openstack.common import imageutils
LOG = logging.getLogger(__name__)
image_opts = [
cfg.BoolOpt('force_raw_images',
default=True,
help='If True, convert backing images to "raw" disk image '
'format.'),
cfg.StrOpt('isolinux_bin',
default='/usr/lib/syslinux/isolinux.bin',
help='Path to isolinux binary file.'),
cfg.StrOpt('isolinux_config_template',
default=paths.basedir_def('common/isolinux_config.template'),
help='Template file for isolinux configuration file.'),
cfg.StrOpt('grub_config_template',
default=paths.basedir_def('common/grub_conf.template'),
help='Template file for grub configuration file.'),
]
CONF = cfg.CONF
CONF.register_opts(image_opts)
def _create_root_fs(root_directory, files_info):
"""Creates a filesystem root in given directory.
Given a mapping of absolute path of files to their relative paths
within the filesystem, this method copies the files to their
destination.
:param root_directory: the filesystem root directory.
:param files_info: A dict containing absolute path of file to be copied
-> relative path within the vfat image. For example,
{
'/absolute/path/to/file' -> 'relative/path/within/root'
...
}
:raises: OSError, if creation of any directory failed.
:raises: IOError, if copying any of the files failed.
"""
for src_file, path in files_info.items():
target_file = os.path.join(root_directory, path)
dirname = os.path.dirname(target_file)
if not os.path.exists(dirname):
os.makedirs(dirname)
shutil.copyfile(src_file, target_file)
def _umount_without_raise(mount_dir):
"""Helper method to umount without raise."""
try:
utils.umount(mount_dir)
except processutils.ProcessExecutionError:
pass
def create_vfat_image(output_file, files_info=None, parameters=None,
parameters_file='parameters.txt', fs_size_kib=100):
"""Creates the fat fs image on the desired file.
This method copies the given files to a root directory (optional),
writes the parameters specified to the parameters file within the
root directory (optional), and then creates a vfat image of the root
directory.
:param output_file: The path to the file where the fat fs image needs
to be created.
:param files_info: A dict containing absolute path of file to be copied
-> relative path within the vfat image. For example,
{
'/absolute/path/to/file' -> 'relative/path/within/root'
...
}
:param parameters: A dict containing key-value pairs of parameters.
:param parameters_file: The filename for the parameters file.
:param fs_size_kib: size of the vfat filesystem in KiB.
:raises: ImageCreationFailed, if image creation failed while doing any
of filesystem manipulation activities like creating dirs, mounting,
creating filesystem, copying files, etc.
"""
try:
utils.dd('/dev/zero', output_file, 'count=1', "bs=%dKiB" % fs_size_kib)
except processutils.ProcessExecutionError as e:
raise exception.ImageCreationFailed(image_type='vfat', error=e)
with utils.tempdir() as tmpdir:
try:
# The label helps ramdisks to find the partition containing
# the parameters (by using /dev/disk/by-label/ir-vfd-dev).
# NOTE: FAT filesystem label can be up to 11 characters long.
utils.mkfs('vfat', output_file, label="ir-vfd-dev")
utils.mount(output_file, tmpdir, '-o', 'umask=0')
except processutils.ProcessExecutionError as e:
raise exception.ImageCreationFailed(image_type='vfat', error=e)
try:
if files_info:
_create_root_fs(tmpdir, files_info)
if parameters:
parameters_file = os.path.join(tmpdir, parameters_file)
params_list = ['%(key)s=%(val)s' % {'key': k, 'val': v}
for k, v in parameters.items()]
file_contents = '\n'.join(params_list)
utils.write_to_file(parameters_file, file_contents)
except Exception as e:
LOG.exception(_LE("vfat image creation failed. Error: %s"), e)
raise exception.ImageCreationFailed(image_type='vfat', error=e)
finally:
try:
utils.umount(tmpdir)
except processutils.ProcessExecutionError as e:
raise exception.ImageCreationFailed(image_type='vfat', error=e)
def _generate_cfg(kernel_params, template, options):
"""Generates a isolinux or grub configuration file.
Given a given a list of strings containing kernel parameters, this method
returns the kernel cmdline string.
:param kernel_params: a list of strings(each element being a string like
'K=V' or 'K' or combination of them like 'K1=V1 K2 K3=V3') to be added
as the kernel cmdline.
:param template: the path of the config template file.
:param options: a dictionary of keywords which need to be replaced in
template file to generate a proper config file.
:returns: a string containing the contents of the isolinux configuration
file.
"""
if not kernel_params:
kernel_params = []
kernel_params_str = ' '.join(kernel_params)
tmpl_path, tmpl_file = os.path.split(template)
env = jinja2.Environment(loader=jinja2.FileSystemLoader(tmpl_path))
template = env.get_template(tmpl_file)
options.update({'kernel_params': kernel_params_str})
cfg = template.render(options)
return cfg
def create_isolinux_image_for_bios(output_file, kernel, ramdisk,
kernel_params=None):
"""Creates an isolinux image on the specified file.
Copies the provided kernel, ramdisk to a directory, generates the isolinux
configuration file using the kernel parameters provided, and then generates
a bootable ISO image.
:param output_file: the path to the file where the iso image needs to be
created.
:param kernel: the kernel to use.
:param ramdisk: the ramdisk to use.
:param kernel_params: a list of strings(each element being a string like
'K=V' or 'K' or combination of them like 'K1=V1,K2,...') to be added
as the kernel cmdline.
:raises: ImageCreationFailed, if image creation failed while copying files
or while running command to generate iso.
"""
ISOLINUX_BIN = 'isolinux/isolinux.bin'
ISOLINUX_CFG = 'isolinux/isolinux.cfg'
options = {'kernel': '/vmlinuz', 'ramdisk': '/initrd'}
with utils.tempdir() as tmpdir:
files_info = {
kernel: 'vmlinuz',
ramdisk: 'initrd',
CONF.isolinux_bin: ISOLINUX_BIN,
}
try:
_create_root_fs(tmpdir, files_info)
except (OSError, IOError) as e:
LOG.exception(_LE("Creating the filesystem root failed."))
raise exception.ImageCreationFailed(image_type='iso', error=e)
cfg = _generate_cfg(kernel_params,
CONF.isolinux_config_template, options)
isolinux_cfg = os.path.join(tmpdir, ISOLINUX_CFG)
utils.write_to_file(isolinux_cfg, cfg)
try:
utils.execute('mkisofs', '-r', '-V', "VMEDIA_BOOT_ISO",
'-cache-inodes', '-J', '-l', '-no-emul-boot',
'-boot-load-size', '4', '-boot-info-table',
'-b', ISOLINUX_BIN, '-o', output_file, tmpdir)
except processutils.ProcessExecutionError as e:
LOG.exception(_LE("Creating ISO image failed."))
raise exception.ImageCreationFailed(image_type='iso', error=e)
def create_isolinux_image_for_uefi(output_file, deploy_iso, kernel, ramdisk,
kernel_params=None):
"""Creates an isolinux image on the specified file.
Copies the provided kernel, ramdisk, efiboot.img to a directory, creates
the path for grub config file, generates the isolinux configuration file
using the kernel parameters provided, generates the grub configuration
file using kernel parameters and then generates a bootable ISO image
for uefi.
:param output_file: the path to the file where the iso image needs to be
created.
:param deploy_iso: deploy iso used to initiate the deploy.
:param kernel: the kernel to use.
:param ramdisk: the ramdisk to use.
:param kernel_params: a list of strings(each element being a string like
'K=V' or 'K' or combination of them like 'K1=V1,K2,...') to be added
as the kernel cmdline.
:raises: ImageCreationFailed, if image creation failed while copying files
or while running command to generate iso.
"""
ISOLINUX_BIN = 'isolinux/isolinux.bin'
ISOLINUX_CFG = 'isolinux/isolinux.cfg'
isolinux_options = {'kernel': '/vmlinuz', 'ramdisk': '/initrd'}
grub_options = {'linux': '/vmlinuz', 'initrd': '/initrd'}
with utils.tempdir() as tmpdir:
files_info = {
kernel: 'vmlinuz',
ramdisk: 'initrd',
CONF.isolinux_bin: ISOLINUX_BIN,
}
# Open the deploy iso used to initiate deploy and copy the
# efiboot.img i.e. boot loader to the current temporary
# directory.
with utils.tempdir() as mountdir:
uefi_path_info, e_img_rel_path, grub_rel_path = (
_mount_deploy_iso(deploy_iso, mountdir))
# if either of these variables are not initialized then the
# uefi efiboot.img cannot be created.
files_info.update(uefi_path_info)
try:
_create_root_fs(tmpdir, files_info)
except (OSError, IOError) as e:
LOG.exception(_LE("Creating the filesystem root failed."))
raise exception.ImageCreationFailed(image_type='iso', error=e)
finally:
_umount_without_raise(mountdir)
cfg = _generate_cfg(kernel_params,
CONF.isolinux_config_template, isolinux_options)
isolinux_cfg = os.path.join(tmpdir, ISOLINUX_CFG)
utils.write_to_file(isolinux_cfg, cfg)
# Generate and copy grub config file.
grub_cfg = os.path.join(tmpdir, grub_rel_path)
grub_conf = _generate_cfg(kernel_params,
CONF.grub_config_template, grub_options)
utils.write_to_file(grub_cfg, grub_conf)
# Create the boot_iso.
try:
utils.execute('mkisofs', '-r', '-V', "VMEDIA_BOOT_ISO",
'-cache-inodes', '-J', '-l', '-no-emul-boot',
'-boot-load-size', '4', '-boot-info-table',
'-b', ISOLINUX_BIN, '-eltorito-alt-boot',
'-e', e_img_rel_path, '-no-emul-boot',
'-o', output_file, tmpdir)
except processutils.ProcessExecutionError as e:
LOG.exception(_LE("Creating ISO image failed."))
raise exception.ImageCreationFailed(image_type='iso', error=e)
def qemu_img_info(path):
"""Return an object containing the parsed output from qemu-img info."""
if not os.path.exists(path):
return imageutils.QemuImgInfo()
out, err = utils.execute('env', 'LC_ALL=C', 'LANG=C',
'qemu-img', 'info', path)
return imageutils.QemuImgInfo(out)
def convert_image(source, dest, out_format, run_as_root=False):
"""Convert image to other format."""
cmd = ('qemu-img', 'convert', '-O', out_format, source, dest)
utils.execute(*cmd, run_as_root=run_as_root)
def fetch(context, image_href, path, image_service=None, force_raw=False):
# TODO(vish): Improve context handling and add owner and auth data
# when it is added to glance. Right now there is no
# auth checking in glance, so we assume that access was
# checked before we got here.
if not image_service:
image_service = service.get_image_service(image_href,
context=context)
LOG.debug("Using %(image_service)s to download image %(image_href)s." %
{'image_service': image_service.__class__,
'image_href': image_href})
with fileutils.remove_path_on_error(path):
with open(path, "wb") as image_file:
image_service.download(image_href, image_file)
if force_raw:
image_to_raw(image_href, path, "%s.part" % path)
def image_to_raw(image_href, path, path_tmp):
with fileutils.remove_path_on_error(path_tmp):
data = qemu_img_info(path_tmp)
fmt = data.file_format
if fmt is None:
raise exception.ImageUnacceptable(
reason=_("'qemu-img info' parsing failed."),
image_id=image_href)
backing_file = data.backing_file
if backing_file is not None:
raise exception.ImageUnacceptable(
image_id=image_href,
reason=_("fmt=%(fmt)s backed by: %(backing_file)s") %
{'fmt': fmt, 'backing_file': backing_file})
if fmt != "raw":
staged = "%s.converted" % path
LOG.debug("%(image)s was %(format)s, converting to raw" %
{'image': image_href, 'format': fmt})
with fileutils.remove_path_on_error(staged):
convert_image(path_tmp, staged, 'raw')
os.unlink(path_tmp)
data = qemu_img_info(staged)
if data.file_format != "raw":
raise exception.ImageConvertFailed(
image_id=image_href,
reason=_("Converted to raw, but format is "
"now %s") % data.file_format)
os.rename(staged, path)
else:
os.rename(path_tmp, path)
def download_size(context, image_href, image_service=None):
if not image_service:
image_service = service.get_image_service(image_href, context=context)
return image_service.show(image_href)['size']
def converted_size(path):
"""Get size of converted raw image.
The size of image converted to raw format can be growing up to the virtual
size of the image.
:param path: path to the image file.
:returns: virtual size of the image or 0 if conversion not needed.
"""
data = qemu_img_info(path)
return data.virtual_size
def get_image_properties(context, image_href, properties="all"):
"""Returns the values of several properties of an image
:param context: context
:param image_href: href of the image
:param properties: the properties whose values are required.
This argument is optional, default value is "all", so if not specified
all properties will be returned.
:returns: a dict of the values of the properties. A property not on the
glance metadata will have a value of None.
"""
img_service = service.get_image_service(image_href, context=context)
iproperties = img_service.show(image_href)['properties']
if properties == "all":
return iproperties
return {p: iproperties.get(p) for p in properties}
def get_temp_url_for_glance_image(context, image_uuid):
"""Returns the tmp url for a glance image.
:param context: context
:param image_uuid: the UUID of the image in glance
:returns: the tmp url for the glance image.
"""
# Glance API version 2 is required for getting direct_url of the image.
glance_service = service.GlanceImageService(version=2, context=context)
image_properties = glance_service.show(image_uuid)
LOG.debug('Got image info: %(info)s for image %(image_uuid)s.',
{'info': image_properties, 'image_uuid': image_uuid})
return glance_service.swift_temp_url(image_properties)
def create_boot_iso(context, output_filename, kernel_href,
ramdisk_href, deploy_iso_uuid, root_uuid=None,
kernel_params=None, boot_mode=None):
"""Creates a bootable ISO image for a node.
Given the hrefs for kernel, ramdisk, root partition's UUID and
kernel cmdline arguments, this method fetches the kernel and ramdisk,
and builds a bootable ISO image that can be used to boot up the
baremetal node.
:param context: context
:param output_filename: the absolute path of the output ISO file
:param kernel_href: URL or glance uuid of the kernel to use
:param ramdisk_href: URL or glance uuid of the ramdisk to use
:param deploy_iso_uuid: URL or glance uuid of the deploy iso used
:param root_uuid: uuid of the root filesystem (optional)
:param kernel_params: a string containing whitespace separated values
kernel cmdline arguments of the form K=V or K (optional).
:boot_mode: the boot mode in which the deploy is to happen.
:raises: ImageCreationFailed, if creating boot ISO failed.
"""
with utils.tempdir() as tmpdir:
kernel_path = os.path.join(tmpdir, kernel_href.split('/')[-1])
ramdisk_path = os.path.join(tmpdir, ramdisk_href.split('/')[-1])
fetch(context, kernel_href, kernel_path)
fetch(context, ramdisk_href, ramdisk_path)
params = []
if root_uuid:
params.append('root=UUID=%s' % root_uuid)
if kernel_params:
params.append(kernel_params)
if boot_mode == 'uefi':
deploy_iso = os.path.join(tmpdir, deploy_iso_uuid)
fetch(context, deploy_iso_uuid, deploy_iso)
create_isolinux_image_for_uefi(output_filename,
deploy_iso,
kernel_path,
ramdisk_path,
params)
else:
create_isolinux_image_for_bios(output_filename,
kernel_path,
ramdisk_path,
params)
def is_whole_disk_image(ctx, instance_info):
"""Find out if the image is a partition image or a whole disk image.
:param ctx: an admin context
:param instance_info: a node's instance info dict
:returns True for whole disk images and False for partition images
and None on no image_source or Error.
"""
image_source = instance_info.get('image_source')
if not image_source:
return
is_whole_disk_image = False
if glance_utils.is_glance_image(image_source):
try:
iproperties = get_image_properties(ctx, image_source)
except Exception:
return
is_whole_disk_image = (not iproperties.get('kernel_id') and
not iproperties.get('ramdisk_id'))
else:
# Non glance image ref
if (not instance_info.get('kernel') and
not instance_info.get('ramdisk')):
is_whole_disk_image = True
return is_whole_disk_image
def _mount_deploy_iso(deploy_iso, mountdir):
"""This function opens up the deploy iso used for deploy.
:param: deploy_iso: path to the deploy iso where its
contents are fetched to.
:raises: ImageCreationFailed if mount fails.
:returns: a tuple consisting of - 1. a dictionary containing
the values as required
by create_isolinux_image,
2. efiboot.img relative path, and
3. grub.cfg relative path.
"""
e_img_rel_path = None
e_img_path = None
grub_rel_path = None
grub_path = None
try:
utils.mount(deploy_iso, mountdir, '-o', 'loop')
except processutils.ProcessExecutionError as e:
LOG.exception(_LE("mounting the deploy iso failed."))
raise exception.ImageCreationFailed(image_type='iso', error=e)
try:
for (dir, subdir, files) in os.walk(mountdir):
if 'efiboot.img' in files:
e_img_path = os.path.join(dir, 'efiboot.img')
e_img_rel_path = os.path.relpath(e_img_path,
mountdir)
if 'grub.cfg' in files:
grub_path = os.path.join(dir, 'grub.cfg')
grub_rel_path = os.path.relpath(grub_path,
mountdir)
except (OSError, IOError) as e:
LOG.exception(_LE("examining the deploy iso failed."))
_umount_without_raise(mountdir)
raise exception.ImageCreationFailed(image_type='iso', error=e)
# check if the variables are assigned some values or not during
# walk of the mountdir.
if not (e_img_path and e_img_rel_path and grub_path and grub_rel_path):
error = (_("Deploy iso didn't contain efiboot.img or grub.cfg"))
_umount_without_raise(mountdir)
raise exception.ImageCreationFailed(image_type='iso', error=error)
uefi_path_info = {e_img_path: e_img_rel_path,
grub_path: grub_rel_path}
# Returning a tuple as it makes the code simpler and clean.
# uefi_path_info: is needed by the caller for _create_root_fs to create
# appropriate directory structures for uefi boot iso.
# grub_rel_path: is needed to copy the new grub.cfg generated using
# generate_cfg() to the same directory path structure where it was
# present in deploy iso. This path varies for different OS vendors.
# e_img_rel_path: is required by mkisofs to generate boot iso.
return uefi_path_info, e_img_rel_path, grub_rel_path

View File

@ -0,0 +1,5 @@
default boot
label boot
kernel {{ kernel }}
append initrd={{ ramdisk }} text {{ kernel_params }} --

139
iotronic/common/keystone.py Normal file
View File

@ -0,0 +1,139 @@
# coding=utf-8
#
# 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 keystoneclient import exceptions as ksexception
from oslo_config import cfg
from six.moves.urllib import parse
from iotronic.common import exception
from iotronic.common.i18n import _
CONF = cfg.CONF
keystone_opts = [
cfg.StrOpt('region_name',
help='The region used for getting endpoints of OpenStack'
'services.'),
]
CONF.register_opts(keystone_opts, group='keystone')
CONF.import_group('keystone_authtoken', 'keystonemiddleware.auth_token')
def _is_apiv3(auth_url, auth_version):
"""Checks if V3 version of API is being used or not.
This method inspects auth_url and auth_version, and checks whether V3
version of the API is being used or not.
:param auth_url: a http or https url to be inspected (like
'http://127.0.0.1:9898/').
:param auth_version: a string containing the version (like 'v2', 'v3.0')
:returns: True if V3 of the API is being used.
"""
return auth_version == 'v3.0' or '/v3' in parse.urlparse(auth_url).path
def _get_ksclient(token=None):
auth_url = CONF.keystone_authtoken.auth_uri
if not auth_url:
raise exception.KeystoneFailure(_('Keystone API endpoint is missing'))
auth_version = CONF.keystone_authtoken.auth_version
api_v3 = _is_apiv3(auth_url, auth_version)
if api_v3:
from keystoneclient.v3 import client
else:
from keystoneclient.v2_0 import client
auth_url = get_keystone_url(auth_url, auth_version)
try:
if token:
return client.Client(token=token, auth_url=auth_url)
else:
return client.Client(
username=CONF.keystone_authtoken.admin_user,
password=CONF.keystone_authtoken.admin_password,
tenant_name=CONF.keystone_authtoken.admin_tenant_name,
region_name=CONF.keystone.region_name,
auth_url=auth_url)
except ksexception.Unauthorized:
raise exception.KeystoneUnauthorized()
except ksexception.AuthorizationFailure as err:
raise exception.KeystoneFailure(_('Could not authorize in Keystone:'
' %s') % err)
def get_keystone_url(auth_url, auth_version):
"""Gives an http/https url to contact keystone.
Given an auth_url and auth_version, this method generates the url in
which keystone can be reached.
:param auth_url: a http or https url to be inspected (like
'http://127.0.0.1:9898/').
:param auth_version: a string containing the version (like v2, v3.0, etc)
:returns: a string containing the keystone url
"""
api_v3 = _is_apiv3(auth_url, auth_version)
api_version = 'v3' if api_v3 else 'v2.0'
# NOTE(lucasagomes): Get rid of the trailing '/' otherwise urljoin()
# fails to override the version in the URL
return parse.urljoin(auth_url.rstrip('/'), api_version)
def get_service_url(service_type='baremetal', endpoint_type='internal'):
"""Wrapper for get service url from keystone service catalog.
Given a service_type and an endpoint_type, this method queries keystone
service catalog and provides the url for the desired endpoint.
:param service_type: the keystone service for which url is required.
:param endpoint_type: the type of endpoint for the service.
:returns: an http/https url for the desired endpoint.
"""
ksclient = _get_ksclient()
if not ksclient.has_service_catalog():
raise exception.KeystoneFailure(_('No Keystone service catalog '
'loaded'))
try:
endpoint = ksclient.service_catalog.url_for(
service_type=service_type,
endpoint_type=endpoint_type,
region_name=CONF.keystone.region_name)
except ksexception.EndpointNotFound:
raise exception.CatalogNotFound(service_type=service_type,
endpoint_type=endpoint_type)
return endpoint
def get_admin_auth_token():
"""Get an admin auth_token from the Keystone."""
ksclient = _get_ksclient()
return ksclient.auth_token
def token_expires_soon(token, duration=None):
"""Determines if token expiration is about to occur.
:param duration: time interval in seconds
:returns: boolean : true if expiration is within the given duration
"""
ksclient = _get_ksclient(token=token)
return ksclient.auth_ref.will_expire_soon(stale_duration=duration)

View File

@ -0,0 +1,30 @@
# Copyright 2014 Rackspace, Inc.
#
# 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.
def get_node_vif_ids(task):
"""Get all VIF ids for a node.
This function does not handle multi node operations.
:param task: a TaskManager instance.
:returns: A dict of the Node's port UUIDs and their associated VIFs
"""
port_vifs = {}
for port in task.ports:
vif = port.extra.get('vif_port_id')
if vif:
port_vifs[port.uuid] = vif
return port_vifs

66
iotronic/common/paths.py Normal file
View File

@ -0,0 +1,66 @@
# Copyright 2010 United States Government as represented by the
# Administrator of the National Aeronautics and Space Administration.
# All Rights Reserved.
# Copyright 2012 Red Hat, Inc.
#
# 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.
import os
from oslo_config import cfg
path_opts = [
cfg.StrOpt('pybasedir',
default=os.path.abspath(os.path.join(os.path.dirname(__file__),
'../')),
help='Directory where the iotronic python module is installed.'),
cfg.StrOpt('bindir',
default='$pybasedir/bin',
help='Directory where iotronic binaries are installed.'),
cfg.StrOpt('state_path',
default='$pybasedir',
help="Top-level directory for maintaining iotronic's state."),
]
CONF = cfg.CONF
CONF.register_opts(path_opts)
def basedir_def(*args):
"""Return an uninterpolated path relative to $pybasedir."""
return os.path.join('$pybasedir', *args)
def bindir_def(*args):
"""Return an uninterpolated path relative to $bindir."""
return os.path.join('$bindir', *args)
def state_path_def(*args):
"""Return an uninterpolated path relative to $state_path."""
return os.path.join('$state_path', *args)
def basedir_rel(*args):
"""Return a path relative to $pybasedir."""
return os.path.join(CONF.pybasedir, *args)
def bindir_rel(*args):
"""Return a path relative to $bindir."""
return os.path.join(CONF.bindir, *args)
def state_path_rel(*args):
"""Return a path relative to $state_path."""
return os.path.join(CONF.state_path, *args)

68
iotronic/common/policy.py Normal file
View File

@ -0,0 +1,68 @@
# Copyright (c) 2011 OpenStack Foundation
# 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.
"""Policy Engine For Iotronic."""
from oslo_concurrency import lockutils
from oslo_config import cfg
from oslo_policy import policy
_ENFORCER = None
CONF = cfg.CONF
@lockutils.synchronized('policy_enforcer', 'iotronic-')
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.
: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
be used if none is specified.
:param use_conf: Whether to load rules from config file.
"""
global _ENFORCER
if _ENFORCER:
return
_ENFORCER = policy.Enforcer(CONF, policy_file=policy_file,
rules=rules,
default_rule=default_rule,
use_conf=use_conf)
def get_enforcer():
"""Provides access to the single instance of Policy enforcer."""
if not _ENFORCER:
init_enforcer()
return _ENFORCER
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.
"""
enforcer = get_enforcer()
return enforcer.enforce(rule, target, creds, do_raise=do_raise,
exc=exc, *args, **kwargs)

View File

@ -0,0 +1,285 @@
#
# Copyright 2014 Rackspace, Inc
# 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.
import os
import jinja2
from oslo_config import cfg
from oslo_log import log as logging
from iotronic.common import dhcp_factory
from iotronic.common import exception
from iotronic.common.i18n import _
from iotronic.common import utils
from iotronic.drivers.modules import deploy_utils
from iotronic.drivers import utils as driver_utils
from iotronic.openstack.common import fileutils
CONF = cfg.CONF
LOG = logging.getLogger(__name__)
PXE_CFG_DIR_NAME = 'pxelinux.cfg'
def get_root_dir():
"""Returns the directory where the config files and images will live."""
if CONF.pxe.ipxe_enabled:
return CONF.pxe.http_root
else:
return CONF.pxe.tftp_root
def _ensure_config_dirs_exist(node_uuid):
"""Ensure that the node's and PXE configuration directories exist.
:param node_uuid: the UUID of the node.
"""
root_dir = get_root_dir()
fileutils.ensure_tree(os.path.join(root_dir, node_uuid))
fileutils.ensure_tree(os.path.join(root_dir, PXE_CFG_DIR_NAME))
def _build_pxe_config(pxe_options, template):
"""Build the PXE boot configuration file.
This method builds the PXE boot configuration file by rendering the
template with the given parameters.
:param pxe_options: A dict of values to set on the configuration file.
:param template: The PXE configuration template.
:returns: A formatted string with the file content.
"""
tmpl_path, tmpl_file = os.path.split(template)
env = jinja2.Environment(loader=jinja2.FileSystemLoader(tmpl_path))
template = env.get_template(tmpl_file)
return template.render({'pxe_options': pxe_options,
'ROOT': '{{ ROOT }}',
'DISK_IDENTIFIER': '{{ DISK_IDENTIFIER }}',
})
def _link_mac_pxe_configs(task):
"""Link each MAC address with the PXE configuration file.
:param task: A TaskManager instance.
"""
def create_link(mac_path):
utils.unlink_without_raise(mac_path)
utils.create_link_without_raise(pxe_config_file_path, mac_path)
pxe_config_file_path = get_pxe_config_file_path(task.node.uuid)
for mac in driver_utils.get_node_mac_addresses(task):
create_link(_get_pxe_mac_path(mac))
# TODO(lucasagomes): Backward compatibility with :hexraw,
# to be removed in M.
# see: https://bugs.launchpad.net/iotronic/+bug/1441710
if CONF.pxe.ipxe_enabled:
create_link(_get_pxe_mac_path(mac, delimiter=''))
def _link_ip_address_pxe_configs(task):
"""Link each IP address with the PXE configuration file.
:param task: A TaskManager instance.
:raises: FailedToGetIPAddressOnPort
:raises: InvalidIPv4Address
"""
pxe_config_file_path = get_pxe_config_file_path(task.node.uuid)
api = dhcp_factory.DHCPFactory().provider
ip_addrs = api.get_ip_addresses(task)
if not ip_addrs:
raise exception.FailedToGetIPAddressOnPort(_(
"Failed to get IP address for any port on node %s.") %
task.node.uuid)
for port_ip_address in ip_addrs:
ip_address_path = _get_pxe_ip_address_path(port_ip_address)
utils.unlink_without_raise(ip_address_path)
utils.create_link_without_raise(pxe_config_file_path,
ip_address_path)
def _get_pxe_mac_path(mac, delimiter=None):
"""Convert a MAC address into a PXE config file name.
:param mac: A MAC address string in the format xx:xx:xx:xx:xx:xx.
:param delimiter: The MAC address delimiter. Defaults to dash ('-').
:returns: the path to the config file.
"""
if delimiter is None:
delimiter = '-'
mac_file_name = mac.replace(':', delimiter).lower()
if not CONF.pxe.ipxe_enabled:
mac_file_name = '01-' + mac_file_name
return os.path.join(get_root_dir(), PXE_CFG_DIR_NAME, mac_file_name)
def _get_pxe_ip_address_path(ip_address):
"""Convert an ipv4 address into a PXE config file name.
:param ip_address: A valid IPv4 address string in the format 'n.n.n.n'.
:returns: the path to the config file.
"""
ip = ip_address.split('.')
hex_ip = '{0:02X}{1:02X}{2:02X}{3:02X}'.format(*map(int, ip))
return os.path.join(
CONF.pxe.tftp_root, hex_ip + ".conf"
)
def get_deploy_kr_info(node_uuid, driver_info):
"""Get href and tftp path for deploy kernel and ramdisk.
Note: driver_info should be validated outside of this method.
"""
root_dir = get_root_dir()
image_info = {}
for label in ('deploy_kernel', 'deploy_ramdisk'):
image_info[label] = (
str(driver_info[label]),
os.path.join(root_dir, node_uuid, label)
)
return image_info
def get_pxe_config_file_path(node_uuid):
"""Generate the path for the node's PXE configuration file.
:param node_uuid: the UUID of the node.
:returns: The path to the node's PXE configuration file.
"""
return os.path.join(get_root_dir(), node_uuid, 'config')
def create_pxe_config(task, pxe_options, template=None):
"""Generate PXE configuration file and MAC address links for it.
This method will generate the PXE configuration file for the task's
node under a directory named with the UUID of that node. For each
MAC address (port) of that node, a symlink for the configuration file
will be created under the PXE configuration directory, so regardless
of which port boots first they'll get the same PXE configuration.
:param task: A TaskManager instance.
:param pxe_options: A dictionary with the PXE configuration
parameters.
:param template: The PXE configuration template. If no template is
given the CONF.pxe.pxe_config_template will be used.
"""
LOG.debug("Building PXE config for node %s", task.node.uuid)
if template is None:
template = CONF.pxe.pxe_config_template
_ensure_config_dirs_exist(task.node.uuid)
pxe_config_file_path = get_pxe_config_file_path(task.node.uuid)
pxe_config = _build_pxe_config(pxe_options, template)
utils.write_to_file(pxe_config_file_path, pxe_config)
if deploy_utils.get_boot_mode_for_deploy(task.node) == 'uefi':
_link_ip_address_pxe_configs(task)
else:
_link_mac_pxe_configs(task)
def clean_up_pxe_config(task):
"""Clean up the TFTP environment for the task's node.
:param task: A TaskManager instance.
"""
LOG.debug("Cleaning up PXE config for node %s", task.node.uuid)
if deploy_utils.get_boot_mode_for_deploy(task.node) == 'uefi':
api = dhcp_factory.DHCPFactory().provider
ip_addresses = api.get_ip_addresses(task)
if not ip_addresses:
return
for port_ip_address in ip_addresses:
try:
ip_address_path = _get_pxe_ip_address_path(port_ip_address)
except exception.InvalidIPv4Address:
continue
utils.unlink_without_raise(ip_address_path)
else:
for mac in driver_utils.get_node_mac_addresses(task):
utils.unlink_without_raise(_get_pxe_mac_path(mac))
# TODO(lucasagomes): Backward compatibility with :hexraw,
# to be removed in M.
# see: https://bugs.launchpad.net/iotronic/+bug/1441710
if CONF.pxe.ipxe_enabled:
utils.unlink_without_raise(_get_pxe_mac_path(mac,
delimiter=''))
utils.rmtree_without_raise(os.path.join(get_root_dir(),
task.node.uuid))
def dhcp_options_for_instance(task):
"""Retrieves the DHCP PXE boot options.
:param task: A TaskManager instance.
"""
dhcp_opts = []
if CONF.pxe.ipxe_enabled:
script_name = os.path.basename(CONF.pxe.ipxe_boot_script)
ipxe_script_url = '/'.join([CONF.pxe.http_url, script_name])
dhcp_provider_name = dhcp_factory.CONF.dhcp.dhcp_provider
# if the request comes from dumb firmware send them the iPXE
# boot image.
if dhcp_provider_name == 'neutron':
# Neutron use dnsmasq as default DHCP agent, add extra config
# to neutron "dhcp-match=set:ipxe,175" and use below option
dhcp_opts.append({'opt_name': 'tag:!ipxe,bootfile-name',
'opt_value': CONF.pxe.pxe_bootfile_name})
dhcp_opts.append({'opt_name': 'tag:ipxe,bootfile-name',
'opt_value': ipxe_script_url})
else:
# !175 == non-iPXE.
# http://ipxe.org/howto/dhcpd#ipxe-specific_options
dhcp_opts.append({'opt_name': '!175,bootfile-name',
'opt_value': CONF.pxe.pxe_bootfile_name})
dhcp_opts.append({'opt_name': 'bootfile-name',
'opt_value': ipxe_script_url})
else:
if deploy_utils.get_boot_mode_for_deploy(task.node) == 'uefi':
boot_file = CONF.pxe.uefi_pxe_bootfile_name
else:
boot_file = CONF.pxe.pxe_bootfile_name
dhcp_opts.append({'opt_name': 'bootfile-name',
'opt_value': boot_file})
dhcp_opts.append({'opt_name': 'server-ip-address',
'opt_value': CONF.pxe.tftp_server})
dhcp_opts.append({'opt_name': 'tftp-server',
'opt_value': CONF.pxe.tftp_server})
return dhcp_opts

150
iotronic/common/rpc.py Normal file
View File

@ -0,0 +1,150 @@
# Copyright 2014 Red Hat, Inc.
# 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.
__all__ = [
'init',
'cleanup',
'set_defaults',
'add_extra_exmods',
'clear_extra_exmods',
'get_allowed_exmods',
'RequestContextSerializer',
'get_client',
'get_server',
'get_notifier',
'TRANSPORT_ALIASES',
]
from oslo_config import cfg
import oslo_messaging as messaging
from oslo_serialization import jsonutils
from iotronic.common import context as iotronic_context
from iotronic.common import exception
CONF = cfg.CONF
#print CONF.transport_url
TRANSPORT = None
NOTIFIER = None
ALLOWED_EXMODS = [
exception.__name__,
]
EXTRA_EXMODS = []
# NOTE(lucasagomes): The iotronic.openstack.common.rpc entries are for
# backwards compat with IceHouse rpc_backend configuration values.
TRANSPORT_ALIASES = {
'iotronic.openstack.common.rpc.impl_kombu': 'rabbit',
'iotronic.openstack.common.rpc.impl_qpid': 'qpid',
'iotronic.openstack.common.rpc.impl_zmq': 'zmq',
'iotronic.rpc.impl_kombu': 'rabbit',
'iotronic.rpc.impl_qpid': 'qpid',
'iotronic.rpc.impl_zmq': 'zmq',
}
def init(conf):
global TRANSPORT, NOTIFIER
exmods = get_allowed_exmods()
TRANSPORT = messaging.get_transport(conf,
allowed_remote_exmods=exmods,
aliases=TRANSPORT_ALIASES)
serializer = RequestContextSerializer(JsonPayloadSerializer())
NOTIFIER = messaging.Notifier(TRANSPORT, serializer=serializer)
def cleanup():
global TRANSPORT, NOTIFIER
assert TRANSPORT is not None
assert NOTIFIER is not None
TRANSPORT.cleanup()
TRANSPORT = NOTIFIER = None
def set_defaults(control_exchange):
messaging.set_transport_defaults(control_exchange)
def add_extra_exmods(*args):
EXTRA_EXMODS.extend(args)
def clear_extra_exmods():
del EXTRA_EXMODS[:]
def get_allowed_exmods():
return ALLOWED_EXMODS + EXTRA_EXMODS
class JsonPayloadSerializer(messaging.NoOpSerializer):
@staticmethod
def serialize_entity(context, entity):
return jsonutils.to_primitive(entity, convert_instances=True)
class RequestContextSerializer(messaging.Serializer):
def __init__(self, base):
self._base = base
def serialize_entity(self, context, entity):
if not self._base:
return entity
return self._base.serialize_entity(context, entity)
def deserialize_entity(self, context, entity):
if not self._base:
return entity
return self._base.deserialize_entity(context, entity)
def serialize_context(self, context):
return context.to_dict()
def deserialize_context(self, context):
return iotronic_context.RequestContext.from_dict(context)
def get_transport_url(url_str=None):
#LOG.info('yoooooooooooo')
return messaging.TransportURL.parse(CONF, url_str, TRANSPORT_ALIASES)
def get_client(target, version_cap=None, serializer=None):
assert TRANSPORT is not None
serializer = RequestContextSerializer(serializer)
return messaging.RPCClient(TRANSPORT,
target,
version_cap=version_cap,
serializer=serializer)
def get_server(target, endpoints, serializer=None):
assert TRANSPORT is not None
serializer = RequestContextSerializer(serializer)
return messaging.get_rpc_server(TRANSPORT,
target,
endpoints,
executor='eventlet',
serializer=serializer)
def get_notifier(service=None, host=None, publisher_id=None):
assert NOTIFIER is not None
if not publisher_id:
publisher_id = "%s.%s" % (service, host or CONF.host)
return NOTIFIER.prepare(publisher_id=publisher_id)

View File

@ -0,0 +1,53 @@
# Copyright 2010 United States Government as represented by the
# Administrator of the National Aeronautics and Space Administration.
# Copyright 2011 Justin Santa Barbara
# 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.
"""Utilities and helper functions that won't produce circular imports."""
import inspect
def getcallargs(function, *args, **kwargs):
"""This is a simplified inspect.getcallargs (2.7+).
It should be replaced when python >= 2.7 is standard.
"""
keyed_args = {}
argnames, varargs, keywords, defaults = inspect.getargspec(function)
keyed_args.update(kwargs)
# NOTE(alaski) the implicit 'self' or 'cls' argument shows up in
# argnames but not in args or kwargs. Uses 'in' rather than '==' because
# some tests use 'self2'.
if 'self' in argnames[0] or 'cls' == argnames[0]:
# The function may not actually be a method or have __self__.
# Typically seen when it's stubbed with mox.
if inspect.ismethod(function) and hasattr(function, '__self__'):
keyed_args[argnames[0]] = function.__self__
else:
keyed_args[argnames[0]] = None
remaining_argnames = filter(lambda x: x not in keyed_args, argnames)
keyed_args.update(dict(zip(remaining_argnames, args)))
if defaults:
num_defaults = len(defaults)
for argname, value in zip(argnames[-num_defaults:], defaults):
if argname not in keyed_args:
keyed_args[argname] = value
return keyed_args

138
iotronic/common/service.py Normal file
View File

@ -0,0 +1,138 @@
# -*- encoding: utf-8 -*-
#
# Copyright © 2012 eNovance <licensing@enovance.com>
#
# 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.
import signal
import socket
from oslo_config import cfg
from oslo_context import context
from oslo_log import log
import oslo_messaging as messaging
from oslo_utils import importutils
from iotronic.common import config
from iotronic.common.i18n import _LE
from iotronic.common.i18n import _LI
from iotronic.common import rpc
from iotronic.objects import base as objects_base
from iotronic.openstack.common import service
service_opts = [
cfg.IntOpt('periodic_interval',
default=60,
help='Seconds between running periodic tasks.'),
cfg.StrOpt('host',
default=socket.getfqdn(),
help='Name of this node. This can be an opaque identifier. '
'It is not necessarily a hostname, FQDN, or IP address. '
'However, the node name must be valid within '
'an AMQP key, and if using ZeroMQ, a valid '
'hostname, FQDN, or IP address.'),
]
cfg.CONF.register_opts(service_opts)
LOG = log.getLogger(__name__)
class RPCService(service.Service):
def __init__(self, host, manager_module, manager_class):
super(RPCService, self).__init__()
self.host = host
manager_module = importutils.try_import(manager_module)
manager_class = getattr(manager_module, manager_class)
self.manager = manager_class(host, manager_module.MANAGER_TOPIC)
self.topic = self.manager.topic
self.rpcserver = None
self.deregister = True
def start(self):
super(RPCService, self).start()
admin_context = context.RequestContext('admin', 'admin', is_admin=True)
target = messaging.Target(topic=self.topic, server=self.host)
endpoints = [self.manager]
serializer = objects_base.IotronicObjectSerializer()
self.rpcserver = rpc.get_server(target, endpoints, serializer)
self.rpcserver.start()
self.handle_signal()
self.manager.init_host()
self.tg.add_dynamic_timer(
self.manager.periodic_tasks,
periodic_interval_max=cfg.CONF.periodic_interval,
context=admin_context)
LOG.info(_LI('Created RPC server for service %(service)s on host '
'%(host)s.'),
{'service': self.topic, 'host': self.host})
def stop(self):
try:
self.rpcserver.stop()
self.rpcserver.wait()
except Exception as e:
LOG.exception(_LE('Service error occurred when stopping the '
'RPC server. Error: %s'), e)
try:
self.manager.del_host(deregister=self.deregister)
except Exception as e:
LOG.exception(_LE('Service error occurred when cleaning up '
'the RPC manager. Error: %s'), e)
super(RPCService, self).stop(graceful=True)
LOG.info(_LI('Stopped RPC server for service %(service)s on host '
'%(host)s.'),
{'service': self.topic, 'host': self.host})
def _handle_signal(self, signo, frame):
LOG.info(_LI('Got signal SIGUSR1. Not deregistering on next shutdown '
'of service %(service)s on host %(host)s.'),
{'service': self.topic, 'host': self.host})
self.deregister = False
def handle_signal(self):
"""Add a signal handler for SIGUSR1.
The handler ensures that the manager is not deregistered when it is
shutdown.
"""
signal.signal(signal.SIGUSR1, self._handle_signal)
def prepare_service(argv=[]):
log.register_options(cfg.CONF)
log.set_defaults(default_log_levels=['amqp=WARN',
'amqplib=WARN',
'qpid.messagregister_optionsing=INFO',
'oslo.messaging=INFO',
'sqlalchemy=WARN',
'keystoneclient=INFO',
'stevedore=INFO',
'eventlet.wsgi.server=WARN',
'iso8601=WARN',
'paramiko=WARN',
'requests=WARN',
'neutronclient=WARN',
'glanceclient=WARN',
'iotronic.openstack.common=WARN',
'urllib3.connectionpool=WARN',
])
config.parse_args(argv)
log.setup(cfg.CONF, 'iotronic')

298
iotronic/common/states.py Normal file
View File

@ -0,0 +1,298 @@
# Copyright (c) 2012 NTT DOCOMO, INC.
# Copyright 2010 OpenStack Foundation
# 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.
"""
Mapping of bare metal node states.
Setting the node `power_state` is handled by the conductor's power
synchronization thread. Based on the power state retrieved from the driver
for the node, the state is set to POWER_ON or POWER_OFF, accordingly.
Should this fail, the `power_state` value is left unchanged, and the node
is placed into maintenance mode.
The `power_state` can also be set manually via the API. A failure to change
the state leaves the current state unchanged. The node is NOT placed into
maintenance mode in this case.
"""
from oslo_log import log as logging
from iotronic.common import fsm
LOG = logging.getLogger(__name__)
#####################
# Provisioning states
#####################
# TODO(deva): add add'l state mappings here
VERBS = {
'active': 'deploy',
'deleted': 'delete',
'manage': 'manage',
'provide': 'provide',
'inspect': 'inspect',
}
""" Mapping of state-changing events that are PUT to the REST API
This is a mapping of target states which are PUT to the API, eg,
PUT /v1/node/states/provision {'target': 'active'}
The dict format is:
{target string used by the API: internal verb}
This provides a reference set of supported actions, and in the future
may be used to support renaming these actions.
"""
NOSTATE = None
""" No state information.
This state is used with power_state to represent a lack of knowledge of
power state, and in target_*_state fields when there is no target.
"""
MANAGEABLE = 'manageable'
""" Node is in a manageable state.
This state indicates that Iotronic has verified, at least once, that it had
sufficient information to manage the hardware. While in this state, the node
is not available for provisioning (it must be in the AVAILABLE state for that).
"""
AVAILABLE = 'available'
""" Node is available for use and scheduling.
This state is replacing the NOSTATE state used prior to Kilo.
"""
ACTIVE = 'active'
""" Node is successfully deployed and associated with an instance. """
DEPLOYWAIT = 'wait call-back'
""" Node is waiting to be deployed.
This will be the node `provision_state` while the node is waiting for
the driver to finish deployment.
"""
DEPLOYING = 'deploying'
""" Node is ready to receive a deploy request, or is currently being deployed.
A node will have its `provision_state` set to DEPLOYING briefly before it
receives its initial deploy request. It will also move to this state from
DEPLOYWAIT after the callback is triggered and deployment is continued
(disk partitioning and image copying).
"""
DEPLOYFAIL = 'deploy failed'
""" Node deployment failed. """
DEPLOYDONE = 'deploy complete'
""" Node was successfully deployed.
This is mainly a target provision state used during deployment. A successfully
deployed node should go to ACTIVE status.
"""
DELETING = 'deleting'
""" Node is actively being torn down. """
DELETED = 'deleted'
""" Node tear down was successful.
In Juno, target_provision_state was set to this value during node tear down.
In Kilo, this will be a transitory value of provision_state, and never
represented in target_provision_state.
"""
CLEANING = 'cleaning'
""" Node is being automatically cleaned to prepare it for provisioning. """
CLEANFAIL = 'clean failed'
""" Node failed cleaning. This requires operator intervention to resolve. """
ERROR = 'error'
""" An error occurred during node processing.
The `last_error` attribute of the node details should contain an error message.
"""
REBUILD = 'rebuild'
""" Node is to be rebuilt.
This is not used as a state, but rather as a "verb" when changing the node's
provision_state via the REST API.
"""
INSPECTING = 'inspecting'
""" Node is under inspection.
This is the provision state used when inspection is started. A successfully
inspected node shall transition to MANAGEABLE status.
"""
INSPECTFAIL = 'inspect failed'
""" Node inspection failed. """
UPDATE_ALLOWED_STATES = (DEPLOYFAIL, INSPECTING, INSPECTFAIL, CLEANFAIL)
"""Transitional states in which we allow updating a node."""
##############
# Power states
##############
POWER_ON = 'power on'
""" Node is powered on. """
POWER_OFF = 'power off'
""" Node is powered off. """
REBOOT = 'rebooting'
""" Node is rebooting. """
#####################
# State machine model
#####################
def on_exit(old_state, event):
"""Used to log when a state is exited."""
LOG.debug("Exiting old state '%s' in response to event '%s'",
old_state, event)
def on_enter(new_state, event):
"""Used to log when entering a state."""
LOG.debug("Entering new state '%s' in response to event '%s'",
new_state, event)
watchers = {}
watchers['on_exit'] = on_exit
watchers['on_enter'] = on_enter
machine = fsm.FSM()
# Add stable states
machine.add_state(MANAGEABLE, stable=True, **watchers)
machine.add_state(AVAILABLE, stable=True, **watchers)
machine.add_state(ACTIVE, stable=True, **watchers)
machine.add_state(ERROR, stable=True, **watchers)
# Add deploy* states
# NOTE(deva): Juno shows a target_provision_state of DEPLOYDONE
# this is changed in Kilo to ACTIVE
machine.add_state(DEPLOYING, target=ACTIVE, **watchers)
machine.add_state(DEPLOYWAIT, target=ACTIVE, **watchers)
machine.add_state(DEPLOYFAIL, target=ACTIVE, **watchers)
# Add clean* states
machine.add_state(CLEANING, target=AVAILABLE, **watchers)
machine.add_state(CLEANFAIL, target=AVAILABLE, **watchers)
# Add delete* states
machine.add_state(DELETING, target=AVAILABLE, **watchers)
# From AVAILABLE, a deployment may be started
machine.add_transition(AVAILABLE, DEPLOYING, 'deploy')
# Add inspect* states.
machine.add_state(INSPECTING, target=MANAGEABLE, **watchers)
machine.add_state(INSPECTFAIL, target=MANAGEABLE, **watchers)
# A deployment may fail
machine.add_transition(DEPLOYING, DEPLOYFAIL, 'fail')
# A failed deployment may be retried
# iotronic/conductor/manager.py:do_node_deploy()
machine.add_transition(DEPLOYFAIL, DEPLOYING, 'rebuild')
# NOTE(deva): Juno allows a client to send "active" to initiate a rebuild
machine.add_transition(DEPLOYFAIL, DEPLOYING, 'deploy')
# A deployment may also wait on external callbacks
machine.add_transition(DEPLOYING, DEPLOYWAIT, 'wait')
machine.add_transition(DEPLOYWAIT, DEPLOYING, 'resume')
# A deployment waiting on callback may time out
machine.add_transition(DEPLOYWAIT, DEPLOYFAIL, 'fail')
# A deployment may complete
machine.add_transition(DEPLOYING, ACTIVE, 'done')
# An active instance may be re-deployed
# iotronic/conductor/manager.py:do_node_deploy()
machine.add_transition(ACTIVE, DEPLOYING, 'rebuild')
# An active instance may be deleted
# iotronic/conductor/manager.py:do_node_tear_down()
machine.add_transition(ACTIVE, DELETING, 'delete')
# While a deployment is waiting, it may be deleted
# iotronic/conductor/manager.py:do_node_tear_down()
machine.add_transition(DEPLOYWAIT, DELETING, 'delete')
# A failed deployment may also be deleted
# iotronic/conductor/manager.py:do_node_tear_down()
machine.add_transition(DEPLOYFAIL, DELETING, 'delete')
# This state can also transition to error
machine.add_transition(DELETING, ERROR, 'error')
# When finished deleting, a node will begin cleaning
machine.add_transition(DELETING, CLEANING, 'clean')
# If cleaning succeeds, it becomes available for scheduling
machine.add_transition(CLEANING, AVAILABLE, 'done')
# If cleaning fails, wait for operator intervention
machine.add_transition(CLEANING, CLEANFAIL, 'fail')
# An operator may want to move a CLEANFAIL node to MANAGEABLE, to perform
# other actions like zapping
machine.add_transition(CLEANFAIL, MANAGEABLE, 'manage')
# From MANAGEABLE, a node may move to available after going through cleaning
machine.add_transition(MANAGEABLE, CLEANING, 'provide')
# From AVAILABLE, a node may be made unavailable by managing it
machine.add_transition(AVAILABLE, MANAGEABLE, 'manage')
# An errored instance can be rebuilt
# iotronic/conductor/manager.py:do_node_deploy()
machine.add_transition(ERROR, DEPLOYING, 'rebuild')
# or deleted
# iotronic/conductor/manager.py:do_node_tear_down()
machine.add_transition(ERROR, DELETING, 'delete')
# Added transitions for inspection.
# Initiate inspection.
machine.add_transition(MANAGEABLE, INSPECTING, 'inspect')
# iotronic/conductor/manager.py:inspect_hardware().
machine.add_transition(INSPECTING, MANAGEABLE, 'done')
# Inspection may fail.
machine.add_transition(INSPECTING, INSPECTFAIL, 'fail')
# Move the node to manageable state for any other
# action.
machine.add_transition(INSPECTFAIL, MANAGEABLE, 'manage')
# Reinitiate the inspect after inspectfail.
machine.add_transition(INSPECTFAIL, INSPECTING, 'inspect')

191
iotronic/common/swift.py Normal file
View File

@ -0,0 +1,191 @@
#
# Copyright 2014 OpenStack Foundation
# 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
from oslo_log import log as logging
from six.moves.urllib import parse
from swiftclient import client as swift_client
from swiftclient import exceptions as swift_exceptions
from swiftclient import utils as swift_utils
from iotronic.common import exception
from iotronic.common.i18n import _
from iotronic.common import keystone
swift_opts = [
cfg.IntOpt('swift_max_retries',
default=2,
help='Maximum number of times to retry a Swift request, '
'before failing.')
]
CONF = cfg.CONF
CONF.register_opts(swift_opts, group='swift')
CONF.import_opt('admin_user', 'keystonemiddleware.auth_token',
group='keystone_authtoken')
CONF.import_opt('admin_tenant_name', 'keystonemiddleware.auth_token',
group='keystone_authtoken')
CONF.import_opt('admin_password', 'keystonemiddleware.auth_token',
group='keystone_authtoken')
CONF.import_opt('auth_uri', 'keystonemiddleware.auth_token',
group='keystone_authtoken')
CONF.import_opt('auth_version', 'keystonemiddleware.auth_token',
group='keystone_authtoken')
CONF.import_opt('insecure', 'keystonemiddleware.auth_token',
group='keystone_authtoken')
CONF.import_opt('cafile', 'keystonemiddleware.auth_token',
group='keystone_authtoken')
LOG = logging.getLogger(__name__)
class SwiftAPI(object):
"""API for communicating with Swift."""
def __init__(self,
user=CONF.keystone_authtoken.admin_user,
tenant_name=CONF.keystone_authtoken.admin_tenant_name,
key=CONF.keystone_authtoken.admin_password,
auth_url=CONF.keystone_authtoken.auth_uri,
auth_version=CONF.keystone_authtoken.auth_version):
"""Constructor for creating a SwiftAPI object.
:param user: the name of the user for Swift account
:param tenant_name: the name of the tenant for Swift account
:param key: the 'password' or key to authenticate with
:param auth_url: the url for authentication
:param auth_version: the version of api to use for authentication
"""
auth_url = keystone.get_keystone_url(auth_url, auth_version)
params = {'retries': CONF.swift.swift_max_retries,
'insecure': CONF.keystone_authtoken.insecure,
'cacert': CONF.keystone_authtoken.cafile,
'user': user,
'tenant_name': tenant_name,
'key': key,
'authurl': auth_url,
'auth_version': auth_version}
self.connection = swift_client.Connection(**params)
def create_object(self, container, object, filename,
object_headers=None):
"""Uploads a given file to Swift.
:param container: The name of the container for the object.
:param object: The name of the object in Swift
:param filename: The file to upload, as the object data
:param object_headers: the headers for the object to pass to Swift
:returns: The Swift UUID of the object
:raises: SwiftOperationError, if any operation with Swift fails.
"""
try:
self.connection.put_container(container)
except swift_exceptions.ClientException as e:
operation = _("put container")
raise exception.SwiftOperationError(operation=operation, error=e)
with open(filename, "r") as fileobj:
try:
obj_uuid = self.connection.put_object(container,
object,
fileobj,
headers=object_headers)
except swift_exceptions.ClientException as e:
operation = _("put object")
raise exception.SwiftOperationError(operation=operation,
error=e)
return obj_uuid
def get_temp_url(self, container, object, timeout):
"""Returns the temp url for the given Swift object.
:param container: The name of the container in which Swift object
is placed.
:param object: The name of the Swift object.
:param timeout: The timeout in seconds after which the generated url
should expire.
:returns: The temp url for the object.
:raises: SwiftOperationError, if any operation with Swift fails.
"""
try:
account_info = self.connection.head_account()
except swift_exceptions.ClientException as e:
operation = _("head account")
raise exception.SwiftOperationError(operation=operation,
error=e)
storage_url, token = self.connection.get_auth()
parse_result = parse.urlparse(storage_url)
swift_object_path = '/'.join((parse_result.path, container, object))
temp_url_key = account_info['x-account-meta-temp-url-key']
url_path = swift_utils.generate_temp_url(swift_object_path, timeout,
temp_url_key, 'GET')
return parse.urlunparse((parse_result.scheme,
parse_result.netloc,
url_path,
None,
None,
None))
def delete_object(self, container, object):
"""Deletes the given Swift object.
:param container: The name of the container in which Swift object
is placed.
:param object: The name of the object in Swift to be deleted.
:raises: SwiftOperationError, if operation with Swift fails.
"""
try:
self.connection.delete_object(container, object)
except swift_exceptions.ClientException as e:
operation = _("delete object")
raise exception.SwiftOperationError(operation=operation, error=e)
def head_object(self, container, object):
"""Retrieves the information about the given Swift object.
:param container: The name of the container in which Swift object
is placed.
:param object: The name of the object in Swift
:returns: The information about the object as returned by
Swift client's head_object call.
:raises: SwiftOperationError, if operation with Swift fails.
"""
try:
return self.connection.head_object(container, object)
except swift_exceptions.ClientException as e:
operation = _("head object")
raise exception.SwiftOperationError(operation=operation, error=e)
def update_object_meta(self, container, object, object_headers):
"""Update the metadata of a given Swift object.
:param container: The name of the container in which Swift object
is placed.
:param object: The name of the object in Swift
:param object_headers: the headers for the object to pass to Swift
:raises: SwiftOperationError, if operation with Swift fails.
"""
try:
self.connection.post_object(container, object, object_headers)
except swift_exceptions.ClientException as e:
operation = _("post object")
raise exception.SwiftOperationError(operation=operation, error=e)

599
iotronic/common/utils.py Normal file
View File

@ -0,0 +1,599 @@
# Copyright 2010 United States Government as represented by the
# Administrator of the National Aeronautics and Space Administration.
# Copyright 2011 Justin Santa Barbara
# Copyright (c) 2012 NTT DOCOMO, INC.
# 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.
"""Utilities and helper functions."""
import contextlib
import errno
import hashlib
import os
import random
import re
import shutil
import tempfile
import netaddr
from oslo_concurrency import processutils
from oslo_config import cfg
from oslo_log import log as logging
from oslo_utils import excutils
import paramiko
import six
from iotronic.common import exception
from iotronic.common.i18n import _
from iotronic.common.i18n import _LE
from iotronic.common.i18n import _LW
utils_opts = [
cfg.StrOpt('rootwrap_config',
default="/etc/iotronic/rootwrap.conf",
help='Path to the rootwrap configuration file to use for '
'running commands as root.'),
cfg.StrOpt('tempdir',
help='Explicitly specify the temporary working directory.'),
]
CONF = cfg.CONF
CONF.register_opts(utils_opts)
LOG = logging.getLogger(__name__)
def _get_root_helper():
return 'sudo iotronic-rootwrap %s' % CONF.rootwrap_config
def execute(*cmd, **kwargs):
"""Convenience wrapper around oslo's execute() method.
:param cmd: Passed to processutils.execute.
:param use_standard_locale: True | False. Defaults to False. If set to
True, execute command with standard locale
added to environment variables.
:returns: (stdout, stderr) from process execution
:raises: UnknownArgumentError
:raises: ProcessExecutionError
"""
use_standard_locale = kwargs.pop('use_standard_locale', False)
if use_standard_locale:
env = kwargs.pop('env_variables', os.environ.copy())
env['LC_ALL'] = 'C'
kwargs['env_variables'] = env
if kwargs.get('run_as_root') and 'root_helper' not in kwargs:
kwargs['root_helper'] = _get_root_helper()
result = processutils.execute(*cmd, **kwargs)
LOG.debug('Execution completed, command line is "%s"',
' '.join(map(str, cmd)))
LOG.debug('Command stdout is: "%s"' % result[0])
LOG.debug('Command stderr is: "%s"' % result[1])
return result
def trycmd(*args, **kwargs):
"""Convenience wrapper around oslo's trycmd() method."""
if kwargs.get('run_as_root') and 'root_helper' not in kwargs:
kwargs['root_helper'] = _get_root_helper()
return processutils.trycmd(*args, **kwargs)
def ssh_connect(connection):
"""Method to connect to a remote system using ssh protocol.
:param connection: a dict of connection parameters.
:returns: paramiko.SSHClient -- an active ssh connection.
:raises: SSHConnectFailed
"""
try:
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
key_contents = connection.get('key_contents')
if key_contents:
data = six.moves.StringIO(key_contents)
if "BEGIN RSA PRIVATE" in key_contents:
pkey = paramiko.RSAKey.from_private_key(data)
elif "BEGIN DSA PRIVATE" in key_contents:
pkey = paramiko.DSSKey.from_private_key(data)
else:
# Can't include the key contents - secure material.
raise ValueError(_("Invalid private key"))
else:
pkey = None
ssh.connect(connection.get('host'),
username=connection.get('username'),
password=connection.get('password'),
port=connection.get('port', 22),
pkey=pkey,
key_filename=connection.get('key_filename'),
timeout=connection.get('timeout', 10))
# send TCP keepalive packets every 20 seconds
ssh.get_transport().set_keepalive(20)
except Exception as e:
LOG.debug("SSH connect failed: %s" % e)
raise exception.SSHConnectFailed(host=connection.get('host'))
return ssh
def generate_uid(topic, size=8):
characters = '01234567890abcdefghijklmnopqrstuvwxyz'
choices = [random.choice(characters) for _x in range(size)]
return '%s-%s' % (topic, ''.join(choices))
def random_alnum(size=32):
characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
return ''.join(random.choice(characters) for _ in range(size))
def delete_if_exists(pathname):
"""delete a file, but ignore file not found error."""
try:
os.unlink(pathname)
except OSError as e:
if e.errno == errno.ENOENT:
return
else:
raise
def is_valid_boolstr(val):
"""Check if the provided string is a valid bool string or not."""
boolstrs = ('true', 'false', 'yes', 'no', 'y', 'n', '1', '0')
return str(val).lower() in boolstrs
def is_valid_mac(address):
"""Verify the format of a MAC address.
Check if a MAC address is valid and contains six octets. Accepts
colon-separated format only.
:param address: MAC address to be validated.
:returns: True if valid. False if not.
"""
m = "[0-9a-f]{2}(:[0-9a-f]{2}){5}$"
return (isinstance(address, six.string_types) and
re.match(m, address.lower()))
def is_hostname_safe(hostname):
"""Determine if the supplied hostname is RFC compliant.
Check that the supplied hostname conforms to:
* http://en.wikipedia.org/wiki/Hostname
* http://tools.ietf.org/html/rfc952
* http://tools.ietf.org/html/rfc1123
Allowing for hostnames, and hostnames + domains.
:param hostname: The hostname to be validated.
:returns: True if valid. False if not.
"""
if not isinstance(hostname, six.string_types) or len(hostname) > 255:
return False
# Periods on the end of a hostname are ok, but complicates the
# regex so we'll do this manually
if hostname.endswith('.'):
hostname = hostname[:-1]
host = '[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?'
domain = '[a-z0-9\-_]{0,62}[a-z0-9]'
m = '^' + host + '(\.' + domain + ')*$'
return re.match(m, hostname) is not None
def validate_and_normalize_mac(address):
"""Validate a MAC address and return normalized form.
Checks whether the supplied MAC address is formally correct and
normalize it to all lower case.
:param address: MAC address to be validated and normalized.
:returns: Normalized and validated MAC address.
:raises: InvalidMAC If the MAC address is not valid.
"""
if not is_valid_mac(address):
raise exception.InvalidMAC(mac=address)
return address.lower()
def is_valid_ipv6_cidr(address):
try:
str(netaddr.IPNetwork(address, version=6).cidr)
return True
except Exception:
return False
def get_shortened_ipv6(address):
addr = netaddr.IPAddress(address, version=6)
return str(addr.ipv6())
def get_shortened_ipv6_cidr(address):
net = netaddr.IPNetwork(address, version=6)
return str(net.cidr)
def is_valid_cidr(address):
"""Check if the provided ipv4 or ipv6 address is a valid CIDR address."""
try:
# Validate the correct CIDR Address
netaddr.IPNetwork(address)
except netaddr.core.AddrFormatError:
return False
except UnboundLocalError:
# NOTE(MotoKen): work around bug in netaddr 0.7.5 (see detail in
# https://github.com/drkjam/netaddr/issues/2)
return False
# Prior validation partially verify /xx part
# Verify it here
ip_segment = address.split('/')
if (len(ip_segment) <= 1 or
ip_segment[1] == ''):
return False
return True
def get_ip_version(network):
"""Returns the IP version of a network (IPv4 or IPv6).
:raises: AddrFormatError if invalid network.
"""
if netaddr.IPNetwork(network).version == 6:
return "IPv6"
elif netaddr.IPNetwork(network).version == 4:
return "IPv4"
def convert_to_list_dict(lst, label):
"""Convert a value or list into a list of dicts."""
if not lst:
return None
if not isinstance(lst, list):
lst = [lst]
return [{label: x} for x in lst]
def sanitize_hostname(hostname):
"""Return a hostname which conforms to RFC-952 and RFC-1123 specs."""
if isinstance(hostname, six.text_type):
hostname = hostname.encode('latin-1', 'ignore')
hostname = re.sub(b'[ _]', b'-', hostname)
hostname = re.sub(b'[^\w.-]+', b'', hostname)
hostname = hostname.lower()
hostname = hostname.strip(b'.-')
return hostname
def read_cached_file(filename, cache_info, reload_func=None):
"""Read from a file if it has been modified.
:param cache_info: dictionary to hold opaque cache.
:param reload_func: optional function to be called with data when
file is reloaded due to a modification.
:returns: data from file
"""
mtime = os.path.getmtime(filename)
if not cache_info or mtime != cache_info.get('mtime'):
LOG.debug("Reloading cached file %s" % filename)
with open(filename) as fap:
cache_info['data'] = fap.read()
cache_info['mtime'] = mtime
if reload_func:
reload_func(cache_info['data'])
return cache_info['data']
def file_open(*args, **kwargs):
"""Open file
see built-in file() documentation for more details
Note: The reason this is kept in a separate module is to easily
be able to provide a stub module that doesn't alter system
state at all (for unit tests)
"""
return file(*args, **kwargs)
def hash_file(file_like_object):
"""Generate a hash for the contents of a file."""
checksum = hashlib.sha1()
for chunk in iter(lambda: file_like_object.read(32768), b''):
checksum.update(chunk)
return checksum.hexdigest()
@contextlib.contextmanager
def temporary_mutation(obj, **kwargs):
"""Temporarily change object attribute.
Temporarily set the attr on a particular object to a given value then
revert when finished.
One use of this is to temporarily set the read_deleted flag on a context
object:
with temporary_mutation(context, read_deleted="yes"):
do_something_that_needed_deleted_objects()
"""
def is_dict_like(thing):
return hasattr(thing, 'has_key')
def get(thing, attr, default):
if is_dict_like(thing):
return thing.get(attr, default)
else:
return getattr(thing, attr, default)
def set_value(thing, attr, val):
if is_dict_like(thing):
thing[attr] = val
else:
setattr(thing, attr, val)
def delete(thing, attr):
if is_dict_like(thing):
del thing[attr]
else:
delattr(thing, attr)
NOT_PRESENT = object()
old_values = {}
for attr, new_value in kwargs.items():
old_values[attr] = get(obj, attr, NOT_PRESENT)
set_value(obj, attr, new_value)
try:
yield
finally:
for attr, old_value in old_values.items():
if old_value is NOT_PRESENT:
delete(obj, attr)
else:
set_value(obj, attr, old_value)
@contextlib.contextmanager
def tempdir(**kwargs):
tempfile.tempdir = CONF.tempdir
tmpdir = tempfile.mkdtemp(**kwargs)
try:
yield tmpdir
finally:
try:
shutil.rmtree(tmpdir)
except OSError as e:
LOG.error(_LE('Could not remove tmpdir: %s'), e)
def mkfs(fs, path, label=None):
"""Format a file or block device
:param fs: Filesystem type (examples include 'swap', 'ext3', 'ext4'
'btrfs', etc.)
:param path: Path to file or block device to format
:param label: Volume label to use
"""
if fs == 'swap':
args = ['mkswap']
else:
args = ['mkfs', '-t', fs]
# add -F to force no interactive execute on non-block device.
if fs in ('ext3', 'ext4'):
args.extend(['-F'])
if label:
if fs in ('msdos', 'vfat'):
label_opt = '-n'
else:
label_opt = '-L'
args.extend([label_opt, label])
args.append(path)
try:
execute(*args, run_as_root=True, use_standard_locale=True)
except processutils.ProcessExecutionError as e:
with excutils.save_and_reraise_exception() as ctx:
if os.strerror(errno.ENOENT) in e.stderr:
ctx.reraise = False
LOG.exception(_LE('Failed to make file system. '
'File system %s is not supported.'), fs)
raise exception.FileSystemNotSupported(fs=fs)
else:
LOG.exception(_LE('Failed to create a file system '
'in %(path)s. Error: %(error)s'),
{'path': path, 'error': e})
def unlink_without_raise(path):
try:
os.unlink(path)
except OSError as e:
if e.errno == errno.ENOENT:
return
else:
LOG.warn(_LW("Failed to unlink %(path)s, error: %(e)s"),
{'path': path, 'e': e})
def rmtree_without_raise(path):
try:
if os.path.isdir(path):
shutil.rmtree(path)
except OSError as e:
LOG.warn(_LW("Failed to remove dir %(path)s, error: %(e)s"),
{'path': path, 'e': e})
def write_to_file(path, contents):
with open(path, 'w') as f:
f.write(contents)
def create_link_without_raise(source, link):
try:
os.symlink(source, link)
except OSError as e:
if e.errno == errno.EEXIST:
return
else:
LOG.warn(_LW("Failed to create symlink from %(source)s to %(link)s"
", error: %(e)s"),
{'source': source, 'link': link, 'e': e})
def safe_rstrip(value, chars=None):
"""Removes trailing characters from a string if that does not make it empty
:param value: A string value that will be stripped.
:param chars: Characters to remove.
:return: Stripped value.
"""
if not isinstance(value, six.string_types):
LOG.warn(_LW("Failed to remove trailing character. Returning original "
"object. Supplied object is not a string: %s,"), value)
return value
return value.rstrip(chars) or value
def mount(src, dest, *args):
"""Mounts a device/image file on specified location.
:param src: the path to the source file for mounting
:param dest: the path where it needs to be mounted.
:param args: a tuple containing the arguments to be
passed to mount command.
:raises: processutils.ProcessExecutionError if it failed
to run the process.
"""
args = ('mount', ) + args + (src, dest)
execute(*args, run_as_root=True, check_exit_code=[0])
def umount(loc, *args):
"""Umounts a mounted location.
:param loc: the path to be unmounted.
:param args: a tuple containing the argumnets to be
passed to the umount command.
:raises: processutils.ProcessExecutionError if it failed
to run the process.
"""
args = ('umount', ) + args + (loc, )
execute(*args, run_as_root=True, check_exit_code=[0])
def dd(src, dst, *args):
"""Execute dd from src to dst.
:param src: the input file for dd command.
:param dst: the output file for dd command.
:param args: a tuple containing the arguments to be
passed to dd command.
:raises: processutils.ProcessExecutionError if it failed
to run the process.
"""
LOG.debug("Starting dd process.")
execute('dd', 'if=%s' % src, 'of=%s' % dst, *args,
run_as_root=True, check_exit_code=[0])
def is_http_url(url):
url = url.lower()
return url.startswith('http://') or url.startswith('https://')
def check_dir(directory_to_check=None, required_space=1):
"""Check a directory is usable.
This function can be used by drivers to check that directories
they need to write to are usable. This should be called from the
drivers init function. This function checks that the directory
exists and then calls check_dir_writable and check_dir_free_space.
If directory_to_check is not provided the default is to use the
temp directory.
:param directory_to_check: the directory to check.
:param required_space: amount of space to check for in MiB.
:raises: PathNotFound if directory can not be found
:raises: DirectoryNotWritable if user is unable to write to the
directory
:raises InsufficientDiskSpace: if free space is < required space
"""
# check if directory_to_check is passed in, if not set to tempdir
if directory_to_check is None:
directory_to_check = (tempfile.gettempdir() if CONF.tempdir
is None else CONF.tempdir)
LOG.debug("checking directory: %s", directory_to_check)
if not os.path.exists(directory_to_check):
raise exception.PathNotFound(dir=directory_to_check)
_check_dir_writable(directory_to_check)
_check_dir_free_space(directory_to_check, required_space)
def _check_dir_writable(chk_dir):
"""Check that the chk_dir is able to be written to.
:param chk_dir: Directory to check
:raises: DirectoryNotWritable if user is unable to write to the
directory
"""
is_writable = os.access(chk_dir, os.W_OK)
if not is_writable:
raise exception.DirectoryNotWritable(dir=chk_dir)
def _check_dir_free_space(chk_dir, required_space=1):
"""Check that directory has some free space.
:param chk_dir: Directory to check
:param required_space: amount of space to check for in MiB.
:raises InsufficientDiskSpace: if free space is < required space
"""
# check that we have some free space
stat = os.statvfs(chk_dir)
# get dir free space in MiB.
free_space = float(stat.f_bsize * stat.f_bavail) / 1024 / 1024
# check for at least required_space MiB free
if free_space < required_space:
raise exception.InsufficientDiskSpace(path=chk_dir,
required=required_space,
actual=free_space)

View File

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,362 @@
# coding=utf-8
# 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.
"""
A context manager to perform a series of tasks on a set of resources.
:class:`TaskManager` is a context manager, created on-demand to allow
synchronized access to a node and its resources.
The :class:`TaskManager` will, by default, acquire an exclusive lock on
a node for the duration that the TaskManager instance exists. You may
create a TaskManager instance without locking by passing "shared=True"
when creating it, but certain operations on the resources held by such
an instance of TaskManager will not be possible. Requiring this exclusive
lock guards against parallel operations interfering with each other.
A shared lock is useful when performing non-interfering operations,
such as validating the driver interfaces.
An exclusive lock is stored in the database to coordinate between
:class:`iotronic.iotconductor.manager` instances, that are typically deployed on
different hosts.
:class:`TaskManager` methods, as well as driver methods, may be decorated to
determine whether their invocation requires an exclusive lock.
The TaskManager instance exposes certain node resources and properties as
attributes that you may access:
task.context
The context passed to TaskManager()
task.shared
False if Node is locked, True if it is not locked. (The
'shared' kwarg arg of TaskManager())
task.node
The Node object
task.ports
Ports belonging to the Node
task.driver
The Driver for the Node, or the Driver based on the
'driver_name' kwarg of TaskManager().
Example usage:
::
with task_manager.acquire(context, node_id) as task:
task.driver.power.power_on(task.node)
If you need to execute task-requiring code in a background thread, the
TaskManager instance provides an interface to handle this for you, making
sure to release resources when the thread finishes (successfully or if
an exception occurs). Common use of this is within the Manager like so:
::
with task_manager.acquire(context, node_id) as task:
<do some work>
task.spawn_after(self._spawn_worker,
utils.node_power_action, task, new_state)
All exceptions that occur in the current GreenThread as part of the
spawn handling are re-raised. You can specify a hook to execute custom
code when such exceptions occur. For example, the hook is a more elegant
solution than wrapping the "with task_manager.acquire()" with a
try..exception block. (Note that this hook does not handle exceptions
raised in the background thread.):
::
def on_error(e):
if isinstance(e, Exception):
...
with task_manager.acquire(context, node_id) as task:
<do some work>
task.set_spawn_error_hook(on_error)
task.spawn_after(self._spawn_worker,
utils.node_power_action, task, new_state)
"""
import functools
from oslo_config import cfg
from oslo_log import log as logging
from oslo_utils import excutils
import retrying
from iotronic.common import driver_factory
from iotronic.common import exception
from iotronic.common.i18n import _LW
from iotronic.common import states
from iotronic import objects
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
def require_exclusive_lock(f):
"""Decorator to require an exclusive lock.
Decorated functions must take a :class:`TaskManager` as the first
parameter. Decorated class methods should take a :class:`TaskManager`
as the first parameter after "self".
"""
@functools.wraps(f)
def wrapper(*args, **kwargs):
task = args[0] if isinstance(args[0], TaskManager) else args[1]
if task.shared:
raise exception.ExclusiveLockRequired()
return f(*args, **kwargs)
return wrapper
def acquire(context, node_id, shared=False, driver_name=None):
"""Shortcut for acquiring a lock on a Node.
:param context: Request context.
:param node_id: ID or UUID of node to lock.
:param shared: Boolean indicating whether to take a shared or exclusive
lock. Default: False.
:param driver_name: Name of Driver. Default: None.
:returns: An instance of :class:`TaskManager`.
"""
return TaskManager(context, node_id, shared=shared,
driver_name=driver_name)
class TaskManager(object):
"""Context manager for tasks.
This class wraps the locking, driver loading, and acquisition
of related resources (eg, Node and Ports) when beginning a unit of work.
"""
def __init__(self, context, node_id, shared=False, driver_name=None):
"""Create a new TaskManager.
Acquire a lock on a node. The lock can be either shared or
exclusive. Shared locks may be used for read-only or
non-disruptive actions only, and must be considerate to what
other threads may be doing on the same node at the same time.
:param context: request context
:param node_id: ID or UUID of node to lock.
:param shared: Boolean indicating whether to take a shared or exclusive
lock. Default: False.
:param driver_name: The name of the driver to load, if different
from the Node's current driver.
:raises: DriverNotFound
:raises: NodeNotFound
:raises: NodeLocked
"""
self._spawn_method = None
self._on_error_method = None
self.context = context
self.node = None
self.shared = shared
self.fsm = states.machine.copy()
# NodeLocked exceptions can be annoying. Let's try to alleviate
# some of that pain by retrying our lock attempts. The retrying
# module expects a wait_fixed value in milliseconds.
@retrying.retry(
retry_on_exception=lambda e: isinstance(e, exception.NodeLocked),
stop_max_attempt_number=CONF.conductor.node_locked_retry_attempts,
wait_fixed=CONF.conductor.node_locked_retry_interval * 1000)
def reserve_node():
LOG.debug("Attempting to reserve node %(node)s",
{'node': node_id})
self.node = objects.Node.reserve(context, CONF.host, node_id)
try:
if not self.shared:
reserve_node()
else:
self.node = objects.Node.get(context, node_id)
#self.ports = objects.Port.list_by_node_id(context, self.node.id)
#self.driver = driver_factory.get_driver(driver_name or
# self.node.driver)
# NOTE(deva): this handles the Juno-era NOSTATE state
# and should be deleted after Kilo is released
'''
if self.node.provision_state is states.NOSTATE:
self.node.provision_state = states.AVAILABLE
self.node.save()
self.fsm.initialize(self.node.provision_state)
'''
except Exception:
with excutils.save_and_reraise_exception():
self.release_resources()
def spawn_after(self, _spawn_method, *args, **kwargs):
"""Call this to spawn a thread to complete the task.
The specified method will be called when the TaskManager instance
exits.
:param _spawn_method: a method that returns a GreenThread object
:param args: args passed to the method.
:param kwargs: additional kwargs passed to the method.
"""
self._spawn_method = _spawn_method
self._spawn_args = args
self._spawn_kwargs = kwargs
def set_spawn_error_hook(self, _on_error_method, *args, **kwargs):
"""Create a hook to handle exceptions when spawning a task.
Create a hook that gets called upon an exception being raised
from spawning a background thread to do a task.
:param _on_error_method: a callable object, it's first parameter
should accept the Exception object that was raised.
:param args: additional args passed to the callable object.
:param kwargs: additional kwargs passed to the callable object.
"""
self._on_error_method = _on_error_method
self._on_error_args = args
self._on_error_kwargs = kwargs
def release_resources(self):
"""Unlock a node and release resources.
If an exclusive lock is held, unlock the node. Reset attributes
to make it clear that this instance of TaskManager should no
longer be accessed.
"""
if not self.shared:
try:
if self.node:
objects.Node.release(self.context, CONF.host, self.node.id)
except exception.NodeNotFound:
# squelch the exception if the node was deleted
# within the task's context.
pass
self.node = None
self.driver = None
self.ports = None
self.fsm = None
def _thread_release_resources(self, t):
"""Thread.link() callback to release resources."""
self.release_resources()
def process_event(self, event, callback=None, call_args=None,
call_kwargs=None, err_handler=None):
"""Process the given event for the task's current state.
:param event: the name of the event to process
:param callback: optional callback to invoke upon event transition
:param call_args: optional \*args to pass to the callback method
:param call_kwargs: optional \**kwargs to pass to the callback method
:param err_handler: optional error handler to invoke if the
callback fails, eg. because there are no workers available
(err_handler should accept arguments node, prev_prov_state, and
prev_target_state)
:raises: InvalidState if the event is not allowed by the associated
state machine
"""
# Advance the state model for the given event. Note that this doesn't
# alter the node in any way. This may raise InvalidState, if this event
# is not allowed in the current state.
self.fsm.process_event(event)
# stash current states in the error handler if callback is set,
# in case we fail to get a worker from the pool
if err_handler and callback:
self.set_spawn_error_hook(err_handler, self.node,
self.node.provision_state,
self.node.target_provision_state)
self.node.provision_state = self.fsm.current_state
self.node.target_provision_state = self.fsm.target_state
# set up the async worker
if callback:
# clear the error if we're going to start work in a callback
self.node.last_error = None
if call_args is None:
call_args = ()
if call_kwargs is None:
call_kwargs = {}
self.spawn_after(callback, *call_args, **call_kwargs)
# publish the state transition by saving the Node
self.node.save()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None and self._spawn_method is not None:
# Spawn a worker to complete the task
# The linked callback below will be called whenever:
# - background task finished with no errors.
# - background task has crashed with exception.
# - callback was added after the background task has
# finished or crashed. While eventlet currently doesn't
# schedule the new thread until the current thread blocks
# for some reason, this is true.
# All of the above are asserted in tests such that we'll
# catch if eventlet ever changes this behavior.
thread = None
try:
thread = self._spawn_method(*self._spawn_args,
**self._spawn_kwargs)
# NOTE(comstud): Trying to use a lambda here causes
# the callback to not occur for some reason. This
# also makes it easier to test.
thread.link(self._thread_release_resources)
# Don't unlock! The unlock will occur when the
# thread finshes.
return
except Exception as e:
with excutils.save_and_reraise_exception():
try:
# Execute the on_error hook if set
if self._on_error_method:
self._on_error_method(e, *self._on_error_args,
**self._on_error_kwargs)
except Exception:
LOG.warning(_LW("Task's on_error hook failed to "
"call %(method)s on node %(node)s"),
{'method': self._on_error_method.__name__,
'node': self.node.uuid})
if thread is not None:
# This means the link() failed for some
# reason. Nuke the thread.
thread.cancel()
self.release_resources()
self.release_resources()

View File

@ -0,0 +1,160 @@
# coding=utf-8
# 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_log import log
from oslo_utils import excutils
from iotronic.common import exception
from iotronic.common.i18n import _
from iotronic.common.i18n import _LI
from iotronic.common.i18n import _LW
from iotronic.common import states
from iotronic.conductor import task_manager
LOG = log.getLogger(__name__)
@task_manager.require_exclusive_lock
def node_set_boot_device(task, device, persistent=False):
"""Set the boot device for a node.
:param task: a TaskManager instance.
:param device: Boot device. Values are vendor-specific.
:param persistent: Whether to set next-boot, or make the change
permanent. Default: False.
:raises: InvalidParameterValue if the validation of the
ManagementInterface fails.
"""
if getattr(task.driver, 'management', None):
task.driver.management.validate(task)
task.driver.management.set_boot_device(task,
device=device,
persistent=persistent)
@task_manager.require_exclusive_lock
def node_power_action(task, new_state):
"""Change power state or reset for a node.
Perform the requested power action if the transition is required.
:param task: a TaskManager instance containing the node to act on.
:param new_state: Any power state from iotronic.common.states. If the
state is 'REBOOT' then a reboot will be attempted, otherwise
the node power state is directly set to 'state'.
:raises: InvalidParameterValue when the wrong state is specified
or the wrong driver info is specified.
:raises: other exceptions by the node's power driver if something
wrong occurred during the power action.
"""
node = task.node
target_state = states.POWER_ON if new_state == states.REBOOT else new_state
if new_state != states.REBOOT:
try:
curr_state = task.driver.power.get_power_state(task)
except Exception as e:
with excutils.save_and_reraise_exception():
node['last_error'] = _(
"Failed to change power state to '%(target)s'. "
"Error: %(error)s") % {'target': new_state, 'error': e}
node['target_power_state'] = states.NOSTATE
node.save()
if curr_state == new_state:
# Neither the iotronic service nor the hardware has erred. The
# node is, for some reason, already in the requested state,
# though we don't know why. eg, perhaps the user previously
# requested the node POWER_ON, the network delayed those IPMI
# packets, and they are trying again -- but the node finally
# responds to the first request, and so the second request
# gets to this check and stops.
# This isn't an error, so we'll clear last_error field
# (from previous operation), log a warning, and return.
node['last_error'] = None
# NOTE(dtantsur): under rare conditions we can get out of sync here
node['power_state'] = new_state
node['target_power_state'] = states.NOSTATE
node.save()
LOG.warn(_LW("Not going to change_node_power_state because "
"current state = requested state = '%(state)s'."),
{'state': curr_state})
return
if curr_state == states.ERROR:
# be optimistic and continue action
LOG.warn(_LW("Driver returns ERROR power state for node %s."),
node.uuid)
# Set the target_power_state and clear any last_error, if we're
# starting a new operation. This will expose to other processes
# and clients that work is in progress.
if node['target_power_state'] != target_state:
node['target_power_state'] = target_state
node['last_error'] = None
node.save()
# take power action
try:
if new_state != states.REBOOT:
task.driver.power.set_power_state(task, new_state)
else:
task.driver.power.reboot(task)
except Exception as e:
with excutils.save_and_reraise_exception():
node['last_error'] = _(
"Failed to change power state to '%(target)s'. "
"Error: %(error)s") % {'target': target_state, 'error': e}
else:
# success!
node['power_state'] = target_state
LOG.info(_LI('Successfully set node %(node)s power state to '
'%(state)s.'),
{'node': node.uuid, 'state': target_state})
finally:
node['target_power_state'] = states.NOSTATE
node.save()
@task_manager.require_exclusive_lock
def cleanup_after_timeout(task):
"""Cleanup deploy task after timeout.
:param task: a TaskManager instance.
"""
node = task.node
msg = (_('Timeout reached while waiting for callback for node %s')
% node.uuid)
node.last_error = msg
LOG.error(msg)
node.save()
error_msg = _('Cleanup failed for node %(node)s after deploy timeout: '
' %(error)s')
try:
task.driver.deploy.clean_up(task)
except exception.IotronicException as e:
msg = error_msg % {'node': node.uuid, 'error': e}
LOG.error(msg)
node.last_error = msg
node.save()
except Exception as e:
msg = error_msg % {'node': node.uuid, 'error': e}
LOG.error(msg)
node.last_error = _('Deploy timed out, but an unhandled exception was '
'encountered while aborting. More info may be '
'found in the log file.')
node.save()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,519 @@
# coding=utf-8
# 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.
"""
Client side of the conductor RPC API.
"""
import random
import oslo_messaging as messaging
from iotronic.common import exception
from iotronic.common import hash_ring
from iotronic.common.i18n import _
from iotronic.common import rpc
from iotronic.conductor import manager
from iotronic.objects import base as objects_base
class ConductorAPI(object):
"""Client side of the conductor RPC API.
API version history:
| 1.0 - Initial version.
"""
# NOTE(rloo): This must be in sync with manager.ConductorManager's.
RPC_API_VERSION = '1.0'
def __init__(self, topic=None):
super(ConductorAPI, self).__init__()
self.topic = topic
if self.topic is None:
self.topic = manager.MANAGER_TOPIC
target = messaging.Target(topic=self.topic,
version='1.0')
serializer = objects_base.IotronicObjectSerializer()
self.client = rpc.get_client(target,
version_cap=self.RPC_API_VERSION,
serializer=serializer)
# NOTE(deva): this is going to be buggy
self.ring_manager = hash_ring.HashRingManager()
def get_topic_for(self, node):
"""Get the RPC topic for the conductor service the node is mapped to.
:param node: a node object.
:returns: an RPC topic string.
:raises: NoValidHost
"""
'''
self.ring_manager.reset()
try:
ring = self.ring_manager[node.driver]
dest = ring.get_hosts(node.uuid)
return self.topic + "." + dest[0]
except exception.DriverNotFound:
reason = (_('No conductor service registered which supports '
'driver %s.') % node.driver)
raise exception.NoValidHost(reason=reason)
'''
pass
def get_topic_for_driver(self, driver_name):
"""Get RPC topic name for a conductor supporting the given driver.
The topic is used to route messages to the conductor supporting
the specified driver. A conductor is selected at random from the
set of qualified conductors.
:param driver_name: the name of the driver to route to.
:returns: an RPC topic string.
:raises: DriverNotFound
"""
self.ring_manager.reset()
hash_ring = self.ring_manager[driver_name]
host = random.choice(list(hash_ring.hosts))
return self.topic + "." + host
def update_node(self, context, node_obj, topic=None):
"""Synchronously, have a conductor update the node's information.
Update the node's information in the database and return a node object.
The conductor will lock the node while it validates the supplied
information. If driver_info is passed, it will be validated by
the core drivers. If instance_uuid is passed, it will be set or unset
only if the node is properly configured.
Note that power_state should not be passed via this method.
Use change_node_power_state for initiating driver actions.
:param context: request context.
:param node_obj: a changed (but not saved) node object.
:param topic: RPC topic. Defaults to self.topic.
:returns: updated node object, including all fields.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.1')
return cctxt.call(context, 'update_node', node_obj=node_obj)
def change_node_power_state(self, context, node_id, new_state, topic=None):
"""Change a node's power state.
Synchronously, acquire lock and start the conductor background task
to change power state of a node.
:param context: request context.
:param node_id: node id or uuid.
:param new_state: one of iotronic.common.states power state values
:param topic: RPC topic. Defaults to self.topic.
:raises: NoFreeConductorWorker when there is no free worker to start
async task.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.6')
return cctxt.call(context, 'change_node_power_state', node_id=node_id,
new_state=new_state)
def vendor_passthru(self, context, node_id, driver_method, http_method,
info, topic=None):
"""Receive requests for vendor-specific actions.
Synchronously validate driver specific info or get driver status,
and if successful invokes the vendor method. If the method mode
is async the conductor will start background worker to perform
vendor action.
:param context: request context.
:param node_id: node id or uuid.
:param driver_method: name of method for driver.
:param http_method: the HTTP method used for the request.
:param info: info for node driver.
:param topic: RPC topic. Defaults to self.topic.
:raises: InvalidParameterValue if supplied info is not valid.
:raises: MissingParameterValue if a required parameter is missing
:raises: UnsupportedDriverExtension if current driver does not have
vendor interface.
:raises: NoFreeConductorWorker when there is no free worker to start
async task.
:raises: NodeLocked if node is locked by another conductor.
:returns: A tuple containing the response of the invoked method
and a boolean value indicating whether the method was
invoked asynchronously (True) or synchronously (False).
If invoked asynchronously the response field will be
always None.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.20')
return cctxt.call(context, 'vendor_passthru', node_id=node_id,
driver_method=driver_method,
http_method=http_method,
info=info)
def driver_vendor_passthru(self, context, driver_name, driver_method,
http_method, info, topic=None):
"""Pass vendor-specific calls which don't specify a node to a driver.
Handles driver-level vendor passthru calls. These calls don't
require a node UUID and are executed on a random conductor with
the specified driver. If the method mode is async the conductor
will start background worker to perform vendor action.
:param context: request context.
:param driver_name: name of the driver on which to call the method.
:param driver_method: name of the vendor method, for use by the driver.
:param http_method: the HTTP method used for the request.
:param info: data to pass through to the driver.
:param topic: RPC topic. Defaults to self.topic.
:raises: InvalidParameterValue for parameter errors.
:raises: MissingParameterValue if a required parameter is missing
:raises: UnsupportedDriverExtension if the driver doesn't have a vendor
interface, or if the vendor interface does not support the
specified driver_method.
:raises: DriverNotFound if the supplied driver is not loaded.
:raises: NoFreeConductorWorker when there is no free worker to start
async task.
:returns: A tuple containing the response of the invoked method
and a boolean value indicating whether the method was
invoked asynchronously (True) or synchronously (False).
If invoked asynchronously the response field will be
always None.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.20')
return cctxt.call(context, 'driver_vendor_passthru',
driver_name=driver_name,
driver_method=driver_method,
http_method=http_method,
info=info)
def get_node_vendor_passthru_methods(self, context, node_id, topic=None):
"""Retrieve information about vendor methods of the given node.
:param context: an admin context.
:param node_id: the id or uuid of a node.
:param topic: RPC topic. Defaults to self.topic.
:returns: dictionary of <method name>:<method metadata> entries.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.21')
return cctxt.call(context, 'get_node_vendor_passthru_methods',
node_id=node_id)
def get_driver_vendor_passthru_methods(self, context, driver_name,
topic=None):
"""Retrieve information about vendor methods of the given driver.
:param context: an admin context.
:param driver_name: name of the driver.
:param topic: RPC topic. Defaults to self.topic.
:returns: dictionary of <method name>:<method metadata> entries.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.21')
return cctxt.call(context, 'get_driver_vendor_passthru_methods',
driver_name=driver_name)
def do_node_deploy(self, context, node_id, rebuild, configdrive,
topic=None):
"""Signal to conductor service to perform a deployment.
:param context: request context.
:param node_id: node id or uuid.
:param rebuild: True if this is a rebuild request.
:param configdrive: A gzipped and base64 encoded configdrive.
:param topic: RPC topic. Defaults to self.topic.
:raises: InstanceDeployFailure
:raises: InvalidParameterValue if validation fails
:raises: MissingParameterValue if a required parameter is missing
:raises: NoFreeConductorWorker when there is no free worker to start
async task.
The node must already be configured and in the appropriate
undeployed state before this method is called.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.22')
return cctxt.call(context, 'do_node_deploy', node_id=node_id,
rebuild=rebuild, configdrive=configdrive)
def do_node_tear_down(self, context, node_id, topic=None):
"""Signal to conductor service to tear down a deployment.
:param context: request context.
:param node_id: node id or uuid.
:param topic: RPC topic. Defaults to self.topic.
:raises: InstanceDeployFailure
:raises: InvalidParameterValue if validation fails
:raises: MissingParameterValue if a required parameter is missing
:raises: NoFreeConductorWorker when there is no free worker to start
async task.
The node must already be configured and in the appropriate
deployed state before this method is called.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.6')
return cctxt.call(context, 'do_node_tear_down', node_id=node_id)
def do_provisioning_action(self, context, node_id, action, topic=None):
"""Signal to conductor service to perform the given action on a node.
:param context: request context.
:param node_id: node id or uuid.
:param action: an action. One of iotronic.common.states.VERBS
:param topic: RPC topic. Defaults to self.topic.
:raises: InvalidParameterValue
:raises: NoFreeConductorWorker when there is no free worker to start
async task.
:raises: InvalidStateRequested if the requested action can not
be performed.
This encapsulates some provisioning actions in a single call.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.23')
return cctxt.call(context, 'do_provisioning_action',
node_id=node_id, action=action)
def continue_node_clean(self, context, node_id, topic=None):
"""Signal to conductor service to start the next cleaning action.
NOTE(JoshNang) this is an RPC cast, there will be no response or
exception raised by the conductor for this RPC.
:param context: request context.
:param node_id: node id or uuid.
:param topic: RPC topic. Defaults to self.topic.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.27')
return cctxt.cast(context, 'continue_node_clean',
node_id=node_id)
def validate_driver_interfaces(self, context, node_id, topic=None):
"""Validate the `core` and `standardized` interfaces for drivers.
:param context: request context.
:param node_id: node id or uuid.
:param topic: RPC topic. Defaults to self.topic.
:returns: a dictionary containing the results of each
interface validation.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.5')
return cctxt.call(context, 'validate_driver_interfaces',
node_id=node_id)
def destroy_node(self, context, node_id, topic=None):
"""Delete a node.
:param context: request context.
:param node_id: node id or uuid.
:raises: NodeLocked if node is locked by another conductor.
:raises: NodeAssociated if the node contains an instance
associated with it.
:raises: InvalidState if the node is in the wrong provision
state to perform deletion.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.9')
return cctxt.call(context, 'destroy_node', node_id=node_id)
def get_console_information(self, context, node_id, topic=None):
"""Get connection information about the console.
:param context: request context.
:param node_id: node id or uuid.
:param topic: RPC topic. Defaults to self.topic.
:raises: UnsupportedDriverExtension if the node's driver doesn't
support console.
:raises: InvalidParameterValue when the wrong driver info is specified.
:raises: MissingParameterValue if a required parameter is missing
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.11')
return cctxt.call(context, 'get_console_information', node_id=node_id)
def set_console_mode(self, context, node_id, enabled, topic=None):
"""Enable/Disable the console.
:param context: request context.
:param node_id: node id or uuid.
:param topic: RPC topic. Defaults to self.topic.
:param enabled: Boolean value; whether the console is enabled or
disabled.
:raises: UnsupportedDriverExtension if the node's driver doesn't
support console.
:raises: InvalidParameterValue when the wrong driver info is specified.
:raises: MissingParameterValue if a required parameter is missing
:raises: NoFreeConductorWorker when there is no free worker to start
async task.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.11')
return cctxt.call(context, 'set_console_mode', node_id=node_id,
enabled=enabled)
def update_port(self, context, port_obj, topic=None):
"""Synchronously, have a conductor update the port's information.
Update the port's information in the database and return a port object.
The conductor will lock related node and trigger specific driver
actions if they are needed.
:param context: request context.
:param port_obj: a changed (but not saved) port object.
:param topic: RPC topic. Defaults to self.topic.
:returns: updated port object, including all fields.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.13')
return cctxt.call(context, 'update_port', port_obj=port_obj)
def get_driver_properties(self, context, driver_name, topic=None):
"""Get the properties of the driver.
:param context: request context.
:param driver_name: name of the driver.
:param topic: RPC topic. Defaults to self.topic.
:returns: a dictionary with <property name>:<property description>
entries.
:raises: DriverNotFound.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.16')
return cctxt.call(context, 'get_driver_properties',
driver_name=driver_name)
def set_boot_device(self, context, node_id, device, persistent=False,
topic=None):
"""Set the boot device for a node.
Set the boot device to use on next reboot of the node. Be aware
that not all drivers support this.
:param context: request context.
:param node_id: node id or uuid.
:param device: the boot device, one of
:mod:`iotronic.common.boot_devices`.
:param persistent: Whether to set next-boot, or make the change
permanent. Default: False.
:raises: NodeLocked if node is locked by another conductor.
:raises: UnsupportedDriverExtension if the node's driver doesn't
support management.
:raises: InvalidParameterValue when the wrong driver info is
specified or an invalid boot device is specified.
:raises: MissingParameterValue if missing supplied info.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.17')
return cctxt.call(context, 'set_boot_device', node_id=node_id,
device=device, persistent=persistent)
def get_boot_device(self, context, node_id, topic=None):
"""Get the current boot device.
Returns the current boot device of a node.
:param context: request context.
:param node_id: node id or uuid.
:raises: NodeLocked if node is locked by another conductor.
:raises: UnsupportedDriverExtension if the node's driver doesn't
support management.
:raises: InvalidParameterValue when the wrong driver info is
specified.
:raises: MissingParameterValue if missing supplied info.
:returns: a dictionary containing:
:boot_device: the boot device, one of
:mod:`iotronic.common.boot_devices` or None if it is unknown.
:persistent: Whether the boot device will persist to all
future boots or not, None if it is unknown.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.17')
return cctxt.call(context, 'get_boot_device', node_id=node_id)
def get_supported_boot_devices(self, context, node_id, topic=None):
"""Get the list of supported devices.
Returns the list of supported boot devices of a node.
:param context: request context.
:param node_id: node id or uuid.
:raises: NodeLocked if node is locked by another conductor.
:raises: UnsupportedDriverExtension if the node's driver doesn't
support management.
:raises: InvalidParameterValue when the wrong driver info is
specified.
:raises: MissingParameterValue if missing supplied info.
:returns: A list with the supported boot devices defined
in :mod:`iotronic.common.boot_devices`.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.17')
return cctxt.call(context, 'get_supported_boot_devices',
node_id=node_id)
def inspect_hardware(self, context, node_id, topic=None):
"""Signals the conductor service to perform hardware introspection.
:param context: request context.
:param node_id: node id or uuid.
:param topic: RPC topic. Defaults to self.topic.
:raises: NodeLocked if node is locked by another conductor.
:raises: HardwareInspectionFailure
:raises: NoFreeConductorWorker when there is no free worker to start
async task.
:raises: UnsupportedDriverExtension if the node's driver doesn't
support inspection.
:raises: InvalidStateRequested if 'inspect' is not a valid
action to do in the current state.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.24')
return cctxt.call(context, 'inspect_hardware', node_id=node_id)
def destroy_port(self, context, port, topic=None):
"""Delete a port.
:param context: request context.
:param port: port object
:param topic: RPC topic. Defaults to self.topic.
:raises: NodeLocked if node is locked by another conductor.
:raises: NodeNotFound if the node associated with the port does not
exist.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.25')
return cctxt.call(context, 'destroy_port', port=port)
######################### NEW
def destroy_board(self, context, board_id, topic=None):
"""Delete a board.
:param context: request context.
:param board_id: board id or uuid.
:raises: BoardLocked if board is locked by another conductor.
:raises: BoardAssociated if the board contains an instance
associated with it.
:raises: InvalidState if the board is in the wrong provision
state to perform deletion.
"""
cctxt = self.client.prepare(topic=topic or self.topic, version='1.0')
return cctxt.call(context, 'destroy_board', board_id=board_id)

View File

@ -0,0 +1,363 @@
# coding=utf-8
# 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.
"""
A context manager to perform a series of tasks on a set of resources.
:class:`TaskManager` is a context manager, created on-demand to allow
synchronized access to a board and its resources.
The :class:`TaskManager` will, by default, acquire an exclusive lock on
a board for the duration that the TaskManager instance exists. You may
create a TaskManager instance without locking by passing "shared=True"
when creating it, but certain operations on the resources held by such
an instance of TaskManager will not be possible. Requiring this exclusive
lock guards against parallel operations interfering with each other.
A shared lock is useful when performing non-interfering operations,
such as validating the driver interfaces.
An exclusive lock is stored in the database to coordinate between
:class:`iotronic.iotconductor.manager` instances, that are typically deployed on
different hosts.
:class:`TaskManager` methods, as well as driver methods, may be decorated to
determine whether their invocation requires an exclusive lock.
The TaskManager instance exposes certain board resources and properties as
attributes that you may access:
task.context
The context passed to TaskManager()
task.shared
False if Board is locked, True if it is not locked. (The
'shared' kwarg arg of TaskManager())
task.board
The Board object
task.ports
Ports belonging to the Board
task.driver
The Driver for the Board, or the Driver based on the
'driver_name' kwarg of TaskManager().
Example usage:
::
with task_manager.acquire(context, board_id) as task:
task.driver.power.power_on(task.board)
If you need to execute task-requiring code in a background thread, the
TaskManager instance provides an interface to handle this for you, making
sure to release resources when the thread finishes (successfully or if
an exception occurs). Common use of this is within the Manager like so:
::
with task_manager.acquire(context, board_id) as task:
<do some work>
task.spawn_after(self._spawn_worker,
utils.board_power_action, task, new_state)
All exceptions that occur in the current GreenThread as part of the
spawn handling are re-raised. You can specify a hook to execute custom
code when such exceptions occur. For example, the hook is a more elegant
solution than wrapping the "with task_manager.acquire()" with a
try..exception block. (Note that this hook does not handle exceptions
raised in the background thread.):
::
def on_error(e):
if isinstance(e, Exception):
...
with task_manager.acquire(context, board_id) as task:
<do some work>
task.set_spawn_error_hook(on_error)
task.spawn_after(self._spawn_worker,
utils.board_power_action, task, new_state)
"""
import functools
from oslo_config import cfg
from oslo_log import log as logging
from oslo_utils import excutils
import retrying
from iotronic.common import driver_factory
from iotronic.common import exception
from iotronic.common.i18n import _LW
from iotronic.common import states
from iotronic import objects
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
def require_exclusive_lock(f):
"""Decorator to require an exclusive lock.
Decorated functions must take a :class:`TaskManager` as the first
parameter. Decorated class methods should take a :class:`TaskManager`
as the first parameter after "self".
"""
@functools.wraps(f)
def wrapper(*args, **kwargs):
task = args[0] if isinstance(args[0], TaskManager) else args[1]
if task.shared:
raise exception.ExclusiveLockRequired()
return f(*args, **kwargs)
return wrapper
def acquire(context, board_id, shared=False, driver_name=None):
"""Shortcut for acquiring a lock on a Board.
:param context: Request context.
:param board_id: ID or UUID of board to lock.
:param shared: Boolean indicating whether to take a shared or exclusive
lock. Default: False.
:param driver_name: Name of Driver. Default: None.
:returns: An instance of :class:`TaskManager`.
"""
return TaskManager(context, board_id, shared=shared,
driver_name=driver_name)
class TaskManager(object):
"""Context manager for tasks.
This class wraps the locking, driver loading, and acquisition
of related resources (eg, Board and Ports) when beginning a unit of work.
"""
def __init__(self, context, board_id, shared=False, driver_name=None):
"""Create a new TaskManager.
Acquire a lock on a board. The lock can be either shared or
exclusive. Shared locks may be used for read-only or
non-disruptive actions only, and must be considerate to what
other threads may be doing on the same board at the same time.
:param context: request context
:param board_id: ID or UUID of board to lock.
:param shared: Boolean indicating whether to take a shared or exclusive
lock. Default: False.
:param driver_name: The name of the driver to load, if different
from the Board's current driver.
:raises: DriverNotFound
:raises: BoardNotFound
:raises: BoardLocked
"""
self._spawn_method = None
self._on_error_method = None
self.context = context
#self.board = None
self.board = None
self.shared = shared
self.fsm = states.machine.copy()
# BoardLocked exceptions can be annoying. Let's try to alleviate
# some of that pain by retrying our lock attempts. The retrying
# module expects a wait_fixed value in milliseconds.
@retrying.retry(
retry_on_exception=lambda e: isinstance(e, exception.BoardLocked),
stop_max_attempt_number=CONF.conductor.board_locked_retry_attempts,
wait_fixed=CONF.conductor.board_locked_retry_interval * 1000)
def reserve_board():
LOG.debug("Attempting to reserve board %(board)s",
{'board': board_id})
self.board = objects.Board.reserve(context, CONF.host, board_id)
try:
if not self.shared:
reserve_board()
else:
self.board = objects.Board.get(context, board_id)
#self.ports = objects.Port.list_by_board_id(context, self.board.id)
#self.driver = driver_factory.get_driver(driver_name or
# self.board.driver)
# NOTE(deva): this handles the Juno-era NOSTATE state
# and should be deleted after Kilo is released
'''
if self.board.provision_state is states.NOSTATE:
self.board.provision_state = states.AVAILABLE
self.board.save()
self.fsm.initialize(self.board.provision_state)
'''
except Exception:
with excutils.save_and_reraise_exception():
self.release_resources()
def spawn_after(self, _spawn_method, *args, **kwargs):
"""Call this to spawn a thread to complete the task.
The specified method will be called when the TaskManager instance
exits.
:param _spawn_method: a method that returns a GreenThread object
:param args: args passed to the method.
:param kwargs: additional kwargs passed to the method.
"""
self._spawn_method = _spawn_method
self._spawn_args = args
self._spawn_kwargs = kwargs
def set_spawn_error_hook(self, _on_error_method, *args, **kwargs):
"""Create a hook to handle exceptions when spawning a task.
Create a hook that gets called upon an exception being raised
from spawning a background thread to do a task.
:param _on_error_method: a callable object, it's first parameter
should accept the Exception object that was raised.
:param args: additional args passed to the callable object.
:param kwargs: additional kwargs passed to the callable object.
"""
self._on_error_method = _on_error_method
self._on_error_args = args
self._on_error_kwargs = kwargs
def release_resources(self):
"""Unlock a board and release resources.
If an exclusive lock is held, unlock the board. Reset attributes
to make it clear that this instance of TaskManager should no
longer be accessed.
"""
if not self.shared:
try:
if self.board:
objects.Board.release(self.context, CONF.host, self.board.id)
except exception.BoardNotFound:
# squelch the exception if the board was deleted
# within the task's context.
pass
self.board = None
self.driver = None
self.ports = None
self.fsm = None
def _thread_release_resources(self, t):
"""Thread.link() callback to release resources."""
self.release_resources()
def process_event(self, event, callback=None, call_args=None,
call_kwargs=None, err_handler=None):
"""Process the given event for the task's current state.
:param event: the name of the event to process
:param callback: optional callback to invoke upon event transition
:param call_args: optional \*args to pass to the callback method
:param call_kwargs: optional \**kwargs to pass to the callback method
:param err_handler: optional error handler to invoke if the
callback fails, eg. because there are no workers available
(err_handler should accept arguments board, prev_prov_state, and
prev_target_state)
:raises: InvalidState if the event is not allowed by the associated
state machine
"""
# Advance the state model for the given event. Note that this doesn't
# alter the board in any way. This may raise InvalidState, if this event
# is not allowed in the current state.
self.fsm.process_event(event)
# stash current states in the error handler if callback is set,
# in case we fail to get a worker from the pool
if err_handler and callback:
self.set_spawn_error_hook(err_handler, self.board,
self.board.provision_state,
self.board.target_provision_state)
self.board.provision_state = self.fsm.current_state
self.board.target_provision_state = self.fsm.target_state
# set up the async worker
if callback:
# clear the error if we're going to start work in a callback
self.board.last_error = None
if call_args is None:
call_args = ()
if call_kwargs is None:
call_kwargs = {}
self.spawn_after(callback, *call_args, **call_kwargs)
# publish the state transition by saving the Board
self.board.save()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None and self._spawn_method is not None:
# Spawn a worker to complete the task
# The linked callback below will be called whenever:
# - background task finished with no errors.
# - background task has crashed with exception.
# - callback was added after the background task has
# finished or crashed. While eventlet currently doesn't
# schedule the new thread until the current thread blocks
# for some reason, this is true.
# All of the above are asserted in tests such that we'll
# catch if eventlet ever changes this behavior.
thread = None
try:
thread = self._spawn_method(*self._spawn_args,
**self._spawn_kwargs)
# NOTE(comstud): Trying to use a lambda here causes
# the callback to not occur for some reason. This
# also makes it easier to test.
thread.link(self._thread_release_resources)
# Don't unlock! The unlock will occur when the
# thread finshes.
return
except Exception as e:
with excutils.save_and_reraise_exception():
try:
# Execute the on_error hook if set
if self._on_error_method:
self._on_error_method(e, *self._on_error_args,
**self._on_error_kwargs)
except Exception:
LOG.warning(_LW("Task's on_error hook failed to "
"call %(method)s on board %(board)s"),
{'method': self._on_error_method.__name__,
'board': self.board.uuid})
if thread is not None:
# This means the link() failed for some
# reason. Nuke the thread.
thread.cancel()
self.release_resources()
self.release_resources()

160
iotronic/conductor/utils.py Normal file
View File

@ -0,0 +1,160 @@
# coding=utf-8
# 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_log import log
from oslo_utils import excutils
from iotronic.common import exception
from iotronic.common.i18n import _
from iotronic.common.i18n import _LI
from iotronic.common.i18n import _LW
from iotronic.common import states
from iotronic.conductor import task_manager
LOG = log.getLogger(__name__)
@task_manager.require_exclusive_lock
def node_set_boot_device(task, device, persistent=False):
"""Set the boot device for a node.
:param task: a TaskManager instance.
:param device: Boot device. Values are vendor-specific.
:param persistent: Whether to set next-boot, or make the change
permanent. Default: False.
:raises: InvalidParameterValue if the validation of the
ManagementInterface fails.
"""
if getattr(task.driver, 'management', None):
task.driver.management.validate(task)
task.driver.management.set_boot_device(task,
device=device,
persistent=persistent)
@task_manager.require_exclusive_lock
def node_power_action(task, new_state):
"""Change power state or reset for a node.
Perform the requested power action if the transition is required.
:param task: a TaskManager instance containing the node to act on.
:param new_state: Any power state from iotronic.common.states. If the
state is 'REBOOT' then a reboot will be attempted, otherwise
the node power state is directly set to 'state'.
:raises: InvalidParameterValue when the wrong state is specified
or the wrong driver info is specified.
:raises: other exceptions by the node's power driver if something
wrong occurred during the power action.
"""
node = task.node
target_state = states.POWER_ON if new_state == states.REBOOT else new_state
if new_state != states.REBOOT:
try:
curr_state = task.driver.power.get_power_state(task)
except Exception as e:
with excutils.save_and_reraise_exception():
node['last_error'] = _(
"Failed to change power state to '%(target)s'. "
"Error: %(error)s") % {'target': new_state, 'error': e}
node['target_power_state'] = states.NOSTATE
node.save()
if curr_state == new_state:
# Neither the iotronic service nor the hardware has erred. The
# node is, for some reason, already in the requested state,
# though we don't know why. eg, perhaps the user previously
# requested the node POWER_ON, the network delayed those IPMI
# packets, and they are trying again -- but the node finally
# responds to the first request, and so the second request
# gets to this check and stops.
# This isn't an error, so we'll clear last_error field
# (from previous operation), log a warning, and return.
node['last_error'] = None
# NOTE(dtantsur): under rare conditions we can get out of sync here
node['power_state'] = new_state
node['target_power_state'] = states.NOSTATE
node.save()
LOG.warn(_LW("Not going to change_node_power_state because "
"current state = requested state = '%(state)s'."),
{'state': curr_state})
return
if curr_state == states.ERROR:
# be optimistic and continue action
LOG.warn(_LW("Driver returns ERROR power state for node %s."),
node.uuid)
# Set the target_power_state and clear any last_error, if we're
# starting a new operation. This will expose to other processes
# and clients that work is in progress.
if node['target_power_state'] != target_state:
node['target_power_state'] = target_state
node['last_error'] = None
node.save()
# take power action
try:
if new_state != states.REBOOT:
task.driver.power.set_power_state(task, new_state)
else:
task.driver.power.reboot(task)
except Exception as e:
with excutils.save_and_reraise_exception():
node['last_error'] = _(
"Failed to change power state to '%(target)s'. "
"Error: %(error)s") % {'target': target_state, 'error': e}
else:
# success!
node['power_state'] = target_state
LOG.info(_LI('Successfully set node %(node)s power state to '
'%(state)s.'),
{'node': node.uuid, 'state': target_state})
finally:
node['target_power_state'] = states.NOSTATE
node.save()
@task_manager.require_exclusive_lock
def cleanup_after_timeout(task):
"""Cleanup deploy task after timeout.
:param task: a TaskManager instance.
"""
node = task.node
msg = (_('Timeout reached while waiting for callback for node %s')
% node.uuid)
node.last_error = msg
LOG.error(msg)
node.save()
error_msg = _('Cleanup failed for node %(node)s after deploy timeout: '
' %(error)s')
try:
task.driver.deploy.clean_up(task)
except exception.IotronicException as e:
msg = error_msg % {'node': node.uuid, 'error': e}
LOG.error(msg)
node.last_error = msg
node.save()
except Exception as e:
msg = error_msg % {'node': node.uuid, 'error': e}
LOG.error(msg)
node.last_error = _('Deploy timed out, but an unhandled exception was '
'encountered while aborting. More info may be '
'found in the log file.')
node.save()

0
iotronic/db/__init__.py Normal file
View File

488
iotronic/db/api.py Normal file
View File

@ -0,0 +1,488 @@
# -*- encoding: utf-8 -*-
#
# Copyright 2013 Hewlett-Packard Development Company, L.P.
#
# 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.
"""
Base classes for storage engines
"""
import abc
from oslo_config import cfg
from oslo_db import api as db_api
import six
_BACKEND_MAPPING = {'sqlalchemy': 'iotronic.db.sqlalchemy.api'}
IMPL = db_api.DBAPI.from_config(cfg.CONF, backend_mapping=_BACKEND_MAPPING,
lazy=True)
def get_instance():
"""Return a DB API instance."""
return IMPL
@six.add_metaclass(abc.ABCMeta)
class Connection(object):
"""Base class for storage system connections."""
@abc.abstractmethod
def __init__(self):
"""Constructor."""
@abc.abstractmethod
def get_nodeinfo_list(self, columns=None, filters=None, limit=None,
marker=None, sort_key=None, sort_dir=None):
"""Get specific columns for matching nodes.
Return a list of the specified columns for all nodes that match the
specified filters.
:param columns: List of column names to return.
Defaults to 'id' column when columns == None.
:param filters: Filters to apply. Defaults to None.
:associated: True | False
:reserved: True | False
:maintenance: True | False
:chassis_uuid: uuid of chassis
:driver: driver's name
:provision_state: provision state of node
:provisioned_before:
nodes with provision_updated_at field before this
interval in seconds
:param limit: Maximum number of nodes to return.
:param marker: the last item of the previous page; we return the next
result set.
:param sort_key: Attribute by which results should be sorted.
:param sort_dir: direction in which results should be sorted.
(asc, desc)
:returns: A list of tuples of the specified columns.
"""
@abc.abstractmethod
def get_node_list(self, filters=None, limit=None, marker=None,
sort_key=None, sort_dir=None):
"""Return a list of nodes.
:param filters: Filters to apply. Defaults to None.
:associated: True | False
:reserved: True | False
:maintenance: True | False
:chassis_uuid: uuid of chassis
:driver: driver's name
:provision_state: provision state of node
:provisioned_before:
nodes with provision_updated_at field before this
interval in seconds
:param limit: Maximum number of nodes to return.
:param marker: the last item of the previous page; we return the next
result set.
:param sort_key: Attribute by which results should be sorted.
:param sort_dir: direction in which results should be sorted.
(asc, desc)
"""
@abc.abstractmethod
def reserve_node(self, tag, node_id):
"""Reserve a node.
To prevent other ManagerServices from manipulating the given
Node while a Task is performed, mark it reserved by this host.
:param tag: A string uniquely identifying the reservation holder.
:param node_id: A node id or uuid.
:returns: A Node object.
:raises: NodeNotFound if the node is not found.
:raises: NodeLocked if the node is already reserved.
"""
@abc.abstractmethod
def release_node(self, tag, node_id):
"""Release the reservation on a node.
:param tag: A string uniquely identifying the reservation holder.
:param node_id: A node id or uuid.
:raises: NodeNotFound if the node is not found.
:raises: NodeLocked if the node is reserved by another host.
:raises: NodeNotLocked if the node was found to not have a
reservation at all.
"""
@abc.abstractmethod
def create_node(self, values):
"""Create a new node.
:param values: A dict containing several items used to identify
and track the node, and several dicts which are passed
into the Drivers when managing this node. For example:
::
{
'uuid': uuidutils.generate_uuid(),
'instance_uuid': None,
'power_state': states.POWER_OFF,
'provision_state': states.AVAILABLE,
'driver': 'pxe_ipmitool',
'driver_info': { ... },
'properties': { ... },
'extra': { ... },
}
:returns: A node.
"""
@abc.abstractmethod
def get_node_by_id(self, node_id):
"""Return a node.
:param node_id: The id of a node.
:returns: A node.
"""
@abc.abstractmethod
def get_node_by_uuid(self, node_uuid):
"""Return a node.
:param node_uuid: The uuid of a node.
:returns: A node.
"""
@abc.abstractmethod
def get_node_by_name(self, node_name):
"""Return a node.
:param node_name: The logical name of a node.
:returns: A node.
"""
@abc.abstractmethod
def get_node_by_instance(self, instance):
"""Return a node.
:param instance: The instance name or uuid to search for.
:returns: A node.
"""
@abc.abstractmethod
def destroy_node(self, node_id):
"""Destroy a node and all associated interfaces.
:param node_id: The id or uuid of a node.
"""
@abc.abstractmethod
def update_node(self, node_id, values):
"""Update properties of a node.
:param node_id: The id or uuid of a node.
:param values: Dict of values to update.
May be a partial list, eg. when setting the
properties for a driver. For example:
::
{
'driver_info':
{
'my-field-1': val1,
'my-field-2': val2,
}
}
:returns: A node.
:raises: NodeAssociated
:raises: NodeNotFound
"""
@abc.abstractmethod
def get_port_by_id(self, port_id):
"""Return a network port representation.
:param port_id: The id of a port.
:returns: A port.
"""
@abc.abstractmethod
def get_port_by_uuid(self, port_uuid):
"""Return a network port representation.
:param port_uuid: The uuid of a port.
:returns: A port.
"""
@abc.abstractmethod
def get_port_by_address(self, address):
"""Return a network port representation.
:param address: The MAC address of a port.
:returns: A port.
"""
@abc.abstractmethod
def get_port_list(self, limit=None, marker=None,
sort_key=None, sort_dir=None):
"""Return a list of ports.
:param limit: Maximum number of ports to return.
:param marker: the last item of the previous page; we return the next
result set.
:param sort_key: Attribute by which results should be sorted.
:param sort_dir: direction in which results should be sorted.
(asc, desc)
"""
@abc.abstractmethod
def get_ports_by_node_id(self, node_id, limit=None, marker=None,
sort_key=None, sort_dir=None):
"""List all the ports for a given node.
:param node_id: The integer node ID.
:param limit: Maximum number of ports to return.
:param marker: the last item of the previous page; we return the next
result set.
:param sort_key: Attribute by which results should be sorted
:param sort_dir: direction in which results should be sorted
(asc, desc)
:returns: A list of ports.
"""
@abc.abstractmethod
def create_port(self, values):
"""Create a new port.
:param values: Dict of values.
"""
@abc.abstractmethod
def update_port(self, port_id, values):
"""Update properties of an port.
:param port_id: The id or MAC of a port.
:param values: Dict of values to update.
:returns: A port.
"""
@abc.abstractmethod
def destroy_port(self, port_id):
"""Destroy an port.
:param port_id: The id or MAC of a port.
"""
@abc.abstractmethod
def create_chassis(self, values):
"""Create a new chassis.
:param values: Dict of values.
"""
@abc.abstractmethod
def get_chassis_by_id(self, chassis_id):
"""Return a chassis representation.
:param chassis_id: The id of a chassis.
:returns: A chassis.
"""
@abc.abstractmethod
def get_chassis_by_uuid(self, chassis_uuid):
"""Return a chassis representation.
:param chassis_uuid: The uuid of a chassis.
:returns: A chassis.
"""
@abc.abstractmethod
def get_chassis_list(self, limit=None, marker=None,
sort_key=None, sort_dir=None):
"""Return a list of chassis.
:param limit: Maximum number of chassis to return.
:param marker: the last item of the previous page; we return the next
result set.
:param sort_key: Attribute by which results should be sorted.
:param sort_dir: direction in which results should be sorted.
(asc, desc)
"""
@abc.abstractmethod
def update_chassis(self, chassis_id, values):
"""Update properties of an chassis.
:param chassis_id: The id or the uuid of a chassis.
:param values: Dict of values to update.
:returns: A chassis.
"""
@abc.abstractmethod
def destroy_chassis(self, chassis_id):
"""Destroy a chassis.
:param chassis_id: The id or the uuid of a chassis.
"""
@abc.abstractmethod
def register_conductor(self, values, update_existing=False):
"""Register an active conductor with the cluster.
:param values: A dict of values which must contain the following:
::
{
'hostname': the unique hostname which identifies
this Conductor service.
'drivers': a list of supported drivers.
}
:param update_existing: When false, registration will raise an
exception when a conflicting online record
is found. When true, will overwrite the
existing record. Default: False.
:returns: A conductor.
:raises: ConductorAlreadyRegistered
"""
@abc.abstractmethod
def get_conductor(self, hostname):
"""Retrieve a conductor's service record from the database.
:param hostname: The hostname of the conductor service.
:returns: A conductor.
:raises: ConductorNotFound
"""
@abc.abstractmethod
def unregister_conductor(self, hostname):
"""Remove this conductor from the service registry immediately.
:param hostname: The hostname of this conductor service.
:raises: ConductorNotFound
"""
@abc.abstractmethod
def touch_conductor(self, hostname):
"""Mark a conductor as active by updating its 'updated_at' property.
:param hostname: The hostname of this conductor service.
:raises: ConductorNotFound
"""
@abc.abstractmethod
def get_active_driver_dict(self, interval):
"""Retrieve drivers for the registered and active conductors.
:param interval: Seconds since last check-in of a conductor.
:returns: A dict which maps driver names to the set of hosts
which support them. For example:
::
{driverA: set([host1, host2]),
driverB: set([host2, host3])}
"""
###################### NEW #############################
@abc.abstractmethod
def get_board_by_uuid(self, node_uuid):
"""Return a node.
:param node_uuid: The uuid of a node.
:returns: A node.
"""
@abc.abstractmethod
def get_board_list(self, filters=None, limit=None, marker=None,
sort_key=None, sort_dir=None):
"""Return a list of nodes.
:param filters: Filters to apply. Defaults to None.
:associated: True | False
:reserved: True | False
:maintenance: True | False
:chassis_uuid: uuid of chassis
:driver: driver's name
:provision_state: provision state of node
:provisioned_before:
nodes with provision_updated_at field before this
interval in seconds
:param limit: Maximum number of nodes to return.
:param marker: the last item of the previous page; we return the next
result set.
:param sort_key: Attribute by which results should be sorted.
:param sort_dir: direction in which results should be sorted.
(asc, desc)
"""
@abc.abstractmethod
def reserve_board(self, tag, board_id):
"""Reserve a board.
To prevent other ManagerServices from manipulating the given
Board while a Task is performed, mark it reserved by this host.
:param tag: A string uniquely identifying the reservation holder.
:param board_id: A board id or uuid.
:returns: A Board object.
:raises: BoardNotFound if the board is not found.
:raises: BoardLocked if the board is already reserved.
"""
@abc.abstractmethod
def release_board(self, tag, board_id):
"""Release the reservation on a board.
:param tag: A string uniquely identifying the reservation holder.
:param board_id: A board id or uuid.
:raises: BoardNotFound if the board is not found.
:raises: BoardLocked if the board is reserved by another host.
:raises: BoardNotLocked if the board was found to not have a
reservation at all.
"""
@abc.abstractmethod
def destroy_board(self, board_id):
"""Destroy a board and all associated interfaces.
:param board_id: The id or uuid of a board.
"""
@abc.abstractmethod
def create_board(self, values):
"""Create a new board.
:param values: A dict containing several items used to identify
and track the board, and several dicts which are passed
into the Drivers when managing this board. For example:
::
{
'uuid': uuidutils.generate_uuid(),
'instance_uuid': None,
'power_state': states.POWER_OFF,
'provision_state': states.AVAILABLE,
'driver': 'pxe_ipmitool',
'driver_info': { ... },
'properties': { ... },
'extra': { ... },
}
:returns: A board.
"""

56
iotronic/db/migration.py Normal file
View File

@ -0,0 +1,56 @@
# Copyright 2010 United States Government as represented by the
# Administrator of the National Aeronautics and Space Administration.
# 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.
"""Database setup and migration commands."""
from oslo_config import cfg
from stevedore import driver
_IMPL = None
def get_backend():
global _IMPL
if not _IMPL:
cfg.CONF.import_opt('backend', 'oslo_db.options', group='database')
_IMPL = driver.DriverManager("iotronic.database.migration_backend",
cfg.CONF.database.backend).driver
return _IMPL
def upgrade(version=None):
"""Migrate the database to `version` or the most recent version."""
return get_backend().upgrade(version)
def downgrade(version=None):
return get_backend().downgrade(version)
def version():
return get_backend().version()
def stamp(version):
return get_backend().stamp(version)
def revision(message, autogenerate):
return get_backend().revision(message, autogenerate)
def create_schema():
return get_backend().create_schema()

View File

View File

@ -0,0 +1,54 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = %(here)s/alembic
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# max length of characters to apply to the
# "slug" field
#truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
#sqlalchemy.url = driver://user:pass@localhost/dbname
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@ -0,0 +1,16 @@
Please see https://alembic.readthedocs.org/en/latest/index.html for general documentation
To create alembic migrations use:
$ iotronic-dbsync revision --message --autogenerate
Stamp db with most recent migration version, without actually running migrations
$ iotronic-dbsync stamp --revision head
Upgrade can be performed by:
$ iotronic-dbsync - for backward compatibility
$ iotronic-dbsync upgrade
# iotronic-dbsync upgrade --revision head
Downgrading db:
$ iotronic-dbsync downgrade
$ iotronic-dbsync downgrade --revision base

View File

@ -0,0 +1,61 @@
# 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 logging import config as log_config
from alembic import context
try:
# NOTE(whaom): This is to register the DB2 alembic code which
# is an optional runtime dependency.
from ibm_db_alembic.ibm_db import IbmDbImpl # noqa
except ImportError:
pass
from iotronic.db.sqlalchemy import api as sqla_api
from iotronic.db.sqlalchemy import models
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
log_config.fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
target_metadata = models.Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
engine = sqla_api.get_engine()
with engine.connect() as connection:
context.configure(connection=connection,
target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
run_migrations_online()

View File

@ -0,0 +1,22 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision}
Create Date: ${create_date}
"""
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,40 @@
# 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.
"""add inspection_started_at and inspection_finished_at
Revision ID: 1e1d5ace7dc6
Revises: 3ae36a5f5131
Create Date: 2015-02-26 10:46:46.861927
"""
# revision identifiers, used by Alembic.
revision = '1e1d5ace7dc6'
down_revision = '3ae36a5f5131'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('nodes', sa.Column('inspection_started_at',
sa.DateTime(),
nullable=True))
op.add_column('nodes', sa.Column('inspection_finished_at',
sa.DateTime(),
nullable=True))
def downgrade():
op.drop_column('nodes', 'inspection_started_at')
op.drop_column('nodes', 'inspection_finished_at')

View File

@ -0,0 +1,35 @@
# 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.
"""Add provision_updated_at
Revision ID: 21b331f883ef
Revises: 2581ebaf0cb2
Create Date: 2014-02-19 13:45:30.150632
"""
# revision identifiers, used by Alembic.
revision = '21b331f883ef'
down_revision = '2581ebaf0cb2'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('nodes', sa.Column('provision_updated_at', sa.DateTime(),
nullable=True))
def downgrade():
op.drop_column('nodes', 'provision_updated_at')

View File

@ -0,0 +1,36 @@
# 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.
"""Add Node.maintenance_reason
Revision ID: 242cc6a923b3
Revises: 487deb87cc9d
Create Date: 2014-10-15 23:00:43.164061
"""
# revision identifiers, used by Alembic.
revision = '242cc6a923b3'
down_revision = '487deb87cc9d'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('nodes', sa.Column('maintenance_reason',
sa.Text(),
nullable=True))
def downgrade():
op.drop_column('nodes', 'maintenance_reason')

View File

@ -0,0 +1,106 @@
#
# 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.
"""initial migration
Revision ID: 2581ebaf0cb2
Revises: None
Create Date: 2014-01-17 12:14:07.754448
"""
# revision identifiers, used by Alembic.
revision = '2581ebaf0cb2'
down_revision = None
from alembic import op
import sqlalchemy as sa
def upgrade():
# commands auto generated by Alembic - please adjust!
op.create_table(
'conductors',
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('hostname', sa.String(length=255), nullable=False),
sa.Column('drivers', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('hostname', name='uniq_conductors0hostname'),
mysql_ENGINE='InnoDB',
mysql_DEFAULT_CHARSET='UTF8'
)
op.create_table(
'chassis',
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('uuid', sa.String(length=36), nullable=True),
sa.Column('extra', sa.Text(), nullable=True),
sa.Column('description', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('uuid', name='uniq_chassis0uuid'),
mysql_ENGINE='InnoDB',
mysql_DEFAULT_CHARSET='UTF8'
)
op.create_table(
'nodes',
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('uuid', sa.String(length=36), nullable=True),
sa.Column('instance_uuid', sa.String(length=36), nullable=True),
sa.Column('chassis_id', sa.Integer(), nullable=True),
sa.Column('power_state', sa.String(length=15), nullable=True),
sa.Column('target_power_state', sa.String(length=15), nullable=True),
sa.Column('provision_state', sa.String(length=15), nullable=True),
sa.Column('target_provision_state', sa.String(length=15),
nullable=True),
sa.Column('last_error', sa.Text(), nullable=True),
sa.Column('properties', sa.Text(), nullable=True),
sa.Column('driver', sa.String(length=15), nullable=True),
sa.Column('driver_info', sa.Text(), nullable=True),
sa.Column('reservation', sa.String(length=255), nullable=True),
sa.Column('maintenance', sa.Boolean(), nullable=True),
sa.Column('extra', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['chassis_id'], ['chassis.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('uuid', name='uniq_nodes0uuid'),
mysql_ENGINE='InnoDB',
mysql_DEFAULT_CHARSET='UTF8'
)
op.create_index('node_instance_uuid', 'nodes', ['instance_uuid'],
unique=False)
op.create_table(
'ports',
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('uuid', sa.String(length=36), nullable=True),
sa.Column('address', sa.String(length=18), nullable=True),
sa.Column('node_id', sa.Integer(), nullable=True),
sa.Column('extra', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['node_id'], ['nodes.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('address', name='uniq_ports0address'),
sa.UniqueConstraint('uuid', name='uniq_ports0uuid'),
mysql_ENGINE='InnoDB',
mysql_DEFAULT_CHARSET='UTF8'
)
# end Alembic commands
def downgrade():
raise NotImplementedError(('Downgrade from initial migration is'
' unsupported.'))

View File

@ -0,0 +1,42 @@
# 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.
"""increase-node-name-length
Revision ID: 2fb93ffd2af1
Revises: 4f399b21ae71
Create Date: 2015-03-18 17:08:11.470791
"""
# revision identifiers, used by Alembic.
revision = '2fb93ffd2af1'
down_revision = '4f399b21ae71'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
def upgrade():
op.alter_column('nodes', 'name',
existing_type=mysql.VARCHAR(length=63),
type_=sa.String(length=255),
existing_nullable=True)
def downgrade():
op.alter_column('nodes', 'name',
existing_type=sa.String(length=255),
type_=mysql.VARCHAR(length=63),
existing_nullable=True)

View File

@ -0,0 +1,40 @@
# 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.
"""Add Node instance info
Revision ID: 31baaf680d2b
Revises: 3cb628139ea4
Create Date: 2014-03-05 21:09:32.372463
"""
# revision identifiers, used by Alembic.
revision = '31baaf680d2b'
down_revision = '3cb628139ea4'
from alembic import op
import sqlalchemy as sa
def upgrade():
# commands auto generated by Alembic - please adjust
op.add_column('nodes', sa.Column('instance_info',
sa.Text(),
nullable=True))
# end Alembic commands
def downgrade():
# commands auto generated by Alembic - please adjust
op.drop_column('nodes', 'instance_info')
# end Alembic commands

View File

@ -0,0 +1,37 @@
# 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.
"""add_logical_name
Revision ID: 3ae36a5f5131
Revises: bb59b63f55a
Create Date: 2014-12-10 14:27:26.323540
"""
# revision identifiers, used by Alembic.
revision = '3ae36a5f5131'
down_revision = 'bb59b63f55a'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('nodes', sa.Column('name', sa.String(length=63),
nullable=True))
op.create_unique_constraint('uniq_nodes0name', 'nodes', ['name'])
def downgrade():
op.drop_constraint('uniq_nodes0name', 'nodes', type_='unique')
op.drop_column('nodes', 'name')

View File

@ -0,0 +1,39 @@
# Copyright 2014 Red Hat, Inc.
# 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.
"""add unique constraint to instance_uuid
Revision ID: 3bea56f25597
Revises: 31baaf680d2b
Create Date: 2014-06-05 11:45:07.046670
"""
# revision identifiers, used by Alembic.
revision = '3bea56f25597'
down_revision = '31baaf680d2b'
from alembic import op
def upgrade():
op.create_unique_constraint("uniq_nodes0instance_uuid", "nodes",
["instance_uuid"])
op.drop_index('node_instance_uuid', 'nodes')
def downgrade():
op.drop_constraint("uniq_nodes0instance_uuid", "nodes", type_='unique')
op.create_index('node_instance_uuid', 'nodes', ['instance_uuid'])

View File

@ -0,0 +1,34 @@
# 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.
"""Nodes add console enabled
Revision ID: 3cb628139ea4
Revises: 21b331f883ef
Create Date: 2014-02-26 11:24:11.318023
"""
# revision identifiers, used by Alembic.
revision = '3cb628139ea4'
down_revision = '21b331f883ef'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('nodes', sa.Column('console_enabled', sa.Boolean))
def downgrade():
op.drop_column('nodes', 'console_enabled')

View File

@ -0,0 +1,45 @@
# 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.
"""add conductor_affinity and online
Revision ID: 487deb87cc9d
Revises: 3bea56f25597
Create Date: 2014-09-26 16:16:30.988900
"""
# revision identifiers, used by Alembic.
revision = '487deb87cc9d'
down_revision = '3bea56f25597'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
'conductors',
sa.Column('online', sa.Boolean(), default=True))
op.add_column(
'nodes',
sa.Column('conductor_affinity', sa.Integer(),
sa.ForeignKey('conductors.id',
name='nodes_conductor_affinity_fk'),
nullable=True))
def downgrade():
op.drop_constraint('nodes_conductor_affinity_fk', 'nodes',
type_='foreignkey')
op.drop_column('nodes', 'conductor_affinity')
op.drop_column('conductors', 'online')

View File

@ -0,0 +1,35 @@
# 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.
"""Add node.clean_step
Revision ID: 4f399b21ae71
Revises: 1e1d5ace7dc6
Create Date: 2015-02-18 01:21:46.062311
"""
# revision identifiers, used by Alembic.
revision = '4f399b21ae71'
down_revision = '1e1d5ace7dc6'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('nodes', sa.Column('clean_step', sa.Text(),
nullable=True))
def downgrade():
op.drop_column('nodes', 'clean_step')

View File

@ -0,0 +1,52 @@
# 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.
"""replace NOSTATE with AVAILABLE
Revision ID: 5674c57409b9
Revises: 242cc6a923b3
Create Date: 2015-01-14 16:55:44.718196
"""
# revision identifiers, used by Alembic.
revision = '5674c57409b9'
down_revision = '242cc6a923b3'
from alembic import op
from sqlalchemy import String
from sqlalchemy.sql import table, column
node = table('nodes',
column('uuid', String(36)),
column('provision_state', String(15)))
# NOTE(deva): We must represent the states as static strings in this migration
# file, rather than import iotronic.common.states, because that file may change
# in the future. This migration script must still be able to be run with
# future versions of the code and still produce the same results.
AVAILABLE = 'available'
def upgrade():
op.execute(
node.update().where(
node.c.provision_state == None).values(
{'provision_state': op.inline_literal(AVAILABLE)}))
def downgrade():
op.execute(
node.update().where(
node.c.provision_state == op.inline_literal(AVAILABLE)).values(
{'provision_state': None}))

Some files were not shown because too many files have changed in this diff Show More