Coverage for britney2/policies/policy.py: 92%

1373 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2026-08-18 12:43 +0000

1import json 

2import logging 

3import optparse 

4import os 

5import re 

6import sys 

7import time 

8from abc import ABC, abstractmethod 

9from collections import defaultdict 

10from collections.abc import Callable, Container, Iterator 

11from dataclasses import dataclass, field 

12from enum import Enum, IntEnum, StrEnum, auto, unique 

13from itertools import chain 

14from typing import TYPE_CHECKING, Any, Generic, Optional, TypeVar, cast 

15from urllib.parse import quote 

16 

17import apt_pkg 

18import yaml 

19from more_itertools import first 

20 

21from britney2 import ( 

22 BinaryPackage, 

23 BinaryPackageId, 

24 DependencyType, 

25 PackageId, 

26 SourcePackage, 

27 Suite, 

28 SuiteClass, 

29 Suites, 

30 TargetSuite, 

31) 

32from britney2.excusedeps import DependencySpec 

33from britney2.hints import ( 

34 Hint, 

35 HintAnnotate, 

36 HintCollection, 

37 HintParser, 

38 HintType, 

39 PolicyHintParserProto, 

40) 

41from britney2.inputs.suiteloader import SuiteContentLoader 

42from britney2.migrationitem import MigrationItem, MigrationItemFactory 

43from britney2.policies import ApplySrcPolicy, PolicyVerdict 

44from britney2.utils import ( 

45 binaries_from_source_version, 

46 compute_reverse_tree, 

47 filter_out_faux, 

48 filter_out_faux_gen, 

49 find_newer_binaries, 

50 get_component, 

51 get_dependency_solvers, 

52 is_smooth_update_allowed, 

53 parse_option, 

54) 

55 

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

57 from ..britney import Britney 

58 from ..excuse import Excuse 

59 from ..installability.universe import BinaryPackageUniverse 

60 

61 

62class PolicyLoadRequest: 

63 __slots__ = ("_options_name", "_default_value", "_policy_constructor") 

64 

65 def __init__( 

66 self, 

67 policy_constructor: Callable[[optparse.Values, Suites], "BasePolicy"], 

68 options_name: str | None, 

69 default_value: bool, 

70 ) -> None: 

71 self._policy_constructor = policy_constructor 

72 self._options_name = options_name 

73 self._default_value = default_value 

74 

75 def is_enabled(self, options: optparse.Values) -> bool: 

76 if self._options_name is None: 

77 assert self._default_value 

78 return True 

79 actual_value = getattr(options, self._options_name, None) 

80 if actual_value is None: 

81 return self._default_value 

82 return actual_value.lower() in ("yes", "y", "true", "t") 

83 

84 def load(self, options: optparse.Values, suite_info: Suites) -> "BasePolicy": 

85 return self._policy_constructor(options, suite_info) 

86 

87 @classmethod 

88 def always_load( 

89 cls, policy_constructor: Callable[[optparse.Values, Suites], "BasePolicy"] 

90 ) -> "PolicyLoadRequest": 

91 return cls(policy_constructor, None, True) 

92 

93 @classmethod 

94 def conditionally_load( 

95 cls, 

96 policy_constructor: Callable[[optparse.Values, Suites], "BasePolicy"], 

97 option_name: str, 

98 default_value: bool, 

99 ) -> "PolicyLoadRequest": 

100 return cls(policy_constructor, option_name, default_value) 

101 

102 

103class PolicyEngine: 

104 def __init__(self) -> None: 

105 self._policies: list["BasePolicy"] = [] 

106 

107 def add_policy(self, policy: "BasePolicy") -> None: 

108 self._policies.append(policy) 

109 

110 def load_policies( 

111 self, 

112 options: optparse.Values, 

113 suite_info: Suites, 

114 policy_load_requests: list[PolicyLoadRequest], 

115 ) -> None: 

116 for policy_load_request in policy_load_requests: 

117 if policy_load_request.is_enabled(options): 

118 self.add_policy(policy_load_request.load(options, suite_info)) 

119 

120 def register_policy_hints(self, hint_parser: HintParser) -> None: 

121 for policy in self._policies: 

122 policy.register_hints(hint_parser) 

123 

124 def initialise(self, britney: "Britney", hints: HintCollection) -> None: 

125 for policy in self._policies: 

126 policy.hints = hints 

127 policy.initialise(britney) 

128 

129 def save_state(self, britney: "Britney") -> None: 

130 for policy in self._policies: 

131 policy.save_state(britney) 

132 

133 def apply_src_policies( 

134 self, 

135 source_t: SourcePackage | None, 

136 source_u: SourcePackage, 

137 excuse: "Excuse", 

138 ) -> None: 

139 excuse_verdict = excuse.policy_verdict 

140 source_suite = excuse.item.suite 

141 suite_class = source_suite.suite_class 

142 for policy in self._policies: 

143 pinfo: dict[str, Any] = {} 

144 policy_verdict = PolicyVerdict.NOT_APPLICABLE 

145 if suite_class in policy.applicable_suites: 

146 if policy.src_policy.run_arch: 

147 for arch in policy.options.architectures: 

148 v = policy.apply_srcarch_policy_impl( 

149 pinfo, arch, source_t, source_u, excuse 

150 ) 

151 policy_verdict = PolicyVerdict.worst_of(policy_verdict, v) 

152 if policy.src_policy.run_src: 

153 v = policy.apply_src_policy_impl(pinfo, source_t, source_u, excuse) 

154 policy_verdict = PolicyVerdict.worst_of(policy_verdict, v) 

155 # The base policy provides this field, so the subclass should leave it blank 

156 assert "verdict" not in pinfo 

157 if policy_verdict is not PolicyVerdict.NOT_APPLICABLE: 

158 excuse.policy_info[policy.policy_id] = pinfo 

159 pinfo["verdict"] = policy_verdict.name 

160 excuse_verdict = PolicyVerdict.worst_of(policy_verdict, excuse_verdict) 

161 excuse.policy_verdict = excuse_verdict 

162 

163 def apply_srcarch_policies( 

164 self, 

165 arch: str, 

166 source_t: SourcePackage | None, 

167 source_u: SourcePackage, 

168 excuse: "Excuse", 

169 ) -> None: 

170 excuse_verdict = excuse.policy_verdict 

171 source_suite = excuse.item.suite 

172 suite_class = source_suite.suite_class 

173 for policy in self._policies: 

174 pinfo: dict[str, Any] = {} 

175 if suite_class in policy.applicable_suites: 

176 policy_verdict = policy.apply_srcarch_policy_impl( 

177 pinfo, arch, source_t, source_u, excuse 

178 ) 

179 excuse_verdict = PolicyVerdict.worst_of(policy_verdict, excuse_verdict) 

180 # The base policy provides this field, so the subclass should leave it blank 

181 assert "verdict" not in pinfo 

182 if policy_verdict is not PolicyVerdict.NOT_APPLICABLE: 

183 excuse.policy_info[policy.policy_id] = pinfo 

184 pinfo["verdict"] = policy_verdict.name 

185 excuse.policy_verdict = excuse_verdict 

186 

187 

188class BasePolicy(ABC): 

189 britney: "Britney" 

190 policy_id: str 

191 hints: HintCollection | None 

192 applicable_suites: set[SuiteClass] 

193 src_policy: ApplySrcPolicy 

194 options: optparse.Values 

195 suite_info: Suites 

196 

197 def __init__( 

198 self, 

199 options: optparse.Values, 

200 suite_info: Suites, 

201 ) -> None: 

202 """The BasePolicy constructor 

203 

204 :param options: The options member of Britney with all the 

205 config values. 

206 """ 

207 

208 @property 

209 @abstractmethod 

210 def state_dir(self) -> str: ... 210 ↛ exitline 210 didn't return from function 'state_dir' because

211 

212 def register_hints(self, hint_parser: HintParser) -> None: # pragma: no cover 

213 """Register new hints that this policy accepts 

214 

215 :param hint_parser: (see HintParser.register_hint_type) 

216 """ 

217 

218 def initialise(self, britney: "Britney") -> None: # pragma: no cover 

219 """Called once to make the policy initialise any data structures 

220 

221 This is useful for e.g. parsing files or other "heavy do-once" work. 

222 

223 :param britney: This is the instance of the "Britney" class. 

224 """ 

225 self.britney = britney 

226 

227 def save_state(self, britney: "Britney") -> None: # pragma: no cover 

228 """Called once at the end of the run to make the policy save any persistent data 

229 

230 Note this will *not* be called for "dry-runs" as such runs should not change 

231 the state. 

232 

233 :param britney: This is the instance of the "Britney" class. 

234 """ 

235 

236 def apply_src_policy_impl( 

237 self, 

238 policy_info: dict[str, Any], 

239 source_data_tdist: SourcePackage | None, 

240 source_data_srcdist: SourcePackage, 

241 excuse: "Excuse", 

242 ) -> PolicyVerdict: # pragma: no cover 

243 """Apply a policy on a given source migration 

244 

245 Britney will call this method on a given source package, when 

246 Britney is considering to migrate it from the given source 

247 suite to the target suite. The policy will then evaluate the 

248 the migration and then return a verdict. 

249 

250 :param policy_info: A dictionary of all policy results. The 

251 policy can add a value stored in a key related to its name. 

252 (e.g. policy_info['age'] = {...}). This will go directly into 

253 the "excuses.yaml" output. 

254 

255 :param source_data_tdist: Information about the source package 

256 in the target distribution (e.g. "testing"). This is the 

257 data structure in source_suite.sources[source_name] 

258 

259 :param source_data_srcdist: Information about the source 

260 package in the source distribution (e.g. "unstable" or "tpu"). 

261 This is the data structure in target_suite.sources[source_name] 

262 

263 :return: A Policy Verdict (e.g. PolicyVerdict.PASS) 

264 """ 

265 return PolicyVerdict.NOT_APPLICABLE 

266 

267 def apply_srcarch_policy_impl( 

268 self, 

269 policy_info: dict[str, Any], 

270 arch: str, 

271 source_data_tdist: SourcePackage | None, 

272 source_data_srcdist: SourcePackage, 

273 excuse: "Excuse", 

274 ) -> PolicyVerdict: 

275 """Apply a policy on a given binary migration 

276 

277 Britney will call this method on binaries from a given source package 

278 on a given architecture, when Britney is considering to migrate them 

279 from the given source suite to the target suite. The policy will then 

280 evaluate the migration and then return a verdict. 

281 

282 :param policy_info: A dictionary of all policy results. The 

283 policy can add a value stored in a key related to its name. 

284 (e.g. policy_info['age'] = {...}). This will go directly into 

285 the "excuses.yaml" output. 

286 

287 :param arch: The architecture the item is applied to. This is mostly 

288 relevant for policies where src_policy is not ApplySrcPolicy.RUN_SRC 

289 (as that is the only case where arch can differ from item.architecture) 

290 

291 :param source_data_tdist: Information about the source package 

292 in the target distribution (e.g. "testing"). This is the 

293 data structure in source_suite.sources[source_name] 

294 

295 :param source_data_srcdist: Information about the source 

296 package in the source distribution (e.g. "unstable" or "tpu"). 

297 This is the data structure in target_suite.sources[source_name] 

298 

299 :return: A Policy Verdict (e.g. PolicyVerdict.PASS) 

300 """ 

301 # if the policy doesn't implement this function, assume it's OK 

302 return PolicyVerdict.NOT_APPLICABLE 

303 

304 

305class AbstractBasePolicy(BasePolicy): 

306 """ 

307 A shared abstract class for building BasePolicy objects. 

308 

309 tests/test_policy.py:initialize_policy() needs to be able to build BasePolicy 

310 objects with just a two-item constructor, while all other uses of BasePolicy- 

311 derived objects need the 5-item constructor. So AbstractBasePolicy was split 

312 out to document this. 

313 """ 

314 

315 def __init__( 

316 self, 

317 policy_id: str, 

318 options: optparse.Values, 

319 suite_info: Suites, 

320 applicable_suites: set[SuiteClass], 

321 src_policy: ApplySrcPolicy = ApplySrcPolicy.RUN_SRC, 

322 ) -> None: 

323 """Concrete initializer. 

324 

325 :param policy_id: Identifies the policy. It will 

326 determine the key used for the excuses.yaml etc. 

327 

328 :param options: The options member of Britney with all the 

329 config values. 

330 

331 :param applicable_suites: Where this policy applies. 

332 """ 

333 self.policy_id = policy_id 

334 self.options = options 

335 self.suite_info = suite_info 

336 self.applicable_suites = applicable_suites 

337 self.src_policy = src_policy 

338 self.hints: HintCollection | None = None 

339 logger_name = ".".join((self.__class__.__module__, self.__class__.__name__)) 

340 self.logger = logging.getLogger(logger_name) 

341 

342 @property 

343 def state_dir(self) -> str: 

344 return cast(str, self.options.state_dir) 

345 

346 

347_T = TypeVar("_T") 

348 

349 

350class SimplePolicyHint(Hint, Generic[_T]): 

351 def __init__( 

352 self, 

353 user: str, 

354 hint_type: HintType, 

355 policy_parameter: _T, 

356 packages: list[MigrationItem], 

357 ) -> None: 

358 super().__init__(user, hint_type, packages) 

359 self._policy_parameter = policy_parameter 

360 

361 def __eq__(self, other: Any) -> bool: 

362 if self.type != other.type or self._policy_parameter != other._policy_parameter: 

363 return False 

364 return super().__eq__(other) 

365 

366 def str(self) -> str: 

367 return "{} {} {}".format( 

368 self._type, 

369 str(self._policy_parameter), 

370 " ".join(x.name for x in self._packages), 

371 ) 

372 

373 

374class AgeDayHint(SimplePolicyHint[int]): 

375 @property 

376 def days(self) -> int: 

377 return self._policy_parameter 

378 

379 

380class IgnoreRCBugHint(SimplePolicyHint[frozenset[str]]): 

381 @property 

382 def ignored_rcbugs(self) -> frozenset[str]: 

383 return self._policy_parameter 

384 

385 

386def simple_policy_hint_parser_function( 

387 class_name: Callable[[str, HintType, _T, list[MigrationItem]], Hint], 

388 converter: Callable[[str], _T], 

389) -> PolicyHintParserProto: 

390 def f( 

391 mi_factory: MigrationItemFactory, 

392 hints: HintCollection, 

393 who: str, 

394 hint_type: HintType, 

395 *args: str, 

396 ) -> None: 

397 policy_parameter = args[0] 

398 for item in mi_factory.parse_items(args[1:]): 

399 hints.add_hint( 

400 class_name(who, hint_type, converter(policy_parameter), [item]) 

401 ) 

402 

403 return f 

404 

405 

406class AgePolicy(AbstractBasePolicy): 

407 """Configurable Aging policy for source migrations 

408 

409 The AgePolicy will let packages stay in the source suite for a pre-defined 

410 amount of days before letting migrate (based on their urgency, if any). 

411 

412 The AgePolicy's decision is influenced by the following: 

413 

414 State files: 

415 * ${STATE_DIR}/age-policy-urgencies: File containing urgencies for source 

416 packages. Note that urgencies are "sticky" and the most "urgent" urgency 

417 will be used (i.e. the one with lowest age-requirements). 

418 - This file needs to be updated externally, if the policy should take 

419 urgencies into consideration. If empty (or not updated), the policy 

420 will simply use the default urgency (see the "Config" section below) 

421 - In Debian, these values are taken from the .changes file, but that is 

422 not a requirement for Britney. 

423 * ${STATE_DIR}/age-policy-dates: File containing the age of all source 

424 packages. 

425 - The policy will automatically update this file. 

426 Config: 

427 * DEFAULT_URGENCY: Name of the urgency used for packages without an urgency 

428 (or for unknown urgencies). Will also be used to set the "minimum" 

429 aging requirements for packages not in the target suite. 

430 * MINDAYS_<URGENCY>: The age-requirements in days for packages with the 

431 given urgency. 

432 - Commonly used urgencies are: low, medium, high, emergency, critical 

433 Hints: 

434 * urgent <source>/<version>: Disregard the age requirements for a given 

435 source/version. 

436 * age-days X <source>/<version>: Set the age requirements for a given 

437 source/version to X days. Note that X can exceed the highest 

438 age-requirement normally given. 

439 

440 """ 

