Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1# Standard library 

2import os 

3from hashlib import md5 

4 

5# Packages 

6import flask 

7from canonicalwebteam.store_api.stores.snapstore import SnapPublisher 

8 

9from requests.exceptions import HTTPError 

10 

11# Local 

12from webapp.helpers import api_publisher_session, launchpad 

13from webapp.api.github import GitHub, InvalidYAML 

14from webapp.decorators import login_required 

15from webapp.extensions import csrf 

16from webapp.publisher.snaps.builds import map_build_and_upload_states 

17from werkzeug.exceptions import Unauthorized, Forbidden 

18 

19GITHUB_SNAPCRAFT_USER_TOKEN = os.getenv("GITHUB_SNAPCRAFT_USER_TOKEN") 

20GITHUB_WEBHOOK_HOST_URL = os.getenv("GITHUB_WEBHOOK_HOST_URL") 

21BUILDS_PER_PAGE = 15 

22publisher_api = SnapPublisher(api_publisher_session) 

23 

24 

25def get_builds(lp_snap, selection): 

26 builds = launchpad.get_snap_builds(lp_snap["store_name"]) 

27 

28 total_builds = len(builds) 

29 

30 builds = builds[selection] 

31 

32 snap_builds = [] 

33 builders_status = None 

34 

35 for build in builds: 

36 status = map_build_and_upload_states( 

37 build["buildstate"], build["store_upload_status"] 

38 ) 

39 

40 snap_build = { 

41 "id": build["self_link"].split("/")[-1], 

42 "arch_tag": build["arch_tag"], 

43 "datebuilt": build["datebuilt"], 

44 "duration": build["duration"], 

45 "logs": build["build_log_url"], 

46 "revision_id": build["revision_id"], 

47 "status": status, 

48 "title": build["title"], 

49 "queue_time": None, 

50 } 

51 

52 if build["buildstate"] == "Needs building": 

53 if not builders_status: 

54 builders_status = launchpad.get_builders_status() 

55 

56 snap_build["queue_time"] = builders_status[build["arch_tag"]][ 

57 "estimated_duration" 

58 ] 

59 

60 snap_builds.append(snap_build) 

61 

62 return { 

63 "total_builds": total_builds, 

64 "snap_builds": snap_builds, 

65 } 

66 

67 

68@login_required 

69def get_snap_builds(snap_name): 

70 details = publisher_api.get_snap_info(snap_name, flask.session) 

71 

72 # API call to make users without needed permissions refresh the session 

73 # Users needs package_upload_request permission to use this feature 

74 publisher_api.get_package_upload_macaroon( 

75 session=flask.session, snap_name=snap_name, channels=["edge"] 

76 ) 

77 

78 context = { 

79 "publisher_name": details["publisher"]["display-name"], 

80 "snap_id": details["snap_id"], 

81 "snap_name": details["snap_name"], 

82 "snap_title": details["title"], 

83 "snap_builds_enabled": False, 

84 "snap_builds": [], 

85 "total_builds": 0, 

86 } 

87 

88 # Get built snap in launchpad with this store name 

89 lp_snap = launchpad.get_snap_by_store_name(details["snap_name"]) 

90 

91 if lp_snap: 

92 # In this case we can use the GitHub user account or 

93 # the Snapcraft GitHub user to check the snapcraft.yaml 

94 github = GitHub( 

95 flask.session.get( 

96 "github_auth_secret", GITHUB_SNAPCRAFT_USER_TOKEN 

97 ) 

98 ) 

99 

100 # Git repository without GitHub hostname 

101 context["github_repository"] = lp_snap["git_repository_url"][19:] 

102 github_owner, github_repo = context["github_repository"].split("/") 

103 gh_snap_base = None 

104 

105 try: 

106 context["github_repository_exists"] = github.check_if_repo_exists( 

107 github_owner, github_repo 

108 ) 

109 context["yaml_file_exists"] = github.get_snapcraft_yaml_location( 

110 github_owner, github_repo 

111 ) 

112 

113 if context["yaml_file_exists"]: 

114 try: 

115 yaml_data = github.get_snapcraft_yaml_data( 

116 github_owner, 

117 github_repo, 

118 location=context["yaml_file_exists"], 

119 ) 

