Merge "API Nodes states"

This commit is contained in:
Jenkins 2013-08-22 17:18:25 +00:00 committed by Gerrit Code Review
commit c4de596b11
14 changed files with 364 additions and 57 deletions

View File

@ -28,6 +28,7 @@ from ironic.api.controllers.v1 import base
from ironic.api.controllers.v1 import collection
from ironic.api.controllers.v1 import link
from ironic.api.controllers.v1 import port
from ironic.api.controllers.v1 import state
from ironic.api.controllers.v1 import utils
from ironic.common import exception
from ironic.openstack.common import log
@ -35,6 +36,136 @@ from ironic.openstack.common import log
LOG = log.getLogger(__name__)
class NodePowerState(state.State):
@classmethod
def convert_with_links(cls, rpc_node, expand=True):
power_state = NodePowerState()
# FIXME(lucasagomes): this request could potentially take a
# while. It's dependent upon the driver talking to the hardware. At
# least with IPMI, this often times out, and even fails after 3
# retries at a statistically significant frequency....
power_state.current = pecan.request.rpcapi.get_node_power_state(
pecan.request.context,
rpc_node.uuid)
url_arg = '%s/state/power' % rpc_node.uuid
power_state.links = [link.Link.make_link('self',
pecan.request.host_url,
'nodes', url_arg),
link.Link.make_link('bookmark',
pecan.request.host_url,
'nodes', url_arg,
bookmark=True)
]
if expand:
power_state.target = rpc_node.target_power_state
# TODO(lucasagomes): get_next_power_available_states
power_state.available = []
return power_state
class NodePowerStateController(rest.RestController):
# GET nodes/<uuid>/state/power
@wsme_pecan.wsexpose(NodePowerState, unicode)
def get(self, node_id):
node = objects.Node.get_by_uuid(pecan.request.context, node_id)
return NodePowerState.convert_with_links(node)
# PUT nodes/<uuid>/state/power
@wsme_pecan.wsexpose(NodePowerState, unicode, unicode, status=202)
def put(self, node_id, target):
"""Set the power state of the machine."""
node = objects.Node.get_by_uuid(pecan.request.context, node_id)
if node.target_power_state is not None:
raise wsme.exc.ClientSideError(_("One power operation is "
"already in process"))
#TODO(lucasagomes): Test if target is a valid state and if it's able
# to transition to the target state from the current one
node['target_power_state'] = target
updated_node = pecan.request.rpcapi.update_node(pecan.request.context,
node)
pecan.request.rpcapi.start_power_state_change(pecan.request.context,
updated_node, target)
return NodePowerState.convert_with_links(updated_node, expand=False)
class NodeProvisionState(state.State):
@classmethod
def convert_with_links(cls, rpc_node, expand=True):
provision_state = NodeProvisionState()
provision_state.current = rpc_node.provision_state
url_arg = '%s/state/provision' % rpc_node.uuid
provision_state.links = [link.Link.make_link('self',
pecan.request.host_url,
'nodes', url_arg),
link.Link.make_link('bookmark',
pecan.request.host_url,
'nodes', url_arg,
bookmark=True)
]
if expand:
provision_state.target = rpc_node.target_provision_state
# TODO(lucasagomes): get_next_provision_available_states
provision_state.available = []
return provision_state
class NodeProvisionStateController(rest.RestController):
# GET nodes/<uuid>/state/provision
@wsme_pecan.wsexpose(NodeProvisionState, unicode)
def get(self, node_id):
node = objects.Node.get_by_uuid(pecan.request.context, node_id)
provision_state = NodeProvisionState.convert_with_links(node)
return provision_state
# PUT nodes/<uuid>/state/provision
@wsme_pecan.wsexpose(NodeProvisionState, unicode, unicode, status=202)
def put(self, node_id, target):
"""Set the provision state of the machine."""
#TODO(lucasagomes): Test if target is a valid state and if it's able
# to transition to the target state from the current one
# TODO(lucasagomes): rpcapi.start_provision_state_change()
raise NotImplementedError()
class NodeStates(base.APIBase):
"""API representation of the states of a node."""
power = NodePowerState
"The current power state of the node"
provision = NodeProvisionState
"The current provision state of the node"
@classmethod
def convert_with_links(cls, rpc_node):
states = NodeStates()
states.power = NodePowerState.convert_with_links(rpc_node,
expand=False)
states.provision = NodeProvisionState.convert_with_links(rpc_node,
expand=False)
return states
class NodeStatesController(rest.RestController):
power = NodePowerStateController()
"Expose the power controller action as a sub-element of state"
provision = NodeProvisionStateController()
"Expose the provision controller action as a sub-element of state"
# GET nodes/<uuid>/state
@wsme_pecan.wsexpose(NodeStates, unicode)
def get(self, node_id):
"""List or update the state of a node."""
node = objects.Node.get_by_uuid(pecan.request.context, node_id)
state = NodeStates.convert_with_links(node)
return state
class Node(base.APIBase):
"""API representation of a bare metal node.
@ -46,9 +177,17 @@ class Node(base.APIBase):
uuid = wtypes.text
instance_uuid = wtypes.text
# NOTE: task_* fields probably need to be reworked to match API spec
task_state = wtypes.text
task_start = wtypes.text
power_state = wtypes.text
"Represent the current (not transition) power state of the node"
target_power_state = wtypes.text
"The user modified desired power state of the node."
provision_state = wtypes.text
"Represent the current (not transition) provision state of the node"
target_provision_state = wtypes.text
"The user modified desired provision state of the node."
# NOTE: allow arbitrary dicts for driver_info and extra so that drivers
# and vendors can expand on them without requiring API changes.
@ -120,6 +259,9 @@ class NodeCollection(collection.Collection):
class NodesController(rest.RestController):
"""REST controller for Nodes."""
state = NodeStatesController()
"Expose the state controller action as a sub-element of nodes"
_custom_actions = {
'ports': ['GET'],
}
@ -169,9 +311,14 @@ class NodesController(rest.RestController):
# to a dict and stripping keys with value=None
delta = node_data.as_terse_dict()
# NOTE: state transitions are separate from informational changes
# so don't pass a task_state to update_node.
new_state = delta.pop('task_state', None)
# Prevent states from being updated
state_rel_attr = ['power_state', 'target_power_state',
'provision_state', 'target_provision_state']
if any((getattr(node_data, attr) for attr in state_rel_attr)):
raise wsme.exc.ClientSideError(_("Changing states is not allowed "
"here; You must use the "
"nodes/%s/state interface.")
% node_id)
response = wsme.api.Response(Node(), status_code=200)
try:
@ -190,12 +337,6 @@ class NodesController(rest.RestController):
LOG.exception(e)
response.status_code = 500
if new_state:
# NOTE: state change is async, so change the REST response
response.status_code = 202
pecan.request.rpcapi.start_state_change(pecan.request.context,
node, new_state)
# TODO(deva): return the response object instead of raising
# after wsme 0.5b3 is released
if response.status_code not in [200, 202]:

View File

@ -0,0 +1,37 @@
#!/usr/bin/env python
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# 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 ironic.api.controllers.v1 import base
from ironic.api.controllers.v1 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

@ -98,7 +98,7 @@ class ConductorManager(service.PeriodicService):
LOG.debug("RPC update_node called for node %s." % node_id)
delta = node_obj.obj_what_changed()
if 'task_state' in delta:
if 'power_state' in delta:
raise exception.IronicException(_(
"Invalid method call: update_node can not change node state."))
@ -108,30 +108,30 @@ class ConductorManager(service.PeriodicService):
if 'driver_info' in delta:
task.driver.deploy.validate(node_obj)
task.driver.power.validate(node_obj)
node_obj['task_state'] = task.driver.power.get_power_state
node_obj['power_state'] = task.driver.power.get_power_state
# TODO(deva): Determine what value will be passed by API when
# instance_uuid needs to be unset, and handle it.
if 'instance_uuid' in delta:
if node_obj['task_state'] != states.POWER_OFF:
if node_obj['power_state'] != states.POWER_OFF:
raise exception.NodeInWrongPowerState(
node=node_id,
pstate=node_obj['task_state'])
pstate=node_obj['power_state'])
# update any remaining parameters, then save
node_obj.save(context)
return node_obj
def start_state_change(self, context, node_obj, new_state):
def start_power_state_change(self, context, node_obj, new_state):
"""RPC method to encapsulate changes to a node's state.
Perform actions such as power on, power off, deploy, and cleanup.
Perform actions such as power on and power off.
TODO
:param context: an admin context
:param node_obj: an RPC-style node object
:param new_state: the desired state of the node
:param new_state: the desired power state of the node
"""
pass

View File

@ -32,7 +32,7 @@ class ConductorAPI(ironic.openstack.common.rpc.proxy.RpcProxy):
1.0 - Initial version.
Included get_node_power_status
1.1 - Added update_node and start_state_change.
1.1 - Added update_node and start_power_state_change.
"""
RPC_API_VERSION = '1.1'
@ -66,8 +66,8 @@ class ConductorAPI(ironic.openstack.common.rpc.proxy.RpcProxy):
the core drivers. If instance_uuid is passed, it will be set or unset
only if the node is properly configured.
Note that task_state should not be passed via this method.
Use start_state_change for initiating driver actions.
Note that power_state should not be passed via this method.
Use start_power_state_change for initiating driver actions.
:param context: request context.
:param node_obj: a changed (but not saved) node object.
@ -77,7 +77,7 @@ class ConductorAPI(ironic.openstack.common.rpc.proxy.RpcProxy):
self.make_msg('update_node',
node_obj=node_obj))
def start_state_change(self, context, node_obj, new_state):
def start_power_state_change(self, context, node_obj, new_state):
"""Asynchronously perform an action on a node.
:param context: request context.
@ -85,6 +85,6 @@ class ConductorAPI(ironic.openstack.common.rpc.proxy.RpcProxy):
:param new_state: one of ironic.common.states power state values
"""
self.cast(context,
self.make_msg('start_state_change',
self.make_msg('start_power_state_change',
node_obj=node_obj,
new_state=new_state))

View File

@ -0,0 +1,39 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# -*- 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 sqlalchemy import Table, Column, MetaData, String
def upgrade(migrate_engine):
meta = MetaData()
meta.bind = migrate_engine
nodes = Table('nodes', meta, autoload=True)
# Drop task_* columns
nodes.c.task_start.drop()
nodes.c.task_state.drop()
# Create new states columns
nodes.create_column(Column('power_state', String(15), nullable=True))
nodes.create_column(Column('target_power_state', String(15),
nullable=True))
nodes.create_column(Column('provision_state', String(15), nullable=True))
nodes.create_column(Column('target_provision_state', String(15),
nullable=True))
def downgrade(migrate_engine):
raise NotImplementedError('Downgrade from version 009 is unsupported.')

View File

@ -25,7 +25,7 @@ import urlparse
from oslo.config import cfg
from sqlalchemy import Column, ForeignKey
from sqlalchemy import Integer, String, DateTime
from sqlalchemy import Integer, String
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.types import TypeDecorator, VARCHAR
@ -97,8 +97,10 @@ class Node(Base):
uuid = Column(String(36), unique=True)
instance_uuid = Column(String(36), nullable=True, unique=True)
chassis_id = Column(Integer, ForeignKey('chassis.id'), nullable=True)
task_start = Column(DateTime, nullable=True)
task_state = Column(String(15))
power_state = Column(String(15), nullable=True)
target_power_state = Column(String(15), nullable=True)
provision_state = Column(String(15), nullable=True)
target_provision_state = Column(String(15), nullable=True)
properties = Column(JSONEncodedDict)
driver = Column(String(15))
driver_info = Column(JSONEncodedDict)

View File

@ -40,8 +40,10 @@ class Node(base.IronicObject):
'properties': utils.dict_or_none,
'reservation': utils.str_or_none,
'task_state': utils.str_or_none,
'task_start': utils.datetime_or_none,
'power_state': utils.str_or_none,
'target_power_state': utils.str_or_none,
'provision_state': utils.str_or_none,
'target_provision_state': utils.str_or_none,
'extra': utils.dict_or_none,
}
@ -70,7 +72,7 @@ class Node(base.IronicObject):
"""Save updates to this Node.
Column-wise updates will be made based on the result of
self.what_changed(). If expected_task_state is provided,
self.what_changed(). If target_power_state is provided,
it will be checked against the in-database copy of the
node before updates are made.

View File

@ -17,8 +17,10 @@ Tests for the API /nodes/ methods.
"""
import mox
import webtest.app
from ironic.common import exception
from ironic.common import states
from ironic.conductor import rpcapi
from ironic.openstack.common import uuidutils
from ironic.tests.api import base
@ -101,6 +103,38 @@ class TestListNodes(base.FunctionalTest):
self.assertEqual(len(data['items']), 1)
self.assertEqual(len(data['links']), 1)
def test_state(self):
ndict = dbutils.get_test_node()
self.dbapi.create_node(ndict)
data = self.get_json('/nodes/%s/state' % ndict['uuid'])
[self.assertIn(key, data) for key in ['power', 'provision']]
# Check if it only returns a sub-set of the attributes
[self.assertIn(key, ['current', 'links'])
for key in data['power'].keys()]
[self.assertIn(key, ['current', 'links'])
for key in data['provision'].keys()]
def test_power_state(self):
ndict = dbutils.get_test_node()
self.dbapi.create_node(ndict)
data = self.get_json('/nodes/%s/state/power' % ndict['uuid'])
[self.assertIn(key, data) for key in
['available', 'current', 'target', 'links']]
#TODO(lucasagomes): Add more tests to check to which states it can
# transition to from the current one, and check if they are present
# in the available list.
def test_provision_state(self):
ndict = dbutils.get_test_node()
self.dbapi.create_node(ndict)
data = self.get_json('/nodes/%s/state/provision' % ndict['uuid'])
[self.assertIn(key, data) for key in
['available', 'current', 'target', 'links']]
#TODO(lucasagomes): Add more tests to check to which states it can
# transition to from the current one, and check if they are present
# in the available list.
class TestPatch(base.FunctionalTest):
@ -109,7 +143,8 @@ class TestPatch(base.FunctionalTest):
ndict = dbutils.get_test_node()
self.node = self.dbapi.create_node(ndict)
self.mox.StubOutWithMock(rpcapi.ConductorAPI, 'update_node')
self.mox.StubOutWithMock(rpcapi.ConductorAPI, 'start_state_change')
self.mox.StubOutWithMock(rpcapi.ConductorAPI,
'start_power_state_change')
def test_update_ok(self):
rpcapi.ConductorAPI.update_node(mox.IgnoreArg(), mox.IgnoreArg()).\
@ -123,18 +158,9 @@ class TestPatch(base.FunctionalTest):
self.mox.VerifyAll()
def test_update_state(self):
rpcapi.ConductorAPI.update_node(mox.IgnoreArg(), mox.IgnoreArg()).\
AndReturn(self.node)
rpcapi.ConductorAPI.start_state_change(mox.IgnoreArg(),
mox.IgnoreArg(), mox.IgnoreArg())
self.mox.ReplayAll()
response = self.patch_json('/nodes/%s' % self.node['uuid'],
{'task_state': 'new state'})
self.assertEqual(response.content_type, 'application/json')
# TODO(deva): change to 202 when wsme 0.5b3 is released
self.assertEqual(response.status_code, 200)
self.mox.VerifyAll()
self.assertRaises(webtest.app.AppError, self.patch_json,
'/nodes/%s' % self.node['uuid'],
{'power_state': 'new state'})
def test_update_fails_bad_driver_info(self):
fake_err = 'Fake Error Message'
@ -185,3 +211,43 @@ class TestDelete(base.FunctionalTest):
self.assertEqual(response.status_int, 500)
self.assertEqual(response.content_type, 'application/json')
self.assertTrue(response.json['error_message'])
class TestPut(base.FunctionalTest):
def setUp(self):
super(TestPut, self).setUp()
ndict = dbutils.get_test_node()
self.node = self.dbapi.create_node(ndict)
self.mox.StubOutWithMock(rpcapi.ConductorAPI, 'update_node')
self.mox.StubOutWithMock(rpcapi.ConductorAPI,
'start_power_state_change')
def test_power_state(self):
rpcapi.ConductorAPI.update_node(mox.IgnoreArg(), mox.IgnoreArg()).\
AndReturn(self.node)
rpcapi.ConductorAPI.start_power_state_change(mox.IgnoreArg(),
mox.IgnoreArg(),
mox.IgnoreArg())
self.mox.ReplayAll()
response = self.put_json('/nodes/%s/state/power' % self.node['uuid'],
{'target': states.POWER_ON})
self.assertEqual(response.content_type, 'application/json')
# FIXME(lucasagomes): WSME should return 202 not 200
self.assertEqual(response.status_code, 200)
self.mox.VerifyAll()
def test_power_state_in_progress(self):
rpcapi.ConductorAPI.update_node(mox.IgnoreArg(), mox.IgnoreArg()).\
AndReturn(self.node)
rpcapi.ConductorAPI.start_power_state_change(mox.IgnoreArg(),
mox.IgnoreArg(),
mox.IgnoreArg())
self.mox.ReplayAll()
self.put_json('/nodes/%s/state/power' % self.node['uuid'],
{'target': states.POWER_ON})
self.assertRaises(webtest.app.AppError, self.put_json,
'/nodes/%s/state/power' % self.node['uuid'],
{'target': states.POWER_ON})
self.mox.VerifyAll()

