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
This commit is contained in:
parent
b944ba666f
commit
dffb8d3b93
@ -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
|
@ -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
|
||||
|
@ -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.
|
||||
"""
|
@ -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
|
@ -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)
|
||||
)
|
@ -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
|
@ -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"]
|
@ -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")
|
@ -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']
|
||||
)
|
@ -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'])
|
@ -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')
|
@ -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: []
|
@ -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: []
|
@ -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: []
|
@ -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: []
|
@ -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
|
@ -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
|
@ -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: []
|
@ -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: []
|
@ -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.
|
Loading…
x
Reference in New Issue
Block a user