Add Goal in BaseStrategy + Goal API reads from DB

In this changeset, I changed the Strategy base class to add new
abstract class methods. I also added an abstract strategy class
per Goal type (dummy, server consolidation, thermal optimization).

This changeset also includes an update of the /goals Watcher API
endpoint to now use the new Goal model (DB entries) instead of
reading from the configuration file.

Partially Implements: blueprint get-goal-from-strategy
Change-Id: Iecfed58c72f3f9df4e9d27e50a3a274a1fc0a75f
This commit is contained in:
Vincent Françoise
2016-04-29 17:22:45 +02:00
parent a3ac26870a
commit 673642e436
20 changed files with 461 additions and 192 deletions

View File

@@ -44,7 +44,7 @@ class Collection(base.APIBase):
q_args = ''.join(['%s=%s&' % (key, kwargs[key]) for key in kwargs]) q_args = ''.join(['%s=%s&' % (key, kwargs[key]) for key in kwargs])
next_args = '?%(args)slimit=%(limit)d&marker=%(marker)s' % { next_args = '?%(args)slimit=%(limit)d&marker=%(marker)s' % {
'args': q_args, 'limit': limit, 'args': q_args, 'limit': limit,
'marker': self.collection[-1].uuid} 'marker': getattr(self.collection[-1], "uuid")}
return link.Link.make_link('next', pecan.request.host_url, return link.Link.make_link('next', pecan.request.host_url,
resource_url, next_args).href resource_url, next_args).href

View File

