From dffb8d3b93a5a08e25ded5b0b5a3595767379bfd Mon Sep 17 00:00:00 2001 From: Bryan Strassner Date: Mon, 23 Apr 2018 18:53:27 -0500 Subject: [PATCH] Common code for deployment groups in Shipyard Adds in the classes and traversal logic for groups as part of a deployment strategy. Nothing is using these yet, but will be in subsequent patchsets There are some artifacts being removed that are left over from a prior refactor and were existing in two places. Change-Id: Id38b211d3cf8112f4cb34c2ef5dcf440d3d20e4c --- .../schemas/deploymentStrategy.yaml | 74 ---- src/bin/shipyard_airflow/requirements.txt | 1 + .../shipyard_airflow/common/README.txt | 27 +- .../shipyard_airflow/common/__init__.py | 0 .../common/deployment_group/__init__.py | 0 .../deployment_group/deployment_group.py | 330 ++++++++++++++++++ .../deployment_group_manager.py | 262 ++++++++++++++ .../common/deployment_group/errors.py | 64 ++++ .../tests/unit/common/__init__.py | 0 .../unit/common/deployment_group/__init__.py | 0 .../deployment_group/node_lookup_stubs.py | 85 +++++ .../deployment_group/test_deployment_group.py | 268 ++++++++++++++ .../test_deployment_group_manager.py | 322 +++++++++++++++++ tests/unit/schemas/base_schema_validation.py | 51 --- .../unit/schemas/test_deployment_strategy.py | 38 -- .../deploymentStrategy_bad_values_1.yaml | 16 - .../deploymentStrategy_bad_values_2.yaml | 16 - .../deploymentStrategy_bad_values_3.yaml | 15 - .../deploymentStrategy_bad_values_4.yaml | 18 - .../deploymentStrategy_bad_values_5.yaml | 20 -- .../deploymentStrategy_full_valid.yaml | 75 ---- .../deploymentStrategy_min_with_content.yaml | 15 - .../deploymentStrategy_minimal.yaml | 11 - tests/unit/yaml_samples/total_garbage.yaml | 44 --- 24 files changed, 1342 insertions(+), 410 deletions(-) delete mode 100644 shipyard_airflow/schemas/deploymentStrategy.yaml rename tests/unit/schemas/conftest.py => src/bin/shipyard_airflow/shipyard_airflow/common/README.txt (51%) rename tests/unit/yaml_samples/empty.yaml => src/bin/shipyard_airflow/shipyard_airflow/common/__init__.py (100%) create mode 100644 src/bin/shipyard_airflow/shipyard_airflow/common/deployment_group/__init__.py create mode 100644 src/bin/shipyard_airflow/shipyard_airflow/common/deployment_group/deployment_group.py create mode 100644 src/bin/shipyard_airflow/shipyard_airflow/common/deployment_group/deployment_group_manager.py create mode 100644 src/bin/shipyard_airflow/shipyard_airflow/common/deployment_group/errors.py create mode 100644 src/bin/shipyard_airflow/tests/unit/common/__init__.py create mode 100644 src/bin/shipyard_airflow/tests/unit/common/deployment_group/__init__.py create mode 100644 src/bin/shipyard_airflow/tests/unit/common/deployment_group/node_lookup_stubs.py create mode 100644 src/bin/shipyard_airflow/tests/unit/common/deployment_group/test_deployment_group.py create mode 100644 src/bin/shipyard_airflow/tests/unit/common/deployment_group/test_deployment_group_manager.py delete mode 100644 tests/unit/schemas/base_schema_validation.py delete mode 100644 tests/unit/schemas/test_deployment_strategy.py delete mode 100644 tests/unit/yaml_samples/deploymentStrategy_bad_values_1.yaml delete mode 100644 tests/unit/yaml_samples/deploymentStrategy_bad_values_2.yaml delete mode 100644 tests/unit/yaml_samples/deploymentStrategy_bad_values_3.yaml delete mode 100644 tests/unit/yaml_samples/deploymentStrategy_bad_values_4.yaml delete mode 100644 tests/unit/yaml_samples/deploymentStrategy_bad_values_5.yaml delete mode 100644 tests/unit/yaml_samples/deploymentStrategy_full_valid.yaml delete mode 100644 tests/unit/yaml_samples/deploymentStrategy_min_with_content.yaml delete mode 100644 tests/unit/yaml_samples/deploymentStrategy_minimal.yaml delete mode 100644 tests/unit/yaml_samples/total_garbage.yaml diff --git a/shipyard_airflow/schemas/deploymentStrategy.yaml b/shipyard_airflow/schemas/deploymentStrategy.yaml deleted file mode 100644 index 51bda9f0..00000000 --- a/shipyard_airflow/schemas/deploymentStrategy.yaml +++ /dev/null @@ -1,74 +0,0 @@ ---- -schema: 'deckhand/DataSchema/v1' -metadata: - schema: metadata/Control/v1 - name: shipyard/DeploymentStrategy/v1 - labels: - application: shipyard -data: - $schema: 'http://json-schema.org/schema#' - id: 'https://github.com/att-comdev/shipyard/deploymentStrategy.yaml' - type: 'object' - required: - - groups - properties: - groups: - type: 'array' - minItems: 0 - items: - type: 'object' - required: - - name - - critical - - depends_on - - selectors - properties: - name: - type: 'string' - minLength: 1 - critical: - type: 'boolean' - depends_on: - type: 'array' - minItems: 0 - items: - type: 'string' - selectors: - type: 'array' - minItems: 0 - items: - type: 'object' - minProperties: 1 - properties: - node_names: - type: 'array' - items: - type: 'string' - node_labels: - type: 'array' - items: - type: 'string' - node_tags: - type: 'array' - items: - type: 'string' - rack_names: - type: 'array' - items: - type: 'string' - additionalProperties: false - success_criteria: - type: 'object' - minProperties: 1 - properties: - percent_successful_nodes: - type: 'integer' - minimum: 0 - maximum: 100 - minimum_successful_nodes: - type: 'integer' - minimum: 0 - maximum_failed_nodes: - type: 'integer' - minimum: 0 - additionalProperties: false diff --git a/src/bin/shipyard_airflow/requirements.txt b/src/bin/shipyard_airflow/requirements.txt index 5ccbef29..1e7e35a7 100644 --- a/src/bin/shipyard_airflow/requirements.txt +++ b/src/bin/shipyard_airflow/requirements.txt @@ -21,6 +21,7 @@ falcon==1.2.0 jsonschema==2.6.0 keystoneauth1==3.4.0 keystonemiddleware==4.21.0 +networkx==2.1 oslo.config==5.2.0 oslo.policy==1.33.1 PasteDeploy==1.5.2 diff --git a/tests/unit/schemas/conftest.py b/src/bin/shipyard_airflow/shipyard_airflow/common/README.txt similarity index 51% rename from tests/unit/schemas/conftest.py rename to src/bin/shipyard_airflow/shipyard_airflow/common/README.txt index 4e79dc98..49abf40c 100644 --- a/tests/unit/schemas/conftest.py +++ b/src/bin/shipyard_airflow/shipyard_airflow/common/README.txt @@ -1,4 +1,4 @@ -# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# Copyright 2018 AT&T Intellectual Property. All other rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -11,21 +11,14 @@ # 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 +# +"""Common Modules -import pytest -import shutil +The various packages in this common package should each be stand-alone +modules having no dependencies on prior logic running in Shipyard (e.g. +Setup of configuration files, Shipyard/Airflow database access, etc...). It is +ok if these modules use imports found in requirements.txt - -@pytest.fixture(scope='module') -def input_files(tmpdir_factory, request): - tmpdir = tmpdir_factory.mktemp('data') - samples_dir = os.path.dirname(str( - request.fspath)) + "/" + "../yaml_samples" - samples = os.listdir(samples_dir) - - for f in samples: - src_file = samples_dir + "/" + f - dst_file = str(tmpdir) + "/" + f - shutil.copyfile(src_file, dst_file) - return tmpdir +These modules are intended to be safe for reuse outside of the context of +the Shipyard_Airflow/Api service as well as within. +""" diff --git a/tests/unit/yaml_samples/empty.yaml b/src/bin/shipyard_airflow/shipyard_airflow/common/__init__.py similarity index 100% rename from tests/unit/yaml_samples/empty.yaml rename to src/bin/shipyard_airflow/shipyard_airflow/common/__init__.py diff --git a/src/bin/shipyard_airflow/shipyard_airflow/common/deployment_group/__init__.py b/src/bin/shipyard_airflow/shipyard_airflow/common/deployment_group/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/bin/shipyard_airflow/shipyard_airflow/common/deployment_group/deployment_group.py b/src/bin/shipyard_airflow/shipyard_airflow/common/deployment_group/deployment_group.py new file mode 100644 index 00000000..99b43829 --- /dev/null +++ b/src/bin/shipyard_airflow/shipyard_airflow/common/deployment_group/deployment_group.py @@ -0,0 +1,330 @@ +# Copyright 2018 AT&T Intellectual Property. All other 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. +# +"""Deployment group module + +Encapsulates classes and functions that provide core deployment group +functionality used during baremetal provisioning. +""" +import collections +from enum import Enum +import logging +import operator + +from .errors import DeploymentGroupStageError +from .errors import InvalidDeploymentGroupError +from .errors import InvalidDeploymentGroupNodeLookupError + +LOG = logging.getLogger(__name__) + + +class Stage(Enum): + """Valid values for baremetal node and deployment group stages of + deployment + """ + # A node that has not yet started deployment. The default. + NOT_STARTED = 'NOT_STARTED' + # A node that has finished the prepare_node stage successfully + PREPARED = 'PREPARED' + # A node that has finished the deploy_node stage successfully + DEPLOYED = 'DEPLOYED' + # A node that has failed to complete in any step. + FAILED = 'FAILED' + + @classmethod + def is_complete(cls, stage): + return stage in [cls.DEPLOYED, cls.FAILED] + + @classmethod + def previous_stage(cls, stage): + """The valid states before the supplied state""" + if stage == cls.NOT_STARTED: + return [] + if stage == cls.PREPARED: + return [cls.NOT_STARTED] + if stage == cls.DEPLOYED: + return [cls.PREPARED] + if stage == cls.FAILED: + return [cls.NOT_STARTED, cls.PREPARED] + else: + raise DeploymentGroupStageError("{} is not a valid stage".format( + str(stage))) + + +class GroupNodeSelector: + """GroupNodeSelector object + + :param selector_dict: dictionary representing the possible selector values + + Encapsulates the criteria defining the selector for a deployment group. + Example selector_dict:: + + { + 'node_names': [], + 'node_labels': [], + 'node_tags': ['control'], + 'rack_names': ['rack03'], + } + """ + def __init__(self, selector_dict): + self.node_names = selector_dict.get('node_names', []) + self.node_labels = selector_dict.get('node_labels', []) + self.node_tags = selector_dict.get('node_tags', []) + self.rack_names = selector_dict.get('rack_names', []) + + # A selector is an "all_selector" if there are no criteria specified. + self.all_selector = not any([self.node_names, self.node_labels, + self.node_tags, self.rack_names]) + if self.all_selector: + LOG.debug("Selector values select all available nodes") + + +class SuccessCriteria: + """Defines the success criteria for a deployment group + + :param criteria: a dictionary containing up to 3 fields in + percent_successful_nodes, minimum_successful_nodes, + maximum_failed_nodes + + If no criteria are specified, all results are considered a success + """ + def __init__(self, criteria): + if not criteria: + self._always_succeed = True + return + + self._always_succeed = False + # set the criteria or let them be None + self.pct_succ_nodes = criteria.get('percent_successful_nodes') + self.min_succ_nodes = criteria.get('minimum_successful_nodes') + self.max_failed_nodes = criteria.get('maximum_failed_nodes') + + def get_failed(self, succ_list, all_nodes_list): + """Determine which criteria have failed. + + :param succ_list: A list of names of nodes that have successfully + completed a stage + :param all_nodes_list: A list of all node names that are to be + evaluated against. + + Using the provided list of successful nodes, and the list of all + nodes, check which of the success criteria have failed to have been + met. + """ + failures = [] + + # If no criteria, or list of all nodes is empty, return empty list + if self._always_succeed or len(all_nodes_list) == 0: + return failures + + succ_set = set(succ_list) + all_set = set(all_nodes_list) + + all_size = len(all_set) + succ_size = len(succ_set.intersection(all_set)) + fail_size = len(all_set.difference(succ_set)) + actual_pct_succ = succ_size / all_size * 100 + + failures.extend(self._check("percent_successful_nodes", + actual_pct_succ, operator.ge, + self.pct_succ_nodes)) + failures.extend(self._check("minimum_successful_nodes", succ_size, + operator.ge, self.min_succ_nodes)) + + failures.extend(self._check("maximum_failed_nodes", fail_size, + operator.le, self.max_failed_nodes)) + return failures + + def _check(self, name, actual, op, needed): + """Evaluates a single criteria + + :param name: name of the check + :param actual: the result that was achieved (LHS) + :param op: operator used for comparison + :param needed: the threshold of success (RHS). If this parameter + is None, the criteria is ignored as "successful" because it + was not set as a needed criteria + + Returns a list containing the failure dictionary if the comparison + fails or and empty list if check is successful. + """ + if needed is None: + LOG.info(" - %s criteria not specified, not evaluated", name) + return [] + + if op(actual, needed): + LOG.info(" - %s succeeded, %s %s %s", name, actual, op.__name__, + needed) + return [] + else: + fail = {"criteria": name, "needed": needed, "actual": actual} + LOG.info(" - %s failed, %s %s %s", name, actual, op.__name__, + needed) + return [fail] + + +class DeploymentGroup: + """DeploymentGroup object representing a deployment group + + :param group_dict: dictionary representing a group + :param node_lookup: an injected function that will perform node lookup for + a group. Function must accept an iterable of GroupNodeSelector and + return a string list of node names + + Example group_dict:: + + { + 'name': 'control-nodes', + 'critical': True, + 'depends_on': ['ntp-node'], + 'selectors': [ + { + 'node_names': [], + 'node_labels': [], + 'node_tags': ['control'], + 'rack_names': ['rack03'], + }, + ], + 'success_criteria': { + 'percent_successful_nodes': 90, + 'minimum_successful_nodes': 3, + 'maximum_failed_nodes': 1, + }, + } + """ + def __init__(self, group_dict, node_lookup): + # store the original dictionary + self._group_dict = group_dict + + # fields required by schema + self._check_required_fields() + + self.critical = group_dict['critical'] + self.depends_on = group_dict['depends_on'] + self.name = group_dict['name'] + + self.selectors = [] + for selector_dict in group_dict['selectors']: + self.selectors.append(GroupNodeSelector(selector_dict)) + if not self.selectors: + # no selectors means add an "all" selector + self.selectors.append(GroupNodeSelector({})) + + self.success_criteria = SuccessCriteria( + group_dict.get('success_criteria', {}) + ) + + # all groups start as NOT_STARTED + self.__stage = None + self.stage = Stage.NOT_STARTED + + # node_lookup function for use with this deployment group + # lookup the full list of nodes for this group's selectors + self.node_lookup = node_lookup + self.full_nodes = self._calculate_all_nodes() + + # actionable_nodes is set up based on multi-group interaction. + # Only declaring the field here. Used for deduplicaiton. + self.actionable_nodes = [] + + @property + def stage(self): + return self.__stage + + @stage.setter + def stage(self, stage): + valid_prior = Stage.previous_stage(stage) + pre_change_stage = self.__stage + if self.__stage == stage: + return + elif self.__stage is None and not valid_prior: + self.__stage = stage + elif self.__stage in valid_prior: + self.__stage = stage + else: + raise DeploymentGroupStageError( + "{} is not a valid stage for a group in stage {}".format( + stage, self.__stage + )) + LOG.info("Setting group %s with %s -> %s", + self.name, + pre_change_stage, + stage) + + def _check_required_fields(self): + """Checks for required input fields and errors if any are missing""" + for attr in ['critical', 'depends_on', 'name', 'selectors']: + try: + value = self._group_dict[attr] + LOG.debug("Attribute %s has value %s", attr, str(value)) + except KeyError: + raise InvalidDeploymentGroupError( + "Attribute '{}' is required as input to create a " + "DeploymentGroup".format(attr)) + + def _calculate_all_nodes(self): + """Invoke the node_lookup to retrieve nodes + + After construction of the DeploymentGroup, this method is generally + not useful as the results are stored in self.full_nodes + """ + LOG.debug("Beginning lookup of nodes for group %s", self.name) + node_list = self.node_lookup(self.selectors) + if node_list is None: + node_list = [] + if not isinstance(node_list, collections.Sequence): + raise InvalidDeploymentGroupNodeLookupError( + "The node lookup function supplied to the DeploymentGroup " + "does not return a valid result of an iterable" + ) + if not all(isinstance(node, str) for node in node_list): + raise InvalidDeploymentGroupNodeLookupError( + "The node lookup function supplied to the DeploymentGroup " + "has returned an iterable, but not all strings" + ) + LOG.info("Group %s selectors have resolved to nodes: %s", + self.name, ", ".join(node_list)) + return node_list + + def get_failed_success_criteria(self, success_node_list): + """Check the success criteria for this group. + + :param success_node_list: list of nodes that are deemed successful + to be compared to the success criteria + + Using the list of all nodes, and the provided success_node_list, + use the SuccessCriteria for this group to see if that list of + successes meets the criteria. + Note that this is not checking for any particular stage of deployment, + simply the comparison of the total list of nodes to the provided list. + Returns a list of failures. An empty list indicates successful + comparison with all criteria. + + A good pattern for use of this method is to provide a list of all + nodes being deployed across all groups that are successful for a + given stage of deployment (e.g. all prepared, all deployed). + Calculations are done using set comparisons, so nodes that are not + important for this group will be ignored. It is important *not* to + provide only a list of nodes that were recently acted upon as part of + this group, as deduplication from overlapping groups may cause the + calculations to be skewed and report false failures. + """ + LOG.info('Assessing success criteria for group %s', self.name) + sc = self.success_criteria.get_failed(success_node_list, + self.full_nodes) + if sc: + LOG.info('Group %s failed success criteria', self.name) + else: + LOG.info('Group %s success criteria passed', self.name) + return sc diff --git a/src/bin/shipyard_airflow/shipyard_airflow/common/deployment_group/deployment_group_manager.py b/src/bin/shipyard_airflow/shipyard_airflow/common/deployment_group/deployment_group_manager.py new file mode 100644 index 00000000..e434b954 --- /dev/null +++ b/src/bin/shipyard_airflow/shipyard_airflow/common/deployment_group/deployment_group_manager.py @@ -0,0 +1,262 @@ +# Copyright 2018 AT&T Intellectual Property. All other 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. +# +"""Deployment group manager module + +Encapsulates classes and functions related to the management and use of +deployment groups used during baremetal provisioning. +""" +import logging + +import networkx as nx + +from .deployment_group import DeploymentGroup +from .deployment_group import Stage +from .errors import DeploymentGroupCycleError +from .errors import DeploymentGroupStageError +from .errors import UnknownDeploymentGroupError +from .errors import UnknownNodeError + +LOG = logging.getLogger(__name__) + + +class DeploymentGroupManager: + """Manager object to control ordering and cross-group interactions + + :param group_dict_list: list of group entries translated from a + DeploymentStrategy document. + :param node_lookup: function to lookup nodes based on group selectors + """ + + def __init__(self, group_dict_list, node_lookup): + LOG.debug("Initializing DeploymentGroupManager") + + # the raw input + self._group_dict_list = group_dict_list + + # A dictionary of all groups by group name. E.g.: + # { + # 'group-1': DeploymentGroup(...), + # } + self._all_groups = {} + for group_dict in group_dict_list: + group = DeploymentGroup(group_dict, node_lookup) + self._all_groups[group.name] = group + + self._group_graph = _generate_group_graph( + self._all_groups.values() + ) + self._group_order = list(nx.topological_sort(self._group_graph)) + + # Setup nodes. + # self.all_nodes is a dictionary of all nodes by node name, + # representing each node's status of deployment. E.g.: + # { 'node-01' : Stage.NOT_STARTED} + # + # each group is also updated with group.actionable_nodes based on group + # ordering (deduplication) + self._all_nodes = {} + self._calculate_nodes() + + def get_next_group(self, stage): + """Get the next eligible group name to use for the provided stage + + Finds the next group that has as status eligible for the stage + provided. + Returns None if there are no groups ready for the stage + """ + prev_stage = Stage.previous_stage(stage) + for group in self._group_order: + if self._all_groups[group].stage in prev_stage: + return self._all_groups[group] + return None + + # + # Methods that support setup of the nodes in groups + # + + def _calculate_nodes(self): + """Calculate the mapping of all compute nodes + + Uses self.group_order, self.all_groups + """ + for name in self._group_order: + group = self._all_groups[name] + known_nodes = set(self._all_nodes.keys()) + _update_group_actionable_nodes(group, known_nodes) + for node in group.full_nodes: + self._all_nodes[node] = Stage.NOT_STARTED + + # + # Methods for managing marking the stage of processing for a group + # + + def mark_group_failed(self, group_name): + """Sets status for a group and all successors(dependents) to failed + + :param group_name: The name of the group to fail + """ + group = self._find_group(group_name) + group.stage = Stage.FAILED + successors = list(self._group_graph.successors(group_name)) + if successors: + LOG.info("Group %s (now FAILED) has dependent groups %s", + group_name, ", ".join(successors)) + for name in successors: + self.mark_group_failed(name) + + def mark_group_prepared(self, group_name): + """Sets a group to the Stage.PREPARED stage""" + group = self._find_group(group_name) + group.stage = Stage.PREPARED + + def mark_group_deployed(self, group_name): + """Sets a group to the Stage.DEPLOYED stage""" + group = self._find_group(group_name) + group.stage = Stage.DEPLOYED + + def _find_group(self, group_name): + """Wrapper for accessing groups from self.all_groups""" + group = self._all_groups.get(group_name) + if group is None: + raise UnknownDeploymentGroupError( + "Group name {} does not refer to a known group".format( + group_name) + ) + return group + + def get_group_failures_for_stage(self, group_name, stage): + """Check if the nodes of a group cause the group to fail + + Returns the list of failed success criteria, or [] if the group is + successful + This is only for checking transitions to PREPARED and DEPLOYED. The + valid stages for input to this method are Stage.PREPARED and + Stage.DEPLOYED. + Note that nodes that are DEPLOYED count as PREPARED, but not + the other way around. + """ + if stage not in [Stage.DEPLOYED, Stage.PREPARED]: + raise DeploymentGroupStageError( + "The stage {} is not valid for checking group" + " failures.".format(stage)) + success_nodes = set() + # deployed nodes count as success for prepared and deployed + success_nodes.update(self.get_nodes(Stage.DEPLOYED)) + if stage == Stage.PREPARED: + success_nodes.update(self.get_nodes(Stage.PREPARED)) + group = self._find_group(group_name) + return group.get_failed_success_criteria(success_nodes) + + # + # Methods for handling nodes + # + + def mark_node_deployed(self, node_name): + """Mark a node as deployed""" + self._set_node_stage(node_name, Stage.DEPLOYED) + + def mark_node_prepared(self, node_name): + """Mark a node as prepared""" + self._set_node_stage(node_name, Stage.PREPARED) + + def mark_node_failed(self, node_name): + """Mark a node as failed""" + self._set_node_stage(node_name, Stage.FAILED) + + def _set_node_stage(self, node_name, stage): + """Find and set a node's stage to the specified stage""" + if node_name in self._all_nodes: + self._all_nodes[node_name] = stage + else: + raise UnknownNodeError("The specified node {} does not" + " exist in this manager".format(node_name)) + + def get_nodes(self, stage=None): + """Get a list of nodes that have the specified status""" + if stage is None: + return [name for name in self._all_nodes] + + return [name for name, n_stage + in self._all_nodes.items() + if n_stage == stage] + + +def _update_group_actionable_nodes(group, known_nodes): + """Updates a group's actionable nodes + + Acitonable nodes is the group's (full_nodes - known_nodes) + """ + LOG.debug("Known nodes before processing group %s is %s", + group.name, + ", ".join(known_nodes)) + + group_nodes = set(group.full_nodes) + group.actionable_nodes = group_nodes.difference(known_nodes) + LOG.debug("Group %s set actionable_nodes to %s. " + "Full node list for this group is %s", + group.name, + ", ".join(group.actionable_nodes), + ", ".join(group.full_nodes)) + + +def _generate_group_graph(groups): + """Create the directed graph of groups + + :param groups: An iterable of DeploymentGroup objects + returns a directed graph of group names + """ + LOG.debug("Generating directed graph of groups based on dependencies") + graph = nx.DiGraph() + # Add all groups as graph nodes. It is not strictly necessary to do two + # loops here, but n is small and for obviousness. + for group in groups: + graph.add_node(group.name) + + # Add all edges + for group in groups: + if group.depends_on: + for parent in group.depends_on: + LOG.debug("%s has parent %s", group.name, parent) + graph.add_edge(parent, group.name) + else: + LOG.debug("%s is not dependent upon any other groups") + + _detect_cycles(graph) + return graph + + +def _detect_cycles(graph): + """Detect if there are cycles between the groups + + Raise a DeploymentGroupCycleError if there are any circular + dependencies + """ + LOG.debug("Detecting cycles in graph") + circ_deps = [] + try: + circ_deps = list(nx.find_cycle(graph)) + except nx.NetworkXNoCycle: + LOG.info('There are no cycles detected in the graph') + pass + + if circ_deps: + involved_nodes = set() + # a value in this list is like: ('group1', 'group2') + for dep in circ_deps: + involved_nodes.update(dep) + raise DeploymentGroupCycleError( + "The following are involved in a circular dependency:" + " %s", ", ".join(involved_nodes) + ) diff --git a/src/bin/shipyard_airflow/shipyard_airflow/common/deployment_group/errors.py b/src/bin/shipyard_airflow/shipyard_airflow/common/deployment_group/errors.py new file mode 100644 index 00000000..b1667c33 --- /dev/null +++ b/src/bin/shipyard_airflow/shipyard_airflow/common/deployment_group/errors.py @@ -0,0 +1,64 @@ +# Copyright 2018 AT&T Intellectual Property. All other 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. +# + + +class InvalidDeploymentGroupError(Exception): + """InvalidDeploymentGroupError + + Represents that a deployment group's configuration is invalid + """ + pass + + +class InvalidDeploymentGroupNodeLookupError(InvalidDeploymentGroupError): + """InvalidDeploymentGroupNodeLookupError + + Indicates that there is a problem with the node lookup function + provided to the deployment group + """ + pass + + +class DeploymentGroupCycleError(Exception): + """DeploymentGroupCycleError + + Raised when a set of deployment groups have a dependency cycle + """ + pass + + +class DeploymentGroupStageError(Exception): + """DeploymentGroupStageError + + Raised for invalid operations while processing stages + """ + pass + + +class UnknownDeploymentGroupError(Exception): + """UnknownDeploymentGroupError + + Raised when there is an attempt to access a deployment group that isn't + recognized by the system + """ + pass + + +class UnknownNodeError(Exception): + """UnknownNodeError + + Raised when trying to access a node that does not exist + """ + pass diff --git a/src/bin/shipyard_airflow/tests/unit/common/__init__.py b/src/bin/shipyard_airflow/tests/unit/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/bin/shipyard_airflow/tests/unit/common/deployment_group/__init__.py b/src/bin/shipyard_airflow/tests/unit/common/deployment_group/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/bin/shipyard_airflow/tests/unit/common/deployment_group/node_lookup_stubs.py b/src/bin/shipyard_airflow/tests/unit/common/deployment_group/node_lookup_stubs.py new file mode 100644 index 00000000..c3ba4b4a --- /dev/null +++ b/src/bin/shipyard_airflow/tests/unit/common/deployment_group/node_lookup_stubs.py @@ -0,0 +1,85 @@ +# Copyright 2017 AT&T Intellectual Property. All other 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. +"""Stubs that play the role of a node lookup for testing of DeploymentGroup +related functionality""" + + +# Lookups for testing different node selectors +_NODE_LABELS = { + 'label1:label1': ['node1', 'node3', 'node5', 'node7', 'node9', 'node11'], + 'label2:label2': ['node2', 'node4', 'node6', 'node8', 'node10', 'node12'], + 'label3:label3': ['node1', 'node2', 'node3', 'node4', 'node5', 'node6'], + 'label4:label4': ['node7', 'node8', 'node9', 'node10', 'node11', 'node12'], + 'compute:true': ['node4', 'node5', 'node7', 'node8', 'node10', 'node11'] +} +_NODE_TAGS = { + 'tag1': ['node2', 'node5', 'node8'], + 'tag2': ['node3', 'node6', 'node9'], + 'monitoring': ['node6', 'node9', 'node12'] +} +_RACK_NAMES = { + 'rack1': ['node1', 'node2', 'node3'], + 'rack2': ['node4', 'node5', 'node6'], + 'rack3': ['node7', 'node8', 'node9'], + 'rack4': ['node10', 'node11', 'node12'], +} + +_ALL_NODES = {'node1', 'node2', 'node3', 'node4', 'node5', 'node6', 'node7', + 'node8', 'node9', 'node10', 'node11', 'node12'} + + +def node_lookup(selectors): + """A method that can be used in place of a real node lookup + + Performs a simple intersection of the selector criteria using the + lookup fields defined above. + """ + def get_nodes(lookup, keys): + nodes = [] + for key in keys: + nodes.extend(lookup[key]) + return set(nodes) + nodes_full = [] + for selector in selectors: + nl_list = [] + if selector.all_selector: + nl_list.append(_ALL_NODES) + else: + if selector.node_names: + nl_list.append(set(selector.node_names)) + if selector.node_labels: + nl_list.append(get_nodes(_NODE_LABELS, + selector.node_labels)) + if selector.node_tags: + nl_list.append(get_nodes(_NODE_TAGS, selector.node_tags)) + if selector.rack_names: + nl_list.append(get_nodes(_RACK_NAMES, selector.rack_names)) + nodes = set.intersection(*nl_list) + nodes_full.extend(nodes) + return nodes_full + + +def crummy_node_lookup(selectors): + """Returns None""" + return None + + +def broken_node_lookup_1(selectors): + """Doesn't return a list""" + return {"this": "that"} + + +def broken_node_lookup_2(selectors): + """Returns a list of various garbage, not strings""" + return [{"this": "that"}, 7, "node3"] diff --git a/src/bin/shipyard_airflow/tests/unit/common/deployment_group/test_deployment_group.py b/src/bin/shipyard_airflow/tests/unit/common/deployment_group/test_deployment_group.py new file mode 100644 index 00000000..6433a78f --- /dev/null +++ b/src/bin/shipyard_airflow/tests/unit/common/deployment_group/test_deployment_group.py @@ -0,0 +1,268 @@ +# Copyright 2017 AT&T Intellectual Property. All other 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. +"""Tests to validate behavior of the classes in the deployment_group module""" +import pytest +import yaml + +from shipyard_airflow.common.deployment_group.deployment_group import ( + DeploymentGroup, Stage +) +from shipyard_airflow.common.deployment_group.errors import ( + DeploymentGroupStageError, InvalidDeploymentGroupError, + InvalidDeploymentGroupNodeLookupError +) + +from .node_lookup_stubs import node_lookup +from .node_lookup_stubs import crummy_node_lookup +from .node_lookup_stubs import broken_node_lookup_1 +from .node_lookup_stubs import broken_node_lookup_2 + + +_GROUP_YAML_1 = """ +name: control-nodes +critical: true +depends_on: + - ntp-node +selectors: + - node_names: [] + node_labels: [] + node_tags: + - tag1 + rack_names: + - rack3 +success_criteria: + percent_successful_nodes: 90 + minimum_successful_nodes: 3 + maximum_failed_nodes: 1 +""" + +_GROUP_YAML_MULTI_SELECTOR = """ +name: control-nodes +critical: true +depends_on: + - ntp-node +selectors: + - node_names: [] + node_labels: [] + node_tags: + - tag1 + rack_names: + - rack3 + - node_names: [] + node_labels: + - label1:label1 + node_tags: [] + rack_names: + - rack3 + - rack4 +success_criteria: + percent_successful_nodes: 79 + minimum_successful_nodes: 3 + maximum_failed_nodes: 1 +""" + +_GROUP_YAML_EXCLUDES_ALL = """ +name: control-nodes +critical: true +depends_on: + - ntp-node +selectors: + - node_names: [] + node_labels: [] + node_tags: + - tag2 + rack_names: + - rack4 +success_criteria: + percent_successful_nodes: 90 + minimum_successful_nodes: 3 + maximum_failed_nodes: 1 +""" + +_GROUP_YAML_MISSING = """ +name: control-nodes +""" + +_GROUP_YAML_NO_SUCC_CRITERIA = """ +name: control-nodes +critical: true +depends_on: + - ntp-node +selectors: + - node_names: [] + node_labels: + - label1:label1 + node_tags: [] + rack_names: + - rack3 + - rack4 +""" + +_GROUP_YAML_MINIMAL_SUCC_CRITERIA = """ +name: control-nodes +critical: true +depends_on: + - ntp-node +selectors: + - node_names: [] + node_labels: [] + node_tags: + - tag1 + rack_names: + - rack3 + - node_names: [] + node_labels: + - label1:label1 + node_tags: [] + rack_names: + - rack3 + - rack4 +success_criteria: + maximum_failed_nodes: 1 +""" + + +_GROUP_YAML_ALL_SELECTOR = """ +name: control-nodes +critical: true +depends_on: + - ntp-node +selectors: [] +""" + +_GROUP_YAML_ALL_SELECTOR_2 = """ +name: control-nodes +critical: true +depends_on: + - ntp-node +selectors: [ + node_names: [] +] +""" + + +class TestDeploymentGroup: + def test_basic_class(self): + dg = DeploymentGroup(yaml.safe_load(_GROUP_YAML_1), node_lookup) + assert set(dg.full_nodes) == {'node8'} + assert dg.critical + assert dg.name == "control-nodes" + assert set(dg.depends_on) == {"ntp-node"} + assert len(dg.selectors) == 1 + assert not dg.success_criteria._always_succeed + assert dg.success_criteria.pct_succ_nodes == 90 + assert dg.success_criteria.min_succ_nodes == 3 + assert dg.success_criteria.max_failed_nodes == 1 + assert dg.stage == Stage.NOT_STARTED + + def test_basic_class_multi_selector(self): + dg = DeploymentGroup(yaml.safe_load(_GROUP_YAML_MULTI_SELECTOR), + node_lookup) + assert set(dg.full_nodes) == {'node7', 'node8', 'node9', 'node11'} + + def test_basic_class_missing_req(self): + with pytest.raises(InvalidDeploymentGroupError): + DeploymentGroup(yaml.safe_load(_GROUP_YAML_MISSING), + node_lookup) + + def test_basic_class_no_succ_criteria(self): + dg = DeploymentGroup(yaml.safe_load(_GROUP_YAML_NO_SUCC_CRITERIA), + node_lookup) + assert dg.success_criteria._always_succeed + assert not dg.get_failed_success_criteria([]) + + def test_succ_criteria_success(self): + dg = DeploymentGroup(yaml.safe_load(_GROUP_YAML_MULTI_SELECTOR), + node_lookup) + assert set(dg.full_nodes) == {'node7', 'node8', 'node9', 'node11'} + assert not dg.get_failed_success_criteria( + success_node_list=['node7', 'node8', 'node11', 'node9'] + ) + + def test_succ_criteria_minimal_criteria(self): + dg = DeploymentGroup(yaml.safe_load(_GROUP_YAML_MINIMAL_SUCC_CRITERIA), + node_lookup) + assert set(dg.full_nodes) == {'node7', 'node8', 'node9', 'node11'} + assert not dg.get_failed_success_criteria( + success_node_list=['node8', 'node11', 'node9'] + ) + + def test_succ_criteria_failure(self): + dg = DeploymentGroup(yaml.safe_load(_GROUP_YAML_MULTI_SELECTOR), + node_lookup) + assert set(dg.full_nodes) == {'node7', 'node8', 'node9', 'node11'} + failed = dg.get_failed_success_criteria( + success_node_list=['node8', 'node11', 'node9'] + ) + assert len(failed) == 1 + assert failed[0] == {'actual': 75.0, + 'criteria': 'percent_successful_nodes', + 'needed': 79} + + def test_all_selector_group(self): + dg = DeploymentGroup(yaml.safe_load(_GROUP_YAML_ALL_SELECTOR), + node_lookup) + assert dg.selectors[0].all_selector + dg = DeploymentGroup(yaml.safe_load(_GROUP_YAML_ALL_SELECTOR_2), + node_lookup) + assert dg.selectors[0].all_selector + + def test_selector_excludes_all(self): + dg = DeploymentGroup(yaml.safe_load(_GROUP_YAML_EXCLUDES_ALL), + node_lookup) + assert dg.full_nodes == [] + + def test_handle_none_node_lookup(self): + dg = DeploymentGroup(yaml.safe_load(_GROUP_YAML_1), + crummy_node_lookup) + assert dg.full_nodes == [] + + def test_handle_broken_node_lookup(self): + with pytest.raises(InvalidDeploymentGroupNodeLookupError) as err: + dg = DeploymentGroup(yaml.safe_load(_GROUP_YAML_1), + broken_node_lookup_1) + assert str(err).endswith("iterable") + + with pytest.raises(InvalidDeploymentGroupNodeLookupError) as err: + dg = DeploymentGroup(yaml.safe_load(_GROUP_YAML_1), + broken_node_lookup_2) + assert str(err).endswith("but not all strings") + + def test_set_stage(self): + dg = DeploymentGroup(yaml.safe_load(_GROUP_YAML_ALL_SELECTOR), + node_lookup) + with pytest.raises(DeploymentGroupStageError): + dg.stage = Stage.DEPLOYED + dg.stage = Stage.PREPARED + assert dg.stage == Stage.PREPARED + dg.stage = Stage.DEPLOYED + assert dg.stage == Stage.DEPLOYED + + +class TestStage: + def test_is_complete(self): + assert not Stage.is_complete(Stage.NOT_STARTED) + assert not Stage.is_complete(Stage.PREPARED) + assert Stage.is_complete(Stage.DEPLOYED) + assert Stage.is_complete(Stage.FAILED) + + def test_previous_stage(self): + assert Stage.previous_stage(Stage.NOT_STARTED) == [] + assert Stage.previous_stage(Stage.PREPARED) == [Stage.NOT_STARTED] + assert Stage.previous_stage(Stage.DEPLOYED) == [Stage.PREPARED] + assert Stage.previous_stage(Stage.FAILED) == [Stage.NOT_STARTED, + Stage.PREPARED] + with pytest.raises(DeploymentGroupStageError) as de: + Stage.previous_stage('Chickens and Turkeys') + assert str(de).endswith("Chickens and Turkeys is not a valid stage") diff --git a/src/bin/shipyard_airflow/tests/unit/common/deployment_group/test_deployment_group_manager.py b/src/bin/shipyard_airflow/tests/unit/common/deployment_group/test_deployment_group_manager.py new file mode 100644 index 00000000..15656646 --- /dev/null +++ b/src/bin/shipyard_airflow/tests/unit/common/deployment_group/test_deployment_group_manager.py @@ -0,0 +1,322 @@ +# Copyright 2017 AT&T Intellectual Property. All other 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. +"""Tests to validate behavior of the classes in the deployment_group_manager +module +""" +import pytest +import yaml + +from shipyard_airflow.common.deployment_group.deployment_group import ( + Stage +) +from shipyard_airflow.common.deployment_group.deployment_group_manager import ( + DeploymentGroupManager +) + +from shipyard_airflow.common.deployment_group.errors import ( + DeploymentGroupCycleError, DeploymentGroupStageError, + UnknownDeploymentGroupError, UnknownNodeError +) + +from .node_lookup_stubs import node_lookup + +_GROUPS_YAML = """ +- name: control-nodes + critical: true + depends_on: + - ntp-node + selectors: + - node_names: + - node1 + - node2 + node_labels: [] + node_tags: [] + rack_names: + - rack1 + success_criteria: + percent_successful_nodes: 100 +- name: compute-nodes-1 + critical: false + depends_on: + - control-nodes + selectors: + - node_names: [] + node_labels: + - compute:true + rack_names: + - rack2 + node_tags: [] + success_criteria: + percent_successful_nodes: 50 +- name: compute-nodes-2 + critical: false + depends_on: + - control-nodes + selectors: + - node_names: [] + node_labels: + - compute:true + rack_names: + - rack3 + node_tags: [] + success_criteria: + percent_successful_nodes: 50 +- name: spare-compute-nodes + critical: false + depends_on: + - compute-nodes-2 + - compute-nodes-1 + selectors: + - node_names: [] + node_labels: + - compute:true + rack_names: + - rack4 + node_tags: [] +- name: all-compute-nodes + critical: false + depends_on: + - compute-nodes-2 + - compute-nodes-1 + - spare-compute-nodes + selectors: + - node_names: [] + node_labels: + - compute:true + rack_names: [] + node_tags: [] +- name: monitoring-nodes + critical: false + depends_on: [] + selectors: + - node_names: [] + node_labels: [] + node_tags: + - monitoring + rack_names: [] + success_criteria: + minimum_successful_nodes: 3 +- name: ntp-node + critical: true + depends_on: [] + selectors: + - node_names: + - node3 + node_labels: [] + node_tags: [] + rack_names: + - rack1 + success_criteria: + minimum_successful_nodes: 1 +""" + +_CYCLE_GROUPS_YAML = """ +- name: group-a + critical: true + depends_on: + - group-c + selectors: [] +- name: group-b + critical: true + depends_on: + - group-a + selectors: [] +- name: group-c + critical: true + depends_on: + - group-d + selectors: [] +- name: group-d + critical: true + depends_on: + - group-a + selectors: [] + +""" + + +class TestDeploymentGroupManager: + def test_basic_class(self): + dgm = DeploymentGroupManager(yaml.safe_load(_GROUPS_YAML), node_lookup) + assert dgm is not None + # topological sort doesn't guarantee a specific order. + assert dgm.get_next_group(Stage.PREPARED).name in ['ntp-node', + 'monitoring-nodes'] + assert len(dgm._all_groups) == 7 + assert len(dgm._all_nodes) == 12 + for name, group in dgm._all_groups.items(): + assert name == group.name + + def test_cycle_error(self): + with pytest.raises(DeploymentGroupCycleError) as ce: + DeploymentGroupManager(yaml.safe_load(_CYCLE_GROUPS_YAML), + node_lookup) + assert 'The following are involved' in str(ce) + for g in ['group-a', 'group-c', 'group-d']: + assert g in str(ce) + assert 'group-b' not in str(ce) + + def test_no_next_group(self): + dgm = DeploymentGroupManager(yaml.safe_load(_GROUPS_YAML), node_lookup) + assert dgm.get_next_group(Stage.DEPLOYED) is None + + def test_ordering_stages_flow_failure(self): + dgm = DeploymentGroupManager(yaml.safe_load(_GROUPS_YAML), node_lookup) + + group = dgm.get_next_group(Stage.PREPARED) + if group.name == 'monitoring-nodes': + dgm.mark_group_prepared(group.name) + dgm.mark_group_deployed(group.name) + group = dgm.get_next_group(Stage.PREPARED) + if group.name == 'ntp-node': + dgm.mark_group_failed(group.name) + + group = dgm.get_next_group(Stage.PREPARED) + if group and group.name == 'monitoring-nodes': + dgm.mark_group_prepared(group.name) + dgm.mark_group_deployed(group.name) + group = dgm.get_next_group(Stage.PREPARED) + # all remaining groups should be failed, so no more to prepare + for name, grp in dgm._all_groups.items(): + if (name == 'monitoring-nodes'): + assert grp.stage == Stage.DEPLOYED + else: + assert grp.stage == Stage.FAILED + assert group is None + + def test_deduplication(self): + """all-compute-nodes is a duplicate of things it's dependent on, it + should have no actionable nodes""" + dgm = DeploymentGroupManager(yaml.safe_load(_GROUPS_YAML), node_lookup) + acn = dgm._all_groups['all-compute-nodes'] + assert len(acn.actionable_nodes) == 0 + assert len(acn.full_nodes) == 6 + + def test_bad_group_name_lookup(self): + dgm = DeploymentGroupManager(yaml.safe_load(_GROUPS_YAML), node_lookup) + with pytest.raises(UnknownDeploymentGroupError) as udge: + dgm.mark_group_prepared('Limburger Cheese') + assert "Group name Limburger Cheese does not refer" in str(udge) + + def test_get_group_failures_for_stage_bad_input(self): + dgm = DeploymentGroupManager(yaml.safe_load(_GROUPS_YAML), node_lookup) + with pytest.raises(DeploymentGroupStageError): + dgm.get_group_failures_for_stage('group1', Stage.FAILED) + + def test_get_group_failures_for_stage(self): + dgm = DeploymentGroupManager(yaml.safe_load(_GROUPS_YAML), node_lookup) + dgm._all_nodes = { + 'node1': Stage.DEPLOYED, + 'node2': Stage.DEPLOYED, + 'node3': Stage.DEPLOYED, + 'node4': Stage.DEPLOYED, + 'node5': Stage.DEPLOYED, + 'node6': Stage.DEPLOYED, + 'node7': Stage.DEPLOYED, + 'node8': Stage.DEPLOYED, + 'node9': Stage.DEPLOYED, + 'node10': Stage.DEPLOYED, + 'node11': Stage.DEPLOYED, + 'node12': Stage.DEPLOYED, + } + + for group_name in dgm._all_groups: + assert not dgm.get_group_failures_for_stage(group_name, + Stage.DEPLOYED) + assert not dgm.get_group_failures_for_stage(group_name, + Stage.PREPARED) + + dgm._all_nodes = { + 'node1': Stage.PREPARED, + 'node2': Stage.PREPARED, + 'node3': Stage.PREPARED, + 'node4': Stage.PREPARED, + 'node5': Stage.PREPARED, + 'node6': Stage.PREPARED, + 'node7': Stage.PREPARED, + 'node8': Stage.PREPARED, + 'node9': Stage.PREPARED, + 'node10': Stage.PREPARED, + 'node11': Stage.PREPARED, + 'node12': Stage.PREPARED, + } + + for group_name in dgm._all_groups: + assert not dgm.get_group_failures_for_stage(group_name, + Stage.PREPARED) + + for group_name in ['compute-nodes-1', + 'monitoring-nodes', + 'compute-nodes-2', + 'control-nodes', + 'ntp-node']: + # assert that these have a failure + assert dgm.get_group_failures_for_stage(group_name, Stage.DEPLOYED) + + dgm._all_nodes = { + 'node1': Stage.FAILED, + 'node2': Stage.PREPARED, + 'node3': Stage.FAILED, + 'node4': Stage.PREPARED, + 'node5': Stage.FAILED, + 'node6': Stage.PREPARED, + 'node7': Stage.FAILED, + 'node8': Stage.PREPARED, + 'node9': Stage.FAILED, + 'node10': Stage.PREPARED, + 'node11': Stage.FAILED, + 'node12': Stage.PREPARED, + } + for group_name in dgm._all_groups: + scf = dgm.get_group_failures_for_stage(group_name, + Stage.PREPARED) + if group_name == 'monitoring-nodes': + assert scf[0] == {'criteria': 'minimum_successful_nodes', + 'needed': 3, + 'actual': 2} + if group_name == 'control-nodes': + assert scf[0] == {'criteria': 'percent_successful_nodes', + 'needed': 100, + 'actual': 50.0} + if group_name == 'ntp-node': + assert scf[0] == {'criteria': 'minimum_successful_nodes', + 'needed': 1, + 'actual': 0} + + def test_mark_node_deployed(self): + dgm = DeploymentGroupManager(yaml.safe_load(_GROUPS_YAML), node_lookup) + dgm.mark_node_deployed('node1') + assert dgm.get_nodes(Stage.DEPLOYED) == ['node1'] + + def test_mark_node_prepared(self): + dgm = DeploymentGroupManager(yaml.safe_load(_GROUPS_YAML), node_lookup) + dgm.mark_node_prepared('node1') + assert dgm.get_nodes(Stage.PREPARED) == ['node1'] + + def test_mark_node_failed(self): + dgm = DeploymentGroupManager(yaml.safe_load(_GROUPS_YAML), node_lookup) + dgm.mark_node_failed('node1') + assert dgm.get_nodes(Stage.FAILED) == ['node1'] + + def test_mark_node_failed_unknown(self): + dgm = DeploymentGroupManager(yaml.safe_load(_GROUPS_YAML), node_lookup) + with pytest.raises(UnknownNodeError): + dgm.mark_node_failed('not_node') + + def test_get_nodes_all(self): + dgm = DeploymentGroupManager(yaml.safe_load(_GROUPS_YAML), node_lookup) + assert set(dgm.get_nodes()) == set( + ['node1', 'node2', 'node3', 'node4', 'node5', 'node6', 'node7', + 'node8', 'node9', 'node10', 'node11', 'node12'] + ) diff --git a/tests/unit/schemas/base_schema_validation.py b/tests/unit/schemas/base_schema_validation.py deleted file mode 100644 index 13685769..00000000 --- a/tests/unit/schemas/base_schema_validation.py +++ /dev/null @@ -1,51 +0,0 @@ -# Copyright 2018 AT&T Intellectual Property. All other 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 logging -import os -import yaml - -import jsonschema -import pkg_resources -import pytest - -from jsonschema.exceptions import ValidationError - -LOG = logging.getLogger(__name__) - - -class BaseSchemaValidationTest(object): - def _test_validate(self, schema, expect_failure, input_files, input): - """validates input yaml against schema. - :param schema: schema yaml file - :param expect_failure: should the validation pass or fail. - :param input_files: pytest fixture used to access the test input files - :param input: test input yaml doc filename""" - schema_dir = pkg_resources.resource_filename('shipyard_airflow', - 'schemas') - schema_filename = os.path.join(schema_dir, schema) - schema_file = open(schema_filename, 'r') - schema = yaml.safe_load(schema_file) - - input_file = input_files.join(input) - instance_file = open(str(input_file), 'r') - instance = yaml.safe_load(instance_file) - - LOG.info('Input: %s, Schema: %s', input_file, schema_filename) - - if expect_failure: - # TypeError is raised when he input document is not well formed. - with pytest.raises((ValidationError, TypeError)): - jsonschema.validate(instance['data'], schema['data']) - else: - jsonschema.validate(instance['data'], schema['data']) diff --git a/tests/unit/schemas/test_deployment_strategy.py b/tests/unit/schemas/test_deployment_strategy.py deleted file mode 100644 index c610dc9e..00000000 --- a/tests/unit/schemas/test_deployment_strategy.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright 2017 AT&T Intellectual Property. All other 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 .base_schema_validation import BaseSchemaValidationTest - - -class TestValidation(BaseSchemaValidationTest): - def test_validate_deploy_config_full_valid(self, input_files): - self._test_validate('deploymentStrategy.yaml', False, input_files, - 'deploymentStrategy_full_valid.yaml') - - self._test_validate('deploymentStrategy.yaml', False, input_files, - 'deploymentStrategy_minimal.yaml') - - self._test_validate('deploymentStrategy.yaml', False, input_files, - 'deploymentStrategy_min_with_content.yaml') - - for testnum in range(1, 5): - self._test_validate( - 'deploymentStrategy.yaml', True, input_files, - 'deploymentStrategy_bad_values_{}.yaml'.format(testnum) - ) - - self._test_validate('deploymentStrategy.yaml', True, input_files, - 'total_garbage.yaml') - - self._test_validate('deploymentStrategy.yaml', True, input_files, - 'empty.yaml') diff --git a/tests/unit/yaml_samples/deploymentStrategy_bad_values_1.yaml b/tests/unit/yaml_samples/deploymentStrategy_bad_values_1.yaml deleted file mode 100644 index dc7711f4..00000000 --- a/tests/unit/yaml_samples/deploymentStrategy_bad_values_1.yaml +++ /dev/null @@ -1,16 +0,0 @@ ---- -schema: shipyard/DeploymentStrategy/v1 -metadata: - schema: metadata/Document/v1 - name: deployment-strategy - layeringDefinition: - abstract: false - layer: global - storagePolicy: cleartext -data: - groups: -# name is a min length of 1 - - name: "" - critical: false - depends_on: [] - selectors: [] diff --git a/tests/unit/yaml_samples/deploymentStrategy_bad_values_2.yaml b/tests/unit/yaml_samples/deploymentStrategy_bad_values_2.yaml deleted file mode 100644 index 6903c12a..00000000 --- a/tests/unit/yaml_samples/deploymentStrategy_bad_values_2.yaml +++ /dev/null @@ -1,16 +0,0 @@ ---- -schema: shipyard/DeploymentStrategy/v1 -metadata: - schema: metadata/Document/v1 - name: deployment-strategy - layeringDefinition: - abstract: false - layer: global - storagePolicy: cleartext -data: - groups: - - name: a_group -# critical is boolean - critical: cheese sandwich - depends_on: [] - selectors: [] diff --git a/tests/unit/yaml_samples/deploymentStrategy_bad_values_3.yaml b/tests/unit/yaml_samples/deploymentStrategy_bad_values_3.yaml deleted file mode 100644 index f2c6f375..00000000 --- a/tests/unit/yaml_samples/deploymentStrategy_bad_values_3.yaml +++ /dev/null @@ -1,15 +0,0 @@ ---- -schema: shipyard/DeploymentStrategy/v1 -metadata: - schema: metadata/Document/v1 - name: deployment-strategy - layeringDefinition: - abstract: false - layer: global - storagePolicy: cleartext -data: - groups: - - name: some_group - critical: true -# depends_on missing - selectors: [] diff --git a/tests/unit/yaml_samples/deploymentStrategy_bad_values_4.yaml b/tests/unit/yaml_samples/deploymentStrategy_bad_values_4.yaml deleted file mode 100644 index 154b5ce9..00000000 --- a/tests/unit/yaml_samples/deploymentStrategy_bad_values_4.yaml +++ /dev/null @@ -1,18 +0,0 @@ ---- -schema: shipyard/DeploymentStrategy/v1 -metadata: - schema: metadata/Document/v1 - name: deployment-strategy - layeringDefinition: - abstract: false - layer: global - storagePolicy: cleartext -data: - groups: - - name: my_name_is - critical: false - depends_on: [] - selectors: -# node_names are strings - - node_names: [false, true, false] - node_labels: [] diff --git a/tests/unit/yaml_samples/deploymentStrategy_bad_values_5.yaml b/tests/unit/yaml_samples/deploymentStrategy_bad_values_5.yaml deleted file mode 100644 index d363f3f8..00000000 --- a/tests/unit/yaml_samples/deploymentStrategy_bad_values_5.yaml +++ /dev/null @@ -1,20 +0,0 @@ ---- -schema: shipyard/DeploymentStrategy/v1 -metadata: - schema: metadata/Document/v1 - name: deployment-strategy - layeringDefinition: - abstract: false - layer: global - storagePolicy: cleartext -data: - groups: - - name: my_group - critical: false - depends_on: [] - selectors: - - node_names: [] - node_labels: [] - success_criteria: -# should be 100 or less - percent_successful_nodes: 190 diff --git a/tests/unit/yaml_samples/deploymentStrategy_full_valid.yaml b/tests/unit/yaml_samples/deploymentStrategy_full_valid.yaml deleted file mode 100644 index d218bd8d..00000000 --- a/tests/unit/yaml_samples/deploymentStrategy_full_valid.yaml +++ /dev/null @@ -1,75 +0,0 @@ ---- -schema: shipyard/DeploymentStrategy/v1 -metadata: - schema: metadata/Document/v1 - name: deployment-strategy - layeringDefinition: - abstract: false - layer: global - storagePolicy: cleartext -data: - groups: - - name: control-nodes - critical: true - depends_on: - - ntp-node - selectors: - - node_names: [] - node_labels: [] - node_tags: - - control - rack_names: - - rack03 - success_criteria: - percent_successful_nodes: 90 - minimum_successful_nodes: 3 - maximum_failed_nodes: 1 - - name: compute-nodes-1 - critical: false - depends_on: - - control-nodes - selectors: - - node_names: [] - node_labels: [] - rack_names: - - rack01 - node_tags: - - compute - success_criteria: - percent_successful_nodes: 50 - - name: compute-nodes-2 - critical: false - depends_on: - - control-nodes - selectors: - - node_names: [] - node_labels: [] - rack_names: - - rack02 - node_tags: - - compute - success_criteria: - percent_successful_nodes: 50 - - name: monitoring-nodes - critical: false - depends_on: [] - selectors: - - node_names: [] - node_labels: [] - node_tags: - - monitoring - rack_names: - - rack03 - - rack02 - - rack01 - - name: ntp-node - critical: true - depends_on: [] - selectors: - - node_names: - - ntp01 - node_labels: [] - node_tags: [] - rack_names: [] - success_criteria: - minimum_successful_nodes: 1 diff --git a/tests/unit/yaml_samples/deploymentStrategy_min_with_content.yaml b/tests/unit/yaml_samples/deploymentStrategy_min_with_content.yaml deleted file mode 100644 index 65e1930c..00000000 --- a/tests/unit/yaml_samples/deploymentStrategy_min_with_content.yaml +++ /dev/null @@ -1,15 +0,0 @@ ---- -schema: shipyard/DeploymentStrategy/v1 -metadata: - schema: metadata/Document/v1 - name: deployment-strategy - layeringDefinition: - abstract: false - layer: global - storagePolicy: cleartext -data: - groups: - - name: some-nodes - critical: false - depends_on: [] - selectors: [] diff --git a/tests/unit/yaml_samples/deploymentStrategy_minimal.yaml b/tests/unit/yaml_samples/deploymentStrategy_minimal.yaml deleted file mode 100644 index 3f36a115..00000000 --- a/tests/unit/yaml_samples/deploymentStrategy_minimal.yaml +++ /dev/null @@ -1,11 +0,0 @@ ---- -schema: shipyard/DeploymentStrategy/v1 -metadata: - schema: metadata/Document/v1 - name: deployment-strategy - layeringDefinition: - abstract: false - layer: global - storagePolicy: cleartext -data: - groups: [] diff --git a/tests/unit/yaml_samples/total_garbage.yaml b/tests/unit/yaml_samples/total_garbage.yaml deleted file mode 100644 index 15bab1c2..00000000 --- a/tests/unit/yaml_samples/total_garbage.yaml +++ /dev/null @@ -1,44 +0,0 @@ -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras euismod -sed urna nec posuere. Phasellus vel arcu vestibulum, mattis ligula eu, -pulvinar magna. Cras mollis velit quis maximus gravida. Morbi nec ligula -neque. Cras vitae cursus tellus, ut ornare enim. Nulla vel suscipit -arcu, in auctor ipsum. Ut a maximus magna. Integer massa risus, -tristique sit amet urna sit amet, facilisis bibendum lectus. Vivamus -vehicula urna in purus lacinia, sit amet tincidunt diam consequat. -Quisque ut metus vitae mauris condimentum sollicitudin. Pellentesque -urna nibh, mollis eu dui ac, molestie malesuada quam. Aliquam fringilla -faucibus orci, a tincidunt dui sollicitudin ac. Nullam enim velit, -imperdiet ut nulla quis, efficitur tincidunt eros. Curabitur nisi justo, -tristique non ornare vitae, mollis eu tortor. In non congue libero. -Mauris et tincidunt sem. Quisque sed congue diam, non ultrices turpis. -Pellentesque lobortis quam justo, facilisis sollicitudin mi imperdiet -sed. Ut nec leo placerat, gravida odio id, hendrerit erat. Praesent -placerat diam mi, et blandit mi sollicitudin et. Proin ligula sapien, -faucibus eget arcu vel, rhoncus vestibulum ipsum. Morbi tristique -pharetra diam non faucibus. Nam scelerisque, leo ut tempus fermentum, -dolor odio tempus nisl, et volutpat ante est id enim. Integer venenatis -scelerisque augue, quis porta lorem dapibus non. Sed arcu purus, iaculis -vitae sem sit amet, ultrices pretium leo. Nulla ultricies eleifend -tempus. Aenean elementum ipsum id auctor faucibus. Cras quis ipsum -vehicula, auctor velit et, dignissim sem. Duis sed nunc sagittis, -interdum dui consequat, iaculis purus. Curabitur quam ex, pellentesque -nec sapien ut, sodales lacinia enim. Etiam hendrerit sem eu turpis -euismod, quis luctus tortor iaculis. Vivamus a rutrum orci. Class aptent -taciti sociosqu ad litora torquent per conubia nostra, per inceptos -himenaeos. Pellentesque habitant morbi tristique senectus et netus et -malesuada fames ac turpis egestas. Aenean non neque ultrices, consequat -erat vitae, porta lorem. Phasellus fringilla fringilla imperdiet. -Quisque in nulla at elit sodales vestibulum ac eget mauris. Ut vel purus -nec metus ultrices aliquet in sed leo. Mauris vel congue velit. Donec -quam turpis, venenatis tristique sem nec, condimentum fringilla orci. -Sed eu feugiat dui. Proin vulputate lacus id blandit tempor. Vivamus -sollicitudin tincidunt ultrices. Aenean sit amet orci efficitur, -condimentum mi vel, condimentum nisi. Cras pellentesque, felis vel -maximus volutpat, turpis arcu dapibus metus, vitae fringilla massa lorem -et elit. Interdum et malesuada fames ac ante ipsum primis in faucibus. -Duis lorem velit, laoreet tincidunt fringilla at, vestibulum eget risus. -Pellentesque ullamcorper venenatis lectus, a mattis lectus feugiat vel. -Suspendisse potenti. Duis suscipit malesuada risus nec egestas. Vivamus -maximus, neque quis egestas rhoncus, mauris purus fringilla nisl, ut -fringilla odio nunc sit amet justo. Phasellus at dui quis magna -elementum sagittis. Nullam sed luctus felis, ac tincidunt erat.