Implement initial draft of a Pecan-based API.
This commit is contained in:
parent
73f81e029f
commit
8ac57c720c
6
.gitignore
vendored
6
.gitignore
vendored
@ -14,7 +14,6 @@ dist
|
|||||||
build
|
build
|
||||||
eggs
|
eggs
|
||||||
parts
|
parts
|
||||||
bin
|
|
||||||
var
|
var
|
||||||
sdist
|
sdist
|
||||||
develop-eggs
|
develop-eggs
|
||||||
@ -24,12 +23,9 @@ develop-eggs
|
|||||||
*.DS_Store
|
*.DS_Store
|
||||||
.testrepository
|
.testrepository
|
||||||
.tox
|
.tox
|
||||||
|
.venv
|
||||||
.*.swp
|
.*.swp
|
||||||
.coverage
|
.coverage
|
||||||
cover
|
cover
|
||||||
AUTHORS
|
AUTHORS
|
||||||
ChangeLog
|
ChangeLog
|
||||||
|
|
||||||
.testrepository/
|
|
||||||
.tox
|
|
||||||
.venv
|
|
||||||
|
37
ironic/api/__init__.py
Normal file
37
ironic/api/__init__.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
|
||||||
|
# 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 flask.helpers
|
||||||
|
from oslo.config import cfg
|
||||||
|
|
||||||
|
from ironic.openstack.common import jsonutils
|
||||||
|
|
||||||
|
flask.helpers.json = jsonutils
|
||||||
|
|
||||||
|
API_SERVICE_OPTS = [
|
||||||
|
cfg.StrOpt('ironic_api_bind_ip',
|
||||||
|
default='0.0.0.0',
|
||||||
|
help='IP for the Ironic API server to bind to',
|
||||||
|
),
|
||||||
|
cfg.IntOpt('ironic_api_port',
|
||||||
|
default=6385,
|
||||||
|
help='The port for the Ironic API server',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
CONF = cfg.CONF
|
||||||
|
CONF.register_opts(API_SERVICE_OPTS)
|
56
ironic/api/acl.py
Normal file
56
ironic/api/acl.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright © 2012 New Dream Network, LLC (DreamHost)
|
||||||
|
#
|
||||||
|
# Author: Doug Hellmann <doug.hellmann@dreamhost.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.
|
||||||
|
|
||||||
|
"""Access Control Lists (ACL's) control access the API server."""
|
||||||
|
|
||||||
|
from keystoneclient.middleware import auth_token
|
||||||
|
from oslo.config import cfg
|
||||||
|
from pecan import hooks
|
||||||
|
from webob import exc
|
||||||
|
|
||||||
|
from ironic.common import policy
|
||||||
|
|
||||||
|
|
||||||
|
OPT_GROUP_NAME = 'keystone_authtoken'
|
||||||
|
|
||||||
|
|
||||||
|
def register_opts(conf):
|
||||||
|
"""Register keystoneclient middleware options
|
||||||
|
"""
|
||||||
|
conf.register_opts(auth_token.opts,
|
||||||
|
group=OPT_GROUP_NAME)
|
||||||
|
auth_token.CONF = conf
|
||||||
|
|
||||||
|
|
||||||
|
register_opts(cfg.CONF)
|
||||||
|
|
||||||
|
|
||||||
|
def install(app, conf):
|
||||||
|
"""Install ACL check on application."""
|
||||||
|
return auth_token.AuthProtocol(app,
|
||||||
|
conf=dict(conf.get(OPT_GROUP_NAME)))
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAuthHook(hooks.PecanHook):
|
||||||
|
"""Verify that the user has admin rights
|
||||||
|
"""
|
||||||
|
|
||||||
|
def before(self, state):
|
||||||
|
headers = state.request.headers
|
||||||
|
if not policy.check_is_admin(headers.get('X-Roles', "").split(",")):
|
||||||
|
raise exc.HTTPUnauthorized()
|
81
ironic/api/app.py
Normal file
81
ironic/api/app.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
|
||||||
|
# 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 ironic.api import acl
|
||||||
|
from ironic.api import config
|
||||||
|
from ironic.api import hooks
|
||||||
|
|
||||||
|
auth_opts = [
|
||||||
|
cfg.StrOpt('auth_strategy',
|
||||||
|
default='noauth',
|
||||||
|
help='Method to use for auth: noauth or keystone.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
CONF = cfg.CONF
|
||||||
|
CONF.register_opts(auth_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):
|
||||||
|
# FIXME: Replace DBHook with a hooks.TransactionHook
|
||||||
|
app_hooks = [hooks.ConfigHook()]
|
||||||
|
# hooks.DBHook()]
|
||||||
|
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(acl.AdminAuthHook())
|
||||||
|
|
||||||
|
pecan.configuration.set_config(dict(pecan_config), overwrite=True)
|
||||||
|
|
||||||
|
app = pecan.make_app(
|
||||||
|
pecan_config.app.root,
|
||||||
|
static_root=pecan_config.app.static_root,
|
||||||
|
template_path=pecan_config.app.template_path,
|
||||||
|
logging=getattr(pecan_config, 'logging', {}),
|
||||||
|
debug=getattr(pecan_config.app, 'debug', False),
|
||||||
|
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)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
class VersionSelectorApplication(object):
|
||||||
|
def __init__(self):
|
||||||
|
pc = get_pecan_config()
|
||||||
|
pc.app.debug = CONF.debug
|
||||||
|
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
ironic/api/config.py
Normal file
43
ironic/api/config.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# Server Specific Configurations
|
||||||
|
server = {
|
||||||
|
'port': '6382',
|
||||||
|
'host': '0.0.0.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pecan Application Configurations
|
||||||
|
app = {
|
||||||
|
'root': 'ironic.api.controllers.root.RootController',
|
||||||
|
'modules': ['ironic.api'],
|
||||||
|
'static_root': '%(confdir)s/public',
|
||||||
|
'template_path': '%(confdir)s/ironic/api/templates',
|
||||||
|
'debug': False,
|
||||||
|
'enable_acl': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
logging = {
|
||||||
|
'loggers': {
|
||||||
|
'root': {'level': 'INFO', 'handlers': ['console']},
|
||||||
|
'ironic': {'level': 'DEBUG', 'handlers': ['console']},
|
||||||
|
'wsme': {'level': 'DEBUG', 'handlers': ['console']}
|
||||||
|
},
|
||||||
|
'handlers': {
|
||||||
|
'console': {
|
||||||
|
'level': 'DEBUG',
|
||||||
|
'class': 'logging.StreamHandler',
|
||||||
|
'formatter': 'simple'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'formatters': {
|
||||||
|
'simple': {
|
||||||
|
'format': ('%(asctime)s %(levelname)-5.5s [%(name)s]'
|
||||||
|
'[%(threadName)s] %(message)s')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Custom Configurations must be in Python dictionary format::
|
||||||
|
#
|
||||||
|
# foo = {'bar':'baz'}
|
||||||
|
#
|
||||||
|
# All configurations are accessible at::
|
||||||
|
# pecan.conf
|
16
ironic/api/controllers/__init__.py
Normal file
16
ironic/api/controllers/__init__.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
|
||||||
|
# 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.
|
31
ironic/api/controllers/root.py
Normal file
31
ironic/api/controllers/root.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright © 2012 New Dream Network, LLC (DreamHost)
|
||||||
|
#
|
||||||
|
# Author: Doug Hellmann <doug.hellmann@dreamhost.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 pecan
|
||||||
|
|
||||||
|
from ironic.api.controllers import v1
|
||||||
|
|
||||||
|
|
||||||
|
class RootController(object):
|
||||||
|
|
||||||
|
v1 = v1.Controller()
|
||||||
|
|
||||||
|
@pecan.expose(generic=True)
|
||||||
|
def index(self):
|
||||||
|
# FIXME: GET / should return more than just ''
|
||||||
|
return ''
|
165
ironic/api/controllers/v1.py
Normal file
165
ironic/api/controllers/v1.py
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Version 1 of the Ironic API
|
||||||
|
|
||||||
|
Should maintain feature parity with Nova Baremetal Extension.
|
||||||
|
Specification in ironic/doc/api/v1.rst
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
import pecan
|
||||||
|
from pecan import rest
|
||||||
|
|
||||||
|
import wsme
|
||||||
|
import wsmeext.pecan as wsme_pecan
|
||||||
|
from wsme import types as wtypes
|
||||||
|
|
||||||
|
from ironic import db
|
||||||
|
|
||||||
|
|
||||||
|
class Base(wtypes.Base):
|
||||||
|
# TODO: all the db bindings
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_db_model(cls, m):
|
||||||
|
return cls(**(m.as_dict()))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_db_and_links(cls, m, links):
|
||||||
|
return cls(links=links, **(m.as_dict()))
|
||||||
|
|
||||||
|
def as_dict(self, db_model):
|
||||||
|
valid_keys = inspect.getargspec(db_model.__init__)[0]
|
||||||
|
if 'self' in valid_keys:
|
||||||
|
valid_keys.remove('self')
|
||||||
|
|
||||||
|
return dict((k, getattr(self, k))
|
||||||
|
for k in valid_keys
|
||||||
|
if hasattr(self, k) and
|
||||||
|
getattr(self, k) != wsme.Unset)
|
||||||
|
|
||||||
|
|
||||||
|
class Interface(Base):
|
||||||
|
"""A representation of a network interface for a baremetal node"""
|
||||||
|
|
||||||
|
node_id = int
|
||||||
|
address = wtypes.text
|
||||||
|
|
||||||
|
def __init__(self, node_id=None, address=None):
|
||||||
|
self.node_id = node_id
|
||||||
|
self.address = address
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def sample(cls):
|
||||||
|
return cls(node_id=1,
|
||||||
|
address='52:54:00:cf:2d:31',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InterfacesController(rest.RestController):
|
||||||
|
"""REST controller for Interfaces"""
|
||||||
|
|
||||||
|
@wsme_pecan.wsexpose(Interface, unicode)
|
||||||
|
def post(self, iface):
|
||||||
|
"""Ceate a new interface."""
|
||||||
|
return Interface.sample()
|
||||||
|
|
||||||
|
@wsme_pecan.wsexpose()
|
||||||
|
def get_all(self):
|
||||||
|
"""Retrieve a list of all interfaces."""
|
||||||
|
ifaces = [Interface.sample()]
|
||||||
|
return [(i.node_id, i.address) for i in ifaces]
|
||||||
|
|
||||||
|
@wsme_pecan.wsexpose(Interface, unicode)
|
||||||
|
def get_one(self, address):
|
||||||
|
"""Retrieve information about the given interface."""
|
||||||
|
one = Interface.sample()
|
||||||
|
one.address = address
|
||||||
|
return one
|
||||||
|
|
||||||
|
@wsme_pecan.wsexpose()
|
||||||
|
def delete(self, iface_id):
|
||||||
|
"""Delete an interface"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@wsme_pecan.wsexpose()
|
||||||
|
def put(self, iface_id):
|
||||||
|
"""Update an interface"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Node(Base):
|
||||||
|
"""A representation of a bare metal node"""
|
||||||
|
|
||||||
|
uuid = wtypes.text
|
||||||
|
cpus = int
|
||||||
|
memory_mb = int
|
||||||
|
|
||||||
|
def __init__(self, uuid=None, cpus=None, memory_mb=None):
|
||||||
|
self.uuid = uuid
|
||||||
|
self.cpus = cpus
|
||||||
|
self.memory_mb = memory_mb
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def sample(cls):
|
||||||
|
return cls(uuid='1be26c0b-03f2-4d2e-ae87-c02d7f33c123',
|
||||||
|
cpus=2,
|
||||||
|
memory_mb=1024,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NodesController(rest.RestController):
|
||||||
|
"""REST controller for Nodes"""
|
||||||
|
|
||||||
|
@wsme_pecan.wsexpose(Node, unicode)
|
||||||
|
def post(self, node):
|
||||||
|
"""Ceate a new node."""
|
||||||
|
return Node.sample()
|
||||||
|
|
||||||
|
@wsme_pecan.wsexpose()
|
||||||
|
def get_all(self):
|
||||||
|
"""Retrieve a list of all nodes."""
|
||||||
|
nodes = [Node.sample()]
|
||||||
|
return [n.uuid for n in nodes]
|
||||||
|
|
||||||
|
@wsme_pecan.wsexpose(Node, unicode)
|
||||||
|
def get_one(self, node_id):
|
||||||
|
"""Retrieve information about the given node."""
|
||||||
|
one = Node.sample()
|
||||||
|
one.uuid = node_id
|
||||||
|
return one
|
||||||
|
|
||||||
|
@wsme_pecan.wsexpose()
|
||||||
|
def delete(self, node_id):
|
||||||
|
"""Delete a node"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@wsme_pecan.wsexpose()
|
||||||
|
def put(self, node_id):
|
||||||
|
"""Update a node"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Controller(object):
|
||||||
|
"""Version 1 API controller root."""
|
||||||
|
|
||||||
|
# TODO: _default and index
|
||||||
|
|
||||||
|
nodes = NodesController()
|
||||||
|
interfaces = InterfacesController()
|
45
ironic/api/hooks.py
Normal file
45
ironic/api/hooks.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright © 2012 New Dream Network, LLC (DreamHost)
|
||||||
|
#
|
||||||
|
# Author: Doug Hellmann <doug.hellmann@dreamhost.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.
|
||||||
|
|
||||||
|
from oslo.config import cfg
|
||||||
|
from pecan import hooks
|
||||||
|
|
||||||
|
from ironic import db
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigHook(hooks.PecanHook):
|
||||||
|
"""Attach the configuration object to the request
|
||||||
|
so controllers can get to it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def before(self, state):
|
||||||
|
state.request.cfg = cfg.CONF
|
||||||
|
|
||||||
|
|
||||||
|
class DBHook(hooks.PecanHook):
|
||||||
|
|
||||||
|
def before(self, state):
|
||||||
|
# FIXME
|
||||||
|
storage_engine = storage.get_engine(state.request.cfg)
|
||||||
|
state.request.storage_engine = storage_engine
|
||||||
|
state.request.storage_conn = storage_engine.get_connection(
|
||||||
|
state.request.cfg)
|
||||||
|
|
||||||
|
# def after(self, state):
|
||||||
|
# print 'method:', state.request.method
|
||||||
|
# print 'response:', state.response.status
|
21
ironic/api/model/__init__.py
Normal file
21
ironic/api/model/__init__.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
|
||||||
|
# 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 pecan import conf
|
||||||
|
|
||||||
|
def init_model():
|
||||||
|
pass
|
12
ironic/api/templates/index.html
Normal file
12
ironic/api/templates/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<%def name="title()">
|
||||||
|
Ironic API v1
|
||||||
|
</%def>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="content">
|
||||||
|
|
||||||
|
<p> TODO </p>
|
||||||
|
|
||||||
|
</div>
|
54
ironic/cmd/api.py
Normal file
54
ironic/cmd/api.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
The Ironic Service API
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from oslo.config import cfg
|
||||||
|
from wsgiref import simple_server
|
||||||
|
|
||||||
|
from ironic.api import app
|
||||||
|
from ironic.common.service import prepare_service
|
||||||
|
from ironic.openstack.common import service
|
||||||
|
from ironic.openstack.common.rpc import service as rpc_service
|
||||||
|
|
||||||
|
CONF = cfg.CONF
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Pase config file and command line options, then start logging
|
||||||
|
prepare_service(sys.argv)
|
||||||
|
|
||||||
|
# Build and start the WSGI app
|
||||||
|
host = CONF.ironic_api_bind_ip
|
||||||
|
port = CONF.ironic_api_port
|
||||||
|
wsgi = simple_server.make_server(
|
||||||
|
host, port,
|
||||||
|
app.VersionSelectorApplication())
|
||||||
|
|
||||||
|
print "Serving on http://%s:%s" % (host, port)
|
||||||
|
|
||||||
|
try:
|
||||||
|
wsgi.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
46
ironic/cmd/manager.py
Normal file
46
ironic/cmd/manager.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
The Ironic Management Service
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from oslo.config import cfg
|
||||||
|
from wsgiref import simple_server
|
||||||
|
|
||||||
|
from ironic.manager import manager
|
||||||
|
from ironic.common.service import prepare_service
|
||||||
|
from ironic.openstack.common import service
|
||||||
|
from ironic.openstack.common.rpc import service as rpc_service
|
||||||
|
|
||||||
|
CONF = cfg.CONF
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Pase config file and command line options, then start logging
|
||||||
|
prepare_service(sys.argv)
|
||||||
|
|
||||||
|
mgr = manager.AgentManager()
|
||||||
|
topic = 'ironic.manager'
|
||||||
|
ironic = rcp_service.Service(CONF.host, topic, mgr)
|
||||||
|
launcher = service.launch(ironic)
|
||||||
|
launcher.wait()
|
@ -92,7 +92,7 @@ def enforce(context, action, target, do_raise=True):
|
|||||||
"""
|
"""
|
||||||
init()
|
init()
|
||||||
|
|
||||||
credentials = context.to_dict()
|
credentials = ironic_context.to_dict()
|
||||||
|
|
||||||
# Add the exception arguments if asked to do a raise
|
# Add the exception arguments if asked to do a raise
|
||||||
extra = {}
|
extra = {}
|
||||||
@ -102,17 +102,19 @@ def enforce(context, action, target, do_raise=True):
|
|||||||
return policy.check(action, target, credentials, **extra)
|
return policy.check(action, target, credentials, **extra)
|
||||||
|
|
||||||
|
|
||||||
def check_is_admin(context):
|
def check_is_admin(roles):
|
||||||
"""Whether or not roles contains 'admin' role according to policy setting.
|
"""Whether or not roles contains 'admin' role according to policy setting.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
init()
|
init()
|
||||||
|
|
||||||
|
if isinstance(roles, RequestContext):
|
||||||
# the target is user-self
|
# the target is user-self
|
||||||
credentials = context.to_dict()
|
credentials = roles.to_dict()
|
||||||
target = credentials
|
target = credentials
|
||||||
|
|
||||||
return policy.check('context_is_admin', target, credentials)
|
return policy.check('context_is_admin', target, credentials)
|
||||||
|
else:
|
||||||
|
return policy.check('context_is_admin', {}, {'roles': roles})
|
||||||
|
|
||||||
|
|
||||||
@policy.register('is_admin')
|
@policy.register('is_admin')
|
||||||
|
92
ironic/common/service.py
Normal file
92
ironic/common/service.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- encoding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright © 2012 eNovance <licensing@enovance.com>
|
||||||
|
#
|
||||||
|
# Author: Julien Danjou <julien@danjou.info>
|
||||||
|
#
|
||||||
|
# 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 socket
|
||||||
|
|
||||||
|
from oslo.config import cfg
|
||||||
|
|
||||||
|
from ironic.openstack.common import context
|
||||||
|
from ironic.openstack.common import log
|
||||||
|
from ironic.openstack.common import rpc
|
||||||
|
from ironic.openstack.common.rpc import service as rpc_service
|
||||||
|
|
||||||
|
|
||||||
|
cfg.CONF.register_opts([
|
||||||
|
cfg.IntOpt('periodic_interval',
|
||||||
|
default=600,
|
||||||
|
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'),
|
||||||
|
])
|
||||||
|
|
||||||
|
CLI_OPTIONS = [
|
||||||
|
cfg.StrOpt('os-username',
|
||||||
|
default=os.environ.get('OS_USERNAME', 'ironic'),
|
||||||
|
help='Username to use for openstack service access'),
|
||||||
|
cfg.StrOpt('os-password',
|
||||||
|
default=os.environ.get('OS_PASSWORD', 'admin'),
|
||||||
|
help='Password to use for openstack service access'),
|
||||||
|
cfg.StrOpt('os-tenant-id',
|
||||||
|
default=os.environ.get('OS_TENANT_ID', ''),
|
||||||
|
help='Tenant ID to use for openstack service access'),
|
||||||
|
cfg.StrOpt('os-tenant-name',
|
||||||
|
default=os.environ.get('OS_TENANT_NAME', 'admin'),
|
||||||
|
help='Tenant name to use for openstack service access'),
|
||||||
|
cfg.StrOpt('os-auth-url',
|
||||||
|
default=os.environ.get('OS_AUTH_URL',
|
||||||
|
'http://localhost:5000/v2.0'),
|
||||||
|
help='Auth URL to use for openstack service access'),
|
||||||
|
]
|
||||||
|
cfg.CONF.register_cli_opts(CLI_OPTIONS)
|
||||||
|
|
||||||
|
|
||||||
|
class PeriodicService(rpc_service.Service):
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
super(PeriodicService, self).start()
|
||||||
|
admin_context = context.RequestContext('admin', 'admin', is_admin=True)
|
||||||
|
self.tg.add_timer(cfg.CONF.periodic_interval,
|
||||||
|
self.manager.periodic_tasks,
|
||||||
|
context=admin_context)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_cmd_line(argv):
|
||||||
|
"""Remove non-nova CLI options from argv."""
|
||||||
|
cli_opt_names = ['--%s' % o.name for o in CLI_OPTIONS]
|
||||||
|
return [a for a in argv if a in cli_opt_names]
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_service(argv=[]):
|
||||||
|
rpc.set_defaults(control_exchange='ironic')
|
||||||
|
cfg.set_defaults(log.log_opts,
|
||||||
|
default_log_levels=['amqplib=WARN',
|
||||||
|
'qpid.messaging=INFO',
|
||||||
|
'sqlalchemy=WARN',
|
||||||
|
'keystoneclient=INFO',
|
||||||
|
'stevedore=INFO',
|
||||||
|
'eventlet.wsgi.server=WARN'
|
||||||
|
])
|
||||||
|
cfg.CONF(argv[1:], project='ironic')
|
||||||
|
log.setup('ironic')
|
@ -1,6 +1,8 @@
|
|||||||
# Copyright (c) 2012 NTT DOCOMO, INC.
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
|
||||||
|
# Copyright 2013 Hewlett-Packard Development Company, L.P.
|
||||||
# All Rights Reserved.
|
# All Rights Reserved.
|
||||||
# flake8: noqa
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
# 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
|
# not use this file except in compliance with the License. You may obtain
|
||||||
# a copy of the License at
|
# a copy of the License at
|
||||||
|
@ -45,19 +45,12 @@ these objects be simple dictionaries.
|
|||||||
from oslo.config import cfg
|
from oslo.config import cfg
|
||||||
|
|
||||||
from ironic.common import utils
|
from ironic.common import utils
|
||||||
|
from ironic.openstack.common.db import api as db_api
|
||||||
|
|
||||||
db_opts = [
|
|
||||||
cfg.StrOpt('db_backend',
|
|
||||||
default='sqlalchemy',
|
|
||||||
help='The backend to use for the ironic database'),
|
|
||||||
]
|
|
||||||
|
|
||||||
CONF = cfg.CONF
|
_BACKEND_MAPPING = {'sqlalchemy': 'ironic.db.sqlalchemy.api'}
|
||||||
CONF.register_opts(db_opts)
|
|
||||||
|
|
||||||
IMPL = utils.LazyPluggable(
|
IMPL = db_api.DBAPI(backend_mapping=_BACKEND_MAPPING)
|
||||||
'db_backend',
|
|
||||||
sqlalchemy='ironic.db.sqlalchemy.api')
|
|
||||||
|
|
||||||
|
|
||||||
def bm_node_get_all(context, service_host=None):
|
def bm_node_get_all(context, service_host=None):
|
||||||
|
@ -20,6 +20,7 @@
|
|||||||
|
|
||||||
"""Implementation of SQLAlchemy backend."""
|
"""Implementation of SQLAlchemy backend."""
|
||||||
|
|
||||||
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy.sql.expression import asc
|
from sqlalchemy.sql.expression import asc
|
||||||
@ -40,6 +41,11 @@ get_engine = db_session.get_engine
|
|||||||
get_session = db_session.get_session
|
get_session = db_session.get_session
|
||||||
|
|
||||||
|
|
||||||
|
def get_backend():
|
||||||
|
"""The backend is this module itself."""
|
||||||
|
return sys.modules[__name__]
|
||||||
|
|
||||||
|
|
||||||
def model_query(context, model, *args, **kwargs):
|
def model_query(context, model, *args, **kwargs):
|
||||||
"""Query helper that accounts for context's `read_deleted` field.
|
"""Query helper that accounts for context's `read_deleted` field.
|
||||||
|
|
||||||
|
28
ironic/doc/api/v1.rst
Normal file
28
ironic/doc/api/v1.rst
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
GET / - information about this API
|
||||||
|
|
||||||
|
GET /node - list all nodes
|
||||||
|
GET /node/<id> - get node info
|
||||||
|
POST /node - insert a new node
|
||||||
|
DELETE /node/<id> - delete a node (and any associated interfaces)
|
||||||
|
|
||||||
|
GET /iface - list all interfaces
|
||||||
|
GET /iface/<id> - information about the interface
|
||||||
|
GET /iface/node/<id> - list interfaces for this node
|
||||||
|
POST /iface - insert a new interface
|
||||||
|
DELETE /iface/<id> - delete an interface
|
||||||
|
|
||||||
|
GET /node/image/<id> - get deployment driver info
|
||||||
|
PUT /node/image/<id> - set deployment driver info
|
||||||
|
GET /node/image/pxe/<id> - get PXE info
|
||||||
|
PUT /node/image/pxe/<id> - set PXE info
|
||||||
|
|
||||||
|
GET /node/power/<id> - get power driver info
|
||||||
|
PUT /node/power/<id> - set power driver info
|
||||||
|
GET /node/power/ipmi/<id> - get IPMI info (sanitised pw)
|
||||||
|
PUT /node/power/ipmi/<id> - set IPMI info
|
||||||
|
|
||||||
|
GET /node/power/state/<id> - get the power state
|
||||||
|
PUT /node/power/state/<id> - set the power state
|
||||||
|
|
||||||
|
GET /find/node - search for node based on query string
|
||||||
|
GET /find/iface - search for iface based on query string
|
@ -297,5 +297,11 @@ def _get_impl():
|
|||||||
"""Delay import of rpc_backend until configuration is loaded."""
|
"""Delay import of rpc_backend until configuration is loaded."""
|
||||||
global _RPCIMPL
|
global _RPCIMPL
|
||||||
if _RPCIMPL is None:
|
if _RPCIMPL is None:
|
||||||
|
try:
|
||||||
_RPCIMPL = importutils.import_module(CONF.rpc_backend)
|
_RPCIMPL = importutils.import_module(CONF.rpc_backend)
|
||||||
|
except ImportError:
|
||||||
|
# For backwards compatibility with older nova config.
|
||||||
|
impl = CONF.rpc_backend.replace('nova.rpc',
|
||||||
|
'nova.openstack.common.rpc')
|
||||||
|
_RPCIMPL = importutils.import_module(impl)
|
||||||
return _RPCIMPL
|
return _RPCIMPL
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
[DEFAULT]
|
[DEFAULT]
|
||||||
|
|
||||||
# The list of modules to copy from oslo-incubator.git
|
|
||||||
module=cliutils
|
module=cliutils
|
||||||
module=context
|
module=context
|
||||||
module=db
|
module=db
|
||||||
|
@ -22,6 +22,7 @@ pyasn1
|
|||||||
Babel>=0.9.6
|
Babel>=0.9.6
|
||||||
iso8601>=0.1.4
|
iso8601>=0.1.4
|
||||||
httplib2
|
httplib2
|
||||||
|
setuptools_git>=0.4
|
||||||
python-cinderclient>=1.0.1
|
python-cinderclient>=1.0.1
|
||||||
python-quantumclient>=2.2.0,<3.0.0
|
python-quantumclient>=2.2.0,<3.0.0
|
||||||
python-glanceclient>=0.5.0,<2
|
python-glanceclient>=0.5.0,<2
|
||||||
@ -29,3 +30,6 @@ python-keystoneclient>=0.2.0
|
|||||||
stevedore>=0.7
|
stevedore>=0.7
|
||||||
websockify<0.4
|
websockify<0.4
|
||||||
oslo.config>=1.1.0
|
oslo.config>=1.1.0
|
||||||
|
Flask==0.9
|
||||||
|
pecan>=0.2.0
|
||||||
|
wsme>=0.5b1
|
||||||
|
@ -28,8 +28,8 @@ packages =
|
|||||||
|
|
||||||
[entry_points]
|
[entry_points]
|
||||||
console_scripts =
|
console_scripts =
|
||||||
ironic-baremetal-deploy-helper = ironic.cmd.baremetal_deploy_helper:main
|
ironic-api = ironic.cmd.api:main
|
||||||
ironic-baremetal-manage = ironic.cmd.baremetal_manage:main
|
ironic-manager = ironic.cmd.manager:main
|
||||||
|
|
||||||
[build_sphinx]
|
[build_sphinx]
|
||||||
all_files = 1
|
all_files = 1
|
||||||
|
@ -24,6 +24,8 @@ environment, it should be kept strictly compatible with Python 2.6.
|
|||||||
Synced in from openstack-common
|
Synced in from openstack-common
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import print_function
|
||||||
|
|
||||||
import optparse
|
import optparse
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@ -42,7 +44,7 @@ class InstallVenv(object):
|
|||||||
self.project = project
|
self.project = project
|
||||||
|
|
||||||
def die(self, message, *args):
|
def die(self, message, *args):
|
||||||
print >> sys.stderr, message % args
|
print(message % args, file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
def check_python_version(self):
|
def check_python_version(self):
|
||||||
@ -89,20 +91,20 @@ class InstallVenv(object):
|
|||||||
virtual environment.
|
virtual environment.
|
||||||
"""
|
"""
|
||||||
if not os.path.isdir(self.venv):
|
if not os.path.isdir(self.venv):
|
||||||
print 'Creating venv...',
|
print('Creating venv...', end=' ')
|
||||||
if no_site_packages:
|
if no_site_packages:
|
||||||
self.run_command(['virtualenv', '-q', '--no-site-packages',
|
self.run_command(['virtualenv', '-q', '--no-site-packages',
|
||||||
self.venv])
|
self.venv])
|
||||||
else:
|
else:
|
||||||
self.run_command(['virtualenv', '-q', self.venv])
|
self.run_command(['virtualenv', '-q', self.venv])
|
||||||
print 'done.'
|
print('done.')
|
||||||
print 'Installing pip in venv...',
|
print('Installing pip in venv...', end=' ')
|
||||||
if not self.run_command(['tools/with_venv.sh', 'easy_install',
|
if not self.run_command(['tools/with_venv.sh', 'easy_install',
|
||||||
'pip>1.0']).strip():
|
'pip>1.0']).strip():
|
||||||
self.die("Failed to install pip.")
|
self.die("Failed to install pip.")
|
||||||
print 'done.'
|
print('done.')
|
||||||
else:
|
else:
|
||||||
print "venv already exists..."
|
print("venv already exists...")
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def pip_install(self, *args):
|
def pip_install(self, *args):
|
||||||
@ -111,7 +113,7 @@ class InstallVenv(object):
|
|||||||
redirect_output=False)
|
redirect_output=False)
|
||||||
|
|
||||||
def install_dependencies(self):
|
def install_dependencies(self):
|
||||||
print 'Installing dependencies with pip (this can take a while)...'
|
print('Installing dependencies with pip (this can take a while)...')
|
||||||
|
|
||||||
# First things first, make sure our venv has the latest pip and
|
# First things first, make sure our venv has the latest pip and
|
||||||
# distribute.
|
# distribute.
|
||||||
@ -153,12 +155,12 @@ class Distro(InstallVenv):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if self.check_cmd('easy_install'):
|
if self.check_cmd('easy_install'):
|
||||||
print 'Installing virtualenv via easy_install...',
|
print('Installing virtualenv via easy_install...', end=' ')
|
||||||
if self.run_command(['easy_install', 'virtualenv']):
|
if self.run_command(['easy_install', 'virtualenv']):
|
||||||
print 'Succeeded'
|
print('Succeeded')
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
print 'Failed'
|
print('Failed')
|
||||||
|
|
||||||
self.die('ERROR: virtualenv not found.\n\n%s development'
|
self.die('ERROR: virtualenv not found.\n\n%s development'
|
||||||
' requires virtualenv, please install it using your'
|
' requires virtualenv, please install it using your'
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
d2to1>=0.2.10,<0.3
|
||||||
|
pbr>=0.5,<0.6
|
||||||
SQLAlchemy>=0.7.8,<0.7.99
|
SQLAlchemy>=0.7.8,<0.7.99
|
||||||
Cheetah>=2.4.4
|
Cheetah>=2.4.4
|
||||||
amqplib>=0.6.1
|
amqplib>=0.6.1
|
||||||
@ -28,3 +30,6 @@ python-keystoneclient>=0.2.0
|
|||||||
stevedore>=0.7
|
stevedore>=0.7
|
||||||
websockify<0.4
|
websockify<0.4
|
||||||
oslo.config>=1.1.0
|
oslo.config>=1.1.0
|
||||||
|
Flask==0.9
|
||||||
|
pecan>=0.2.0
|
||||||
|
wsme>=0.5b1
|
||||||
|
Loading…
x
Reference in New Issue
Block a user