Repository: infinispan/infinispan
Pull request #14547: [#14546] CLI create cache with configuration argument
Author: login: tristantarrant
Created: 2025-04-10T12:02:28Z | Merged: 2025-04-11T16:12:01Z

Closes #14546

Linked issue #14546: CLI create cache with configuration argument
### Description

Currently the CLI `create cache` command only allows passing a cache configuration as an external file which is inconvenient.
Add support for the following alternate invocations:
`create cache name "configuration"`
`create cache -c "configuration" name`

### Implementation ideas

_No response_

Diff:
diff --git a/cli/src/main/java/org/infinispan/cli/commands/rest/Create.java b/cli/src/main/java/org/infinispan/cli/commands/rest/Create.java
index 5269f3eda735..0c3b09a8f66a 100644
--- a/cli/src/main/java/org/infinispan/cli/commands/rest/Create.java
+++ b/cli/src/main/java/org/infinispan/cli/commands/rest/Create.java
@@ -1,6 +1,7 @@
 package org.infinispan.cli.commands.rest;
 
 import java.io.File;
+import java.util.List;
 import java.util.concurrent.CompletionStage;
 
 import org.aesh.command.Command;
@@ -9,6 +10,7 @@
 import org.aesh.command.GroupCommandDefinition;
 import org.aesh.command.impl.completer.FileOptionCompleter;
 import org.aesh.command.option.Argument;
+import org.aesh.command.option.Arguments;
 import org.aesh.command.option.Option;
 import org.aesh.command.parser.RequiredOptionException;
 import org.aesh.io.Resource;
