Coverage for watcher/db/purge.py: 91%

246 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-06-17 12:22 +0000

1# -*- encoding: utf-8 -*- 

2# Copyright (c) 2016 b<>com 

3# 

4# Licensed under the Apache License, Version 2.0 (the "License"); 

5# you may not use this file except in compliance with the License. 

6# You may obtain a copy of the License at 

7# 

8# http://www.apache.org/licenses/LICENSE-2.0 

9# 

10# Unless required by applicable law or agreed to in writing, software 

11# distributed under the License is distributed on an "AS IS" BASIS, 

12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 

13# implied. 

14# See the License for the specific language governing permissions and 

15# limitations under the License. 

16# 

17 

18import collections 

19import datetime 

20import itertools 

21import sys 

22 

23from oslo_log import log 

24from oslo_utils import strutils 

25import prettytable as ptable 

26 

27from watcher._i18n import _ 

28from watcher._i18n import lazy_translation_enabled 

29from watcher.common import context 

30from watcher.common import exception 

31from watcher.common import utils 

32from watcher import objects 

33 

34LOG = log.getLogger(__name__) 

35 

36 

37class WatcherObjectsMap(object): 

38 """Wrapper to deal with watcher objects per type 

39 

40 This wrapper object contains a list of watcher objects per type. 

41 Its main use is to simplify the merge of watcher objects by avoiding 

42 duplicates, but also for representing the relationships between these 

43 objects. 

44 """ 

45 

46 # This is for generating the .pot translations 

47 keymap = collections.OrderedDict([ 

48 ("goals", _("Goals")), 

49 ("strategies", _("Strategies")), 

50 ("audit_templates", _("Audit Templates")), 

51 ("audits", _("Audits")), 

52 ("action_plans", _("Action Plans")), 

53 ("actions", _("Actions")), 

54 ]) 

55 

56 def __init__(self): 

57 for attr_name in self.keys(): 

58 setattr(self, attr_name, []) 

59 

60 def values(self): 

61 return (getattr(self, key) for key in self.keys()) 

62 

63 @classmethod 

64 def keys(cls): 

65 return cls.keymap.keys() 

66 

67 def __iter__(self): 

68 return itertools.chain(*self.values()) 

69 

70 def __add__(self, other): 

71 new_map = self.__class__() 

72 

73 # Merge the 2 items dicts into a new object (and avoid dupes) 

74 for attr_name, initials, others in zip(self.keys(), self.values(), 

75 other.values()): 

76 # Creates a copy 

77 merged = initials[:] 

78 initials_ids = [item.id for item in initials] 

79 non_dupes = [item for item in others 

80 if item.id not in initials_ids] 

81 merged += non_dupes 

82 

83 setattr(new_map, attr_name, merged) 

84 

85 return new_map 

86 

87 def __str__(self): 

88 out = "" 

89 for key, vals in zip(self.keys(), self.values()): 

90 ids = [val.id for val in vals] 

91 out += "%(key)s: %(val)s" % (dict(key=key, val=ids)) 

92 out += "\n" 

93 return out 

94 

95 def __len__(self): 

96 return sum(len(getattr(self, key)) for key in self.keys()) 

97 

98 def get_count_table(self): 

99 headers = list(self.keymap.values()) 

100 headers.append(_("Total")) # We also add a total count 

101 translated_headers = [ 

102 h.translate() if lazy_translation_enabled() else h 

103 for h in headers 

104 ] 

105 

106 counters = [len(cat_vals) for cat_vals in self.values()] + [len(self)] 

107 table = ptable.PrettyTable(field_names=translated_headers) 

108 table.add_row(counters) 

109 return table.get_string() 

110 

111 

112class PurgeCommand(object): 

113 """Purges the DB by removing soft deleted entries 

114 

115 The workflow for this purge is the following: 

116 

117 # Find soft deleted objects which are expired 

118 # Find orphan objects 

119 # Find their related objects whether they are expired or not 

120 # Merge them together 

121 # If it does not exceed the limit, destroy them all 

122 """ 

123 

124 ctx = context.make_context(show_deleted=True) 

125 

126 def __init__(self, age_in_days=None, max_number=None, 

127 uuid=None, exclude_orphans=False, dry_run=None): 

128 self.age_in_days = age_in_days 

129 self.max_number = max_number 

130 self.uuid = uuid 

131 self.exclude_orphans = exclude_orphans 

132 self.dry_run = dry_run 

133 

134 self._delete_up_to_max = None 

135 self._objects_map = WatcherObjectsMap() 

