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
« 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
6# Packages
7import flask
8from canonicalwebteam.store_api.dashboard import Dashboard
10from requests.exceptions import HTTPError
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
20GITHUB_SNAPCRAFT_USER_TOKEN = os.getenv("GITHUB_SNAPCRAFT_USER_TOKEN")
21GITHUB_WEBHOOK_HOST_URL = os.getenv("GITHUB_WEBHOOK_HOST_URL")
24def extract_github_repository(git_repository_url):
25 """
26 Extract owner/repo from a GitHub repository URL.
28 Args:
29 git_repository_url (str): The full GitHub repository URL
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
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
46BUILDS_PER_PAGE = 15
47dashboard = Dashboard(api_publisher_session)
50def get_builds(lp_snap, selection):
51 builds = launchpad.get_snap_builds(lp_snap["store_name"])
53 total_builds = len(builds)
55 builds = builds[selection]
57 snap_builds = []
58 builders_status = None
60 # Extract GitHub repository info for commit links
61 github_repository = extract_github_repository(
62 lp_snap.get("git_repository_url")
63 )
65 for build in builds:
66 status = map_build_and_upload_states(
67 build["buildstate"], build["store_upload_status"]
68 )
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 }
83 if build["buildstate"] == "Needs building":
84 if not builders_status:
85 builders_status = launchpad.get_builders_status()
87 snap_build["queue_time"] = builders_status[build["arch_tag"]][
88 "estimated_duration"
89 ]
91 snap_builds.append(snap_build)
93 return {
94 "total_builds": total_builds,
95 "snap_builds": snap_builds,
96 }
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)
106@login_required
107def get_snap_builds(snap_name):
108 res = {"message": "", "success": True}
109 data = {"snap_builds": [], "total_builds": 0}
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)
116 # Get built snap in launchpad with this store name
117 lp_snap = launchpad.get_snap_by_store_name(details["snap_name"])
119 if lp_snap:
120 data.update(get_builds(lp_snap, build_slice))
122 res["data"] = data
124 return flask.jsonify(res)
127@login_required
128def get_snap_build(snap_name, build_id):
129 details = dashboard.get_snap_info(flask.session, snap_name)
131 context = {
132 "snap_id": details["snap_id"],
133 "snap_name": details["snap_name"],
134 "snap_title": details["title"],
135 "snap_build": {},
136 }
138 # Get build by snap name and build_id
139 lp_build = launchpad.get_snap_build(details["snap_name"], build_id)
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 )
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 }
165 if context["snap_build"]["logs"]:
166 context["raw_logs"] = launchpad.get_snap_build_log(
167 details["snap_name"], build_id
168 )
170 return flask.jsonify({"data": context, "success": True})
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)
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")
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 }
219 return result
222@login_required
223def post_snap_builds(snap_name):
224 details = dashboard.get_snap_info(flask.session, snap_name)
226 # Don't allow changes from Admins that are no contributors
227 account_snaps = dashboard.get_account_snaps(flask.session)
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 )
237 redirect_url = flask.url_for(".get_snap_builds_page", snap_name=snap_name)
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("/")
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)
251 repo_validation = validate_repo(
252 flask.session.get("github_auth_secret"), snap_name, owner, repo
253 )
255 if not repo_validation["success"]:
256 flask.flash(repo_validation["error"]["message"], "negative")
257 return flask.redirect(redirect_url)
259 lp_snap = launchpad.get_snap_by_store_name(details["snap_name"])
260 git_url = f"https://github.com/{owner}/{repo}"
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"]
275 if not lp_snap:
276 lp_snap_name = md5(git_url.encode("UTF-8")).hexdigest()
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
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)
294 pending_action = "link"
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"
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 }
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 )
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"]
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 )
353 flask.flash(
354 "The GitHub repository was linked successfully.", "positive"
355 )
357 owner = pending["owner"]
358 repo = pending["repo"]
359 github = GitHub(flask.session.get("github_auth_secret"))
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)
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 )
393 return flask.redirect(redirect_url)
395 return flask.redirect(redirect_url)
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)
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 )
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
431 error_message = None
432 if response["error_message"]:
433 error_message = response["error_message"].split(" HEAD:")[0]
435 return flask.jsonify(
436 {
437 "success": True,
438 "status": response["status"],
439 "error": {"message": error_message},
440 }
441 )
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"]
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
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)
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())
468 if not lp_snap:
469 return ("This repository is not linked with any Snap", 403)
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)
475 github = GitHub()
477 signature = flask.request.headers.get("X-Hub-Signature")
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)
485 validation = validate_repo(
486 GITHUB_SNAPCRAFT_USER_TOKEN, lp_snap["store_name"], gh_owner, gh_repo
487 )
489 if not validation["success"]:
490 return (validation["error"]["message"], 400)
492 if launchpad.is_snap_building(lp_snap["store_name"]):
493 launchpad.cancel_snap_builds(lp_snap["store_name"])
495 launchpad.build_snap(lp_snap["store_name"])
497 return ("", 204)
500@login_required
501def get_update_gh_webhooks(snap_name):
502 details = dashboard.get_snap_info(flask.session, snap_name)
504 lp_snap = launchpad.get_snap_by_store_name(details["snap_name"])
506 if not lp_snap:
507 flask.flash(
508 "This snap is not linked with a GitHub repository", "negative"
509 )
511 return flask.redirect(
512 flask.url_for(".get_settings", snap_name=snap_name)
513 )
515 github = GitHub(flask.session.get("github_auth_secret"))
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 )
524 gh_link = lp_snap["git_repository_url"][19:]
525 gh_owner, gh_repo = gh_link.split("/")
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)
534 if old_hook:
535 github.remove_hook(
536 gh_owner,
537 gh_repo,
538 old_hook["id"],
539 )
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 )
549 if snapcraft_hook:
550 github.remove_hook(
551 gh_owner,
552 gh_repo,
553 snapcraft_hook["id"],
554 )
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")
567 return flask.redirect(flask.url_for(".get_settings", snap_name=snap_name))