@@ -46,61 +46,64 @@ from watcher.api.controllers.v1 import collection
from watcher.api.controllers.v1 import types from watcher.api.controllers.v1 import types
from watcher.api.controllers.v1 import utils as api_utils from watcher.api.controllers.v1 import utils as api_utils
from watcher.common import exception from watcher.common import exception
from watcher.common import utils as common_utils
from watcher import objects
CONF = cfg.CONF CONF = cfg.CONF
class Goal(base.APIBase): class Goal(base.APIBase):
"""API representation of a action. """API representation of a goal.
This class enforces type checking and value constraints, and converts This class enforces type checking and value constraints, and converts
between the internal object model and the API representation of a action. between the internal object model and the API representation of a goal.
""" """
uuid = types.uuid
"""Unique UUID for this goal"""
name = wtypes.text name = wtypes.text
"""Name of the goal""" """Name of the goal"""
strategy = wtypes.text display_name = wtypes.text
"""The strategy associated with the goal""" """Localized name of the goal"""
uuid = types.uuid
"""Unused field"""
links = wsme.wsattr([link.Link], readonly=True) links = wsme.wsattr([link.Link], readonly=True)
"""A list containing a self link and associated action links""" """A list containing a self link and associated audit template links"""
def __init__(self, **kwargs): def __init__(self, **kwargs):
super(Goal, self).__init__() super(Goal, self).__init__()
self.fields = [] self.fields = []
self.fields.append('uuid')
self.fields.append('name') self.fields.append('name')
self.fields.append('strategy') self.fields.append('display_name')
setattr(self, 'name', kwargs.get('name', setattr(self, 'uuid', kwargs.get('uuid', wtypes.Unset))
wtypes.Unset)) setattr(self, 'name', kwargs.get('name', wtypes.Unset))
setattr(self, 'strategy', kwargs.get('strategy', setattr(self, 'display_name', kwargs.get('display_name', wtypes.Unset))
wtypes.Unset))
@staticmethod @staticmethod
def _convert_with_links(goal, url, expand=True): def _convert_with_links(goal, url, expand=True):
if not expand: if not expand:
goal.unset_fields_except(['name', 'strategy']) goal.unset_fields_except(['uuid', 'name', 'display_name'])
goal.links = [link.Link.make_link('self', url, goal.links = [link.Link.make_link('self', url,
'goals', goal.name), 'goals', goal.uuid),
link.Link.make_link('bookmark', url, link.Link.make_link('bookmark', url,
'goals', goal.name, 'goals', goal.uuid,
bookmark=True)] bookmark=True)]
return goal return goal
@classmethod @classmethod
def convert_with_links(cls, goal, expand=True): def convert_with_links(cls, goal, expand=True):
goal = Goal(**goal) goal = Goal(**goal.as_dict())
return cls._convert_with_links(goal, pecan.request.host_url, expand) return cls._convert_with_links(goal, pecan.request.host_url, expand)
@classmethod @classmethod
def sample(cls, expand=True): def sample(cls, expand=True):
sample = cls(name='27e3153e-d5bf-4b7e-b517-fb518e17f34c', sample = cls(uuid='27e3153e-d5bf-4b7e-b517-fb518e17f34c',
strategy='action description') name='DUMMY',
display_name='Dummy strategy')
return cls._convert_with_links(sample, 'http://localhost:9322', expand) return cls._convert_with_links(sample, 'http://localhost:9322', expand)
@@ -117,27 +120,28 @@ class GoalCollection(collection.Collection):
@staticmethod @staticmethod
def convert_with_links(goals, limit, url=None, expand=False, def convert_with_links(goals, limit, url=None, expand=False,
**kwargs): **kwargs):
goal_collection = GoalCollection()
collection = GoalCollection() goal_collection.goals = [
collection.goals = [Goal.convert_with_links(g, expand) for g in goals] Goal.convert_with_links(g, expand) for g in goals]
if 'sort_key' in kwargs: if 'sort_key' in kwargs:
reverse = False reverse = False
if kwargs['sort_key'] == 'strategy': if kwargs['sort_key'] == 'strategy':
if 'sort_dir' in kwargs: if 'sort_dir' in kwargs:
reverse = True if kwargs['sort_dir'] == 'desc' else False reverse = True if kwargs['sort_dir'] == 'desc' else False
collection.goals = sorted( goal_collection.goals = sorted(
collection.goals, goal_collection.goals,
key=lambda goal: goal.name, key=lambda goal: goal.uuid,
reverse=reverse) reverse=reverse)
collection.next = collection.get_next(limit, url=url, **kwargs) goal_collection.next = goal_collection.get_next(
return collection limit, url=url, **kwargs)
return goal_collection
@classmethod @classmethod
def sample(cls): def sample(cls):
sample = cls() sample = cls()
sample.actions = [Goal.sample(expand=False)] sample.goals = [Goal.sample(expand=False)]
return sample return sample
@@ -154,51 +158,49 @@ class GoalsController(rest.RestController):
'detail': ['GET'], 'detail': ['GET'],
} }
def _get_goals_collection(self, limit, def _get_goals_collection(self, marker, limit, sort_key, sort_dir,
sort_key, sort_dir, expand=False, expand=False, resource_url=None):
resource_url=None, goal_name=None):
limit = api_utils.validate_limit(limit) limit = api_utils.validate_limit(limit)
api_utils.validate_sort_dir(sort_dir) api_utils.validate_sort_dir(sort_dir)
goals = [] sort_db_key = (sort_key if sort_key in objects.Goal.fields.keys()
else None)
if not goal_name and goal_name in CONF.watcher_goals.goals.keys(): marker_obj = None
goals.append({'name': goal_name, 'strategy': goals[goal_name]}) if marker:
else: marker_obj = objects.Goal.get_by_uuid(
for name, strategy in CONF.watcher_goals.goals.items(): pecan.request.context, marker)
goals.append({'name': name, 'strategy': strategy})
return GoalCollection.convert_with_links(goals[:limit], limit, goals = objects.Goal.list(pecan.request.context, limit, marker_obj,
sort_key=sort_db_key, sort_dir=sort_dir)
return GoalCollection.convert_with_links(goals, limit,
url=resource_url, url=resource_url,
expand=expand, expand=expand,
sort_key=sort_key, sort_key=sort_key,
sort_dir=sort_dir) sort_dir=sort_dir)
@wsme_pecan.wsexpose(GoalCollection, int, wtypes.text, wtypes.text) @wsme_pecan.wsexpose(GoalCollection, wtypes.text,
def get_all(self, limit=None, int, wtypes.text, wtypes.text)
sort_key='name', sort_dir='asc'): def get_all(self, marker=None, limit=None, sort_key='id', sort_dir='asc'):
"""Retrieve a list of goals. """Retrieve a list of goals.
:param marker: pagination marker for large data sets.
:param limit: maximum number of resources to return in a single result. :param limit: maximum number of resources to return in a single result.
:param sort_key: column to sort results by. Default: id. :param sort_key: column to sort results by. Default: id.
:param sort_dir: direction to sort. "asc" or "desc". Default: asc. :param sort_dir: direction to sort. "asc" or "desc". Default: asc.
to get only actions for that goal.
""" """
return self._get_goals_collection(limit, sort_key, sort_dir) return self._get_goals_collection(marker, limit, sort_key, sort_dir)
@wsme_pecan.wsexpose(GoalCollection, wtypes.text, int, @wsme_pecan.wsexpose(GoalCollection, wtypes.text, int,
wtypes.text, wtypes.text) wtypes.text, wtypes.text)
def detail(self, goal_name=None, limit=None, def detail(self, marker=None, limit=None, sort_key='id', sort_dir='asc'):
sort_key='name', sort_dir='asc'): """Retrieve a list of goals with detail.
"""Retrieve a list of actions with detail.
:param goal_name: name of a goal, to get only goals for that :param marker: pagination marker for large data sets.
action.
:param limit: maximum number of resources to return in a single result. :param limit: maximum number of resources to return in a single result.
:param sort_key: column to sort results by. Default: id. :param sort_key: column to sort results by. Default: id.
:param sort_dir: direction to sort. "asc" or "desc". Default: asc. :param sort_dir: direction to sort. "asc" or "desc". Default: asc.
to get only goals for that goal.
""" """
# NOTE(lucasagomes): /detail should only work agaist collections # NOTE(lucasagomes): /detail should only work agaist collections
parent = pecan.request.path.split('/')[:-1][-1] parent = pecan.request.path.split('/')[:-1][-1]
@@ -206,21 +208,23 @@ class GoalsController(rest.RestController):
raise exception.HTTPNotFound raise exception.HTTPNotFound
expand = True expand = True
resource_url = '/'.join(['goals', 'detail']) resource_url = '/'.join(['goals', 'detail'])
return self._get_goals_collection(limit, sort_key, sort_dir, return self._get_goals_collection(marker, limit, sort_key, sort_dir,
expand, resource_url, goal_name) expand, resource_url)
@wsme_pecan.wsexpose(Goal, wtypes.text) @wsme_pecan.wsexpose(Goal, wtypes.text)
def get_one(self, goal_name): def get_one(self, goal):
"""Retrieve information about the given goal. """Retrieve information about the given goal.
:param goal_name: name of the goal. :param goal: UUID or name of the goal.
""" """
if self.from_goals: if self.from_goals:
raise exception.OperationNotPermitted raise exception.OperationNotPermitted
goals = CONF.watcher_goals.goals if common_utils.is_uuid_like(goal):
goal = {} get_goal_func = objects.Goal.get_by_uuid
if goal_name in goals.keys(): else:
goal = {'name': goal_name, 'strategy': goals[goal_name]} get_goal_func = objects.Goal.get_by_name
return Goal.convert_with_links(goal) rpc_goal = get_goal_func(pecan.request.context, goal)
return Goal.convert_with_links(rpc_goal)

View File

@@ -28,6 +28,7 @@ from oslo_service import service
from watcher._i18n import _LI from watcher._i18n import _LI
from watcher.common import service as watcher_service from watcher.common import service as watcher_service
from watcher.decision_engine import manager from watcher.decision_engine import manager
from watcher.decision_engine import sync
from watcher import version from watcher import version
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@@ -41,6 +42,9 @@ def main():
LOG.info(_LI('Starting Watcher Decision Engine service in PID %s'), LOG.info(_LI('Starting Watcher Decision Engine service in PID %s'),
os.getpid()) os.getpid())
syncer = sync.Syncer()
syncer.sync()
de_service = watcher_service.Service(manager.DecisionEngineManager) de_service = watcher_service.Service(manager.DecisionEngineManager)
launcher = service.launch(CONF, de_service) launcher = service.launch(CONF, de_service)
launcher.wait() launcher.wait()

View File

