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

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
5# Packages
6import flask
7from canonicalwebteam.store_api.stores.snapstore import SnapPublisher
9from requests.exceptions import HTTPError
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
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)
25def get_builds(lp_snap, selection):
26 builds = launchpad.get_snap_builds(lp_snap["store_name"])
28 total_builds = len(builds)
30 builds = builds[selection]
32 snap_builds = []
33 builders_status = None
35 for build in builds:
36 status = map_build_and_upload_states(
37 build["buildstate"], build["store_upload_status"]
38 )
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 }
52 if build["buildstate"] == "Needs building":
53 if not builders_status:
54 builders_status = launchpad.get_builders_status()
56 snap_build["queue_time"] = builders_status[build["arch_tag"]][
57 "estimated_duration"
58 ]
60 snap_builds.append(snap_build)
62 return {
63 "total_builds": total_builds,
64 "snap_builds": snap_builds,
65 }
68@login_required
69def get_snap_builds(snap_name):
70 details = publisher_api.get_snap_info(snap_name, flask.session)
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 )
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 }
88 # Get built snap in launchpad with this store name
89 lp_snap = launchpad.get_snap_by_store_name(details["snap_name"])
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 )
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
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 )
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
128 except Unauthorized:
129 context["github_app_revoked"] = True
131 builds = get_builds(lp_snap, slice(0, BUILDS_PER_PAGE))
132 context.update(builds)
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
149 context["snap_builds_enabled"] = bool(context["snap_builds"])
150 else:
151 github = GitHub(flask.session.get("github_auth_secret"))
153 try:
154 context["github_user"] = github.get_user()
155 except (Unauthorized, Forbidden):
156 context["github_user"] = None
158 if context["github_user"]:
159 context["github_orgs"] = github.get_orgs()
161 return flask.render_template("publisher/builds.html", **context)
164@login_required
165def get_snap_build(snap_name, build_id):
166 details = publisher_api.get_snap_info(snap_name, flask.session)
168 context = {
169 "snap_id": details["snap_id"],
170 "snap_name": details["snap_name"],
171 "snap_title": details["title"],
172 "snap_build": {},
173 }
175 # Get build by snap name and build_id
176 lp_build = launchpad.get_snap_build(details["snap_name"], build_id)
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 }
193 if context["snap_build"]["logs"]:
194 context["raw_logs"] = launchpad.get_snap_build_log(
195 details["snap_name"], build_id
196 )
198 return flask.render_template("publisher/build.html", **context)
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)
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")
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 }
247 return result
250@login_required
251def get_snap_builds_json(snap_name):
252 details = publisher_api.get_snap_info(snap_name, flask.session)
254 context = {"snap_builds": []}
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)
260 # Get built snap in launchpad with this store name
261 lp_snap = launchpad.get_snap_by_store_name(details["snap_name"])
263 if lp_snap:
264 context.update(get_builds(lp_snap, build_slice))
266 return flask.jsonify(context)
269@login_required
270def get_validate_repo(snap_name):
271 details = publisher_api.get_snap_info(snap_name, flask.session)
273 owner, repo = flask.request.args.get("repo").split("/")
275 return flask.jsonify(
276 validate_repo(
277 flask.session.get("github_auth_secret"),
278 details["snap_name"],
279 owner,
280 repo,
281 )
282 )
285@login_required
286def post_snap_builds(snap_name):
287 details = publisher_api.get_snap_info(snap_name, flask.session)
289 # Don't allow changes from Admins that are no contributors
290 account_snaps = publisher_api.get_account_snaps(flask.session)
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 )
300 redirect_url = flask.url_for(".get_snap_builds", snap_name=snap_name)
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("/")
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)
314 repo_validation = validate_repo(
315 flask.session.get("github_auth_secret"), snap_name, owner, repo
316 )
318 if not repo_validation["success"]:
319 flask.flash(repo_validation["error"]["message"], "negative")
320 return flask.redirect(redirect_url)
322 lp_snap = launchpad.get_snap_by_store_name(details["snap_name"])
323 git_url = f"https://github.com/{owner}/{repo}"
325 if not lp_snap:
326 lp_snap_name = md5(git_url.encode("UTF-8")).hexdigest()
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
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)
344 macaroon = publisher_api.get_package_upload_macaroon(
345 session=flask.session, snap_name=snap_name, channels=["edge"]
346 )["macaroon"]
348 launchpad.create_snap(snap_name, git_url, macaroon)
350 flask.flash(
351 "The GitHub repository was linked successfully.", "positive"
352 )
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)
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 )
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 )
377 return flask.redirect(redirect_url)
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)
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 )
397 try:
398 if launchpad.is_snap_building(snap_name):
399 launchpad.cancel_snap_builds(snap_name)
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
416 return flask.jsonify({"success": True, "build_id": build_id})
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)
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 )
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
452 error_message = None
453 if response["error_message"]:
454 error_message = response["error_message"].split(" HEAD:")[0]
456 return flask.jsonify(
457 {
458 "success": True,
459 "status": response["status"],
460 "error": {"message": error_message},
461 }
462 )
465@login_required
466def post_disconnect_repo(snap_name):
467 details = publisher_api.get_snap_info(snap_name, flask.session)
469 lp_snap = launchpad.get_snap_by_store_name(snap_name)
470 launchpad.delete_snap(details["snap_name"])
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"))
476 try:
477 gh_owner, gh_repo = lp_snap["git_repository_url"][19:].split("/")
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 )
485 if old_hook:
486 github.remove_hook(
487 gh_owner,
488 gh_repo,
489 old_hook["id"],
490 )
491 except HTTPError:
492 pass
494 return flask.redirect(
495 flask.url_for(".get_snap_builds", snap_name=snap_name)
496 )
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"]
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
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)
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())
523 if not lp_snap:
524 return ("This repository is not linked with any Snap", 403)
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)
530 github = GitHub()
532 signature = flask.request.headers.get("X-Hub-Signature")
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)
540 validation = validate_repo(
541 GITHUB_SNAPCRAFT_USER_TOKEN, lp_snap["store_name"], gh_owner, gh_repo
542 )
544 if not validation["success"]:
545 return (validation["error"]["message"], 400)
547 if launchpad.is_snap_building(lp_snap["store_name"]):
548 launchpad.cancel_snap_builds(lp_snap["store_name"])
550 launchpad.build_snap(lp_snap["store_name"])
552 return ("", 204)
555@login_required
556def get_update_gh_webhooks(snap_name):
557 details = publisher_api.get_snap_info(snap_name, flask.session)
559 lp_snap = launchpad.get_snap_by_store_name(details["snap_name"])
561 if not lp_snap:
562 flask.flash(
563 "This snap is not linked with a GitHub repository", "negative"
564 )
566 return flask.redirect(
567 flask.url_for(".get_settings", snap_name=snap_name)
568 )
570 github = GitHub(flask.session.get("github_auth_secret"))
572 try:
573 github.get_user()
574 except Unauthorized:
575 return flask.redirect(f"/github/auth?back={flask.request.path}")
577 gh_link = lp_snap["git_repository_url"][19:]
578 gh_owner, gh_repo = gh_link.split("/")
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)
587 if old_hook:
588 github.remove_hook(
589 gh_owner,
590 gh_repo,
591 old_hook["id"],
592 )
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 )
602 if snapcraft_hook:
603 github.remove_hook(
604 gh_owner,
605 gh_repo,
606 snapcraft_hook["id"],
607 )
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")
620 return flask.redirect(flask.url_for(".get_settings", snap_name=snap_name))