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

204 statements  

« prev     ^ index     » next       coverage.py v7.10.5, created at 2025-08-26 22:06 +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", snap_name=snap_name) 

235 ) 

236 

237 redirect_url = flask.url_for(".get_snap_builds", 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 if not lp_snap: 

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

264 

265 try: 

266 repo_exist = launchpad.get_snap(lp_snap_name) 

267 except HTTPError as e: 

268 if e.response.status_code == 404: 

269 repo_exist = False 

270 else: 

271 raise e 

272 

273 if repo_exist: 

274 flask.flash( 

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

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

277 "negative", 

278 ) 

279 return flask.redirect(redirect_url) 

280 

281 macaroon = dashboard.get_package_upload_macaroon( 

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

283 )["macaroon"] 

284 

285 launchpad.create_snap(snap_name, git_url, macaroon) 

286 

287 flask.flash( 

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

289 ) 

290 

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

292 github_hook_url = ( 

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

294 ) 

295 try: 

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

297 

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

299 if not hook: 

300 github.create_hook(owner, repo, github_hook_url) 

301 except HTTPError: 

302 flask.flash( 

303 "The GitHub Webhook could not be created. " 

304 "Please trigger a new build manually.", 

305 "caution", 

306 ) 

307 

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

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

310 raise AttributeError( 

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

312 ) 

313 

314 return flask.redirect(redirect_url) 

315 

316 

317@login_required 

318def check_build_request(snap_name, build_id): 

319 # Don't allow builds from no contributors 

320 account_snaps = dashboard.get_account_snaps(flask.session) 

321 

322 if snap_name not in account_snaps: 

323 return flask.jsonify( 

324 { 

325 "success": False, 

326 "error": { 

327 "type": "FORBIDDEN", 

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

329 "builds for this snap", 

330 }, 

331 } 

332 ) 

333 

334 try: 

335 response = launchpad.get_snap_build_request(snap_name, build_id) 

336 except HTTPError as e: 

337 # Timeout or not found from Launchpad 

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

339 return flask.jsonify( 

340 { 

341 "success": False, 

342 "error": { 

343 "message": "An error happened building " 

344 "this snap, please try again." 

345 }, 

346 } 

347 ) 

348 raise e 

349 

350 error_message = None 

351 if response["error_message"]: 

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

353 

354 return flask.jsonify( 

355 { 

356 "success": True, 

357 "status": response["status"], 

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

359 } 

360 ) 

361 

362 

363@csrf.exempt 

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

365 payload = flask.request.json 

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

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

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

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

370 

371 # The first payload after the webhook creation 

372 # doesn't contain a "ref" key 

373 if "ref" in payload: 

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

375 else: 

376 gh_event_branch = gh_default_branch 

377 

378 # Check the push event is in the default branch 

379 if gh_default_branch != gh_event_branch: 

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

381 

382 if snap_name: 

383 lp_snap = launchpad.get_snap_by_store_name(snap_name) 

384 else: 

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

386 

387 if not lp_snap: 

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

389 

390 # Check that this is the repo for this snap 

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

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

393 

394 github = GitHub() 

395 

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

397 

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

399 if not github.validate_bsi_webhook_secret( 

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

401 ): 

402 return ("Invalid secret", 403) 

403 

404 validation = validate_repo( 

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

406 ) 

407 

408 if not validation["success"]: 

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

410 

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

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

413 

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

415 

416 return ("", 204) 

417 

418 

419@login_required 

420def get_update_gh_webhooks(snap_name): 

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

422 

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

424 

425 if not lp_snap: 

426 flask.flash( 

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

428 ) 

429 

430 return flask.redirect( 

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

432 ) 

433 

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

435 

436 try: 

437 github.get_user() 

438 except Unauthorized: 

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

440 

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

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

443 

444 try: 

445 # Remove old BSI webhook if present 

446 old_url = ( 

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

448 ) 

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

450 

451 if old_hook: 

452 github.remove_hook( 

453 gh_owner, 

454 gh_repo, 

455 old_hook["id"], 

456 ) 

457 

458 # Remove current hook 

459 github_hook_url = ( 

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

461 ) 

462 snapcraft_hook = github.get_hook_by_url( 

463 gh_owner, gh_repo, github_hook_url 

464 ) 

465 

466 if snapcraft_hook: 

467 github.remove_hook( 

468 gh_owner, 

469 gh_repo, 

470 snapcraft_hook["id"], 

471 ) 

472 

473 # Create webhook in the repo 

474 github.create_hook(gh_owner, gh_repo, github_hook_url) 

475 except HTTPError: 

476 flask.flash( 

477 "The GitHub Webhook could not be created. " 

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

479 "caution", 

480 ) 

481 else: 

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

483 

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