136 

137 def get_expiry_date(self): 

138 if not self.age_in_days: 

139 return None 

140 today = datetime.datetime.today() 

141 expiry_date = today - datetime.timedelta(days=self.age_in_days) 

142 return expiry_date 

143 

144 @classmethod 

145 def get_goal_uuid(cls, uuid_or_name): 

146 if uuid_or_name is None: 

147 return 

148 

149 query_func = None 

150 if not utils.is_uuid_like(uuid_or_name): 

151 query_func = objects.Goal.get_by_name 

152 else: 

153 query_func = objects.Goal.get_by_uuid 

154 

155 try: 

156 goal = query_func(cls.ctx, uuid_or_name) 

157 except Exception as exc: 

158 LOG.exception(exc) 

159 raise exception.GoalNotFound(goal=uuid_or_name) 

160 

161 if not goal.deleted_at: 

162 raise exception.NotSoftDeletedStateError( 

163 name=_('Goal'), id=uuid_or_name) 

164 

165 return goal.uuid 

166 

167 def _find_goals(self, filters=None): 

168 return objects.Goal.list(self.ctx, filters=filters) 

169 

170 def _find_strategies(self, filters=None): 

171 return objects.Strategy.list(self.ctx, filters=filters) 

172 

173 def _find_audit_templates(self, filters=None): 

174 return objects.AuditTemplate.list(self.ctx, filters=filters) 

175 

176 def _find_audits(self, filters=None): 

177 return objects.Audit.list(self.ctx, filters=filters) 

178 

179 def _find_action_plans(self, filters=None): 

180 return objects.ActionPlan.list(self.ctx, filters=filters) 

181 

182 def _find_actions(self, filters=None): 

183 return objects.Action.list(self.ctx, filters=filters) 

184 

185 def _find_orphans(self): 

186 orphans = WatcherObjectsMap() 

187 

188 filters = dict(deleted=False) 

189 goals = objects.Goal.list(self.ctx, filters=filters) 

190 strategies = objects.Strategy.list(self.ctx, filters=filters) 

191 audit_templates = objects.AuditTemplate.list(self.ctx, filters=filters) 

192 audits = objects.Audit.list(self.ctx, filters=filters) 

193 action_plans = objects.ActionPlan.list(self.ctx, filters=filters) 

194 actions = objects.Action.list(self.ctx, filters=filters) 

195 

196 goal_ids = set(g.id for g in goals) 

197 orphans.strategies = [ 

198 strategy for strategy in strategies 

199 if strategy.goal_id not in goal_ids] 

200 

201 strategy_ids = [s.id for s in (s for s in strategies 

202 if s not in orphans.strategies)] 

203 orphans.audit_templates = [ 

204 audit_template for audit_template in audit_templates 

205 if audit_template.goal_id not in goal_ids or 

206 (audit_template.strategy_id and 

207 audit_template.strategy_id not in strategy_ids)] 

208 

209 orphans.audits = [ 

210 audit for audit in audits 

211 if audit.goal_id not in goal_ids or 

212 (audit.strategy_id and 

213 audit.strategy_id not in strategy_ids)] 

214 

215 # Objects with orphan parents are themselves orphans 

216 audit_ids = [audit.id for audit in audits 

217 if audit not in orphans.audits] 

218 orphans.action_plans = [ 

219 ap for ap in action_plans 

220 if ap.audit_id not in audit_ids or 

221 ap.strategy_id not in strategy_ids] 

222 

223 # Objects with orphan parents are themselves orphans 

224 action_plan_ids = [ap.id for ap in action_plans 

225 if ap not in orphans.action_plans] 

226 orphans.actions = [ 

227 action for action in actions 

228 if action.action_plan_id not in action_plan_ids] 

229 

230 LOG.debug("Orphans found:\n%s", orphans) 

231 LOG.info("Orphans found:\n%s", orphans.get_count_table()) 

232 

233 return orphans 

234 

235 def _find_soft_deleted_objects(self): 

236 to_be_deleted = WatcherObjectsMap() 

237 expiry_date = self.get_expiry_date() 

238 filters = dict(deleted=True) 

239 

240 if self.uuid: 

241 filters["uuid"] = self.uuid 

242 if expiry_date: 

243 filters.update(dict(deleted_at__lt=expiry_date)) 

244 

245 to_be_deleted.goals.extend(self._find_goals(filters)) 

246 to_be_deleted.strategies.extend(self._find_strategies(filters)) 

