Skip to content

Commit 5e09a35

Browse files
committed
Add a BulkDomainTransferAction
This will be necessary if we wish to do larger BTAPPA transfers (or other types of transfers, I suppose). The nomulus command-line tool is not fast enough to quickly transfer thousands of domains within a reasonable timeframe.
1 parent eed1886 commit 5e09a35

File tree

4 files changed

+437
-5
lines changed

4 files changed

+437
-5
lines changed

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

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,20 @@
2828
import static google.registry.request.RequestParameters.extractRequiredParameter;
2929
import static google.registry.request.RequestParameters.extractSetOfDatetimeParameters;
3030

31+
import com.google.common.collect.ImmutableList;
3132
import com.google.common.collect.ImmutableSet;
3233
import com.google.common.util.concurrent.RateLimiter;
34+
import com.google.gson.Gson;
35+
import com.google.gson.JsonElement;
36+
import com.google.gson.reflect.TypeToken;
3337
import dagger.Module;
3438
import dagger.Provides;
39+
import google.registry.request.HttpException.BadRequestException;
40+
import google.registry.request.OptionalJsonPayload;
3541
import google.registry.request.Parameter;
3642
import jakarta.inject.Named;
3743
import jakarta.servlet.http.HttpServletRequest;
44+
import java.util.List;
3845
import java.util.Optional;
3946
import org.joda.time.DateTime;
4047