@@ -39,6 +39,7 @@ which are dynamically loaded by Watcher at launch time.
import abc import abc
import six import six
from watcher._i18n import _
from watcher.common import clients from watcher.common import clients
from watcher.decision_engine.solution import default from watcher.decision_engine.solution import default
from watcher.decision_engine.strategy.common import level from watcher.decision_engine.strategy.common import level
@@ -52,10 +53,10 @@ class BaseStrategy(object):
Solution for a given Goal. Solution for a given Goal.
""" """
def __init__(self, name=None, description=None, osc=None): def __init__(self, osc=None):
""":param osc: an OpenStackClients instance""" """:param osc: an OpenStackClients instance"""
self._name = name self._name = self.get_name()
self.description = description self._display_name = self.get_display_name()
# default strategy level # default strategy level
self._strategy_level = level.StrategyLevel.conservative self._strategy_level = level.StrategyLevel.conservative
self._cluster_state_collector = None self._cluster_state_collector = None
@@ -63,6 +64,46 @@ class BaseStrategy(object):
self._solution = default.DefaultSolution() self._solution = default.DefaultSolution()
self._osc = osc self._osc = osc
@classmethod
@abc.abstractmethod
def get_name(cls):
"""The name of the strategy"""
raise NotImplementedError()
@classmethod
@abc.abstractmethod
def get_display_name(cls):
"""The goal display name for the strategy"""
raise NotImplementedError()
@classmethod
@abc.abstractmethod
def get_translatable_display_name(cls):
"""The translatable msgid of the strategy"""
# Note(v-francoise): Defined here to be used as the translation key for
# other services
raise NotImplementedError()
@classmethod
@abc.abstractmethod
def get_goal_name(cls):
"""The goal name for the strategy"""
raise NotImplementedError()
@classmethod
@abc.abstractmethod
def get_goal_display_name(cls):
"""The translated display name related to the goal of the strategy"""
raise NotImplementedError()
@classmethod
@abc.abstractmethod
def get_translatable_goal_display_name(cls):
"""The translatable msgid related to the goal of the strategy"""
# Note(v-francoise): Defined here to be used as the translation key for
# other services
raise NotImplementedError()
@abc.abstractmethod @abc.abstractmethod
def execute(self, original_model): def execute(self, original_model):
"""Execute a strategy """Execute a strategy
@@ -88,12 +129,12 @@ class BaseStrategy(object):
self._solution = s self._solution = s
@property @property
def name(self): def id(self):
return self._name return self._name
@name.setter @property
def name(self, n): def display_name(self):
self._name = n return self._display_name
@property @property
def strategy_level(self): def strategy_level(self):
@@ -110,3 +151,51 @@ class BaseStrategy(object):
@state_collector.setter @state_collector.setter
def state_collector(self, s): def state_collector(self, s):
self._cluster_state_collector = s self._cluster_state_collector = s
@six.add_metaclass(abc.ABCMeta)
class DummyBaseStrategy(BaseStrategy):
@classmethod
def get_goal_name(cls):
return "DUMMY"
@classmethod
def get_goal_display_name(cls):
return _("Dummy goal")
@classmethod
def get_translatable_goal_display_name(cls):
return "Dummy goal"
@six.add_metaclass(abc.ABCMeta)
class ServerConsolidationBaseStrategy(BaseStrategy):
@classmethod
def get_goal_name(cls):
return "SERVER_CONSOLIDATION"
@classmethod
def get_goal_display_name(cls):
return _("Server consolidation")
@classmethod
def get_translatable_goal_display_name(cls):
return "Server consolidation"
@six.add_metaclass(abc.ABCMeta)
class ThermalOptimizationBaseStrategy(BaseStrategy):
@classmethod
def get_goal_name(cls):
return "THERMAL_OPTIMIZATION"
@classmethod
def get_goal_display_name(cls):
return _("Thermal optimization")
@classmethod
def get_translatable_goal_display_name(cls):
return "Thermal optimization"

View File

@@ -29,7 +29,7 @@ order to both minimize energy consumption and comply to the various SLAs.
from oslo_log import log from oslo_log import log
from watcher._i18n import _LE, _LI, _LW from watcher._i18n import _, _LE, _LI, _LW
from watcher.common import exception from watcher.common import exception
from watcher.decision_engine.model import hypervisor_state as hyper_state from watcher.decision_engine.model import hypervisor_state as hyper_state
from watcher.decision_engine.model import resource from watcher.decision_engine.model import resource
@@ -41,7 +41,7 @@ from watcher.metrics_engine.cluster_history import ceilometer as \
LOG = log.getLogger(__name__) LOG = log.getLogger(__name__)
class BasicConsolidation(base.BaseStrategy): class BasicConsolidation(base.ServerConsolidationBaseStrategy):
"""Basic offline consolidation using live migration """Basic offline consolidation using live migration
*Description* *Description*
@@ -65,17 +65,13 @@ class BasicConsolidation(base.BaseStrategy):
<None> <None>
""" """
DEFAULT_NAME = "basic"
DEFAULT_DESCRIPTION = "Basic offline consolidation"
HOST_CPU_USAGE_METRIC_NAME = 'compute.node.cpu.percent' HOST_CPU_USAGE_METRIC_NAME = 'compute.node.cpu.percent'
INSTANCE_CPU_USAGE_METRIC_NAME = 'cpu_util' INSTANCE_CPU_USAGE_METRIC_NAME = 'cpu_util'
MIGRATION = "migrate" MIGRATION = "migrate"
CHANGE_NOVA_SERVICE_STATE = "change_nova_service_state" CHANGE_NOVA_SERVICE_STATE = "change_nova_service_state"
def __init__(self, name=DEFAULT_NAME, description=DEFAULT_DESCRIPTION, def __init__(self, osc=None):
osc=None):
"""Basic offline Consolidation using live migration """Basic offline Consolidation using live migration
:param name: The name of the strategy (Default: "basic") :param name: The name of the strategy (Default: "basic")
@@ -84,7 +80,7 @@ class BasicConsolidation(base.BaseStrategy):
:param osc: An :py:class:`~watcher.common.clients.OpenStackClients` :param osc: An :py:class:`~watcher.common.clients.OpenStackClients`
instance instance
""" """
super(BasicConsolidation, self).__init__(name, description, osc) super(BasicConsolidation, self).__init__(osc)
# set default value for the number of released nodes # set default value for the number of released nodes
self.number_of_released_nodes = 0 self.number_of_released_nodes = 0
@@ -114,6 +110,18 @@ class BasicConsolidation(base.BaseStrategy):
# TODO(jed) bound migration attempts (80 %) # TODO(jed) bound migration attempts (80 %)
self.bound_migration = 0.80 self.bound_migration = 0.80
@classmethod
def get_name(cls):
return "basic"
@classmethod
def get_display_name(cls):
return _("Basic offline consolidation")
@classmethod
def get_translatable_display_name(cls):
return "Basic offline consolidation"
@property @property
def ceilometer(self): def ceilometer(self):
if self._ceilometer is None: if self._ceilometer is None:

