Coverage for webapp/publisher/snaps/build_views.py: 34%

218 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-31 22:23 +0000

1# Standard library 

2import os 

3import re 

4from hashlib import md5 

5 

6# Packages 

7import flask 

8from canonicalwebteam.store_api.dashboard import Dashboard 

9 

10from requests.exceptions import HTTPError 

11 

12# Local 

13from webapp.helpers import api_publisher_session, launchpad 

14from webapp.api.github import GitHub, InvalidYAML 

15from webapp.decorators import login_required 

16from webapp.extensions import csrf 

17from webapp.publisher.snaps.builds import map_build_and_upload_states 

18from werkzeug.exceptions import Unauthorized 

19 

20GITHUB_SNAPCRAFT_USER_TOKEN = os.getenv("GITHUB_SNAPCRAFT_USER_TOKEN") 

21GITHUB_WEBHOOK_HOST_URL = os.getenv("GITHUB_WEBHOOK_HOST_URL") 

22 

23 

24def extract_github_repository(git_repository_url): 

25 """ 

26 Extract owner/repo from a GitHub repository URL. 

27 

28 Args: 

29 git_repository_url (str): The full GitHub repository URL 

30 

31 Returns: 

32 str or None: The owner/repo part of the URL, or None if not a 

33 valid GitHub URL 

34 """ 

35 if not git_repository_url: 

36 return None 

37 

38 match = re.search( 

39 r"github\.com/(?P<repo>.+/.+?)(?:\.git)?/?$", git_repository_url 

40 ) 

41 if match: 

42 return match.groupdict()["repo"] 

43 return None 

44 

45 

46BUILDS_PER_PAGE = 15 

47dashboard = Dashboard(api_publisher_session) 

48 

49 

50def get_builds(lp_snap, selection): 

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

52 

53 total_builds = len(builds) 

54 

55 builds = builds[selection] 

56 

57 snap_builds = [] 

58 builders_status = None 

59 

60 # Extract GitHub repository info for commit links 

61 github_repository = extract_github_repository( 

62 lp_snap.get("git_repository_url") 

63 ) 

64 

65 for build in builds: 

66 status = map_build_and_upload_states( 

67 build["buildstate"], build["store_upload_status"] 

68 ) 

69 

70 snap_build = { 

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

72 "arch_tag": build["arch_tag"], 

73 "datebuilt": build["datebuilt"], 

74 "duration": build["duration"], 

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

76 "revision_id": build["revision_id"], 

77 "status": status, 

78 "title": build["title"], 

79 "queue_time": None, 

80 "github_repository": github_repository, 

81 } 

82 

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

84 if not builders_status: 

85 builders_status = launchpad.get_builders_status() 

86 

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

88 "estimated_duration" 

89 ] 

90 

91 snap_builds.append(snap_build) 

92 

93 return { 

94 "total_builds": total_builds, 

95 "snap_builds": snap_builds, 

96 } 

97 

98 

99@login_required 

100def get_snap_builds_page(snap_name): 

101 # If this fails, the page will 404 

102 dashboard.get_snap_info(flask.session, snap_name) 

103 return flask.render_template("store/publisher.html", snap_name=snap_name) 

104 

105 

106@login_required 

107def get_snap_builds(snap_name): 

108 res = {"message": "", "success": True} 

109 data = {"snap_builds": [], "total_builds": 0} 

110 

111 details = dashboard.get_snap_info(flask.session, snap_name) 

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

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

114 build_slice = slice(start, size) 

115 

116 # Get built snap in launchpad with this store name 

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

118 

119 if lp_snap: 

120 data.update(get_builds(lp_snap, build_slice)) 

121 

122 res["data"] = data 

123 

124 return flask.jsonify(res) 

125 

126 

127@login_required 

128def get_snap_build(snap_name, build_id): 

129 details = dashboard.get_snap_info(flask.session, snap_name) 

130 

131 context = { 

132 "snap_id": details["snap_id"], 

133 "snap_name": details["snap_name"], 

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

135 "snap_build": {}, 

136 } 

137 

138 # Get build by snap name and build_id 

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

140 

141 if lp_build: 

142 # Get snap info to extract GitHub repository 

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

144 github_repository = None 

145 if lp_snap: 

146 github_repository = extract_github_repository( 

147 lp_snap.get("git_repository_url") 

148 ) 

149 

150 status = map_build_and_upload_states( 

151 lp_build["buildstate"], lp_build["store_upload_status"] 

152 ) 

153 context["snap_build"] = { 

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

155 "arch_tag": lp_build["arch_tag"], 

156 "datebuilt": lp_build["datebuilt"], 

157 "duration": lp_build["duration"], 

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

159 "revision_id": lp_build["revision_id"], 

160 "status": status, 

161 "title": lp_build["title"], 

162 "github_repository": github_repository, 

163 } 

164 

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

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

167 details["snap_name"], build_id 

168 ) 

169 

170 return flask.jsonify({"data": context, "success": True}) 

171 

172 

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

174 github = GitHub(github_token) 

175 result = {"success": True} 

176 yaml_location = github.get_snapcraft_yaml_location(gh_owner, gh_repo) 

