diff --git a/core/src/main/java/google/registry/tools/EnqueuePollMessageCommand.java b/core/src/main/java/google/registry/tools/EnqueuePollMessageCommand.java
new file mode 100644
index 000000000..ccfc68c09
--- /dev/null
+++ b/core/src/main/java/google/registry/tools/EnqueuePollMessageCommand.java
@@ -0,0 +1,95 @@
+// Copyright 2021 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.model.reporting.HistoryEntry.Type.SYNTHETIC;
+import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
+
+import com.beust.jcommander.Parameter;
+import com.beust.jcommander.Parameters;
+import google.registry.model.domain.DomainBase;
+import google.registry.model.domain.DomainHistory;
+import google.registry.model.poll.PollMessage;
+import google.registry.model.reporting.HistoryEntry;
+import java.util.Optional;
+
+/**
+ * Tool to enqueue a poll message for a registrar.
+ *
+ *
The poll message in question must correspond to an existing domain owing to schema
+ * limitations, but does not necessarily need to correspond to the owner of that domain if an
+ * alternative clientId
is provided.
+ *
+ *
If general broadcast messages are being sent to registrars (e.g. a notice of an upcoming
+ * maintenance window), then it is recommended to use a well-known registry-owned domain for the
+ * enqueueing of these poll messages, such as the nic domain.
+ */
+@Parameters(separators = " =", commandDescription = "Enqueue a poll message for a domain")
+class EnqueuePollMessageCommand extends MutatingCommand {
+
+ @Parameter(
+ names = {"-m", "--message"},
+ description = "The poll message to enqueue",
+ required = true)
+ String message;
+
+ @Parameter(
+ names = {"-d", "--domain"},
+ description = "The domain name to enqueue the poll message for",
+ required = true)
+ String domainName;
+
+ @Parameter(
+ names = {"-c", "--client"},
+ description =
+ "Client identifier of the registrar to send the poll message to, if not the owning"
+ + " registrar of the domain")
+ String clientId;
+
+ @Override
+ protected final void init() {
+ tm().transact(
+ () -> {
+ Optional domainOpt =
+ loadByForeignKey(DomainBase.class, domainName, tm().getTransactionTime());
+ checkArgument(
+ domainOpt.isPresent(), "Domain %s doesn't exist or isn't active", domainName);
+ DomainBase domain = domainOpt.get();
+ String registrarId =
+ Optional.ofNullable(clientId).orElse(domain.getCurrentSponsorRegistrarId());
+ HistoryEntry historyEntry =
+ new DomainHistory.Builder()
+ .setDomain(domain)
+ .setType(SYNTHETIC)
+ .setBySuperuser(true)
+ .setReason("Manual enqueueing of poll message")
+ .setModificationTime(tm().getTransactionTime())
+ .setRequestedByRegistrar(false)
+ .setRegistrarId(registrarId)
+ .build();
+ PollMessage.OneTime pollMessage =
+ new PollMessage.OneTime.Builder()
+ .setRegistrarId(registrarId)
+ .setParent(historyEntry)
+ .setEventTime(tm().getTransactionTime())
+ .setMsg(message)
+ .build();
+ stageEntityChange(null, historyEntry);
+ stageEntityChange(null, pollMessage);
+ });
+ }
+}
diff --git a/core/src/main/java/google/registry/tools/MutatingCommand.java b/core/src/main/java/google/registry/tools/MutatingCommand.java
index 10c1d6d17..d088c6f62 100644
--- a/core/src/main/java/google/registry/tools/MutatingCommand.java
+++ b/core/src/main/java/google/registry/tools/MutatingCommand.java
@@ -103,7 +103,8 @@ public abstract class MutatingCommand extends ConfirmingCommand implements Comma
* workaround to handle cases when a SqlEntity instance does not have a primary key before being
* persisted.
*/
- private EntityChange(ImmutableObject oldEntity, ImmutableObject newEntity, VKey> vkey) {
+ private EntityChange(
+ @Nullable ImmutableObject oldEntity, @Nullable ImmutableObject newEntity, VKey> vkey) {
type = ChangeType.get(oldEntity != null, newEntity != null);
if (type == ChangeType.UPDATE) {
checkArgument(
@@ -127,7 +128,7 @@ public abstract class MutatingCommand extends ConfirmingCommand implements Comma
}
/** Returns a human-readable ID string for the entity being changed. */
- public String getEntityId() {
+ String getEntityId() {
return String.format(
"%s@%s",
key.getOfyKey().getKind(),
diff --git a/core/src/main/java/google/registry/tools/RegistryTool.java b/core/src/main/java/google/registry/tools/RegistryTool.java
index b7bd5886f..50ff73cbb 100644
--- a/core/src/main/java/google/registry/tools/RegistryTool.java
+++ b/core/src/main/java/google/registry/tools/RegistryTool.java
@@ -62,6 +62,7 @@ public final class RegistryTool {
.put("delete_reserved_list", DeleteReservedListCommand.class)
.put("delete_tld", DeleteTldCommand.class)
.put("encrypt_escrow_deposit", EncryptEscrowDepositCommand.class)
+ .put("enqueue_poll_message", EnqueuePollMessageCommand.class)
.put("execute_epp", ExecuteEppCommand.class)
.put("generate_allocation_tokens", GenerateAllocationTokensCommand.class)
.put("generate_dns_report", GenerateDnsReportCommand.class)
diff --git a/core/src/main/java/google/registry/tools/RegistryToolComponent.java b/core/src/main/java/google/registry/tools/RegistryToolComponent.java
index eb8f46278..ebb02a72b 100644
--- a/core/src/main/java/google/registry/tools/RegistryToolComponent.java
+++ b/core/src/main/java/google/registry/tools/RegistryToolComponent.java
@@ -111,6 +111,8 @@ interface RegistryToolComponent {
void inject(EncryptEscrowDepositCommand command);
+ void inject(EnqueuePollMessageCommand command);
+
void inject(GenerateAllocationTokensCommand command);
void inject(GenerateDnsReportCommand command);
diff --git a/core/src/test/java/google/registry/tools/EnqueuePollMessageCommandTest.java b/core/src/test/java/google/registry/tools/EnqueuePollMessageCommandTest.java
new file mode 100644
index 000000000..5c1817149
--- /dev/null
+++ b/core/src/test/java/google/registry/tools/EnqueuePollMessageCommandTest.java
@@ -0,0 +1,134 @@
+// Copyright 2021 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.reporting.HistoryEntry.Type.SYNTHETIC;
+import static google.registry.testing.DatabaseHelper.assertPollMessages;
+import static google.registry.testing.DatabaseHelper.createTld;
+import static google.registry.testing.DatabaseHelper.getOnlyHistoryEntryOfType;
+import static google.registry.testing.DatabaseHelper.persistActiveDomain;
+import static google.registry.testing.HistoryEntrySubject.assertAboutHistoryEntries;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.beust.jcommander.ParameterException;
+import google.registry.model.domain.DomainBase;
+import google.registry.model.ofy.Ofy;
+import google.registry.model.poll.PollMessage;
+import google.registry.model.reporting.HistoryEntry;
+import google.registry.testing.DualDatabaseTest;
+import google.registry.testing.InjectExtension;
+import google.registry.testing.TestOfyAndSql;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/** Unit tests for {@link EnqueuePollMessageCommand}. */
+@DualDatabaseTest
+class EnqueuePollMessageCommandTest extends CommandTestCase {
+
+ @RegisterExtension final InjectExtension inject = new InjectExtension();
+
+ private DomainBase domain;
+
+ @BeforeEach
+ void beforeEach() {
+ createTld("tld");
+ inject.setStaticField(Ofy.class, "clock", fakeClock);
+ domain = persistActiveDomain("example.tld");
+ fakeClock.advanceOneMilli();
+ }
+
+ @TestOfyAndSql
+ void testSuccess_domainAndMessage() throws Exception {
+ runCommandForced("--domain=example.tld", "--message=This domain is bad");
+
+ HistoryEntry synthetic = getOnlyHistoryEntryOfType(domain, SYNTHETIC);
+ assertAboutHistoryEntries()
+ .that(synthetic)
+ .bySuperuser(true)
+ .and()
+ .hasMetadataReason("Manual enqueueing of poll message")
+ .and()
+ .hasNoXml()
+ .and()
+ .hasRegistrarId("TheRegistrar")
+ .and()
+ .hasModificationTime(fakeClock.nowUtc())
+ .and()
+ .hasMetadataRequestedByRegistrar(false);
+ assertPollMessages(
+ "TheRegistrar",
+ new PollMessage.OneTime.Builder()
+ .setParent(synthetic)
+ .setMsg("This domain is bad")
+ .setRegistrarId("TheRegistrar")
+ .setEventTime(fakeClock.nowUtc())
+ .build());
+ }
+
+ @TestOfyAndSql
+ void testSuccess_specifyClientId() throws Exception {
+ runCommandForced(
+ "--domain=example.tld", "--message=This domain needs work", "--client=NewRegistrar");
+
+ HistoryEntry synthetic = getOnlyHistoryEntryOfType(domain, SYNTHETIC);
+ assertAboutHistoryEntries()
+ .that(synthetic)
+ .bySuperuser(true)
+ .and()
+ .hasMetadataReason("Manual enqueueing of poll message")
+ .and()
+ .hasNoXml()
+ .and()
+ .hasRegistrarId("NewRegistrar")
+ .and()
+ .hasModificationTime(fakeClock.nowUtc())
+ .and()
+ .hasMetadataRequestedByRegistrar(false);
+ assertPollMessages(
+ "NewRegistrar",
+ new PollMessage.OneTime.Builder()
+ .setParent(synthetic)
+ .setMsg("This domain needs work")
+ .setRegistrarId("NewRegistrar")
+ .setEventTime(fakeClock.nowUtc())
+ .build());
+ }
+
+ @TestOfyAndSql
+ void testNonexistentDomain() throws Exception {
+ IllegalArgumentException thrown =
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> runCommandForced("--domain=example2.tld", "--message=This domain needs help"));
+ assertThat(thrown)
+ .hasMessageThat()
+ .isEqualTo("Domain example2.tld doesn't exist or isn't active");
+ }
+
+ @TestOfyAndSql
+ void testDomainIsRequired() {
+ ParameterException thrown =
+ assertThrows(ParameterException.class, () -> runCommandForced("--message=Foo bar"));
+ assertThat(thrown).hasMessageThat().contains("The following option is required: -d, --domain");
+ }
+
+ @TestOfyAndSql
+ void testMessageIsRequired() {
+ ParameterException thrown =
+ assertThrows(ParameterException.class, () -> runCommandForced("--domain=example.tld"));
+ assertThat(thrown).hasMessageThat().contains("The following option is required: -m, --message");
+ }
+}