View File

@ -96,7 +96,8 @@ class ManagerTestCase(base.DbTestCase):
def test_update_node_invalid_state(self):
ndict = utils.get_test_node(driver='fake', extra={'test': 'one'},
instance_uuid=None, task_state=states.POWER_ON)
instance_uuid=None,
power_state=states.POWER_ON)
node = self.dbapi.create_node(ndict)
# check that it fails because state is POWER_ON

View File

@ -90,8 +90,8 @@ class RPCAPITestCase(base.DbTestCase):
'call',
node_obj=self.fake_node)
def test_start_state_change(self):
self._test_rpcapi('start_state_change',
def test_start_power_state_change(self):
self._test_rpcapi('start_power_state_change',
'cast',
node_obj=self.fake_node,
new_state=states.POWER_ON)

View File

@ -125,7 +125,7 @@ class ExclusiveLockDecoratorTestCase(base.DbTestCase):
def do_state_change(task):
for r in task.resources:
task.dbapi.update_node(r.node.uuid,
{'task_state': 'test-state'})
{'power_state': 'test-state'})
with task_manager.acquire(self.uuids, shared=True) as task:
self.assertRaises(exception.ExclusiveLockRequired,
@ -137,13 +137,13 @@ class ExclusiveLockDecoratorTestCase(base.DbTestCase):
for uuid in self.uuids:
res = self.dbapi.get_node(uuid)
self.assertEqual('test-state', res.task_state)
self.assertEqual('test-state', res.power_state)
@task_manager.require_exclusive_lock
def _do_state_change(self, task):
for r in task.resources:
task.dbapi.update_node(r.node.uuid,
{'task_state': 'test-state'})
{'power_state': 'test-state'})
def test_require_exclusive_lock_on_object(self):
with task_manager.acquire(self.uuids, shared=True) as task:
@ -156,7 +156,7 @@ class ExclusiveLockDecoratorTestCase(base.DbTestCase):
for uuid in self.uuids:
res = self.dbapi.get_node(uuid)
self.assertEqual('test-state', res.task_state)
self.assertEqual('test-state', res.power_state)
def test_one_node_per_task_properties(self):
with task_manager.acquire(self.uuids) as task:

