Coverage for dak/rm.py: 71%

174 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2026-08-03 16:46 +0000

1"""General purpose package removal tool for ftpmaster""" 

2 

3# Copyright (C) 2000, 2001, 2002, 2003, 2004, 2006 James Troup <james@nocrew.org> 

4# Copyright (C) 2010 Alexander Reichle-Schmehl <tolimar@debian.org> 

5 

6# This program is free software; you can redistribute it and/or modify 

7# it under the terms of the GNU General Public License as published by 

8# the Free Software Foundation; either version 2 of the License, or 

9# (at your option) any later version. 

10 

11# This program is distributed in the hope that it will be useful, 

12# but WITHOUT ANY WARRANTY; without even the implied warranty of 

13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

14# GNU General Public License for more details. 

15 

16# You should have received a copy of the GNU General Public License 

17# along with this program; if not, write to the Free Software 

18# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 

19 

20################################################################################ 

21 

22# o OpenBSD team wants to get changes incorporated into IPF. Darren no 

23# respond. 

24# o Ask again -> No respond. Darren coder supreme. 

25# o OpenBSD decide to make changes, but only in OpenBSD source 

26# tree. Darren hears, gets angry! Decides: "LICENSE NO ALLOW!" 

27# o Insert Flame War. 

28# o OpenBSD team decide to switch to different packet filter under BSD 

29# license. Because Project Goal: Every user should be able to make 

30# changes to source tree. IPF license bad!! 

31# o Darren try get back: says, NetBSD, FreeBSD allowed! MUAHAHAHAH!!! 

32# o Theo say: no care, pf much better than ipf! 

33# o Darren changes mind: changes license. But OpenBSD will not change 

34# back to ipf. Darren even much more bitter. 

35# o Darren so bitterbitter. Decides: I'LL GET BACK BY FORKING OPENBSD AND 

36# RELEASING MY OWN VERSION. HEHEHEHEHE. 

37 

38# http://slashdot.org/comments.pl?sid=26697&cid=2883271 

39 

40################################################################################ 

41 

42import functools 

43import sys 

44from collections import defaultdict 

45from typing import TYPE_CHECKING, NoReturn 

46 

47import apt_pkg 

48from sqlalchemy import sql 

49 

50from daklib import utils 

51from daklib.config import Config 

52from daklib.dbconn import DBConn, get_maintainer, get_suite 

53from daklib.rm import remove 

54 

55if TYPE_CHECKING: 

56 from sqlalchemy.orm import Session 

57 

58################################################################################ 

59 

60Options: apt_pkg.Configuration 

61 

62################################################################################ 

63 

64 

65def usage(exit_code=0) -> NoReturn: 

66 print( 

67 """Usage: dak rm [OPTIONS] PACKAGE[...] 

68Remove PACKAGE(s) from suite(s). 

69 

70 -A, --no-arch-all-rdeps Do not report breaking arch:all packages 

71 or Build-Depends-Indep 

72 -a, --architecture=ARCH only act on this architecture 

73 -b, --binary PACKAGE are binary packages to remove 

74 -B, --binary-only remove binaries only 

75 --binary-version=VER only remove packages with binary version VER 

76 (can be a comma-separated list) 

77 --keep-binary-version=VER 

78 never remove packages with binary version VER 

79 (can be a comma-separated list) 

80 -c, --component=COMPONENT act on this component 

81 -C, --carbon-copy=EMAIL send a CC of removal message to EMAIL 

82 -d, --done=BUG# send removal message as closure to bug# 

83 -D, --do-close also close all bugs associated to that package 

84 -h, --help show this help and exit 

85 -m, --reason=MSG reason for removal 

86 -n, --no-action don't do anything 

87 -o, --outdated remove only outdated sources or binaries that were 

88 built from previous source versions 

89 -p, --partial don't affect override files 

90 -R, --rdep-check check reverse dependencies 

91 -s, --suite=SUITE act on this suite 

92 -S, --source-only remove source only 

93 --source-version=VER only remove packages with source version VER 

94 (can be a comma-separated list) 

95 --keep-source-version=VER 

96 never remove packages with source version VER 

97 (can be a comma-separated list) 

98 

99ARCH, BUG#, COMPONENT, SUITE and VER can be comma (or space) separated lists, e.g. 

100 --architecture=amd64,i386""" 

101 ) 

