diff --git a/core/src/main/java/google/registry/batch/WipeOutCloudSqlAction.java b/core/src/main/java/google/registry/batch/WipeOutCloudSqlAction.java new file mode 100644 index 000000000..dc12eadc0 --- /dev/null +++ b/core/src/main/java/google/registry/batch/WipeOutCloudSqlAction.java @@ -0,0 +1,98 @@ +// Copyright 2021 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.batch; + +import static com.google.common.net.MediaType.PLAIN_TEXT_UTF_8; +import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN; +import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR; +import static javax.servlet.http.HttpServletResponse.SC_OK; + +import com.google.common.collect.ImmutableSet; +import com.google.common.flogger.FluentLogger; +import google.registry.config.RegistryConfig.Config; +import google.registry.persistence.PersistenceModule.SchemaManagerConnection; +import google.registry.request.Action; +import google.registry.request.Response; +import google.registry.request.auth.Auth; +import google.registry.util.Retrier; +import java.sql.Connection; +import java.sql.Statement; +import java.util.function.Supplier; +import javax.inject.Inject; +import org.flywaydb.core.api.FlywayException; + +/** + * Wipes out all Cloud SQL data in a Nomulus GCP environment. + * + *

This class is created for the QA environment, where migration testing with production data + * will happen. A regularly scheduled wipeout is a prerequisite to using production data there. + */ +@Action( + service = Action.Service.BACKEND, + path = "/_dr/task/wipeOutCloudSql", + auth = Auth.AUTH_INTERNAL_OR_ADMIN) +public class WipeOutCloudSqlAction implements Runnable { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + // As a short-lived class, hardcode allowed projects here instead of using config files. + private static final ImmutableSet ALLOWED_PROJECTS = + ImmutableSet.of("domain-registry-qa"); + + private final String projectId; + private final Supplier connectionSupplier; + private final Response response; + private final Retrier retrier; + + @Inject + WipeOutCloudSqlAction( + @Config("projectId") String projectId, + @SchemaManagerConnection Supplier connectionSupplier, + Response response, + Retrier retrier) { + this.projectId = projectId; + this.connectionSupplier = connectionSupplier; + this.response = response; + this.retrier = retrier; + } + + @Override + public void run() { + response.setContentType(PLAIN_TEXT_UTF_8); + + if (!ALLOWED_PROJECTS.contains(projectId)) { + response.setStatus(SC_FORBIDDEN); + response.setPayload("Wipeout is not allowed in " + projectId); + return; + } + + try { + retrier.callWithRetry( + () -> { + try (Connection conn = connectionSupplier.get(); + Statement statement = conn.createStatement()) { + statement.execute("drop owned by schema_deployer;"); + } + return null; + }, + e -> !(e instanceof FlywayException)); + response.setStatus(SC_OK); + response.setPayload("Wiped out Cloud SQL in " + projectId); + } catch (RuntimeException e) { + logger.atSevere().withCause(e).log("Failed to wipe out Cloud SQL data."); + response.setStatus(SC_INTERNAL_SERVER_ERROR); + response.setPayload("Failed to wipe out Cloud SQL in " + projectId); + } + } +} diff --git a/core/src/main/java/google/registry/env/common/backend/WEB-INF/web.xml b/core/src/main/java/google/registry/env/common/backend/WEB-INF/web.xml index 9b1411a3f..76e88279b 100644 --- a/core/src/main/java/google/registry/env/common/backend/WEB-INF/web.xml +++ b/core/src/main/java/google/registry/env/common/backend/WEB-INF/web.xml @@ -379,6 +379,12 @@ /_dr/task/relockDomain + + + backend-servlet + /_dr/task/wipeOutCloudSql + + diff --git a/core/src/main/java/google/registry/env/qa/default/WEB-INF/cron.xml b/core/src/main/java/google/registry/env/qa/default/WEB-INF/cron.xml index 87a444708..179e2d936 100644 --- a/core/src/main/java/google/registry/env/qa/default/WEB-INF/cron.xml +++ b/core/src/main/java/google/registry/env/qa/default/WEB-INF/cron.xml @@ -82,4 +82,13 @@ backend + + + + This job runs an action that deletes all data in Cloud SQL. + + every saturday 03:07 + backend + + diff --git a/core/src/main/java/google/registry/module/backend/BackendComponent.java b/core/src/main/java/google/registry/module/backend/BackendComponent.java index bab38c2dc..17f0b9153 100644 --- a/core/src/main/java/google/registry/module/backend/BackendComponent.java +++ b/core/src/main/java/google/registry/module/backend/BackendComponent.java @@ -36,6 +36,8 @@ import google.registry.keyring.api.KeyModule; import google.registry.keyring.kms.KmsModule; import google.registry.module.backend.BackendRequestComponent.BackendRequestComponentModule; import google.registry.monitoring.whitebox.StackdriverModule; +import google.registry.persistence.PersistenceModule; +import google.registry.privileges.secretmanager.SecretManagerModule; import google.registry.rde.JSchModule; import google.registry.request.Modules.DatastoreServiceModule; import google.registry.request.Modules.Jackson2Module; @@ -71,6 +73,8 @@ import javax.inject.Singleton; KeyringModule.class, KmsModule.class, NetHttpTransportModule.class, + PersistenceModule.class, + SecretManagerModule.class, ServerTridProviderModule.class, SheetsServiceModule.class, StackdriverModule.class, diff --git a/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java b/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java index 8bcb551d0..ef94a30aa 100644 --- a/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java +++ b/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java @@ -30,6 +30,7 @@ import google.registry.batch.RefreshDnsOnHostRenameAction; import google.registry.batch.RelockDomainAction; import google.registry.batch.ResaveAllEppResourcesAction; import google.registry.batch.ResaveEntityAction; +import google.registry.batch.WipeOutCloudSqlAction; import google.registry.cron.CommitLogFanoutAction; import google.registry.cron.CronModule; import google.registry.cron.TldFanoutAction; @@ -205,6 +206,8 @@ interface BackendRequestComponent { PublishInvoicesAction uploadInvoicesAction(); + WipeOutCloudSqlAction wipeOutCloudSqlAction(); + @Subcomponent.Builder abstract class Builder implements RequestComponentBuilder { diff --git a/core/src/main/java/google/registry/persistence/PersistenceModule.java b/core/src/main/java/google/registry/persistence/PersistenceModule.java index 5335628e7..51ec89f8d 100644 --- a/core/src/main/java/google/registry/persistence/PersistenceModule.java +++ b/core/src/main/java/google/registry/persistence/PersistenceModule.java @@ -44,10 +44,14 @@ import google.registry.tools.AuthModule.CloudSqlClientCredential; import google.registry.util.Clock; import java.lang.annotation.Documented; import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Properties; +import java.util.function.Supplier; import javax.annotation.Nullable; import javax.inject.Provider; import javax.inject.Qualifier; @@ -163,6 +167,37 @@ public abstract class PersistenceModule { return ImmutableMap.copyOf(overrides); } + /** + * Provides a {@link Supplier} of single-use JDBC {@link Connection connections} that can manage + * the database DDL schema. + */ + @Provides + @Singleton + @SchemaManagerConnection + static Supplier provideSchemaManagerConnectionSupplier( + SqlCredentialStore credentialStore, + @PartialCloudSqlConfigs ImmutableMap cloudSqlConfigs) { + SqlCredential credential = + credentialStore.getCredential(new RobotUser(RobotId.SCHEMA_DEPLOYER)); + String user = credential.login(); + String password = credential.password(); + return () -> createJdbcConnection(user, password, cloudSqlConfigs); + } + + private static Connection createJdbcConnection( + String user, String password, ImmutableMap cloudSqlConfigs) { + Properties properties = new Properties(); + properties.put("user", user); + properties.put("password", password); + properties.put("cloudSqlInstance", cloudSqlConfigs.get(HIKARI_DS_CLOUD_SQL_INSTANCE)); + properties.put("socketFactory", cloudSqlConfigs.get(HIKARI_DS_SOCKET_FACTORY)); + try { + return DriverManager.getConnection(cloudSqlConfigs.get(Environment.URL), properties); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + @Provides @Singleton @AppEngineJpaTm @@ -378,6 +413,11 @@ public abstract class PersistenceModule { } } + /** Dagger qualifier for JDBC {@link Connection} with schema management privilege. */ + @Qualifier + @Documented + public @interface SchemaManagerConnection {} + /** Dagger qualifier for {@link JpaTransactionManager} used for App Engine application. */ @Qualifier @Documented diff --git a/core/src/test/java/google/registry/batch/WipeOutCloudSqlActionTest.java b/core/src/test/java/google/registry/batch/WipeOutCloudSqlActionTest.java new file mode 100644 index 000000000..31f4fe940 --- /dev/null +++ b/core/src/test/java/google/registry/batch/WipeOutCloudSqlActionTest.java @@ -0,0 +1,101 @@ +// Copyright 2021 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.batch; + +import static com.google.common.truth.Truth.assertThat; +import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN; +import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR; +import static javax.servlet.http.HttpServletResponse.SC_OK; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import google.registry.testing.FakeClock; +import google.registry.testing.FakeResponse; +import google.registry.testing.FakeSleeper; +import google.registry.util.Retrier; +import java.sql.Connection; +import java.sql.Statement; +import org.flywaydb.core.api.FlywayException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** Unit tests for {@link WipeOutCloudSqlAction}. */ +@ExtendWith(MockitoExtension.class) +public class WipeOutCloudSqlActionTest { + + @Mock private Statement stmt; + @Mock private Connection conn; + + private FakeResponse response = new FakeResponse(); + private Retrier retrier = new Retrier(new FakeSleeper(new FakeClock()), 2); + + @BeforeEach + void beforeEach() throws Exception { + lenient().when(conn.createStatement()).thenReturn(stmt); + } + + @Test + void run_projectAllowed() throws Exception { + WipeOutCloudSqlAction action = + new WipeOutCloudSqlAction("domain-registry-qa", () -> conn, response, retrier); + action.run(); + assertThat(response.getStatus()).isEqualTo(SC_OK); + verify(stmt, times(1)).execute(anyString()); + verify(stmt, times(1)).close(); + verifyNoMoreInteractions(stmt); + } + + @Test + void run_projectNotAllowed() { + WipeOutCloudSqlAction action = + new WipeOutCloudSqlAction("domain-registry", () -> conn, response, retrier); + action.run(); + assertThat(response.getStatus()).isEqualTo(SC_FORBIDDEN); + verifyNoInteractions(stmt); + } + + @Test + void run_nonRetrieableFailure() throws Exception { + doThrow(new FlywayException()).when(stmt).execute(anyString()); + WipeOutCloudSqlAction action = + new WipeOutCloudSqlAction("domain-registry-qa", () -> conn, response, retrier); + action.run(); + assertThat(response.getStatus()).isEqualTo(SC_INTERNAL_SERVER_ERROR); + verify(stmt, times(1)).execute(anyString()); + verify(stmt, times(1)).close(); + verifyNoMoreInteractions(stmt); + } + + @Test + void run_retrieableFailure() throws Exception { + when(stmt.execute(anyString())).thenThrow(new RuntimeException()).thenReturn(true); + WipeOutCloudSqlAction action = + new WipeOutCloudSqlAction("domain-registry-qa", () -> conn, response, retrier); + action.run(); + assertThat(response.getStatus()).isEqualTo(SC_OK); + verify(stmt, times(2)).execute(anyString()); + verify(stmt, times(2)).close(); + verifyNoMoreInteractions(stmt); + } +} diff --git a/core/src/test/resources/google/registry/module/backend/backend_routing.txt b/core/src/test/resources/google/registry/module/backend/backend_routing.txt index 680c7ce45..abcc34c4a 100644 --- a/core/src/test/resources/google/registry/module/backend/backend_routing.txt +++ b/core/src/test/resources/google/registry/module/backend/backend_routing.txt @@ -43,3 +43,4 @@ PATH CLASS METHOD /_dr/task/updateRegistrarRdapBaseUrls UpdateRegistrarRdapBaseUrlsAction GET y INTERNAL,API APP ADMIN /_dr/task/updateSnapshotView UpdateSnapshotViewAction POST n INTERNAL,API APP ADMIN /_dr/task/uploadDatastoreBackup UploadDatastoreBackupAction POST n INTERNAL,API APP ADMIN +/_dr/task/wipeOutCloudSql WipeOutCloudSqlAction GET n INTERNAL,API APP ADMIN