View File

@@ -18,12 +18,13 @@
# #
from oslo_log import log from oslo_log import log
from watcher._i18n import _
from watcher.decision_engine.strategy.strategies import base from watcher.decision_engine.strategy.strategies import base
LOG = log.getLogger(__name__) LOG = log.getLogger(__name__)
class DummyStrategy(base.BaseStrategy): class DummyStrategy(base.DummyBaseStrategy):
"""Dummy strategy used for integration testing via Tempest """Dummy strategy used for integration testing via Tempest
*Description* *Description*
@@ -44,15 +45,11 @@ class DummyStrategy(base.BaseStrategy):
<None> <None>
""" """
DEFAULT_NAME = "dummy"
DEFAULT_DESCRIPTION = "Dummy Strategy"
NOP = "nop" NOP = "nop"
SLEEP = "sleep" SLEEP = "sleep"
def __init__(self, name=DEFAULT_NAME, description=DEFAULT_DESCRIPTION, def __init__(self, osc=None):
osc=None): super(DummyStrategy, self).__init__(osc)
super(DummyStrategy, self).__init__(name, description, osc)
def execute(self, original_model): def execute(self, original_model):
LOG.debug("Executing Dummy strategy") LOG.debug("Executing Dummy strategy")
@@ -67,3 +64,15 @@ class DummyStrategy(base.BaseStrategy):
self.solution.add_action(action_type=self.SLEEP, self.solution.add_action(action_type=self.SLEEP,
input_parameters={'duration': 5.0}) input_parameters={'duration': 5.0})
return self.solution return self.solution
@classmethod
def get_name(cls):
return "dummy"
@classmethod
def get_display_name(cls):
return _("Dummy strategy")
@classmethod
def get_translatable_display_name(cls):
return "Dummy strategy"

View File

@@ -30,7 +30,7 @@ telemetries to measure thermal/workload status of server.
from oslo_log import log from oslo_log import log
from watcher._i18n import _LE from watcher._i18n import _, _LE
from watcher.common import exception as wexc from watcher.common import exception as wexc
from watcher.decision_engine.model import resource from watcher.decision_engine.model import resource
from watcher.decision_engine.model import vm_state from watcher.decision_engine.model import vm_state
@@ -41,7 +41,7 @@ from watcher.metrics_engine.cluster_history import ceilometer as ceil
LOG = log.getLogger(__name__) LOG = log.getLogger(__name__)
class OutletTempControl(base.BaseStrategy): class OutletTempControl(base.ThermalOptimizationBaseStrategy):
"""[PoC] Outlet temperature control using live migration """[PoC] Outlet temperature control using live migration
*Description* *Description*
@@ -71,8 +71,6 @@ class OutletTempControl(base.BaseStrategy):
https://github.com/openstack/watcher-specs/blob/master/specs/mitaka/approved/outlet-temperature-based-strategy.rst https://github.com/openstack/watcher-specs/blob/master/specs/mitaka/approved/outlet-temperature-based-strategy.rst
""" # noqa """ # noqa
DEFAULT_NAME = "outlet_temp_control"
DEFAULT_DESCRIPTION = "outlet temperature based migration strategy"
# The meter to report outlet temperature in ceilometer # The meter to report outlet temperature in ceilometer
METER_NAME = "hardware.ipmi.node.outlet_temperature" METER_NAME = "hardware.ipmi.node.outlet_temperature"
# Unit: degree C # Unit: degree C
@@ -80,15 +78,14 @@ class OutletTempControl(base.BaseStrategy):
MIGRATION = "migrate" MIGRATION = "migrate"
def __init__(self, name=DEFAULT_NAME, description=DEFAULT_DESCRIPTION, def __init__(self, osc=None):
osc=None):
"""Outlet temperature control using live migration """Outlet temperature control using live migration
:param name: the name of the strategy :param name: the name of the strategy
:param description: a description of the strategy :param description: a description of the strategy
:param osc: an OpenStackClients object :param osc: an OpenStackClients object
""" """
super(OutletTempControl, self).__init__(name, description, osc) super(OutletTempControl, self).__init__(osc)
# the migration plan will be triggered when the outlet temperature # the migration plan will be triggered when the outlet temperature
# reaches threshold # reaches threshold
# TODO(zhenzanz): Threshold should be configurable for each audit # TODO(zhenzanz): Threshold should be configurable for each audit
@@ -96,6 +93,18 @@ class OutletTempControl(base.BaseStrategy):
self._meter = self.METER_NAME self._meter = self.METER_NAME
self._ceilometer = None self._ceilometer = None
@classmethod
def get_name(cls):
return "outlet_temperature"
@classmethod
def get_display_name(cls):
return _("Outlet temperature based strategy")
@classmethod
def get_translatable_display_name(cls):
return "Outlet temperature based strategy"
@property @property
def ceilometer(self): def ceilometer(self):
if self._ceilometer is None: if self._ceilometer is None:

View File