120 gh_snap_base = yaml_data.get( 

121 "build-base", yaml_data.get("base", None) 

122 ) 

123 except InvalidYAML: 

124 # If we can't parse the yaml we don't 

125 # want to cause an error 

126 pass 

127 

128 except Unauthorized: 

129 context["github_app_revoked"] = True 

130 

131 builds = get_builds(lp_snap, slice(0, BUILDS_PER_PAGE)) 

132 context.update(builds) 

133 

134 # Notify about i386 arch 

135 if gh_snap_base and ( 

136 not gh_snap_base.startswith("core") 

137 or ( 

138 gh_snap_base.startswith("core") 

139 and gh_snap_base.replace("core", "") 

140 and int(gh_snap_base.replace("core", "")) >= 20 

141 ) 

142 ): 

143 # Check if this publisher was building for i386 recently 

144 for build in builds["snap_builds"]: 

145 if build["arch_tag"] == "i386": 

146 context["dropped_i386"] = True 

147 break 

148 

149 context["snap_builds_enabled"] = bool(context["snap_builds"]) 

150 else: 

151 github = GitHub(flask.session.get("github_auth_secret")) 

152 

153 try: 

154 context["github_user"] = github.get_user() 

155 except (Unauthorized, Forbidden): 

156 context["github_user"] = None 

157 

158 if context["github_user"]: 

159 context["github_orgs"] = github.get_orgs() 

160 

161 return flask.render_template("publisher/builds.html", **context) 

162 

163 

164@login_required 

165def get_snap_build(snap_name, build_id): 

166 details = publisher_api.get_snap_info(snap_name, flask.session) 

167 

168 context = { 

169 "snap_id": details["snap_id"], 

170 "snap_name": details["snap_name"], 

171 "snap_title": details["title"], 

172 "snap_build": {}, 

173 } 

174 

175 # Get build by snap name and build_id 

176 lp_build = launchpad.get_snap_build(details["snap_name"], build_id) 

177 

178 if lp_build: 

179 status = map_build_and_upload_states( 

180 lp_build["buildstate"], lp_build["store_upload_status"] 

181 ) 

182 context["snap_build"] = { 

183 "id": lp_build["self_link"].split("/")[-1], 

184 "arch_tag": lp_build["arch_tag"], 

185 "datebuilt": lp_build["datebuilt"], 

186 "duration": lp_build["duration"], 

187 "logs": lp_build["build_log_url"], 

188 "revision_id": lp_build["revision_id"], 

189 "status": status, 

190 "title": lp_build["title"], 

191 } 

192 

193 if context["snap_build"]["logs"]: 

194 context["raw_logs"] = launchpad.get_snap_build_log( 

195 details["snap_name"], build_id 

196 ) 

197 

198 return flask.render_template("publisher/build.html", **context) 

199 

200 

201def validate_repo(github_token, snap_name, gh_owner, gh_repo): 

202 github = GitHub(github_token) 

203 result = {"success": True} 

204 yaml_location = github.get_snapcraft_yaml_location(gh_owner, gh_repo) 

205 

206 # The snapcraft.yaml is not present 

207 if not yaml_location: 

208 result["success"] = False 

209 result["error"] = { 

210 "type": "MISSING_YAML_FILE", 

211 "message": ( 

212 "Missing snapcraft.yaml: this repo needs a snapcraft.yaml " 

213 "file, so that Snapcraft can make it buildable, installable " 

214 "and runnable." 

215 ), 

216 } 

217 # The property name inside the yaml file doesn't match the snap 

218 else: 

219 try: 

220 gh_snap_name = github.get_snapcraft_yaml_data( 

221 gh_owner, gh_repo 

222 ).get("name") 

223 

224 if gh_snap_name != snap_name: 

225 result["success"] = False 

226 result["error"] = { 

227 "type": "SNAP_NAME_DOES_NOT_MATCH", 

228 "message": ( 

229 "Name mismatch: the snapcraft.yaml uses the snap " 

230 f'name "{gh_snap_name}", but you\'ve registered' 

231 f' the name "{snap_name}". Update your ' 

232 "snapcraft.yaml to continue." 

233 ), 

234 "yaml_location": yaml_location, 

235 "gh_snap_name": gh_snap_name, 

236 } 

237 except InvalidYAML: 

238 result["success"] = False 

