From 90eb078e3fb3d0d7219a49db5c633e42755b66c9 Mon Sep 17 00:00:00 2001 From: gbrodman Date: Fri, 12 Dec 2025 16:15:47 -0500 Subject: [PATCH] Add a BulkDomainTransferCommand (#2898) This is a decently simple wrapper around the previously-created BulkDomainTransferAction that batches a provided list of domains up and sends them along to be transferred. --- .../batch/BulkDomainTransferAction.java | 10 +- .../registry/module/RequestComponent.java | 3 + .../tools/BulkDomainTransferCommand.java | 158 ++++++++++++++++ .../google/registry/tools/RegistryTool.java | 1 + .../tools/BulkDomainTransferCommandTest.java | 168 ++++++++++++++++++ .../google/registry/module/routing.txt | 1 + 6 files changed, 337 insertions(+), 4 deletions(-) create mode 100644 core/src/main/java/google/registry/tools/BulkDomainTransferCommand.java create mode 100644 core/src/test/java/google/registry/tools/BulkDomainTransferCommandTest.java diff --git a/core/src/main/java/google/registry/batch/BulkDomainTransferAction.java b/core/src/main/java/google/registry/batch/BulkDomainTransferAction.java index 407517fea..990428edd 100644 --- a/core/src/main/java/google/registry/batch/BulkDomainTransferAction.java +++ b/core/src/main/java/google/registry/batch/BulkDomainTransferAction.java @@ -54,14 +54,16 @@ import org.joda.time.Duration; * Acquisition) process in order to transfer a (possibly large) list of domains from one registrar * to another, though it may be used in other situations as well. * + *

The body of the HTTP post request should be a JSON list of the domains to be transferred. + * Because the list of domains to process can be quite large, this action should be called by a tool + * that batches the list of domains into reasonable sizes if necessary. The recommended usage path + * is to call this through the {@link google.registry.tools.BulkDomainTransferCommand}, which + * handles batching and input handling. + * *

This runs as a single-threaded idempotent action that runs a superuser domain transfer on each * domain to process. We go through the standard EPP process to make sure that we have an accurate * historical representation of events (rather than force-modifying the domains in place). * - *

The body of the HTTP post request should be a JSON list of the domains to be transferred. - * Because the list of domains to process can be quite large, this action should be called by a tool - * that batches the list of domains into reasonable sizes if necessary. - * *

Consider passing in an "maxQps" parameter based on the number of domains being transferred, * otherwise the default is {@link BatchModule#DEFAULT_MAX_QPS}. */ diff --git a/core/src/main/java/google/registry/module/RequestComponent.java b/core/src/main/java/google/registry/module/RequestComponent.java index 58992d4b5..2444567c8 100644 --- a/core/src/main/java/google/registry/module/RequestComponent.java +++ b/core/src/main/java/google/registry/module/RequestComponent.java @@ -17,6 +17,7 @@ package google.registry.module; import dagger.Module; import dagger.Subcomponent; import google.registry.batch.BatchModule; +import google.registry.batch.BulkDomainTransferAction; import google.registry.batch.CannedScriptExecutionAction; import google.registry.batch.DeleteExpiredDomainsAction; import google.registry.batch.DeleteLoadTestDataAction; @@ -171,6 +172,8 @@ interface RequestComponent { BsaValidateAction bsaValidateAction(); + BulkDomainTransferAction bulkDomainTransferAction(); + CannedScriptExecutionAction cannedScriptExecutionAction(); CheckApiAction checkApiAction(); diff --git a/core/src/main/java/google/registry/tools/BulkDomainTransferCommand.java b/core/src/main/java/google/registry/tools/BulkDomainTransferCommand.java new file mode 100644 index 000000000..18482f15d --- /dev/null +++ b/core/src/main/java/google/registry/tools/BulkDomainTransferCommand.java @@ -0,0 +1,158 @@ +// Copyright 2025 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.tools; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.collect.ImmutableList.toImmutableList; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.beust.jcommander.Parameter; +import com.beust.jcommander.Parameters; +import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterables; +import com.google.common.io.Files; +import com.google.common.net.MediaType; +import com.google.gson.Gson; +import google.registry.batch.BulkDomainTransferAction; +import google.registry.model.registrar.Registrar; +import google.registry.util.DomainNameUtils; +import java.io.File; +import java.io.IOException; +import java.util.List; + +/** + * A command to bulk-transfer any number of domains from one registrar to another. + * + *

This should be used as part of the BTAPPA (Bulk Transfer After a Partial Portfolio + * Acquisition) process in order to transfer a (possibly large) list of domains from one registrar + * to another, though it may be used in other situations as well. + * + *

For a true bulk transfer of domains, one should pass in a file with a list of domains (one per + * line) but if we need to do an ad-hoc transfer of one domain we can do that as well. + * + *

For BTAPPA purposes, we expect "requestedByRegistrar" to be true; this may not be the case for + * other purposes e.g. legal compliance transfers. + */ +@Parameters( + separators = " =", + commandDescription = "Transfer domain(s) in bulk with immediate effect.") +public class BulkDomainTransferCommand extends ConfirmingCommand implements CommandWithConnection { + + // we don't need any configuration on the Gson because all we need is a list of strings + private static final Gson GSON = new Gson(); + private static final int DOMAIN_TRANSFER_BATCH_SIZE = 1000; + + @Parameter( + names = {"--domains"}, + description = + "Comma-separated list of domains to transfer, otherwise use --domain_names_file to" + + " specify a possibly-large list of domains") + private List domains; + + @Parameter( + names = {"-d", "--domain_names_file"}, + description = "A file with a list of newline-delimited domain names to create tokens for") + private String domainNamesFile; + + @Parameter( + names = {"-g", "--gaining_registrar_id"}, + description = "The ID of the registrar to which domains should be transferred", + required = true) + private String gainingRegistrarId; + + @Parameter( + names = {"-l", "--losing_registrar_id"}, + description = "The ID of the registrar from which domains should be transferred", + required = true) + private String losingRegistrarId; + + @Parameter( + names = {"--reason"}, + description = "Reason to transfer the domains", + required = true) + private String reason; + + @Parameter( + names = {"--registrar_request"}, + description = "Whether the change was requested by a registrar.") + private boolean requestedByRegistrar = false; + + @Parameter( + names = {"--max_qps"}, + description = + "Maximum queries to run per second, otherwise the default (maxQps) will be used") + private int maxQps; + + private ServiceConnection connection; + + @Override + public void setConnection(ServiceConnection connection) { + this.connection = connection; + } + + @Override + protected String prompt() throws Exception { + checkArgument( + domainNamesFile != null ^ (domains != null && !domains.isEmpty()), + "Must specify exactly one input method, either --domains or --domain_names_file"); + return String.format("Attempt to transfer %d domains?", getDomainList().size()); + } + + @Override + protected String execute() throws Exception { + checkArgument( + Registrar.loadByRegistrarIdCached(gainingRegistrarId).isPresent(), + "Gaining registrar %s doesn't exist", + gainingRegistrarId); + checkArgument( + Registrar.loadByRegistrarIdCached(losingRegistrarId).isPresent(), + "Losing registrar %s doesn't exist", + losingRegistrarId); + + ImmutableMap.Builder paramsBuilder = new ImmutableMap.Builder<>(); + paramsBuilder.put("gainingRegistrarId", gainingRegistrarId); + paramsBuilder.put("losingRegistrarId", losingRegistrarId); + paramsBuilder.put("requestedByRegistrar", requestedByRegistrar); + paramsBuilder.put("reason", reason); + if (maxQps > 0) { + paramsBuilder.put("maxQps", maxQps); + } + ImmutableMap params = paramsBuilder.build(); + + for (List batch : Iterables.partition(getDomainList(), DOMAIN_TRANSFER_BATCH_SIZE)) { + System.out.printf("Sending batch of %d domains\n", batch.size()); + byte[] domainsList = GSON.toJson(batch).getBytes(UTF_8); + System.out.println( + connection.sendPostRequest( + BulkDomainTransferAction.PATH, params, MediaType.PLAIN_TEXT_UTF_8, domainsList)); + } + return ""; + } + + private ImmutableList getDomainList() throws IOException { + return domainNamesFile == null ? ImmutableList.copyOf(domains) : loadDomainsFromFile(); + } + + private ImmutableList loadDomainsFromFile() throws IOException { + return Splitter.on('\n') + .omitEmptyStrings() + .trimResults() + .splitToStream(Files.asCharSource(new File(domainNamesFile), UTF_8).read()) + .map(DomainNameUtils::canonicalizeHostname) + .collect(toImmutableList()); + } +} diff --git a/core/src/main/java/google/registry/tools/RegistryTool.java b/core/src/main/java/google/registry/tools/RegistryTool.java index d5b08e8b4..0a38ff6d1 100644 --- a/core/src/main/java/google/registry/tools/RegistryTool.java +++ b/core/src/main/java/google/registry/tools/RegistryTool.java @@ -30,6 +30,7 @@ public final class RegistryTool { public static final ImmutableMap> COMMAND_MAP = new ImmutableMap.Builder>() .put("ack_poll_messages", AckPollMessagesCommand.class) + .put("bulk_domain_transfer", BulkDomainTransferCommand.class) .put("canonicalize_labels", CanonicalizeLabelsCommand.class) .put("check_domain", CheckDomainCommand.class) .put("check_domain_claims", CheckDomainClaimsCommand.class) diff --git a/core/src/test/java/google/registry/tools/BulkDomainTransferCommandTest.java b/core/src/test/java/google/registry/tools/BulkDomainTransferCommandTest.java new file mode 100644 index 000000000..f77799802 --- /dev/null +++ b/core/src/test/java/google/registry/tools/BulkDomainTransferCommandTest.java @@ -0,0 +1,168 @@ +// Copyright 2025 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.tools; + +import static com.google.common.truth.Truth.assertThat; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.google.common.collect.ImmutableMap; +import com.google.common.io.CharSink; +import com.google.common.io.Files; +import com.google.common.net.MediaType; +import java.io.File; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** Tests fir {@link BulkDomainTransferCommand}. */ +public class BulkDomainTransferCommandTest extends CommandTestCase { + + private ServiceConnection connection; + + @BeforeEach + void beforeEach() { + connection = mock(ServiceConnection.class); + command.setConnection(connection); + } + + @Test + void testSuccess_validParametersSent() throws Exception { + runCommandForced( + "--gaining_registrar_id", "NewRegistrar", + "--losing_registrar_id", "TheRegistrar", + "--reason", "someReason", + "--domains", "foo.tld,bar.tld"); + assertInStdout("Sending batch of 2 domains"); + verify(connection) + .sendPostRequest( + "/_dr/task/bulkDomainTransfer", + ImmutableMap.of( + "gainingRegistrarId", + "NewRegistrar", + "losingRegistrarId", + "TheRegistrar", + "requestedByRegistrar", + false, + "reason", + "someReason"), + MediaType.PLAIN_TEXT_UTF_8, + "[\"foo.tld\",\"bar.tld\"]".getBytes(UTF_8)); + } + + @Test + void testSuccess_fileInBatches() throws Exception { + File domainNamesFile = tmpDir.resolve("domain_names.txt").toFile(); + CharSink sink = Files.asCharSink(domainNamesFile, UTF_8); + sink.writeLines(IntStream.range(0, 1003).mapToObj(i -> String.format("foo%d.tld", i))); + runCommandForced( + "--gaining_registrar_id", "NewRegistrar", + "--losing_registrar_id", "TheRegistrar", + "--reason", "someReason", + "--domain_names_file", domainNamesFile.getPath()); + assertInStdout("Sending batch of 1000 domains"); + assertInStdout("Sending batch of 3 domains"); + ArgumentCaptor listCaptor = ArgumentCaptor.forClass(byte[].class); + verify(connection, times(2)) + .sendPostRequest( + eq("/_dr/task/bulkDomainTransfer"), + eq( + ImmutableMap.of( + "gainingRegistrarId", + "NewRegistrar", + "losingRegistrarId", + "TheRegistrar", + "requestedByRegistrar", + false, + "reason", + "someReason")), + eq(MediaType.PLAIN_TEXT_UTF_8), + listCaptor.capture()); + assertThat(listCaptor.getValue()) + .isEqualTo("[\"foo1000.tld\",\"foo1001.tld\",\"foo1002.tld\"]".getBytes(UTF_8)); + } + + @Test + void testFailure_badGaining() { + assertThat( + assertThrows( + IllegalArgumentException.class, + () -> + runCommandForced( + "--gaining_registrar_id", "Bad", + "--losing_registrar_id", "TheRegistrar", + "--reason", "someReason", + "--domains", "foo.tld,baz.tld"))) + .hasMessageThat() + .isEqualTo("Gaining registrar Bad doesn't exist"); + } + + @Test + void testFailure_badLosing() { + assertThat( + assertThrows( + IllegalArgumentException.class, + () -> + runCommandForced( + "--gaining_registrar_id", "NewRegistrar", + "--losing_registrar_id", "Bad", + "--reason", "someReason", + "--domains", "foo.tld,baz.tld"))) + .hasMessageThat() + .isEqualTo("Losing registrar Bad doesn't exist"); + } + + @Test + void testFailure_noDomainsSpecified() { + assertThat( + assertThrows( + IllegalArgumentException.class, + () -> + runCommandForced( + "--gaining_registrar_id", "NewRegistrar", + "--losing_registrar_id", "TheRegistrar", + "--reason", "someReason"))) + .hasMessageThat() + .isEqualTo( + "Must specify exactly one input method, either --domains or --domain_names_file"); + } + + @Test + void testFailure_bothDomainMethodsSpecified() { + assertThat( + assertThrows( + IllegalArgumentException.class, + () -> + runCommandForced( + "--gaining_registrar_id", + "NewRegistrar", + "--losing_registrar_id", + "TheRegistrar", + "--reason", + "someReason", + "--domains", + "foo.tld,baz.tld", + "--domain_names_file", + "foo.txt"))) + .hasMessageThat() + .isEqualTo( + "Must specify exactly one input method, either --domains or --domain_names_file"); + } +} diff --git a/core/src/test/resources/google/registry/module/routing.txt b/core/src/test/resources/google/registry/module/routing.txt index ae365ca2d..10048de4b 100644 --- a/core/src/test/resources/google/registry/module/routing.txt +++ b/core/src/test/resources/google/registry/module/routing.txt @@ -17,6 +17,7 @@ BACKEND /_dr/task/brdaCopy BrdaCopyAction BACKEND /_dr/task/bsaDownload BsaDownloadAction GET,POST n APP ADMIN BACKEND /_dr/task/bsaRefresh BsaRefreshAction GET,POST n APP ADMIN BACKEND /_dr/task/bsaValidate BsaValidateAction GET,POST n APP ADMIN +BACKEND /_dr/task/bulkDomainTransfer BulkDomainTransferAction POST n APP ADMIN BACKEND /_dr/task/copyDetailReports CopyDetailReportsAction POST n APP ADMIN BACKEND /_dr/task/deleteExpiredDomains DeleteExpiredDomainsAction GET n APP ADMIN BACKEND /_dr/task/deleteLoadTestData DeleteLoadTestDataAction POST n APP ADMIN