From 2145474b21e5e3168c86c5276d21326e9cf3371a Mon Sep 17 00:00:00 2001 From: Alex Zaslavsky Date: Fri, 10 Nov 2023 16:34:40 -0800 Subject: [PATCH] feat(backup): Send relocation started email Once we have decent confidence that a relocation is not obviously invalid, we send the owner (and creator, if they are different people) an email indicating that their relocation has started. Issue: getsentry/team-ospo#203 --- src/sentry/tasks/relocation.py | 35 ++++++++++++++++-- .../templates/sentry/debug/mail/preview.html | 5 ++- .../sentry/emails/relocate_account.txt | 4 +- .../sentry/emails/relocation_started.html | 17 +++++++++ .../sentry/emails/relocation_started.txt | 9 +++++ src/sentry/web/debug_urls.py | 1 + src/sentry/web/frontend/debug/mail.py | 14 +++++++ tests/sentry/tasks/test_relocation.py | 37 +++++++++++++++++-- 8 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 src/sentry/templates/sentry/emails/relocation_started.html create mode 100644 src/sentry/templates/sentry/emails/relocation_started.txt diff --git a/src/sentry/tasks/relocation.py b/src/sentry/tasks/relocation.py index bc6bbfaca1cf25..445227467e8bea 100644 --- a/src/sentry/tasks/relocation.py +++ b/src/sentry/tasks/relocation.py @@ -11,9 +11,11 @@ import yaml from cryptography.fernet import Fernet from django.db import router, transaction +from django.utils import timezone as get_timezone from google.cloud.devtools.cloudbuild_v1 import Build from google.cloud.devtools.cloudbuild_v1 import CloudBuildClient as CloudBuildClient +from sentry import options from sentry.api.serializers.rest_framework.base import camel_to_snake_case, convert_dict_key_case from sentry.backup.dependencies import NormalizedModelName, get_model from sentry.backup.exports import export_in_config_scope, export_in_user_scope @@ -26,6 +28,7 @@ ) from sentry.backup.imports import import_in_organization_scope from sentry.filestore.gcs import GoogleCloudStorage +from sentry.http import get_server_hostname from sentry.models.files.file import File from sentry.models.files.utils import get_storage from sentry.models.importchunk import RegionImportChunk @@ -39,10 +42,12 @@ ) from sentry.models.user import User from sentry.services.hybrid_cloud.organization import organization_service +from sentry.services.hybrid_cloud.user.service import user_service from sentry.silo import SiloMode from sentry.tasks.base import instrumented_task from sentry.utils import json from sentry.utils.db import atomic_transaction +from sentry.utils.email.message_builder import MessageBuilder from sentry.utils.env import gcp_project_id from sentry.utils.relocation import ( RELOCATION_BLOB_SIZE, @@ -307,11 +312,35 @@ def preprocessing_scan(uuid: str) -> None: relocation.want_usernames = sorted(usernames) relocation.save() - # TODO(getsentry/team-ospo#203): The user's import data looks basically okay - we should - # use this opportunity to send a "your relocation request has been accepted and is in - # flight, please give it a couple hours" email. preprocessing_baseline_config.delay(uuid) + # The user's import data looks basically okay - we can use this opportunity to send a + # "your relocation request has been accepted and is in flight, please give it a few + # hours" email. + msg = MessageBuilder( + subject=f"{options.get('mail.subject-prefix')} Your Relocation has Started", + template="sentry/emails/relocation-started.txt", + html_template="sentry/emails/relocation-started.html", + type="relocation.started", + context={ + "domain": get_server_hostname(), + "datetime": get_timezone.now(), + "uuid": str(relocation.uuid), + "orgs": relocation.want_org_slugs, + }, + ) + email_to = [] + owner = user_service.get_user(user_id=relocation.owner_id) + if owner is not None: + email_to.append(owner.email) + + if relocation.owner_id != relocation.creator_id: + creator = user_service.get_user(user_id=relocation.creator_id) + if creator is not None: + email_to.append(creator.email) + + msg.send_async(to=email_to) + @instrumented_task( name="sentry.relocation.preprocessing_baseline_config", diff --git a/src/sentry/templates/sentry/debug/mail/preview.html b/src/sentry/templates/sentry/debug/mail/preview.html index 6b1a213e125f18..0b167446dd8d4a 100644 --- a/src/sentry/templates/sentry/debug/mail/preview.html +++ b/src/sentry/templates/sentry/debug/mail/preview.html @@ -34,7 +34,6 @@ - @@ -53,6 +52,10 @@ + + + + diff --git a/src/sentry/templates/sentry/emails/relocate_account.txt b/src/sentry/templates/sentry/emails/relocate_account.txt index 93d9cd94556b92..25981b86d8b651 100644 --- a/src/sentry/templates/sentry/emails/relocate_account.txt +++ b/src/sentry/templates/sentry/emails/relocate_account.txt @@ -1,7 +1,7 @@ The following Sentry organizations that you are a member of have been migrated onto sentry.io: {% for org in orgs %} -{{ org }} -* {% endfor %} +* {{ org }} +{% endfor %} To continue with using these accounts at their new location, please claim your account with sentry.io. diff --git a/src/sentry/templates/sentry/emails/relocation_started.html b/src/sentry/templates/sentry/emails/relocation_started.html new file mode 100644 index 00000000000000..f26a2b52e60add --- /dev/null +++ b/src/sentry/templates/sentry/emails/relocation_started.html @@ -0,0 +1,17 @@ +{% extends "sentry/emails/base.html" %} + +{% load i18n %} + +{% block main %} +