441 

442 def __init__(self, options: optparse.Values, suite_info: Suites) -> None: 

443 super().__init__("age", options, suite_info, {SuiteClass.PRIMARY_SOURCE_SUITE}) 

444 self._min_days = self._generate_mindays_table() 

445 self._min_days_default = 0 

446 # britney's "day" begins at 7pm (we want aging to occur in the 22:00Z run and we run Britney 2-4 times a day) 

447 # NB: _date_now is used in tests 

448 time_now = time.time() 

449 if hasattr(self.options, "fake_runtime"): 

450 time_now = int(self.options.fake_runtime) 

451 self.logger.info("overriding runtime with fake_runtime %d", time_now) 

452 

453 self._date_now = int(((time_now / (60 * 60)) - 19) / 24) 

454 self._dates: dict[str, tuple[str, int]] = {} 

455 self._urgencies: dict[str, str] = {} 

456 self._default_urgency: str = self.options.default_urgency 

457 self._penalty_immune_urgencies: frozenset[str] = frozenset() 

458 if hasattr(self.options, "no_penalties"): 

459 self._penalty_immune_urgencies = frozenset( 

460 x.strip() for x in self.options.no_penalties.split() 

461 ) 

462 self._bounty_min_age: int | None = None # initialised later 

463 

464 def _generate_mindays_table(self) -> dict[str, int]: 

465 mindays: dict[str, int] = {} 

466 for k in dir(self.options): 

467 if not k.startswith("mindays_"): 

468 continue 

469 v = getattr(self.options, k) 

470 try: 

471 as_days = int(v) 

472 except ValueError: 

473 raise ValueError( 

474 "Unable to parse " 

475 + k 

476 + " as a number of days. Must be 0 or a positive integer" 

477 ) 

478 if as_days < 0: 478 ↛ 479line 478 didn't jump to line 479 because the condition on line 478 was never true

479 raise ValueError( 

480 "The value of " + k + " must be zero or a positive integer" 

481 ) 

482 mindays[k.split("_")[1]] = as_days 

483 return mindays 

484 

485 def register_hints(self, hint_parser: HintParser) -> None: 

486 hint_parser.register_hint_type( 

487 HintType( 

488 "age-days", 

489 simple_policy_hint_parser_function(AgeDayHint, int), 

490 min_args=2, 

491 ) 

492 ) 

493 hint_parser.register_hint_type(HintType("urgent")) 

494 

495 def initialise(self, britney: "Britney") -> None: 

496 super().initialise(britney) 

497 self._read_dates_file() 

498 self._read_urgencies_file() 

499 if self._default_urgency not in self._min_days: # pragma: no cover 

500 raise ValueError( 

501 f"Missing age-requirement for default urgency (MINDAYS_{self._default_urgency})" 

502 ) 

503 self._min_days_default = self._min_days[self._default_urgency] 

504 try: 

505 self._bounty_min_age = int(self.options.bounty_min_age) 

506 except ValueError: 506 ↛ 507line 506 didn't jump to line 507 because the exception caught by line 506 didn't happen

507 if self.options.bounty_min_age in self._min_days: 

508 self._bounty_min_age = self._min_days[self.options.bounty_min_age] 

509 else: # pragma: no cover 

510 raise ValueError( 

511 "Please fix BOUNTY_MIN_AGE in the britney configuration" 

512 ) 

513 except AttributeError: 

514 # The option wasn't defined in the configuration 

515 self._bounty_min_age = 0 

516 

517 def save_state(self, britney: "Britney") -> None: 

518 super().save_state(britney) 

519 self._write_dates_file() 

520 

521 def apply_src_policy_impl( 

522 self, 

523 age_info: dict[str, Any], 

524 source_data_tdist: SourcePackage | None, 

525 source_data_srcdist: SourcePackage, 

526 excuse: "Excuse", 

527 ) -> PolicyVerdict: 

528 # retrieve the urgency for the upload, ignoring it if this is a NEW package 

529 # (not present in the target suite) 

530 source_name = excuse.item.package 

531 urgency = self._urgencies.get(source_name, self._default_urgency) 

532 

533 if urgency not in self._min_days: 533 ↛ 534line 533 didn't jump to line 534 because the condition on line 533 was never true

534 age_info["unknown-urgency"] = urgency 

535 urgency = self._default_urgency 

536 

537 if not source_data_tdist: 

538 if self._min_days[urgency] < self._min_days_default: 

539 age_info["urgency-reduced"] = { 

540 "from": urgency, 

541 "to": self._default_urgency, 

542 } 

543 urgency = self._default_urgency 

544 

545 if source_name not in self._dates: 

546 self._dates[source_name] = (source_data_srcdist.version, self._date_now) 

547 elif self._dates[source_name][0] != source_data_srcdist.version: 

548 self._dates[source_name] = (source_data_srcdist.version, self._date_now) 

549 

550 days_old = self._date_now - self._dates[source_name][1] 

551 min_days = self._min_days[urgency] 

552 for bounty, bounty_value in excuse.bounty.items(): 

553 if bounty_value: 553 ↛ 552line 553 didn't jump to line 552 because the condition on line 553 was always true

554 self.logger.info( 

555 "Applying bounty for %s granted by %s: %d days", 

556 source_name, 

557 bounty, 

558 bounty_value, 

559 ) 

560 excuse.addinfo( 

561 f"Required age reduced by {bounty_value} days because of {bounty}" 

562 ) 

563 assert bounty_value > 0, "negative bounties shouldn't happen" 

564 min_days -= bounty_value 

565 if urgency not in self._penalty_immune_urgencies: 

566 for penalty, penalty_value in excuse.penalty.items(): 

567 if penalty_value: 567 ↛ 566line 567 didn't jump to line 566 because the condition on line 567 was always true

568 self.logger.info( 

569 "Applying penalty for %s given by %s: %d days", 

570 source_name, 

571 penalty, 

572 penalty_value, 

573 ) 

574 excuse.addinfo( 

575 f"Required age increased by {penalty_value} days because of {penalty}" 

576 ) 

577 assert ( 

578 penalty_value > 0 

579 ), "negative penalties should be handled earlier" 

580 min_days += penalty_value 

581 

582 assert self._bounty_min_age is not None 

583 # the age in BOUNTY_MIN_AGE can be higher than the one associated with 

584 # the real urgency, so don't forget to take it into account 

585 bounty_min_age = min(self._bounty_min_age, self._min_days[urgency]) 

586 if min_days < bounty_min_age: 

587 min_days = bounty_min_age 

588 excuse.addinfo(f"Required age is not allowed to drop below {min_days} days") 

589 

590 age_info["current-age"] = days_old 

591 

592 assert self.hints is not None 

593 for hint in self.hints.search( 

594 "age-days", package=source_name, version=source_data_srcdist.version 

595 ): 

596 age_days_hint = cast("AgeDayHint", hint) 

597 

598 new_req = age_days_hint.days 

599 age_info["age-requirement-reduced"] = { 

600 "new-requirement": new_req, 

601 "changed-by": age_days_hint.user, 

602 } 

603 if "original-age-requirement" not in age_info: 603 ↛ 605line 603 didn't jump to line 605 because the condition on line 603 was always true

604 age_info["original-age-requirement"] = min_days 

605 min_days = new_req 

606 

607 age_info["age-requirement"] = min_days 

608 res = PolicyVerdict.PASS 

609 

610 if days_old < min_days: 

611 if ( 

612 urgent_hint := self.hints.search_first( 

613 "urgent", package=source_name, version=source_data_srcdist.version 

614 ) 

615 ) is not None: 

616 age_info["age-requirement-reduced"] = { 

617 "new-requirement": 0, 

618 "changed-by": urgent_hint.user, 

619 } 

620 res = PolicyVerdict.PASS_HINTED 

621 else: 

622 res = PolicyVerdict.REJECTED_TEMPORARILY 

623 

624 # update excuse 

625 age_hint = age_info.get("age-requirement-reduced") 

626 age_min_req = age_info["age-requirement"] 

627 if age_hint is not None: 

628 new_req = age_hint["new-requirement"] 

629 who = age_hint["changed-by"] 

630 if new_req: 

631 excuse.addinfo( 

632 f"Overriding age needed from {age_min_req} days to {new_req} by {who}" 

633 ) 

634 age_min_req = new_req 

635 else: 

636 excuse.addinfo(f"Too young, but urgency pushed by {who}") 

637 age_min_req = 0 

638 excuse.setdaysold(age_info["current-age"], age_min_req) 

639 

640 if age_min_req == 0: 

641 excuse.addinfo(f"{days_old} days old") 

642 elif days_old < age_min_req: 

643 excuse.add_verdict_info( 

644 res, f"Too young, only {days_old} of {age_min_req} days old" 

645 ) 

646 else: 

647 excuse.addinfo(f"{days_old} days old (needed {age_min_req} days)") 

648 

649 return res 

650 

651 def _read_dates_file(self) -> None: 

652 """Parse the dates file""" 

653 dates = self._dates 

654 fallback_filename = os.path.join(self.suite_info.target_suite.path, "Dates") 

655 using_new_name = False 

656 try: 

657 filename = os.path.join(self.state_dir, "age-policy-dates") 

658 if not os.path.exists(filename) and os.path.exists(fallback_filename): 658 ↛ 659line 658 didn't jump to line 659 because the condition on line 658 was never true

659 filename = fallback_filename 

660 else: 

661 using_new_name = True 

662 except AttributeError: 

663 if os.path.exists(fallback_filename): 

664 filename = fallback_filename 

665 else: 

666 raise RuntimeError("Please set STATE_DIR in the britney configuration") 

667 

668 try: 

669 with open(filename, encoding="utf-8") as fd: 

670 for line in fd: 

671 if line.startswith("#"): 

672 # Ignore comment lines (mostly used for tests) 

673 continue 

674 # <source> <version> <date>) 

675 ln = line.split() 

676 if len(ln) != 3: # pragma: no cover 

677 continue 

678 try: 

679 dates[ln[0]] = (ln[1], int(ln[2])) 

680 except ValueError: # pragma: no cover 

681 pass 

682 except FileNotFoundError: 

683 if not using_new_name: 683 ↛ 685line 683 didn't jump to line 685 because the condition on line 683 was never true

684 # If we using the legacy name, then just give up 

685 raise 

686 self.logger.info("%s does not appear to exist. Creating it", filename) 

687 with open(filename, mode="x", encoding="utf-8"): 

688 pass 

689 

690 def _read_urgencies_file(self) -> None: 

691 urgencies = self._urgencies 

692 min_days_default = self._min_days_default 

693 fallback_filename = os.path.join(self.suite_info.target_suite.path, "Urgency") 

694 try: 

695 filename = os.path.join(self.state_dir, "age-policy-urgencies") 

696 if not os.path.exists(filename) and os.path.exists(fallback_filename): 696 ↛ 697line 696 didn't jump to line 697 because the condition on line 696 was never true

697 filename = fallback_filename 

698 except AttributeError: 

699 filename = fallback_filename 

700 

701 sources_s = self.suite_info.primary_source_suite.sources 

702 sources_t = self.suite_info.target_suite.sources 

703 

704 with open(filename, errors="surrogateescape", encoding="ascii") as fd: 

705 for line in fd: 

706 if line.startswith("#"): 

707 # Ignore comment lines (mostly used for tests) 

708 continue 

709 # <source> <version> <urgency> 

710 ln = line.split() 

711 if len(ln) != 3: 711 ↛ 712line 711 didn't jump to line 712 because the condition on line 711 was never true

712 continue 

713 

714 # read the minimum days associated with the urgencies 

715 urgency_old = urgencies.get(ln[0], None) 

716 mindays_old = self._min_days.get(urgency_old, 1000) # type: ignore[arg-type] 

717 mindays_new = self._min_days.get(ln[2], min_days_default) 

718 

719 # if the new urgency is lower (so the min days are higher), do nothing 

720 if mindays_old <= mindays_new: 

721 continue 

722 

723 # if the package exists in the target suite and it is more recent, do nothing 

724 tsrcv = sources_t.get(ln[0], None) 

725 if tsrcv and apt_pkg.version_compare(tsrcv.version, ln[1]) >= 0: 

726 continue 

727 

728 # if the package doesn't exist in the primary source suite or it is older, do nothing 

729 usrcv = sources_s.get(ln[0], None) 

730 if not usrcv or apt_pkg.version_compare(usrcv.version, ln[1]) < 0: 730 ↛ 731line 730 didn't jump to line 731 because the condition on line 730 was never true

731 continue 

732 

733 # update the urgency for the package 

734 urgencies[ln[0]] = ln[2] 

735 

736 def _write_dates_file(self) -> None: 

737 dates = self._dates 

738 try: 

739 directory = self.state_dir 

740 basename = "age-policy-dates" 

741 old_file = os.path.join(self.suite_info.target_suite.path, "Dates") 

742 except AttributeError: 

743 directory = self.suite_info.target_suite.path 

744 basename = "Dates" 

745 old_file = None 

746 filename = os.path.join(directory, basename) 

747 filename_tmp = os.path.join(directory, f"{basename}_new") 

748 with open(filename_tmp, "w", encoding="utf-8") as fd: 

749 fd.writelines( 

750 f"{pkg} {version} {date}\n" 

751 for pkg, (version, date) in sorted(dates.items()) 

752 ) 

753 os.rename(filename_tmp, filename) 

754 if old_file is not None and os.path.exists(old_file): 754 ↛ 755line 754 didn't jump to line 755 because the condition on line 754 was never true

755 self.logger.info("Removing old age-policy-dates file %s", old_file) 

756 os.unlink(old_file) 

757 

758 

759class RCBugPolicy(AbstractBasePolicy): 

760 """RC bug regression policy for source migrations 

761 

762 The RCBugPolicy will read provided list of RC bugs and block any 

763 source upload that would introduce a *new* RC bug in the target 

764 suite. 

765 

766 The RCBugPolicy's decision is influenced by the following: 

767 

768 State files: 

769 * ${STATE_DIR}/rc-bugs-${SUITE_NAME}: File containing RC bugs for packages in 

770 the given suite (one for both primary source suite and the target sutie is 

771 needed). 

772 - These files need to be updated externally. 

773 """ 

774 

775 def __init__(self, options: optparse.Values, suite_info: Suites) -> None: 

776 super().__init__( 

777 "rc-bugs", options, suite_info, {SuiteClass.PRIMARY_SOURCE_SUITE} 

778 ) 

779 self._bugs_source: dict[str, set[str]] | None = None 

780 self._bugs_target: dict[str, set[str]] | None = None 

781 

782 def register_hints(self, hint_parser: HintParser) -> None: 

783 f = simple_policy_hint_parser_function( 

784 IgnoreRCBugHint, lambda x: frozenset(x.split(",")) 

785 ) 

786 hint_parser.register_hint_type(HintType("ignore-rc-bugs", f, min_args=2)) 

