Coverage for britney2/migration.py: 94%

271 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2026-06-17 09:00 +0000

1import contextlib 

2import copy 

3import optparse 

4from collections.abc import Iterator 

5from typing import TYPE_CHECKING, Optional, cast 

6 

7import apt_pkg 

8 

9from britney2.transaction import MigrationTransactionState, UndoItem 

10from britney2.utils import ( 

11 MigrationConstraintException, 

12 check_installability, 

13 clone_nuninst, 

14 compute_reverse_tree, 

15 find_smooth_updateable_binaries, 

16) 

17 

18if TYPE_CHECKING: 18 ↛ 19line 18 didn't jump to line 19 because the condition on line 18 was never true

19 from . import BinaryPackage, BinaryPackageId, Suites 

20 from .hints import HintCollection 

21 from .installability.universe import BinaryPackageUniverse 

22 from .migrationitem import MigrationItem, MigrationItemFactory 

23 

24 

25def compute_eqv_set( 

26 pkg_universe: "BinaryPackageUniverse", 

27 updates: set["BinaryPackageId"], 

28 rms: set["BinaryPackageId"], 

29) -> set[tuple[str, str]]: 

30 eqv_set: set[tuple[str, str]] = set() 

31 # If we are removing *and* updating packages, then check for eqv. packages 

32 if rms and updates: 

33 eqv_table = {(x.package_name, x.architecture): x for x in rms} 

34 

35 for new_pkg_id in updates: 

36 key = (new_pkg_id.package_name, new_pkg_id.architecture) 

37 old_pkg_id = eqv_table.get(key) 

38 if old_pkg_id is not None: 

39 if pkg_universe.are_equivalent(new_pkg_id, old_pkg_id): 

40 eqv_set.add(key) 

41 return eqv_set 

42 

43 

44def is_nuninst_worse( 

45 must_be_installable: list[str], 

46 nuninst_now_arch: set[str], 

47 nuninst_after_arch: set[str], 

48 allow_uninst: set[str | None], 

49) -> bool: 

50 if len(nuninst_after_arch - allow_uninst) > len(nuninst_now_arch - allow_uninst): 

51 return True 

52 

53 regression = nuninst_after_arch - nuninst_now_arch 

54 if not regression.isdisjoint(must_be_installable): 54 ↛ 55line 54 didn't jump to line 55 because the condition on line 54 was never true

55 return True 

56 return False 

57 

58 

59class MigrationManager: 

60 

61 def __init__( 

62 self, 

63 options: optparse.Values, 

64 suite_info: "Suites", 

65 all_binaries: dict["BinaryPackageId", "BinaryPackage"], 

66 pkg_universe: "BinaryPackageUniverse", 

67 constraints: dict[str, list[str]], 

68 allow_uninst: dict[str, set[str | None]], 

69 migration_item_factory: "MigrationItemFactory", 

70 hints: "HintCollection", 

71 ) -> None: 

72 self.options = options 

73 self.suite_info = suite_info 

74 self.all_binaries = all_binaries 

75 self.pkg_universe = pkg_universe 

76 self.constraints = constraints 

77 self.allow_uninst = allow_uninst 

78 self.hints = hints 

79 self._transactions: list[MigrationTransactionState] = [] 

80 self._all_architectures: frozenset[str] = frozenset(self.options.architectures) 

81 self._migration_item_factory = migration_item_factory 

82 

83 @property 

84 def current_transaction(self) -> MigrationTransactionState | None: 

85 return self._transactions[-1] if self._transactions else None 

86 

87 def compute_groups( 

88 self, 

89 item: "MigrationItem", 

90 allow_smooth_updates: bool = True, 

91 removals: set["BinaryPackageId"] = cast(set["BinaryPackageId"], frozenset()), 

92 ) -> tuple[ 

93 str, set["BinaryPackageId"], set["BinaryPackageId"], set["BinaryPackageId"] 

94 ]: 

