1#! /usr/bin/env python3 

2 

3""" 

4Display, edit and check the release manager's transition file. 

5 

6@contact: Debian FTP Master <ftpmaster@debian.org> 

7@copyright: 2008 Joerg Jaspert <joerg@debian.org> 

8@license: GNU General Public License version 2 or later 

9""" 

10 

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

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

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

14# (at your option) any later version. 

15 

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

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

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

19# GNU General Public License for more details. 

20 

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

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

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

24 

25################################################################################ 

26 

27# <elmo> if klecker.d.o died, I swear to god, I'm going to migrate to gentoo. 

28 

29################################################################################ 

30 

31import errno 

32import fcntl 

33import os 

34import subprocess 

35import sys 

36import tempfile 

37import time 

38from collections.abc import Iterable 

39from typing import Optional 

40 

41import apt_pkg 

42import yaml 

43 

44from daklib import utils 

45from daklib.dak_exceptions import TransitionsError 

46from daklib.dbconn import DBConn, get_source_in_suite 

47from daklib.regexes import re_broken_package 

48 

49# Globals 

50Cnf = None #: Configuration, apt_pkg.Configuration 

51Options = None #: Parsed CommandLine arguments 

52 

53################################################################################ 

54 

55##################################### 

56#### This may run within sudo !! #### 

57##################################### 

58 

59 

60def init(): 

61 """ 

62 Initialize. Sets up database connection, parses commandline arguments. 

63 

64 .. warning:: 

65 

66 This function may run **within sudo** 

67 

68 """ 

69 global Cnf, Options 

70 

71 apt_pkg.init() 

72 

73 Cnf = utils.get_conf() 

74 

75 Arguments = [ 

76 ("a", "automatic", "Edit-Transitions::Options::Automatic"), 

77 ("h", "help", "Edit-Transitions::Options::Help"), 

78 ("e", "edit", "Edit-Transitions::Options::Edit"), 

79 ("i", "import", "Edit-Transitions::Options::Import", "HasArg"), 

80 ("c", "check", "Edit-Transitions::Options::Check"), 

81 ("s", "sudo", "Edit-Transitions::Options::Sudo"), 

82 ("n", "no-action", "Edit-Transitions::Options::No-Action"), 

83 ] 

84 

85 for i in ["automatic", "help", "no-action", "edit", "import", "check", "sudo"]: 

86 key = "Edit-Transitions::Options::%s" % i 

87 if key not in Cnf: 87 ↛ 85line 87 didn't jump to line 85, because the condition on line 87 was never false

88 Cnf[key] = "" 

89 

90 apt_pkg.parse_commandline(Cnf, Arguments, sys.argv) 

91 

92 Options = Cnf.subtree("Edit-Transitions::Options") 

93 

94 if Options["help"]: 94 ↛ 97line 94 didn't jump to line 97, because the condition on line 94 was never false

95 usage() 

96 

97 username = utils.getusername() 

98 if username != "dak": 

99 print("Non-dak user: %s" % username) 

100 Options["sudo"] = "y" 

101 

102 # Initialise DB connection 

103 DBConn() 

104 

105 

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

107 

108 

109def usage(exit_code=0): 

110 print( 

111 """Usage: transitions [OPTION]... 

112Update and check the release managers transition file. 

113 

114Options: 

115 

116 -h, --help show this help and exit. 

117 -e, --edit edit the transitions file 

118 -i, --import <file> check and import transitions from file 

119 -c, --check check the transitions file, remove outdated entries 

120 -S, --sudo use sudo to update transitions file 

121 -a, --automatic don't prompt (only affects check). 

122 -n, --no-action don't do anything (only affects check)""" 

123 ) 

124 

125 sys.exit(exit_code) 

126 

127 

128################################################################################ 

129 

130##################################### 

131#### This may run within sudo !! #### 

132##################################### 

133 

134 

135def load_transitions(trans_file: str) -> Optional[dict]: 

136 """ 

137 Parse a transition yaml file and check it for validity. 

138 

139 .. warning:: 

140 

141 This function may run **within sudo** 

142 

143 :param trans_file: filename to parse 

144 :return: validated dictionary of transition entries or None 

145 if validation fails, empty string if reading `trans_file` 

146 returned something else than a dict 

147 

148 """ 

149 # Parse the yaml file 

150 with open(trans_file, "r") as sourcefile: 

151 sourcecontent = sourcefile.read() 