787 

788 def initialise(self, britney: "Britney") -> None: 

789 super().initialise(britney) 

790 source_suite = self.suite_info.primary_source_suite 

791 target_suite = self.suite_info.target_suite 

792 fallback_unstable = os.path.join(source_suite.path, "BugsV") 

793 fallback_testing = os.path.join(target_suite.path, "BugsV") 

794 try: 

795 filename_unstable = os.path.join( 

796 self.state_dir, f"rc-bugs-{source_suite.name}" 

797 ) 

798 filename_testing = os.path.join( 

799 self.state_dir, f"rc-bugs-{target_suite.name}" 

800 ) 

801 if ( 801 ↛ 807line 801 didn't jump to line 807

802 not os.path.exists(filename_unstable) 

803 and not os.path.exists(filename_testing) 

804 and os.path.exists(fallback_unstable) 

805 and os.path.exists(fallback_testing) 

806 ): 

807 filename_unstable = fallback_unstable 

808 filename_testing = fallback_testing 

809 except AttributeError: 

810 filename_unstable = fallback_unstable 

811 filename_testing = fallback_testing 

812 self._bugs_source = self._read_bugs(filename_unstable) 

813 self._bugs_target = self._read_bugs(filename_testing) 

814 

815 def apply_src_policy_impl( 

816 self, 

817 rcbugs_info: dict[str, Any], 

818 source_data_tdist: SourcePackage | None, 

819 source_data_srcdist: SourcePackage, 

820 excuse: "Excuse", 

821 ) -> PolicyVerdict: 

822 assert self._bugs_source is not None # for type checking 

823 assert self._bugs_target is not None # for type checking 

824 bugs_t = set() 

825 bugs_s = set() 

826 source_name = excuse.item.package 

827 binaries_s = {x.package_name for x in source_data_srcdist.binaries} 

828 try: 

829 binaries_t = {x.package_name for x in source_data_tdist.binaries} # type: ignore[union-attr] 

830 except AttributeError: 

831 binaries_t = set() 

832 

833 src_key = f"src:{source_name}" 

834 if ( 

835 source_data_tdist is not None 

836 and (bugs := self._bugs_target.get(src_key)) is not None 

837 ): 

838 bugs_t.update(bugs) 

839 if (bugs := self._bugs_source.get(src_key)) is not None: 

840 bugs_s.update(bugs) 

841 

842 for pkg in binaries_s: 

843 if (bugs := self._bugs_source.get(pkg)) is not None: 

844 bugs_s |= bugs 

845 for pkg in binaries_t: 

846 if (bugs := self._bugs_target.get(pkg)) is not None: 

847 bugs_t |= bugs 

848 

849 # The bts seems to support filing source bugs against a binary of the 

850 # same name if that binary isn't built by any source. An example is bug 

851 # 820347 against Package: juce (in the live-2016-04-11 test). Add those 

852 # bugs too. 

853 if ( 

854 source_name not in (binaries_s | binaries_t) 

855 and not any( 

856 source_name in binaries 

857 for binaries in self.suite_info.primary_source_suite.binaries.values() 

858 ) 

859 and not any( 

860 source_name in binaries 

861 for binaries in self.suite_info.target_suite.binaries.values() 

862 ) 

863 ): 

864 if (bugs := self._bugs_source.get(source_name)) is not None: 

865 bugs_s |= bugs 

866 if (bugs := self._bugs_target.get(source_name)) is not None: 866 ↛ 867line 866 didn't jump to line 867 because the condition on line 866 was never true

867 bugs_t |= bugs 

868 

869 # If a package is not in the target suite, it has no RC bugs per 

870 # definition. Unfortunately, it seems that the live-data is 

871 # not always accurate (e.g. live-2011-12-13 suggests that 

872 # obdgpslogger had the same bug in testing and unstable, 

873 # but obdgpslogger was not in testing at that time). 

874 # - For the curious, obdgpslogger was removed on that day 

875 # and the BTS probably had not caught up with that fact. 

876 # (https://tracker.debian.org/news/415935) 

877 assert ( 

878 not bugs_t or source_data_tdist 

879 ), f"{source_name} had bugs in the target suite but is not present" 

880 

881 verdict = PolicyVerdict.PASS 

882 

883 assert self.hints is not None 

884 for hint in self.hints.search( 

885 "ignore-rc-bugs", 

886 package=source_name, 

887 version=source_data_srcdist.version, 

888 ): 

889 ignore_hint = cast(IgnoreRCBugHint, hint) 

890 ignored_bugs = ignore_hint.ignored_rcbugs 

891 

892 # Only handle one hint for now 

893 if (ignored := rcbugs_info.get("ignored-bugs")) is not None: 

894 self.logger.info( 

895 "Ignoring ignore-rc-bugs hint from %s on %s due to another hint from %s", 

896 ignore_hint.user, 

897 source_name, 

898 ignored["issued-by"], 

899 ) 

900 continue 

901 if not ignored_bugs.isdisjoint(bugs_s): 901 ↛ 910line 901 didn't jump to line 910 because the condition on line 901 was always true

902 bugs_s -= ignored_bugs 

903 bugs_t -= ignored_bugs 

904 rcbugs_info["ignored-bugs"] = { 

905 "bugs": sorted(ignored_bugs), 

906 "issued-by": ignore_hint.user, 

907 } 

908 verdict = PolicyVerdict.PASS_HINTED 

909 else: 

910 self.logger.info( 

911 "Ignoring ignore-rc-bugs hint from %s on %s as none of %s affect the package", 

912 ignore_hint.user, 

913 source_name, 

914 ignored_bugs, 

915 ) 

916 

917 rcbugs_info["shared-bugs"] = sorted(bugs_s & bugs_t) 

918 rcbugs_info["unique-source-bugs"] = sorted(bugs_s - bugs_t) 

919 rcbugs_info["unique-target-bugs"] = sorted(bugs_t - bugs_s) 

920 

921 # update excuse 

922 new_bugs = rcbugs_info["unique-source-bugs"] 

923 old_bugs = rcbugs_info["unique-target-bugs"] 

924 excuse.setbugs(old_bugs, new_bugs) 

925 

926 if new_bugs: 

927 verdict = PolicyVerdict.REJECTED_PERMANENTLY 

928 excuse.add_verdict_info( 

929 verdict, 

930 "Updating %s would introduce bugs in %s: %s" 

931 % ( 

932 source_name, 

933 self.suite_info.target_suite.name, 

934 ", ".join( 

935 f'<a href="https://bugs.debian.org/{quote(a)}">#{a}</a>' 

936 for a in new_bugs 

937 ), 

938 ), 

939 ) 

940 

941 if old_bugs: 

942 excuse.addinfo( 

943 "Updating %s will fix bugs in %s: %s" 

944 % ( 

945 source_name, 

946 self.suite_info.target_suite.name, 

947 ", ".join( 

948 f'<a href="https://bugs.debian.org/{quote(a)}">#{a}</a>' 

949 for a in old_bugs 

950 ), 

951 ) 

952 ) 

953 

954 return verdict 

955 

956 def _read_bugs(self, filename: str) -> dict[str, set[str]]: 

957 """Read the release critical bug summary from the specified file 

958 

959 The file contains rows with the format: 

960 

961 <package-name> <bug number>[,<bug number>...] 

962 

963 The method returns a dictionary where the key is the binary package 

964 name and the value is the list of open RC bugs for it. 

965 """ 

966 bugs: dict[str, set[str]] = {} 

967 self.logger.info("Loading RC bugs data from %s", filename) 

968 with open(filename, encoding="ascii") as f: 

969 for line in f: 

970 ln = line.split() 

971 if len(ln) != 2: # pragma: no cover 

972 self.logger.warning("Malformed line found in line %s", line) 

973 continue 

974 pkg = ln[0] 

975 if pkg not in bugs: 

976 bugs[pkg] = set() 

977 bugs[pkg].update(ln[1].split(",")) 

978 return bugs 

979 

980 

981class PiupartsState(Enum): 

982 FAIL = auto() 

983 PASS = auto() 

984 WAITING = auto() 

985 UNKNOWN = auto() 

986 

987 @staticmethod 

988 def from_str(val: str) -> "PiupartsState": 

989 match val: 

990 case "F": 

991 return PiupartsState.FAIL 

992 case "P": 

993 return PiupartsState.PASS 

994 case "W": 994 ↛ 996line 994 didn't jump to line 996 because the pattern on line 994 always matched

995 return PiupartsState.WAITING 

996 case "X": 

997 return PiupartsState.UNKNOWN 

998 case _: 

999 raise ValueError(f"Invalid piuparts state {val}") 

1000 

1001 

1002class PiupartsResult(StrEnum): 

1003 PASS = "pass" 

1004 REGRESSION = "regression" 

1005 FAILED = "failed" 

1006 WAITING_FOR_TESTS = "waiting-for-test-results" 

1007 CANNOT_BE_TESTED = "cannot-be-tested" 

1008 

1009 

1010class PiupartsPolicy(AbstractBasePolicy): 

1011 def __init__(self, options: optparse.Values, suite_info: Suites) -> None: 

1012 super().__init__( 

1013 "piuparts", options, suite_info, {SuiteClass.PRIMARY_SOURCE_SUITE} 

1014 ) 

1015 self._piuparts_source: dict[str, tuple[PiupartsState, str]] | None = None 

1016 self._piuparts_target: dict[str, PiupartsState] | None = None 

1017 

1018 def register_hints(self, hint_parser: HintParser) -> None: 

1019 hint_parser.register_hint_type(HintType("ignore-piuparts")) 

1020 

1021 def initialise(self, britney: "Britney") -> None: 

1022 super().initialise(britney) 

1023 source_suite = self.suite_info.primary_source_suite 

1024 target_suite = self.suite_info.target_suite 

1025 try: 

1026 filename_unstable = os.path.join( 

1027 self.state_dir, f"piuparts-summary-{source_suite.name}.json" 

1028 ) 

1029 filename_testing = os.path.join( 

1030 self.state_dir, f"piuparts-summary-{target_suite.name}.json" 

1031 ) 

1032 except AttributeError as e: # pragma: no cover 

1033 raise RuntimeError( 

1034 "Please set STATE_DIR in the britney configuration" 

1035 ) from e 

1036 self._piuparts_source = self._read_piuparts_summary(filename_unstable) 

1037 self._piuparts_target = self._read_piuparts_summary_without_url( 

1038 filename_testing 

1039 ) 

1040 

1041 def apply_src_policy_impl( 

1042 self, 

1043 piuparts_info: dict[str, Any], 

1044 source_data_tdist: SourcePackage | None, 

1045 source_data_srcdist: SourcePackage, 

1046 excuse: "Excuse", 

1047 ) -> PolicyVerdict: 

1048 assert self._piuparts_source is not None # for type checking 

1049 assert self._piuparts_target is not None # for type checking 

1050 source_name = excuse.item.package 

1051 

1052 testing_state = self._piuparts_target.get(source_name) 

1053 if testing_state is None: 

1054 testing_state = PiupartsState.UNKNOWN 

1055 url: str | None 

1056 if (data := self._piuparts_source.get(source_name)) is not None: 

1057 unstable_state, url = data 

1058 else: 

1059 unstable_state = PiupartsState.UNKNOWN 

1060 url = None 

1061 url_html = "(no link yet)" 

1062 if url is not None: 

1063 url_html = '<a href="{0}">{0}</a>'.format(url) 

1064 

1065 match unstable_state: 

1066 case PiupartsState.PASS: 

1067 # Not a regression 

1068 msg = f"Piuparts tested OK - {url_html}" 

1069 result = PolicyVerdict.PASS 

1070 piuparts_info["test-results"] = PiupartsResult.PASS 

1071 case PiupartsState.FAIL if testing_state is not PiupartsState.FAIL: 

1072 piuparts_info["test-results"] = PiupartsResult.REGRESSION 

1073 msg = f"Piuparts regression - {url_html}" 

1074 result = PolicyVerdict.REJECTED_PERMANENTLY 

1075 case PiupartsState.FAIL: 

1076 piuparts_info["test-results"] = PiupartsResult.FAILED 

1077 msg = f"Piuparts failure (not a regression) - {url_html}" 

1078 result = PolicyVerdict.PASS 

1079 case PiupartsState.WAITING: 

1080 msg = f"Piuparts check waiting for test results - {url_html}" 

1081 result = PolicyVerdict.REJECTED_TEMPORARILY 

1082 piuparts_info["test-results"] = PiupartsResult.WAITING_FOR_TESTS 

1083 case _: 

1084 msg = f"Piuparts can't test {source_name} (not a blocker) - {url_html}" 

1085 piuparts_info["test-results"] = PiupartsResult.CANNOT_BE_TESTED 

1086 result = PolicyVerdict.PASS 

1087 

1088 if url is not None: 

1089 piuparts_info["piuparts-test-url"] = url 

1090 if result.is_rejected: 

1091 excuse.add_verdict_info(result, msg) 

1092 else: 

1093 excuse.addinfo(msg) 

1094 

1095 if result.is_rejected: 

1096 assert self.hints is not None 

1097 if ( 

1098 ignore_hint := self.hints.search_first( 

1099 "ignore-piuparts", 

1100 package=source_name, 

1101 version=source_data_srcdist.version, 

1102 ) 

1103 ) is not None: 

1104 piuparts_info["ignored-piuparts"] = {"issued-by": ignore_hint.user} 

1105 result = PolicyVerdict.PASS_HINTED 

1106 excuse.addinfo( 

1107 f"Piuparts issue ignored as requested by {ignore_hint.user}" 

1108 ) 

1109 

1110 return result 

1111 

1112 def _read_piuparts_summary_gen( 

1113 self, filename: str 

1114 ) -> Iterator[tuple[str, PiupartsState, str]]: 

1115 self.logger.info("Loading piuparts report from %s", filename) 

1116 with open(filename) as fd: 1116 ↛ exitline 1116 didn't return from function '_read_piuparts_summary_gen' because the return on line 1118 wasn't executed

1117 if os.fstat(fd.fileno()).st_size < 1: 1117 ↛ 1118line 1117 didn't jump to line 1118 because the condition on line 1117 was never true

1118 return 

1119 data = json.load(fd) 

1120 try: 

1121 if ( 

1122 data["_id"] != "Piuparts Package Test Results Summary" 

1123 or data["_version"] != "1.0" 

1124 ): # pragma: no cover 

1125 raise ValueError( 

1126 f"Piuparts results in {filename} does not have the correct ID or version" 

1127 ) 

1128 except KeyError as e: # pragma: no cover 

1129 raise ValueError( 

1130 f"Piuparts results in {filename} is missing id or version field" 

1131 ) from e 

1132 for source, suite_data in data["packages"].items(): 

1133 if len(suite_data) != 1: # pragma: no cover 

1134 raise ValueError( 

1135 f"Piuparts results in {filename}, the source {source} does not have " 

1136 "exactly one result set" 

1137 ) 

1138 item = first(suite_data.values()) 

1139 state, _, url = item 

1140 yield (source, PiupartsState.from_str(state), url) 

1141 

1142 def _read_piuparts_summary( 

1143 self, filename: str 

1144 ) -> dict[str, tuple[PiupartsState, str]]: 

1145 return { 

1146 source: (state, url) 

1147 for (source, state, url) in self._read_piuparts_summary_gen(filename) 

1148 } 

1149 

1150 def _read_piuparts_summary_without_url( 

1151 self, filename: str 

1152 ) -> dict[str, PiupartsState]: 

1153 return { 

1154 source: state 

1155 for (source, state, _) in self._read_piuparts_summary_gen(filename) 

1156 } 

