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
« 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
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", snap_name=snap_name)
235 )
237 redirect_url = flask.url_for(".get_snap_builds", 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 if not lp_snap:
263 lp_snap_name = md5(git_url.encode("UTF-8")).hexdigest()
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
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)
281 macaroon = dashboard.get_package_upload_macaroon(
282 session=flask.session, snap_name=snap_name, channels=["edge"]
283 )["macaroon"]
285 launchpad.create_snap(snap_name, git_url, macaroon)
287 flask.flash(
288 "The GitHub repository was linked successfully.", "positive"
289 )
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)
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 )
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 )
314 return flask.redirect(redirect_url)
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)
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 )
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
350 error_message = None
351 if response["error_message"]:
352 error_message = response["error_message"].split(" HEAD:")[0]
354 return flask.jsonify(
355 {
356 "success": True,
357 "status": response["status"],
358 "error": {"message": error_message},
359 }
360 )
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"]
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
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)
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())
387 if not lp_snap:
388 return ("This repository is not linked with any Snap", 403)
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)
394 github = GitHub()
396 signature = flask.request.headers.get("X-Hub-Signature")
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)
404 validation = validate_repo(
405 GITHUB_SNAPCRAFT_USER_TOKEN, lp_snap["store_name"], gh_owner, gh_repo
406 )
408 if not validation["success"]:
409 return (validation["error"]["message"], 400)
411 if launchpad.is_snap_building(lp_snap["store_name"]):
412 launchpad.cancel_snap_builds(lp_snap["store_name"])
414 launchpad.build_snap(lp_snap["store_name"])
416 return ("", 204)
419@login_required
420def get_update_gh_webhooks(snap_name):
421 details = dashboard.get_snap_info(flask.session, snap_name)
423 lp_snap = launchpad.get_snap_by_store_name(details["snap_name"])
425 if not lp_snap:
426 flask.flash(
427 "This snap is not linked with a GitHub repository", "negative"
428 )
430 return flask.redirect(
431 flask.url_for(".get_settings", snap_name=snap_name)
432 )
434 github = GitHub(flask.session.get("github_auth_secret"))
436 try:
437 github.get_user()
438 except Unauthorized:
439 return flask.redirect(f"/github/auth?back={flask.request.path}")
441 gh_link = lp_snap["git_repository_url"][19:]
442 gh_owner, gh_repo = gh_link.split("/")
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)
451 if old_hook:
452 github.remove_hook(
453 gh_owner,
454 gh_repo,
455 old_hook["id"],
456 )
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 )
466 if snapcraft_hook:
467 github.remove_hook(
468 gh_owner,
469 gh_repo,
470 snapcraft_hook["id"],
471 )
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")
484 return flask.redirect(flask.url_for(".get_settings", snap_name=snap_name))