152 failure = False 

153 try: 

154 trans = yaml.safe_load(sourcecontent) 

155 except yaml.YAMLError as exc: 

156 # Someone fucked it up 

157 print("ERROR: %s" % (exc)) 

158 return None 

159 

160 # lets do further validation here 

161 checkkeys = ["source", "reason", "packages", "new", "rm"] 

162 

163 # If we get an empty definition - we just have nothing to check, no transitions defined 

164 if not isinstance(trans, dict): 

165 # This can be anything. We could have no transitions defined. Or someone totally fucked up the 

166 # file, adding stuff in a way we dont know or want. Then we set it empty - and simply have no 

167 # transitions anymore. User will see it in the information display after he quit the editor and 

168 # could fix it 

169 trans = "" 

170 return trans 

171 

172 try: 

173 for test in trans: 

174 t = trans[test] 

175 

176 # First check if we know all the keys for the transition and if they have 

177 # the right type (and for the packages also if the list has the right types 

178 # included, ie. not a list in list, but only str in the list) 

179 for key in t: 

180 if key not in checkkeys: 

181 print("ERROR: Unknown key %s in transition %s" % (key, test)) 

182 failure = True 

183 

184 if key == "packages": 

185 if not isinstance(t[key], list): 

186 print( 

187 "ERROR: Unknown type %s for packages in transition %s." 

188 % (type(t[key]), test) 

189 ) 

190 failure = True 

191 try: 

192 for package in t["packages"]: 

193 if not isinstance(package, str): 

194 print( 

195 "ERROR: Packages list contains invalid type %s (as %s) in transition %s" 

196 % (type(package), package, test) 

197 ) 

198 failure = True 

199 if re_broken_package.match(package): 

200 # Someone had a space too much (or not enough), we have something looking like 

201 # "package1 - package2" now. 

202 print( 

203 "ERROR: Invalid indentation of package list in transition %s, around package(s): %s" 

204 % (test, package) 

205 ) 

206 failure = True 

207 except TypeError: 

208 # In case someone has an empty packages list 

209 print("ERROR: No packages defined in transition %s" % (test)) 

210 failure = True 

211 continue 

212 

213 elif not isinstance(t[key], str): 

214 if key == "new" and isinstance(t[key], int): 

215 # Ok, debian native version 

216 continue 

217 else: 

218 print( 

219 "ERROR: Unknown type %s for key %s in transition %s" 

220 % (type(t[key]), key, test) 

221 ) 

222 failure = True 

223 

224 # And now the other way round - are all our keys defined? 

225 for key in checkkeys: 

226 if key not in t: 

227 print("ERROR: Missing key %s in transition %s" % (key, test)) 

228 failure = True 

229 except TypeError: 

230 # In case someone defined very broken things 

231 print("ERROR: Unable to parse the file") 

232 failure = True 

233 

234 if failure: 

235 return None 

236 

237 return trans 

238 

239 

240################################################################################ 

241 

242##################################### 

243#### This may run within sudo !! #### 

244##################################### 

245 

246 

247def lock_file(f): 

248 """ 

249 Lock a file 

250 

251 .. warning:: 

252 

253 This function may run **within sudo** 

254 """ 

255 for retry in range(10): 

256 lock_fd = os.open(f, os.O_RDWR | os.O_CREAT) 

257 try: 

258 fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) 

259 return lock_fd 

260 except OSError as e: 

261 if e.errno in (errno.EACCES, errno.EEXIST): 

262 print("Unable to get lock for %s (try %d of 10)" % (f, retry + 1)) 

263 time.sleep(60) 

264 else: 

265 raise 

266 

267 utils.fubar("Couldn't obtain lock for %s." % (f)) 

268 

269 

270################################################################################ 

271 

272##################################### 

273#### This may run within sudo !! #### 

274##################################### 

275 

276 

277def write_transitions(from_trans: dict) -> None: 

278 """ 

279 Update the active transitions file safely. 

280 This function takes a parsed input file (which avoids invalid 

281 files or files that may be be modified while the function is 

282 active) and ensure the transitions file is updated atomically 

283 to avoid locks. 

284 

285 .. warning:: 

286 

287 This function may run **within sudo** 

288 

289 :param from_trans: transitions dictionary, as returned by :func:`load_transitions` 

290 """ 

291 

292 trans_file = Cnf["Dinstall::ReleaseTransitions"] 