1157 

1158 

1159class DependsPolicy(AbstractBasePolicy): 

1160 pkg_universe: "BinaryPackageUniverse" 

1161 broken_packages: frozenset["BinaryPackageId"] 

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

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

1164 

1165 def __init__(self, options: optparse.Values, suite_info: Suites) -> None: 

1166 super().__init__( 

1167 "depends", 

1168 options, 

1169 suite_info, 

1170 {SuiteClass.PRIMARY_SOURCE_SUITE, SuiteClass.ADDITIONAL_SOURCE_SUITE}, 

1171 ApplySrcPolicy.RUN_ON_EVERY_ARCH_ONLY, 

1172 ) 

1173 self.nobreakall_arches = None 

1174 self.new_arches = None 

1175 self.break_arches = None 

1176 

1177 def initialise(self, britney: "Britney") -> None: 

1178 super().initialise(britney) 

1179 self.pkg_universe = britney.pkg_universe 

1180 self.broken_packages = self.pkg_universe.broken_packages 

1181 self.all_binaries = britney.all_binaries 

1182 self.nobreakall_arches = self.options.nobreakall_arches 

1183 self.new_arches = self.options.new_arches 

1184 self.break_arches = self.options.break_arches 

1185 self.allow_uninst = britney.allow_uninst 

1186 

1187 def apply_srcarch_policy_impl( 

1188 self, 

1189 deps_info: dict[str, Any], 

1190 arch: str, 

1191 source_data_tdist: SourcePackage | None, 

1192 source_data_srcdist: SourcePackage, 

1193 excuse: "Excuse", 

1194 ) -> PolicyVerdict: 

1195 verdict = PolicyVerdict.PASS 

1196 

1197 assert self.break_arches is not None 

1198 assert self.new_arches is not None 

1199 if arch in self.break_arches or arch in self.new_arches: 

1200 # we don't check these in the policy (TODO - for now?) 

1201 return verdict 

1202 

1203 item = excuse.item 

1204 source_suite = item.suite 

1205 target_suite = self.suite_info.target_suite 

1206 

1207 packages_s_a = source_suite.binaries[arch] 

1208 packages_t_a = target_suite.binaries[arch] 

1209 

1210 my_bins = sorted(filter_out_faux_gen(excuse.packages[arch])) 

1211 

1212 arch_all_installable = set() 

1213 arch_arch_installable = set() 

1214 consider_it_regression = True 

1215 

1216 for pkg_id in my_bins: 

1217 pkg_name = pkg_id.package_name 

1218 binary_u = packages_s_a[pkg_name] 

1219 pkg_arch = binary_u.architecture 

1220 

1221 # in some cases, we want to track the uninstallability of a 

1222 # package (because the autopkgtest policy uses this), but we still 

1223 # want to allow the package to be uninstallable 

1224 skip_dep_check = False 

1225 

1226 if binary_u.source_version != source_data_srcdist.version: 

1227 # don't check cruft in unstable 

1228 continue 

1229 

1230 if item.architecture != "source" and pkg_arch == "all": 

1231 # we don't care about the existing arch: all binaries when 

1232 # checking a binNMU item, because the arch: all binaries won't 

1233 # migrate anyway 

1234 skip_dep_check = True 

1235 

1236 if pkg_arch == "all" and arch not in self.nobreakall_arches: 

1237 skip_dep_check = True 

1238 

1239 if pkg_name in self.allow_uninst[arch]: 1239 ↛ 1242line 1239 didn't jump to line 1242 because the condition on line 1239 was never true

1240 # this binary is allowed to become uninstallable, so we don't 

1241 # need to check anything 

1242 skip_dep_check = True 

1243 

1244 if (oldbin := packages_t_a.get(pkg_name)) is not None: 

1245 if not target_suite.is_installable(oldbin.pkg_id): 

1246 # as the current binary in testing is already 

1247 # uninstallable, the newer version is allowed to be 

1248 # uninstallable as well, so we don't need to check 

1249 # anything 

1250 skip_dep_check = True 

1251 consider_it_regression = False 

1252 

1253 if pkg_id in self.broken_packages: 

1254 if pkg_arch == "all": 

1255 arch_all_installable.add(False) 

1256 else: 

1257 arch_arch_installable.add(False) 

1258 # dependencies can't be satisfied by all the known binaries - 

1259 # this certainly won't work... 

1260 excuse.add_unsatisfiable_on_arch(arch) 

1261 if skip_dep_check: 

1262 # ...but if the binary is allowed to become uninstallable, 

1263 # we don't care 

1264 # we still want the binary to be listed as uninstallable, 

1265 continue 

1266 verdict = PolicyVerdict.REJECTED_PERMANENTLY 

1267 if pkg_name.endswith("-faux-build-depends"): 1267 ↛ 1268line 1267 didn't jump to line 1268 because the condition on line 1267 was never true

1268 name = pkg_name.removesuffix("-faux-build-depends") 

1269 excuse.add_verdict_info( 

1270 verdict, 

1271 f"src:{name} has unsatisfiable build dependency", 

1272 ) 

1273 else: 

1274 excuse.add_verdict_info( 

1275 verdict, f"{pkg_name}/{arch} has unsatisfiable dependency" 

1276 ) 

1277 excuse.addreason("depends") 

1278 else: 

1279 if pkg_arch == "all": 

1280 arch_all_installable.add(True) 

1281 else: 

1282 arch_arch_installable.add(True) 

1283 

1284 if skip_dep_check: 

1285 continue 

1286 

1287 for dep in self.pkg_universe.dependencies_of(pkg_id): 

1288 # dep is a list of packages, each of which satisfy the 

1289 # dependency 

1290 

1291 if not dep: 

1292 continue 

1293 is_ok = False 

1294 needed_for_dep = set() 

1295 

1296 for alternative in dep: 

1297 if target_suite.is_pkg_in_the_suite(alternative): 

1298 # dep can be satisfied in testing - ok 

1299 is_ok = True 

1300 break 

1301 elif alternative in my_bins: 

1302 # can be satisfied by binary from same item: will be 

1303 # ok if item migrates 

1304 is_ok = True 

1305 break 

1306 else: 

1307 needed_for_dep.add(alternative) 

1308 

1309 if not is_ok: 

1310 spec = DependencySpec(DependencyType.DEPENDS, arch) 

1311 excuse.add_package_depends(spec, needed_for_dep) 

1312 

1313 # The autopkgtest policy needs delicate trade offs for 

1314 # non-installability. The current choice (considering source 

1315 # migration and only binaries built by the version of the 

1316 # source): 

1317 # 

1318 # * Run autopkgtest if all arch:$arch binaries are installable 

1319 # (but some or all arch:all binaries are not) 

1320 # 

1321 # * Don't schedule nor wait for not installable arch:all only package 

1322 # on ! NOBREAKALL_ARCHES 

1323 # 

1324 # * Run autopkgtest if installability isn't a regression (there are (or 

1325 # rather, should) not be a lot of packages in this state, and most 

1326 # likely they'll just fail quickly) 

1327 # 

1328 # * Don't schedule, but wait otherwise 

1329 if arch_arch_installable == {True} and False in arch_all_installable: 

1330 deps_info.setdefault("autopkgtest_run_anyways", []).append(arch) 

1331 elif ( 

1332 arch not in self.nobreakall_arches 

1333 and not arch_arch_installable 

1334 and False in arch_all_installable 

1335 ): 

1336 deps_info.setdefault("arch_all_not_installable", []).append(arch) 

1337 elif not consider_it_regression: 

1338 deps_info.setdefault("autopkgtest_run_anyways", []).append(arch) 

1339 

1340 return verdict 

1341 

1342 

1343@unique 

1344class BuildDepResult(IntEnum): 

1345 # relation is satisfied in target 

1346 OK = 1 

1347 # relation can be satisfied by other packages in source 

1348 DEPENDS = 2 

1349 # relation cannot be satisfied 

1350 FAILED = 3 

1351 

1352 

1353class BuildDependsPolicy(AbstractBasePolicy): 

1354 

1355 def __init__(self, options: optparse.Values, suite_info: Suites) -> None: 

1356 super().__init__( 

1357 "build-depends", 

1358 options, 

1359 suite_info, 

1360 {SuiteClass.PRIMARY_SOURCE_SUITE, SuiteClass.ADDITIONAL_SOURCE_SUITE}, 

1361 ) 

1362 self._all_buildarch: list[str] = [] 

1363 

1364 parse_option(options, "all_buildarch") 

1365 

1366 def initialise(self, britney: "Britney") -> None: 

1367 super().initialise(britney) 

1368 if self.options.all_buildarch: 

1369 self._all_buildarch = SuiteContentLoader.config_str_as_list( 

1370 self.options.all_buildarch, [] 

1371 ) 

1372 

1373 def apply_src_policy_impl( 

1374 self, 

1375 build_deps_info: dict[str, Any], 

1376 source_data_tdist: SourcePackage | None, 

1377 source_data_srcdist: SourcePackage, 

1378 excuse: "Excuse", 

1379 ) -> PolicyVerdict: 

1380 verdict = PolicyVerdict.PASS 

1381 

1382 # analyze the dependency fields (if present) 

1383 if deps := source_data_srcdist.build_deps_arch: 

1384 v = self._check_build_deps( 

1385 deps, 

1386 DependencyType.BUILD_DEPENDS, 

1387 build_deps_info, 

1388 source_data_srcdist, 

1389 excuse, 

1390 ) 

1391 verdict = PolicyVerdict.worst_of(verdict, v) 

1392 

1393 if ideps := source_data_srcdist.build_deps_indep: 

1394 v = self._check_build_deps( 

1395 ideps, 

1396 DependencyType.BUILD_DEPENDS_INDEP, 

1397 build_deps_info, 

1398 source_data_srcdist, 

1399 excuse, 

1400 ) 

1401 verdict = PolicyVerdict.worst_of(verdict, v) 

1402 

1403 return verdict 

1404 

1405 def _get_check_archs( 

1406 self, archs: Container[str], dep_type: DependencyType 

1407 ) -> list[str]: 

1408 oos = self.options.outofsync_arches 

1409 

1410 if dep_type is DependencyType.BUILD_DEPENDS: 

1411 return [ 

1412 arch 

1413 for arch in self.options.architectures 

1414 if arch in archs and arch not in oos 

1415 ] 

1416 

1417 # first try the all buildarch 

1418 checkarchs = list(self._all_buildarch) 

1419 # then try the architectures where this source has arch specific 

1420 # binaries (in the order of the architecture config file) 

1421 checkarchs.extend( 

1422 arch 

1423 for arch in self.options.architectures 

1424 if arch in archs and arch not in checkarchs 

1425 ) 

1426 # then try all other architectures 

1427 checkarchs.extend( 

1428 arch for arch in self.options.architectures if arch not in checkarchs 

1429 ) 

1430 

1431 # and drop OUTOFSYNC_ARCHES 

1432 return [arch for arch in checkarchs if arch not in oos] 

1433 

1434 def _add_info_for_arch( 

1435 self, 

1436 arch: str, 

1437 excuses_info: dict[str, list[str]], 

1438 blockers: dict[str, set[BinaryPackageId]], 

1439 results: dict[str, BuildDepResult], 

1440 dep_type: DependencyType, 

1441 excuse: "Excuse", 

1442 verdict: PolicyVerdict, 

1443 ) -> PolicyVerdict: 

1444 if (packages := blockers.get(arch)) is not None: 

1445 # for the solving packages, update the excuse to add the dependencies 

1446 for p in packages: 

1447 if arch not in self.options.break_arches: 1447 ↛ 1446line 1447 didn't jump to line 1446 because the condition on line 1447 was always true

1448 spec = DependencySpec(dep_type, arch) 

1449 excuse.add_package_depends(spec, {p}) 

1450 

1451 if arch in results and results[arch] is BuildDepResult.FAILED: 

1452 verdict = PolicyVerdict.worst_of( 

1453 verdict, PolicyVerdict.REJECTED_PERMANENTLY 

1454 ) 

1455 

1456 if arch in excuses_info: 

1457 for excuse_text in excuses_info[arch]: 

1458 if verdict.is_rejected: 1458 ↛ 1461line 1458 didn't jump to line 1461 because the condition on line 1458 was always true

1459 excuse.add_verdict_info(verdict, excuse_text) 

1460 else: 

1461 excuse.addinfo(excuse_text) 

1462 

1463 return verdict 

1464 

1465 def _check_build_deps( 

1466 self, 

1467 deps: str, 

1468 dep_type: DependencyType, 

1469 build_deps_info: dict[str, Any], 

1470 source_data_srcdist: SourcePackage, 

1471 excuse: "Excuse", 

1472 ) -> PolicyVerdict: 

1473 verdict = PolicyVerdict.PASS 

1474 any_arch_ok = dep_type is DependencyType.BUILD_DEPENDS_INDEP 

1475 

1476 britney = self.britney 

1477 

1478 # local copies for better performance 

1479 parse_src_depends = apt_pkg.parse_src_depends 

1480 solvers = get_dependency_solvers 

1481 

1482 source_name = excuse.item.package 

1483 source_suite = excuse.item.suite 

1484 target_suite = self.suite_info.target_suite 

1485 binaries_s = source_suite.binaries 

1486 provides_s = source_suite.provides_table 

1487 binaries_t = target_suite.binaries 

1488 provides_t = target_suite.provides_table 

1489 unsat_bd: dict[str, list[str]] = {} 

1490 relevant_archs: set[str] = { 

1491 binary.architecture 

1492 for binary in filter_out_faux_gen(source_data_srcdist.binaries) 

1493 if britney.all_binaries[binary].architecture != "all" 

1494 } 

1495 

1496 excuses_info: dict[str, list[str]] = defaultdict(list) 

1497 blockers: dict[str, set[BinaryPackageId]] = defaultdict(set) 

1498 arch_results = {} 

1499 result_archs = defaultdict(list) 

1500 bestresult = BuildDepResult.FAILED 

1501 check_archs = self._get_check_archs(relevant_archs, dep_type) 

1502 if not check_archs: 

1503 # when the arch list is empty, we check the b-d on any arch, instead of all archs 

1504 # this happens for Build-Depens on a source package that only produces arch: all binaries 

1505 any_arch_ok = True 

1506 check_archs = self._get_check_archs( 

1507 self.options.architectures, DependencyType.BUILD_DEPENDS_INDEP 

1508 ) 

1509 

1510 for arch in check_archs: 

1511 # retrieve the binary package from the specified suite and arch 

1512 binaries_s_a = binaries_s[arch] 

1513 provides_s_a = provides_s[arch] 

1514 binaries_t_a = binaries_t[arch] 

1515 provides_t_a = provides_t[arch] 

1516 arch_results[arch] = BuildDepResult.OK 

1517 # for every dependency block (formed as conjunction of disjunction) 

1518 for block_txt in deps.split(","): 

1519 block_list = parse_src_depends(block_txt, False, arch) 

1520 # Unlike regular dependencies, some clauses of the Build-Depends(-Arch|-Indep) can be 

1521 # filtered out by (e.g.) architecture restrictions. We need to cope with this while 

1522 # keeping block_txt and block aligned. 

1523 if not block_list: 

1524 # Relation is not relevant for this architecture. 

1525 continue 

1526 block = block_list[0] 

1527 # if the block is satisfied in the target suite, then skip the block 

1528 if ( 

1529 next( 

1530 solvers(block, binaries_t_a, provides_t_a, build_depends=True), 

1531 None, 

1532 ) 

1533 is not None 

1534 ): 

1535 # Satisfied in the target suite; all ok. 

1536 continue 

1537 

