Coverage for watcher/api/controllers/v1/action.py: 92%
166 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 <action_definition>` is what enables Watcher to transform the
20current state of a :ref:`Cluster <cluster_definition>` after an
21:ref:`Audit <audit_definition>`.
23An :ref:`Action <action_definition>` is an atomic task which changes the
24current state of a target :ref:`Managed resource <managed_resource_definition>`
25of the OpenStack :ref:`Cluster <cluster_definition>` such as:
27- Live migration of an instance from one compute node to another compute
28 node with Nova
29- Changing the power level of a compute node (ACPI level, ...)
30- Changing the current state of a compute node (enable or disable) with Nova
32In most cases, an :ref:`Action <action_definition>` triggers some concrete
33commands on an existing OpenStack module (Nova, Neutron, Cinder, Ironic, etc.).
35An :ref:`Action <action_definition>` has a life-cycle and its current state may
36be one of the following:
38- **PENDING** : the :ref:`Action <action_definition>` has not been executed
39 yet by the :ref:`Watcher Applier <watcher_applier_definition>`
40- **ONGOING** : the :ref:`Action <action_definition>` is currently being
41 processed by the :ref:`Watcher Applier <watcher_applier_definition>`
42- **SUCCEEDED** : the :ref:`Action <action_definition>` has been executed
43 successfully
44- **FAILED** : an error occurred while trying to execute the
45 :ref:`Action <action_definition>`
46- **DELETED** : the :ref:`Action <action_definition>` is still stored in the
47 :ref:`Watcher database <watcher_database_definition>` but is not returned
48 any more through the Watcher APIs.
49- **CANCELLED** : the :ref:`Action <action_definition>` was in **PENDING** or
50 **ONGOING** state and was cancelled by the
51 :ref:`Administrator <administrator_definition>`
53:ref:`Some default implementations are provided <watcher_planners>`, but it is
54possible to :ref:`develop new implementations <implement_action_plugin>` which
55are dynamically loaded by Watcher at launch time.
56"""
58from http import HTTPStatus
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 types
71from watcher.api.controllers.v1 import utils as api_utils
72from watcher.common import exception
73from watcher.common import policy
74from watcher import objects
77def hide_fields_in_newer_versions(obj):
78 """This method hides fields that were added in newer API versions.
80 Certain node fields were introduced at certain API versions.
81 These fields are only made available when the request's API version
82 matches or exceeds the versions when these fields were introduced.
83 """
84 pass
87class ActionPatchType(types.JsonPatchType):
89 @staticmethod
90 def mandatory_attrs():
91 return []
94class Action(base.APIBase):
95 """API representation of a action.
97 This class enforces type checking and value constraints, and converts
98 between the internal object model and the API representation of a action.
99 """
100 _action_plan_uuid = None
102 def _get_action_plan_uuid(self):
103 return self._action_plan_uuid
105 def _set_action_plan_uuid(self, value):
106 if value == wtypes.Unset:
107 self._action_plan_uuid = wtypes.Unset
108 elif value and self._action_plan_uuid != value:
109 try:
110 action_plan = objects.ActionPlan.get(
111 pecan.request.context, value)
112 self._action_plan_uuid = action_plan.uuid
113 self.action_plan_id = action_plan.id
114 except exception.ActionPlanNotFound:
115 self._action_plan_uuid = None
117 uuid = wtypes.wsattr(types.uuid, readonly=True)
118 """Unique UUID for this action"""
120 action_plan_uuid = wtypes.wsproperty(types.uuid, _get_action_plan_uuid,
121 _set_action_plan_uuid,
122 mandatory=True)
123 """The action plan this action belongs to """
125 state = wtypes.text
126 """This audit state"""
128 action_type = wtypes.text
129 """Action type"""
131 description = wtypes.text
132 """Action description"""
134 input_parameters = types.jsontype
135 """One or more key/value pairs """
137 parents = wtypes.wsattr(types.jsontype, readonly=True)
138 """UUIDs of parent actions"""
140 links = wtypes.wsattr([link.Link], readonly=True)
141 """A list containing a self link and associated action links"""
143 def __init__(self, **kwargs):
144 super(Action, self).__init__()
146 self.fields = []
147 fields = list(objects.Action.fields)
148 fields.append('action_plan_uuid')
149 for field in fields:
150 # Skip fields we do not expose.
151 if not hasattr(self, field):
152 continue
153 self.fields.append(field)
154 setattr(self, field, kwargs.get(field, wtypes.Unset))
156 self.fields.append('action_plan_id')
157 self.fields.append('description')
158 setattr(self, 'action_plan_uuid', kwargs.get('action_plan_id',
159 wtypes.Unset))
161 @staticmethod
162 def _convert_with_links(action, url, expand=True):
163 if not expand:
164 action.unset_fields_except(['uuid', 'state', 'action_plan_uuid',
165 'action_plan_id', 'action_type',
166 'parents'])
168 action.links = [link.Link.make_link('self', url,
169 'actions', action.uuid),
170 link.Link.make_link('bookmark', url,
171 'actions', action.uuid,
172 bookmark=True)
173 ]
174 return action
176 @classmethod
177 def convert_with_links(cls, action, expand=True):
178 action = Action(**action.as_dict())
179 try:
180 obj_action_desc = objects.ActionDescription.get_by_type(
181 pecan.request.context, action.action_type)
182 description = obj_action_desc.description
183 except exception.ActionDescriptionNotFound:
184 description = ""
185 setattr(action, 'description', description)
187 hide_fields_in_newer_versions(action)
189 return cls._convert_with_links(action, pecan.request.host_url, expand)
191 @classmethod
192 def sample(cls, expand=True):
193 sample = cls(uuid='27e3153e-d5bf-4b7e-b517-fb518e17f34c',
194 description='action description',
195 state='PENDING',
196 created_at=timeutils.utcnow(),
197 deleted_at=None,
198 updated_at=timeutils.utcnow(),
199 parents=[])
200 sample._action_plan_uuid = '7ae81bb3-dec3-4289-8d6c-da80bd8001ae'
201 return cls._convert_with_links(sample, 'http://localhost:9322', expand)
204class ActionCollection(collection.Collection):
205 """API representation of a collection of actions."""
207 actions = [Action]
208 """A list containing actions objects"""
210 def __init__(self, **kwargs):
211 self._type = 'actions'
213 @staticmethod
214 def convert_with_links(actions, limit, url=None, expand=False,
215 **kwargs):
217 collection = ActionCollection()
218 collection.actions = [Action.convert_with_links(p, expand)
219 for p in actions]
220 collection.next = collection.get_next(limit, url=url, **kwargs)
221 return collection
223 @classmethod
224 def sample(cls):
225 sample = cls()
226 sample.actions = [Action.sample(expand=False)]
227 return sample
230class ActionsController(rest.RestController):
231 """REST controller for Actions."""
233 def __init__(self):
234 super(ActionsController, self).__init__()
236 from_actions = False
237 """A flag to indicate if the requests to this controller are coming
238 from the top-level resource Actions."""
240 _custom_actions = {
241 'detail': ['GET'],
242 }
244 def _get_actions_collection(self, marker, limit,
245 sort_key, sort_dir, expand=False,
246 resource_url=None,
247 action_plan_uuid=None, audit_uuid=None):
248 additional_fields = ['action_plan_uuid']
250 api_utils.validate_sort_key(sort_key, list(objects.Action.fields) +
251 additional_fields)
252 limit = api_utils.validate_limit(limit)
253 api_utils.validate_sort_dir(sort_dir)
255 marker_obj = None
256 if marker: 256 ↛ 257line 256 didn't jump to line 257 because the condition on line 256 was never true
257 marker_obj = objects.Action.get_by_uuid(pecan.request.context,
258 marker)
260 filters = {}
261 if action_plan_uuid:
262 filters['action_plan_uuid'] = action_plan_uuid
264 if audit_uuid:
265 filters['audit_uuid'] = audit_uuid
267 need_api_sort = api_utils.check_need_api_sort(sort_key,
268 additional_fields)
269 sort_db_key = (sort_key if not need_api_sort
270 else None)
272 actions = objects.Action.list(pecan.request.context,
273 limit,
274 marker_obj, sort_key=sort_db_key,
275 sort_dir=sort_dir,
276 filters=filters)
278 actions_collection = ActionCollection.convert_with_links(
279 actions, limit, url=resource_url, expand=expand,
280 sort_key=sort_key, sort_dir=sort_dir)
282 if need_api_sort:
283 api_utils.make_api_sort(actions_collection.actions,
284 sort_key, sort_dir)
286 return actions_collection
288 @wsme_pecan.wsexpose(ActionCollection, types.uuid, int,
289 wtypes.text, wtypes.text, types.uuid,
290 types.uuid)
291 def get_all(self, marker=None, limit=None,
292 sort_key='id', sort_dir='asc', action_plan_uuid=None,
293 audit_uuid=None):
294 """Retrieve a list of actions.
296 :param marker: pagination marker for large data sets.
297 :param limit: maximum number of resources to return in a single result.
298 :param sort_key: column to sort results by. Default: id.
299 :param sort_dir: direction to sort. "asc" or "desc". Default: asc.
300 :param action_plan_uuid: Optional UUID of an action plan,
301 to get only actions for that action plan.
302 :param audit_uuid: Optional UUID of an audit,
303 to get only actions for that audit.
304 """
305 context = pecan.request.context
306 policy.enforce(context, 'action:get_all',
307 action='action:get_all')
309 if action_plan_uuid and audit_uuid:
310 raise exception.ActionFilterCombinationProhibited
312 return self._get_actions_collection(
313 marker, limit, sort_key, sort_dir,
314 action_plan_uuid=action_plan_uuid, audit_uuid=audit_uuid)
316 @wsme_pecan.wsexpose(ActionCollection, types.uuid, int,
317 wtypes.text, wtypes.text, types.uuid,
318 types.uuid)
319 def detail(self, marker=None, limit=None,
320 sort_key='id', sort_dir='asc', action_plan_uuid=None,
321 audit_uuid=None):
322 """Retrieve a list of actions with detail.
324 :param marker: pagination marker for large data sets.
325 :param limit: maximum number of resources to return in a single result.
326 :param sort_key: column to sort results by. Default: id.
327 :param sort_dir: direction to sort. "asc" or "desc". Default: asc.
328 :param action_plan_uuid: Optional UUID of an action plan,
329 to get only actions for that action plan.
330 :param audit_uuid: Optional UUID of an audit,
331 to get only actions for that audit.
332 """
333 context = pecan.request.context
334 policy.enforce(context, 'action:detail',
335 action='action:detail')
337 # NOTE(lucasagomes): /detail should only work against collections
338 parent = pecan.request.path.split('/')[:-1][-1]
339 if parent != "actions":
340 raise exception.HTTPNotFound
342 if action_plan_uuid and audit_uuid: 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true
343 raise exception.ActionFilterCombinationProhibited
345 expand = True
346 resource_url = '/'.join(['actions', 'detail'])
347 return self._get_actions_collection(
348 marker, limit, sort_key, sort_dir, expand, resource_url,
349 action_plan_uuid=action_plan_uuid, audit_uuid=audit_uuid)
351 @wsme_pecan.wsexpose(Action, types.uuid)
352 def get_one(self, action_uuid):
353 """Retrieve information about the given action.
355 :param action_uuid: UUID of a action.
356 """
357 if self.from_actions: 357 ↛ 358line 357 didn't jump to line 358 because the condition on line 357 was never true
358 raise exception.OperationNotPermitted
360 context = pecan.request.context
361 action = api_utils.get_resource('Action', action_uuid)
362 policy.enforce(context, 'action:get', action, action='action:get')
364 return Action.convert_with_links(action)
366 @wsme_pecan.wsexpose(Action, body=Action, status_code=HTTPStatus.CREATED)
367 def post(self, action):
368 """Create a new action(forbidden).
370 :param action: a action within the request body.
371 """
372 # FIXME: blueprint edit-action-plan-flow
373 raise exception.OperationNotPermitted(
374 _("Cannot create an action directly"))
376 if self.from_actions:
377 raise exception.OperationNotPermitted
379 action_dict = action.as_dict()
380 context = pecan.request.context
381 new_action = objects.Action(context, **action_dict)
382 new_action.create()
384 # Set the HTTP Location Header
385 pecan.response.location = link.build_url('actions', new_action.uuid)
386 return Action.convert_with_links(new_action)
388 @wsme.validate(types.uuid, [ActionPatchType])
389 @wsme_pecan.wsexpose(Action, types.uuid, body=[ActionPatchType])
390 def patch(self, action_uuid, patch):
391 """Update an existing action(forbidden).
393 :param action_uuid: UUID of a action.
394 :param patch: a json PATCH document to apply to this action.
395 """
396 # FIXME: blueprint edit-action-plan-flow
397 raise exception.OperationNotPermitted(
398 _("Cannot modify an action directly"))
400 if self.from_actions:
401 raise exception.OperationNotPermitted
403 action_to_update = objects.Action.get_by_uuid(pecan.request.context,
404 action_uuid)
405 try:
406 action_dict = action_to_update.as_dict()
407 action = Action(**api_utils.apply_jsonpatch(action_dict, patch))
408 except api_utils.JSONPATCH_EXCEPTIONS as e:
409 raise exception.PatchError(patch=patch, reason=e)
411 # Update only the fields that have changed
412 for field in objects.Action.fields:
413 try:
414 patch_val = getattr(action, field)
415 except AttributeError:
416 # Ignore fields that aren't exposed in the API
417 continue
418 if patch_val == wtypes.Unset:
419 patch_val = None
420 if action_to_update[field] != patch_val:
421 action_to_update[field] = patch_val
423 action_to_update.save()
424 return Action.convert_with_links(action_to_update)
426 @wsme_pecan.wsexpose(None, types.uuid, status_code=HTTPStatus.NO_CONTENT)
427 def delete(self, action_uuid):
428 """Delete a action(forbidden).
430 :param action_uuid: UUID of a action.
431 """
432 # FIXME: blueprint edit-action-plan-flow
433 raise exception.OperationNotPermitted(
434 _("Cannot delete an action directly"))
436 action_to_delete = objects.Action.get_by_uuid(
437 pecan.request.context,
438 action_uuid)
439 action_to_delete.soft_delete()