95 """Compute the groups of binaries being migrated by item 

96 

97 This method will compute the binaries that will be added to, 

98 replaced in or removed from the target suite and which of 

99 the removals are smooth updatable. 

100 

101 Parameters: 

102 * "allow_smooth_updates" is a boolean determining whether smooth- 

103 updates are permitted in this migration. When set to False, 

104 the "smoothbins" return value will always be the empty set. 

105 Any value that would have been there will now be in "rms" 

106 instead. (defaults: True) 

107 * "removals" is a set of binaries that is assumed to be 

108 removed at the same time as this migration (e.g. in the same 

109 "easy"-hint). This may affect what if some binaries are 

110 smooth updated or not. (defaults: empty-set) 

111 - Binaries must be given as ("package-name", "version", 

112 "architecture") tuples. 

113 

114 Returns a tuple (adds, rms, smoothbins). "adds" is a set of 

115 binaries that will updated in or appear after the migration. 

116 "rms" is a set of binaries that are not smooth-updatable (or 

117 binaries that could be, but there is no reason to let them be 

118 smooth updated). "smoothbins" is set of binaries that are to 

119 be smooth-updated. 

120 

121 Each "binary" in "adds", "rms" and "smoothbins" will be a 

122 tuple of ("package-name", "version", "architecture") and are 

123 thus tuples suitable for passing on to the 

124 InstallabilityTester. 

125 

126 

127 Unlike migrate_items_to_target_suite, this will not modify 

128 any data structure. 

129 """ 

130 # local copies for better performances 

131 item_package = item.package 

132 target_suite = self.suite_info.target_suite 

133 binaries_t = target_suite.binaries 

134 

135 adds = set() 

136 

137 # remove all binary packages (if the source already exists) 

138 if item.architecture == "source" or not item.is_removal: 

139 source_name = item_package 

140 if source_name in target_suite.sources: 

141 rms, smoothbins = self._compute_removals( 

142 item, allow_smooth_updates, removals 

143 ) 

144 else: 

145 rms = set() 

146 smoothbins = set() 

147 

148 # single binary removal; used for clearing up after smooth 

149 # updates but not supported as a manual hint 

150 else: 

151 assert item_package in binaries_t[item.architecture] 

152 pkg_id_t = binaries_t[item.architecture][item_package].pkg_id 

153 ver = pkg_id_t.version 

154 if ver != item.version: 

155 raise MigrationConstraintException( 

156 "trying cruft removal item %s, while %s has %s/%s on %s" 

157 % ( 

158 item, 

159 target_suite.name, 

160 pkg_id_t.package_name, 

161 ver, 

162 pkg_id_t.architecture, 

163 ) 

164 ) 

165 source_name = binaries_t[item.architecture][item_package].source 

166 rms = {pkg_id_t} 

167 smoothbins = set() 

168 

169 # add the new binary packages (if we are not removing) 

170 if not item.is_removal: 

171 source_suite = item.suite 

172 binaries_s = source_suite.binaries 

173 source_data = source_suite.sources[source_name] 

174 source_ver_new = source_data.version 

175 sources_t = target_suite.sources 

176 if source_name in sources_t: 

177 source_data_old = sources_t[source_name] 

178 source_ver_old = source_data_old.version 

179 if apt_pkg.version_compare(source_ver_old, source_ver_new) > 0: 

180 raise MigrationConstraintException( 

181 "trying src:%s %s, while %s has %s" 

182 % ( 

183 source_name, 

184 source_ver_new, 

185 target_suite.name, 

186 source_ver_old, 

187 ) 

188 ) 

189 

190 for pkg_id_s in source_data.binaries: 

191 ver = pkg_id_s.version 

192 parch = pkg_id_s.architecture 

193 if item.architecture not in ("source", parch): 

194 continue 

195 

196 binary = pkg_id_s.package_name 

197 if binaries_s[parch][binary].source != source_name: 

198 # This binary package has been hijacked by some other source. 

199 # So don't add it as part of this update. 

200 # 

201 # Also, if this isn't a source update, don't remove 

202 # the package that's been hijacked if it's present. 

203 if item.architecture != "source": 203 ↛ 210line 203 didn't jump to line 210 because the condition on line 203 was always true