1538 # check if the block can be satisfied in the source suite, and list the solving packages 

1539 packages = list( 

1540 solvers(block, binaries_s_a, provides_s_a, build_depends=True) 

1541 ) 

1542 

1543 # if the dependency can be satisfied by the same source package, skip the block: 

1544 # obviously both binary packages will enter the target suite together 

1545 if any(source_name == p.source for p in packages): 1545 ↛ 1546line 1545 didn't jump to line 1546 because the condition on line 1545 was never true

1546 continue 

1547 

1548 # if no package can satisfy the dependency, add this information to the excuse 

1549 if not packages: 

1550 excuses_info[arch].append( 

1551 f"{source_name} unsatisfiable {dep_type} on {arch}: {block_txt.strip()}" 

1552 ) 

1553 if arch not in unsat_bd: 1553 ↛ 1555line 1553 didn't jump to line 1555 because the condition on line 1553 was always true

1554 unsat_bd[arch] = [] 

1555 unsat_bd[arch].append(block_txt.strip()) 

1556 arch_results[arch] = BuildDepResult.FAILED 

1557 continue 

1558 

1559 blockers[arch].update(p.pkg_id for p in packages) 

1560 if arch_results[arch] < BuildDepResult.DEPENDS: 

1561 arch_results[arch] = BuildDepResult.DEPENDS 

1562 

1563 if any_arch_ok: 

1564 if arch_results[arch] < bestresult: 

1565 bestresult = arch_results[arch] 

1566 result_archs[arch_results[arch]].append(arch) 

1567 if bestresult is BuildDepResult.OK: 

1568 # we found an architecture where the b-deps-indep are 

1569 # satisfied in the target suite, so we can stop 

1570 break 

1571 

1572 if any_arch_ok: 

1573 arch = result_archs[bestresult][0] 

1574 excuse.add_detailed_info(f"Checking {dep_type.get_description()} on {arch}") 

1575 key = "check-%s-on-arch" % dep_type.get_reason() 

1576 build_deps_info[key] = arch 

1577 verdict = self._add_info_for_arch( 

1578 arch, 

1579 excuses_info, 

1580 blockers, 

1581 arch_results, 

1582 dep_type, 

1583 excuse, 

1584 verdict, 

1585 ) 

1586 

1587 else: 

1588 for arch in check_archs: 

1589 verdict = self._add_info_for_arch( 

1590 arch, 

1591 excuses_info, 

1592 blockers, 

1593 arch_results, 

1594 dep_type, 

1595 excuse, 

1596 verdict, 

1597 ) 

1598 

1599 if unsat_bd: 

1600 build_deps_info["unsatisfiable-arch-build-depends"] = unsat_bd 

1601 

1602 return verdict 

1603 

1604 

1605class BuiltUsingPolicy(AbstractBasePolicy): 

1606 """Built-Using policy 

1607 

1608 Binaries that incorporate (part of) another source package must list these 

1609 sources under 'Built-Using'. 

1610 

1611 This policy checks if the corresponding sources are available in the 

1612 target suite. If they are not, but they are candidates for migration, a 

1613 dependency is added. 

1614 

1615 If the binary incorporates a newer version of a source, that is not (yet) 

1616 a candidate, we don't want to accept that binary. A rebuild later in the 

1617 primary suite wouldn't fix the issue, because that would incorporate the 

1618 newer version again. 

1619 

1620 If the binary incorporates an older version of the source, a newer version 

1621 will be accepted as a replacement. We assume that this can be fixed by 

1622 rebuilding the binary at some point during the development cycle. 

1623 

1624 Requiring exact version of the source would not be useful in practice. A 

1625 newer upload of that source wouldn't be blocked by this policy, so the 

1626 built-using would be outdated anyway. 

1627 

1628 """ 

1629 

1630 def __init__(self, options: optparse.Values, suite_info: Suites) -> None: 

1631 super().__init__( 

1632 "built-using", 

1633 options, 

1634 suite_info, 

1635 {SuiteClass.PRIMARY_SOURCE_SUITE, SuiteClass.ADDITIONAL_SOURCE_SUITE}, 

1636 ApplySrcPolicy.RUN_ON_EVERY_ARCH_ONLY, 

1637 ) 

1638 

1639 def initialise(self, britney: "Britney") -> None: 

1640 super().initialise(britney) 

1641 

1642 def apply_srcarch_policy_impl( 

1643 self, 

1644 build_deps_info: dict[str, Any], 

1645 arch: str, 

1646 source_data_tdist: SourcePackage | None, 

1647 source_data_srcdist: SourcePackage, 

1648 excuse: "Excuse", 

1649 ) -> PolicyVerdict: 

1650 verdict = PolicyVerdict.PASS 

1651 

1652 source_suite = excuse.item.suite 

1653 target_suite = self.suite_info.target_suite 

1654 binaries_s = source_suite.binaries 

1655 

1656 def check_bu_in_suite( 

1657 bu_source: str, bu_version: str, source_suite: Suite 

1658 ) -> bool: 

1659 if (s_source := source_suite.sources.get(bu_source)) is None: 

1660 return False 

1661 s_ver = s_source.version 

1662 if apt_pkg.version_compare(s_ver, bu_version) >= 0: 

1663 dep = PackageId(bu_source, s_ver, "source") 

1664 if arch in self.options.break_arches: 

1665 excuse.add_detailed_info( 

1666 f"Ignoring Built-Using for {pkg_name}/{arch} on {dep.uvname}" 

1667 ) 

1668 else: 

1669 spec = DependencySpec(DependencyType.BUILT_USING, arch) 

1670 excuse.add_package_depends(spec, {dep}) 

1671 excuse.add_detailed_info( 

1672 f"{pkg_name}/{arch} has Built-Using on {dep.uvname}" 

1673 ) 

1674 return True 

1675 

1676 return False 

1677 

1678 for pkg_id in sorted( 

1679 x 

1680 for x in filter_out_faux_gen(source_data_srcdist.binaries) 

1681 if x.architecture == arch 

1682 ): 

1683 pkg_name = pkg_id.package_name 

1684 

1685 # retrieve the testing (if present) and unstable corresponding binary packages 

1686 binary_s = binaries_s[arch][pkg_name] 

1687 if binary_s.builtusing is None: 

1688 continue 

1689 

1690 for bu in binary_s.builtusing: 

1691 bu_source = bu[0] 

1692 bu_version = bu[1] 

1693 if (t_source := target_suite.sources.get(bu_source)) is not None: 

1694 t_ver = t_source.version 

1695 if apt_pkg.version_compare(t_ver, bu_version) >= 0: 

1696 continue 

1697 

1698 if check_bu_in_suite(bu_source, bu_version, source_suite): 

1699 continue 

1700 

1701 if source_suite.suite_class.is_additional_source and check_bu_in_suite( 

1702 bu_source, bu_version, self.suite_info.primary_source_suite 

1703 ): 

1704 continue 

1705 

1706 if arch in self.options.break_arches: 

1707 excuse.add_detailed_info( 

1708 f"Ignoring unsatisfiable Built-Using for {pkg_name}/{arch} on {bu_source} {bu_version}" 

1709 ) 

1710 else: 

1711 verdict = PolicyVerdict.worst_of( 

1712 verdict, PolicyVerdict.REJECTED_PERMANENTLY 

1713 ) 

1714 excuse.add_verdict_info( 

1715 verdict, 

1716 f"{pkg_name}/{arch} has unsatisfiable Built-Using on {bu_source} {bu_version}", 

1717 ) 

1718 

1719 return verdict 

1720 

1721 

1722class BlockPolicy(AbstractBasePolicy): 

1723 BLOCK_HINT_REGEX = re.compile("^(un)?(block-?.*)$") 

1724 

1725 def __init__(self, options: optparse.Values, suite_info: Suites) -> None: 

1726 super().__init__( 

1727 "block", 

1728 options, 

1729 suite_info, 

1730 {SuiteClass.PRIMARY_SOURCE_SUITE, SuiteClass.ADDITIONAL_SOURCE_SUITE}, 

1731 ) 

1732 self._blockall: dict[str | None, Hint] = {} 

1733 

1734 def initialise(self, britney: "Britney") -> None: 

1735 super().initialise(britney) 

1736 assert self.hints is not None 

1737 for hint in self.hints.search(type="block-all"): 

1738 self._blockall[hint.package] = hint 

1739 

1740 self._key_packages: frozenset[str] = frozenset() 

1741 if "key" in self._blockall: 

1742 self._key_packages = self._read_key_packages() 

1743 

1744 def _read_key_packages(self) -> frozenset[str]: 

1745 """Read the list of key packages 

1746 

1747 The file contains data in the yaml format : 

1748 

1749 - reason: <something> 

1750 source: <package> 

1751 

1752 The method returns a list of all key packages. 

1753 """ 

1754 filename = os.path.join(self.state_dir, "key_packages.yaml") 

1755 self.logger.info("Loading key packages from %s", filename) 

1756 if os.path.exists(filename): 1756 ↛ 1761line 1756 didn't jump to line 1761 because the condition on line 1756 was always true

1757 with open(filename) as f: 

1758 data = yaml.safe_load(f) 

1759 key_packages = frozenset(item["source"] for item in data) 

1760 else: 

1761 self.logger.error( 

1762 "Britney was asked to block key packages, " 

1763 + "but no key_packages.yaml file was found." 

1764 ) 

1765 sys.exit(1) 

1766 

1767 return key_packages 

1768 

1769 def register_hints(self, hint_parser: HintParser) -> None: 

1770 # block related hints are currently defined in hint.py 

1771 pass 

1772 

1773 def _check_blocked( 

1774 self, arch: str, version: str, excuse: "Excuse" 

1775 ) -> PolicyVerdict: 

1776 verdict = PolicyVerdict.PASS 

1777 blocked = {} 

1778 unblocked = {} 

1779 block_info = {} 

1780 source_suite = excuse.item.suite 

1781 suite_name = source_suite.name 

1782 src = excuse.item.package 

1783 is_primary = source_suite.suite_class is SuiteClass.PRIMARY_SOURCE_SUITE 

1784 

1785 tooltip = ( 

1786 f"please contact {self.options.distribution}-release if update is needed" 

1787 ) 

1788 

1789 assert self.hints is not None 

1790 mismatches = False 

1791 r = self.BLOCK_HINT_REGEX 

1792 for hint in self.hints.search(package=src): 

1793 m = r.match(hint.type) 

1794 if m: 

1795 if m.group(1) == "un": 

1796 assert hint.suite is not None 

1797 if ( 

1798 hint.version != version 

1799 or hint.suite.name != suite_name 

1800 or (hint.architecture != arch and hint.architecture != "source") 

1801 ): 

1802 self.logger.info( 

1803 "hint mismatch: %s %s %s", version, arch, suite_name 

1804 ) 

1805 mismatches = True 

1806 else: 

1807 unblocked[m.group(2)] = hint.user 

1808 excuse.add_hint(hint) 

1809 else: 

1810 # block(-*) hint: only accepts a source, so this will 

1811 # always match 

1812 blocked[m.group(2)] = hint.user 

1813 excuse.add_hint(hint) 

1814 

1815 if "block" not in blocked and is_primary: 

1816 # if there is a specific block hint for this package, we don't 

1817 # check for the general hints 

1818 

1819 if self.options.distribution == "debian": 1819 ↛ 1823line 1819 didn't jump to line 1823 because the condition on line 1819 was always true

1820 url = "https://release.debian.org/testing/freeze_policy.html" 

1821 tooltip = f'Follow the <a href="{url}">freeze policy</a> when applying for an unblock' 

1822 

1823 if (block := self._blockall.get("source")) is not None: 

1824 blocked["block"] = block.user 

1825 excuse.add_hint(block) 

1826 elif ( 

1827 block := self._blockall.get("new-source") 

1828 ) is not None and src not in self.suite_info.target_suite.sources: 

1829 blocked["block"] = block.user 

1830 excuse.add_hint(block) 

1831 # no tooltip: new sources will probably not be accepted anyway 

1832 block_info["block"] = ( 

1833 f"blocked by {block.user}: is not in {self.suite_info.target_suite.name}" 

1834 ) 

1835 elif ( 

1836 block := self._blockall.get("key") 

1837 ) is not None and src in self._key_packages: 

1838 blocked["block"] = block.user 

1839 excuse.add_hint(block) 

1840 block_info["block"] = ( 

1841 f"blocked by {block.user}: is a key package ({tooltip})" 

1842 ) 

1843 elif (block := self._blockall.get("no-autopkgtest")) is not None: 

1844 if excuse.autopkgtest_results == {"PASS"}: 

1845 if not blocked: 1845 ↛ 1863line 1845 didn't jump to line 1863 because the condition on line 1845 was always true

1846 excuse.addinfo("not blocked: has successful autopkgtest") 

1847 else: 

1848 blocked["block"] = block.user 

1849 excuse.add_hint(block) 

1850 if not excuse.autopkgtest_results: 

1851 block_info["block"] = ( 

1852 f"blocked by {block.user}: does not have autopkgtest ({tooltip})" 

1853 ) 

1854 else: 

1855 block_info["block"] = ( 

1856 f"blocked by {block.user}: autopkgtest not fully successful ({tooltip})" 

1857 ) 

1858 

1859 elif not is_primary: 

1860 blocked["block"] = suite_name 

1861 excuse.needs_approval = True 

1862 

1863 for block_cmd in blocked: 

1864 unblock_cmd = f"un{block_cmd}" 

1865 if block_cmd in unblocked: 

1866 if is_primary or block_cmd == "block-udeb": 

1867 excuse.addinfo( 

1868 f"Ignoring {block_cmd} request by {blocked[block_cmd]}, " 

1869 f"due to {unblock_cmd} request by {unblocked[block_cmd]}" 

1870 ) 

1871 else: 

1872 excuse.addinfo(f"Approved by {unblocked[block_cmd]}") 

1873 else: 

1874 verdict = PolicyVerdict.REJECTED_NEEDS_APPROVAL 

1875 if is_primary or block_cmd == "block-udeb": 

1876 # redirect people to d-i RM for udeb things: 

1877 if block_cmd == "block-udeb": 

1878 tooltip = "please contact the d-i release manager if an update is needed" 

1879 info = block_info.get(block_cmd) 

1880 if info is None: 

1881 info = f"Not touching package due to {block_cmd} request by {blocked[block_cmd]} ({tooltip})" 

1882 excuse.add_verdict_info(verdict, info) 

1883 else: 

1884 excuse.add_verdict_info(verdict, "NEEDS APPROVAL BY RM") 

1885 excuse.addreason("block") 

1886 if mismatches: 

1887 excuse.add_detailed_info( 

1888 f"Some hints for {src} do not match this item" 

1889 ) 

1890 return verdict 

1891 

1892 def apply_src_policy_impl( 

1893 self, 

1894 block_info: dict[str, Any], 

1895 source_data_tdist: SourcePackage | None, 

1896 source_data_srcdist: SourcePackage, 

1897 excuse: "Excuse", 

1898 ) -> PolicyVerdict: 

1899 return self._check_blocked("source", source_data_srcdist.version, excuse) 

1900 

1901 def apply_srcarch_policy_impl( 

1902 self, 

1903 block_info: dict[str, Any], 

1904 arch: str, 

1905 source_data_tdist: SourcePackage | None, 

1906 source_data_srcdist: SourcePackage, 

1907 excuse: "Excuse", 

1908 ) -> PolicyVerdict: 

1909 return self._check_blocked(arch, source_data_srcdist.version, excuse) 

1910 

1911 

1912class BuiltOnBuilddPolicy(AbstractBasePolicy): 