Relocation Accepted

+

Your relocation request has been accepted. You requested that the following organizations be migrating to sentry.io:

+ +

Relocations usually complete in 24 hours or less. If you do not hear from us in that time frame, please contact support.

+

ID: {{ uuid }}

+{% endblock %} diff --git a/src/sentry/templates/sentry/emails/relocation_started.txt b/src/sentry/templates/sentry/emails/relocation_started.txt new file mode 100644 index 00000000000000..3aff63fb0d4ec9 --- /dev/null +++ b/src/sentry/templates/sentry/emails/relocation_started.txt @@ -0,0 +1,9 @@ +Your relocation request has been accepted. You requested that the following organizations be migrating to sentry.io: + +{% for org in orgs %} +* {{ org }} +{% endfor %} + +Relocations usually complete in 24 hours or less. If you do not hear from us in that time frame, please contact support at https://help.sentry.io. + +ID: {{ uuid }} diff --git a/src/sentry/web/debug_urls.py b/src/sentry/web/debug_urls.py index 5f4a634bd33c9b..52226bbef6859b 100644 --- a/src/sentry/web/debug_urls.py +++ b/src/sentry/web/debug_urls.py @@ -123,6 +123,7 @@ re_path(r"^debug/mail/confirm-email/$", sentry.web.frontend.debug.mail.confirm_email), re_path(r"^debug/mail/recover-account/$", sentry.web.frontend.debug.mail.recover_account), re_path(r"^debug/mail/relocate-account/$", sentry.web.frontend.debug.mail.relocate_account), + re_path(r"^debug/mail/relocation-started/$", sentry.web.frontend.debug.mail.relocation_started), re_path(r"^debug/mail/unable-to-delete-repo/$", DebugUnableToDeleteRepository.as_view()), re_path(r"^debug/mail/unable-to-fetch-commits/$", DebugUnableToFetchCommitsEmailView.as_view()), re_path(r"^debug/mail/unassigned/$", DebugUnassignedEmailView.as_view()), diff --git a/src/sentry/web/frontend/debug/mail.py b/src/sentry/web/frontend/debug/mail.py index 1531c45768adfa..525c688620e73c 100644 --- a/src/sentry/web/frontend/debug/mail.py +++ b/src/sentry/web/frontend/debug/mail.py @@ -738,6 +738,20 @@ def relocate_account(request): ).render(request) +@login_required +def relocation_started(request): + return MailPreview( + html_template="sentry/emails/relocation_started.html", + text_template="sentry/emails/relocation_started.txt", + context={ + "domain": get_server_hostname(), + "datetime": timezone.now(), + "uuid": str(uuid.uuid4().hex), + "orgs": ["testsentry", "testgetsentry"], + }, + ).render(request) + + @login_required def org_delete_confirm(request): from sentry.models.auditlogentry import AuditLogEntry diff --git a/tests/sentry/tasks/test_relocation.py b/tests/sentry/tasks/test_relocation.py index c0f5d440df6d2b..a5d18ef79dea47 100644 --- a/tests/sentry/tasks/test_relocation.py +++ b/tests/sentry/tasks/test_relocation.py @@ -86,10 +86,10 @@ class RelocationTaskTestCase(TestCase): def setUp(self): super().setUp() self.owner = self.create_user( - email="owner", is_superuser=False, is_staff=False, is_active=True + email="owner@example.com", is_superuser=False, is_staff=False, is_active=True ) self.superuser = self.create_user( - "superuser", is_superuser=True, is_staff=True, is_active=True + email="superuser@example.com", is_superuser=True, is_staff=True, is_active=True ) self.login_as(user=self.superuser, superuser=True) self.relocation: Relocation = Relocation.objects.create( @@ -231,8 +231,10 @@ def setUp(self): self.relocation.latest_task = "UPLOADING_COMPLETE" self.relocation.save() - def test_success( + @patch("sentry.utils.email.MessageBuilder.send_async") + def test_success_admin_assisted_relocation( self, + send_async_mock: Mock, preprocessing_baseline_config_mock: Mock, fake_kms_client: FakeKeyManagementServiceClient, ): @@ -243,6 +245,26 @@ def test_success( assert fake_kms_client.asymmetric_decrypt.call_count == 1 assert fake_kms_client.get_public_key.call_count == 0 assert preprocessing_baseline_config_mock.call_count == 1 + send_async_mock.assert_called_once_with(to=[self.owner.email, self.superuser.email]) + assert Relocation.objects.get(uuid=self.uuid).want_usernames == ["testing@example.com"] + + @patch("sentry.utils.email.MessageBuilder.send_async") + def test_success_self_service_relocation( + self, + send_async_mock: Mock, + preprocessing_baseline_config_mock: Mock, + fake_kms_client: FakeKeyManagementServiceClient, + ): + self.mock_kms_client(fake_kms_client) + self.relocation.creator_id = self.relocation.owner_id + self.relocation.save() + + preprocessing_scan(self.uuid) + + assert fake_kms_client.asymmetric_decrypt.call_count == 1 + assert fake_kms_client.get_public_key.call_count == 0 + assert preprocessing_baseline_config_mock.call_count == 1 + send_async_mock.assert_called_once_with(to=[self.owner.email]) assert Relocation.objects.get(uuid=self.uuid).want_usernames == ["testing@example.com"] def test_retry_if_attempts_left( @@ -462,7 +484,7 @@ def test_success( # Only user `superuser` is an admin, so only they should be exported. for json_model in json_models: if NormalizedModelName(json_model["model"]) == get_model_name(User): - assert json_model["fields"]["username"] in "superuser" + assert json_model["fields"]["username"] in "superuser@example.com" def test_retry_if_attempts_left( self, @@ -1227,6 +1249,7 @@ def test_fail_if_no_attempts_left(self, completed_mock: Mock): "sentry.tasks.relocation.CloudBuildClient", new_callable=lambda: FakeCloudBuildClient, ) +@patch("sentry.utils.email.MessageBuilder.send_async") @region_silo_test class EndToEndTest(RelocationTaskTestCase, TransactionTestCase): def setUp(self): @@ -1252,6 +1275,7 @@ def setUp(self): def test_valid_no_retries( self, + send_async_mock: Mock, fake_cloudbuild_client: FakeCloudBuildClient, fake_kms_client: FakeKeyManagementServiceClient, ): @@ -1287,8 +1311,11 @@ def test_valid_no_retries( "sentry.useremail", ] + assert send_async_mock.call_count == 1 + def test_invalid_no_retries( self, + send_async_mock: Mock, fake_cloudbuild_client: FakeCloudBuildClient, fake_kms_client: FakeKeyManagementServiceClient, ): @@ -1305,6 +1332,8 @@ def test_invalid_no_retries( assert relocation.failure_reason assert Organization.objects.filter(slug__startswith="testing").count() == org_count + assert send_async_mock.call_count == 1 + # TODO(getsentry/team-ospo#190): We should add "max retry" tests as well, but these are quite # hard to mock in celery at the moment. We may need to use the mock sync celery test scheduler, # rather than the "self.tasks()" approach above, to accomplish this.