@@ -22,7 +22,7 @@ from copy import deepcopy
from oslo_log import log from oslo_log import log
import six import six
from watcher._i18n import _LE, _LI from watcher._i18n import _, _LE, _LI
from watcher.common import exception from watcher.common import exception
from watcher.decision_engine.model import hypervisor_state as hyper_state from watcher.decision_engine.model import hypervisor_state as hyper_state
from watcher.decision_engine.model import resource from watcher.decision_engine.model import resource
@@ -34,9 +34,11 @@ from watcher.metrics_engine.cluster_history import ceilometer \
LOG = log.getLogger(__name__) LOG = log.getLogger(__name__)
class VMWorkloadConsolidation(base.BaseStrategy): class VMWorkloadConsolidation(base.ServerConsolidationBaseStrategy):
"""VM Workload Consolidation Strategy. """VM Workload Consolidation Strategy.
*Description*
A load consolidation strategy based on heuristic first-fit A load consolidation strategy based on heuristic first-fit
algorithm which focuses on measured CPU utilization and tries to algorithm which focuses on measured CPU utilization and tries to
minimize hosts which have too much or too little load respecting minimize hosts which have too much or too little load respecting
@@ -67,19 +69,39 @@ class VMWorkloadConsolidation(base.BaseStrategy):
correctly on all hypervisors within the cluster. correctly on all hypervisors within the cluster.
This strategy assumes it is possible to live migrate any VM from This strategy assumes it is possible to live migrate any VM from
an active hypervisor to any other active hypervisor. an active hypervisor to any other active hypervisor.
"""
DEFAULT_NAME = 'vm_workload_consolidation' *Requirements*
DEFAULT_DESCRIPTION = 'VM Workload Consolidation Strategy'
def __init__(self, name=DEFAULT_NAME, description=DEFAULT_DESCRIPTION, * You must have at least 2 physical compute nodes to run this strategy.
osc=None):
super(VMWorkloadConsolidation, self).__init__(name, description, osc) *Limitations*
<None>
*Spec URL*
https://github.com/openstack/watcher-specs/blob/master/specs/mitaka/implemented/zhaw-load-consolidation.rst
""" # noqa
def __init__(self, osc=None):
super(VMWorkloadConsolidation, self).__init__(osc)
self._ceilometer = None self._ceilometer = None
self.number_of_migrations = 0 self.number_of_migrations = 0
self.number_of_released_hypervisors = 0 self.number_of_released_hypervisors = 0
self.ceilometer_vm_data_cache = dict() self.ceilometer_vm_data_cache = dict()
@classmethod
def get_name(cls):
return "vm_workload_consolidation"
@classmethod
def get_display_name(cls):
return _("VM Workload Consolidation Strategy")
@classmethod
def get_translatable_display_name(cls):
return "VM Workload Consolidation Strategy"
@property @property
def ceilometer(self): def ceilometer(self):
if self._ceilometer is None: if self._ceilometer is None:

View File

@@ -153,21 +153,16 @@ class Syncer(object):
strategy_loader = default.DefaultStrategyLoader() strategy_loader = default.DefaultStrategyLoader()
implemented_strategies = strategy_loader.list_available() implemented_strategies = strategy_loader.list_available()
# TODO(v-francoise): At this point I only register the goals, but later
# on this will be extended to also populate the strategies map.
for _, strategy_cls in implemented_strategies.items(): for _, strategy_cls in implemented_strategies.items():
# This mapping is a temporary trick where I use the strategy goals_map[strategy_cls.get_goal_name()] = {
# DEFAULT_NAME as the goal name because we used to have a 1-to-1 "name": strategy_cls.get_goal_name(),
# mapping between the goal and the strategy. "display_name":
# TODO(v-francoise): Dissociate the goal name and the strategy name strategy_cls.get_translatable_goal_display_name()}
goals_map[strategy_cls.DEFAULT_NAME] = {
"name": strategy_cls.DEFAULT_NAME,
"display_name": strategy_cls.DEFAULT_DESCRIPTION}
strategies_map[strategy_cls.__name__] = { strategies_map[strategy_cls.get_name()] = {
"name": strategy_cls.__name__, "name": strategy_cls.get_name(),
"goal_name": strategy_cls.DEFAULT_NAME, "goal_name": strategy_cls.get_goal_name(),
"display_name": strategy_cls.DEFAULT_DESCRIPTION} "display_name": strategy_cls.get_translatable_display_name()}
return discovered_map return discovered_map

View File

@@ -11,60 +11,109 @@
# limitations under the License. # limitations under the License.
from oslo_config import cfg from oslo_config import cfg
from watcher.tests.api import base as api_base from six.moves.urllib import parse as urlparse
CONF = cfg.CONF from watcher.common import utils
from watcher.tests.api import base as api_base
from watcher.tests.objects import utils as obj_utils
class TestListGoal(api_base.FunctionalTest): class TestListGoal(api_base.FunctionalTest):
def setUp(self):
super(TestListGoal, self).setUp()
# Override the default to get enough goals to test limit on query
cfg.CONF.set_override(
"goals", {
"DUMMY_1": "dummy", "DUMMY_2": "dummy",
"DUMMY_3": "dummy", "DUMMY_4": "dummy",
},
group='watcher_goals', enforce_type=True)
def _assert_goal_fields(self, goal): def _assert_goal_fields(self, goal):
goal_fields = ['name', 'strategy'] goal_fields = ['uuid', 'name', 'display_name']
for field in goal_fields: for field in goal_fields:
self.assertIn(field, goal) self.assertIn(field, goal)
def test_one(self): def test_one(self):
goal = obj_utils.create_test_goal(self.context)
response = self.get_json('/goals') response = self.get_json('/goals')
self.assertEqual(goal.uuid, response['goals'][0]["uuid"])
self._assert_goal_fields(response['goals'][0]) self._assert_goal_fields(response['goals'][0])
def test_get_one(self): def test_get_one_by_uuid(self):
goal_name = list(CONF.watcher_goals.goals.keys())[0] goal = obj_utils.create_test_goal(self.context)
response = self.get_json('/goals/%s' % goal_name) response = self.get_json('/goals/%s' % goal.uuid)
self.assertEqual(goal_name, response['name']) self.assertEqual(goal.uuid, response["uuid"])
self.assertEqual(goal.name, response["name"])
self._assert_goal_fields(response) self._assert_goal_fields(response)
def test_get_one_by_name(self):
goal = obj_utils.create_test_goal(self.context)
response = self.get_json(urlparse.quote(
'/goals/%s' % goal['name']))
self.assertEqual(goal.uuid, response['uuid'])
self._assert_goal_fields(response)
def test_get_one_soft_deleted(self):
goal = obj_utils.create_test_goal(self.context)
goal.soft_delete()
response = self.get_json(
'/goals/%s' % goal['uuid'],
headers={'X-Show-Deleted': 'True'})
self.assertEqual(goal.uuid, response['uuid'])
self._assert_goal_fields(response)
response = self.get_json(
'/goals/%s' % goal['uuid'],
expect_errors=True)
self.assertEqual(404, response.status_int)
def test_detail(self): def test_detail(self):
goal_name = list(CONF.watcher_goals.goals.keys())[0] goal = obj_utils.create_test_goal(self.context)
response = self.get_json('/goals/detail') response = self.get_json('/goals/detail')
self.assertEqual(goal_name, response['goals'][0]["name"]) self.assertEqual(goal.uuid, response['goals'][0]["uuid"])
self._assert_goal_fields(response['goals'][0]) self._assert_goal_fields(response['goals'][0])
def test_detail_against_single(self): def test_detail_against_single(self):
goal_name = list(CONF.watcher_goals.goals.keys())[0] goal = obj_utils.create_test_goal(self.context)
response = self.get_json('/goals/%s/detail' % goal_name, response = self.get_json('/goals/%s/detail' % goal.uuid,
expect_errors=True) expect_errors=True)
self.assertEqual(404, response.status_int) self.assertEqual(404, response.status_int)
def test_many(self): def test_many(self):
goal_list = []
for idx in range(1, 6):
goal = obj_utils.create_test_goal(
self.context, id=idx,
uuid=utils.generate_uuid(),
name='GOAL_{0}'.format(idx))
goal_list.append(goal.uuid)
response = self.get_json('/goals') response = self.get_json('/goals')
self.assertEqual(len(CONF.watcher_goals.goals), self.assertTrue(len(response['goals']) > 2)
len(response['goals']))
def test_many_without_soft_deleted(self):
goal_list = []
for id_ in [1, 2, 3]:
goal = obj_utils.create_test_goal(
self.context, id=id_, uuid=utils.generate_uuid(),
name='GOAL_{0}'.format(id_))
goal_list.append(goal.uuid)
for id_ in [4, 5]:
goal = obj_utils.create_test_goal(
self.context, id=id_, uuid=utils.generate_uuid(),
name='GOAL_{0}'.format(id_))
goal.soft_delete()
response = self.get_json('/goals')
self.assertEqual(3, len(response['goals']))
uuids = [s['uuid'] for s in response['goals']]
self.assertEqual(sorted(goal_list), sorted(uuids))
def test_goals_collection_links(self): def test_goals_collection_links(self):
for idx in range(1, 6):
obj_utils.create_test_goal(
self.context, id=idx,
uuid=utils.generate_uuid(),
name='GOAL_{0}'.format(idx))
response = self.get_json('/goals/?limit=2') response = self.get_json('/goals/?limit=2')
self.assertEqual(2, len(response['goals'])) self.assertEqual(2, len(response['goals']))
def test_goals_collection_links_default_limit(self): def test_goals_collection_links_default_limit(self):
for idx in range(1, 6):
obj_utils.create_test_goal(
self.context, id=idx,
uuid=utils.generate_uuid(),
name='GOAL_{0}'.format(idx))
cfg.CONF.set_override('max_limit', 3, 'api', enforce_type=True) cfg.CONF.set_override('max_limit', 3, 'api', enforce_type=True)
response = self.get_json('/goals') response = self.get_json('/goals')
self.assertEqual(3, len(response['goals'])) self.assertEqual(3, len(response['goals']))

