nova-less-deploy: updates for metalsmith >= 0.9.0

* Replace image detection with the call from metalsmith
* Validate image parameters early to detect trivial errors
* Add support for specifying subnets in NICs
* Use more specific exceptions when checking for existing instances
* Drop compatibility code for nodes in < 0.9.0

Change-Id: I719082c0845b517172c309838e37a5e38a04c04c
Implements: blueprint nova-less-deploy
This commit is contained in:
Dmitry Tantsur 2019-01-28 11:49:51 +01:00
parent 620afb7845
commit 0f1102b3fd
3 changed files with 77 additions and 62 deletions

View File

@ -28,5 +28,5 @@ python-keystoneclient>=3.8.0 # Apache-2.0
keystoneauth1>=3.4.0 # Apache-2.0
tenacity>=4.4.0 # Apache-2.0
futures>=3.0.0;python_version=='2.7' or python_version=='2.6' # BSD
metalsmith>=0.8.0 # Apache-2.0
metalsmith>=0.9.0 # Apache-2.0
jsonschema<3.0.0,>=2.6.0 # MIT

View File

@ -18,6 +18,7 @@ import jsonschema
import metalsmith
from metalsmith import sources
from mistral_lib import actions
from openstack import exceptions as sdk_exc
import six
from tripleo_common.actions import base
@ -51,6 +52,7 @@ _INSTANCES_INPUT_SCHEMA = {
'network': {'type': 'string'},
'port': {'type': 'string'},
'fixed_ip': {'type': 'string'},
'subnet': {'type': 'string'},
},
'additionalProperties': False}},
'profile': {'type': 'string'},
@ -89,10 +91,16 @@ class CheckExistingInstancesAction(base.TripleOAction):
for request in self.instances:
try:
instance = provisioner.show_instance(request['hostname'])
# TODO(dtantsur): use openstacksdk exceptions when metalsmith
# is bumped to 0.9.0.
except Exception:
# TODO(dtantsur): replace Error with a specific exception
except (sdk_exc.ResourceNotFound, metalsmith.exceptions.Error):
not_found.append(request)
except Exception as exc:
message = ('Failed to request instance information for '
'hostname %s' % request['hostname'])
LOG.exception(message)
return actions.Result(
error="%s. %s: %s" % (message, type(exc).__name__, exc)
)
else:
# NOTE(dtantsur): metalsmith can match instances by node names,
# provide a safeguard to avoid conflicts.
@ -166,13 +174,7 @@ class ReserveNodesAction(base.TripleOAction):
traits=instance.get('traits'))
LOG.info('Reserved node %s for instance %s', node, instance)
nodes.append(node)
try:
node_id = node.id
except AttributeError:
# TODO(dtantsur): transition from ironicclient to
# openstacksdk, remove when metalsmith is bumped to 0.9.0
node_id = node.uuid
result.append({'node': node_id, 'instance': instance})
result.append({'node': node.id, 'instance': instance})
except Exception as exc:
LOG.exception('Provisioning failed, cleaning up')
# Remove all reservations on failure
@ -204,45 +206,10 @@ class DeployNodeAction(base.TripleOAction):
self.default_network = default_network
self.default_root_size = default_root_size
def _get_image(self):
# TODO(dtantsur): move this logic to metalsmith in 0.9.0
image = self.instance.get('image', self.default_image)
image_type = _link_type(image)
if image_type == 'glance':
return sources.GlanceImage(image)
else:
checksum = self.instance.get('image_checksum')
if (checksum and image_type == 'http' and
_link_type(checksum) == 'http'):
kwargs = {'checksum_url': checksum}
else:
kwargs = {'checksum': checksum}
whole_disk_image = not (self.instance.get('image_kernel') or
self.instance.get('image_ramdisk'))
if whole_disk_image:
if image_type == 'http':
return sources.HttpWholeDiskImage(image, **kwargs)
else:
return sources.FileWholeDiskImage(image, **kwargs)
else:
if image_type == 'http':
return sources.HttpPartitionImage(
image,
kernel_url=self.instance.get('image_kernel'),
ramdisk_url=self.instance.get('image_ramdisk'),
**kwargs)
else:
return sources.FilePartitionImage(
image,
kernel_location=self.instance.get('image_kernel'),
ramdisk_location=self.instance.get('image_ramdisk'),
**kwargs)
def run(self, context):
try:
_validate_instances([self.instance])
_validate_instances([self.instance],
default_image=self.default_image)
except Exception as exc:
LOG.error('Failed to validate the request. %s', exc)
return actions.Result(error=six.text_type(exc))
@ -252,11 +219,12 @@ class DeployNodeAction(base.TripleOAction):
LOG.debug('Starting provisioning of %s on node %s',
self.instance, self.node)
try:
image = _get_source(self.instance)
instance = provisioner.provision_node(
self.node,
config=self.config,
hostname=self.instance['hostname'],
image=self._get_image(),
image=image,
nics=self.instance.get('nics',
[{'network': self.default_network}]),
root_size_gb=self.instance.get('root_size_gb',
@ -327,16 +295,22 @@ class UndeployInstanceAction(base.TripleOAction):
LOG.info('Successfully unprovisioned %s', instance.hostname)
def _validate_instances(instances):
def _validate_instances(instances, default_image='overcloud-full'):
for inst in instances:
if inst.get('name') and not inst.get('hostname'):
inst['hostname'] = inst['name']
# Set the default image so that the source validation can succeed.
inst.setdefault('image', default_image)
jsonschema.validate(instances, _INSTANCES_INPUT_SCHEMA)
hostnames = set()
names = set()
for inst in instances:
# NOTE(dtantsur): validate image parameters
_get_source(inst)
if inst['hostname'] in hostnames:
raise ValueError('Hostname %s is used more than once' %
inst['hostname'])
@ -360,10 +334,8 @@ def _release_nodes(provisioner, nodes):
LOG.info('Removed reservation from node %s', node)
def _link_type(image):
if image.startswith('http://') or image.startswith('https://'):
return 'http'
elif image.startswith('file://'):
return 'file'
else:
return 'glance'
def _get_source(instance):
return sources.detect(image=instance.get('image'),
kernel=instance.get('image_kernel'),
ramdisk=instance.get('image_ramdisk'),
checksum=instance.get('image_checksum'))

View File

@ -12,8 +12,10 @@
# License for the specific language governing permissions and limitations
# under the License.
import metalsmith
from metalsmith import sources
import mock
from openstack import exceptions as sdk_exc
from tripleo_common.actions import baremetal_deploy
from tripleo_common.tests import base
@ -195,6 +197,34 @@ class TestDeployNode(base.TestCase):
self.assertEqual([], config.ssh_keys)
self.assertEqual('heat-admin', config.users[0]['name'])
def test_success_advanced_nic(self, mock_pr):
action = baremetal_deploy.DeployNodeAction(
instance={'hostname': 'host1',
'nics': [{'subnet': 'ctlplane-subnet'},
{'network': 'ctlplane',
'fixed_ip': '10.0.0.2'}]},
node='1234'
)
result = action.run(mock.Mock())
pr = mock_pr.return_value
self.assertEqual(
pr.provision_node.return_value.to_dict.return_value,
result)
pr.provision_node.assert_called_once_with(
'1234',
image=mock.ANY,
nics=[{'subnet': 'ctlplane-subnet'},
{'network': 'ctlplane', 'fixed_ip': '10.0.0.2'}],
hostname='host1',
root_size_gb=49,
swap_size_mb=None,
config=mock.ANY,
)
config = pr.provision_node.call_args[1]['config']
self.assertEqual([], config.ssh_keys)
self.assertEqual('heat-admin', config.users[0]['name'])
def test_success(self, mock_pr):
pr = mock_pr.return_value
action = baremetal_deploy.DeployNodeAction(
@ -228,8 +258,6 @@ class TestDeployNode(base.TestCase):
self.assertIsInstance(source, sources.GlanceImage)
# TODO(dtantsur): check the image when it's a public field
# NOTE(dtantsur): limited coverage for source detection since this code is
# being moved to metalsmith in 0.9.0.
def test_success_http_partition_image(self, mock_pr):
action = baremetal_deploy.DeployNodeAction(
instance={'hostname': 'host1',
@ -363,12 +391,14 @@ class TestCheckExistingInstances(base.TestCase):
pr = mock_pr.return_value
instances = [
{'hostname': 'host1'},
{'hostname': 'host3'},
{'hostname': 'host2', 'resource_class': 'compute',
'capabilities': {'answer': '42'}}
]
existing = mock.MagicMock(hostname='host2')
pr.show_instance.side_effect = [
RuntimeError('not found'),
sdk_exc.ResourceNotFound(""),
metalsmith.exceptions.Error(""),
existing,
]
action = baremetal_deploy.CheckExistingInstancesAction(instances)
@ -376,10 +406,11 @@ class TestCheckExistingInstances(base.TestCase):
self.assertEqual({
'instances': [existing.to_dict.return_value],
'not_found': [{'hostname': 'host1'}]
'not_found': [{'hostname': 'host1', 'image': 'overcloud-full'},
{'hostname': 'host3', 'image': 'overcloud-full'}]
}, result)
pr.show_instance.assert_has_calls([
mock.call('host1'), mock.call('host2')
mock.call(host) for host in ['host1', 'host3', 'host2']
])
def test_missing_hostname(self, mock_pr):
@ -404,6 +435,18 @@ class TestCheckExistingInstances(base.TestCase):
self.assertIn("hostname host1 was not found", result.error)
mock_pr.return_value.show_instance.assert_called_once_with('host1')
def test_unexpected_error(self, mock_pr):
instances = [
{'hostname': 'host%d' % i} for i in range(3)
]
mock_pr.return_value.show_instance.side_effect = RuntimeError('boom')
action = baremetal_deploy.CheckExistingInstancesAction(instances)
result = action.run(mock.Mock())
self.assertIn("hostname host0", result.error)
self.assertIn("RuntimeError: boom", result.error)
mock_pr.return_value.show_instance.assert_called_once_with('host0')
@mock.patch.object(baremetal_deploy, '_provisioner', autospec=True)
class TestWaitForDeployment(base.TestCase):