diff --git a/java/google/registry/tools/RegistryTool.java b/java/google/registry/tools/RegistryTool.java
index 36ca98165..e9755ffc6 100644
--- a/java/google/registry/tools/RegistryTool.java
+++ b/java/google/registry/tools/RegistryTool.java
@@ -112,6 +112,7 @@ public final class RegistryTool {
.put("send_escrow_report_to_icann", SendEscrowReportToIcannCommand.class)
.put("setup_ote", SetupOteCommand.class)
.put("uniform_rapid_suspension", UniformRapidSuspensionCommand.class)
+ .put("unlock_domain", UnlockDomainCommand.class)
.put("update_application_status", UpdateApplicationStatusCommand.class)
.put("update_claims_notice", UpdateClaimsNoticeCommand.class)
.put("update_cursors", UpdateCursorsCommand.class)
diff --git a/java/google/registry/tools/UnlockDomainCommand.java b/java/google/registry/tools/UnlockDomainCommand.java
new file mode 100644
index 000000000..598814629
--- /dev/null
+++ b/java/google/registry/tools/UnlockDomainCommand.java
@@ -0,0 +1,78 @@
+// Copyright 2017 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 google.registry.model.EppResourceUtils.loadByForeignKey;
+import static google.registry.util.FormattingLogger.getLoggerForCallerClass;
+import static java.util.stream.Collectors.toList;
+import static org.joda.time.DateTimeZone.UTC;
+
+import com.beust.jcommander.Parameters;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.template.soy.data.SoyMapData;
+import google.registry.model.domain.DomainResource;
+import google.registry.model.eppcommon.StatusValue;
+import google.registry.tools.soy.DomainUpdateSoyInfo;
+import google.registry.util.FormattingLogger;
+import org.joda.time.DateTime;
+
+/**
+ * A command to registry unlock domain names via EPP.
+ *
+ *
A registry lock consists of server-side statuses preventing deletes, updates, and transfers.
+ */
+@Parameters(separators = " =", commandDescription = "Registry unlock a domain via EPP.")
+public class UnlockDomainCommand extends LockOrUnlockDomainCommand {
+
+ private static final FormattingLogger logger = getLoggerForCallerClass();
+
+ @Override
+ protected void initMutatingEppToolCommand() throws Exception {
+ // Project all domains as of the same time so that argument order doesn't affect behavior.
+ DateTime now = DateTime.now(UTC);
+ for (String domain : getDomains()) {
+ DomainResource domainResource = loadByForeignKey(DomainResource.class, domain, now);
+ checkArgument(domainResource != null, "Domain '%s' does not exist", domain);
+ ImmutableSet statusesToRemove =
+ Sets.intersection(domainResource.getStatusValues(), REGISTRY_LOCK_STATUSES)
+ .immutableCopy();
+ if (statusesToRemove.isEmpty()) {
+ logger.infofmt("Domain '%s' is already unlocked and needs no updates.", domain);
+ continue;
+ }
+
+ setSoyTemplate(DomainUpdateSoyInfo.getInstance(), DomainUpdateSoyInfo.DOMAINUPDATE);
+ addSoyRecord(
+ clientId,
+ new SoyMapData(
+ "domain", domain,
+ "add", false,
+ "addNameservers", ImmutableList.of(),
+ "addAdmins", ImmutableList.of(),
+ "addTechs", ImmutableList.of(),
+ "addStatuses", ImmutableList.of(),
+ "remove", true,
+ "removeNameservers", ImmutableList.of(),
+ "removeAdmins", ImmutableList.of(),
+ "removeTechs", ImmutableList.of(),
+ "removeStatuses",
+ statusesToRemove.stream().map(StatusValue::getXmlName).collect(toList()),
+ "change", false));
+ }
+ }
+}
diff --git a/javatests/google/registry/tools/UnlockDomainCommandTest.java b/javatests/google/registry/tools/UnlockDomainCommandTest.java
new file mode 100644
index 000000000..81949dbd7
--- /dev/null
+++ b/javatests/google/registry/tools/UnlockDomainCommandTest.java
@@ -0,0 +1,120 @@
+// Copyright 2017 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 google.registry.model.eppcommon.StatusValue.SERVER_DELETE_PROHIBITED;
+import static google.registry.model.eppcommon.StatusValue.SERVER_TRANSFER_PROHIBITED;
+import static google.registry.model.eppcommon.StatusValue.SERVER_UPDATE_PROHIBITED;
+import static google.registry.testing.DatastoreHelper.newDomainResource;
+import static google.registry.testing.DatastoreHelper.persistActiveDomain;
+import static google.registry.testing.DatastoreHelper.persistResource;
+import static google.registry.testing.JUnitBackports.expectThrows;
+import static google.registry.testing.TestDataHelper.applySubstitutions;
+import static google.registry.tools.server.ToolsTestData.loadUtf8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+
+/** Unit tests for {@link UnlockDomainCommand}. */
+public class UnlockDomainCommandTest extends EppToolCommandTestCase {
+
+ /** Gets an overridden eppVerifier that has superuser set to true on it. */
+ @Override
+ EppToolVerifier eppVerifier() {
+ return new EppToolVerifier()
+ .withConnection(connection)
+ .withClientId("NewRegistrar")
+ .asSuperuser();
+ }
+
+ private static void persistLockedDomain(String domainName) {
+ persistResource(
+ newDomainResource(domainName)
+ .asBuilder()
+ .addStatusValues(
+ ImmutableSet.of(
+ SERVER_DELETE_PROHIBITED, SERVER_TRANSFER_PROHIBITED, SERVER_UPDATE_PROHIBITED))
+ .build());
+ }
+
+ @Test
+ public void testSuccess_sendsCorrectEppXml() throws Exception {
+ persistLockedDomain("example.tld");
+ runCommandForced("--client=NewRegistrar", "example.tld");
+ eppVerifier()
+ .verifySentContents(
+ ImmutableList.of(
+ loadUtf8("domain_unlock.xml", ImmutableMap.of("DOMAIN", "example.tld"))));
+ }
+
+ @Test
+ public void testSuccess_partiallyUpdatesStatuses() throws Exception {
+ persistResource(
+ newDomainResource("example.tld")
+ .asBuilder()
+ .addStatusValues(ImmutableSet.of(SERVER_DELETE_PROHIBITED, SERVER_UPDATE_PROHIBITED))
+ .build());
+ runCommandForced("--client=NewRegistrar", "example.tld");
+ eppVerifier().verifySent("domain_unlock_partial_statuses.xml");
+ }
+
+ @Test
+ public void testSuccess_manyDomains() throws Exception {
+ List params = new ArrayList<>();
+ List expectedXmls = new ArrayList<>();
+ params.add("--client=NewRegistrar");
+ String updateDomainXml = loadUtf8("domain_unlock.xml");
+ // Create 26 domains -- one more than the number of entity groups allowed in a transaction (in
+ // case that was going to be the failure point).
+ for (int n = 0; n < 26; n++) {
+ String domain = String.format("domain%d.tld", n);
+ persistLockedDomain(domain);
+ params.add(domain);
+ expectedXmls.add(applySubstitutions(updateDomainXml, ImmutableMap.of("DOMAIN", domain)));
+ }
+ runCommandForced(params);
+ eppVerifier().verifySentContents(expectedXmls);
+ }
+
+ @Test
+ public void testFailure_domainDoesntExist() throws Exception {
+ IllegalArgumentException e =
+ expectThrows(
+ IllegalArgumentException.class,
+ () -> runCommandForced("--client=NewRegistrar", "missing.tld"));
+ assertThat(e).hasMessageThat().isEqualTo("Domain 'missing.tld' does not exist");
+ }
+
+ @Test
+ public void testSuccess_alreadyUnlockedDomain_performsNoAction() throws Exception {
+ persistActiveDomain("example.tld");
+ runCommandForced("--client=NewRegistrar", "example.tld");
+ eppVerifier().verifyNothingSent();
+ }
+
+ @Test
+ public void testFailure_duplicateDomainsAreSpecified() throws Exception {
+ IllegalArgumentException e =
+ expectThrows(
+ IllegalArgumentException.class,
+ () -> runCommandForced("--client=NewRegistrar", "dupe.tld", "dupe.tld"));
+ assertThat(e).hasMessageThat().isEqualTo("Duplicate domain arguments found: 'dupe.tld'");
+ }
+}
diff --git a/javatests/google/registry/tools/server/testdata/domain_unlock.xml b/javatests/google/registry/tools/server/testdata/domain_unlock.xml
new file mode 100644
index 000000000..f8be65aaf
--- /dev/null
+++ b/javatests/google/registry/tools/server/testdata/domain_unlock.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+ %DOMAIN%
+
+
+
+
+
+
+
+ RegistryTool
+
+
diff --git a/javatests/google/registry/tools/server/testdata/domain_unlock_partial_statuses.xml b/javatests/google/registry/tools/server/testdata/domain_unlock_partial_statuses.xml
new file mode 100644
index 000000000..d8454b7b3
--- /dev/null
+++ b/javatests/google/registry/tools/server/testdata/domain_unlock_partial_statuses.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+ example.tld
+
+
+
+
+
+
+ RegistryTool
+
+