View File

@@ -24,6 +24,7 @@ from oslo_config import cfg
from oslo_service import service from oslo_service import service
from watcher.cmd import decisionengine from watcher.cmd import decisionengine
from watcher.decision_engine import sync
from watcher.tests import base from watcher.tests import base
@@ -45,6 +46,7 @@ class TestDecisionEngine(base.BaseTestCase):
super(TestDecisionEngine, self).tearDown() super(TestDecisionEngine, self).tearDown()
self.conf._parse_cli_opts = self._parse_cli_opts self.conf._parse_cli_opts = self._parse_cli_opts
@mock.patch.object(sync.Syncer, "sync", mock.Mock())
@mock.patch.object(service, "launch") @mock.patch.object(service, "launch")
def test_run_de_app(self, m_launch): def test_run_de_app(self, m_launch):
decisionengine.main() decisionengine.main()

View File

@@ -242,17 +242,15 @@ class DbGoalTestCase(base.DbTestCase):
self.assertEqual(uuids.sort(), res_uuids.sort()) self.assertEqual(uuids.sort(), res_uuids.sort())
def test_get_goal_list_with_filters(self): def test_get_goal_list_with_filters(self):
goal1_uuid = w_utils.generate_uuid()
goal2_uuid = w_utils.generate_uuid()
goal1 = self._create_test_goal( goal1 = self._create_test_goal(
id=1, id=1,
uuid=goal1_uuid, uuid=w_utils.generate_uuid(),
name="GOAL_1", name="GOAL_1",
display_name='Goal 1', display_name='Goal 1',
) )
goal2 = self._create_test_goal( goal2 = self._create_test_goal(
id=2, id=2,
uuid=goal2_uuid, uuid=w_utils.generate_uuid(),
name="GOAL_2", name="GOAL_2",
display_name='Goal 2', display_name='Goal 2',
) )

View File

@@ -0,0 +1,80 @@
# -*- encoding: utf-8 -*-
# Copyright (c) 2016 b<>com
#
# 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 watcher.decision_engine.strategy.strategies import base as base_strategy
class FakeStrategy(base_strategy.BaseStrategy):
GOAL_NAME = NotImplemented
GOAL_DISPLAY_NAME = NotImplemented
NAME = NotImplemented
DISPLAY_NAME = NotImplemented
@classmethod
def get_name(cls):
return cls.NAME
@classmethod
def get_display_name(cls):
return cls.DISPLAY_NAME
@classmethod
def get_translatable_display_name(cls):
return cls.DISPLAY_NAME
@classmethod
def get_goal_name(cls):
return cls.GOAL_NAME
@classmethod
def get_goal_display_name(cls):
return cls.GOAL_DISPLAY_NAME
@classmethod
def get_translatable_goal_display_name(cls):
return cls.GOAL_DISPLAY_NAME
def execute(self, original_model):
pass
class FakeDummy1Strategy1(FakeStrategy):
GOAL_NAME = "DUMMY_1"
GOAL_DISPLAY_NAME = "Dummy 1"
NAME = "STRATEGY_1"
DISPLAY_NAME = "Strategy 1"
class FakeDummy1Strategy2(FakeStrategy):
GOAL_NAME = "DUMMY_1"
GOAL_DISPLAY_NAME = "Dummy 1"
NAME = "STRATEGY_2"
DISPLAY_NAME = "Strategy 2"
class FakeDummy2Strategy3(FakeStrategy):
GOAL_NAME = "DUMMY_2"
GOAL_DISPLAY_NAME = "Dummy 2"
NAME = "STRATEGY_3"
DISPLAY_NAME = "Strategy 3"
class FakeDummy2Strategy4(FakeStrategy):
GOAL_NAME = "DUMMY_2"
GOAL_DISPLAY_NAME = "Other Dummy 2"
NAME = "STRATEGY_4"
DISPLAY_NAME = "Strategy 4"

