Start a Gfsh Command

  1. Your command class needs to be stateless, no member variables are allowed.
  2. Your command class needs to extends GfshCommand, which implements spring shell's CommandMarker. We need this to know that this is a command class. GfshCommand also provides a lot of convenience methods for you to interact with the cluster.
  3. Avoid using static methods in your command. If the functionality is general enough, consider adding it to GfshCommand so that other commands can use it as well, and it provides easy mocking for testing.

Example:

public class CreateIndexCommand extends GfshCommand {
  private static final CreateIndexFunction createIndexFunction = new CreateIndexFunction();

  @CliCommand(value = CliStrings.CREATE_INDEX, help = CliStrings.CREATE_INDEX__HELP)
  @CliMetaData(relatedTopic = {CliStrings.TOPIC_GEODE_REGION, CliStrings.TOPIC_GEODE_DATA})
  @ResourceOperation(resource = ResourcePermission.Resource.CLUSTER,
      operation = ResourcePermission.Operation.MANAGE, target = ResourcePermission.Target.QUERY)
  public Result createIndex(@CliOption(key = CliStrings.CREATE_INDEX__NAME, mandatory = true,
      help = CliStrings.CREATE_INDEX__NAME__HELP) final String indexName,

      @CliOption(key = CliStrings.CREATE_INDEX__EXPRESSION, mandatory = true,
          help = CliStrings.CREATE_INDEX__EXPRESSION__HELP) final String indexedExpression,

      @CliOption(key = CliStrings.CREATE_INDEX__REGION, mandatory = true,
          optionContext = ConverterHint.REGION_PATH,
          help = CliStrings.CREATE_INDEX__REGION__HELP) String regionPath,

      @CliOption(key = {CliStrings.MEMBER, CliStrings.MEMBERS},
          optionContext = ConverterHint.MEMBERIDNAME,
          help = CliStrings.CREATE_INDEX__MEMBER__HELP) final String[] memberNameOrID,

      @CliOption(key = CliStrings.CREATE_INDEX__TYPE, unspecifiedDefaultValue = "range",
          optionContext = ConverterHint.INDEX_TYPE,
          help = CliStrings.CREATE_INDEX__TYPE__HELP) final IndexType indexType,

      @CliOption(key = {CliStrings.GROUP, CliStrings.GROUPS},
          optionContext = ConverterHint.MEMBERGROUP,
          help = CliStrings.CREATE_INDEX__GROUP__HELP) final String[] group) {

    authorize(CLUSTER, MANAGE, QUERY);
    final Set<DistributedMember> targetMembers = findMembers(group, memberNameOrID);

    if (targetMembers.isEmpty()) {
      return ResultBuilder.createUserErrorResult(CliStrings.NO_MEMBERS_FOUND_MESSAGE);
    }

    IndexInfo indexInfo = new IndexInfo(indexName, indexedExpression, regionPath, indexType);
    List<CliFunctionResult> functionResults =
        executeAndGetFunctionResult(createIndexFunction, indexInfo, targetMembers);
    return ResultBuilder.buildResult(functionResults);
  }
}

 

Option Validation

A command usually needs to validate it's options. There are three ways to do validations. One is provided by the CliOption to mark an option as mandatory, one is to use Converters, and the other one is to use Interceptors. These are all done in the Gfsh client vm. It's best to do validation in these places other than putting the validation logic inside the command execution which is executed on the locator. 

CliOption 

CliOption allows you to specify if an option is mandatory or not. If a parameter is marked as mandatory then the command will fail if this option is not specified.

@CliOption(key = "name", mandatory = true) String region


Converters

Converters are the way for you to manipulate value input before you feed it into your command class. You can use them to convert and validate user input and provide hints for auto-completion. Gfsh provides commonly used converters that would convert strings to boolean, int, long, String[] (comma separated), enum, Date, File etc. These converters also serves validation purposes when converting the values. For example, below is an option specified in "test "command. If a command is "test --count=abc", then this command will fail the parser validation, because the parser can not convert "abc" into an integer.

@CliOption(key = "count") int count

Converters is a way to provide commonly-used validation that can be shared across multiple commands. E.g. a file type converter can validate if the filename is in the correct format and then converts it to File type. If we have such converters handy, all commands that needs a file option can use it and get the benefit of free validation. If you find yourself trying to validate a common type, consider using a converter.

Usually you can tell the parser to use a converter by just specifying your parameter type. If there is only one converter available for that type, then the parser will use that to convert your value, but if there are multiple converters available, then it's indeterministic as to which one parser will use. To bypass this ambiguity, you will need to use optionContext in @CliOption to narrow down which converter to use.  e.g. below option specified an optionContext string:

@CliOption(key = "property-file", optionContext = ConverterHint.FILE) File propertyFile

Then in the converter implementation, you will need to use that string when determining if this converter can be used for that option or not:

@Override
public boolean supports(Class<?> type, String optionContext) {
  return File.class.equals(type) && optionContext.contains(ConverterHint.FILE);
}

Interceptors

Interceptors are generally used when your validate include multiple options, e.g validate mutual exclusivity. Here is an example of a command that has interceptors. You can see it's validating that user can not specify both options in this command.

public class MyCommand implements GfshCommand {
  @CliCommand(value = "my-command")
  @CliMetaData(interceptor = "org.apache.geode.management.internal.cli.commands.MyCommand$Interceptor"
  public Result execute(
	@CliOption(key = "groups") String[] groups,
	@CliOption(key = "members") String[] members) throws Exception{
	return ResultBuilder.buildInfoResult("my result");
  }
}
 
static class Interceptor extends AbstractCliAroundInterceptor {
  @Override
  public Result preExecution(GfshParseResult parseResult) {
    // validates groupId and memberIds not both set
    if (parseResult.getParamValue("groups") != null && parseResult.getParamValue("members") != null) {
      return ResultBuilder.createUserErrorResult("Can't specify both group and member.");
    }
    return ResultBuilder.createInfoResult("");
  }
}

 

Writing Tests for Option Validation

It's sufficient to write just a unit tests for your option validations. It's an overkill to fire up a cluster, only to issues commands that are going to fail validation. Use GfshParserRule to help you with these kind of tests. Below is an example:

@Category(UnitTest.class)
public class ShowMetricsCommandUnitTest {

  @Rule
  public GfshParserRule parser = new GfshParserRule();

  private ShowMetricsCommand command;
  @Before
  public void before() {
    // use spy so that we get to excute this class
    command = spy(ShowMetricsCommand.class);
  }

  @Test
  public void testMandatory() {
    assertThat(parser.parse("command without mandatory option")).isNull();
  }

  @Test
  public void testAutoComplete() throws Exception {
    assertThat(parser.complete("incomplete command").getCandidates().size()).isEqualTo(5);
  }

  @Test
  public void testPortAndRegion() throws Exception {
    parser.executeAndAssertThat(command, "show metrics --port=0 --region=regionA")
        .statusIsError()
        .containsOutput("The --region and --port parameters are mutually exclusive");
  }
  
  @Test
  pulic void testCommandLogic() throws Exception {
    // mock out the interaction of your command class
    // test and assert.
    parser.executeAndAssertThat(command, "command string");
  }

Command Execution:

When you are in the command execution code, please follow these guidelines:

  1. Use methods in GfshCommand as much as possbile to interact with the cluster, like getMember, findMember, getSecurityService, executeFunction etc. There are two sets of functions in GfshCommand: getXXX() and findXXX(). getXXX() will throw you and exception if nothing is found, while findXXX() will give you an empty set if nothing is found.
  2. Do not try to catch any exceptions unless you absolutely need to. All commands are executed by CommandExecutor which will turn exceptions into appropriate CommandResult. 
  3. In your code, you can throw Exceptions. They are treated by CommandExecutors depending on what type of exceptions you throw:
    1. UserErrorException/IllegalArgumentException/IllegalStateExceptions are not logged by the executor (no stack trace in logs). The messages in these exception are reported back to gfsh as error messages. Commands are deemed to have failed.
    2. EntityNotFoundException can either be reported as error or info depending on whether the exception is created with statusOK flag. If your command requires you not to report "region not found" or "member not found" or any other "entity not found" back as error messages, you can throw an EntityNotFoundException with statusOK to be true. This exception is also not logged by the executor.
    3. NotAuthorizedException is logged and rethrown by the executor
    4. All other exceptions are caught by the executor and turned into a command result with "error" state, and statck trace logged in the logs.
  4. ResultBuilder.buildInfoResult() will build a result with OK status, while ResultBuilder.buildGemfireErrorResult or ResultBuilder.buildUserErrorResult will build a result with ERROR status.

Test Your Command

There are 4 level of tests for your command:

  1. There should be at least a unit test that would test the option validation and controll logic inside your command code. Use GfshParserRule as mentioned above for that purpose.
  2. Use LocatorStarterRule or ServerStarterRule in combination with GfshCommandRule to write an integration test for your command.
  3. Use LocatorServerStartupRule and GfshCommandRule to write DUnit test for your command
  4. Use GfshRule to write acceptance test for your command.

Unit tests are a must. Sometimes that alone would be sufficient to test your command if you are not testing the actual function of the command (like create the region, etc). But it would be nice to verify the behavior of the command using an integration test. If your command has complicated behavior with different VMs, a DUnit test would be needed. It's always a good idea to add an acceptance test for smoke test purposes.

When writing tests, please follow these guidelines:

  1. Use rules to share code between tests; DO NOT use abstract test classes.
  2. Each test class needs to be self contained, i.e., changing one test does not affect another one.
  3. Do NOT use DUnit/Integration tests to test option validation.
  4. We have tests that verified the behavior of all the functions in GfshCommand already; you do not need to test these functions in your command test again. 

 

 

  • No labels