239 result["error"] = { 

240 "type": "INVALID_YAML_FILE", 

241 "message": ( 

242 "Invalid snapcraft.yaml: there was an issue parsing the " 

243 f"snapcraft.yaml for {snap_name}." 

244 ), 

245 } 

246 

247 return result 

248 

249 

250@login_required 

251def get_snap_builds_json(snap_name): 

252 details = publisher_api.get_snap_info(snap_name, flask.session) 

253 

254 context = {"snap_builds": []} 

255 

256 start = flask.request.args.get("start", 0, type=int) 

257 size = flask.request.args.get("size", 15, type=int) 

258 build_slice = slice(start, size) 

259 

260 # Get built snap in launchpad with this store name 

261 lp_snap = launchpad.get_snap_by_store_name(details["snap_name"]) 

262 

263 if lp_snap: 

264 context.update(get_builds(lp_snap, build_slice)) 

265 

266 return flask.jsonify(context) 

267 

268 

269@login_required 

270def get_validate_repo(snap_name): 

271 details = publisher_api.get_snap_info(snap_name, flask.session) 

272 

273 owner, repo = flask.request.args.get("repo").split("/") 

274 

275 return flask.jsonify( 

276 validate_repo( 

277 flask.session.get("github_auth_secret"), 

278 details["snap_name"], 

279 owner, 

280 repo, 

281 ) 

282 ) 

283 

284 

285@login_required 

286def post_snap_builds(snap_name): 

287 details = publisher_api.get_snap_info(snap_name, flask.session) 

288 

289 # Don't allow changes from Admins that are no contributors 

290 account_snaps = publisher_api.get_account_snaps(flask.session) 

291 

292 if snap_name not in account_snaps: 

293 flask.flash( 

294 "You do not have permissions to modify this Snap", "negative" 

295 ) 

296 return flask.redirect( 

297 flask.url_for(".get_snap_builds", snap_name=snap_name) 

298 ) 

299 

300 redirect_url = flask.url_for(".get_snap_builds", snap_name=snap_name) 

301 

302 # Get built snap in launchpad with this store name 

303 github = GitHub(flask.session.get("github_auth_secret")) 

304 owner, repo = flask.request.form.get("github_repository").split("/") 

305 

306 if not github.check_permissions_over_repo(owner, repo): 

307 flask.flash( 

308 "The repository doesn't exist or you don't have" 

309 " enough permissions", 

310 "negative", 

311 ) 

312 return flask.redirect(redirect_url) 

313 

314 repo_validation = validate_repo( 

315 flask.session.get("github_auth_secret"), snap_name, owner, repo 

316 ) 

317 

318 if not repo_validation["success"]: 

319 flask.flash(repo_validation["error"]["message"], "negative") 

320 return flask.redirect(redirect_url) 

321 

322 lp_snap = launchpad.get_snap_by_store_name(details["snap_name"]) 

323 git_url = f"https://github.com/{owner}/{repo}" 

324 

325 if not lp_snap: 

326 lp_snap_name = md5(git_url.encode("UTF-8")).hexdigest() 

327 

328 try: 

329 repo_exist = launchpad.get_snap(lp_snap_name) 

330 except HTTPError as e: 

331 if e.response.status_code == 404: 

332 repo_exist = False 

333 else: 

334 raise e 

335 

336 if repo_exist: 

337 flask.flash( 

338 "The specified repository is being used by another snap:" 

339 f" {repo_exist['store_name']}", 

340 "negative", 

341 ) 

342 return flask.redirect(redirect_url) 

343 

344 macaroon = publisher_api.get_package_upload_macaroon( 

345 session=flask.session, snap_name=snap_name, channels=["edge"] 

346 )["macaroon"] 

347 

348 launchpad.create_snap(snap_name, git_url, macaroon) 

349 

350 flask.flash( 

351 "The GitHub repository was linked successfully.", "positive" 

352 ) 

353 

354 # Create webhook in the repo, it should also trigger the first build 

355 github_hook_url = ( 

356 f"{GITHUB_WEBHOOK_HOST_URL}{snap_name}/webhook/notify" 

357 ) 

358 try: 

359 hook = github.get_hook_by_url(owner, repo, github_hook_url) 

360 

361 # We create the webhook if doesn't exist already in this repo 

362 if not hook: 