102 

103 sys.exit(exit_code) 

104 

105 

106################################################################################ 

107 

108# "Hudson: What that's great, that's just fucking great man, now what 

109# the fuck are we supposed to do? We're in some real pretty shit now 

110# man...That's it man, game over man, game over, man! Game over! What 

111# the fuck are we gonna do now? What are we gonna do?" 

112 

113 

114def game_over() -> None: 

115 answer = utils.input_or_exit("Continue (y/N)? ").lower() 

116 if answer != "y": 116 ↛ 117line 116 didn't jump to line 117 because the condition on line 116 was never true

117 print("Aborted.") 

118 sys.exit(1) 

119 

120 

121################################################################################ 

122 

123 

124def reverse_depends_check( 

125 removals: list[str], 

126 suite: str, 

127 arches: list[str] | None, 

128 session: "Session", 

129 include_arch_all: bool, 

130) -> None: 

131 print("Checking reverse dependencies...") 

132 if utils.check_reverse_depends( 132 ↛ 135line 132 didn't jump to line 135 because the condition on line 132 was never true

133 removals, suite, arches, session, include_arch_all=include_arch_all 

134 ): 

135 print("Dependency problem found.") 

136 if not Options["No-Action"]: 

137 game_over() 

138 else: 

139 print("No dependency problem found.") 

140 print() 

141 

142 

143################################################################################ 

144 

145 

146def main() -> None: 

147 global Options 

148 

149 cnf = Config() 

150 

151 Arguments = [ 

152 ("h", "help", "Rm::Options::Help"), 

153 ("A", "no-arch-all-rdeps", "Rm::Options::NoArchAllRdeps"), 

154 ("a", "architecture", "Rm::Options::Architecture", "HasArg"), 

155 ("b", "binary", "Rm::Options::Binary"), 

156 ("B", "binary-only", "Rm::Options::Binary-Only"), 

157 ("\0", "binary-version", "Rm::Options::Binary-Version", "HasArg"), 

158 ("\0", "keep-binary-version", "Rm::Options::Keep-Binary-Version", "HasArg"), 

159 ("c", "component", "Rm::Options::Component", "HasArg"), 

160 ("C", "carbon-copy", "Rm::Options::Carbon-Copy", "HasArg"), # Bugs to Cc 

161 ("d", "done", "Rm::Options::Done", "HasArg"), # Bugs fixed 

162 ("D", "do-close", "Rm::Options::Do-Close"), 

163 ("R", "rdep-check", "Rm::Options::Rdep-Check"), 

164 ( 

165 "m", 

166 "reason", 

167 "Rm::Options::Reason", 

168 "HasArg", 

169 ), # Hysterical raisins; -m is old-dinstall option for rejection reason 

170 ("n", "no-action", "Rm::Options::No-Action"), 

171 ("o", "outdated", "Rm::Options::Outdated"), 

172 ("p", "partial", "Rm::Options::Partial"), 

173 ("s", "suite", "Rm::Options::Suite", "HasArg"), 

174 ("S", "source-only", "Rm::Options::Source-Only"), 

175 ("\0", "source-version", "Rm::Options::Source-Version", "HasArg"), 

176 ("\0", "keep-source-version", "Rm::Options::Keep-Source-Version", "HasArg"), 

177 ] 

178 

179 for i in [ 

180 "NoArchAllRdeps", 

181 "architecture", 

182 "binary", 

183 "binary-only", 

184 "carbon-copy", 

185 "component", 

186 "done", 

187 "help", 

188 "no-action", 

189 "outdated", 

190 "partial", 

191 "rdep-check", 

192 "reason", 

193 "source-only", 

194 "Do-Close", 

195 ]: 

196 key = "Rm::Options::%s" % (i) 

197 if key not in cnf: 197 ↛ 179line 197 didn't jump to line 179

198 cnf[key] = "" 

199 if "Rm::Options::Suite" not in cnf: 199 ↛ 202line 199 didn't jump to line 202 because the condition on line 199 was always true