293 trans_temp = trans_file + ".tmp" 

294 

295 trans_lock = lock_file(trans_file) 

296 temp_lock = lock_file(trans_temp) 

297 

298 with open(trans_temp, "w") as destfile: 

299 yaml.safe_dump(from_trans, destfile, default_flow_style=False) 

300 

301 os.rename(trans_temp, trans_file) 

302 os.close(temp_lock) 

303 os.close(trans_lock) 

304 

305 

306################################################################################ 

307 

308########################################## 

309#### This usually runs within sudo !! #### 

310########################################## 

311 

312 

313def write_transitions_from_file(from_file: str) -> None: 

314 """ 

315 We have a file we think is valid; if we're using sudo, we invoke it 

316 here, otherwise we just parse the file and call write_transitions 

317 

318 .. warning:: 

319 

320 This function usually runs **within sudo** 

321 

322 :param from_file: filename of a transitions file 

323 """ 

324 

325 # Lets check if from_file is in the directory we expect it to be in 

326 if not os.path.abspath(from_file).startswith(Cnf["Dir::TempPath"]): 

327 print("Will not accept transitions file outside of %s" % (Cnf["Dir::TempPath"])) 

328 sys.exit(3) 

329 

330 if Options["sudo"]: 

331 subprocess.check_call( 

332 [ 

333 "/usr/bin/sudo", 

334 "-u", 

335 "dak", 

336 "-H", 

337 "/usr/local/bin/dak", 

338 "transitions", 

339 "--import", 

340 from_file, 

341 ] 

342 ) 

343 else: 

344 trans = load_transitions(from_file) 

345 if trans is None: 

346 raise TransitionsError("Unparsable transitions file %s" % (from_file)) 

347 write_transitions(trans) 

348 

349 

350################################################################################ 

351 

352 

353def temp_transitions_file(transitions: dict) -> str: 

354 """ 

355 Open a temporary file and dump the current transitions into it, so users 

356 can edit them. 

357 

358 :param transitions: current defined transitions 

359 :return: path of newly created tempfile 

360 

361 .. note:: 

362 

363 file is unlinked by caller, but fd is never actually closed. 

364 We need the chmod, as the file is (most possibly) copied from a 

365 sudo-ed script and would be unreadable if it has default mkstemp mode 

366 """ 

367 

368 (fd, path) = tempfile.mkstemp("", "transitions", Cnf["Dir::TempPath"]) 

369 os.chmod(path, 0o644) 

370 with open(path, "w") as f: 

371 yaml.safe_dump(transitions, f, default_flow_style=False) 

372 return path 

373 

374 

375################################################################################ 

376 

377 

378def edit_transitions(): 

379 """Edit the defined transitions.""" 

380 trans_file = Cnf["Dinstall::ReleaseTransitions"] 

381 edit_file = temp_transitions_file(load_transitions(trans_file)) 

382 

383 editor = os.environ.get("EDITOR", "vi") 

384 

385 while True: 

386 result = os.system("%s %s" % (editor, edit_file)) 

387 if result != 0: 

388 os.unlink(edit_file) 

389 utils.fubar( 

390 "%s invocation failed for %s, not removing tempfile." 

391 % (editor, edit_file) 

392 ) 

393 

394 # Now try to load the new file 

395 test = load_transitions(edit_file) 

396 

397 if test is None: 

398 # Edit is broken 

399 print("Edit was unparsable.") 

400 prompt = "[E]dit again, Drop changes?" 

401 default = "E" 

402 else: 

403 print("Edit looks okay.\n") 

404 print("The following transitions are defined:") 

405 print( 

406 "------------------------------------------------------------------------" 

407 ) 

408 transition_info(test) 

409 

410 prompt = "[S]ave, Edit again, Drop changes?" 

411 default = "S" 

412 

413 answer = "XXX" 

414 while prompt.find(answer) == -1: 

415 answer = utils.input_or_exit(prompt) 

416 if answer == "": 

417 answer = default 

418 answer = answer[:1].upper() 

419 

420 if answer == "E": 

421 continue 

422 elif answer == "D": 

423 os.unlink(edit_file) 

424 print("OK, discarding changes") 

425 sys.exit(0) 

426 elif answer == "S": 

427 # Ready to save 

428 break 

429 else: 

430 print("You pressed something you shouldn't have :(") 

431 sys.exit(1) 

432 

433 # We seem to be done and also have a working file. Copy over. 