363 github.create_hook(owner, repo, github_hook_url) 

364 except HTTPError: 

365 flask.flash( 

366 "The GitHub Webhook could not be created. " 

367 "Please trigger a new build manually.", 

368 "caution", 

369 ) 

370 

371 elif lp_snap["git_repository_url"] != git_url: 

372 # In the future, create a new record, delete the old one 

373 raise AttributeError( 

374 f"Snap {snap_name} already has a build repository associated" 

375 ) 

376 

377 return flask.redirect(redirect_url) 

378 

379 

380@login_required 

381def post_build(snap_name): 

382 # Don't allow builds from no contributors 

383 account_snaps = publisher_api.get_account_snaps(flask.session) 

384 

385 if snap_name not in account_snaps: 

386 return flask.jsonify( 

387 { 

388 "success": False, 

389 "error": { 

390 "type": "FORBIDDEN", 

391 "message": "You are not allowed to request " 

392 "builds for this snap", 

393 }, 

394 } 

395 ) 

396 

397 try: 

398 if launchpad.is_snap_building(snap_name): 

399 launchpad.cancel_snap_builds(snap_name) 

400 

401 build_id = launchpad.build_snap(snap_name) 

402 except HTTPError as e: 

403 # Timeout or not found from Launchpad 

404 if e.response.status_code in [408, 404]: 

405 return flask.jsonify( 

406 { 

407 "success": False, 

408 "error": { 

409 "message": "An error happened building " 

410 "this snap, please try again." 

411 }, 

412 } 

413 ) 

414 raise e 

415 

416 return flask.jsonify({"success": True, "build_id": build_id}) 

417 

418 

419@login_required 

420def check_build_request(snap_name, build_id): 

421 # Don't allow builds from no contributors 

422 account_snaps = publisher_api.get_account_snaps(flask.session) 

423 

424 if snap_name not in account_snaps: 

425 return flask.jsonify( 

426 { 

427 "success": False, 

428 "error": { 

429 "type": "FORBIDDEN", 

430 "message": "You are not allowed to request " 

431 "builds for this snap", 

432 }, 

433 } 

434 ) 

435 

436 try: 

437 response = launchpad.get_snap_build_request(snap_name, build_id) 

438 except HTTPError as e: 

439 # Timeout or not found from Launchpad 

440 if e.response.status_code in [408, 404]: 

441 return flask.jsonify( 

442 { 

443 "success": False, 

444 "error": { 

445 "message": "An error happened building " 

446 "this snap, please try again." 

447 }, 

448 } 

449 ) 

450 raise e 

451 

452 error_message = None 

453 if response["error_message"]: 

454 error_message = response["error_message"].split(" HEAD:")[0] 

455 

456 return flask.jsonify( 

457 { 

458 "success": True, 

459 "status": response["status"], 

460 "error": {"message": error_message}, 

461 } 

462 ) 

463 

464 

465@login_required 

466def post_disconnect_repo(snap_name): 

467 details = publisher_api.get_snap_info(snap_name, flask.session) 

468 

469 lp_snap = launchpad.get_snap_by_store_name(snap_name) 

470 launchpad.delete_snap(details["snap_name"]) 

471 

472 # Try to remove the GitHub webhook if possible 

473 if flask.session.get("github_auth_secret"): 

474 github = GitHub(flask.session.get("github_auth_secret")) 

475 

476 try: 

477 gh_owner, gh_repo = lp_snap["git_repository_url"][19:].split("/") 

478 

479 old_hook = github.get_hook_by_url( 

480 gh_owner, 

481 gh_repo, 

482 f"{GITHUB_WEBHOOK_HOST_URL}{snap_name}/webhook/notify", 

483 ) 

484 

485 if old_hook: 

486 github.remove_hook( 

487 gh_owner, 

488 gh_repo, 

489 old_hook["id"], 

490 ) 

491 except HTTPError: 

492 pass 

493 

494 return flask.redirect( 

495 flask.url_for(".get_snap_builds", snap_name=snap_name) 

496 ) 

497 

498 

499@csrf.exempt 

500def post_github_webhook(snap_name=None, github_owner=None, github_repo=None): 

501 payload = flask.request.json 

502 repo_url = payload["repository"]["html_url"] 

503 gh_owner = payload["repository"]["owner"]["login"] 