View File

@@ -36,8 +36,7 @@ class SolutionFaker(object):
def build(): def build():
metrics = fake.FakerMetricsCollector() metrics = fake.FakerMetricsCollector()
current_state_cluster = faker_cluster_state.FakerModelCollector() current_state_cluster = faker_cluster_state.FakerModelCollector()
sercon = strategies.BasicConsolidation("basic", sercon = strategies.BasicConsolidation()
"Basic offline consolidation")
sercon.ceilometer = mock.\ sercon.ceilometer = mock.\
MagicMock(get_statistics=metrics.mock_get_statistics) MagicMock(get_statistics=metrics.mock_get_statistics)
return sercon.execute(current_state_cluster.generate_scenario_1()) return sercon.execute(current_state_cluster.generate_scenario_1())
@@ -48,8 +47,7 @@ class SolutionFakerSingleHyp(object):
def build(): def build():
metrics = fake.FakerMetricsCollector() metrics = fake.FakerMetricsCollector()
current_state_cluster = faker_cluster_state.FakerModelCollector() current_state_cluster = faker_cluster_state.FakerModelCollector()
sercon = strategies.BasicConsolidation("basic", sercon = strategies.BasicConsolidation()
"Basic offline consolidation")
sercon.ceilometer = \ sercon.ceilometer = \
mock.MagicMock(get_statistics=metrics.mock_get_statistics) mock.MagicMock(get_statistics=metrics.mock_get_statistics)

View File

@@ -32,11 +32,11 @@ class TestDefaultStrategyLoader(base.TestCase):
exception.LoadingError, self.strategy_loader.load, None) exception.LoadingError, self.strategy_loader.load, None)
def test_load_strategy_is_basic(self): def test_load_strategy_is_basic(self):
exptected_strategy = 'basic' expected_strategy = 'basic'
selected_strategy = self.strategy_loader.load(exptected_strategy) selected_strategy = self.strategy_loader.load(expected_strategy)
self.assertEqual( self.assertEqual(
selected_strategy.name, selected_strategy.id,
exptected_strategy, expected_strategy,
'The default strategy should be basic') 'The default strategy should be basic')
@patch("watcher.common.loader.default.ExtensionManager") @patch("watcher.common.loader.default.ExtensionManager")
@@ -58,8 +58,8 @@ class TestDefaultStrategyLoader(base.TestCase):
strategy_loader = default_loading.DefaultStrategyLoader() strategy_loader = default_loading.DefaultStrategyLoader()
loaded_strategy = strategy_loader.load("dummy") loaded_strategy = strategy_loader.load("dummy")
self.assertEqual("dummy", loaded_strategy.name) self.assertEqual("dummy", loaded_strategy.id)
self.assertEqual("Dummy Strategy", loaded_strategy.description) self.assertEqual("Dummy strategy", loaded_strategy.display_name)
def test_load_dummy_strategy(self): def test_load_dummy_strategy(self):
strategy_loader = default_loading.DefaultStrategyLoader() strategy_loader = default_loading.DefaultStrategyLoader()

View File

@@ -23,14 +23,14 @@ from watcher.tests.decision_engine.strategy.strategies import \
class TestDummyStrategy(base.TestCase): class TestDummyStrategy(base.TestCase):
def test_dummy_strategy(self): def test_dummy_strategy(self):
dummy = strategies.DummyStrategy("dummy", "Dummy strategy") dummy = strategies.DummyStrategy()
fake_cluster = faker_cluster_state.FakerModelCollector() fake_cluster = faker_cluster_state.FakerModelCollector()
model = fake_cluster.generate_scenario_3_with_2_hypervisors() model = fake_cluster.generate_scenario_3_with_2_hypervisors()
solution = dummy.execute(model) solution = dummy.execute(model)
self.assertEqual(3, len(solution.actions)) self.assertEqual(3, len(solution.actions))
def test_check_parameters(self): def test_check_parameters(self):
dummy = strategies.DummyStrategy("dummy", "Dummy strategy") dummy = strategies.DummyStrategy()
fake_cluster = faker_cluster_state.FakerModelCollector() fake_cluster = faker_cluster_state.FakerModelCollector()
model = fake_cluster.generate_scenario_3_with_2_hypervisors() model = fake_cluster.generate_scenario_3_with_2_hypervisors()
solution = dummy.execute(model) solution = dummy.execute(model)

View File