434 write_transitions_from_file(edit_file) 

435 os.unlink(edit_file) 

436 

437 print("Transitions file updated.") 

438 

439 

440################################################################################ 

441 

442 

443def check_transitions(transitions): 

444 """ 

445 Check if the defined transitions still apply and remove those that no longer do. 

446 @note: Asks the user for confirmation first unless -a has been set. 

447 

448 """ 

449 global Cnf 

450 

451 to_dump = 0 

452 to_remove = [] 

453 info = {} 

454 

455 session = DBConn().session() 

456 

457 # Now look through all defined transitions 

458 for trans in transitions: 

459 t = transitions[trans] 

460 source = t["source"] 

461 expected = t["new"] 

462 

463 # Will be an empty list if nothing is in testing. 

464 sourceobj = get_source_in_suite(source, "testing", session) 

465 

466 info[trans] = get_info( 

467 trans, source, expected, t["rm"], t["reason"], t["packages"] 

468 ) 

469 print(info[trans]) 

470 

471 if sourceobj is None: 

472 # No package in testing 

473 print( 

474 "Transition source %s not in testing, transition still ongoing." 

475 % (source) 

476 ) 

477 else: 

478 current = sourceobj.version 

479 compare = apt_pkg.version_compare(current, expected) 

480 if compare < 0: 

481 # This is still valid, the current version in database is older than 

482 # the new version we wait for 

483 print( 

484 "This transition is still ongoing, we currently have version %s" 

485 % (current) 

486 ) 

487 else: 

488 print( 

489 "REMOVE: This transition is over, the target package reached testing. REMOVE" 

490 ) 

491 print("%s wanted version: %s, has %s" % (source, expected, current)) 

492 to_remove.append(trans) 

493 to_dump = 1 

494 print( 

495 "-------------------------------------------------------------------------" 

496 ) 

497 

498 if to_dump: 

499 prompt = "Removing: " 

500 for remove in to_remove: 

501 prompt += remove 

502 prompt += "," 

503 

504 prompt += " Commit Changes? (y/N)" 

505 answer = "" 

506 

507 if Options["no-action"]: 

508 answer = "n" 

509 elif Options["automatic"]: 

510 answer = "y" 

511 else: 

512 answer = utils.input_or_exit(prompt).lower() 

513 

514 if answer == "": 

515 answer = "n" 

516 

517 if answer == "n": 

518 print("Not committing changes") 

519 sys.exit(0) 

520 elif answer == "y": 

521 print("Committing") 

522 subst = {} 

523 subst["__SUBJECT__"] = "Transitions completed: " + ", ".join( 

524 sorted(to_remove) 

525 ) 

526 subst["__TRANSITION_MESSAGE__"] = ( 

527 "The following transitions were removed:\n" 

528 ) 

529 for remove in sorted(to_remove): 

530 subst["__TRANSITION_MESSAGE__"] += info[remove] + "\n" 

531 del transitions[remove] 

532 

533 # If we have a mail address configured for transitions, 

534 # send a notification 

535 subst["__TRANSITION_EMAIL__"] = Cnf.get("Transitions::Notifications", "") 

536 if subst["__TRANSITION_EMAIL__"] != "": 

537 print("Sending notification to %s" % subst["__TRANSITION_EMAIL__"]) 

538 subst["__DAK_ADDRESS__"] = Cnf["Dinstall::MyEmailAddress"] 

539 subst["__BCC__"] = "X-DAK: dak transitions" 

540 if "Dinstall::Bcc" in Cnf: 

541 subst["__BCC__"] += "\nBcc: %s" % Cnf["Dinstall::Bcc"] 

542 message = utils.TemplateSubst( 

543 subst, os.path.join(Cnf["Dir::Templates"], "transition.removed") 

544 ) 

545 utils.send_mail(message) 

546 

547 edit_file = temp_transitions_file(transitions) 

548 write_transitions_from_file(edit_file) 

549 

550 print("Done") 

551 else: 

552 print("WTF are you typing?") 

553 sys.exit(0) 

554 

555 

556################################################################################ 

557 

558 

559def get_info( 

560 trans: str, 

561 source: str, 

562 expected: str, 

563 rm: str, 

564 reason: str, 

565 packages: Iterable[str], 

566) -> str: 

567 """ 

568 Print information about a single transition. 

569 

570 :param trans: Transition name 

571 :param source: Source package 

572 :param expected: Expected version in testing 

573 :param rm: Responsible release manager (RM) 

574 :param reason: Reason 

575 :param packages: list of blocked packages 

576 """ 