204 for rm_item in list(rms): 

205 if ( 

206 rm_item.package_name == binary 

207 and rm_item.architecture == parch 

208 ): 

209 rms.remove(rm_item) 

210 continue 

211 

212 # Don't add the binary if it is cruft; smooth updates will keep it if possible 

213 if ( 

214 parch not in self.options.outofsync_arches 

215 and source_data.version != binaries_s[parch][binary].source_version 

216 ): 

217 continue 

218 

219 if binary in binaries_t[parch]: 

220 oldver = binaries_t[parch][binary].version 

221 ver = pkg_id_s.version 

222 if apt_pkg.version_compare(oldver, ver) > 0: 

223 raise MigrationConstraintException( 

224 "trying %s %s from src:%s %s, while %s has %s" 

225 % ( 

226 binary, 

227 ver, 

228 source_name, 

229 source_ver_new, 

230 target_suite.name, 

231 oldver, 

232 ) 

233 ) 

234 

235 adds.add(pkg_id_s) 

236 

237 return (source_name, adds, rms, smoothbins) 

238 

239 def _compute_removals( 

240 self, 

241 item: "MigrationItem", 

242 allow_smooth_updates: bool, 

243 removals: set["BinaryPackageId"], 

244 ) -> tuple[set["BinaryPackageId"], set["BinaryPackageId"]]: 

245 pkg_universe = self.pkg_universe 

246 source_suite = item.suite 

247 target_suite = self.suite_info.target_suite 

248 binaries_s = source_suite.binaries 

249 binaries_t = target_suite.binaries 

250 source_name = item.package 

251 source_data = target_suite.sources[source_name] 

252 

253 bins: list["BinaryPackageId"] = [] 

254 # remove all the binaries 

255 

256 # first, build a list of eligible binaries 

257 for pkg_id in source_data.binaries: 

258 parch = pkg_id.architecture 

259 if item.architecture != "source" and parch != item.architecture: 

260 continue 

261 

262 binary = pkg_id.package_name 

263 # Work around #815995 

264 if ( 264 ↛ 269line 264 didn't jump to line 269

265 item.architecture == "source" 

266 and item.is_removal 

267 and binary not in binaries_t[parch] 

268 ): 

269 continue 

270 

271 bin_data = binaries_t[parch][binary] 

272 # Do not include hijacked binaries nor cruft (cruft is handled separately) 

273 if ( 

274 bin_data.source != source_name 

275 or bin_data.source_version != source_data.version 

276 ): 

277 continue 

278 bins.append(pkg_id) 

279 

280 if allow_smooth_updates and source_suite.suite_class.is_primary_source: 

281 smoothbins = find_smooth_updateable_binaries( 

282 bins, 

283 source_suite.sources[source_name], 

284 pkg_universe, 

285 target_suite, 

286 binaries_t, 

287 binaries_s, 

288 removals, 

289 self.options.smooth_updates, 

290 self.hints, 

291 ) 

292 else: 

293 smoothbins = set() 

294 

295 # remove all the binaries which aren't being smooth updated 

296 if ( 

297 item.architecture != "source" 

298 and source_suite.suite_class.is_additional_source 

299 ): 

300 # Special-case for pu/tpu: 

301 # if this is a binary migration from *pu, only the arch:any 

302 # packages will be present. ideally dak would also populate 

303 # the arch-indep packages, but as that's not the case we 

304 # must keep them around; they will not be re-added by the 

305 # migration so will end up missing from testing 

306 all_binaries = self.all_binaries 

307 rms = { 

308 pkg_id 

309 for pkg_id in bins 

310 if pkg_id not in smoothbins 

311 and all_binaries[pkg_id].architecture != "all" 

312 } 

313 else: 

314 rms = {pkg_id for pkg_id in bins if pkg_id not in smoothbins} 

315 

316 return rms, smoothbins 

317 

318 def _apply_item_to_target_suite( 

319 self, 

320 item: "MigrationItem", 

321 removals: set["BinaryPackageId"] = cast(set["BinaryPackageId"], frozenset()), 

322 ) -> tuple[set["BinaryPackageId"], set["BinaryPackageId"]]: 

