1
0
mirror of https://github.com/google/nomulus synced 2025-12-23 06:15:42 +00:00

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.
This commit is contained in:
gbrodman
2025-12-12 16:15:47 -05:00
committed by GitHub
parent 2a94bdc257
commit 90eb078e3f
6 changed files with 337 additions and 4 deletions

View File

@@ -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.
*
* <p>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.
*
* <p>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).
*
* <p>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.
*
* <p>Consider passing in an "maxQps" parameter based on the number of domains being transferred,
* otherwise the default is {@link BatchModule#DEFAULT_MAX_QPS}.
*/

View File

@@ -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();

View File

@@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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<String> 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<String, Object> 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<String, Object> params = paramsBuilder.build();
for (List<String> 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<String> getDomainList() throws IOException {
return domainNamesFile == null ? ImmutableList.copyOf(domains) : loadDomainsFromFile();
}
private ImmutableList<String> loadDomainsFromFile() throws IOException {
return Splitter.on('\n')
.omitEmptyStrings()
.trimResults()
.splitToStream(Files.asCharSource(new File(domainNamesFile), UTF_8).read())
.map(DomainNameUtils::canonicalizeHostname)
.collect(toImmutableList());
}
}

View File

@@ -30,6 +30,7 @@ public final class RegistryTool {
public static final ImmutableMap<String, Class<? extends Command>> COMMAND_MAP =
new ImmutableMap.Builder<String, Class<? extends Command>>()
.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)

View File

@@ -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<BulkDomainTransferCommand> {
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<byte[]> 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");
}
}

View File

@@ -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