504 gh_repo = payload["repository"]["name"] 

505 gh_default_branch = payload["repository"]["default_branch"] 

506 

507 # The first payload after the webhook creation 

508 # doesn't contain a "ref" key 

509 if "ref" in payload: 

510 gh_event_branch = payload["ref"][11:] 

511 else: 

512 gh_event_branch = gh_default_branch 

513 

514 # Check the push event is in the default branch 

515 if gh_default_branch != gh_event_branch: 

516 return ("The push event is not for the default branch", 200) 

517 

518 if snap_name: 

519 lp_snap = launchpad.get_snap_by_store_name(snap_name) 

520 else: 

521 lp_snap = launchpad.get_snap(md5(repo_url.encode("UTF-8")).hexdigest()) 

522 

523 if not lp_snap: 

524 return ("This repository is not linked with any Snap", 403) 

525 

526 # Check that this is the repo for this snap 

527 if lp_snap["git_repository_url"] != repo_url: 

528 return ("The repository does not match the one used by this Snap", 403) 

529 

530 github = GitHub() 

531 

532 signature = flask.request.headers.get("X-Hub-Signature") 

533 

534 if not github.validate_webhook_signature(flask.request.data, signature): 

535 if not github.validate_bsi_webhook_secret( 

536 gh_owner, gh_repo, flask.request.data, signature 

537 ): 

538 return ("Invalid secret", 403) 

539 

540 validation = validate_repo( 

541 GITHUB_SNAPCRAFT_USER_TOKEN, lp_snap["store_name"], gh_owner, gh_repo 

542 ) 

543 

544 if not validation["success"]: 

545 return (validation["error"]["message"], 400) 

546 

547 if launchpad.is_snap_building(lp_snap["store_name"]): 

548 launchpad.cancel_snap_builds(lp_snap["store_name"]) 

549 

550 launchpad.build_snap(lp_snap["store_name"]) 

551 

552 return ("", 204) 

553 

554 

555@login_required 

556def get_update_gh_webhooks(snap_name): 

557 details = publisher_api.get_snap_info(snap_name, flask.session) 

558 

559 lp_snap = launchpad.get_snap_by_store_name(details["snap_name"]) 

560 

561 if not lp_snap: 

562 flask.flash( 

563 "This snap is not linked with a GitHub repository", "negative" 

564 ) 

565 

566 return flask.redirect( 

567 flask.url_for(".get_settings", snap_name=snap_name) 

568 ) 

569 

570 github = GitHub(flask.session.get("github_auth_secret")) 

571 

572 try: 

573 github.get_user() 

574 except Unauthorized: 

575 return flask.redirect(f"/github/auth?back={flask.request.path}") 

576 

577 gh_link = lp_snap["git_repository_url"][19:] 

578 gh_owner, gh_repo = gh_link.split("/") 

579 

580 try: 

581 # Remove old BSI webhook if present 

582 old_url = ( 

583 f"https://build.snapcraft.io/{gh_owner}/{gh_repo}/webhook/notify" 

584 ) 

585 old_hook = github.get_hook_by_url(gh_owner, gh_repo, old_url) 

586 

587 if old_hook: 

588 github.remove_hook( 

589 gh_owner, 

590 gh_repo, 

591 old_hook["id"], 

592 ) 

593 

594 # Remove current hook 

595 github_hook_url = ( 

596 f"{GITHUB_WEBHOOK_HOST_URL}{snap_name}/webhook/notify" 

597 ) 

598 snapcraft_hook = github.get_hook_by_url( 

599 gh_owner, gh_repo, github_hook_url 

600 ) 

601 

602 if snapcraft_hook: 

603 github.remove_hook( 

604 gh_owner, 

605 gh_repo, 

606 snapcraft_hook["id"], 

607 ) 

608 

609 # Create webhook in the repo 

610 github.create_hook(gh_owner, gh_repo, github_hook_url) 

611 except HTTPError: 

612 flask.flash( 

613 "The GitHub Webhook could not be created. " 

614 "Please try again or check your permissions over the repository.", 

615 "caution", 

616 ) 

617 else: 

618 flask.flash("The webhook has been created successfully", "positive") 

619 

620 return flask.redirect(flask.url_for(".get_settings", snap_name=snap_name))