plugin.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. import base64
  2. import hmac
  3. import logging
  4. from hashlib import sha256
  5. from django.http import HttpResponse
  6. from rest_framework.request import Request
  7. from rest_framework.response import Response
  8. from sentry.integrations import FeatureDescription, IntegrationFeatures
  9. from sentry.models import ApiKey, ProjectOption, Repository, User
  10. from sentry.plugins.base.configuration import react_plugin_config
  11. from sentry.plugins.bases import ReleaseTrackingPlugin
  12. from sentry.plugins.interfaces.releasehook import ReleaseHook
  13. from sentry.utils import json
  14. from sentry_plugins.base import CorePluginMixin
  15. from sentry_plugins.utils import get_secret_field_config
  16. from .client import HerokuApiClient
  17. logger = logging.getLogger("sentry.plugins.heroku")
  18. class HerokuReleaseHook(ReleaseHook):
  19. def get_auth(self):
  20. try:
  21. return ApiKey(organization=self.project.organization, scope_list=["project:write"])
  22. except ApiKey.DoesNotExist:
  23. return None
  24. def get_client(self):
  25. return HerokuApiClient()
  26. def is_valid_signature(self, body, heroku_hmac):
  27. secret = ProjectOption.objects.get_value(project=self.project, key="heroku:webhook_secret")
  28. computed_hmac = base64.b64encode(
  29. hmac.new(
  30. key=secret.encode("utf-8"),
  31. msg=body.encode("utf-8"),
  32. digestmod=sha256,
  33. ).digest()
  34. ).decode("utf-8")
  35. return hmac.compare_digest(heroku_hmac, computed_hmac)
  36. def handle(self, request: Request) -> Response:
  37. heroku_hmac = request.headers.get("Heroku-Webhook-Hmac-SHA256")
  38. if not self.is_valid_signature(request.body.decode("utf-8"), heroku_hmac):
  39. logger.info("heroku.webhook.invalid-signature", extra={"project_id": self.project.id})
  40. return HttpResponse(status=401)
  41. body = json.loads(request.body)
  42. data = body.get("data")
  43. email = data.get("user", {}).get("email") or data.get("actor", {}).get("email")
  44. try:
  45. user = User.objects.get(
  46. email__iexact=email, sentry_orgmember_set__organization__project=self.project
  47. )
  48. except (User.DoesNotExist, User.MultipleObjectsReturned):
  49. user = None
  50. logger.info(
  51. "owner.missing",
  52. extra={
  53. "organization_id": self.project.organization_id,
  54. "project_id": self.project.id,
  55. "email": email,
  56. },
  57. )
  58. slug = data.get("slug")
  59. if not slug:
  60. logger.info("heroku.payload.missing-commit", extra={"project_id": self.project.id})
  61. return HttpResponse(status=401)
  62. commit = slug.get("commit")
  63. app_name = data.get("app", {}).get("name")
  64. if data.get("action") == "update":
  65. if app_name:
  66. self.finish_release(
  67. version=commit,
  68. url=f"http://{app_name}.herokuapp.com",
  69. owner_id=user.id if user else None,
  70. )
  71. else:
  72. self.finish_release(version=commit, owner_id=user.id if user else None)
  73. def set_refs(self, release, **values):
  74. if not values.get("owner_id", None):
  75. return
  76. # check if user exists, and then try to get refs based on version
  77. repo_project_option = ProjectOption.objects.get_value(
  78. project=self.project, key="heroku:repository"
  79. )
  80. deploy_project_option = (
  81. ProjectOption.objects.get_value(
  82. project=self.project, key="heroku:environment", default="production"
  83. )
  84. or "production"
  85. )
  86. if repo_project_option:
  87. try:
  88. repository = Repository.objects.get(
  89. organization_id=self.project.organization_id, name=repo_project_option
  90. )
  91. except Repository.DoesNotExist:
  92. logger.info(
  93. "repository.missing",
  94. extra={
  95. "organization_id": self.project.organization_id,
  96. "project_id": self.project.id,
  97. "repository": repo_project_option,
  98. },
  99. )
  100. else:
  101. release.set_refs(
  102. refs=[{"commit": release.version, "repository": repository.name}],
  103. user_id=values["owner_id"],
  104. fetch=True,
  105. )
  106. # create deploy associated with release via ReleaseDeploysEndpoint
  107. endpoint = (
  108. f"/organizations/{self.project.organization.slug}/releases/{release.version}/deploys/"
  109. )
  110. client = self.get_client()
  111. client.post(endpoint, data={"environment": deploy_project_option}, auth=self.get_auth())
  112. class HerokuPlugin(CorePluginMixin, ReleaseTrackingPlugin):
  113. author = "Sentry Team"
  114. author_url = "https://github.com/getsentry"
  115. title = "Heroku"
  116. slug = "heroku"
  117. description = "Integrate Heroku release tracking."
  118. required_field = "repository"
  119. feature_descriptions = [
  120. FeatureDescription(
  121. """
  122. Integrate Heroku release tracking.
  123. """,
  124. IntegrationFeatures.DEPLOYMENT,
  125. )
  126. ]
  127. def configure(self, project, request):
  128. return react_plugin_config(self, project, request)
  129. def can_enable_for_projects(self):
  130. return True
  131. def can_configure_for_project(self, project):
  132. return True
  133. def has_project_conf(self):
  134. return True
  135. def get_conf_key(self):
  136. return "heroku"
  137. def get_config(self, project, **kwargs):
  138. repo_list = list(Repository.objects.filter(organization_id=project.organization_id))
  139. if not ProjectOption.objects.get_value(project=project, key="heroku:repository"):
  140. choices = [("", "select a repo")]
  141. else:
  142. choices = []
  143. choices.extend([(repo.name, repo.name) for repo in repo_list])
  144. webhook_secret = self.get_option("webhook_secret", project)
  145. secret_field = get_secret_field_config(
  146. webhook_secret,
  147. "Enter the webhook signing secret shown after running the Heroku CLI command.",
  148. )
  149. secret_field.update(
  150. {
  151. "name": "webhook_secret",
  152. "label": "Webhook Secret",
  153. "required": False,
  154. }
  155. )
  156. return [
  157. {
  158. "name": "repository",
  159. "label": "Respository",
  160. "type": "select",
  161. "required": True,
  162. "choices": choices,
  163. "help": "Select which repository you would like to be associated with this project",
  164. },
  165. {
  166. "name": "environment",
  167. "label": "Deploy Environment",
  168. "type": "text",
  169. "required": False,
  170. "default": "production",
  171. "help": "Specify an environment name for your Heroku deploys",
  172. },
  173. secret_field,
  174. ]
  175. def get_release_doc_html(self, hook_url):
  176. return f"""
  177. <p>Add a Sentry release webhook to automatically track new releases.</p>
  178. <pre class="clippy">heroku webhooks:add -i api:release -l notify -u {hook_url} -a YOUR_APP_NAME</pre>
  179. """
  180. def get_release_hook(self):
  181. return HerokuReleaseHook