177 

178 # The snapcraft.yaml is not present 

179 if not yaml_location: 

180 result["success"] = False 

181 result["error"] = { 

182 "type": "MISSING_YAML_FILE", 

183 "message": ( 

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

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

186 "and runnable." 

187 ), 

188 } 

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

190 else: 

191 try: 

192 gh_snap_name = github.get_snapcraft_yaml_data( 

193 gh_owner, gh_repo 

194 ).get("name") 

195 

196 if gh_snap_name != snap_name: 

197 result["success"] = False 

198 result["error"] = { 

199 "type": "SNAP_NAME_DOES_NOT_MATCH", 

200 "message": ( 

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

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

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

204 "snapcraft.yaml to continue." 

205 ), 

206 "yaml_location": yaml_location, 

207 "gh_snap_name": gh_snap_name, 

208 } 

209 except InvalidYAML: 

210 result["success"] = False 

211 result["error"] = { 

212 "type": "INVALID_YAML_FILE", 

213 "message": ( 

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

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

216 ), 

217 } 

218 

219 return result 

220 

221 

222@login_required 

223def post_snap_builds(snap_name): 

224 details = dashboard.get_snap_info(flask.session, snap_name) 

225 

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

227 account_snaps = dashboard.get_account_snaps(flask.session) 

228 

229 if snap_name not in account_snaps: 

230 flask.flash( 

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

232 ) 

233 return flask.redirect( 

234 flask.url_for(".get_snap_builds_page", snap_name=snap_name) 

235 ) 

236 

237 redirect_url = flask.url_for(".get_snap_builds_page", snap_name=snap_name) 

238 

239 # Get built snap in launchpad with this store name 

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

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

242 

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

244 flask.flash( 

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

246 " enough permissions", 

247 "negative", 

248 ) 

249 return flask.redirect(redirect_url) 

250 

251 repo_validation = validate_repo( 

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

253 ) 

254 

255 if not repo_validation["success"]: 

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

257 return flask.redirect(redirect_url) 

258 

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

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

261 

262 # Root macaroon obtained from the store, to authorize builds to be 

263 # uploaded to the store from Launchpad. This macaroon carries an SSO 

264 # third-party caveat that only login.ubuntu.com can discharge, and that 

265 # discharge is specific to *this* macaroon's caveat_id (it cannot be 

266 # satisfied by reusing a discharge obtained for a different macaroon, 

267 # e.g. the one from the user's original login). Without a matching 

268 # discharge, Launchpad will accept the authorization call but can never 

269 # actually use it to upload builds, and builds will be stuck as 

270 # "Unscheduled" ("Won't release") forever with no visible error. 

271 upload_macaroon = dashboard.get_package_upload_macaroon( 

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

273 )["macaroon"] 

274 

275 if not lp_snap: 

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

277 

278 try: 

279 repo_exist = launchpad.get_snap(lp_snap_name) 

280 except HTTPError as e: 

281 if e.response.status_code == 404: 

282 repo_exist = False 

283 else: 

284 raise e 

285 

286 if repo_exist: 

287 flask.flash( 

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

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

290 "negative", 

291 ) 

292 return flask.redirect(redirect_url) 

293 

294 pending_action = "link" 

295 

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

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

298 raise AttributeError( 

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

300 ) 

301 else: 

302 pending_action = "repair" 

303 

304 # We can't complete the store authorization yet: we still need a 

305 # discharge macaroon that specifically discharges `upload_macaroon`'s 

306 # SSO caveat, which requires a redirect round-trip through 

307 # login.ubuntu.com (see webapp/login/views.py:authorize_snap_build). 

308 # Stash everything needed to finish the job once we're back, and kick 

309 # off that round-trip. 

310 flask.session["pending_snap_authorization"] = { 

311 "action": pending_action, 

312 "snap_name": snap_name, 

313 "git_url": git_url, 

314 "owner": owner, 

315 "repo": repo, 

316 "lp_snap_name": lp_snap["name"] if lp_snap else None, 

317 "root_macaroon": upload_macaroon, 

318 "redirect_url": redirect_url, 

319 } 

320 

321 # This endpoint is called via `fetch()` from the React frontend, so we 

322 # can't just return an HTTP redirect here: the browser needs to 

323 # actually navigate away to complete the login.ubuntu.com round-trip, 

324 # which a fetch() call can't do on the page's behalf. Signal the 

325 # frontend to do that navigation itself. 

326 return flask.jsonify( 

327 { 

328 "success": False, 

329 "authorization_required": True, 

330 "redirect_url": flask.url_for("login.authorize_snap_build"), 

331 } 

332 ) 

333 

334 

335def complete_pending_snap_authorization(pending, discharge_macaroon): 

336 """ 

337 Finish linking/repairing a snap's Launchpad build authorization, 

338 once a discharge macaroon for the pending upload macaroon's SSO 

339 caveat has been obtained (see webapp/login/views.py). 

340 """ 

341 snap_name = pending["snap_name"] 

342 git_url = pending["git_url"] 

343 redirect_url = pending["redirect_url"] 

344 

345 if pending["action"] == "link": 