@@ -19,38 +19,10 @@ import mock
from watcher.common import context from watcher.common import context
from watcher.common import utils from watcher.common import utils
from watcher.decision_engine.strategy.loading import default from watcher.decision_engine.strategy.loading import default
from watcher.decision_engine.strategy.strategies import base as base_strategy
from watcher.decision_engine import sync from watcher.decision_engine import sync
from watcher import objects from watcher import objects
from watcher.tests.db import base from watcher.tests.db import base
from watcher.tests.decision_engine import fake_strategies
class FakeStrategy(base_strategy.BaseStrategy):
DEFAULT_NAME = ""
DEFAULT_DESCRIPTION = ""
def execute(self, original_model):
pass
class FakeDummy1Strategy1(FakeStrategy):
DEFAULT_NAME = "DUMMY_1"
DEFAULT_DESCRIPTION = "Dummy 1"
class FakeDummy1Strategy2(FakeStrategy):
DEFAULT_NAME = "DUMMY_1"
DEFAULT_DESCRIPTION = "Dummy 1"
class FakeDummy2Strategy3(FakeStrategy):
DEFAULT_NAME = "DUMMY_2"
DEFAULT_DESCRIPTION = "Dummy 2"
class FakeDummy2Strategy4(FakeStrategy):
DEFAULT_NAME = "DUMMY_2"
DEFAULT_DESCRIPTION = "Other Dummy 2"
class TestSyncer(base.DbTestCase): class TestSyncer(base.DbTestCase):
@@ -60,10 +32,14 @@ class TestSyncer(base.DbTestCase):
self.ctx = context.make_context() self.ctx = context.make_context()
self.m_available_strategies = mock.Mock(return_value={ self.m_available_strategies = mock.Mock(return_value={
FakeDummy1Strategy1.__name__: FakeDummy1Strategy1, fake_strategies.FakeDummy1Strategy1.get_name():
FakeDummy1Strategy2.__name__: FakeDummy1Strategy2, fake_strategies.FakeDummy1Strategy1,
FakeDummy2Strategy3.__name__: FakeDummy2Strategy3, fake_strategies.FakeDummy1Strategy2.get_name():
FakeDummy2Strategy4.__name__: FakeDummy2Strategy4, fake_strategies.FakeDummy1Strategy2,
fake_strategies.FakeDummy2Strategy3.get_name():
fake_strategies.FakeDummy2Strategy3,
fake_strategies.FakeDummy2Strategy4.get_name():
fake_strategies.FakeDummy2Strategy4,
}) })
p_strategies = mock.patch.object( p_strategies = mock.patch.object(
@@ -150,8 +126,8 @@ class TestSyncer(base.DbTestCase):
name="DUMMY_1", display_name="Dummy 1") name="DUMMY_1", display_name="Dummy 1")
] ]
m_s_list.return_value = [ m_s_list.return_value = [
objects.Strategy(self.ctx, id=1, name="FakeDummy1Strategy1", objects.Strategy(self.ctx, id=1, name="STRATEGY_1",
goal_id=1, display_name="Dummy 1") goal_id=1, display_name="Strategy 1")
] ]
self.syncer.sync() self.syncer.sync()
@@ -211,7 +187,7 @@ class TestSyncer(base.DbTestCase):
name="DUMMY_1", display_name="Dummy 1") name="DUMMY_1", display_name="Dummy 1")
] ]
m_s_list.return_value = [ m_s_list.return_value = [
objects.Strategy(self.ctx, id=1, name="FakeDummy1Strategy1", objects.Strategy(self.ctx, id=1, name="STRATEGY_1",
goal_id=1, display_name="original") goal_id=1, display_name="original")
] ]
self.syncer.sync() self.syncer.sync()
@@ -229,7 +205,7 @@ class TestSyncer(base.DbTestCase):
name="DUMMY_1", display_name="Original") name="DUMMY_1", display_name="Original")
goal.create() goal.create()
strategy = objects.Strategy( strategy = objects.Strategy(
self.ctx, id=1, name="FakeDummy1Strategy1", self.ctx, id=1, name="STRATEGY_1",
display_name="Original", goal_id=goal.id) display_name="Original", goal_id=goal.id)
strategy.create() strategy.create()
# audit_template = objects.AuditTemplate( # audit_template = objects.AuditTemplate(
@@ -260,8 +236,7 @@ class TestSyncer(base.DbTestCase):
{"DUMMY_1", "DUMMY_2"}, {"DUMMY_1", "DUMMY_2"},
set([g.name for g in after_goals])) set([g.name for g in after_goals]))
self.assertEqual( self.assertEqual(
{'FakeDummy1Strategy1', 'FakeDummy1Strategy2', {"STRATEGY_1", "STRATEGY_2", "STRATEGY_3", "STRATEGY_4"},
'FakeDummy2Strategy3', 'FakeDummy2Strategy4'},
set([s.name for s in after_strategies])) set([s.name for s in after_strategies]))
created_goals = [ag for ag in after_goals created_goals = [ag for ag in after_goals
if ag.uuid not in [bg.uuid for bg in before_goals]] if ag.uuid not in [bg.uuid for bg in before_goals]]

View File

@@ -135,3 +135,30 @@ def create_test_action(context, **kw):
action = get_test_action(context, **kw) action = get_test_action(context, **kw)
action.create() action.create()
return action return action
def get_test_goal(context, **kw):
"""Return a Goal object with appropriate attributes.
NOTE: The object leaves the attributes marked as changed, such
that a create() could be used to commit it to the DB.
"""
db_goal = db_utils.get_test_goal(**kw)
# Let DB generate ID if it isn't specified explicitly
if 'id' not in kw:
del db_goal['id']
goal = objects.Goal(context)
for key in db_goal:
setattr(goal, key, db_goal[key])
return goal
def create_test_goal(context, **kw):
"""Create and return a test goal object.
Create a goal in the DB and return a Goal object with appropriate
attributes.
"""
goal = get_test_goal(context, **kw)
goal.create()
return goal

View File

@@ -234,7 +234,7 @@ class InfraOptimClientJSON(base.BaseInfraOptimClient):
def show_goal(self, goal): def show_goal(self, goal):
"""Gets a specific goal """Gets a specific goal
:param goal: Name of the goal :param goal: UUID or Name of the goal
:return: Serialized goal as a dictionary :return: Serialized goal as a dictionary
""" """
return self._show_request('/goals', goal) return self._show_request('/goals', goal)

View File

@@ -40,14 +40,14 @@ class TestShowListGoal(base.BaseInfraOptimTest):
_, goal = self.client.show_goal(self.DUMMY_GOAL) _, goal = self.client.show_goal(self.DUMMY_GOAL)
self.assertEqual(self.DUMMY_GOAL, goal['name']) self.assertEqual(self.DUMMY_GOAL, goal['name'])
self.assertEqual("dummy", goal['strategy']) self.assertIn("display_name", goal.keys())
@test.attr(type='smoke') @test.attr(type='smoke')
def test_show_goal_with_links(self): def test_show_goal_with_links(self):
_, goal = self.client.show_goal(self.DUMMY_GOAL) _, goal = self.client.show_goal(self.DUMMY_GOAL)
self.assertIn('links', goal.keys()) self.assertIn('links', goal.keys())
self.assertEqual(2, len(goal['links'])) self.assertEqual(2, len(goal['links']))
self.assertIn(goal['name'], self.assertIn(goal['uuid'],
goal['links'][0]['href']) goal['links'][0]['href'])
@test.attr(type="smoke") @test.attr(type="smoke")
@@ -58,5 +58,5 @@ class TestShowListGoal(base.BaseInfraOptimTest):
# Verify self links. # Verify self links.
for goal in body['goals']: for goal in body['goals']:
self.validate_self_link('goals', goal['name'], self.validate_self_link('goals', goal['uuid'],
goal['links'][0]['href']) goal['links'][0]['href'])