247 to_be_deleted.audit_templates.extend( 

248 self._find_audit_templates(filters)) 

249 to_be_deleted.audits.extend(self._find_audits(filters)) 

250 to_be_deleted.action_plans.extend( 

251 self._find_action_plans(filters)) 

252 to_be_deleted.actions.extend(self._find_actions(filters)) 

253 

254 soft_deleted_objs = self._find_related_objects( 

255 to_be_deleted, base_filters=dict(deleted=True)) 

256 

257 LOG.debug("Soft deleted objects:\n%s", soft_deleted_objs) 

258 

259 return soft_deleted_objs 

260 

261 def _find_related_objects(self, objects_map, base_filters=None): 

262 base_filters = base_filters or {} 

263 

264 for goal in objects_map.goals: 

265 filters = {} 

266 filters.update(base_filters) 

267 filters.update(dict(goal_id=goal.id)) 

268 related_objs = WatcherObjectsMap() 

269 related_objs.strategies = self._find_strategies(filters) 

270 related_objs.audit_templates = self._find_audit_templates(filters) 

271 related_objs.audits = self._find_audits(filters) 

272 objects_map += related_objs 

273 

274 for strategy in objects_map.strategies: 

275 filters = {} 

276 filters.update(base_filters) 

277 filters.update(dict(strategy_id=strategy.id)) 

278 related_objs = WatcherObjectsMap() 

279 related_objs.audit_templates = self._find_audit_templates(filters) 

280 related_objs.audits = self._find_audits(filters) 

281 objects_map += related_objs 

282 

283 for audit in objects_map.audits: 

284 filters = {} 

285 filters.update(base_filters) 

286 filters.update(dict(audit_id=audit.id)) 

287 related_objs = WatcherObjectsMap() 

288 related_objs.action_plans = self._find_action_plans(filters) 

289 objects_map += related_objs 

290 

291 for action_plan in objects_map.action_plans: 

292 filters = {} 

293 filters.update(base_filters) 

294 filters.update(dict(action_plan_id=action_plan.id)) 

295 related_objs = WatcherObjectsMap() 

296 related_objs.actions = self._find_actions(filters) 

297 objects_map += related_objs 

298 

299 return objects_map 

300 

301 def confirmation_prompt(self): 

302 print(self._objects_map.get_count_table()) 

303 raw_val = input( 

304 _("There are %(count)d objects set for deletion. " 

305 "Continue? [y/N]") % dict(count=len(self._objects_map))) 

306 

307 return strutils.bool_from_string(raw_val) 

308 

309 def delete_up_to_max_prompt(self, objects_map): 

310 print(objects_map.get_count_table()) 

311 print(_("The number of objects (%(num)s) to delete from the database " 

312 "exceeds the maximum number of objects (%(max_number)s) " 

313 "specified.") % dict(max_number=self.max_number, 

314 num=len(objects_map))) 

315 raw_val = input( 

316 _("Do you want to delete objects up to the specified maximum " 

317 "number? [y/N]")) 

318 

319 self._delete_up_to_max = strutils.bool_from_string(raw_val) 

320 

321 return self._delete_up_to_max 

322 

323 def _aggregate_objects(self): 

324 """Objects aggregated on a 'per goal' basis""" 

325 # todo: aggregate orphans as well 

326 aggregate = [] 

327 for goal in self._objects_map.goals: 

328 related_objs = WatcherObjectsMap() 

329 

330 # goals 

331 related_objs.goals = [goal] 

332 

333 # strategies 

334 goal_ids = [goal.id] 

335 related_objs.strategies = [ 

336 strategy for strategy in self._objects_map.strategies 

337 if strategy.goal_id in goal_ids 

338 ] 

339 

340 # audit templates 

341 strategy_ids = [ 

342 strategy.id for strategy in related_objs.strategies] 

343 related_objs.audit_templates = [ 

344 at for at in self._objects_map.audit_templates 

345 if at.goal_id in goal_ids or 

346 (at.strategy_id and at.strategy_id in strategy_ids) 

347 ] 

348 

349 # audits 

350 related_objs.audits = [ 

351 audit for audit in self._objects_map.audits 

352 if audit.goal_id in goal_ids 

353 ] 

354 

355 # action plans 

356 audit_ids = [audit.id for audit in related_objs.audits] 

357 related_objs.action_plans = [ 

358 action_plan for action_plan in self._objects_map.action_plans 

359 if action_plan.audit_id in audit_ids 

360 ] 

361 

362 # actions 

363 action_plan_ids = [ 

364 action_plan.id for action_plan in related_objs.action_plans 

365 ] 