577 return """Looking at transition: %s 

578Source: %s 

579New Version: %s 

580Responsible: %s 

581Description: %s 

582Blocked Packages (total: %d): %s 

583""" % ( 

584 trans, 

585 source, 

586 expected, 

587 rm, 

588 reason, 

589 len(packages), 

590 ", ".join(packages), 

591 ) 

592 

593 

594################################################################################ 

595 

596 

597def transition_info(transitions: dict): 

598 """ 

599 Print information about all defined transitions. 

600 Calls :func:`get_info` for every transition and then tells user if the transition is 

601 still ongoing or if the expected version already hit testing. 

602 

603 :param transitions: defined transitions 

604 """ 

605 

606 session = DBConn().session() 

607 

608 for trans in transitions: 

609 t = transitions[trans] 

610 source = t["source"] 

611 expected = t["new"] 

612 

613 # Will be None if nothing is in testing. 

614 sourceobj = get_source_in_suite(source, "testing", session) 

615 

616 print(get_info(trans, source, expected, t["rm"], t["reason"], t["packages"])) 

617 

618 if sourceobj is None: 

619 # No package in testing 

620 print( 

621 "Transition source %s not in testing, transition still ongoing." 

622 % (source) 

623 ) 

624 else: 

625 compare = apt_pkg.version_compare(sourceobj.version, expected) 

626 print("Apt compare says: %s" % (compare)) 

627 if compare < 0: 

628 # This is still valid, the current version in database is older than 

629 # the new version we wait for 

630 print( 

631 "This transition is still ongoing, we currently have version %s" 

632 % (sourceobj.version) 

633 ) 

634 else: 

635 print( 

636 "This transition is over, the target package reached testing, should be removed" 

637 ) 

638 print( 

639 "%s wanted version: %s, has %s" 

640 % (source, expected, sourceobj.version) 

641 ) 

642 print( 

643 "-------------------------------------------------------------------------" 

644 ) 

645 

646 

647################################################################################ 

648 

649 

650def main(): 

651 """ 

652 Prepare the work to be done, do basic checks. 

653 

654 .. warning:: 

655 

656 This function may run **within sudo** 

657 """ 

658 global Cnf 

659 

660 ##################################### 

661 #### This can run within sudo !! #### 

662 ##################################### 

663 init() 

664 

665 # Check if there is a file defined (and existant) 

666 transpath = Cnf.get("Dinstall::ReleaseTransitions", "") 

667 if transpath == "": 

668 utils.warn("Dinstall::ReleaseTransitions not defined") 

669 sys.exit(1) 

670 if not os.path.exists(transpath): 

671 utils.warn( 

672 "ReleaseTransitions file, %s, not found." 

673 % (Cnf["Dinstall::ReleaseTransitions"]) 

674 ) 

675 sys.exit(1) 

676 # Also check if our temp directory is defined and existant 

677 temppath = Cnf.get("Dir::TempPath", "") 

678 if temppath == "": 

679 utils.warn("Dir::TempPath not defined") 

680 sys.exit(1) 

681 if not os.path.exists(temppath): 

682 utils.warn("Temporary path %s not found." % (Cnf["Dir::TempPath"])) 

683 sys.exit(1) 

684 

685 if Options["import"]: 

686 try: 

687 write_transitions_from_file(Options["import"]) 

688 except TransitionsError as m: 

689 print(m) 

690 sys.exit(2) 

691 sys.exit(0) 

692 ############################################## 

693 #### Up to here it can run within sudo !! #### 

694 ############################################## 

695 

696 # Parse the yaml file 

697 transitions = load_transitions(transpath) 

698 if transitions is None: 

699 # Something very broken with the transitions, exit 

700 utils.warn("Could not parse existing transitions file. Aborting.") 

701 sys.exit(2) 

702 

703 if Options["edit"]: 

704 # Let's edit the transitions file 

705 edit_transitions() 

706 elif Options["check"]: 

707 # Check and remove outdated transitions 

708 check_transitions(transitions) 

709 else: 

710 # Output information about the currently defined transitions. 

711 print("Currently defined transitions:") 

712 transition_info(transitions) 

713 

714 sys.exit(0) 

715 

716 

717################################################################################ 

718 

719 

720if __name__ == "__main__": 

721 main()