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

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. 

17 

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>`. 

22 

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: 

26 

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 

31 

32In most cases, an :ref:`Action <action_definition>` triggers some concrete 

33commands on an existing OpenStack module (Nova, Neutron, Cinder, Ironic, etc.). 

34 

35An :ref:`Action <action_definition>` has a life-cycle and its current state may 

36be one of the following: 

37 

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>` 

52 

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""" 

57 

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 

65 

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 

75 

76 

77def hide_fields_in_newer_versions(obj): 

78 """This method hides fields that were added in newer API versions. 

79 

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 

85 

86 

87class ActionPatchType(types.JsonPatchType): 

88 

89 @staticmethod 

90 def mandatory_attrs(): 

91 return [] 

92 

93 

94class Action(base.APIBase): 

95 """API representation of a action. 

96 

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 

101 

102 def _get_action_plan_uuid(self): 

103 return self._action_plan_uuid 

104 

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 

116 

117 uuid = wtypes.wsattr(types.uuid, readonly=True) 

118 """Unique UUID for this action""" 

119 

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 """ 

124 

125 state = wtypes.text 

126 """This audit state""" 

127 

128 action_type = wtypes.text 

129 """Action type""" 

130 

131 description = wtypes.text 

132 """Action description""" 

133 

134 input_parameters = types.jsontype 

135 """One or more key/value pairs """ 

136 

137 parents = wtypes.wsattr(types.jsontype, readonly=True) 

138 """UUIDs of parent actions""" 

139 

140 links = wtypes.wsattr([link.Link], readonly=True) 

141 """A list containing a self link and associated action links""" 

142 

143 def __init__(self, **kwargs): 

144 super(Action, self).__init__() 

145 

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)) 

155 

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)) 

160 

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']) 

167 

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 

175 

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) 

186 

187 hide_fields_in_newer_versions(action) 

188 

189 return cls._convert_with_links(action, pecan.request.host_url, expand) 

190 

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) 

202 

203 

204class ActionCollection(collection.Collection): 

205 """API representation of a collection of actions.""" 

206 

207 actions = [Action] 

208 """A list containing actions objects""" 

209 

210 def __init__(self, **kwargs): 

211 self._type = 'actions' 

212 

213 @staticmethod 

214 def convert_with_links(actions, limit, url=None, expand=False, 

215 **kwargs): 

216 

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 

222 

223 @classmethod 

224 def sample(cls): 

225 sample = cls() 

226 sample.actions = [Action.sample(expand=False)] 

227 return sample 

228 

229 

230class ActionsController(rest.RestController): 

231 """REST controller for Actions.""" 

232 

233 def __init__(self): 

234 super(ActionsController, self).__init__() 

235 

236 from_actions = False 

237 """A flag to indicate if the requests to this controller are coming 

238 from the top-level resource Actions.""" 

239 

240 _custom_actions = { 

241 'detail': ['GET'], 

242 } 

243 

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'] 

249 

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) 

254 

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) 

259 

260 filters = {} 

261 if action_plan_uuid: 

262 filters['action_plan_uuid'] = action_plan_uuid 

263 

264 if audit_uuid: 

265 filters['audit_uuid'] = audit_uuid 

266 

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) 

271 

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) 

277 

278 actions_collection = ActionCollection.convert_with_links( 

279 actions, limit, url=resource_url, expand=expand, 

280 sort_key=sort_key, sort_dir=sort_dir) 

281 

282 if need_api_sort: 

283 api_utils.make_api_sort(actions_collection.actions, 

284 sort_key, sort_dir) 

285 

286 return actions_collection 

287 

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. 

295 

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') 

308 

309 if action_plan_uuid and audit_uuid: 

310 raise exception.ActionFilterCombinationProhibited 

311 

312 return self._get_actions_collection( 

313 marker, limit, sort_key, sort_dir, 

314 action_plan_uuid=action_plan_uuid, audit_uuid=audit_uuid) 

315 

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. 

323 

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') 

336 

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 

341 

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 

344 

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) 

350 

351 @wsme_pecan.wsexpose(Action, types.uuid) 

352 def get_one(self, action_uuid): 

353 """Retrieve information about the given action. 

354 

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 

359 

360 context = pecan.request.context 

361 action = api_utils.get_resource('Action', action_uuid) 

362 policy.enforce(context, 'action:get', action, action='action:get') 

363 

364 return Action.convert_with_links(action) 

365 

366 @wsme_pecan.wsexpose(Action, body=Action, status_code=HTTPStatus.CREATED) 

367 def post(self, action): 

368 """Create a new action(forbidden). 

369 

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")) 

375 

376 if self.from_actions: 

377 raise exception.OperationNotPermitted 

378 

379 action_dict = action.as_dict() 

380 context = pecan.request.context 

381 new_action = objects.Action(context, **action_dict) 

382 new_action.create() 

383 

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) 

387 

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). 

392 

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")) 

399 

400 if self.from_actions: 

401 raise exception.OperationNotPermitted 

402 

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) 

410 

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 

422 

423 action_to_update.save() 

424 return Action.convert_with_links(action_to_update) 

425 

426 @wsme_pecan.wsexpose(None, types.uuid, status_code=HTTPStatus.NO_CONTENT) 

427 def delete(self, action_uuid): 

428 """Delete a action(forbidden). 

429 

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")) 

435 

436 action_to_delete = objects.Action.get_by_uuid( 

437 pecan.request.context, 

438 action_uuid) 

439 action_to_delete.soft_delete()