1913 

1914 def __init__(self, options: optparse.Values, suite_info: Suites) -> None: 

1915 super().__init__( 

1916 "builtonbuildd", 

1917 options, 

1918 suite_info, 

1919 {SuiteClass.PRIMARY_SOURCE_SUITE, SuiteClass.ADDITIONAL_SOURCE_SUITE}, 

1920 ApplySrcPolicy.RUN_ON_EVERY_ARCH_ONLY, 

1921 ) 

1922 self._signer_info: dict[str, Any] = {} 

1923 

1924 def register_hints(self, hint_parser: HintParser) -> None: 

1925 hint_parser.register_hint_type( 

1926 HintType( 

1927 "allow-archall-maintainer-upload", 

1928 versioned=HintAnnotate.FORBIDDEN, 

1929 ) 

1930 ) 

1931 

1932 def initialise(self, britney: "Britney") -> None: 

1933 super().initialise(britney) 

1934 try: 

1935 filename_signerinfo = os.path.join(self.state_dir, "signers.json") 

1936 except AttributeError as e: # pragma: no cover 

1937 raise RuntimeError( 

1938 "Please set STATE_DIR in the britney configuration" 

1939 ) from e 

1940 self._signer_info = self._read_signerinfo(filename_signerinfo) 

1941 

1942 def apply_srcarch_policy_impl( 

1943 self, 

1944 buildd_info: dict[str, Any], 

1945 arch: str, 

1946 source_data_tdist: SourcePackage | None, 

1947 source_data_srcdist: SourcePackage, 

1948 excuse: "Excuse", 

1949 ) -> PolicyVerdict: 

1950 verdict = PolicyVerdict.PASS 

1951 signers = self._signer_info 

1952 

1953 if "signed-by" not in buildd_info: 

1954 buildd_info["signed-by"] = {} 

1955 

1956 item = excuse.item 

1957 source_suite = item.suite 

1958 

1959 # we use the source component, because a binary in contrib can 

1960 # belong to a source in main 

1961 component = get_component(source_data_srcdist.section) 

1962 

1963 packages_s_a = source_suite.binaries[arch] 

1964 assert self.hints is not None 

1965 

1966 for pkg_id in sorted( 

1967 x 

1968 for x in filter_out_faux_gen(source_data_srcdist.binaries) 

1969 if x.architecture == arch 

1970 ): 

1971 pkg_name = pkg_id.package_name 

1972 binary_u = packages_s_a[pkg_name] 

1973 pkg_arch = binary_u.architecture 

1974 

1975 if binary_u.source_version != source_data_srcdist.version: 1975 ↛ 1976line 1975 didn't jump to line 1976 because the condition on line 1975 was never true

1976 continue 

1977 

1978 if item.architecture != "source" and pkg_arch == "all": 

1979 # we don't care about the existing arch: all binaries when 

1980 # checking a binNMU item, because the arch: all binaries won't 

1981 # migrate anyway 

1982 continue 

1983 

1984 signer = None 

1985 uid = None 

1986 uidinfo = "" 

1987 buildd_ok = False 

1988 failure_verdict = PolicyVerdict.REJECTED_PERMANENTLY 

1989 try: 

1990 signer = signers[pkg_name][pkg_id.version][pkg_arch] 

1991 if signer["buildd"]: 

1992 buildd_ok = True 

1993 uid = signer["uid"] 

1994 uidinfo = f"arch {pkg_arch} binaries uploaded by {uid}" 

1995 except KeyError: 

1996 self.logger.info( 

1997 "signer info for %s %s (%s) on %s not found", 

1998 pkg_name, 

1999 binary_u.version, 

2000 pkg_arch, 

2001 arch, 

2002 ) 

2003 uidinfo = f"upload info for arch {pkg_arch} binaries not found" 

2004 failure_verdict = PolicyVerdict.REJECTED_CANNOT_DETERMINE_IF_PERMANENT 

2005 if not buildd_ok: 

2006 if component != "main": 

2007 if pkg_arch not in buildd_info["signed-by"]: 2007 ↛ 2011line 2007 didn't jump to line 2011 because the condition on line 2007 was always true

2008 excuse.add_detailed_info( 

2009 f"{uidinfo}, but package in {component}" 

2010 ) 

2011 buildd_ok = True 

2012 elif pkg_arch == "all": 

2013 if ( 

2014 allow_hint := self.hints.search_first( 

2015 "allow-archall-maintainer-upload", package=item.package 

2016 ) 

2017 ) is not None: 

2018 buildd_ok = True 

2019 verdict = PolicyVerdict.worst_of( 

2020 verdict, PolicyVerdict.PASS_HINTED 

2021 ) 

2022 if pkg_arch not in buildd_info["signed-by"]: 

2023 excuse.addinfo( 

2024 f"{uidinfo}, but whitelisted by {allow_hint.user}" 

2025 ) 

2026 if not buildd_ok: 

2027 verdict = failure_verdict 

2028 if pkg_arch not in buildd_info["signed-by"]: 

2029 if pkg_arch == "all": 

2030 uidinfo += ( 

2031 ", a new source-only upload is needed to allow migration" 

2032 ) 

2033 excuse.add_verdict_info(verdict, f"Not built on buildd: {uidinfo}") 

2034 

2035 if ( 2035 ↛ 2038line 2035 didn't jump to line 2038 because the condition on line 2035 was never true

2036 data := buildd_info["signed-by"].get(pkg_arch) 

2037 ) is not None and data != uid: 

2038 self.logger.info( 

2039 "signer mismatch for %s (%s %s) on %s: %s, while %s already listed", 

2040 pkg_name, 

2041 binary_u.source, 

2042 binary_u.source_version, 

2043 pkg_arch, 

2044 uid, 

2045 data, 

2046 ) 

2047 

2048 buildd_info["signed-by"][pkg_arch] = uid 

2049 

2050 return verdict 

2051 

2052 def _read_signerinfo(self, filename: str) -> dict[str, Any]: 

2053 signerinfo: dict[str, Any] = {} 

2054 self.logger.info("Loading signer info from %s", filename) 

2055 with open(filename) as fd: 2055 ↛ exitline 2055 didn't return from function '_read_signerinfo' because the return on line 2057 wasn't executed

2056 if os.fstat(fd.fileno()).st_size < 1: 2056 ↛ 2057line 2056 didn't jump to line 2057 because the condition on line 2056 was never true

2057 return signerinfo 

2058 signerinfo = json.load(fd) 

2059 

2060 return signerinfo 

2061 

2062 

2063class ImplicitDependencyPolicy(AbstractBasePolicy): 

2064 """Implicit Dependency policy 

2065 

2066 Upgrading a package pkg-a can break the installability of a package pkg-b. 

2067 A newer version (or the removal) of pkg-b might fix the issue. In that 

2068 case, pkg-a has an 'implicit dependency' on pkg-b, because pkg-a can only 

2069 migrate if pkg-b also migrates. 

2070 

2071 This policy tries to discover a few common cases, and adds the relevant 

2072 info to the excuses. If another item is needed to fix the 

2073 uninstallability, a dependency is added. If no newer item can fix it, this 

2074 excuse will be blocked. 

2075 

2076 Note that the migration step will check the installability of every 

2077 package, so this policy doesn't need to handle every corner case. It 

2078 must, however, make sure that no excuse is unnecessarily blocked. 

2079 

2080 Some cases that should be detected by this policy: 

2081 

2082 * pkg-a is upgraded from 1.0-1 to 2.0-1, while 

2083 pkg-b has "Depends: pkg-a (<< 2.0)" 

2084 This typically happens if pkg-b has a strict dependency on pkg-a because 

2085 it uses some non-stable internal interface (examples are glibc, 

2086 binutils, python3-defaults, ...) 

2087 

2088 * pkg-a is upgraded from 1.0-1 to 2.0-1, and 

2089 pkg-a 1.0-1 has "Provides: provides-1", 

2090 pkg-a 2.0-1 has "Provides: provides-2", 

2091 pkg-b has "Depends: provides-1" 

2092 This typically happens when pkg-a has an interface that changes between 

2093 versions, and a virtual package is used to identify the version of this 

2094 interface (e.g. perl-api-x.y) 

2095 

2096 """ 

2097 

2098 _pkg_universe: "BinaryPackageUniverse" 

2099 _all_binaries: dict["BinaryPackageId", "BinaryPackage"] 

2100 _allow_uninst: dict[str, set[str | None]] 

2101 _nobreakall_arches: list[str] 

2102 

2103 def __init__(self, options: optparse.Values, suite_info: Suites) -> None: 

2104 super().__init__( 

2105 "implicit-deps", 

2106 options, 

2107 suite_info, 

2108 {SuiteClass.PRIMARY_SOURCE_SUITE, SuiteClass.ADDITIONAL_SOURCE_SUITE}, 

2109 ApplySrcPolicy.RUN_ON_EVERY_ARCH_ONLY, 

2110 ) 

2111 

2112 def initialise(self, britney: "Britney") -> None: 

2113 super().initialise(britney) 

2114 self._pkg_universe = britney.pkg_universe 

2115 self._all_binaries = britney.all_binaries 

2116 self._smooth_updates = britney.options.smooth_updates 

2117 self._nobreakall_arches = self.options.nobreakall_arches 

2118 self._new_arches = self.options.new_arches 

2119 self._break_arches = self.options.break_arches 

2120 self._allow_uninst = britney.allow_uninst 

2121 self._outofsync_arches = self.options.outofsync_arches 

2122 

2123 def can_be_removed(self, pkg: BinaryPackage) -> bool: 

2124 src = pkg.source 

2125 target_suite = self.suite_info.target_suite 

2126 

2127 # TODO these conditions shouldn't be hardcoded here 

2128 # ideally, we would be able to look up excuses to see if the removal 

2129 # is in there, but in the current flow, this policy is called before 

2130 # all possible excuses exist, so there is no list for us to check 

2131 

2132 if src not in self.suite_info.primary_source_suite.sources: 

2133 # source for pkg not in unstable: candidate for removal 

2134 return True 

2135 

2136 source_t = target_suite.sources[src] 

2137 assert self.hints is not None 

2138 if self.hints.has_hint("remove", package=src, version=source_t.version): 

2139 # removal hint for the source in testing: candidate for removal 

2140 return True 

2141 

2142 if target_suite.is_cruft(pkg): 

2143 # if pkg is cruft in testing, removal will be tried 

2144 return True 

2145 

2146 # the case were the newer version of the source no longer includes the 

2147 # binary (or includes a cruft version of the binary) will be handled 

2148 # separately (in that case there might be an implicit dependency on 

2149 # the newer source) 

2150 

2151 return False 

2152 

2153 def should_skip_rdep( 

2154 self, pkg: BinaryPackage, source_name: str, myarch: str 

2155 ) -> bool: 

2156 target_suite = self.suite_info.target_suite 

2157 

2158 if not target_suite.is_pkg_in_the_suite(pkg.pkg_id): 

2159 # it is not in the target suite, migration cannot break anything 

2160 return True 

2161 

2162 if pkg.source == source_name: 

2163 # if it is built from the same source, it will be upgraded 

2164 # with the source 

2165 return True 

2166 

2167 if self.can_be_removed(pkg): 

2168 # could potentially be removed, so if that happens, it won't be 

2169 # broken 

2170 return True 

2171 

2172 if pkg.architecture == "all" and myarch not in self._nobreakall_arches: 

2173 # arch all on non nobreakarch is allowed to become uninstallable 

2174 return True 

2175 

2176 if pkg.pkg_id.package_name in self._allow_uninst[myarch]: 

2177 # there is a hint to allow this binary to become uninstallable 

2178 return True 

2179 

2180 if not target_suite.is_installable(pkg.pkg_id): 

2181 # it is already uninstallable in the target suite, migration 

2182 # cannot break anything 

2183 return True 

2184 

2185 return False 

2186 

2187 def breaks_installability( 

2188 self, 

2189 pkg_id_t: BinaryPackageId, 

2190 pkg_id_s: BinaryPackageId | None, 

2191 pkg_to_check: BinaryPackageId, 

2192 ) -> bool: 

2193 """ 

2194 Check if upgrading pkg_id_t to pkg_id_s breaks the installability of 

2195 pkg_to_check. 

2196 

2197 To check if removing pkg_id_t breaks pkg_to_check, set pkg_id_s to 

2198 None. 

2199 """ 

2200 

2201 pkg_universe = self._pkg_universe 

2202 negative_deps = pkg_universe.negative_dependencies_of(pkg_to_check) 

2203 

2204 for dep in pkg_universe.dependencies_of(pkg_to_check): 

2205 if pkg_id_t not in dep: 

2206 # this depends doesn't have pkg_id_t as alternative, so 

2207 # upgrading pkg_id_t cannot break this dependency clause 

2208 continue 

2209 

2210 # We check all the alternatives for this dependency, to find one 

2211 # that can satisfy it when pkg_id_t is upgraded to pkg_id_s 

2212 found_alternative = False 

2213 for d in dep: 

2214 if d in negative_deps: 

2215 # If this alternative dependency conflicts with 

2216 # pkg_to_check, it cannot be used to satisfy the 

2217 # dependency. 

2218 # This commonly happens when breaks are added to pkg_id_s. 

2219 continue 

2220 

2221 if d.package_name != pkg_id_t.package_name: 

2222 # a binary different from pkg_id_t can satisfy the dep, so 

2223 # upgrading pkg_id_t won't break this dependency 

2224 found_alternative = True 

2225 break 

2226 

2227 if d != pkg_id_s: 

2228 # We want to know the impact of the upgrade of 

2229 # pkg_id_t to pkg_id_s. If pkg_id_s migrates to the 

2230 # target suite, any other version of this binary will 

2231 # not be there, so it cannot satisfy this dependency. 

2232 # This includes pkg_id_t, but also other versions. 

2233 continue 

2234 

2235 # pkg_id_s can satisfy the dep 

2236 found_alternative = True 

2237 break 

2238 

2239 if not found_alternative: 

2240 return True 

2241 return False 

2242 

2243 def check_upgrade( 

2244 self, 

2245 pkg_id_t: BinaryPackageId, 

2246 pkg_id_s: BinaryPackageId | None, 

2247 source_name: str, 

2248 myarch: str, 

2249 broken_binaries: set[str], 

2250 excuse: "Excuse", 

2251 ) -> PolicyVerdict: 

2252 verdict = PolicyVerdict.PASS 

2253 

2254 pkg_universe = self._pkg_universe 

2255 all_binaries = self._all_binaries 

2256 

2257 # check all rdeps of the package in testing 

2258 rdeps_t = pkg_universe.reverse_dependencies_of(pkg_id_t) 

2259 

2260 for rdep_pkg in sorted(rdeps_t): 

2261 rdep_p = all_binaries[rdep_pkg] 

2262 

2263 # check some cases where the rdep won't become uninstallable, or 

2264 # where we don't care if it does 

2265 if self.should_skip_rdep(rdep_p, source_name, myarch): 

2266 continue 

2267 

2268 if not self.breaks_installability(pkg_id_t, pkg_id_s, rdep_pkg): 

2269 # if upgrading pkg_id_t to pkg_id_s doesn't break rdep_pkg, 

2270 # there is no implicit dependency 

2271 continue 

2272 

2273 # The upgrade breaks the installability of the rdep. We need to 

2274 # find out if there is a newer version of the rdep that solves the 

2275 # uninstallability. If that is the case, there is an implicit 

2276 # dependency. If not, the upgrade will fail. 

2277 

2278 # check source versions 

2279 good_newer_versions = set() 

