Coverage for watcher/api/controllers/v1/action_plan.py: 86%
296 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-17 12:22 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-17 12:22 +0000
1# -*- encoding: utf-8 -*-
2# Copyright 2013 Red Hat, Inc.
3# All Rights Reserved.
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14# implied.
15# See the License for the specific language governing permissions and
16# limitations under the License.
18"""
19An :ref:`Action Plan <action_plan_definition>` specifies a flow of
20:ref:`Actions <action_definition>` that should be executed in order to satisfy
21a given :ref:`Goal <goal_definition>`. It also contains an estimated
22:ref:`global efficacy <efficacy_definition>` alongside a set of
23:ref:`efficacy indicators <efficacy_indicator_definition>`.
25An :ref:`Action Plan <action_plan_definition>` is generated by Watcher when an
26:ref:`Audit <audit_definition>` is successful which implies that the
27:ref:`Strategy <strategy_definition>`
28which was used has found a :ref:`Solution <solution_definition>` to achieve the
29:ref:`Goal <goal_definition>` of this :ref:`Audit <audit_definition>`.
31In the default implementation of Watcher, an action plan is composed of
32a list of successive :ref:`Actions <action_definition>` (i.e., a Workflow of
33:ref:`Actions <action_definition>` belonging to a unique branch).
35However, Watcher provides abstract interfaces for many of its components,
36allowing other implementations to generate and handle more complex :ref:`Action
37Plan(s) <action_plan_definition>` composed of two types of Action Item(s):
39- simple :ref:`Actions <action_definition>`: atomic tasks, which means it
40 can not be split into smaller tasks or commands from an OpenStack point of
41 view.
42- composite Actions: which are composed of several simple
43 :ref:`Actions <action_definition>`
44 ordered in sequential and/or parallel flows.
46An :ref:`Action Plan <action_plan_definition>` may be described using
47standard workflow model description formats such as
48`Business Process Model and Notation 2.0 (BPMN 2.0)
49<http://www.omg.org/spec/BPMN/2.0/>`_ or `Unified Modeling Language (UML)
50<http://www.uml.org/>`_.
52To see the life-cycle and description of
53:ref:`Action Plan <action_plan_definition>` states, visit :ref:`the Action Plan
54state machine <action_plan_state_machine>`.
55"""
57from http import HTTPStatus
58from oslo_log import log
59from oslo_utils import timeutils
60import pecan
61from pecan import rest
62import wsme
63from wsme import types as wtypes
64import wsmeext.pecan as wsme_pecan
66from watcher._i18n import _
67from watcher.api.controllers import base
68from watcher.api.controllers import link
69from watcher.api.controllers.v1 import collection
70from watcher.api.controllers.v1 import efficacy_indicator as efficacyindicator
71from watcher.api.controllers.v1 import types
72from watcher.api.controllers.v1 import utils as api_utils
73from watcher.applier import rpcapi
74from watcher.common import exception
75from watcher.common import policy
76from watcher.common import utils
77from watcher import objects
78from watcher.objects import action_plan as ap_objects
80LOG = log.getLogger(__name__)
83def hide_fields_in_newer_versions(obj):
84 """This method hides fields that were added in newer API versions.
86 Certain node fields were introduced at certain API versions.
87 These fields are only made available when the request's API version
88 matches or exceeds the versions when these fields were introduced.
89 """
90 pass
93class ActionPlanPatchType(types.JsonPatchType):
95 @staticmethod
96 def _validate_state(patch):
97 serialized_patch = {'path': patch.path, 'op': patch.op}
98 if patch.value is not wtypes.Unset:
99 serialized_patch['value'] = patch.value
100 # todo: use state machines to handle state transitions
101 state_value = patch.value
102 if state_value and not hasattr(ap_objects.State, state_value): 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true
103 msg = _("Invalid state: %(state)s")
104 raise exception.PatchError(
105 patch=serialized_patch, reason=msg % dict(state=state_value))
107 @staticmethod
108 def validate(patch):
109 if patch.path == "/state":
110 ActionPlanPatchType._validate_state(patch)
111 return types.JsonPatchType.validate(patch)
113 @staticmethod
114 def internal_attrs():
115 return types.JsonPatchType.internal_attrs()
117 @staticmethod
118 def mandatory_attrs():
119 return ["audit_id", "state"]
122class ActionPlan(base.APIBase):
123 """API representation of a action plan.
125 This class enforces type checking and value constraints, and converts
126 between the internal object model and the API representation of an
127 action plan.
128 """
130 _audit_uuid = None
131 _strategy_uuid = None
132 _strategy_name = None
133 _efficacy_indicators = None
135 def _get_audit_uuid(self):
136 return self._audit_uuid
138 def _set_audit_uuid(self, value):
139 if value == wtypes.Unset: 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true
140 self._audit_uuid = wtypes.Unset
141 elif value and self._audit_uuid != value: 141 ↛ exitline 141 didn't return from function '_set_audit_uuid' because the condition on line 141 was always true
142 try:
143 audit = objects.Audit.get(pecan.request.context, value)
144 self._audit_uuid = audit.uuid
145 self.audit_id = audit.id
146 except exception.AuditNotFound:
147 self._audit_uuid = None
149 def _get_efficacy_indicators(self):
150 if self._efficacy_indicators is None:
151 self._set_efficacy_indicators(wtypes.Unset)
152 return self._efficacy_indicators
154 def _set_efficacy_indicators(self, value):
155 efficacy_indicators = []
156 if value == wtypes.Unset and not self._efficacy_indicators: 156 ↛ 174line 156 didn't jump to line 174 because the condition on line 156 was always true
157 try:
158 _efficacy_indicators = objects.EfficacyIndicator.list(
159 pecan.request.context,
160 filters={"action_plan_uuid": self.uuid})
162 for indicator in _efficacy_indicators:
163 efficacy_indicator = efficacyindicator.EfficacyIndicator(
164 context=pecan.request.context,
165 name=indicator.name,
166 description=indicator.description,
167 unit=indicator.unit,
168 value=float(indicator.value),
169 )
170 efficacy_indicators.append(efficacy_indicator.as_dict())
171 self._efficacy_indicators = efficacy_indicators
172 except exception.EfficacyIndicatorNotFound as exc:
173 LOG.exception(exc)
174 elif value and self._efficacy_indicators != value:
175 self._efficacy_indicators = value
177 def _get_strategy(self, value):
178 if value == wtypes.Unset: 178 ↛ 179line 178 didn't jump to line 179 because the condition on line 178 was never true
179 return None
180 strategy = None
181 try:
182 if utils.is_uuid_like(value) or utils.is_int_like(value): 182 ↛ 186line 182 didn't jump to line 186 because the condition on line 182 was always true
183 strategy = objects.Strategy.get(
184 pecan.request.context, value)
185 else:
186 strategy = objects.Strategy.get_by_name(
187 pecan.request.context, value)
188 except exception.StrategyNotFound:
189 pass
190 if strategy: 190 ↛ 192line 190 didn't jump to line 192 because the condition on line 190 was always true
191 self.strategy_id = strategy.id
192 return strategy
194 def _get_strategy_uuid(self):
195 return self._strategy_uuid
197 def _set_strategy_uuid(self, value):
198 if value and self._strategy_uuid != value: 198 ↛ exitline 198 didn't return from function '_set_strategy_uuid' because the condition on line 198 was always true
199 self._strategy_uuid = None
200 strategy = self._get_strategy(value)
201 if strategy: 201 ↛ exitline 201 didn't return from function '_set_strategy_uuid' because the condition on line 201 was always true
202 self._strategy_uuid = strategy.uuid
204 def _get_strategy_name(self):
205 return self._strategy_name
207 def _set_strategy_name(self, value):
208 if value and self._strategy_name != value: 208 ↛ exitline 208 didn't return from function '_set_strategy_name' because the condition on line 208 was always true
209 self._strategy_name = None
210 strategy = self._get_strategy(value)
211 if strategy: 211 ↛ exitline 211 didn't return from function '_set_strategy_name' because the condition on line 211 was always true
212 self._strategy_name = strategy.name
214 uuid = wtypes.wsattr(types.uuid, readonly=True)
215 """Unique UUID for this action plan"""
217 audit_uuid = wtypes.wsproperty(types.uuid, _get_audit_uuid,
218 _set_audit_uuid,
219 mandatory=True)
220 """The UUID of the audit this port belongs to"""
222 strategy_uuid = wtypes.wsproperty(
223 wtypes.text, _get_strategy_uuid, _set_strategy_uuid, mandatory=False)
224 """Strategy UUID the action plan refers to"""
226 strategy_name = wtypes.wsproperty(
227 wtypes.text, _get_strategy_name, _set_strategy_name, mandatory=False)
228 """The name of the strategy this action plan refers to"""
230 efficacy_indicators = wtypes.wsproperty(
231 types.jsontype, _get_efficacy_indicators, _set_efficacy_indicators,
232 mandatory=True)
233 """The list of efficacy indicators associated to this action plan"""
235 global_efficacy = wtypes.wsattr(types.jsontype, readonly=True)
236 """The global efficacy of this action plan"""
238 state = wtypes.text
239 """This action plan state"""
241 links = wtypes.wsattr([link.Link], readonly=True)
242 """A list containing a self link and associated action links"""
244 hostname = wtypes.wsattr(wtypes.text, mandatory=False)
245 """Hostname the actionplan is running on"""
247 def __init__(self, **kwargs):
248 super(ActionPlan, self).__init__()
249 self.fields = []
250 fields = list(objects.ActionPlan.fields)
251 for field in fields:
252 # Skip fields we do not expose.
253 if not hasattr(self, field):
254 continue
255 self.fields.append(field)
256 setattr(self, field, kwargs.get(field, wtypes.Unset))
258 self.fields.append('audit_uuid')
259 self.fields.append('efficacy_indicators')
261 setattr(self, 'audit_uuid', kwargs.get('audit_id', wtypes.Unset))
262 fields.append('strategy_uuid')
263 setattr(self, 'strategy_uuid', kwargs.get('strategy_id', wtypes.Unset))
264 fields.append('strategy_name')
265 setattr(self, 'strategy_name', kwargs.get('strategy_id', wtypes.Unset))
267 @staticmethod
268 def _convert_with_links(action_plan, url, expand=True):
269 if not expand:
270 action_plan.unset_fields_except(
271 ['uuid', 'state', 'efficacy_indicators', 'global_efficacy',
272 'updated_at', 'audit_uuid', 'strategy_uuid', 'strategy_name'])
274 action_plan.links = [
275 link.Link.make_link(
276 'self', url,
277 'action_plans', action_plan.uuid),
278 link.Link.make_link(
279 'bookmark', url,
280 'action_plans', action_plan.uuid,
281 bookmark=True)]
282 return action_plan
284 @classmethod
285 def convert_with_links(cls, rpc_action_plan, expand=True):
286 action_plan = ActionPlan(**rpc_action_plan.as_dict())
287 hide_fields_in_newer_versions(action_plan)
288 return cls._convert_with_links(action_plan, pecan.request.host_url,
289 expand)
291 @classmethod
292 def sample(cls, expand=True):
293 sample = cls(uuid='9ef4d84c-41e8-4418-9220-ce55be0436af',
294 state='ONGOING',
295 created_at=timeutils.utcnow(),
296 deleted_at=None,
297 updated_at=timeutils.utcnow())
298 sample._audit_uuid = 'abcee106-14d3-4515-b744-5a26885cf6f6'
299 sample._efficacy_indicators = [{'description': 'Test indicator',
300 'name': 'test_indicator',
301 'unit': '%'}]
302 sample._global_efficacy = {'description': 'Global efficacy',
303 'name': 'test_global_efficacy',
304 'unit': '%'}
305 return cls._convert_with_links(sample, 'http://localhost:9322', expand)
308class ActionPlanCollection(collection.Collection):
309 """API representation of a collection of action_plans."""
311 action_plans = [ActionPlan]
312 """A list containing action_plans objects"""
314 def __init__(self, **kwargs):
315 self._type = 'action_plans'
317 @staticmethod
318 def convert_with_links(rpc_action_plans, limit, url=None, expand=False,
319 **kwargs):
320 ap_collection = ActionPlanCollection()
321 ap_collection.action_plans = [ActionPlan.convert_with_links(
322 p, expand) for p in rpc_action_plans]
323 ap_collection.next = ap_collection.get_next(limit, url=url, **kwargs)
324 return ap_collection
326 @classmethod
327 def sample(cls):
328 sample = cls()
329 sample.action_plans = [ActionPlan.sample(expand=False)]
330 return sample
333class ActionPlansController(rest.RestController):
334 """REST controller for Actions."""
336 def __init__(self):
337 super(ActionPlansController, self).__init__()
338 self.applier_client = rpcapi.ApplierAPI()
340 from_actionsPlans = False
341 """A flag to indicate if the requests to this controller are coming
342 from the top-level resource ActionPlan."""
344 _custom_actions = {
345 'start': ['POST'],
346 'detail': ['GET']
347 }
349 def _get_action_plans_collection(self, marker, limit,
350 sort_key, sort_dir, expand=False,
351 resource_url=None, audit_uuid=None,
352 strategy=None):
353 additional_fields = ['audit_uuid', 'strategy_uuid', 'strategy_name']
355 api_utils.validate_sort_key(
356 sort_key, list(objects.ActionPlan.fields) + additional_fields)
357 limit = api_utils.validate_limit(limit)
358 api_utils.validate_sort_dir(sort_dir)
360 marker_obj = None
361 if marker: 361 ↛ 362line 361 didn't jump to line 362 because the condition on line 361 was never true
362 marker_obj = objects.ActionPlan.get_by_uuid(
363 pecan.request.context, marker)
365 filters = {}
366 if audit_uuid:
367 filters['audit_uuid'] = audit_uuid
369 if strategy: 369 ↛ 370line 369 didn't jump to line 370 because the condition on line 369 was never true
370 if utils.is_uuid_like(strategy):
371 filters['strategy_uuid'] = strategy
372 else:
373 filters['strategy_name'] = strategy
375 need_api_sort = api_utils.check_need_api_sort(sort_key,
376 additional_fields)
377 sort_db_key = (sort_key if not need_api_sort
378 else None)
380 action_plans = objects.ActionPlan.list(
381 pecan.request.context,
382 limit,
383 marker_obj, sort_key=sort_db_key,
384 sort_dir=sort_dir, filters=filters)
386 action_plans_collection = ActionPlanCollection.convert_with_links(
387 action_plans, limit, url=resource_url, expand=expand,
388 sort_key=sort_key, sort_dir=sort_dir)
390 if need_api_sort:
391 api_utils.make_api_sort(action_plans_collection.action_plans,
392 sort_key, sort_dir)
394 return action_plans_collection
396 @wsme_pecan.wsexpose(ActionPlanCollection, types.uuid, int, wtypes.text,
397 wtypes.text, types.uuid, wtypes.text)
398 def get_all(self, marker=None, limit=None,
399 sort_key='id', sort_dir='asc', audit_uuid=None, strategy=None):
400 """Retrieve a list of action plans.
402 :param marker: pagination marker for large data sets.
403 :param limit: maximum number of resources to return in a single result.
404 :param sort_key: column to sort results by. Default: id.
405 :param sort_dir: direction to sort. "asc" or "desc". Default: asc.
406 :param audit_uuid: Optional UUID of an audit, to get only actions
407 for that audit.
408 :param strategy: strategy UUID or name to filter by
409 """
410 context = pecan.request.context
411 policy.enforce(context, 'action_plan:get_all',
412 action='action_plan:get_all')
414 return self._get_action_plans_collection(
415 marker, limit, sort_key, sort_dir,
416 audit_uuid=audit_uuid, strategy=strategy)
418 @wsme_pecan.wsexpose(ActionPlanCollection, types.uuid, int, wtypes.text,
419 wtypes.text, types.uuid, wtypes.text)
420 def detail(self, marker=None, limit=None,
421 sort_key='id', sort_dir='asc', audit_uuid=None, strategy=None):
422 """Retrieve a list of action_plans with detail.
424 :param marker: pagination marker for large data sets.
425 :param limit: maximum number of resources to return in a single result.
426 :param sort_key: column to sort results by. Default: id.
427 :param sort_dir: direction to sort. "asc" or "desc". Default: asc.
428 :param audit_uuid: Optional UUID of an audit, to get only actions
429 for that audit.
430 :param strategy: strategy UUID or name to filter by
431 """
432 context = pecan.request.context
433 policy.enforce(context, 'action_plan:detail',
434 action='action_plan:detail')
436 # NOTE(lucasagomes): /detail should only work against collections
437 parent = pecan.request.path.split('/')[:-1][-1]
438 if parent != "action_plans": 438 ↛ 439line 438 didn't jump to line 439 because the condition on line 438 was never true
439 raise exception.HTTPNotFound
441 expand = True
442 resource_url = '/'.join(['action_plans', 'detail'])
443 return self._get_action_plans_collection(
444 marker, limit, sort_key, sort_dir, expand,
445 resource_url, audit_uuid=audit_uuid, strategy=strategy)
447 @wsme_pecan.wsexpose(ActionPlan, types.uuid)
448 def get_one(self, action_plan_uuid):
449 """Retrieve information about the given action plan.
451 :param action_plan_uuid: UUID of a action plan.
452 """
453 if self.from_actionsPlans: 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true
454 raise exception.OperationNotPermitted
456 context = pecan.request.context
457 action_plan = api_utils.get_resource('ActionPlan', action_plan_uuid)
458 policy.enforce(
459 context, 'action_plan:get', action_plan, action='action_plan:get')
461 return ActionPlan.convert_with_links(action_plan)
463 @wsme_pecan.wsexpose(None, types.uuid, status_code=HTTPStatus.NO_CONTENT)
464 def delete(self, action_plan_uuid):
465 """Delete an action plan.
467 :param action_plan_uuid: UUID of a action.
468 """
469 context = pecan.request.context
470 action_plan = api_utils.get_resource(
471 'ActionPlan', action_plan_uuid, eager=True)
472 policy.enforce(context, 'action_plan:delete', action_plan,
473 action='action_plan:delete')
475 allowed_states = (ap_objects.State.SUCCEEDED,
476 ap_objects.State.RECOMMENDED,
477 ap_objects.State.FAILED,
478 ap_objects.State.SUPERSEDED,
479 ap_objects.State.CANCELLED)
480 if action_plan.state not in allowed_states:
481 raise exception.DeleteError(
482 state=action_plan.state)
484 action_plan.soft_delete()
486 @wsme.validate(types.uuid, [ActionPlanPatchType])
487 @wsme_pecan.wsexpose(ActionPlan, types.uuid,
488 body=[ActionPlanPatchType])
489 def patch(self, action_plan_uuid, patch):
490 """Update an existing action plan.
492 :param action_plan_uuid: UUID of a action plan.
493 :param patch: a json PATCH document to apply to this action plan.
494 """
495 if self.from_actionsPlans: 495 ↛ 496line 495 didn't jump to line 496 because the condition on line 495 was never true
496 raise exception.OperationNotPermitted
498 context = pecan.request.context
499 action_plan_to_update = api_utils.get_resource(
500 'ActionPlan', action_plan_uuid, eager=True)
501 policy.enforce(context, 'action_plan:update', action_plan_to_update,
502 action='action_plan:update')
504 try:
505 action_plan_dict = action_plan_to_update.as_dict()
506 action_plan = ActionPlan(**api_utils.apply_jsonpatch(
507 action_plan_dict, patch))
508 except api_utils.JSONPATCH_EXCEPTIONS as e:
509 raise exception.PatchError(patch=patch, reason=e)
511 launch_action_plan = False
512 cancel_action_plan = False
514 # transitions that are allowed via PATCH
515 allowed_patch_transitions = [
516 (ap_objects.State.RECOMMENDED,
517 ap_objects.State.PENDING),
518 (ap_objects.State.RECOMMENDED,
519 ap_objects.State.CANCELLED),
520 (ap_objects.State.ONGOING,
521 ap_objects.State.CANCELLING),
522 (ap_objects.State.PENDING,
523 ap_objects.State.CANCELLED),
524 ]
526 # todo: improve this in blueprint watcher-api-validation
527 if hasattr(action_plan, 'state'): 527 ↛ 544line 527 didn't jump to line 544 because the condition on line 527 was always true
528 transition = (action_plan_to_update.state, action_plan.state)
529 if transition not in allowed_patch_transitions:
530 error_message = _("State transition not allowed: "
531 "(%(initial_state)s -> %(new_state)s)")
532 raise exception.PatchError(
533 patch=patch,
534 reason=error_message % dict(
535 initial_state=action_plan_to_update.state,
536 new_state=action_plan.state))
538 if action_plan.state == ap_objects.State.PENDING:
539 launch_action_plan = True
540 if action_plan.state == ap_objects.State.CANCELLED:
541 cancel_action_plan = True
543 # Update only the fields that have changed
544 for field in objects.ActionPlan.fields:
545 try:
546 patch_val = getattr(action_plan, field)
547 except AttributeError:
548 # Ignore fields that aren't exposed in the API
549 continue
550 if patch_val == wtypes.Unset: 550 ↛ 551line 550 didn't jump to line 551 because the condition on line 550 was never true
551 patch_val = None
552 if action_plan_to_update[field] != patch_val:
553 action_plan_to_update[field] = patch_val
555 if (field == 'state' and
556 patch_val == objects.action_plan.State.PENDING):
557 launch_action_plan = True
559 action_plan_to_update.save()
561 # NOTE: if action plan is cancelled from pending or recommended
562 # state update action state here only
563 if cancel_action_plan:
564 filters = {'action_plan_uuid': action_plan.uuid}
565 actions = objects.Action.list(pecan.request.context,
566 filters=filters, eager=True)
567 for a in actions: 567 ↛ 568line 567 didn't jump to line 568 because the loop on line 567 never started
568 a.state = objects.action.State.CANCELLED
569 a.save()
571 if launch_action_plan:
572 self.applier_client.launch_action_plan(pecan.request.context,
573 action_plan.uuid)
575 action_plan_to_update = objects.ActionPlan.get_by_uuid(
576 pecan.request.context,
577 action_plan_uuid)
578 return ActionPlan.convert_with_links(action_plan_to_update)
580 @wsme_pecan.wsexpose(ActionPlan, types.uuid)
581 def start(self, action_plan_uuid, **kwargs):
582 """Start an action_plan
584 :param action_plan_uuid: UUID of an action_plan.
585 """
587 action_plan_to_start = api_utils.get_resource(
588 'ActionPlan', action_plan_uuid, eager=True)
589 context = pecan.request.context
591 policy.enforce(context, 'action_plan:start', action_plan_to_start,
592 action='action_plan:start')
594 if action_plan_to_start['state'] != \ 594 ↛ 596line 594 didn't jump to line 596 because the condition on line 594 was never true
595 objects.action_plan.State.RECOMMENDED:
596 raise exception.StartError(
597 state=action_plan_to_start.state)
599 action_plan_to_start['state'] = objects.action_plan.State.PENDING
600 action_plan_to_start.save()
602 self.applier_client.launch_action_plan(pecan.request.context,
603 action_plan_uuid)
604 action_plan_to_start = objects.ActionPlan.get_by_uuid(
605 pecan.request.context, action_plan_uuid)
607 return ActionPlan.convert_with_links(action_plan_to_start)