323 """Apply a change to the target suite as requested by `item` 

324 

325 An optional set of binaries may be passed in "removals". Binaries listed 

326 in this set will be assumed to be removed at the same time as the "item" 

327 will migrate. This may change what binaries will be smooth-updated. 

328 - Binaries in this set must be instances of BinaryPackageId. 

329 

330 This method applies the changes required by the action `item` tracking 

331 them so it will be possible to revert them. 

332 

333 The method returns a tuple containing a set of packages 

334 affected by the change (as (name, arch)-tuples) and the 

335 dictionary undo which can be used to rollback the changes. 

336 """ 

337 undo: UndoItem = {"binaries": {}, "sources": {}, "virtual": {}} 

338 

339 affected_all = set() 

340 updated_binaries = set() 

341 

342 # local copies for better performance 

343 source_suite = item.suite 

344 target_suite = self.suite_info.target_suite 

345 packages_t = target_suite.binaries 

346 provides_t = target_suite.provides_table 

347 pkg_universe = self.pkg_universe 

348 transaction = self.current_transaction 

349 

350 source_name, updates, rms, smooth_updates = self.compute_groups( 

351 item, removals=removals 

352 ) 

353 sources_t = target_suite.sources 

354 # Handle the source package 

355 old_source = sources_t.get(source_name) 

356 

357 # add/update the source package 

358 if item.is_removal and item.architecture == "source": 

359 del sources_t[source_name] 

360 else: 

361 # with OUTOFSYNC_ARCHES, the source can be removed before out-of-sync binaries are removed 

362 if not item.is_removal or source_name in source_suite.sources: 362 ↛ 371line 362 didn't jump to line 371 because the condition on line 362 was always true

363 # always create a copy of the SourcePackage object 

364 sources_t[source_name] = copy.copy(source_suite.sources[source_name]) 

365 if old_source is not None: 

366 # always create a new list of binaries 

367 sources_t[source_name].binaries = copy.copy(old_source.binaries) 

368 else: 

369 sources_t[source_name].binaries = set() 

370 

371 undo["sources"][source_name] = old_source 

372 

373 eqv_set = compute_eqv_set(pkg_universe, updates, rms) 

374 

375 # remove all the binaries which aren't being smooth updated 

376 for rm_pkg_id in rms: 

377 binary = rm_pkg_id.package_name 

378 parch = rm_pkg_id.architecture 

379 pkey = (binary, parch) 

380 binaries_t_a = packages_t[parch] 

381 provides_t_a = provides_t[parch] 

382 

383 pkg_data = binaries_t_a[binary] 

384 # save the old binary for undo 

385 undo["binaries"][pkey] = rm_pkg_id 

386 if pkey not in eqv_set: 

387 # all the reverse dependencies are affected by 

388 # the change 

389 affected_all.update(pkg_universe.reverse_dependencies_of(rm_pkg_id)) 

390 affected_all.update(pkg_universe.negative_dependencies_of(rm_pkg_id)) 

391 

392 # remove the provided virtual packages 

393 for provided_pkg, prov_version, _ in ( 

394 pkg_data.provides if pkg_data.provides is not None else [] 

395 ): 

396 key = (provided_pkg, parch) 

397 if key not in undo["virtual"]: 

398 undo["virtual"][key] = provides_t_a[provided_pkg].copy() 

399 provides_t_a[provided_pkg].remove((binary, prov_version)) 

400 if not provides_t_a[provided_pkg]: 

401 del provides_t_a[provided_pkg] 

402 # for source removal, the source is already gone 

403 if source_name in sources_t: 

404 sources_t[source_name].binaries.discard(rm_pkg_id) 

405 # finally, remove the binary package 

406 del binaries_t_a[binary] 

407 target_suite.remove_binary(rm_pkg_id) 

408 

409 # Add/Update binary packages in testing 

410 if updates: 

411 packages_s = source_suite.binaries 

412 

413 for updated_pkg_id in updates: 

