diff --git a/cluster_upgrade/handlers.py b/cluster_upgrade/handlers.py index 4e7f96d..ac590c3 100644 --- a/cluster_upgrade/handlers.py +++ b/cluster_upgrade/handlers.py @@ -73,16 +73,16 @@ class NodeReassignHandler(base.BaseHandler): @base.handle_errors @base.validate def POST(self, cluster_id): - """Reassign node to the given cluster. + """Reassign nodes to the given cluster. - The given node will be assigned from the current cluster to the - given cluster, by default it involves the reprovisioning of this - node. If the 'reprovision' flag is set to False, then the node + The given nodes will be assigned from the current cluster to the + given cluster, by default it involves the reprovisioning of the + nodes. If the 'reprovision' flag is set to False, then the nodes will be just reassigned. If the 'roles' list is specified, then the given roles will be used as 'pending_roles' in case of the reprovisioning or otherwise as 'roles'. - :param cluster_id: ID of the cluster node should be assigned to. + :param cluster_id: ID of the cluster nodes should be assigned to. :returns: None :http: * 202 (OK) * 400 (Incorrect node state, problem with task execution, @@ -93,18 +93,22 @@ class NodeReassignHandler(base.BaseHandler): self.get_object_or_404(self.single, cluster_id)) data = self.checked_data(cluster=cluster) - node = adapters.NailgunNodeAdapter( - self.get_object_or_404(objects.Node, data['node_id'])) reprovision = data.get('reprovision', True) given_roles = data.get('roles', []) - roles, pending_roles = upgrade.UpgradeHelper.get_node_roles( - reprovision, node.roles, given_roles) - upgrade.UpgradeHelper.assign_node_to_cluster( - node, cluster, roles, pending_roles) + nodes_to_provision = [] + for node_id in data['nodes_ids']: + node = adapters.NailgunNodeAdapter( + self.get_object_or_404(objects.Node, node_id)) + nodes_to_provision.append(node.node) + + roles, pending_roles = upgrade.UpgradeHelper.get_node_roles( + reprovision, node.roles, given_roles) + upgrade.UpgradeHelper.assign_node_to_cluster( + node, cluster, roles, pending_roles) if reprovision: - self.handle_task(cluster_id, [node.node]) + self.handle_task(cluster_id, nodes_to_provision) class CopyVIPsHandler(base.BaseHandler): diff --git a/cluster_upgrade/tests/test_handlers.py b/cluster_upgrade/tests/test_handlers.py index 6e4382d..f3f5d1d 100644 --- a/cluster_upgrade/tests/test_handlers.py +++ b/cluster_upgrade/tests/test_handlers.py @@ -87,14 +87,14 @@ class TestNodeReassignHandler(base.BaseIntegrationTest): resp = self.app.post( reverse('NodeReassignHandler', kwargs={'cluster_id': seed_cluster['id']}), - jsonutils.dumps({'node_id': node_id}), + jsonutils.dumps({'nodes_ids': [node_id]}), headers=self.default_headers) self.assertEqual(202, resp.status_code) args, kwargs = mcast.call_args nodes = args[1]['args']['provisioning_info']['nodes'] provisioned_uids = [int(n['uid']) for n in nodes] - self.assertEqual([node_id, ], provisioned_uids) + self.assertEqual([node_id], provisioned_uids) @mock.patch('nailgun.task.task.rpc.cast') def test_node_reassign_handler_with_roles(self, mcast): @@ -108,7 +108,7 @@ class TestNodeReassignHandler(base.BaseIntegrationTest): # NOTE(akscram): reprovision=True means that the node will be # re-provisioned during the reassigning. This is # a default behavior. - data = {'node_id': node.id, + data = {'nodes_ids': [node.id], 'reprovision': True, 'roles': ['compute']} resp = self.app.post( @@ -130,7 +130,7 @@ class TestNodeReassignHandler(base.BaseIntegrationTest): node = cluster.nodes[0] seed_cluster = self.env.create_cluster(api=False) - data = {'node_id': node.id, + data = {'nodes_ids': [node.id], 'reprovision': False, 'roles': ['compute']} resp = self.app.post( @@ -148,7 +148,7 @@ class TestNodeReassignHandler(base.BaseIntegrationTest): resp = self.app.post( reverse('NodeReassignHandler', kwargs={'cluster_id': cluster['id']}), - jsonutils.dumps({'node_id': 42}), + jsonutils.dumps({'nodes_ids': [42]}), headers=self.default_headers, expect_errors=True) self.assertEqual(404, resp.status_code) @@ -163,7 +163,7 @@ class TestNodeReassignHandler(base.BaseIntegrationTest): resp = self.app.post( reverse('NodeReassignHandler', kwargs={'cluster_id': cluster['id']}), - jsonutils.dumps({'node_id': cluster.nodes[0]['id']}), + jsonutils.dumps({'nodes_ids': [cluster.nodes[0]['id']]}), headers=self.default_headers, expect_errors=True) self.assertEqual(400, resp.status_code) @@ -179,7 +179,7 @@ class TestNodeReassignHandler(base.BaseIntegrationTest): resp = self.app.post( reverse('NodeReassignHandler', kwargs={'cluster_id': cluster['id']}), - jsonutils.dumps({'node_id': cluster.nodes[0]['id']}), + jsonutils.dumps({'nodes_ids': [cluster.nodes[0]['id']]}), headers=self.default_headers, expect_errors=True) self.assertEqual(400, resp.status_code) @@ -196,7 +196,7 @@ class TestNodeReassignHandler(base.BaseIntegrationTest): resp = self.app.post( reverse('NodeReassignHandler', kwargs={'cluster_id': cluster_id}), - jsonutils.dumps({'node_id': node_id}), + jsonutils.dumps({'nodes_ids': [node_id]}), headers=self.default_headers, expect_errors=True) self.assertEqual(400, resp.status_code) diff --git a/cluster_upgrade/tests/test_validators.py b/cluster_upgrade/tests/test_validators.py index 43da11c..2afd9bc 100644 --- a/cluster_upgrade/tests/test_validators.py +++ b/cluster_upgrade/tests/test_validators.py @@ -137,7 +137,7 @@ class TestNodeReassignValidator(base.BaseTestCase): node = self.env.create_node(cluster_id=cluster.id, roles=["compute"], status="ready") - msg = "^'node_id' is a required property" + msg = "^'nodes_ids' is a required property" with self.assertRaisesRegexp(errors.InvalidData, msg): self.validator.validate("{}", node) @@ -164,7 +164,7 @@ class TestNodeReassignNoReinstallValidator(tests_base.BaseCloneClusterTest): roles=["compute"], status="ready") def test_validate_defaults(self): - request = {"node_id": self.node.id} + request = {"nodes_ids": [self.node.id]} data = jsonutils.dumps(request) parsed = self.validator.validate(data, self.dst_cluster) self.assertEqual(parsed, request) @@ -172,7 +172,7 @@ class TestNodeReassignNoReinstallValidator(tests_base.BaseCloneClusterTest): def test_validate_with_roles(self): request = { - "node_id": self.node.id, + "nodes_ids": [self.node.id], "reprovision": True, "roles": ['controller'], } @@ -182,7 +182,7 @@ class TestNodeReassignNoReinstallValidator(tests_base.BaseCloneClusterTest): def test_validate_not_unique_roles(self): data = jsonutils.dumps({ - "node_id": self.node.id, + "nodes_ids": [self.node.id], "roles": ['compute', 'compute'], }) msg = "has non-unique elements" @@ -191,7 +191,7 @@ class TestNodeReassignNoReinstallValidator(tests_base.BaseCloneClusterTest): def test_validate_no_reprovision_with_conflicts(self): data = jsonutils.dumps({ - "node_id": self.node.id, + "nodes_ids": [self.node.id], "reprovision": False, "roles": ['controller', 'compute'], }) @@ -203,6 +203,64 @@ class TestNodeReassignNoReinstallValidator(tests_base.BaseCloneClusterTest): "Role 'controller' in conflict with role 'compute'." ) + def test_validate_empty_nodes_ids_error(self): + data = jsonutils.dumps({ + "nodes_ids": [], + "roles": ['controller'], + }) + msg = "minItems.*nodes_ids" + with self.assertRaisesRegexp(errors.InvalidData, msg): + self.validator.validate(data, self.dst_cluster) + + def test_validate_several_nodes_ids(self): + node = self.env.create_node(cluster_id=self.src_cluster.id, + roles=["compute"], status="ready") + request = { + "nodes_ids": [self.node.id, node.id], + } + data = jsonutils.dumps(request) + parsed = self.validator.validate(data, self.dst_cluster) + self.assertEqual(parsed, request) + + def test_validate_mixed_two_not_sorted_roles(self): + self.node.roles = ["compute", "ceph-osd"] + node = self.env.create_node(cluster_id=self.src_cluster.id, + roles=["ceph-osd", "compute"], + status="ready") + request = { + "nodes_ids": [self.node.id, node.id], + "roles": ["compute"], + } + data = jsonutils.dumps(request) + parsed = self.validator.validate(data, self.dst_cluster) + self.assertEqual(parsed, request) + + def test_validate_mixed_roles_error(self): + node = self.env.create_node(cluster_id=self.src_cluster.id, + roles=["ceph-osd"], status="ready") + self._assert_validate_nodes_roles([self.node.id, node.id], + ["controller"]) + + def test_validate_mixed_two_roles_error(self): + # Two nodes have two roles each, the first ones are the same + # while the second ones differ. + self.node.roles = ["compute", "ceph-osd"] + node = self.env.create_node(cluster_id=self.src_cluster.id, + roles=["compute", "mongo"], + status="ready") + self._assert_validate_nodes_roles([self.node.id, node.id], + ["compute"]) + + def _assert_validate_nodes_roles(self, nodes_ids, roles): + data = jsonutils.dumps({ + "nodes_ids": nodes_ids, + "roles": roles, + }) + msg = "Only nodes with the same set of assigned roles are supported " \ + "for the operation." + with self.assertRaisesRegexp(errors.InvalidData, msg): + self.validator.validate(data, self.dst_cluster) + class TestCopyVIPsValidator(base.BaseTestCase): validator = validators.CopyVIPsValidator diff --git a/cluster_upgrade/validators.py b/cluster_upgrade/validators.py index 95f2f6d..4bc2831 100644 --- a/cluster_upgrade/validators.py +++ b/cluster_upgrade/validators.py @@ -98,13 +98,18 @@ class NodeReassignValidator(assignment.NodeAssignmentValidator): "description": "Serialized parameters to assign node", "type": "object", "properties": { - "node_id": {"type": "number"}, + "nodes_ids": { + "type": "array", + "items": {"type": "number"}, + "uniqueItems": True, + "minItems": 1, + }, "reprovision": {"type": "boolean", "default": True}, "roles": {"type": "array", "items": {"type": "string"}, "uniqueItems": True}, }, - "required": ["node_id"], + "required": ["nodes_ids"], } @classmethod @@ -112,14 +117,19 @@ class NodeReassignValidator(assignment.NodeAssignmentValidator): parsed = super(NodeReassignValidator, cls).validate(data) cls.validate_schema(parsed, cls.schema) - node = cls.validate_node(parsed['node_id']) - cls.validate_node_cluster(node, cluster) + nodes = [] + for node_id in parsed['nodes_ids']: + node = cls.validate_node(node_id) + cls.validate_node_cluster(node, cluster) + nodes.append(node) roles = parsed.get('roles', []) if roles: + cls.validate_nodes_roles(nodes) cls.validate_roles(cluster, roles) else: - cls.validate_roles(cluster, node.roles) + for node in nodes: + cls.validate_roles(cluster, node.roles) return parsed @classmethod @@ -147,6 +157,16 @@ class NodeReassignValidator(assignment.NodeAssignmentValidator): log_message=True) return node + @classmethod + def validate_nodes_roles(cls, nodes): + roles = set(nodes[0].roles) + if all(roles == set(n.roles) for n in nodes[1:]): + return + raise errors.InvalidData( + "Only nodes with the same set of assigned roles are supported " + "for the operation.", + log_message=True) + @classmethod def validate_node_cluster(cls, node, cluster): if node.cluster_id == cluster.id: