diff --git a/java/google/registry/tools/CommandRunner.java b/java/google/registry/tools/CommandRunner.java new file mode 100644 index 000000000..e82237a8e --- /dev/null +++ b/java/google/registry/tools/CommandRunner.java @@ -0,0 +1,24 @@ +// Copyright 2018 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; + +/** + * Interface for things that run commands. + * + *

This exists only to allow us to test ShellCommand. + */ +interface CommandRunner { + public void run(String[] args) throws Exception; +} diff --git a/java/google/registry/tools/RegistryCli.java b/java/google/registry/tools/RegistryCli.java index 1c6975f56..89ad73d2e 100644 --- a/java/google/registry/tools/RegistryCli.java +++ b/java/google/registry/tools/RegistryCli.java @@ -35,7 +35,7 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider; /** Container class to create and run remote commands against a Datastore instance. */ @Parameters(separators = " =", commandDescription = "Command-line interface to the registry") -final class RegistryCli { +final class RegistryCli implements AutoCloseable, CommandRunner { @Parameter( names = {"-e", "--environment"}, @@ -57,33 +57,54 @@ final class RegistryCli { @ParametersDelegate private LoggingParameters loggingParams = new LoggingParameters(); - // The > wildcard looks a little funny, but is needed so that - // we can accept maps with value types that are subtypes of Class rather than - // literally that type. For more explanation, see: - // http://www.angelikalanger.com/GenericsFAQ/FAQSections/TypeArguments.html#FAQ104 - void run( - String programName, - String[] args, - ImmutableMap> commands) throws Exception { + // These are created lazily on first use. + private AppEngineConnection connection; + private RemoteApiInstaller installer; + + + Map commandInstances; + Map> commands; + JCommander jcommander; + + RegistryCli( + String programName, ImmutableMap> commands) { + this.commands = commands; + Security.addProvider(new BouncyCastleProvider()); - JCommander jcommander = new JCommander(this); + jcommander = new JCommander(this); jcommander.addConverterFactory(new ParameterFactory()); jcommander.setProgramName(programName); // Store the instances of each Command class here so we can retrieve the same one for the // called command later on. JCommander could have done this for us, but it doesn't. - Map commandInstances = new HashMap<>(); + commandInstances = new HashMap<>(); HelpCommand helpCommand = new HelpCommand(jcommander); jcommander.addCommand("help", helpCommand); commandInstances.put("help", helpCommand); - for (Map.Entry> entry : commands.entrySet()) { - Command command = entry.getValue().getDeclaredConstructor().newInstance(); - jcommander.addCommand(entry.getKey(), command); - commandInstances.put(entry.getKey(), command); - } + // Add the shell command. + ShellCommand shellCommand = new ShellCommand(System.in, this); + jcommander.addCommand("shell", shellCommand); + commandInstances.put("shell", shellCommand); + try { + for (Map.Entry> entry : commands.entrySet()) { + Command command = entry.getValue().getDeclaredConstructor().newInstance(); + jcommander.addCommand(entry.getKey(), command); + commandInstances.put(entry.getKey(), command); + } + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } + + // The > wildcard looks a little funny, but is needed so that + // we can accept maps with value types that are subtypes of Class rather than + // literally that type. For more explanation, see: + // http://www.angelikalanger.com/GenericsFAQ/FAQSections/TypeArguments.html#FAQ104 + @Override + public void run(String[] args) throws Exception { try { jcommander.parse(args); } catch (ParameterException e) { @@ -127,6 +148,14 @@ final class RegistryCli { } } + @Override + public void close() { + if (installer != null) { + installer.uninstall(); + installer = null; + } + } + private void runCommand(Command command) throws Exception { // Create the main component and use it to inject the command class. RegistryToolComponent component = DaggerRegistryToolComponent.builder() @@ -142,31 +171,35 @@ final class RegistryCli { } // Get the App Engine connection, advise the user if they are not currently logged in.. - AppEngineConnection connection = component.appEngineConnection(); + if (connection == null) { + connection = component.appEngineConnection(); + } if (command instanceof ServerSideCommand) { ((ServerSideCommand) command).setConnection(connection); } // RemoteApiCommands need to have the remote api installed to work. - RemoteApiInstaller installer = new RemoteApiInstaller(); - RemoteApiOptions options = new RemoteApiOptions(); - options.server(connection.getServer().getHost(), connection.getServer().getPort()); - if (connection.isLocalhost()) { - // Use dev credentials for localhost. - options.useDevelopmentServerCredential(); - } else { - options.useApplicationDefaultCredential(); + if (installer == null) { + installer = new RemoteApiInstaller(); + RemoteApiOptions options = new RemoteApiOptions(); + options.server(connection.getServer().getHost(), connection.getServer().getPort()); + if (connection.isLocalhost()) { + // Use dev credentials for localhost. + options.useDevelopmentServerCredential(); + } else { + options.useApplicationDefaultCredential(); + } + installer.install(options); } - installer.install(options); // Ensure that all entity classes are loaded before command code runs. ObjectifyService.initOfy(); - try { - command.run(); - } finally { - installer.uninstall(); - } + command.run(); + } + + void setEnvironment(RegistryToolEnvironment environment) { + this.environment = environment; } } diff --git a/java/google/registry/tools/RegistryTool.java b/java/google/registry/tools/RegistryTool.java index f10044307..5fe576aa5 100644 --- a/java/google/registry/tools/RegistryTool.java +++ b/java/google/registry/tools/RegistryTool.java @@ -132,6 +132,8 @@ public final class RegistryTool { public static void main(String[] args) throws Exception { RegistryToolEnvironment.parseFromArgs(args).setup(); - new RegistryCli().run("nomulus", args, COMMAND_MAP); + try (RegistryCli cli = new RegistryCli("nomulus", COMMAND_MAP)) { + cli.run(args); + } } } diff --git a/java/google/registry/tools/ShellCommand.java b/java/google/registry/tools/ShellCommand.java new file mode 100644 index 000000000..0e8f3838b --- /dev/null +++ b/java/google/registry/tools/ShellCommand.java @@ -0,0 +1,93 @@ +// Copyright 2018 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 java.nio.charset.StandardCharsets.US_ASCII; + +import com.beust.jcommander.Parameters; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.StreamTokenizer; +import java.io.StringReader; + +/** + * Implements a tiny shell interpreter for the nomulus tool. + * + *

Parses a very simple command grammar. Tokens are either whitespace delimited words or + * double-quoted strings. + */ +@Parameters(commandDescription = "Run an interactive shell") +public class ShellCommand implements Command { + + private final CommandRunner runner; + private final BufferedReader lineReader; + + ShellCommand(InputStream in, CommandRunner runner) { + this.runner = runner; + this.lineReader = new BufferedReader(new InputStreamReader(in, US_ASCII)); + } + + /** Run the shell until the user presses "Ctrl-D". */ + @Override + public void run() { + String line; + while ((line = getLine()) != null) { + String[] lineArgs = parseCommand(line); + try { + runner.run(lineArgs); + } catch (Exception e) { + System.err.println("Got an exception:\n" + e); + } + } + } + + private String getLine() { + if (System.console() != null) { + System.err.print("nom> "); + } + try { + return lineReader.readLine(); + } catch (IOException e) { + return null; + } + } + + @VisibleForTesting + static String[] parseCommand(String line) { + ImmutableList.Builder resultBuilder = new ImmutableList.Builder<>(); + + // Create a tokenizer, make everything word characters except quoted strings and unprintable + // ascii chars and space (just treat them all as whitespace). + StreamTokenizer tokenizer = new StreamTokenizer(new StringReader(line)); + tokenizer.resetSyntax(); + tokenizer.whitespaceChars(0, ' '); + tokenizer.wordChars('!', '~'); + tokenizer.quoteChar('"'); + + try { + while (tokenizer.nextToken() != StreamTokenizer.TT_EOF) { + resultBuilder.add(tokenizer.sval); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + + return resultBuilder.build().toArray(new String[0]); + } +} diff --git a/javatests/google/registry/tools/RegistryToolTest.java b/javatests/google/registry/tools/RegistryToolTest.java index 96b98b6fa..68204dcf3 100644 --- a/javatests/google/registry/tools/RegistryToolTest.java +++ b/javatests/google/registry/tools/RegistryToolTest.java @@ -90,8 +90,8 @@ public class RegistryToolTest { /** * Gets the set of all non-abstract classes implementing the {@link Command} interface (abstract * class and interface subtypes of Command aren't expected to have cli commands). Note that this - * also filters out HelpCommand, which has special handling in {@link RegistryCli} and isn't in - * the command map. + * also filters out HelpCommand and ShellCommand, which have special handling in {@link + * RegistryCli} and aren't in the command map. * * @throws IOException if reading the classpath resources fails. */ @@ -105,7 +105,8 @@ public class RegistryToolTest { if (Command.class.isAssignableFrom(clazz) && !Modifier.isAbstract(clazz.getModifiers()) && !Modifier.isInterface(clazz.getModifiers()) - && !clazz.equals(HelpCommand.class)) { + && !clazz.equals(HelpCommand.class) + && !clazz.equals(ShellCommand.class)) { builder.add((Class) clazz); } } diff --git a/javatests/google/registry/tools/ShellCommandTest.java b/javatests/google/registry/tools/ShellCommandTest.java new file mode 100644 index 000000000..76581f5f7 --- /dev/null +++ b/javatests/google/registry/tools/ShellCommandTest.java @@ -0,0 +1,68 @@ +// Copyright 2018 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.US_ASCII; +import static org.mockito.Mockito.mock; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.io.ByteArrayInputStream; +import java.util.ArrayList; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class ShellCommandTest { + + CommandRunner cli = mock(CommandRunner.class); + + public ShellCommandTest() {} + + @Test + public void testParsing() { + assertThat(ShellCommand.parseCommand("foo bar 123 baz+ // comment \"string data\"")) + .isEqualTo(new String[] {"foo", "bar", "123", "baz+", "//", "comment", "string data"}); + assertThat(ShellCommand.parseCommand("\"got \\\" escapes?\"")) + .isEqualTo(new String[] {"got \" escapes?"}); + assertThat(ShellCommand.parseCommand("")).isEqualTo(new String[0]); + } + + @Test + public void testCommandProcessing() { + String testData = "test1 foo bar\ntest2 foo bar\n"; + ImmutableMap> commandMap = ImmutableMap.of(); + MockCli cli = new MockCli(); + ShellCommand shellCommand = + new ShellCommand(new ByteArrayInputStream(testData.getBytes(US_ASCII)), cli); + shellCommand.run(); + assertThat(cli.calls) + .containsExactly( + ImmutableList.of("test1", "foo", "bar"), ImmutableList.of("test2", "foo", "bar")) + .inOrder(); + } + + static class MockCli implements CommandRunner { + public ArrayList> calls = new ArrayList<>(); + + @Override + public void run(String[] args) + throws Exception { + calls.add(ImmutableList.copyOf(args)); + } + } +}