414 binary, parch = updated_pkg_id.package_name, updated_pkg_id.architecture 

415 key = (binary, parch) 

416 binaries_t_a = packages_t[parch] 

417 provides_t_a = provides_t[parch] 

418 equivalent_replacement = key in eqv_set 

419 

420 # obviously, added/modified packages are affected 

421 if not equivalent_replacement: 

422 affected_all.add(updated_pkg_id) 

423 # if the binary already exists in testing, it is currently 

424 # built by another source package. we therefore remove the 

425 # version built by the other source package, after marking 

426 # all of its reverse dependencies as affected 

427 if binary in binaries_t_a: 

428 old_pkg_data = binaries_t_a[binary] 

429 old_pkg_id = old_pkg_data.pkg_id 

430 # save the old binary package 

431 undo["binaries"][key] = old_pkg_id 

432 if not equivalent_replacement: 432 ↛ 437line 432 didn't jump to line 437 because the condition on line 432 was always true

433 # all the reverse conflicts 

434 affected_all.update( 

435 pkg_universe.reverse_dependencies_of(old_pkg_id) 

436 ) 

437 target_suite.remove_binary(old_pkg_id) 

438 elif transaction and transaction.parent_transaction: 

439 # the binary isn't in the target suite, but it may have been at 

440 # the start of the current hint and have been removed 

441 # by an earlier migration. if that's the case then we 

442 # will have a record of the older instance of the binary 

443 # in the undo information. we can use that to ensure 

444 # that the reverse dependencies of the older binary 

445 # package are also checked. 

446 # reverse dependencies built from this source can be 

447 # ignored as their reverse trees are already handled 

448 # by this function 

449 for tundo, tpkg in transaction.parent_transaction.undo_items: 

450 if key in tundo["binaries"]: 450 ↛ 451line 450 didn't jump to line 451 because the condition on line 450 was never true

451 tpkg_id = tundo["binaries"][key] 

452 affected_all.update( 

453 pkg_universe.reverse_dependencies_of(tpkg_id) 

454 ) 

455 

456 # add/update the binary package from the source suite 

457 new_pkg_data = packages_s[parch][binary] 

458 binaries_t_a[binary] = new_pkg_data 

459 target_suite.add_binary(updated_pkg_id) 

460 updated_binaries.add(updated_pkg_id) 

461 # add the binary to the source package 

462 sources_t[source_name].binaries.add(updated_pkg_id) 

463 # register new provided packages 

464 for provided_pkg, prov_version, _ in ( 

465 new_pkg_data.provides if new_pkg_data.provides is not None else [] 

466 ): 

467 key = (provided_pkg, parch) 

468 if key not in undo["virtual"]: 

469 restore_as = ( 

470 provides_t_a[provided_pkg].copy() 

471 if provided_pkg in provides_t_a 

472 else None 

473 ) 

474 undo["virtual"][key] = restore_as 

475 provides_t_a[provided_pkg].add((binary, prov_version)) 

476 if not equivalent_replacement: 

477 # all the reverse dependencies are affected by the change 

478 affected_all.add(updated_pkg_id) 

479 affected_all.update( 

480 pkg_universe.negative_dependencies_of(updated_pkg_id) 

481 ) 

482 

483 # Also include the transitive rdeps of the packages found so far 

484 compute_reverse_tree(pkg_universe, affected_all) 

485 if transaction: 

486 transaction.add_undo_item(undo, updated_binaries) 

487 # return the affected packages (direct and than all) 

488 return (affected_all, smooth_updates) 

489 

490 def _apply_multiple_items_to_target_suite( 

491 self, items: list["MigrationItem"] 

492 ) -> tuple[ 

493 bool, 

494 frozenset[str] | set[str], 

495 set["BinaryPackageId"], 

496 set["BinaryPackageId"], 

497 ]: 

498 is_source_migration = False 

499 if len(items) == 1: 

500 item = items[0] 

501 # apply the changes 

502 affected_all, smooth_updates = self._apply_item_to_target_suite(item) 

503 if item.architecture == "source": 

504 affected_architectures: frozenset[str] | set[str] = ( 

505 self._all_architectures 

506 ) 