200 cnf["Rm::Options::Suite"] = "unstable" 

201 

202 arguments = apt_pkg.parse_commandline(cnf.Cnf, Arguments, sys.argv) # type: ignore[attr-defined] 

203 Options = cnf.subtree("Rm::Options") 

204 

205 if Options["Help"]: 

206 usage() 

207 

208 session = DBConn().session() 

209 

210 # Sanity check options 

211 if not arguments: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true

212 utils.fubar("need at least one package name as an argument.") 

213 if Options["Architecture"] and Options["Source-Only"]: 213 ↛ 214line 213 didn't jump to line 214 because the condition on line 213 was never true

214 utils.fubar( 

215 "can't use -a/--architecture and -S/--source-only options simultaneously." 

216 ) 

217 actions = [Options["Binary"], Options["Binary-Only"], Options["Source-Only"]] 

218 nr_actions = len([act for act in actions if act]) 

219 if nr_actions > 1: 219 ↛ 220line 219 didn't jump to line 220 because the condition on line 219 was never true

220 utils.fubar( 

221 "Only one of -b/--binary, -B/--binary-only and -S/--source-only can be used." 

222 ) 

223 if Options["Architecture"] and not Options["Partial"]: 223 ↛ 224line 223 didn't jump to line 224 because the condition on line 223 was never true

224 utils.warn("-a/--architecture implies -p/--partial.") 

225 Options["Partial"] = "true" # type: ignore[index] 

226 if Options["Outdated"] and not Options["Partial"]: 226 ↛ 227line 226 didn't jump to line 227 because the condition on line 226 was never true

227 utils.warn("-o/--outdated implies -p/--partial.") 

228 Options["Partial"] = "true" # type: ignore[index] 

229 if Options["Do-Close"] and not Options["Done"]: 229 ↛ 230line 229 didn't jump to line 230 because the condition on line 229 was never true

230 utils.fubar("-D/--do-close needs -d/--done (bugnr).") 

231 if Options["Do-Close"] and ( 231 ↛ 234line 231 didn't jump to line 234 because the condition on line 231 was never true

232 Options["Binary"] or Options["Binary-Only"] or Options["Source-Only"] 

233 ): 

234 utils.fubar( 

235 "-D/--do-close cannot be used with -b/--binary, -B/--binary-only or -S/--source-only." 

236 ) 

237 

238 # Force the admin to tell someone if we're not doing a 'dak 

239 # cruft-report' inspired removal (or closing a bug, which counts 

240 # as telling someone). 

241 if ( 241 ↛ 248line 241 didn't jump to line 248

242 not Options["No-Action"] 

243 and not Options["Carbon-Copy"] 

244 and not Options["Done"] 

245 and Options["Reason"].find("[auto-cruft]") == -1 

246 and Options["Reason"].find("[cruft]") == -1 

247 ): 

248 utils.fubar( 

249 "Need a -C/--carbon-copy if not closing a bug and not doing a cruft removal." 

250 ) 

251 

252 parameters: dict[str, object] = { 

253 "binary_versions": utils.split_args_or_none(Options.get("Binary-Version", "")), 

254 "source_versions": utils.split_args_or_none(Options.get("Source-Version", "")), 

255 "keep_binary_versions": utils.split_args_or_none( 

256 Options.get("Keep-Binary-Version", "") 

257 ), 

258 "keep_source_versions": utils.split_args_or_none( 

259 Options.get("Keep-Source-Version", "") 

260 ), 

261 } 

262 

263 if Options["Binary"]: 263 ↛ 264line 263 didn't jump to line 264 because the condition on line 263 was never true

264 con_packages = "AND b.package IN :packages" 

265 parameters["packages"] = tuple(arguments) 

266 else: 

267 con_packages = "AND s.source IN :sources" 

268 parameters["sources"] = tuple(arguments) 

269 

270 (con_suites, con_architectures, con_components, check_source) = utils.parse_args( 

271 Options 

272 ) 

273 

274 # Additional suite checks 

275 suite_ids_list = [] 

276 whitelists = [] 

277 suites = utils.split_args(Options["Suite"]) 

278 suites_list = utils.join_with_commas_and(suites) 

