summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ironic/common/release_mappings.py1
-rw-r--r--ironic/conductor/manager.py3
-rw-r--r--ironic/drivers/modules/console_utils.py23
-rw-r--r--ironic/drivers/modules/ipmitool.py7
-rw-r--r--ironic/objects/__init__.py1
-rw-r--r--ironic/objects/deployment.py259
-rw-r--r--ironic/tests/unit/common/test_release_mappings.py2
-rw-r--r--ironic/tests/unit/conductor/test_manager.py22
-rw-r--r--ironic/tests/unit/drivers/modules/test_console_utils.py43
-rw-r--r--ironic/tests/unit/drivers/modules/test_ipmitool.py5
-rw-r--r--ironic/tests/unit/objects/test_deployment.py117
-rw-r--r--ironic/tests/unit/objects/test_objects.py1
-rw-r--r--releasenotes/notes/skip-power-sync-for-adoptfail-d2498f1a2e997ed7.yaml5
-rw-r--r--releasenotes/notes/socat-console-port-alloc-ipv6-26760f53f86209d0.yaml5
14 files changed, 480 insertions, 14 deletions
diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py
index 77d7f2500..bc9565a98 100644
--- a/ironic/common/release_mappings.py
+++ b/ironic/common/release_mappings.py
@@ -255,6 +255,7 @@ RELEASE_MAPPING = {
'Node': ['1.35'],
'Conductor': ['1.3'],
'Chassis': ['1.3'],
+ 'Deployment': ['1.0'],
'DeployTemplate': ['1.1'],
'Port': ['1.9'],
'Portgroup': ['1.4'],
diff --git a/ironic/conductor/manager.py b/ironic/conductor/manager.py
index cb20fcfd8..356403bab 100644
--- a/ironic/conductor/manager.py
+++ b/ironic/conductor/manager.py
@@ -81,7 +81,8 @@ LOG = log.getLogger(__name__)
METRICS = metrics_utils.get_metrics_logger(__name__)
-SYNC_EXCLUDED_STATES = (states.DEPLOYWAIT, states.CLEANWAIT, states.ENROLL)
+SYNC_EXCLUDED_STATES = (states.DEPLOYWAIT, states.CLEANWAIT, states.ENROLL,
+ states.ADOPTFAIL)
class ConductorManager(base_manager.BaseConductorManager):
diff --git a/ironic/drivers/modules/console_utils.py b/ironic/drivers/modules/console_utils.py
index b2f92ba3d..6e08b6712 100644
--- a/ironic/drivers/modules/console_utils.py
+++ b/ironic/drivers/modules/console_utils.py
@@ -162,11 +162,24 @@ def _get_port_range():
return start, stop
-def _verify_port(port):
+def _verify_port(port, host=None):
"""Check whether specified port is in use."""
- s = socket.socket()
+ ip_version = None
+ if host is not None:
+ try:
+ ip_version = ipaddress.ip_address(host).version
+ except ValueError:
+ # Assume it's a hostname
+ pass
+ else:
+ host = CONF.host
+ if ip_version == 6:
+ s = socket.socket(socket.AF_INET6)
+ else:
+ s = socket.socket()
+
try:
- s.bind((CONF.host, port))
+ s.bind((host, port))
except socket.error:
raise exception.Conflict()
finally:
@@ -174,7 +187,7 @@ def _verify_port(port):
@lockutils.synchronized(SERIAL_LOCK)
-def acquire_port():
+def acquire_port(host=None):
"""Returns a free TCP port on current host.
Find and returns a free TCP port in the range
@@ -187,7 +200,7 @@ def acquire_port():
if port in ALLOCATED_PORTS:
continue
try:
- _verify_port(port)
+ _verify_port(port, host=host)
ALLOCATED_PORTS.add(port)
return port
except exception.Conflict:
diff --git a/ironic/drivers/modules/ipmitool.py b/ironic/drivers/modules/ipmitool.py
index 85beca183..a3b443b58 100644
--- a/ironic/drivers/modules/ipmitool.py
+++ b/ironic/drivers/modules/ipmitool.py
@@ -807,10 +807,10 @@ def _constructor_checks(driver):
_check_temp_dir()
-def _allocate_port(task):
+def _allocate_port(task, host=None):
node = task.node
dii = node.driver_internal_info or {}
- allocated_port = console_utils.acquire_port()
+ allocated_port = console_utils.acquire_port(host=host)
dii['allocated_ipmi_terminal_port'] = allocated_port
node.driver_internal_info = dii
node.save()
@@ -1411,7 +1411,8 @@ class IPMISocatConsole(IPMIConsole):
"""
driver_info = _parse_driver_info(task.node)
if not driver_info['port']:
- driver_info['port'] = _allocate_port(task)
+ driver_info['port'] = _allocate_port(
+ task, host=CONF.console.socat_address)
try:
self._exec_stop_console(driver_info)
diff --git a/ironic/objects/__init__.py b/ironic/objects/__init__.py
index 2afe75003..7f199c6aa 100644
--- a/ironic/objects/__init__.py
+++ b/ironic/objects/__init__.py
@@ -29,6 +29,7 @@ def register_all():
__import__('ironic.objects.chassis')
__import__('ironic.objects.conductor')
__import__('ironic.objects.deploy_template')
+ __import__('ironic.objects.deployment')
__import__('ironic.objects.node')
__import__('ironic.objects.port')
__import__('ironic.objects.portgroup')
diff --git a/ironic/objects/deployment.py b/ironic/objects/deployment.py
new file mode 100644
index 000000000..7fe7f7544
--- /dev/null
+++ b/ironic/objects/deployment.py
@@ -0,0 +1,259 @@
+# 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_utils import uuidutils
+from oslo_versionedobjects import base as object_base
+
+from ironic.common import exception
+from ironic.db import api as dbapi
+from ironic.objects import base
+from ironic.objects import fields as object_fields
+from ironic.objects import node as node_obj
+
+
+@base.IronicObjectRegistry.register
+class Deployment(base.IronicObject, object_base.VersionedObjectDictCompat):
+ # Version 1.0: Initial version
+ VERSION = '1.0'
+
+ dbapi = dbapi.get_instance()
+
+ fields = {
+ 'uuid': object_fields.UUIDField(nullable=True),
+ 'node_uuid': object_fields.UUIDField(nullable=True),
+ 'image_checksum': object_fields.StringField(nullable=True),
+ 'image_ref': object_fields.StringField(nullable=True),
+ 'kernel_ref': object_fields.StringField(nullable=True),
+ 'ramdisk_ref': object_fields.StringField(nullable=True),
+ 'root_device': object_fields.FlexibleDictField(nullable=True),
+ 'root_gib': object_fields.IntegerField(nullable=True),
+ 'state': object_fields.StringField(nullable=True),
+ 'swap_mib': object_fields.IntegerField(nullable=True),
+ }
+
+ node_mapping = {
+ 'instance_uuid': 'uuid',
+ 'provision_state': 'state',
+ 'uuid': 'node_uuid',
+ }
+
+ instance_info_mapping = {
+ 'image_checksum': 'image_checksum',
+ 'image_source': 'image_ref',
+ 'kernel': 'kernel_ref',
+ 'ramdisk': 'ramdisk_ref',
+ 'root_device': 'root_device',
+ 'root_gb': 'root_gib',
+ 'swap_mb': 'swap_mib',
+ }
+
+ instance_info_mapping_rev = {v: k
+ for k, v in instance_info_mapping.items()}
+
+ assert (set(node_mapping.values()) | set(instance_info_mapping.values())
+ == set(fields))
+
+ def _convert_to_version(self, target_version,
+ remove_unavailable_fields=True):
+ """Convert to the target version.
+
+ Convert the object to the target version. The target version may be
+ the same, older, or newer than the version of the object. This is
+ used for DB interactions as well as for serialization/deserialization.
+
+ :param target_version: the desired version of the object
+ :param remove_unavailable_fields: True to remove fields that are
+ unavailable in the target version; set this to True when
+ (de)serializing. False to set the unavailable fields to appropriate
+ values; set this to False for DB interactions.
+ """
+
+ @classmethod
+ def _from_node_object(cls, context, node):
+ """Convert a node into a virtual `Deployment` object."""
+ result = cls(context)
+ result._update_from_node_object(node)
+ return result
+
+ def _update_from_node_object(self, node):
+ """Update the Deployment object from the node."""
+ for src, dest in self.node_mapping.items():
+ setattr(self, dest, getattr(node, src, None))
+ for src, dest in self.instance_info_mapping.items():
+ setattr(self, dest, node.instance_info.get(src))
+
+ def _update_node_object(self, node):
+ """Update the given node object with the changes here."""
+ changes = self.obj_get_changes()
+ try:
+ new_instance_uuid = changes.pop('uuid')
+ except KeyError:
+ pass
+ else:
+ node.instance_uuid = new_instance_uuid
+
+ changes.pop('node_uuid', None)
+ instance_info = node.instance_info
+
+ for field, value in changes.items():
+ # NOTE(dtantsur): only instance_info fields can be updated here.
+ try:
+ dest = self.instance_info_mapping_rev[field]
+ except KeyError:
+ # NOTE(dtantsur): this should not happen because of API-level
+ # validations, but checking just in case.
+ raise exception.BadRequest('Field %s cannot be set or updated'
+ % changes)
+ instance_info[dest] = value
+
+ node.instance_info = instance_info
+ return node
+
+ # NOTE(xek): We don't want to enable RPC on this call just yet. Remotable
+ # methods can be used in the future to replace current explicit RPC calls.
+ # Implications of calling new remote procedures should be thought through.
+ # @object_base.remotable_classmethod
+ @classmethod
+ def get_by_uuid(cls, context, uuid):
+ """Find a deployment by its UUID.
+
+ :param cls: the :class:`Deployment`
+ :param context: Security context
+ :param uuid: The UUID of a deployment.
+ :returns: An :class:`Deployment` object.
+ :raises: InstanceNotFound
+
+ """
+ node = node_obj.Node.get_by_instance_uuid(context, uuid)
+ return cls._from_node_object(context, node)
+
+ # NOTE(xek): We don't want to enable RPC on this call just yet. Remotable
+ # methods can be used in the future to replace current explicit RPC calls.
+ # Implications of calling new remote procedures should be thought through.
+ # @object_base.remotable_classmethod
+ @classmethod
+ def get_by_node_uuid(cls, context, node_uuid):
+ """Find a deployment based by its node's UUID.
+
+ :param cls: the :class:`Deployment`
+ :param context: Security context
+ :param node_uuid: The UUID of a corresponding node.
+ :returns: An :class:`Deployment` object.
+ :raises: NodeNotFound
+
+ """
+ node = node_obj.Node.get_by_uuid(context, node_uuid)
+ return cls._from_node_object(context, node)
+
+ # NOTE(xek): We don't want to enable RPC on this call just yet. Remotable
+ # methods can be used in the future to replace current explicit RPC calls.
+ # Implications of calling new remote procedures should be thought through.
+ # @object_base.remotable_classmethod
+ @classmethod
+ def list(cls, context, filters=None, limit=None, marker=None,
+ sort_key=None, sort_dir=None):
+ """Return a list of Deployment objects.
+
+ :param cls: the :class:`Deployment`
+ :param context: Security context.
+ :param filters: Filters to apply.
+ :param limit: Maximum number of resources to return in a single result.
+ :param marker: Pagination marker for large data sets.
+ :param sort_key: Column to sort results by.
+ :param sort_dir: Direction to sort. "asc" or "desc".
+ :returns: A list of :class:`Deployment` object.
+ :raises: InvalidParameterValue
+
+ """
+ nodes = node_obj.Node.list(context, filters=filters, limit=limit,
+ marker=marker, sort_key=sort_key,
+ sort_dir=sort_dir)
+ return [cls._from_node_object(context, node) for node in nodes]
+
+ # NOTE(xek): We don't want to enable RPC on this call just yet. Remotable
+ # methods can be used in the future to replace current explicit RPC calls.
+ # Implications of calling new remote procedures should be thought through.
+ # @object_base.remotable
+ def create(self, context=None, node=None):
+ """Create a Deployment.
+
+ Updates the corresponding node under the hood.
+
+ :param context: Security context. NOTE: This should only
+ be used internally by the indirection_api.
+ Unfortunately, RPC requires context as the first
+ argument, even though we don't use it.
+ A context should be set when instantiating the
+ object, e.g.: Deployment(context)
+ :param node: Node object for deployment.
+ :raises: InstanceAssociated, NodeAssociated, NodeNotFound
+
+ """
+ if node is None:
+ node = node_obj.Node.get_by_uuid(self._context, self.node_uuid)
+ elif 'node_uuid' in self and self.node_uuid:
+ # NOTE(dtantsur): this is only possible if a bug happens on
+ # a higher level.
+ assert self.node_uuid == node.uuid
+
+ if 'uuid' not in self or not self.uuid:
+ self.uuid = uuidutils.generate_uuid()
+ node.instance_uuid = self.uuid
+ self._update_node_object(node)
+ node.save()
+ self._update_from_node_object(node)
+ self.obj_reset_changes()
+
+ # NOTE(xek): We don't want to enable RPC on this call just yet. Remotable
+ # methods can be used in the future to replace current explicit RPC calls.
+ # Implications of calling new remote procedures should be thought through.
+ # @object_base.remotable
+ def destroy(self, context=None, node=None):
+ """Delete the Deployment.
+
+ Updates the corresponding node under the hood.
+
+ :param context: Security context. NOTE: This should only
+ be used internally by the indirection_api.
+ Unfortunately, RPC requires context as the first
+ argument, even though we don't use it.
+ A context should be set when instantiating the
+ object, e.g.: Node(context)
+ :param node: Node object for deployment.
+ """
+ if node is None:
+ node = node_obj.Node.get_by_uuid(self._context, self.node_uuid)
+ else:
+ assert node.uuid == self.node_uuid
+ node.instance_uuid = None
+ node.instance_info = {}
+ node.save()
+ self._update_from_node_object(node)
+ self.obj_reset_changes()
+
+ # NOTE(xek): We don't want to enable RPC on this call just yet. Remotable
+ # methods can be used in the future to replace current explicit RPC calls.
+ # Implications of calling new remote procedures should be thought through.
+ # @object_base.remotable
+ def refresh(self, context=None):
+ """Refresh the object by re-fetching from the DB.
+
+ :param context: Security context. NOTE: This should only
+ be used internally by the indirection_api.
+ Unfortunately, RPC requires context as the first
+ argument, even though we don't use it.
+ A context should be set when instantiating the
+ object, e.g.: Node(context)
+ """
+ current = self.get_by_uuid(self._context, self.uuid)
+ self.obj_refresh(current)
+ self.obj_reset_changes()
diff --git a/ironic/tests/unit/common/test_release_mappings.py b/ironic/tests/unit/common/test_release_mappings.py
index 2231d0be5..b5adfa060 100644
--- a/ironic/tests/unit/common/test_release_mappings.py
+++ b/ironic/tests/unit/common/test_release_mappings.py
@@ -92,6 +92,8 @@ class ReleaseMappingsTestCase(base.TestCase):
model_names -= exceptions
# NodeTrait maps to two objects
model_names |= set(['Trait', 'TraitList'])
+ # Deployment is purely virtual.
+ model_names.add('Deployment')
object_names = set(
release_mappings.RELEASE_MAPPING['master']['objects'])
self.assertEqual(model_names, object_names)
diff --git a/ironic/tests/unit/conductor/test_manager.py b/ironic/tests/unit/conductor/test_manager.py
index 7ab03e175..52a5e03a3 100644
--- a/ironic/tests/unit/conductor/test_manager.py
+++ b/ironic/tests/unit/conductor/test_manager.py
@@ -5266,6 +5266,28 @@ class ManagerSyncPowerStatesTestCase(mgr_utils.CommonMixIn,
shared=True)
sync_mock.assert_called_once_with(task, mock.ANY)
+ def test_single_node_adopt_failed(self, get_nodeinfo_mock,
+ mapped_mock, acquire_mock, sync_mock):
+ get_nodeinfo_mock.return_value = self._get_nodeinfo_list_response()
+ mapped_mock.return_value = True
+ task = self._create_task(
+ node_attrs=dict(uuid=self.node.uuid,
+ provision_state=states.ADOPTFAIL))
+ acquire_mock.side_effect = self._get_acquire_side_effect(task)
+
+ self.service._sync_power_states(self.context)
+
+ get_nodeinfo_mock.assert_called_once_with(
+ columns=self.columns, filters=self.filters)
+ mapped_mock.assert_called_once_with(self.service,
+ self.node.uuid,
+ self.node.driver,
+ self.node.conductor_group)
+ acquire_mock.assert_called_once_with(self.context, self.node.uuid,
+ purpose=mock.ANY,
+ shared=True)
+ sync_mock.assert_not_called()
+
def test__sync_power_state_multiple_nodes(self, get_nodeinfo_mock,
mapped_mock, acquire_mock,
sync_mock):
diff --git a/ironic/tests/unit/drivers/modules/test_console_utils.py b/ironic/tests/unit/drivers/modules/test_console_utils.py
index 752fa5fd1..3419abb4a 100644
--- a/ironic/tests/unit/drivers/modules/test_console_utils.py
+++ b/ironic/tests/unit/drivers/modules/test_console_utils.py
@@ -23,6 +23,7 @@ import ipaddress
import os
import random
import signal
+import socket
import string
import subprocess
import tempfile
@@ -668,7 +669,7 @@ class ConsoleUtilsTestCase(db_base.DbTestCase):
def test_allocate_port_success(self, mock_verify, mock_ports):
self.config(port_range='10000:10001', group='console')
port = console_utils.acquire_port()
- mock_verify.assert_called_once_with(10000)
+ mock_verify.assert_called_once_with(10000, host=None)
self.assertEqual(port, 10000)
mock_ports.add.assert_called_once_with(10000)
@@ -679,7 +680,9 @@ class ConsoleUtilsTestCase(db_base.DbTestCase):
mock_verify.side_effect = (exception.Conflict, exception.Conflict,
None)
port = console_utils.acquire_port()
- verify_calls = [mock.call(10000), mock.call(10001), mock.call(10002)]
+ verify_calls = [mock.call(10000, host=None),
+ mock.call(10001, host=None),
+ mock.call(10002, host=None)]
mock_verify.assert_has_calls(verify_calls)
self.assertEqual(port, 10002)
mock_ports.add.assert_called_once_with(10002)
@@ -691,5 +694,39 @@ class ConsoleUtilsTestCase(db_base.DbTestCase):
mock_verify.side_effect = exception.Conflict
self.assertRaises(exception.NoFreeIPMITerminalPorts,
console_utils.acquire_port)
- verify_calls = [mock.call(p) for p in range(10000, 10005)]
+ verify_calls = [mock.call(p, host=None) for p in range(10000, 10005)]
mock_verify.assert_has_calls(verify_calls)
+
+ @mock.patch.object(socket, 'socket', autospec=True)
+ def test__verify_port_default(self, mock_socket):
+ self.config(host='localhost.localdomain')
+ mock_sock = mock.MagicMock()
+ mock_socket.return_value = mock_sock
+ console_utils._verify_port(10000)
+ mock_sock.bind.assert_called_once_with(('localhost.localdomain',
+ 10000))
+
+ @mock.patch.object(socket, 'socket', autospec=True)
+ def test__verify_port_hostname(self, mock_socket):
+ mock_sock = mock.MagicMock()
+ mock_socket.return_value = mock_sock
+ console_utils._verify_port(10000, host='localhost.localdomain')
+ mock_socket.assert_called_once_with()
+ mock_sock.bind.assert_called_once_with(('localhost.localdomain',
+ 10000))
+
+ @mock.patch.object(socket, 'socket', autospec=True)
+ def test__verify_port_ipv4(self, mock_socket):
+ mock_sock = mock.MagicMock()
+ mock_socket.return_value = mock_sock
+ console_utils._verify_port(10000, host='1.2.3.4')
+ mock_socket.assert_called_once_with()
+ mock_sock.bind.assert_called_once_with(('1.2.3.4', 10000))
+
+ @mock.patch.object(socket, 'socket', autospec=True)
+ def test__verify_port_ipv6(self, mock_socket):
+ mock_sock = mock.MagicMock()
+ mock_socket.return_value = mock_sock
+ console_utils._verify_port(10000, host='2001:dead:beef::1')
+ mock_socket.assert_called_once_with(socket.AF_INET6)
+ mock_sock.bind.assert_called_once_with(('2001:dead:beef::1', 10000))
diff --git a/ironic/tests/unit/drivers/modules/test_ipmitool.py b/ironic/tests/unit/drivers/modules/test_ipmitool.py
index e45aee26f..c13aae62a 100644
--- a/ironic/tests/unit/drivers/modules/test_ipmitool.py
+++ b/ironic/tests/unit/drivers/modules/test_ipmitool.py
@@ -2629,7 +2629,7 @@ class IPMIToolDriverTestCase(Base):
with task_manager.acquire(self.context,
self.node.uuid) as task:
port = ipmi._allocate_port(task)
- mock_acquire.assert_called_once_with()
+ mock_acquire.assert_called_once_with(host=None)
self.assertEqual(port, 1234)
info = task.node.driver_internal_info
self.assertEqual(info['allocated_ipmi_terminal_port'], 1234)
@@ -2959,6 +2959,7 @@ class IPMIToolSocatDriverTestCase(IPMIToolShellinaboxTestCase):
autospec=True)
def test_start_console_alloc_port(self, mock_stop, mock_start, mock_info,
mock_alloc):
+ self.config(socat_address='2001:dead:beef::1', group='console')
mock_start.return_value = None
mock_info.return_value = {'port': None}
mock_alloc.return_value = 1234
@@ -2970,7 +2971,7 @@ class IPMIToolSocatDriverTestCase(IPMIToolShellinaboxTestCase):
mock_start.assert_called_once_with(
self.console, {'port': 1234},
console_utils.start_socat_console)
- mock_alloc.assert_called_once_with(mock.ANY)
+ mock_alloc.assert_called_once_with(mock.ANY, host='2001:dead:beef::1')
@mock.patch.object(ipmi.IPMISocatConsole, '_get_ipmi_cmd', autospec=True)
@mock.patch.object(console_utils, 'start_socat_console',
diff --git a/ironic/tests/unit/objects/test_deployment.py b/ironic/tests/unit/objects/test_deployment.py
new file mode 100644
index 000000000..cb62fad8e
--- /dev/null
+++ b/ironic/tests/unit/objects/test_deployment.py
@@ -0,0 +1,117 @@
+# 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_utils import uuidutils
+
+from ironic.common import exception
+from ironic import objects
+from ironic.tests.unit.db import base as db_base
+from ironic.tests.unit.objects import utils as obj_utils
+
+
+class TestDeploymentObject(db_base.DbTestCase, obj_utils.SchemasTestMixIn):
+
+ def setUp(self):
+ super(TestDeploymentObject, self).setUp()
+ self.uuid = uuidutils.generate_uuid()
+ self.instance_info = {
+ 'image_source': 'http://source',
+ 'kernel': 'http://kernel',
+ 'ramdisk': 'http://ramdisk',
+ 'image_checksum': '1234',
+ 'root_device': {'size': 42},
+ }
+ self.node = obj_utils.create_test_node(
+ self.context,
+ provision_state='active',
+ instance_uuid=self.uuid,
+ instance_info=self.instance_info)
+
+ def _check(self, do):
+ self.assertEqual(self.uuid, do.uuid)
+ self.assertEqual(self.node.uuid, do.node_uuid)
+ self.assertEqual(self.context, do._context)
+ self.assertEqual('http://source', do.image_ref)
+ self.assertEqual('http://kernel', do.kernel_ref)
+ self.assertEqual('http://ramdisk', do.ramdisk_ref)
+ self.assertEqual('1234', do.image_checksum)
+ self.assertEqual({'size': 42}, do.root_device)
+
+ def test_get_by_uuid(self):
+ do = objects.Deployment.get_by_uuid(self.context, self.uuid)
+ self._check(do)
+
+ def test_get_by_node_uuid(self):
+ do = objects.Deployment.get_by_node_uuid(self.context, self.node.uuid)
+ self._check(do)
+
+ def test_not_found(self):
+ self.assertRaises(exception.InstanceNotFound,
+ objects.Deployment.get_by_uuid,
+ self.context, uuidutils.generate_uuid())
+ self.assertRaises(exception.NodeNotFound,
+ objects.Deployment.get_by_node_uuid,
+ self.context, uuidutils.generate_uuid())
+
+ def test_create(self):
+ do = objects.Deployment(self.context)
+ do.node_uuid = self.node.uuid
+ do.image_ref = 'new-image'
+ do.create()
+ self.assertIsNotNone(do.uuid)
+
+ node = objects.Node.get_by_uuid(self.context, do.node_uuid)
+ self.assertEqual(do.uuid, node.instance_uuid)
+ self.assertEqual('new-image', node.instance_info['image_source'])
+ self.assertFalse(do.obj_what_changed())
+
+ def test_create_with_node(self):
+ do = objects.Deployment(self.context)
+ do.node_uuid = self.node.uuid
+ do.image_ref = 'new-image'
+ do.create(node=self.node)
+ self.assertIsNotNone(do.uuid)
+ self.assertEqual(do.uuid, self.node.instance_uuid)
+ self.assertEqual('new-image', self.node.instance_info['image_source'])
+ self.assertFalse(do.obj_what_changed())
+ self.assertFalse(self.node.obj_what_changed())
+
+ def test_destroy(self):
+ do = objects.Deployment(self.context)
+ do.node_uuid = self.node.uuid
+ do.image_ref = 'new-image'
+ do.create()
+ do.destroy()
+
+ node = objects.Node.get_by_uuid(self.context, do.node_uuid)
+ self.assertIsNone(node.instance_uuid)
+ self.assertEqual({}, node.instance_info)
+ self.assertFalse(do.obj_what_changed())
+
+ def test_destroy_with_node(self):
+ do = objects.Deployment(self.context)
+ do.node_uuid = self.node.uuid
+ do.image_ref = 'new-image'
+ do.create()
+ do.destroy(node=self.node)
+ self.assertIsNone(self.node.instance_uuid)
+ self.assertEqual({}, self.node.instance_info)
+ self.assertFalse(do.obj_what_changed())
+ self.assertFalse(self.node.obj_what_changed())
+
+ def test_refresh(self):
+ do = objects.Deployment.get_by_uuid(self.context, self.uuid)
+ do.node_uuid = None
+ do.image_source = 'updated'
+ do.refresh()
+ self._check(do)
+ self.assertFalse(do.obj_what_changed())
diff --git a/ironic/tests/unit/objects/test_objects.py b/ironic/tests/unit/objects/test_objects.py
index 1320d96d3..ffdf375fd 100644
--- a/ironic/tests/unit/objects/test_objects.py
+++ b/ironic/tests/unit/objects/test_objects.py
@@ -719,6 +719,7 @@ expected_object_fingerprints = {
'DeployTemplate': '1.1-4e30c8e9098595e359bb907f095bf1a9',
'DeployTemplateCRUDNotification': '1.0-59acc533c11d306f149846f922739c15',
'DeployTemplateCRUDPayload': '1.0-200857e7e715f58a5b6d6b700ab73a3b',
+ 'Deployment': '1.0-ff10ae028c5968f1596131d85d7f5f9d',
}
diff --git a/releasenotes/notes/skip-power-sync-for-adoptfail-d2498f1a2e997ed7.yaml b/releasenotes/notes/skip-power-sync-for-adoptfail-d2498f1a2e997ed7.yaml
new file mode 100644
index 000000000..2bff91797
--- /dev/null
+++ b/releasenotes/notes/skip-power-sync-for-adoptfail-d2498f1a2e997ed7.yaml
@@ -0,0 +1,5 @@
+---
+fixes:
+ - |
+ Fixes the conductor so the power sync operations are not asserted for
+ nodes in the ``adopt failed`` state.
diff --git a/releasenotes/notes/socat-console-port-alloc-ipv6-26760f53f86209d0.yaml b/releasenotes/notes/socat-console-port-alloc-ipv6-26760f53f86209d0.yaml
new file mode 100644
index 000000000..f8087363b
--- /dev/null
+++ b/releasenotes/notes/socat-console-port-alloc-ipv6-26760f53f86209d0.yaml
@@ -0,0 +1,5 @@
+---
+fixes:
+ - |
+ Fixes the issue that port auto allocation for the socat console failed to
+ correctly identify the availablility of ports under IPv6 networks.