Skip to content

Commit 90eb078

Browse files
authored
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.
1 parent 2a94bdc commit 90eb078

File tree

6 files changed

+337
-4
lines changed

6 files changed

+337
-4
lines changed

core/src/main/java/google/registry/batch/BulkDomainTransferAction.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,16 @@
5454
* Acquisition) process in order to transfer a (possibly large) list of domains from one registrar
5555
* to another, though it may be used in other situations as well.
5656
*
57+
* <p>The body of the HTTP post request should be a JSON list of the domains to be transferred.
58+
* Because the list of domains to process can be quite large, this action should be called by a tool
59+
* that batches the list of domains into reasonable sizes if necessary. The recommended usage path
60+
* is to call this through the {@link google.registry.tools.BulkDomainTransferCommand}, which
61+
* handles batching and input handling.
62+
*
5763
* <p>This runs as a single-threaded idempotent action that runs a superuser domain transfer on each
5864
* domain to process. We go through the standard EPP process to make sure that we have an accurate
5965
* historical representation of events (rather than force-modifying the domains in place).
6066
*
61-
* <p>The body of the HTTP post request should be a JSON list of the domains to be transferred.
62-
* Because the list of domains to process can be quite large, this action should be called by a tool
63-
* that batches the list of domains into reasonable sizes if necessary.
64-
*
6567
* <p>Consider passing in an "maxQps" parameter based on the number of domains being transferred,
6668
* otherwise the default is {@link BatchModule#DEFAULT_MAX_QPS}.
6769
*/

core/src/main/java/google/registry/module/RequestComponent.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import dagger.Module;
1818
import dagger.Subcomponent;
1919
import google.registry.batch.BatchModule;
20+
import google.registry.batch.BulkDomainTransferAction;
2021
import google.registry.batch.CannedScriptExecutionAction;
2122
import google.registry.batch.DeleteExpiredDomainsAction;
2223
import google.registry.batch.DeleteLoadTestDataAction;
@@ -171,6 +172,8 @@ interface RequestComponent {
171172

172173
BsaValidateAction bsaValidateAction();
173174

175+
BulkDomainTransferAction bulkDomainTransferAction();
176+
174177
CannedScriptExecutionAction cannedScriptExecutionAction();
175178

176179
CheckApiAction checkApiAction();
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Copyright 2025 The Nomulus Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package google.registry.tools;
16+
17+
import static com.google.common.base.Preconditions.checkArgument;
18+
import static com.google.common.collect.ImmutableList.toImmutableList;
19+
import static java.nio.charset.StandardCharsets.UTF_8;
20+
21+
import com.beust.jcommander.Parameter;
22+
import com.beust.jcommander.Parameters;
23+
import com.google.common.base.Splitter;
24+
import com.google.common.collect.ImmutableList;
25+
import com.google.common.collect.ImmutableMap;
26+
import com.google.common.collect.Iterables;
27+
import com.google.common.io.Files;
28+
import com.google.common.net.MediaType;
29+
import com.google.gson.Gson;
30+
import google.registry.batch.BulkDomainTransferAction;
31+
import google.registry.model.registrar.Registrar;
32+
import google.registry.util.DomainNameUtils;
33+
import java.io.File;
34+
import java.io.IOException;
35+
import java.util.List;
36+
37+
/**
38+
* A command to bulk-transfer any number of domains from one registrar to another.
39+
*
40+
* <p>This should be used as part of the BTAPPA (Bulk Transfer After a Partial Portfolio
41+
* Acquisition) process in order to transfer a (possibly large) list of domains from one registrar
42+
* to another, though it may be used in other situations as well.
43+
*
44+
* <p>For a true bulk transfer of domains, one should pass in a file with a list of domains (one per
45+
* line) but if we need to do an ad-hoc transfer of one domain we can do that as well.
46+
*
47+
* <p>For BTAPPA purposes, we expect "requestedByRegistrar" to be true; this may not be the case for
48+
* other purposes e.g. legal compliance transfers.
49+
*/
50+
@Parameters(
51+
separators = " =",
52+
commandDescription = "Transfer domain(s) in bulk with immediate effect.")
53+
public class BulkDomainTransferCommand extends ConfirmingCommand implements CommandWithConnection {
54+
55+
// we don't need any configuration on the Gson because all we need is a list of strings
56+
private static final Gson GSON = new Gson();
57+
private static final int DOMAIN_TRANSFER_BATCH_SIZE = 1000;
58+
59+
@Parameter(
60+
names = {"--domains"},
61+
description =
62+
"Comma-separated list of domains to transfer, otherwise use --domain_names_file to"
63+
+ " specify a possibly-large list of domains")
64+
private List<String> domains;
65+
66+
@Parameter(
67+
names = {"-d", "--domain_names_file"},
68+
description = "A file with a list of newline-delimited domain names to create tokens for")
69+
private String domainNamesFile;
70+
71+
@Parameter(
72+
names = {"-g", "--gaining_registrar_id"},
73+
description = "The ID of the registrar to which domains should be transferred",
74+
required = true)
75+
private String gainingRegistrarId;
76+
77+
@Parameter(
78+
names = {"-l", "--losing_registrar_id"},
79+
description = "The ID of the registrar from which domains should be transferred",
80+
required = true)
81+
private String losingRegistrarId;
82+
83+
@Parameter(
84+
names = {"--reason"},
85+
description = "Reason to transfer the domains",
86+
required = true)
87+
private String reason;
88+
89+
@Parameter(
90+
names = {"--registrar_request"},
91+
description = "Whether the change was requested by a registrar.")
92+
private boolean requestedByRegistrar = false;
93+
94+
@Parameter(
95+
names = {"--max_qps"},
96+
description =
97+
"Maximum queries to run per second, otherwise the default (maxQps) will be used")
98+
private int maxQps;
99+
100+
private ServiceConnection connection;
101+
102+
@Override
103+
public void setConnection(ServiceConnection connection) {
104+
this.connection = connection;
105+
}
106+
107+
@Override
108+
protected String prompt() throws Exception {
109+
checkArgument(
110+
domainNamesFile != null ^ (domains != null && !domains.isEmpty()),
111+
"Must specify exactly one input method, either --domains or --domain_names_file");
112+
return String.format("Attempt to transfer %d domains?", getDomainList().size());
113+
}
114+
115+
@Override
116+
protected String execute() throws Exception {
117+
checkArgument(
118+
Registrar.loadByRegistrarIdCached(gainingRegistrarId).isPresent(),
119+
"Gaining registrar %s doesn't exist",
120+
gainingRegistrarId);
121+
checkArgument(
122+
Registrar.loadByRegistrarIdCached(losingRegistrarId).isPresent(),
123+
"Losing registrar %s doesn't exist",
124+
losingRegistrarId);
125+
126+
ImmutableMap.Builder<String, Object> paramsBuilder = new ImmutableMap.Builder<>();
127+
paramsBuilder.put("gainingRegistrarId", gainingRegistrarId);
128+
paramsBuilder.put("losingRegistrarId", losingRegistrarId);
129+
paramsBuilder.put("requestedByRegistrar", requestedByRegistrar);
130+
paramsBuilder.put("reason", reason);
131+
if (maxQps > 0) {
132+
paramsBuilder.put("maxQps", maxQps);
133+
}
134+
ImmutableMap<String, Object> params = paramsBuilder.build();
135+
136+
for (List<String> batch : Iterables.partition(getDomainList(), DOMAIN_TRANSFER_BATCH_SIZE)) {
137+
System.out.printf("Sending batch of %d domains\n", batch.size());
138+
byte[] domainsList = GSON.toJson(batch).getBytes(UTF_8);
139+
System.out.println(
140+
connection.sendPostRequest(
141+
BulkDomainTransferAction.PATH, params, MediaType.PLAIN_TEXT_UTF_8, domainsList));
142+
}
143+
return "";
144+
}
145+
146+
private ImmutableList<String> getDomainList() throws IOException {
147+
return domainNamesFile == null ? ImmutableList.copyOf(domains) : loadDomainsFromFile();
148+
}
149+
150+
private ImmutableList<String> loadDomainsFromFile() throws IOException {
151+
return Splitter.on('\n')
152+
.omitEmptyStrings()
153+
.trimResults()
154+
.splitToStream(Files.asCharSource(new File(domainNamesFile), UTF_8).read())
155+
.map(DomainNameUtils::canonicalizeHostname)
156+
.collect(toImmutableList());
157+
}
158+
}

core/src/main/java/google/registry/tools/RegistryTool.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public final class RegistryTool {
3030
public static final ImmutableMap<String, Class<? extends Command>> COMMAND_MAP =
3131
new ImmutableMap.Builder<String, Class<? extends Command>>()
3232
.put("ack_poll_messages", AckPollMessagesCommand.class)
33+
.put("bulk_domain_transfer", BulkDomainTransferCommand.class)
3334
.put("canonicalize_labels", CanonicalizeLabelsCommand.class)
3435
.put("check_domain", CheckDomainCommand.class)
3536
.put("check_domain_claims", CheckDomainClaimsCommand.class)
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright 2025 The Nomulus Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package google.registry.tools;
16+
17+
import static com.google.common.truth.Truth.assertThat;
18+
import static java.nio.charset.StandardCharsets.UTF_8;
19+
import static org.junit.Assert.assertThrows;
20+
import static org.mockito.ArgumentMatchers.eq;
21+
import static org.mockito.Mockito.mock;
22+
import static org.mockito.Mockito.times;
23+
import static org.mockito.Mockito.verify;
24+
25+
import com.google.common.collect.ImmutableMap;
26+
import com.google.common.io.CharSink;
27+
import com.google.common.io.Files;
28+
import com.google.common.net.MediaType;
29+
import java.io.File;
30+
import java.util.stream.IntStream;
31+
import org.junit.jupiter.api.BeforeEach;
32+
import org.junit.jupiter.api.Test;
33+
import org.mockito.ArgumentCaptor;
34+
35+
/** Tests fir {@link BulkDomainTransferCommand}. */
36+
public class BulkDomainTransferCommandTest extends CommandTestCase<BulkDomainTransferCommand> {
37+
38+
private ServiceConnection connection;
39+
40+
@BeforeEach
41+
void beforeEach() {
42+
connection = mock(ServiceConnection.class);
43+
command.setConnection(connection);
44+
}
45+
46+
@Test
47+
void testSuccess_validParametersSent() throws Exception {
48+
runCommandForced(
49+
"--gaining_registrar_id", "NewRegistrar",
50+
"--losing_registrar_id", "TheRegistrar",
51+
"--reason", "someReason",
52+
"--domains", "foo.tld,bar.tld");
53+
assertInStdout("Sending batch of 2 domains");
54+
verify(connection)
55+
.sendPostRequest(
56+
"/_dr/task/bulkDomainTransfer",
57+
ImmutableMap.of(
58+
"gainingRegistrarId",
59+
"NewRegistrar",
60+
"losingRegistrarId",
61+
"TheRegistrar",
62+
"requestedByRegistrar",
63+
false,
64+
"reason",
65+
"someReason"),
66+
MediaType.PLAIN_TEXT_UTF_8,
67+
"[\"foo.tld\",\"bar.tld\"]".getBytes(UTF_8));
68+
}
69+
70+
@Test
71+
void testSuccess_fileInBatches() throws Exception {
72+
File domainNamesFile = tmpDir.resolve("domain_names.txt").toFile();
73+
CharSink sink = Files.asCharSink(domainNamesFile, UTF_8);
74+
sink.writeLines(IntStream.range(0, 1003).mapToObj(i -> String.format("foo%d.tld", i)));
75+
runCommandForced(
76+
"--gaining_registrar_id", "NewRegistrar",
77+
"--losing_registrar_id", "TheRegistrar",
78+
"--reason", "someReason",
79+
"--domain_names_file", domainNamesFile.getPath());
80+
assertInStdout("Sending batch of 1000 domains");
81+
assertInStdout("Sending batch of 3 domains");
82+
ArgumentCaptor<byte[]> listCaptor = ArgumentCaptor.forClass(byte[].class);
83+
verify(connection, times(2))
84+
.sendPostRequest(
85+
eq("/_dr/task/bulkDomainTransfer"),
86+
eq(
87+
ImmutableMap.of(
88+
"gainingRegistrarId",
89+
"NewRegistrar",
90+
"losingRegistrarId",
91+
"TheRegistrar",
92+
"requestedByRegistrar",
93+
false,
94+
"reason",
95+
"someReason")),
96+
eq(MediaType.PLAIN_TEXT_UTF_8),
97+
listCaptor.capture());
98+
assertThat(listCaptor.getValue())
99+
.isEqualTo("[\"foo1000.tld\",\"foo1001.tld\",\"foo1002.tld\"]".getBytes(UTF_8));
100+
}
101+
102+
@Test
103+
void testFailure_badGaining() {
104+
assertThat(
105+
assertThrows(
106+
IllegalArgumentException.class,
107+
() ->
108+
runCommandForced(
109+
"--gaining_registrar_id", "Bad",
110+
"--losing_registrar_id", "TheRegistrar",
111+
"--reason", "someReason",
112+
"--domains", "foo.tld,baz.tld")))
113+
.hasMessageThat()
114+
.isEqualTo("Gaining registrar Bad doesn't exist");
115+
}
116+
117+
@Test
118+
void testFailure_badLosing() {
119+
assertThat(
120+
assertThrows(
121+
IllegalArgumentException.class,
122+
() ->
123+
runCommandForced(
124+
"--gaining_registrar_id", "NewRegistrar",
125+
"--losing_registrar_id", "Bad",
126+
"--reason", "someReason",
127+
"--domains", "foo.tld,baz.tld")))
128+
.hasMessageThat()
129+
.isEqualTo("Losing registrar Bad doesn't exist");
130+
}
131+
132+
@Test
133+
void testFailure_noDomainsSpecified() {
134+
assertThat(
135+
assertThrows(
136+
IllegalArgumentException.class,
137+
() ->
138+
runCommandForced(
139+
"--gaining_registrar_id", "NewRegistrar",
140+
"--losing_registrar_id", "TheRegistrar",
141+
"--reason", "someReason")))
142+
.hasMessageThat()
143+
.isEqualTo(
144+
"Must specify exactly one input method, either --domains or --domain_names_file");
145+
}
146+
147+
@Test
148+
void testFailure_bothDomainMethodsSpecified() {
149+
assertThat(
150+
assertThrows(
151+
IllegalArgumentException.class,
152+
() ->
153+
runCommandForced(
154+
"--gaining_registrar_id",
155+
"NewRegistrar",
156+
"--losing_registrar_id",
157+
"TheRegistrar",
158+
"--reason",
159+
"someReason",
160+
"--domains",
161+
"foo.tld,baz.tld",
162+
"--domain_names_file",
163+
"foo.txt")))
164+
.hasMessageThat()
165+
.isEqualTo(
166+
"Must specify exactly one input method, either --domains or --domain_names_file");
167+
}
168+
}

core/src/test/resources/google/registry/module/routing.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ BACKEND /_dr/task/brdaCopy BrdaCopyAction
1717
BACKEND /_dr/task/bsaDownload BsaDownloadAction GET,POST n APP ADMIN
1818
BACKEND /_dr/task/bsaRefresh BsaRefreshAction GET,POST n APP ADMIN
1919
BACKEND /_dr/task/bsaValidate BsaValidateAction GET,POST n APP ADMIN
20+
BACKEND /_dr/task/bulkDomainTransfer BulkDomainTransferAction POST n APP ADMIN
2021
BACKEND /_dr/task/copyDetailReports CopyDetailReportsAction POST n APP ADMIN
2122
BACKEND /_dr/task/deleteExpiredDomains DeleteExpiredDomainsAction GET n APP ADMIN
2223
BACKEND /_dr/task/deleteLoadTestData DeleteLoadTestDataAction POST n APP ADMIN

0 commit comments

Comments
 (0)