@@ -54,8 +56,8 @@ public CommandResult exec(ContextAwareCommandInvocation invocation) {
    @CommandDefinition(name = "cache", description = "Create a cache", activator = ConnectionActivator.class)
    public static class Cache extends RestCliCommand {
 
-      @Argument(required = true)
-      String name;
+      @Arguments(required = true)
+      List<String> args;
 
       @Option(completer = CacheConfigurationCompleter.class, shortName = 't')
       String template;
@@ -63,6 +65,9 @@ public static class Cache extends RestCliCommand {
       @Option(completer = FileOptionCompleter.class, shortName = 'f')
       Resource file;
 
+      @Option(shortName = 'c')
+      String configuration;
+
       @Option(defaultValue = "false", name = "volatile", shortName = 'v')
       boolean volatileCache;
 
@@ -76,19 +81,23 @@ public boolean isHelp() {
 
       @Override
       protected CompletionStage<RestResponse> exec(ContextAwareCommandInvocation invocation, RestClient client, org.infinispan.cli.resources.Resource resource) throws RequiredOptionException {
-         if (template != null && file != null) {
-            throw Messages.MSG.mutuallyExclusiveOptions("template", "file");
+         int count = (template == null ? 0 : 1) + (file == null ? 0 : 1) + (configuration == null ? 0 : 1) + (args.size() - 1);
+         if (count == 0) {
+            throw Messages.MSG.requiresOneOf("template", "file", "configuration", "configuration argument");
          }
-         if (template == null && file == null) {
-            throw Messages.MSG.requiresOneOf("template", "file");
+         if (count > 1) {
+            throw Messages.MSG.mutuallyExclusiveOptions("template", "file", "configuration", "configuration argument");
          }
-
-         RestCacheClient cache = client.cache(name);
-         CacheContainerAdmin.AdminFlag flags[] = volatileCache ? new CacheContainerAdmin.AdminFlag[]{CacheContainerAdmin.AdminFlag.VOLATILE} : new CacheContainerAdmin.AdminFlag[]{};
+         RestCacheClient cache = client.cache(args.get(0));
+         CacheContainerAdmin.AdminFlag[] flags = volatileCache ? new CacheContainerAdmin.AdminFlag[]{CacheContainerAdmin.AdminFlag.VOLATILE} : new CacheContainerAdmin.AdminFlag[]{};
          if (template != null) {
             return cache.createWithTemplate(template, flags);
-         } else {
+         } else if (file != null) {
             return cache.createWithConfiguration(RestEntity.create(new File(file.getAbsolutePath())), flags);
+         } else if (configuration != null) {
+            return cache.createWithConfiguration(RestEntity.create(configuration), flags);
+         } else {
+            return cache.createWithConfiguration(RestEntity.create(args.get(1)), flags);
          }
       }
    }
diff --git a/cli/src/main/java/org/infinispan/cli/logging/Messages.java b/cli/src/main/java/org/infinispan/cli/logging/Messages.java
index b790365af0b9..158835f571c5 100644
--- a/cli/src/main/java/org/infinispan/cli/logging/Messages.java
+++ b/cli/src/main/java/org/infinispan/cli/logging/Messages.java
@@ -57,11 +57,11 @@ public interface Messages {
    @Message("Illegal arguments for command")
    IllegalArgumentException illegalCommandArguments();
 
-   @Message("The options '%s' and '%s' are mutually exclusive")
-   IllegalArgumentException mutuallyExclusiveOptions(String arg1, String arg2);
+   @Message("The options '%s' are mutually exclusive")
+   IllegalArgumentException mutuallyExclusiveOptions(String... opts);
 
-   @Message("One of the '%s' and '%s' options are required")
-   RequiredOptionException requiresOneOf(String arg1, String arg2);
+   @Message("One of the '%s' options is required")
+   RequiredOptionException requiresOneOf(String... opts);
 
    @Message("Could not connect to server: %s")
    ConnectException connectionFailed(String message);
diff --git a/cli/src/main/resources/help/create.adoc b/cli/src/main/resources/help/create.adoc
index ebe6b096c0e6..a64faec325cb 100644
--- a/cli/src/main/resources/help/create.adoc
+++ b/cli/src/main/resources/help/create.adoc
@@ -10,18 +10,21 @@ create - creates caches and counters on ${infinispan.brand.name} servers.
 
 SYNOPSIS
 --------
-*create cache* ['OPTIONS'] `CACHE_NAME`
+*create cache* ['OPTIONS'] `CACHE_NAME` [`CONFIGURATION`]
 
 *create counter* ['OPTIONS'] `COUNTER_NAME`
 
 
 CREATE CACHE OPTIONS
 --------------------
+*-c, --configuration*='CONFIGURATION'::
+Specifies a configuration as a string in XML, JSON or YAML format. Mutually exclusive with `-f`, `-t` and the configuration argument.
+
 *-f, --file*='FILE'::
-Specifies a configuration file in XML, JSON or YAML format.
+Specifies a configuration file in XML, JSON or YAML format. Mutually exclusive with `-c`, `-t` and the configuration argument.
 
 *-t, --template*='TEMPLATE'::
-Specifies a configuration template. Use tab autocompletion to see available templates.
+Specifies a configuration template. Use tab autocompletion to see available templates. Mutually exclusive with `-c`, `-f` and the configuration argument.
 
 *-v, --volatile*='[true|false]'::
 Specifies whether the cache is persistent or volatile. The default is false.
diff --git a/client/rest-client/src/main/java/org/infinispan/client/rest/RestEntity.java b/client/rest-client/src/main/java/org/infinispan/client/rest/RestEntity.java
index d1538f6ba419..b9fa3a3bb3b5 100644
--- a/client/rest-client/src/main/java/org/infinispan/client/rest/RestEntity.java
+++ b/client/rest-client/src/main/java/org/infinispan/client/rest/RestEntity.java
@@ -103,7 +103,7 @@ static RestEntity form(Map<String, List<String>> formData) {
       StringBuilder fb = new StringBuilder();
       for (Map.Entry<String, List<String>> singleEntry : formData.entrySet()) {
          for (String v : singleEntry.getValue()) {
-            if (fb.length() > 0) {
+            if (!fb.isEmpty()) {
                fb.append("&");
             }
             fb.append(URLEncoder.encode(singleEntry.getKey(), StandardCharsets.UTF_8));
@@ -137,4 +137,12 @@ static RestEntity create(File file) {
       }
       return RestEntity.create(MediaType.APPLICATION_OCTET_STREAM, file);
    }
+
+   static RestEntity create(String s) {
+      return switch (s.trim().charAt(0)) {
+         case '{' -> RestEntity.create(MediaType.APPLICATION_JSON, s);
+         case '<' -> RestEntity.create(MediaType.APPLICATION_XML, s);
+         default -> RestEntity.create(MediaType.TEXT_PLAIN, s);
+      };
+   }
 }
diff --git a/commons/all/src/main/java/org/infinispan/commons/configuration/io/ConfigurationReader.java b/commons/all/src/main/java/org/infinispan/commons/configuration/io/ConfigurationReader.java
index 5259f8a8a2e7..befd797502cf 100644
--- a/commons/all/src/main/java/org/infinispan/commons/configuration/io/ConfigurationReader.java
+++ b/commons/all/src/main/java/org/infinispan/commons/configuration/io/ConfigurationReader.java
@@ -80,7 +80,7 @@ private int firstChar() {
       }
 
       public ConfigurationReader build() {
-         if (type == null) {
+         if (type == null || type.equals(MediaType.TEXT_PLAIN)) {
             // Auto-detect
             int first = firstChar();
             switch (first) {
@@ -96,16 +96,15 @@ public ConfigurationReader build() {
                   break;
             }
          }
-         switch (type.getTypeSubtype()) {
-            case MediaType.APPLICATION_XML_TYPE:
-               return new XmlConfigurationReader(reader, resolver, properties, replacer, namingStrategy);
-            case MediaType.APPLICATION_YAML_TYPE:
-               return new YamlConfigurationReader(reader, resolver, properties, replacer, namingStrategy);
-            case MediaType.APPLICATION_JSON_TYPE:
-               return new JsonConfigurationReader(reader, resolver, properties, replacer, namingStrategy);
-            default:
-               throw new IllegalArgumentException(type.toString());
-         }
+         return switch (type.getTypeSubtype()) {
+            case MediaType.APPLICATION_XML_TYPE ->
+                  new XmlConfigurationReader(reader, resolver, properties, replacer, namingStrategy);
+            case MediaType.APPLICATION_YAML_TYPE ->
+                  new YamlConfigurationReader(reader, resolver, properties, replacer, namingStrategy);
+            case MediaType.APPLICATION_JSON_TYPE ->
+                  new JsonConfigurationReader(reader, resolver, properties, replacer, namingStrategy);
+            default -> throw new IllegalArgumentException(type.toString());
+         };
       }
    }
 
diff --git a/documentation/src/main/asciidoc/topics/proc_creating_caches_cli.adoc b/documentation/src/main/asciidoc/topics/proc_creating_caches_cli.adoc
index 9b120cfd524b..ba9bdecef10b 100644
--- a/documentation/src/main/asciidoc/topics/proc_creating_caches_cli.adoc
+++ b/documentation/src/main/asciidoc/topics/proc_creating_caches_cli.adoc
@@ -20,7 +20,21 @@ include::cmd_examples/cli_nix.adoc[]
 +
 . Use the [command]`create cache` command to create remote caches.
 +
-For example, create a cache named "mycache" from a file named `mycache.xml` as follows:
+For example, create a distributed cache named "distcache" as follows:
++
+[source,options="nowrap",subs=attributes+]
+----
+create cache distcache "<distributed-cache />"
+----
+
+You can use any configuration format supported by {brandname}. Create a cache using a JSON configuration as follows:
++
+[source,options="nowrap",subs=attributes+]
+----
+create cache json '{"distributed-cache":{"mode":"SYNC"}}'
+----
+
+It is possible to use a configuration stored in an external file. For example, create a cache named "mycache" from a file named `mycache.xml` as follows:
 +
 [source,options="nowrap",subs=attributes+]
 ----
diff --git a/server/rest/src/main/java/org/infinispan/rest/resources/CacheResourceV2.java b/server/rest/src/main/java/org/infinispan/rest/resources/CacheResourceV2.java
index 556d5e857918..e7742580b00c 100644
--- a/server/rest/src/main/java/org/infinispan/rest/resources/CacheResourceV2.java
+++ b/server/rest/src/main/java/org/infinispan/rest/resources/CacheResourceV2.java
@@ -879,7 +879,7 @@ private CompletableFuture<RestResponse> createOrUpdate(RestRequest request) {
          }, invocationHelper.getExecutor());
       }
       MediaType sourceType = request.contentType() == null ? APPLICATION_JSON : request.contentType();
-      if (!sourceType.match(APPLICATION_JSON) && !sourceType.match(APPLICATION_XML) && !sourceType.match(APPLICATION_YAML)) {
+      if (!sourceType.match(APPLICATION_JSON) && !sourceType.match(APPLICATION_XML) && !sourceType.match(APPLICATION_YAML) && !sourceType.match(TEXT_PLAIN)) {
          responseBuilder.status(HttpResponseStatus.UNSUPPORTED_MEDIA_TYPE);
          return CompletableFuture.completedFuture(responseBuilder.build());
       }
diff --git a/server/tests/src/test/java/org/infinispan/server/cli/CliIT.java b/server/tests/src/test/java/org/infinispan/server/cli/CliIT.java
index ac7fb247c1f7..400327c885f6 100644
--- a/server/tests/src/test/java/org/infinispan/server/cli/CliIT.java
+++ b/server/tests/src/test/java/org/infinispan/server/cli/CliIT.java
@@ -88,6 +88,23 @@ public void testCliInteractive() {
          terminal.send("describe k2");
          terminal.assertContains("\"timetoliveseconds\" : [ \"10\" ]");
 
+         terminal.send("cd /containers/default/caches");
+
+         terminal.clear();
+         terminal.send("create cache xml '<distributed-cache/>'");
+         terminal.send("describe xml");
+         terminal.assertContains("\"mode\" : \"SYNC\"");
+
+         terminal.clear();
+         terminal.send("create cache json '{\"distributed-cache\":{}}'");
+         terminal.send("describe json");
+         terminal.assertContains("\"mode\" : \"SYNC\"");
+
+         terminal.clear();
+         terminal.send("create cache yaml 'distributedCache: ~'");
+         terminal.send("describe yaml");
+         terminal.assertContains("\"mode\" : \"SYNC\"");
+
          terminal.send("schema upload -f=" + getCliResource("person.proto").getPath() + " person.proto");
          terminal.send("create cache --file=" + getCliResource("qcache.xml").getPath() + " qcache");
          terminal.clear();


Reviews:
user: login: ryanemerson
body: Docs or it didn't happen :stuck_out_tongue_winking_eye: 
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T12:45:19Z
user: login: ryanemerson
body: It's really nice that this works with multi-line configurations :tada: 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T16:35:54Z
user: login: tristantarrant
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T08:51:37Z
user: login: ryanemerson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T09:47:10Z
user: login: tristantarrant
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T10:42:26Z
user: login: ryanemerson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T11:05:30Z
user: login: tristantarrant
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T11:24:43Z
user: login: ryanemerson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T13:40:56Z
user: login: ryanemerson
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T13:42:51Z

Review comments:
diff_hunk: @@ -54,15 +56,18 @@ public CommandResult exec(ContextAwareCommandInvocation invocation) {
    @CommandDefinition(name = "cache", description = "Create a cache", activator = ConnectionActivator.class)
    public static class Cache extends RestCliCommand {
 
-      @Argument(required = true)
-      String name;
+      @Arguments(required = true)
+      List<String> args;
 
       @Option(completer = CacheConfigurationCompleter.class, shortName = 't')
       String template;
 
       @Option(completer = FileOptionCompleter.class, shortName = 'f')
       Resource file;
 
+      @Option(shortName = 'c')
+      String configuration;
path: cli/src/main/java/org/infinispan/cli/commands/rest/Create.java
user: login: ryanemerson
body: Do we include this just to be consistent with `-f` and `-t`?
created_at: 2025-04-10T16:18:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -54,15 +56,18 @@ public CommandResult exec(ContextAwareCommandInvocation invocation) {
    @CommandDefinition(name = "cache", description = "Create a cache", activator = ConnectionActivator.class)
    public static class Cache extends RestCliCommand {
 
-      @Argument(required = true)
-      String name;
+      @Arguments(required = true)
+      List<String> args;
 
       @Option(completer = CacheConfigurationCompleter.class, shortName = 't')
       String template;
 
       @Option(completer = FileOptionCompleter.class, shortName = 'f')
       Resource file;
 
+      @Option(shortName = 'c')
+      String configuration;
path: cli/src/main/java/org/infinispan/cli/commands/rest/Create.java
user: login: tristantarrant
body: Yes
created_at: 2025-04-11T08:51:37Z
author_association: MEMBER
diff_hunk: @@ -137,4 +137,12 @@ static RestEntity create(File file) {
       }
       return RestEntity.create(MediaType.APPLICATION_OCTET_STREAM, file);
    }
+
+   static RestEntity create(String s) {
+      return switch (s.trim().charAt(0)) {
+         case '{' -> RestEntity.create(MediaType.APPLICATION_JSON, s);
+         case '<' -> RestEntity.create(MediaType.APPLICATION_XML, s);
+         default -> RestEntity.create(MediaType.TEXT_PLAIN, s);
path: client/rest-client/src/main/java/org/infinispan/client/rest/RestEntity.java
user: login: ryanemerson
body: Hmm my comment from yesterday seems to have been lost :thinking: 

What about if we are passing yaml?

The following fails: `create cache distcache "distributedCache: ~"`

I know it's hard to guess yaml, so maybe we should introduce an optional `media-type` flag?

So it would be  `create cache distcache "distributedCache: ~" --media-type=<JSON|XML|YAML>`
created_at: 2025-04-11T09:47:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -137,4 +137,12 @@ static RestEntity create(File file) {
       }
       return RestEntity.create(MediaType.APPLICATION_OCTET_STREAM, file);
    }
+
+   static RestEntity create(String s) {
+      return switch (s.trim().charAt(0)) {
+         case '{' -> RestEntity.create(MediaType.APPLICATION_JSON, s);
+         case '<' -> RestEntity.create(MediaType.APPLICATION_XML, s);
+         default -> RestEntity.create(MediaType.TEXT_PLAIN, s);
path: client/rest-client/src/main/java/org/infinispan/client/rest/RestEntity.java
user: login: tristantarrant
body: There was an error in `ConfigurationReader`. It should work now
created_at: 2025-04-11T10:42:26Z
author_association: MEMBER
diff_hunk: @@ -137,4 +137,12 @@ static RestEntity create(File file) {
       }
       return RestEntity.create(MediaType.APPLICATION_OCTET_STREAM, file);
    }
+
+   static RestEntity create(String s) {
+      return switch (s.trim().charAt(0)) {
+         case '{' -> RestEntity.create(MediaType.APPLICATION_JSON, s);
+         case '<' -> RestEntity.create(MediaType.APPLICATION_XML, s);
+         default -> RestEntity.create(MediaType.TEXT_PLAIN, s);
path: client/rest-client/src/main/java/org/infinispan/client/rest/RestEntity.java
user: login: ryanemerson
body: I'm still getting an error, but it's not very informative:

```
[df0dba52a5e5-62733@cluster//containers/default]> create cache yaml "distributedCache: ~"
Error: 
```
created_at: 2025-04-11T11:05:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -137,4 +137,12 @@ static RestEntity create(File file) {
       }
       return RestEntity.create(MediaType.APPLICATION_OCTET_STREAM, file);
    }
+
+   static RestEntity create(String s) {
+      return switch (s.trim().charAt(0)) {
+         case '{' -> RestEntity.create(MediaType.APPLICATION_JSON, s);
+         case '<' -> RestEntity.create(MediaType.APPLICATION_XML, s);
+         default -> RestEntity.create(MediaType.TEXT_PLAIN, s);
path: client/rest-client/src/main/java/org/infinispan/client/rest/RestEntity.java
user: login: tristantarrant
body: I've added tests to `CliIT`. They work locally
created_at: 2025-04-11T11:24:42Z
author_association: MEMBER
diff_hunk: @@ -137,4 +137,12 @@ static RestEntity create(File file) {
       }
       return RestEntity.create(MediaType.APPLICATION_OCTET_STREAM, file);
    }
+
+   static RestEntity create(String s) {
+      return switch (s.trim().charAt(0)) {
+         case '{' -> RestEntity.create(MediaType.APPLICATION_JSON, s);
+         case '<' -> RestEntity.create(MediaType.APPLICATION_XML, s);
+         default -> RestEntity.create(MediaType.TEXT_PLAIN, s);
path: client/rest-client/src/main/java/org/infinispan/client/rest/RestEntity.java
user: login: ryanemerson
body: Argh, I wasn't engaging my brain. I was running an old server in docker to test interactively ... obviously that doesn't have the fixes you've added to the backend :man_facepalming: 
created_at: 2025-04-11T13:40:56Z
author_association: CONTRIBUTOR

PR comments:
user: login: tristantarrant
created_at: 2025-04-11T08:51:50Z
body: > Docs or it didn't happen 😜

they are there now :)
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-04-11T13:59:12Z
body: |Overall Project|72.47%|:green_apple:|
|:-|:-|:-:|

> There is no coverage information present for the Files changed
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-11T14:16:11Z
body: __FLAKY TESTS__
   - org.infinispan.client.hotrod.ProtobufJsonScriptTest#testDataAsJSONFromScript 
   - org.infinispan.client.hotrod.event.RemoteContinuousQueryLeavingRemoteCacheManagerTest#testContinuousQueryRemoveRCM 
   - org.infinispan.globalstate.ThreeNodeDistGlobalStateRestartTest#testDisableRebalanceRestartEnableRebalance 
   - org.infinispan.globalstate.ThreeNodeReplGlobalStateRestartTest#testDisableRebalanceRestartEnableRebalance 
   - org.infinispan.partitionhandling.DelayedAvailabilityUpdateTest#testDelayedAvailabilityUpdate0\[DIST_SYNC, DENY_READ_WRITES\] 
   - org.infinispan.persistence.sifs.SoftIndexFileStoreRestartTest#testStatsUponRestart\[LOCAL, tx\=false, l1\=false, shared\=false, preload\=false, segmented\=true, fileSize\=10000, hasPassivation\=false\]([DELETE]) 
   - org.infinispan.topology.ClusterTopologyStatefulTest#testRebalanceAfterRestart 
   - org.infinispan.jcache.remote.JCacheCreateCachePredefinedTest#testCreateCachePredefinedTouched 
   - org.infinispan.server.hotrod.HotRodStatsTest#testStats 

author_association: NONE
user: login: tristantarrant
created_at: 2025-04-11T16:07:30Z
body: @ryanemerson the test passes
author_association: MEMBER
user: login: ryanemerson
created_at: 2025-04-11T16:12:14Z
body: Thanks @tristantarrant 
author_association: CONTRIBUTOR