@@ -44,6 +51,8 @@ public class BatchModule {
4451

4552
public static final String PARAM_FAST = "fast";
4653

54+
static final int DEFAULT_MAX_QPS = 10;
55+
4756
@Provides
4857
@Parameter("url")
4958
static String provideUrl(HttpServletRequest req) {
@@ -140,17 +149,49 @@ static boolean provideIsFast(HttpServletRequest req) {
140149
return extractBooleanParameter(req, PARAM_FAST);
141150
}
142151

143-
private static final int DEFAULT_MAX_QPS = 10;
144-
145152
@Provides
146153
@Parameter("maxQps")
147154
static int provideMaxQps(HttpServletRequest req) {
148155
return extractOptionalIntParameter(req, "maxQps").orElse(DEFAULT_MAX_QPS);
149156
}
150157

151158
@Provides
152-
@Named("removeAllDomainContacts")
153-
static RateLimiter provideRemoveAllDomainContactsRateLimiter(@Parameter("maxQps") int maxQps) {
159+
@Named("standardRateLimiter")
160+
static RateLimiter provideStandardRateLimiter(@Parameter("maxQps") int maxQps) {
154161
return RateLimiter.create(maxQps);
155162
}
163+
164+
@Provides
165+
@Parameter("gainingRegistrarId")
166+
static String provideGainingRegistrarId(HttpServletRequest req) {
167+
return extractRequiredParameter(req, "gainingRegistrarId");
168+
}
169+
170+
@Provides
171+
@Parameter("losingRegistrarId")
172+
static String provideLosingRegistrarId(HttpServletRequest req) {
173+
return extractRequiredParameter(req, "losingRegistrarId");
174+
}
175+
176+
@Provides
177+
@Parameter("bulkTransferDomainNames")
178+
static ImmutableList<String> provideBulkTransferDomainNames(
179+
Gson gson, @OptionalJsonPayload Optional<JsonElement> optionalJsonElement) {
180+
return optionalJsonElement
181+
.map(je -> ImmutableList.copyOf(gson.fromJson(je, new TypeToken<List<String>>() {})))
182+
.orElseThrow(
183+
() -> new BadRequestException("Missing POST body of bulk transfer domain names"));
184+
}
185+
186+
@Provides
187+
@Parameter("requestedByRegistrar")
188+
static boolean provideRequestedByRegistrar(HttpServletRequest req) {
189+
return extractBooleanParameter(req, "requestedByRegistrar");
190+
}
191+
192+
@Provides
193+
@Parameter("reason")
194+
static String provideReason(HttpServletRequest req) {
195+
return extractRequiredParameter(req, "reason");
196+
}
156197
}
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
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.batch;
16+
17+
import static com.google.common.net.MediaType.PLAIN_TEXT_UTF_8;
18+
import static google.registry.flows.FlowUtils.marshalWithLenientRetry;
19+
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
20+
import static jakarta.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
21+
import static jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT;
22+
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
23+
import static java.nio.charset.StandardCharsets.US_ASCII;
24+
25+
import com.google.common.collect.ImmutableList;
26+
import com.google.common.flogger.FluentLogger;
27+
import com.google.common.util.concurrent.RateLimiter;
28+
import google.registry.flows.EppController;
29+
import google.registry.flows.EppRequestSource;
30+
import google.registry.flows.PasswordOnlyTransportCredentials;
31+
import google.registry.flows.StatelessRequestSessionMetadata;
32+
import google.registry.model.ForeignKeyUtils;
33+
import google.registry.model.domain.Domain;
34+
import google.registry.model.eppcommon.ProtocolDefinition;
35+
import google.registry.model.eppcommon.StatusValue;
36+
import google.registry.model.eppoutput.EppOutput;
37+
import google.registry.request.Action;
38+
import google.registry.request.Parameter;
39+
import google.registry.request.Response;
40+
import google.registry.request.auth.Auth;
41+
import google.registry.request.lock.LockHandler;
42+
import google.registry.util.DateTimeUtils;
43+
import jakarta.inject.Inject;
44+
import jakarta.inject.Named;
45+
import java.util.Optional;
46+
import java.util.concurrent.Callable;
47+
import java.util.logging.Level;
48+
import org.joda.time.Duration;
49+
50+
/**
51+
* An action that transfers a set of domains from one registrar to another.
52+
*
53+
* <p>This should be used as part of the BTAPPA (Bulk Transfer After a Partial Portfolio
54+
* Acquisition) process in order to transfer a (possibly large) list of domains from one registrar
55+
* to another, though it may be used in other situations as well.
56+
*
57+
* <p>This runs as a single-threaded idempotent action that runs a superuser domain transfer on each
58+
* domain to process. We go through the standard EPP process to make sure that we have an accurate
59+
* historical representation of events (rather than force-modifying the domains in place).
60+
*
61+
* <p>Because the list of domains to process can be quite large, this action should be called by a
62+
* tool that batches the list of domains into reasonable sizes.
63+
*
64+
* <p>Consider passing in an "maxQps" parameter based on the number of domains being transferred,
65+
* otherwise the default is {@link BatchModule#DEFAULT_MAX_QPS}.
66+
*/
67+
@Action(
68+
service = Action.Service.BACKEND,
69+
path = BulkDomainTransferAction.PATH,
70+
method = Action.Method.POST,
71+
auth = Auth.AUTH_ADMIN)
72+
public class BulkDomainTransferAction implements Runnable {
73+
74+
public static final String PATH = "/_dr/task/bulkDomainTransfer";
75+
76+
private static final String SUPERUSER_TRANSFER_XML_FORMAT =
77+
"""
78+
<epp xmlns="urn:ietf:params:xml:ns:epp-1.0">
79+
<command>
80+
<transfer op="request">
81+
<domain:transfer xmlns:domain="urn:ietf:params:xml:ns:domain-1.0">
82+
<domain:name>%DOMAIN_NAME%</domain:name>
83+
</domain:transfer>
84+
</transfer>
85+
<extension>
86+
<superuser:domainTransferRequest xmlns:superuser="urn:google:params:xml:ns:superuser-1.0">
87+
<superuser:renewalPeriod unit="y">0</superuser:renewalPeriod>
88+
<superuser:automaticTransferLength>0</superuser:automaticTransferLength>
89+
</superuser:domainTransferRequest>
90+
<metadata:metadata xmlns:metadata="urn:google:params:xml:ns:metadata-1.0">
91+
<metadata:reason>%REASON%</metadata:reason>
92+
<metadata:requestedByRegistrar>%REQUESTED_BY_REGISTRAR%</metadata:requestedByRegistrar>
93+
</metadata:metadata>
94+
</extension>
95+
<clTRID>BulkDomainTransferAction</clTRID>
96+
</command>
97+
</epp>
98+
""";
99+
100+
private static final String LOCK_NAME = "Domain bulk transfer";
101+
102+
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
103+
104+
private final EppController eppController;
105+
private final LockHandler lockHandler;
106+
private final RateLimiter rateLimiter;
107+
private final ImmutableList<String> bulkTransferDomainNames;
108+
private final String gainingRegistrarId;
109+
private final String losingRegistrarId;
110+
private final boolean requestedByRegistrar;
111+
private final String reason;
112+
private final Response response;
113+
114+
private int successes = 0;
115+
private int alreadyTransferred = 0;
116+
private int pendingDelete = 0;
117+
private int missingDomains = 0;
118+
private int errors = 0;
119+
120+
@Inject
121+
BulkDomainTransferAction(
122+
EppController eppController,
123+
LockHandler lockHandler,
124+
@Named("standardRateLimiter") RateLimiter rateLimiter,
125+
@Parameter("bulkTransferDomainNames") ImmutableList<String> bulkTransferDomainNames,
126+
@Parameter("gainingRegistrarId") String gainingRegistrarId,
127+
@Parameter("losingRegistrarId") String losingRegistrarId,
128+
@Parameter("requestedByRegistrar") boolean requestedByRegistrar,
129+
@Parameter("reason") String reason,
130+
Response response) {
131+
this.eppController = eppController;
132+
this.lockHandler = lockHandler;
133+
this.rateLimiter = rateLimiter;
134+
this.bulkTransferDomainNames = bulkTransferDomainNames;
135+
this.gainingRegistrarId = gainingRegistrarId;
136+
this.losingRegistrarId = losingRegistrarId;
137+
this.requestedByRegistrar = requestedByRegistrar;
138+
this.reason = reason;
139+
this.response = response;
140+
}
141+
142+
@Override
143+
public void run() {
144+
response.setContentType(PLAIN_TEXT_UTF_8);
145+
Callable<Void> runner =
146+
() -> {
147+
try {
148+
runLocked();
149+
response.setStatus(SC_OK);
150+
} catch (Exception e) {
151+
logger.atSevere().withCause(e).log("Errored out during execution.");
152+
response.setStatus(SC_INTERNAL_SERVER_ERROR);
153+
response.setPayload(String.format("Errored out with cause: %s", e));
154+
}
155+
return null;
156+
};
157+
158+
if (!lockHandler.executeWithLocks(runner, null, Duration.standardHours(1), LOCK_NAME)) {
159+
// Send a 200-series status code to prevent this conflicting action from retrying.
160+
response.setStatus(SC_NO_CONTENT);
161+
response.setPayload("Could not acquire lock; already running?");
162+
}
163+
}
164+
165+
private void runLocked() {
166+
logger.atInfo().log("Attempting to transfer %d domains.", bulkTransferDomainNames.size());
167+
for (String domainName : bulkTransferDomainNames) {
168+
rateLimiter.acquire();
169+
tm().transact(() -> runTransferFlowInTransaction(domainName));
170+
}
171+
172+
String msg =
173+
String.format(
174+
"Finished; %d domains were successfully transferred, %d were previously transferred, %s"
175+
+ " were missing domains, %s are pending delete, and %d errored out.",
176+
successes, alreadyTransferred, missingDomains, pendingDelete, errors);
177+
logger.at(errors + missingDomains == 0 ? Level.INFO : Level.WARNING).log(msg);
178+
response.setPayload(msg);
179+
}
180+
181+
private void runTransferFlowInTransaction(String domainName) {
182+
if (shouldSkipDomain(domainName)) {
183+
return;
184+
}
185+
String xml =
186+
SUPERUSER_TRANSFER_XML_FORMAT
187+
.replace("%DOMAIN_NAME%", domainName)
188+
.replace("%REASON%", reason)
189+
.replace("%REQUESTED_BY_REGISTRAR%", String.valueOf(requestedByRegistrar));
190+
EppOutput output =
191+
eppController.handleEppCommand(
192+
new StatelessRequestSessionMetadata(
193+
gainingRegistrarId, ProtocolDefinition.getVisibleServiceExtensionUris()),
194+
new PasswordOnlyTransportCredentials(),
195+
EppRequestSource.TOOL,
196+
false,
197+
true,
198+
xml.getBytes(US_ASCII));
199+
if (output.isSuccess()) {
200+
logger.atInfo().log("Successfully transferred domain '%s'.", domainName);
201+
successes++;
202+
} else {
203+
logger.atWarning().log(
204+
"Failed transferring domain '%s' with error '%s'.",
205+
domainName, new String(marshalWithLenientRetry(output), US_ASCII));
206+
errors++;
207+
}
208+
}
209+
210+
private boolean shouldSkipDomain(String domainName) {
211+
Optional<Domain> maybeDomain =
212+
ForeignKeyUtils.loadResource(Domain.class, domainName, tm().getTransactionTime());
213+
if (maybeDomain.isEmpty()) {
214+
logger.atWarning().log("Domain '%s' was already deleted", domainName);
215+
missingDomains++;
216+
return true;
217+
}
218+
Domain domain = maybeDomain.get();
219+
String currentRegistrarId = domain.getCurrentSponsorRegistrarId();
220+
if (currentRegistrarId.equals(gainingRegistrarId)) {
221+
logger.atInfo().log("Domain '%s' was already transferred", domainName);
222+
alreadyTransferred++;
223+
return true;
224+
}
225+
if (!currentRegistrarId.equals(losingRegistrarId)) {
226+
logger.atWarning().log(
227+
"Domain '%s' had unexpected registrar '%s'", domainName, currentRegistrarId);
228+
errors++;
229+
return true;
230+
}
231+
if (domain.getStatusValues().contains(StatusValue.PENDING_DELETE)
232+
|| !domain.getDeletionTime().equals(DateTimeUtils.END_OF_TIME)) {
233+
logger.atWarning().log("Domain '%s' is in PENDING_DELETE", domainName);
234+
pendingDelete++;
235+
return true;
236+
}
237+
return false;
238+
}
239+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ public class RemoveAllDomainContactsAction implements Runnable {
9393
EppController eppController,
9494
@Config("registryAdminClientId") String registryAdminClientId,
9595
LockHandler lockHandler,
96-
@Named("removeAllDomainContacts") RateLimiter rateLimiter,
96+
@Named("standardRateLimiter") RateLimiter rateLimiter,
9797
Response response) {
9898
this.eppController = eppController;
9999
this.registryAdminClientId = registryAdminClientId;

0 commit comments

Comments
 (0)