View File

@ -656,3 +656,18 @@ class TestMigrations(BaseMigrationTestCase, WalkVersionsMixin):
chassis = db_utils.get_table(engine, 'chassis')
self.assertTrue(isinstance(chassis.c.description.type,
sqlalchemy.types.String))
def _check_009(self, engine, data):
nodes = db_utils.get_table(engine, 'nodes')
col_names = [column.name for column in nodes.c]
self.assertFalse('task_start' in col_names)
self.assertFalse('task_state' in col_names)
new_col = {'power_state': 'String',
'target_power_state': 'String',
'provision_state': 'String',
'target_provision_state': 'String'}
for col, coltype in new_col.items():
self.assertTrue(isinstance(nodes.c[col].type,
getattr(sqlalchemy.types, coltype)))

View File

@ -158,12 +158,12 @@ class DbNodeTestCase(base.DbTestCase):
def test_update_node(self):
n = self._create_test_node()
old_state = n['task_state']
new_state = 'TESTSTATE'
self.assertNotEqual(old_state, new_state)
old_extra = n['extra']
new_extra = {'foo': 'bar'}
self.assertNotEqual(old_extra, new_extra)
res = self.dbapi.update_node(n['id'], {'task_state': new_state})
self.assertEqual(new_state, res['task_state'])
res = self.dbapi.update_node(n['id'], {'extra': new_extra})
self.assertEqual(new_extra, res['extra'])
def test_reserve_one_node(self):
n = self._create_test_node()

View File

@ -16,6 +16,8 @@
# under the License.
"""Ironic test utilities."""
from ironic.common import states
from ironic.openstack.common import jsonutils as json
@ -70,8 +72,10 @@ def get_test_node(**kw):
'id': kw.get('id', 123),
'uuid': kw.get('uuid', '1be26c0b-03f2-4d2e-ae87-c02d7f33c123'),
'chassis_id': 42,
'task_start': None,
'task_state': kw.get('task_state', 'NOSTATE'),
'power_state': kw.get('power_state', states.NOSTATE),
'target_power_state': None,
'provision_state': kw.get('provision_state', states.NOSTATE),
'target_provision_state': None,
'instance_uuid': kw.get('instance_uuid',
'8227348d-5f1d-4488-aad1-7c92b2d42504'),
'driver': kw.get('driver', 'fake'),