2280 for npkg, suite in find_newer_binaries( 

2281 self.suite_info, rdep_p, add_source_for_dropped_bin=True 

2282 ): 

2283 if npkg.architecture == "source": 

2284 # When a newer version of the source package doesn't have 

2285 # the binary, we get the source as 'newer version'. In 

2286 # this case, the binary will not be uninstallable if the 

2287 # newer source migrates, because it is no longer there. 

2288 good_newer_versions.add(npkg) 

2289 continue 

2290 assert isinstance(npkg, BinaryPackageId) 

2291 if not self.breaks_installability(pkg_id_t, pkg_id_s, npkg): 

2292 good_newer_versions.add(npkg) 

2293 

2294 if good_newer_versions: 

2295 spec = DependencySpec(DependencyType.IMPLICIT_DEPENDENCY, myarch) 

2296 excuse.add_package_depends(spec, good_newer_versions) 

2297 else: 

2298 # no good newer versions: no possible solution 

2299 broken_binaries.add(rdep_pkg.name) 

2300 if pkg_id_s: 

2301 action = f"migrating {pkg_id_s.name} to {self.suite_info.target_suite.name}" 

2302 else: 

2303 action = f"removing {pkg_id_t.name} from {self.suite_info.target_suite.name}" 

2304 if rdep_pkg.package_name.endswith("-faux-build-depends"): 

2305 name = rdep_pkg.package_name.removesuffix("-faux-build-depends") 

2306 info = f'{action} makes Build-Depends of src:<a href="#{name}">{name}</a> uninstallable' 

2307 else: 

2308 info = '{0} makes <a href="#{1}">{1}</a> uninstallable'.format( 

2309 action, rdep_pkg.name 

2310 ) 

2311 verdict = PolicyVerdict.REJECTED_PERMANENTLY 

2312 excuse.add_verdict_info(verdict, info) 

2313 

2314 return verdict 

2315 

2316 def apply_srcarch_policy_impl( 

2317 self, 

2318 implicit_dep_info: dict[str, Any], 

2319 arch: str, 

2320 source_data_tdist: SourcePackage | None, 

2321 source_data_srcdist: SourcePackage, 

2322 excuse: "Excuse", 

2323 ) -> PolicyVerdict: 

2324 verdict = PolicyVerdict.PASS 

2325 

2326 if not source_data_tdist: 

2327 # this item is not currently in testing: no implicit dependency 

2328 return verdict 

2329 

2330 if excuse.hasreason("missingbuild"): 

2331 # if the build is missing, the policy would treat this as if the 

2332 # binaries would be removed, which would give incorrect (and 

2333 # confusing) info 

2334 info = f"missing build, not checking implicit dependencies on {arch}" 

2335 excuse.add_detailed_info(info) 

2336 return verdict 

2337 

2338 source_suite = excuse.item.suite 

2339 source_name = excuse.item.package 

2340 target_suite = self.suite_info.target_suite 

2341 all_binaries = self._all_binaries 

2342 

2343 # we check all binaries for this excuse that are currently in testing 

2344 relevant_binaries = sorted( 

2345 x 

2346 for x in source_data_tdist.binaries 

2347 if (arch == "source" or x.architecture == arch) 

2348 and x.package_name in target_suite.binaries[x.architecture] 

2349 and x.architecture not in self._new_arches 

2350 and x.architecture not in self._break_arches 

2351 and x.architecture not in self._outofsync_arches 

2352 ) 

2353 

2354 broken_binaries: set[str] = set() 

2355 

2356 assert self.hints is not None 

2357 for pkg_id_t in relevant_binaries: 

2358 mypkg = pkg_id_t.package_name 

2359 myarch = pkg_id_t.architecture 

2360 binaries_t_a = target_suite.binaries[myarch] 

2361 binaries_s_a = source_suite.binaries[myarch] 

2362 

2363 if target_suite.is_cruft(all_binaries[pkg_id_t]): 

2364 # this binary is cruft in testing: it will stay around as long 

2365 # as necessary to satisfy dependencies, so we don't need to 

2366 # care 

2367 continue 

2368 

2369 if (mybin := binaries_s_a.get(mypkg)) is not None: 

2370 pkg_id_s: Optional["BinaryPackageId"] = mybin.pkg_id 

2371 if mybin.source != source_name: 

2372 # hijack: this is too complicated to check, so we ignore 

2373 # it (the migration code will check the installability 

2374 # later anyway) 

2375 pass 

2376 elif mybin.source_version != source_data_srcdist.version: 

2377 # cruft in source suite: pretend the binary doesn't exist 

2378 pkg_id_s = None 

2379 elif pkg_id_t == pkg_id_s: 

2380 # same binary (probably arch: all from a binNMU): 

2381 # 'upgrading' doesn't change anything, for this binary, so 

2382 # it won't break anything 

2383 continue 

2384 else: 

2385 pkg_id_s = None 

2386 

2387 if not pkg_id_s and is_smooth_update_allowed( 

2388 binaries_t_a[mypkg], self._smooth_updates, self.hints 

2389 ): 

2390 # the binary isn't in the new version (or is cruft there), and 

2391 # smooth updates are allowed: the binary can stay around if 

2392 # that is necessary to satisfy dependencies, so we don't need 

2393 # to check it 

2394 continue 

2395 

2396 if ( 

2397 not pkg_id_s 

2398 and source_data_tdist.version == source_data_srcdist.version 

2399 and source_suite.suite_class is SuiteClass.ADDITIONAL_SOURCE_SUITE 

2400 and binaries_t_a[mypkg].architecture == "all" 

2401 ): 

2402 # we're very probably migrating a binNMU built in tpu where the arch:all 

2403 # binaries were not copied to it as that's not needed. This policy could 

2404 # needlessly block. 

2405 continue 

2406 

2407 v = self.check_upgrade( 

2408 pkg_id_t, pkg_id_s, source_name, myarch, broken_binaries, excuse 

2409 ) 

2410 verdict = PolicyVerdict.worst_of(verdict, v) 

2411 

2412 # each arch is processed separately, so if we already have info from 

2413 # other archs, we need to merge the info from this arch 

2414 broken_old = set(implicit_dep_info.get("broken-binaries", [])) 

2415 implicit_dep_info["broken-binaries"] = sorted(broken_old | broken_binaries) 

2416 

2417 return verdict 

2418 

2419 

2420class ReverseRemovalPolicy(AbstractBasePolicy): 

2421 def __init__(self, options: optparse.Values, suite_info: Suites) -> None: 

2422 super().__init__( 

2423 "reverseremoval", 

2424 options, 

2425 suite_info, 

2426 {SuiteClass.PRIMARY_SOURCE_SUITE, SuiteClass.ADDITIONAL_SOURCE_SUITE}, 

2427 ) 

2428 

2429 def register_hints(self, hint_parser: HintParser) -> None: 

2430 hint_parser.register_hint_type(HintType("ignore-reverse-remove")) 

2431 

2432 def initialise(self, britney: "Britney") -> None: 

2433 super().initialise(britney) 

2434 

2435 pkg_universe = britney.pkg_universe 

2436 source_suites = britney.suite_info.source_suites 

2437 target_suite = britney.suite_info.target_suite 

2438 

2439 # Build set of the sources of reverse (Build-) Depends 

2440 assert self.hints is not None 

2441 

2442 rev_bin: dict[BinaryPackageId, set[str]] = defaultdict(set) 

2443 for hint in self.hints.search("remove"): 

2444 for item in hint.packages: 

2445 # I think we don't need to look at the target suite 

2446 for src_suite in source_suites: 

2447 try: 

2448 # Explicitly not running filter_out_faux here 

2449 my_bins = set(src_suite.sources[item.uvname].binaries) 

2450 except KeyError: 

2451 continue 

2452 compute_reverse_tree(pkg_universe, my_bins) 

2453 for this_bin in my_bins: 

2454 rev_bin.setdefault(this_bin, set()).add(item.uvname) 

2455 

2456 rev_src: dict[str, set[str]] = defaultdict(set) 

2457 for bin_pkg, reasons in rev_bin.items(): 

2458 # If the pkg is in the target suite, there's nothing this 

2459 # policy wants to do. 

2460 if target_suite.is_pkg_in_the_suite(bin_pkg): 

2461 continue 

2462 that_bin = britney.all_binaries[bin_pkg] 

2463 bin_src = that_bin.source + "/" + that_bin.source_version 

2464 rev_src.setdefault(bin_src, set()).update(reasons) 

2465 self._block_src_for_rm_hint = rev_src 

2466 

2467 def apply_src_policy_impl( 

2468 self, 

2469 rev_remove_info: dict[str, Any], 

2470 source_data_tdist: SourcePackage | None, 

2471 source_data_srcdist: SourcePackage, 

2472 excuse: "Excuse", 

2473 ) -> PolicyVerdict: 

2474 verdict = PolicyVerdict.PASS 

2475 

2476 item = excuse.item 

2477 if item.name in self._block_src_for_rm_hint: 

2478 reason = ", ".join(sorted(self._block_src_for_rm_hint[item.name])) 

2479 assert self.hints is not None 

2480 excuse.addreason("reverseremoval") 

2481 if ( 

2482 ignore_hint := self.hints.search_first( 

2483 "ignore-reverse-remove", package=item.uvname, version=item.version 

2484 ) 

2485 ) is not None: 

2486 excuse.addreason("ignore-reverse-remove") 

2487 excuse.addinfo( 

2488 f"Should block migration because of remove hint for {reason}, but forced by {ignore_hint.user}" 

2489 ) 

2490 verdict = PolicyVerdict.PASS_HINTED 

2491 else: 

2492 verdict = PolicyVerdict.REJECTED_PERMANENTLY 

2493 excuse.add_verdict_info( 

2494 verdict, f"Remove hint for (transitive) dependency: {reason}" 

2495 ) 

2496 

2497 return verdict 

2498 

2499 

2500class ReproducibleState(Enum): 

2501 BAD = auto() 

2502 FAIL = auto() 

2503 GOOD = auto() 

2504 UNKNOWN = auto() 

2505 

2506 @staticmethod 

2507 def from_str(val: str | None) -> "ReproducibleState": 

2508 match val: 

2509 case "BAD": 

2510 return ReproducibleState.BAD 

2511 case "FAIL": 2511 ↛ 2512line 2511 didn't jump to line 2512 because the pattern on line 2511 never matched

2512 return ReproducibleState.FAIL 

2513 case "GOOD": 

2514 return ReproducibleState.GOOD 

2515 case "UNKNOWN" | None: 2515 ↛ 2517line 2515 didn't jump to line 2517 because the pattern on line 2515 always matched

2516 return ReproducibleState.UNKNOWN 

2517 case _: 

2518 raise ValueError(f"Invalid reproducability state f{str}") 

2519 

2520 

2521@dataclass(slots=True, frozen=True) 

2522class ReproducibleData: 

2523 state: ReproducibleState 

2524 build_id: str | None = field(default=None, kw_only=True) 

2525 diffoscope_log_id: str | None = field(default=None, kw_only=True) 

2526 artifact_id: str | None = field(default=None, kw_only=True) 

2527 

2528 

2529class ReproduciblePolicy(AbstractBasePolicy): 

2530 def __init__(self, options: optparse.Values, suite_info: Suites) -> None: 

2531 super().__init__( 

2532 "reproducible", 

2533 options, 

2534 suite_info, 

2535 {SuiteClass.PRIMARY_SOURCE_SUITE}, 

2536 ApplySrcPolicy.RUN_ON_EVERY_ARCH_ONLY, 

2537 ) 

2538 self._reproducible: dict[str, dict[tuple[str, str], ReproducibleData]] = {} 

2539 self._components: tuple[str, ...] = () 

2540 

2541 # Default values for this policy's options 

2542 parse_option(options, "repro_success_bounty", default=0, to_int=True) 

2543 parse_option(options, "repro_regression_penalty", default=0, to_int=True) 

2544 parse_option(options, "repro_log_url") 

2545 parse_option(options, "repro_excuse_url") 

2546 parse_option(options, "repro_retry_url") 

2547 parse_option(options, "repro_components") 

2548 

2549 def register_hints(self, hint_parser: HintParser) -> None: 

2550 hint_parser.register_hint_type( 

2551 HintType( 

2552 "ignore-reproducible-src", 

2553 versioned=HintAnnotate.OPTIONAL, 

2554 architectured=HintAnnotate.OPTIONAL, 

2555 ) 

2556 ) 

2557 hint_parser.register_hint_type( 

2558 HintType( 

2559 "ignore-reproducible", 

2560 versioned=HintAnnotate.OPTIONAL, 

2561 architectured=HintAnnotate.OPTIONAL, 

2562 ) 

2563 ) 

2564 

2565 def initialise(self, britney: "Britney") -> None: 

2566 super().initialise(britney) 

2567 summary = self._reproducible 

2568 

2569 valid_release_names = { 

2570 suite.codename 

2571 for suite in chain( 

2572 (britney.suite_info.target_suite,), 

2573 britney.suite_info.source_suites, 

2574 ) 

2575 } | { 

2576 suite.name 

2577 for suite in chain( 

2578 (britney.suite_info.target_suite,), 

2579 britney.suite_info.source_suites, 

2580 ) 

2581 } 

2582 

2583 assert hasattr( 

2584 self, "state_dir" 

2585 ), "Please set STATE_DIR in the britney configuration" 

2586 assert ( 

2587 self.options.repro_components 

2588 ), "Please set REPRO_COMPONENTS in the britney configuration" 

2589 self._components = tuple(self.options.repro_components.split()) 

2590 

2591 for file in os.listdir(self.state_dir): 

2592 if not file.startswith("reproducible-") or not file.endswith(".json"): 

2593 continue 

2594 filename = os.path.join(self.state_dir, file) 

2595 

2596 self.logger.info("Loading reproducibility report from %s", filename) 

2597 with open(filename) as fd: 

2598 if os.fstat(fd.fileno()).st_size < 1: 

2599 continue 

2600 data = json.load(fd) 

2601 

2602 for result in data["records"]: 

2603 if ( 2603 ↛ 2607line 2603 didn't jump to line 2607 because the condition on line 2603 was never true

2604 release := result.get("release") 

2605 ) is not None and release not in valid_release_names: 

2606 # tests do not have a release set 

2607 continue 

2608 

2609 state = ReproducibleState.from_str(result.get("status")) 

2610 repo = { 

2611 key: value 

2612 for key, value in result.items() 

2613 if key in ("build_id", "diffoscope_log_id", "artifact_id") 

2614 } 

2615 

2616 summary.setdefault(result["architecture"], {})[ 

2617 (result["name"], result["version"]) 

2618 ] = ReproducibleData(state, **repo) 

2619 

2620 def _lookup_data( 

2621 self, package_name: str, version: str, arch: str 

2622 ) -> tuple[ReproducibleData, str] | None: 

2623 key = (package_name, version) 

2624 if (repo := self._reproducible[arch].get(key)) is not None: 

2625 return repo, arch 

2626 

2627 repo = self._reproducible["all"].get(key) 

2628 return (repo, "all") if repo is not None else None 

2629 

2630 def _format_link(self, bpid: BinaryPackageId, arch: str) -> str: 

2631 data = self._lookup_data(bpid.package_name, bpid.version, arch) 

2632 assert data is not None 

2633 repo, arch = data 

2634 if repo.diffoscope_log_id and (diff_id := repo.artifact_id): 2634 ↛ 2635line 2634 didn't jump to line 2635 because the condition on line 2634 was never true

2635 endpoint = f"artifacts/{diff_id}/diffoscope" 

2636 else: 

2637 endpoint = "log" 