279 if not Options["No-Action"]: 

280 for suite in suites: 

281 s = get_suite(suite, session=session) 

282 if s is not None: 282 ↛ 285line 282 didn't jump to line 285 because the condition on line 282 was always true

283 suite_ids_list.append(s.suite_id) 

284 whitelists.append(s.mail_whitelist) 

285 if suite in ("oldstable", "stable"): 285 ↛ 286line 285 didn't jump to line 286 because the condition on line 285 was never true

286 print("**WARNING** About to remove from the (old)stable suite!") 

287 print( 

288 "This should only be done just prior to a (point) release and not at" 

289 ) 

290 print("any other time.") 

291 game_over() 

292 elif suite == "testing": 292 ↛ 293line 292 didn't jump to line 293 because the condition on line 292 was never true

293 print("**WARNING About to remove from the testing suite!") 

294 print( 

295 "There's no need to do this normally as removals from unstable will" 

296 ) 

297 print("propogate to testing automagically.") 

298 game_over() 

299 

300 # Additional architecture checks 

301 if Options["Architecture"] and check_source: 301 ↛ 302line 301 didn't jump to line 302 because the condition on line 301 was never true

302 utils.warn("'source' in -a/--argument makes no sense and is ignored.") 

303 

304 # Don't do dependency checks on multiple suites 

305 if Options["Rdep-Check"] and len(suites) > 1: 305 ↛ 306line 305 didn't jump to line 306 because the condition on line 305 was never true

306 utils.fubar("Reverse dependency check on multiple suites is not implemented.") 

307 

308 q_outdated = "TRUE" 

309 if Options["Outdated"]: 309 ↛ 310line 309 didn't jump to line 310 because the condition on line 309 was never true

310 q_outdated = "s.version < newest_source.version" 

311 

312 to_remove: list[tuple[str, str, str, int, int, str, str, str]] = [] 

313 maintainers: set[int] = set() 

314 

315 # We have 3 modes of package selection: binary, source-only, binary-only 

316 # and source+binary. 

317 

318 # XXX: TODO: This all needs converting to use placeholders or the object 

319 # API. It's an SQL injection dream at the moment 

320 

321 if Options["Binary"]: 321 ↛ 323line 321 didn't jump to line 323 because the condition on line 321 was never true

322 # Removal by binary package name 

323 q = session.execute( 

324 sql.text( 

325 """ 

326 SELECT b.package, b.version, a.arch_string, b.id, b.maintainer, s.source, 

327 s.version as source_version, newest_source.version as newest_sversion 

328 FROM binaries b 

329 JOIN source s ON s.id = b.source 

330 JOIN bin_associations ba ON ba.bin = b.id 

331 JOIN architecture a ON a.id = b.architecture 

332 JOIN suite su ON su.id = ba.suite 

333 JOIN files f ON f.id = b.file 

334 JOIN files_archive_map af ON af.file_id = f.id AND af.archive_id = su.archive_id 

335 JOIN component c ON c.id = af.component_id 

336 LEFT JOIN newest_source on s.source = newest_source.source AND su.id = newest_source.suite 

337 WHERE 

338 (:binary_versions IS NULL OR b.version = ANY(CAST(:binary_versions AS debversion[]))) 

339 AND (:source_versions IS NULL OR s.version = ANY(CAST(:source_versions AS debversion[]))) 

340 AND (:keep_binary_versions IS NULL OR b.version != ALL(CAST(:keep_binary_versions AS debversion[]))) 

341 AND (:keep_source_versions IS NULL OR s.version != ALL(CAST(:keep_source_versions AS debversion[]))) 

342 AND %s %s %s %s %s 

343 """ 

344 % ( 

345 q_outdated, 

346 con_packages, 

347 con_suites, 

348 con_components, 

349 con_architectures, 

350 ) 

351 ), 

352 parameters, 

353 ) 

354 to_remove.extend(q) # type: ignore[arg-type] 

355 else: 

356 # Source-only 

357 if not Options["Binary-Only"]: 357 ↛ 381line 357 didn't jump to line 381 because the condition on line 357 was always true