346 launchpad.create_snap( 

347 snap_name, 

348 git_url, 

349 pending["root_macaroon"], 

350 discharge_macaroon=discharge_macaroon, 

351 ) 

352 

353 flask.flash( 

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

355 ) 

356 

357 owner = pending["owner"] 

358 repo = pending["repo"] 

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

360 

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

362 github_hook_url = ( 

363 f"{GITHUB_WEBHOOK_HOST_URL}api/{snap_name}/webhook/notify" 

364 ) 

365 try: 

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

367 

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

369 if not hook: 

370 github.create_hook(owner, repo, github_hook_url) 

371 except HTTPError: 

372 flask.flash( 

373 "The GitHub Webhook could not be created. " 

374 "Please trigger a new build manually.", 

375 "caution", 

376 ) 

377 else: 

378 # The repo is already linked to this snap: re-run the store 

379 # authorization handshake. This lets users self-repair snaps 

380 # whose authorization silently failed (e.g. it was completed 

381 # without a matching discharge macaroon), without having to 

382 # unlink and relink the repository. 

383 launchpad.complete_snap_authorization( 

384 pending["lp_snap_name"], 

385 pending["root_macaroon"], 

386 discharge_macaroon=discharge_macaroon, 

387 ) 

388 flask.flash( 

389 "The GitHub repository authorization was refreshed.", 

390 "positive", 

391 ) 

392 

393 return flask.redirect(redirect_url) 

394 

395 return flask.redirect(redirect_url) 

396 

397 

398@login_required 

399def check_build_request(snap_name, build_id): 

400 # Don't allow builds from no contributors 

401 account_snaps = dashboard.get_account_snaps(flask.session) 

402 

403 if snap_name not in account_snaps: 

404 return flask.jsonify( 

405 { 

406 "success": False, 

407 "error": { 

408 "type": "FORBIDDEN", 

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

410 "builds for this snap", 

411 }, 

412 } 

413 ) 

414 

415 try: 

416 response = launchpad.get_snap_build_request(snap_name, build_id) 

417 except HTTPError as e: 

418 # Timeout or not found from Launchpad 

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

420 return flask.jsonify( 

421 { 

422 "success": False, 

423 "error": { 

424 "message": "An error happened building " 

425 "this snap, please try again." 

426 }, 

427 } 

428 ) 

429 raise e 

430 

431 error_message = None 

432 if response["error_message"]: 

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

434 

435 return flask.jsonify( 

436 { 

437 "success": True, 

438 "status": response["status"], 

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

440 } 

441 ) 

442 

443 

444@csrf.exempt 

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

446 payload = flask.request.json 

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

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

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

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

451 

452 # The first payload after the webhook creation 

453 # doesn't contain a "ref" key 

454 if "ref" in payload: 

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

456 else: 

457 gh_event_branch = gh_default_branch 

458 

459 # Check the push event is in the default branch 

460 if gh_default_branch != gh_event_branch: 

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

462 

463 if snap_name: 

464 lp_snap = launchpad.get_snap_by_store_name(snap_name) 

465 else: 

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

467 

468 if not lp_snap: 

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

470 

471 # Check that this is the repo for this snap 

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

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

474 

475 github = GitHub() 

476 

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

478 

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

480 if not github.validate_bsi_webhook_secret( 

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

482 ): 

483 return ("Invalid secret", 403) 

484 

485 validation = validate_repo( 

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

487 ) 

488 

489 if not validation["success"]: 

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

491 

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

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

494 

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

496 

497 return ("", 204) 

498 

499 

500@login_required 

501def get_update_gh_webhooks(snap_name): 

502 details = dashboard.get_snap_info(flask.session, snap_name) 

503 

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

505 

506 if not lp_snap: 

507 flask.flash( 

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

509 ) 

510 

511 return flask.redirect( 

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

513 ) 

514 

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

516 

517 try: 

518 github.get_user() 

519 except Unauthorized: 

520 return flask.redirect( 

521 flask.url_for("oauth.github_auth", back=flask.request.path) 

522 ) 

523 

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

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

526 

527 try: 

528 # Remove old BSI webhook if present 

529 old_url = ( 

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

531 ) 

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

533 

534 if old_hook: 

535 github.remove_hook( 

536 gh_owner, 

537 gh_repo, 

538 old_hook["id"], 

539 ) 

540 

541 # Remove current hook 

542 github_hook_url = ( 

543 f"{GITHUB_WEBHOOK_HOST_URL}api/{snap_name}/webhook/notify" 

544 ) 

545 snapcraft_hook = github.get_hook_by_url( 

546 gh_owner, gh_repo, github_hook_url 

547 ) 

548 

549 if snapcraft_hook: 

550 github.remove_hook( 

551 gh_owner, 

552 gh_repo, 

553 snapcraft_hook["id"], 

554 ) 

555 

556 # Create webhook in the repo 

557 github.create_hook(gh_owner, gh_repo, github_hook_url) 

558 except HTTPError: 

559 flask.flash( 

560 "The GitHub Webhook could not be created. " 

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

562 "caution", 

563 ) 

564 else: 

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

566 

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