2638 url = self.options.repro_log_url.format( 

2639 arch=arch, build_id=repo.build_id, endpoint=endpoint 

2640 ) 

2641 return f'<a href="{url}">{bpid.package_name}</a>' 

2642 

2643 def _create_link_to_log(self, arch: str, failed_bpids: set[BinaryPackageId]) -> str: 

2644 if not self.options.repro_log_url: 2644 ↛ 2645line 2644 didn't jump to line 2645 because the condition on line 2644 was never true

2645 return ": " + ", ".join(bpid.package_name for bpid in sorted(failed_bpids)) 

2646 

2647 return ": " + ", ".join( 

2648 self._format_link(bpid, arch) for bpid in sorted(failed_bpids) 

2649 ) 

2650 

2651 def apply_srcarch_policy_impl( 

2652 self, 

2653 policy_info: dict[str, Any], 

2654 arch: str, 

2655 source_data_tdist: SourcePackage | None, 

2656 source_data_srcdist: SourcePackage, 

2657 excuse: "Excuse", 

2658 ) -> PolicyVerdict: 

2659 verdict = PolicyVerdict.PASS 

2660 eligible_for_bounty = False 

2661 all_hints = [] 

2662 

2663 assert self.hints is not None # Needed for type checking / mypy 

2664 

2665 # we don't want to apply this policy (yet) on binNMUs 

2666 if excuse.item.architecture != "source": 2666 ↛ 2667line 2666 didn't jump to line 2667 because the condition on line 2666 was never true

2667 return verdict 

2668 

2669 # we're not supposed to judge on this arch 

2670 if arch not in self.options.repro_arches: 2670 ↛ 2671line 2670 didn't jump to line 2671 because the condition on line 2670 was never true

2671 return verdict 

2672 

2673 # bail out if this arch has no packages for this source (not build 

2674 # here) 

2675 if arch not in excuse.packages: 2675 ↛ 2676line 2675 didn't jump to line 2676 because the condition on line 2675 was never true

2676 return verdict 

2677 

2678 component = get_component(source_data_srcdist.section) 

2679 

2680 if self._components and component not in self._components: 2680 ↛ 2681line 2680 didn't jump to line 2681 because the condition on line 2680 was never true

2681 self.logger.debug( 

2682 "%s skipping reproducible policy, component %s not requested", 

2683 excuse.name, 

2684 component, 

2685 ) 

2686 return verdict 

2687 

2688 source_name = excuse.item.package 

2689 

2690 if self.options.repro_excuse_url: 

2691 url = self.options.repro_excuse_url.format( 

2692 package=quote(source_name), arch=arch 

2693 ) 

2694 url_html = ' - <a href="%s">info</a>' % url 

2695 # When run on multiple archs, the last one "wins" 

2696 policy_info["status-url"] = url 

2697 else: 

2698 url = None 

2699 url_html = "" 

2700 

2701 if arch not in self._reproducible: 2701 ↛ 2702line 2701 didn't jump to line 2702 because the condition on line 2701 was never true

2702 verdict = PolicyVerdict.REJECTED_TEMPORARILY 

2703 msg = f"No reproducibility data available at all for {arch}" 

2704 excuse.add_verdict_info(verdict, msg) 

2705 return verdict 

2706 if "all" not in self._reproducible: 2706 ↛ 2707line 2706 didn't jump to line 2707 because the condition on line 2706 was never true

2707 verdict = PolicyVerdict.REJECTED_TEMPORARILY 

2708 msg = "No reproducibility data available at all for arch:all" 

2709 excuse.add_verdict_info(verdict, msg) 

2710 return verdict 

2711 

2712 # skip/delay policy until both arch:arch and arch:all builds are done 

2713 if (arch or "all") in excuse.missing_builds: 2713 ↛ 2714line 2713 didn't jump to line 2714 because the condition on line 2713 was never true

2714 self.logger.debug( 

2715 "%s not built for %s or all, skipping reproducible policy", 

2716 excuse.name, 

2717 arch, 

2718 ) 

2719 verdict = PolicyVerdict.REJECTED_TEMPORARILY 

2720 excuse.add_verdict_info( 

2721 verdict, 

2722 f"Reproducibility check deferred on {arch}: missing builds{url_html}", 

2723 ) 

2724 return verdict 

2725 

2726 source_suite_state = "not-unknown" 

2727 failed_bpids: set[BinaryPackageId] = set() 

2728 # The states should either be GOOD/BAD for all binaries, UNKNOWN for all 

2729 # binaries, or missing for all binaries, but let's not assume that. 

2730 # They can be from different components after all. 

2731 bins_src, src_suite_name = binaries_from_source_version( 

2732 source_data_srcdist, self.suite_info 

2733 ) 

2734 for bpid in bins_src: 

2735 if bpid.architecture not in ("all", arch): 2735 ↛ 2736line 2735 didn't jump to line 2736 because the condition on line 2735 was never true

2736 continue 

2737 in_component = True 

2738 for suite in self.suite_info.source_suites: 

2739 if suite.name == src_suite_name and ( 

2740 ( 

2741 component := get_component( 

2742 suite.all_binaries_in_suite[bpid].section 

2743 ) 

2744 ) 

2745 not in self._components 

2746 ): 

2747 self.logger.debug( 

2748 "repro check for %s skipped due to component %s", 

2749 bpid, 

2750 component, 

2751 ) 

2752 in_component = False 

2753 break 

2754 if not in_component: 

2755 # TODO: should we update the excuses text? 

2756 continue 

2757 

2758 if ( 

2759 data := self._lookup_data(bpid.package_name, bpid.version, arch) 

2760 ) is not None: 

2761 pkg_info, _ = data 

2762 self.logger.debug("repro data for %s: %s", bpid, pkg_info.state) 

2763 if pkg_info.state is ReproducibleState.BAD: 

2764 failed_bpids.add(bpid) 

2765 # not changing source_suite_state here on purpose 

2766 elif ( 2766 ↛ 2770line 2766 didn't jump to line 2770

2767 pkg_info.state is ReproducibleState.FAIL 

2768 or pkg_info.state is ReproducibleState.UNKNOWN 

2769 ): 

2770 source_suite_state = "unknown" 

2771 else: 

2772 self.logger.debug("No repro data found for %s", bpid) 

2773 # but maybe it's hinted (e.g. at the time of writing 

2774 # reproduce.debian.net has a bug where udebs go missing) 

2775 if ( 

2776 bpid_hints := self.hints.search_first( 

2777 "ignore-reproducible", 

2778 package=bpid.package_name, 

2779 version=bpid.version, 

2780 architecture=bpid.architecture, 

2781 ) 

2782 ) is not None: 

2783 all_hints.append(bpid_hints) 

2784 self.logger.debug("repro: hint found for %s: %s", source_name, bpid) 

2785 else: 

2786 source_suite_state = "unknown" 

2787 break 

2788 

2789 if source_suite_state == "not-unknown": 

2790 source_suite_state = "known" 

2791 

2792 excuse_info = [] 

2793 if source_suite_state == "unknown": 

2794 verdict = PolicyVerdict.REJECTED_TEMPORARILY 

2795 excuse_info.append( 

2796 f"Reproducibility check waiting for results on {arch}{url_html}" 

2797 ) 

2798 policy_info.setdefault("state", {}).setdefault(arch, "unavailable") 

2799 elif failed_bpids: 

2800 ignored_bpids: set[BinaryPackageId] = set() 

2801 if source_data_tdist is None: 2801 ↛ 2802line 2801 didn't jump to line 2802 because the condition on line 2801 was never true

2802 target_suite_state = "new" 

2803 else: 

2804 target_suite_state = "reproducible" 

2805 for bpid in failed_bpids: 

2806 pkg_name = bpid.package_name 

2807 for bpid_t in filter_out_faux_gen(source_data_tdist.binaries): 

2808 if bpid_t.architecture not in ("all", arch): 2808 ↛ 2809line 2808 didn't jump to line 2809 because the condition on line 2808 was never true

2809 continue 

2810 if pkg_name != bpid_t.package_name: 2810 ↛ 2811line 2810 didn't jump to line 2811 because the condition on line 2810 was never true

2811 continue 

2812 if ( 2812 ↛ 2827line 2812 didn't jump to line 2827 because the condition on line 2812 was always true

2813 data := self._lookup_data(pkg_name, bpid_t.version, arch) 

2814 ) is not None: 

2815 pkg_info, _ = data 

2816 self.logger.debug( 

2817 "testing repro data for %s: %s", bpid_t, pkg_info.state 

2818 ) 

2819 if pkg_info.state is ReproducibleState.BAD: 

2820 ignored_bpids.add(bpid) 

2821 elif ( 2821 ↛ 2825line 2821 didn't jump to line 2825

2822 pkg_info.state is ReproducibleState.FAIL 

2823 or pkg_info.state is ReproducibleState.UNKNOWN 

2824 ): 

2825 target_suite_state = "unknown" 

2826 else: 

2827 self.logger.debug( 

2828 "No testing repro data found for %s", bpid_t 

2829 ) 

2830 # This shouldn't happen as for the past migration 

2831 # to have been allowed, there should be data. 

2832 target_suite_state = "unknown" 

2833 break 

2834 

2835 # Reminder: code here is part of the non-reproducibile source-suite branch 

2836 if target_suite_state == "new": 2836 ↛ 2837line 2836 didn't jump to line 2837 because the condition on line 2836 was never true

2837 verdict = PolicyVerdict.REJECTED_PERMANENTLY 

2838 excuse_info.append( 

2839 f"New but not reproduced on {arch}{url_html}" 

2840 f"{self._create_link_to_log(arch, failed_bpids)}" 

2841 ) 

2842 policy_info.setdefault("state", {}).setdefault( 

2843 arch, "new but not reproducible" 

2844 ) 

2845 elif target_suite_state == "unknown": 2845 ↛ 2847line 2845 didn't jump to line 2847 because the condition on line 2845 was never true

2846 # Shouldn't happen after initial bootstrap once blocking 

2847 verdict = PolicyVerdict.REJECTED_TEMPORARILY 

2848 excuse_info.append( 

2849 f"Reproducibility check failed and now waiting for reference " 

2850 f"results on {arch}{url_html}" 

2851 f"{self._create_link_to_log(arch, failed_bpids)}" 

2852 ) 

2853 policy_info.setdefault("state", {}).setdefault( 

2854 arch, "waiting for reference" 

2855 ) 

2856 elif failed_bpids <= ignored_bpids: 

2857 # For the forseeable future we want to prevent regressions, one day 

2858 # we might want to even block these. 

2859 # verdict = PolicyVerdict.REJECTED_PERMANENTLY 

2860 excuse_info.append( 

2861 f"Not reproduced on {arch} (not a regression)" 

2862 f"{self._create_link_to_log(arch, failed_bpids)}" 

2863 ) 

2864 policy_info.setdefault("state", {}).setdefault(arch, "not reproducible") 

2865 else: 

2866 verdict = PolicyVerdict.REJECTED_PERMANENTLY 

2867 excuse_info.append( 

2868 f"Reproducibility regression on {arch}" 

2869 f"{self._create_link_to_log(arch, failed_bpids - ignored_bpids)}" 

2870 ) 

2871 policy_info.setdefault("state", {}).setdefault(arch, "regression") 

2872 

2873 # non-reproducible source-suite cases are handled above, so here we 

2874 # handle the last of the source-suite cases 

2875 else: 

2876 excuse_info.append(f"Reproduced on {arch}{url_html}") 

2877 policy_info.setdefault("state", {}).setdefault(arch, "reproducible") 

2878 eligible_for_bounty = True 

2879 

2880 if verdict.is_rejected: 

2881 for hint_arch in ("source", arch): 

2882 if ( 

2883 ignore_hint := self.hints.search_first( 

2884 "ignore-reproducible-src", 

2885 package=source_name, 

2886 version=source_data_srcdist.version, 

2887 architecture=hint_arch, 

2888 ) 

2889 ) is not None: 

2890 # one hint is enough, take the first one encountered 

2891 verdict = PolicyVerdict.PASS_HINTED 

2892 policy_info.setdefault("hints", {}).setdefault(arch, []).append( 

2893 f"{ignore_hint.user}: {str(ignore_hint)}" 

2894 ) 

2895 if hint_arch == arch: 2895 ↛ 2898line 2895 didn't jump to line 2898 because the condition on line 2895 was always true

2896 on_arch = f" on {arch}" 

2897 else: 

2898 on_arch = "" 

2899 excuse_info.append( 

2900 f"Reproducibility issues ignored for src:{ignore_hint.package}" 

2901 f"{on_arch} as requested by {ignore_hint.user}" 

2902 ) 

2903 break 

2904 

2905 if verdict.is_rejected: 

2906 if source_suite_state == "known": 

2907 check_bpids = failed_bpids - ignored_bpids 

2908 else: 

2909 # Let's not wait for results if all binaries have a hint 

2910 check_bpids = filter_out_faux(source_data_srcdist.binaries) 

2911 missed_bpids = set() 

2912 

2913 for bpid in check_bpids: 

2914 if ( 

2915 bpid_hint := self.hints.search_first( 

2916 "ignore-reproducible", 

2917 package=bpid.package_name, 

2918 version=bpid.version, 

2919 architecture=bpid.architecture, 

2920 ) 

2921 ) is not None: 

2922 # one hint per binary is enough 

2923 all_hints.append(bpid_hint) 

2924 self.logger.debug( 

2925 "repro: hint found for %s: %s", source_name, bpid 

2926 ) 

2927 else: 

2928 missed_bpids.add(bpid) 

2929 

2930 if not missed_bpids: 

2931 verdict = PolicyVerdict.PASS_HINTED 

2932 for hint in all_hints: 

2933 policy_info.setdefault("hints", {}).setdefault(arch, []).append( 

2934 hint.user + ": " + str(hint) 

2935 ) 

2936 # TODO: we're going to print this for arch:all binaries on each arch 

2937 excuse_info.append( 

2938 f"Reproducibility issues ignored for {hint.package} on {arch} as " 

2939 f"requested by {hint.user}" 

2940 ) 

2941 elif all_hints: 2941 ↛ 2942line 2941 didn't jump to line 2942 because the condition on line 2941 was never true

2942 self.logger.info( 

2943 "repro: binary hints for %s ignored as they don't cover these binaries %s", 

2944 source_name, 

2945 missed_bpids, 

2946 ) 

2947 

2948 # A binary without results got hinted 

2949 if not verdict.is_rejected and all_hints: 

2950 for hint in all_hints: 

2951 excuse_info.append( 

2952 f"Reproducibility unknown for {hint.package} but ignored on {arch} as " 

2953 f"requested by {hint.user}" 

2954 ) 

2955 

2956 if self.options.repro_success_bounty and eligible_for_bounty: 2956 ↛ 2957line 2956 didn't jump to line 2957 because the condition on line 2956 was never true

2957 excuse.add_bounty("reproducibility", self.options.repro_success_bounty) 

2958 

2959 if verdict.is_rejected and self.options.repro_regression_penalty: 2959 ↛ 2961line 2959 didn't jump to line 2961 because the condition on line 2959 was never true

2960 # With a non-zero penalty, we shouldn't block on this policy 

2961 verdict = PolicyVerdict.PASS 

2962 if self.options.repro_regression_penalty > 0: 

2963 excuse.add_penalty( 

2964 "reproducibility", self.options.repro_regression_penalty 

2965 ) 

2966 

2967 for msg in excuse_info: 

2968 if verdict.is_rejected: 

2969 excuse.add_verdict_info(verdict, msg) 

2970 else: 

2971 excuse.addinfo(msg) 

2972 

2973 return verdict