507 is_source_migration = True 

508 else: 

509 affected_architectures = {item.architecture} 

510 else: 

511 affected_architectures = set() 

512 removals: set[BinaryPackageId] = set() 

513 affected_all = set() 

514 smooth_updates = set() 

515 for item in items: 

516 _, _, rms, _ = self.compute_groups(item, allow_smooth_updates=False) 

517 removals.update(rms) 

518 affected_architectures.add(item.architecture) 

519 

520 if "source" in affected_architectures: 

521 affected_architectures = self._all_architectures 

522 is_source_migration = True 

523 

524 for item in items: 

525 item_affected_all, item_smooth = self._apply_item_to_target_suite( 

526 item, removals=removals 

527 ) 

528 affected_all.update(item_affected_all) 

529 smooth_updates.update(item_smooth) 

530 

531 return is_source_migration, affected_architectures, affected_all, smooth_updates 

532 

533 def migrate_items_to_target_suite( 

534 self, 

535 items: list["MigrationItem"], 

536 nuninst_now: dict[str, set[str]], 

537 stop_on_first_regression: bool = True, 

538 ) -> tuple[bool, dict[str, set[str]], str | None, set["MigrationItem"]]: 

539 is_accepted = True 

540 target_suite = self.suite_info.target_suite 

541 packages_t = target_suite.binaries 

542 

543 nobreakall_arches = self.options.nobreakall_arches 

544 new_arches = self.options.new_arches 

545 break_arches = self.options.break_arches 

546 arch = None 

547 

548 is_source_migration, affected_architectures, affected_all, smooth_updates = ( 

549 self._apply_multiple_items_to_target_suite(items) 

550 ) 

551 

552 # Copy nuninst_comp - we have to deep clone affected 

553 # architectures. 

554 

555 # NB: We do this *after* updating testing as we have to filter out 

556 # removed binaries. Otherwise, uninstallable binaries that were 

557 # removed by the item would still be counted. 

558 

559 nuninst_after = clone_nuninst( 

560 nuninst_now, packages_s=packages_t, architectures=affected_architectures 

561 ) 

562 must_be_installable = self.constraints["keep-installable"] 

563 

564 # check the affected packages on all the architectures 

565 for arch in sorted(affected_architectures): 

566 check_archall = arch in nobreakall_arches 

567 

568 check_installability( 

569 target_suite, 

570 packages_t, 

571 arch, 

572 affected_all, 

573 check_archall, 

574 nuninst_after, 

575 ) 

576 

577 # if the uninstallability counter is worse than before, break the loop 

578 if stop_on_first_regression: 

579 if is_nuninst_worse( 

580 must_be_installable, 

581 nuninst_now[arch], 

582 nuninst_after[arch], 

583 self.allow_uninst[arch], 

584 ): 

585 if arch not in break_arches: 

586 is_accepted = False 

587 break 

588 # ... except for a few special cases: 

589 elif is_source_migration or arch in new_arches: 589 ↛ 592line 589 didn't jump to line 592 because the condition on line 589 was always true

590 pass 

591 else: 

592 is_accepted = False 

593 break 

594 

595 new_cruft = { 

596 self._migration_item_factory.generate_removal_for_cruft_item(x) 

597 for x in smooth_updates 

598 } 

599 

600 return (is_accepted, nuninst_after, arch, new_cruft) 

601 

602 @contextlib.contextmanager 

603 def start_transaction(self) -> Iterator[MigrationTransactionState]: 

604 tmts = MigrationTransactionState( 

605 self.suite_info, self.all_binaries, self.current_transaction 

606 ) 

607 self._transactions.append(tmts) 

608 try: 

609 yield tmts 

610 except Exception: 

611 if not tmts.is_committed and not tmts.is_rolled_back: 

612 tmts.rollback() 

613 raise 

614 finally: 

615 self._transactions.pop() 615 ↛ exitline 615 didn't except from function 'start_transaction' because the raise on line 613 wasn't executed

616 assert tmts.is_rolled_back or tmts.is_committed