358 q = session.execute( 

359 sql.text( 

360 """ 

361 SELECT s.source, s.version, 'source', s.id, s.maintainer, s.source, 

362 s.version as source_version, newest_source.version as newest_sversion 

363 FROM source s 

364 JOIN src_associations sa ON sa.source = s.id 

365 JOIN suite su ON su.id = sa.suite 

366 JOIN archive ON archive.id = su.archive_id 

367 JOIN files f ON f.id = s.file 

368 JOIN files_archive_map af ON af.file_id = f.id AND af.archive_id = su.archive_id 

369 JOIN component c ON c.id = af.component_id 

370 LEFT JOIN newest_source on s.source = newest_source.source AND su.id = newest_source.suite 

371 WHERE 

372 (:source_versions IS NULL OR s.version = ANY(CAST(:source_versions AS debversion[]))) 

373 AND (:keep_source_versions IS NULL OR s.version != ALL(CAST(:keep_source_versions AS debversion[]))) 

374 AND %s %s %s %s 

375 """ 

376 % (q_outdated, con_packages, con_suites, con_components) 

377 ), 

378 parameters, 

379 ) 

380 to_remove.extend(q) # type: ignore[arg-type] 

381 if not Options["Source-Only"]: 381 ↛ 416line 381 didn't jump to line 416 because the condition on line 381 was always true

382 # Source + Binary 

383 q = session.execute( 

384 sql.text( 

385 """ 

386 SELECT b.package, b.version, a.arch_string, b.id, b.maintainer, s.source, 

387 s.version as source_version, newest_source.version as newest_sversion 

388 FROM binaries b 

389 JOIN bin_associations ba ON b.id = ba.bin 

390 JOIN architecture a ON b.architecture = a.id 

391 JOIN suite su ON ba.suite = su.id 

392 JOIN archive ON archive.id = su.archive_id 

393 JOIN files_archive_map af ON b.file = af.file_id AND af.archive_id = archive.id 

394 JOIN component c ON af.component_id = c.id 

395 JOIN source s ON b.source = s.id 

396 LEFT JOIN newest_source on s.source = newest_source.source AND su.id = newest_source.suite 

397 WHERE 

398 (:binary_versions IS NULL OR b.version = ANY(CAST(:binary_versions AS debversion[]))) 

399 AND (:source_versions IS NULL OR s.version = ANY(CAST(:source_versions AS debversion[]))) 

400 AND (:keep_binary_versions IS NULL OR b.version != ALL(CAST(:keep_binary_versions AS debversion[]))) 

401 AND (:keep_source_versions IS NULL OR s.version != ALL(CAST(:keep_source_versions AS debversion[]))) 

402 AND %s %s %s %s %s 

403 """ 

404 % ( 

405 q_outdated, 

406 con_packages, 

407 con_suites, 

408 con_components, 

409 con_architectures, 

410 ) 

411 ), 

412 parameters, 

413 ) 

414 to_remove.extend(q) # type: ignore[arg-type] 

415 

416 if not to_remove: 416 ↛ 417line 416 didn't jump to line 417 because the condition on line 416 was never true

417 print("Nothing to do.") 

418 sys.exit(0) 

419 

420 # Process -C/--carbon-copy 

421 # 

422 # Accept 3 types of arguments (space separated): 

423 # 1) a number - assumed to be a bug number, i.e. nnnnn@bugs.debian.org 

424 # 2) the keyword 'package' - cc's $package@packages.debian.org for every argument 

425 # 3) contains a '@' - assumed to be an email address, used unmodified 

426 # 

427 carbon_copy = [] 

428 for copy_to in utils.split_args(Options.get("Carbon-Copy", "")): 

429 if copy_to.isdigit(): 429 ↛ 430line 429 didn't jump to line 430 because the condition on line 429 was never true

430 if "Dinstall::BugServer" in cnf: 

431 carbon_copy.append(copy_to + "@" + cnf["Dinstall::BugServer"]) 

432 else: 

433 utils.fubar( 

434 "Asked to send mail to #%s in BTS but Dinstall::BugServer is not configured" 

435 % copy_to 

436 ) 

437 elif copy_to == "package": 437 ↛ 438line 437 didn't jump to line 438 because the condition on line 437 was never true