366 related_objs.actions = [ 

367 action for action in self._objects_map.actions 

368 if action.action_plan_id in action_plan_ids 

369 ] 

370 aggregate.append(related_objs) 

371 

372 return aggregate 

373 

374 def _get_objects_up_to_limit(self): 

375 aggregated_objects = self._aggregate_objects() 

376 to_be_deleted_subset = WatcherObjectsMap() 

377 

378 for aggregate in aggregated_objects: 378 ↛ 384line 378 didn't jump to line 384 because the loop on line 378 didn't complete

379 if len(aggregate) + len(to_be_deleted_subset) <= self.max_number: 

380 to_be_deleted_subset += aggregate 

381 else: 

382 break 

383 

384 LOG.debug(to_be_deleted_subset) 

385 return to_be_deleted_subset 

386 

387 def find_objects_to_delete(self): 

388 """Finds all the objects to be purged 

389 

390 :returns: A mapping with all the Watcher objects to purged 

391 :rtype: :py:class:`~.WatcherObjectsMap` instance 

392 """ 

393 to_be_deleted = self._find_soft_deleted_objects() 

394 

395 if not self.exclude_orphans: 

396 to_be_deleted += self._find_orphans() 

397 

398 LOG.debug("Objects to be deleted:\n%s", to_be_deleted) 

399 

400 return to_be_deleted 

401 

402 def do_delete(self): 

403 LOG.info("Deleting...") 

404 # Reversed to avoid errors with foreign keys 

405 for entry in reversed(list(self._objects_map)): 

406 entry.destroy() 

407 

408 def execute(self): 

409 LOG.info("Starting purge command") 

410 self._objects_map = self.find_objects_to_delete() 

411 

412 if (self.max_number is not None and 

413 len(self._objects_map) > self.max_number): 

414 if self.delete_up_to_max_prompt(self._objects_map): 414 ↛ 417line 414 didn't jump to line 417 because the condition on line 414 was always true

415 self._objects_map = self._get_objects_up_to_limit() 

416 else: 

417 return 

418 

419 _orphans_note = (_(" (orphans excluded)") if self.exclude_orphans 

420 else _(" (may include orphans)")) 

421 if not self.dry_run and self.confirmation_prompt(): 421 ↛ 426line 421 didn't jump to line 426 because the condition on line 421 was always true

422 self.do_delete() 

423 print(_("Purge results summary%s:") % _orphans_note) 

424 LOG.info("Purge results summary%s:", _orphans_note) 

425 else: 

426 LOG.debug(self._objects_map) 

427 print(_("Here below is a table containing the objects " 

428 "that can be purged%s:") % _orphans_note) 

429 

430 LOG.info("\n%s", self._objects_map.get_count_table()) 

431 print(self._objects_map.get_count_table()) 

432 LOG.info("Purge process completed") 

433 

434 

435def purge(age_in_days, max_number, goal, exclude_orphans, dry_run): 

436 """Removes soft deleted objects from the database 

437 

438 :param age_in_days: Number of days since deletion (from today) 

439 to exclude from the purge. If None, everything will be purged. 

440 :type age_in_days: int 

441 :param max_number: Max number of objects expected to be deleted. 

442 Prevents the deletion if exceeded. No limit if set to None. 

443 :type max_number: int 

444 :param goal: UUID or name of the goal to purge. 

445 :type goal: str 

446 :param exclude_orphans: Flag to indicate whether or not you want to 

447 exclude orphans from deletion (default: False). 

448 :type exclude_orphans: bool 

449 :param dry_run: Flag to indicate whether or not you want to perform 

450 a dry run (no deletion). 

451 :type dry_run: bool 

452 """ 

453 try: 

454 if max_number and max_number < 0: 

455 raise exception.NegativeLimitError 

456 

457 LOG.info("[options] age_in_days = %s", age_in_days) 

458 LOG.info("[options] max_number = %s", max_number) 

459 LOG.info("[options] goal = %s", goal) 

460 LOG.info("[options] exclude_orphans = %s", exclude_orphans) 

461 LOG.info("[options] dry_run = %s", dry_run) 

462 

463 uuid = PurgeCommand.get_goal_uuid(goal) 

464 

465 cmd = PurgeCommand(age_in_days, max_number, uuid, 

466 exclude_orphans, dry_run) 

467 

468 cmd.execute() 

469 

470 except Exception as exc: 

471 LOG.exception(exc) 

472 print(exc) 

473 sys.exit(1)