438 for package in {s[5] for s in to_remove}: 

439 if "Dinstall::PackagesServer" in cnf: 

440 carbon_copy.append(package + "@" + cnf["Dinstall::PackagesServer"]) 

441 elif "@" in copy_to: 441 ↛ 444line 441 didn't jump to line 444 because the condition on line 441 was always true

442 carbon_copy.append(copy_to) 

443 else: 

444 utils.fubar( 

445 "Invalid -C/--carbon-copy argument '%s'; not a bug number, 'package' or email address." 

446 % (copy_to) 

447 ) 

448 

449 # If we don't have a reason; spawn an editor so the user can add one 

450 # Write the rejection email out as the <foo>.reason file 

451 if not Options["Reason"] and not Options["No-Action"]: 451 ↛ 452line 451 didn't jump to line 452 because the condition on line 451 was never true

452 Options["Reason"] = utils.call_editor() # type: ignore[index] 

453 

454 # Generate the summary of what's to be removed 

455 d: dict[str, dict[str, list[str]]] = defaultdict(lambda: defaultdict(list)) 

456 for j in to_remove: 

457 package = j[0] 

458 version = j[1] 

459 architecture = j[2] 

460 maintainer = j[4] 

461 maintainers.add(maintainer) 

462 # source = j[5] 

463 # source_version = j[6] 

464 # source_newest = j[7] 

465 if architecture not in d[package][version]: 465 ↛ 456line 465 didn't jump to line 456 because the condition on line 465 was always true

466 d[package][version].append(architecture) 

467 

468 maintainer_list = [] 

469 for maintainer_id in maintainers: 

470 m = get_maintainer(maintainer_id, session=session) 

471 assert m is not None 

472 maintainer_list.append(m.name) 

473 summary = "" 

474 removals = sorted(d) 

475 for package in removals: 

476 versions = sorted(d[package], key=functools.cmp_to_key(apt_pkg.version_compare)) 

477 for version in versions: 

478 d[package][version].sort(key=utils.ArchKey) 

479 summary += "%10s | %10s | %s\n" % ( 

480 package, 

481 version, 

482 ", ".join(d[package][version]), 

483 ) 

484 print("Will remove the following packages from %s:" % (suites_list)) 

485 print() 

486 print(summary) 

487 print("Maintainer: %s" % ", ".join(maintainer_list)) 

488 if Options["Done"]: 

489 print("Will also close bugs: " + Options["Done"]) 

490 if carbon_copy: 

491 print("Will also send CCs to: " + ", ".join(carbon_copy)) 

492 if Options["Do-Close"]: 492 ↛ 493line 492 didn't jump to line 493 because the condition on line 492 was never true

493 print("Will also close associated bug reports.") 

494 print() 

495 print("------------------- Reason -------------------") 

496 print(Options["Reason"]) 

497 print("----------------------------------------------") 

498 print() 

499 

500 if Options["Rdep-Check"]: 

501 arches = utils.split_args(Options["Architecture"]) 

502 include_arch_all = Options["NoArchAllRdeps"] == "" 

503 check_all_arches = include_arch_all and "all" in arches 

504 # when arches is None, rdeps are checked on all arches in the suite 

505 reverse_depends_check( 

506 removals, 

507 suites[0], 

508 arches if not check_all_arches else None, 

509 session, 

510 include_arch_all=include_arch_all, 

511 ) 

512 

513 # If -n/--no-action, drop out here 

514 if Options["No-Action"]: 

515 sys.exit(0) 

516 

517 print("Going to remove the packages now.") 

518 game_over() 

519 

520 # Do the actual deletion 

521 print("Deleting...", end=" ") 

522 sys.stdout.flush() 

523 

524 bugs = utils.split_args(Options["Done"]) 

525 remove( 

526 session, 

527 Options["Reason"], 

528 suites, 

529 [j[0:4] for j in to_remove], 

530 partial=bool(Options["Partial"]), 

531 components=utils.split_args(Options["Component"]), 

532 done_bugs=bugs, 

533 carbon_copy=carbon_copy, 

534 close_related_bugs=bool(Options["Do-Close"]), 

535 ) 

536 

537 print("done.")