From 6db0ea79ca68bd9cc2b6fc5352485300fb140496 Mon Sep 17 00:00:00 2001 From: Serhiy Bzhezytskyy Date: Tue, 22 Sep 2026 14:10:50 +0300 Subject: [PATCH 01/13] SOLR-17697: Add shared picocli connection-resolution helpers to CLIUtils CLIUtils.resolveSolrUrl/resolveSolrConnection resolve a picocli tool's ConnectionOptions (--solr-url/--solr-connection/--zk-host) the same way CLIUtils.normalizeSolrUrl(CommandLine)/getSolrConnection(CommandLine) do for the commons-cli path, so each newly-converted tool does not need to re-implement that resolution itself. --- .../java/org/apache/solr/cli/CLIUtils.java | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/solr/core/src/java/org/apache/solr/cli/CLIUtils.java b/solr/core/src/java/org/apache/solr/cli/CLIUtils.java index 68d4eedc4700..0dde4a85cdc5 100644 --- a/solr/core/src/java/org/apache/solr/cli/CLIUtils.java +++ b/solr/core/src/java/org/apache/solr/cli/CLIUtils.java @@ -284,6 +284,77 @@ public static boolean hasConnectionOption(CommandLine cli) { || cli.hasOption(CommonCLIOptions.ZK_HOST_OPTION); } + /** + * Resolves the base Solr URL for a picocli tool's {@link ConnectionOptions}, honoring whichever + * of {@code --solr-url}, {@code --solr-connection} or {@code --zk-host} was given. Mirrors {@link + * #normalizeSolrUrl(CommandLine)}, printing the same fallback warning when none was given. + */ + public static String resolveSolrUrl(ConnectionOptions connectionOptions, String credentials) + throws Exception { + if (connectionOptions != null && connectionOptions.solrUrl != null) { + return normalizeSolrUrl(connectionOptions.solrUrl); + } + String connectionString = + connectionOptions != null + ? (connectionOptions.solrConnection != null + ? connectionOptions.solrConnection + : connectionOptions.zkHost) + : null; + if (connectionString != null) { + return solrUrlFromConnection( + CloudSolrClient.CloudSolrClientConnection.parse(connectionString), credentials); + } + String defaultSolrUrl = getDefaultSolrUrl(); + CLIO.err( + "Neither --solr-connection, --zk-host or --solr-url parameters, nor SOLR_CONNECTION, ZK_HOST env var provided, so assuming solr url is " + + defaultSolrUrl + + "."); + return defaultSolrUrl; + } + + /** + * Resolves a Solr connection from a picocli tool's {@link ConnectionOptions}, mirroring {@link + * #getSolrConnection(CommandLine)}: an explicit {@code --solr-connection} or {@code --zk-host} + * wins outright, otherwise a running Solr instance (found via {@code --solr-url} or the default + * URL) is queried to see if it reports a ZooKeeper connection (SolrCloud mode), returning {@code + * null} if it does not. + */ + public static CloudSolrClient.CloudSolrClientConnection resolveSolrConnection( + ConnectionOptions connectionOptions, String credentials) throws Exception { + if (connectionOptions != null && connectionOptions.solrConnection != null) { + return CloudSolrClient.CloudSolrClientConnection.parse(connectionOptions.solrConnection); + } + if (connectionOptions != null && connectionOptions.zkHost != null) { + var zkSolrConnection = + CloudSolrClient.CloudSolrClientConnection.parse(connectionOptions.zkHost); + if (!zkSolrConnection.isZookeeper()) { + throw new IOException( + String.format( + Locale.ROOT, + "Expected ZooKeeper connection string, but got: '%s'.", + connectionOptions.zkHost)); + } + return zkSolrConnection; + } + String resolvedSolrUrl = + (connectionOptions != null && connectionOptions.solrUrl != null) + ? normalizeSolrUrl(connectionOptions.solrUrl) + : getDefaultSolrUrl(); + try (SolrClient solrClient = getSolrClient(resolvedSolrUrl, credentials)) { + Map status = StatusTool.reportStatus(solrClient); + @SuppressWarnings("unchecked") + Map cloud = (Map) status.get("cloud"); + if (cloud == null) { + return null; + } + String zookeeper = (String) cloud.get("ZooKeeper"); + if (zookeeper.endsWith("(embedded)")) { + zookeeper = zookeeper.substring(0, zookeeper.length() - "(embedded)".length()); + } + return CloudSolrClient.CloudSolrClientConnection.parse(zookeeper); + } + } + /** * Get the value of the specified CLI option with fallback to system property and default value. * From 5e4d52196ed55178efa00ddbfcda32a1b70bf7a7 Mon Sep 17 00:00:00 2001 From: Serhiy Bzhezytskyy Date: Tue, 22 Sep 2026 14:11:00 +0300 Subject: [PATCH 02/13] SOLR-17697: Implement picocli for post command / PostTool Reuses the existing ConnectionOptions/CredentialsOptions mixins. post-tool.adoc/stream-tool.adoc's rename is a separate, unrelated ticket; this only wires the already-split PostToolParams into picocli fields. Verified against a live Solr instance across all three connection forms (--solr-url, --solr-connection, --zk-host) and the no-connection default, matching the commons-cli path's output byte for byte, including its stderr fallback warning. --- .../java/org/apache/solr/cli/PostTool.java | 152 +++++++++++++++++- 1 file changed, 151 insertions(+), 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/cli/PostTool.java b/solr/core/src/java/org/apache/solr/cli/PostTool.java index 9d85e6fb6798..18f2ceb0966d 100644 --- a/solr/core/src/java/org/apache/solr/cli/PostTool.java +++ b/solr/core/src/java/org/apache/solr/cli/PostTool.java @@ -74,6 +74,7 @@ import org.apache.solr.client.api.util.SolrVersion; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrServerException; +import org.apache.solr.client.solrj.impl.CloudSolrClient; import org.apache.solr.common.util.Utils; import org.apache.solr.util.RTimer; import org.w3c.dom.Document; @@ -82,6 +83,23 @@ import org.xml.sax.SAXException; /** Supports post command in the bin/solr script. */ +@SuppressWarnings("UnnecessarilyFullyQualified") +@picocli.CommandLine.Command( + name = "post", + description = + "Sends one or more files, directories, urls or literal data to Solr for indexing, using" + + " the bin/solr CLI.", + footerHeading = "%nExamples:%n", + footer = { + " # Index all JSON files into a collection", + " bin/solr post -c gettingstarted *.json", + "", + " # Index a tab-separated file, using the CSV handler's separator param", + " bin/solr post -c gettingstarted --params \"separator=%09\" --type text/csv data.tsv", + "", + " # Crawl a website one layer deep and index the pages found", + " bin/solr post -c gettingstarted --mode web --recursive 1 https://solr.apache.org/" + }) public class PostTool extends ToolBase { public static final String DEFAULT_FILE_TYPES = @@ -212,6 +230,98 @@ public class PostTool extends ToolBase { PostTool.PageFetcher pageFetcher = new PostTool.PageFetcher(); + // --- picocli fields --- + // Named distinctly from the commons-cli-era instance fields above (type, recursive, delay, + // fileTypes, params, commit, optimize, dryRun, args), which postDocuments() still populates from + // whichever parser ran. + + @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "0..1") + private ConnectionOptions connectionOptions; + + @picocli.CommandLine.Mixin private CredentialsOptions credentialsOptions; + + @picocli.CommandLine.Option( + names = {"-c", "--name"}, + required = true, + paramLabel = "name", + description = "Name of the collection.") + private String nameOpt; + + @picocli.CommandLine.Option( + names = "--skip-commit", + description = "Do not 'commit', and thus changes won't be visible till a commit occurs.") + private boolean skipCommitOpt; + + @picocli.CommandLine.Option( + names = {"-o", "--optimize"}, + description = "Issue an optimize at end of posting documents.") + private boolean optimizeOpt; + + @picocli.CommandLine.Option( + names = "--mode", + defaultValue = DATA_MODE_FILES, + paramLabel = "mode", + description = + "Which mode the Post command is running in, 'files' crawls local directory, 'web'" + + " crawls website, 'args' processes input args, and 'stdin' reads a command from" + + " standard in. default: files.") + private String modeOpt; + + @picocli.CommandLine.Option( + names = {"-r", "--recursive"}, + defaultValue = "1", + paramLabel = "recursive", + description = "For web crawl, how deep to go. default: 1") + private int recursiveOpt; + + @picocli.CommandLine.Option( + names = {"-d", "--delay"}, + paramLabel = "delay", + description = + "If recursive then delay will be the wait time between posts. default: 10 for web, 0" + + " for files") + private Integer delayOpt; + + @picocli.CommandLine.Option( + names = {"-t", "--type"}, + paramLabel = "content-type", + description = "Specify a specific mimetype to use, such as application/json.") + private String typeOpt; + + @picocli.CommandLine.Option( + names = {"-ft", "--filetypes"}, + defaultValue = DEFAULT_FILE_TYPES, + paramLabel = "[,,...]", + description = "default: " + DEFAULT_FILE_TYPES) + private String fileTypesOpt; + + @picocli.CommandLine.Option( + names = "--params", + defaultValue = "", + paramLabel = "=[&=...]", + description = "Values must be URL-encoded; these pass through to Solr update request.") + private String paramsOpt; + + @picocli.CommandLine.Option( + names = "--format", + description = + "sends application/json content as Solr commands to /update instead of" + + " /update/json/docs.") + private boolean formatOpt; + + @picocli.CommandLine.Option( + names = "--dry-run", + description = + "Performs a dry run of the posting process without actually sending documents to" + + " Solr. Only works with files mode.") + private boolean dryRunOpt; + + @picocli.CommandLine.Parameters( + arity = "0..*", + paramLabel = "FILE", + description = "Files, directories, urls or literal data to post, depending on --mode.") + private String[] postArgs = new String[0]; + static { DATA_MODES.add(DATA_MODE_FILES); DATA_MODES.add(DATA_MODE_ARGS); @@ -280,6 +390,10 @@ record PostToolParams( CrawlOptions crawl, UpdateOptions update) {} + public PostTool() { + this(new DefaultToolRuntime()); + } + public PostTool(ToolRuntime runtime) { super(runtime); } @@ -1390,7 +1504,43 @@ protected Set getLinksFromWebPage(URL url, InputStream is, String type, URI @Override public int callTool() throws Exception { - throw new UnsupportedOperationException("This tool does not yet support PicoCli"); + String resolvedSolrUrl; + if (connectionOptions != null && connectionOptions.solrUrl != null) { + resolvedSolrUrl = CLIUtils.normalizeSolrUrl(connectionOptions.solrUrl); + } else if (connectionOptions != null + && (connectionOptions.solrConnection != null || connectionOptions.zkHost != null)) { + String connectionString = + connectionOptions.solrConnection != null + ? connectionOptions.solrConnection + : connectionOptions.zkHost; + resolvedSolrUrl = + CLIUtils.solrUrlFromConnection( + CloudSolrClient.CloudSolrClientConnection.parse(connectionString), + credentialsOptions.credentials); + } else { + resolvedSolrUrl = CLIUtils.getDefaultSolrUrl(); + CLIO.err( + "Neither --solr-connection, --zk-host or --solr-url parameters, nor SOLR_CONNECTION, ZK_HOST env var provided, so assuming solr url is " + + resolvedSolrUrl + + "."); + } + + URI updateUrl = new URI(resolvedSolrUrl + "/solr/" + nameOpt + "/update"); + int defaultDelay = modeOpt.equals(DATA_MODE_WEB) ? DEFAULT_WEB_DELAY : 0; + + PostToolParams postParams = + new PostToolParams( + updateUrl, + modeOpt, + dryRunOpt, + credentialsOptions.credentials, + postArgs, + new ContentOptions(typeOpt, formatOpt ? FORMAT_SOLR : "", paramsOpt), + new CrawlOptions( + fileTypesOpt, delayOpt != null ? delayOpt : defaultDelay, recursiveOpt), + new UpdateOptions(!skipCommitOpt, optimizeOpt)); + postDocuments(postParams); + return 0; } /** Utility class to hold the result form a page fetch */ From 337a86ff275041011f34e44ef44d85d0424735cc Mon Sep 17 00:00:00 2001 From: Serhiy Bzhezytskyy Date: Tue, 22 Sep 2026 14:11:00 +0300 Subject: [PATCH 03/13] SOLR-17697: Implement picocli for stream command / StreamTool Mirrors CLIUtils.getSolrConnection(CommandLine)'s cluster-probe fallback locally, since StreamTool (unlike most tools) needs both a resolved CloudSolrClientConnection for local-mode streaming and a plain Solr URL for remote mode. Verified live across --solr-url, --solr-connection, --zk-host and the no-connection default, in both --execution local and remote, matching the commons-cli path's output and stderr warnings. --- .../java/org/apache/solr/cli/StreamTool.java | 176 +++++++++++++++++- 1 file changed, 175 insertions(+), 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/cli/StreamTool.java b/solr/core/src/java/org/apache/solr/cli/StreamTool.java index aa047f4b152c..fb1faed1fee8 100644 --- a/solr/core/src/java/org/apache/solr/cli/StreamTool.java +++ b/solr/core/src/java/org/apache/solr/cli/StreamTool.java @@ -37,6 +37,7 @@ import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; +import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.impl.CloudSolrClient; import org.apache.solr.client.solrj.io.SolrClientCache; import org.apache.solr.client.solrj.io.Tuple; @@ -57,6 +58,19 @@ import org.apache.solr.handler.CatStream; /** Supports stream command in the bin/solr script. */ +@SuppressWarnings("UnnecessarilyFullyQualified") +@picocli.CommandLine.Command( + name = "stream", + description = + "Runs a streaming expression in Solr and prints the results, using the bin/solr CLI.", + footerHeading = "%nExamples:%n", + footer = { + " # Run a streaming expression against the techproducts collection", + " bin/solr stream -c techproducts 'search(techproducts,q=\"iPod\",fl=\"name,price\")'", + "", + " # Run a streaming expression stored in a .expr file", + " bin/solr stream --header -c techproducts stream.expr" + }) public class StreamTool extends ToolBase { /** @@ -76,6 +90,67 @@ record StreamParams( String collection, String credentials) {} + // --- picocli fields --- + // Named distinctly from the record accessors above where a bare name would shadow one. + + @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "0..1") + private ConnectionOptions connectionOptions; + + @picocli.CommandLine.Mixin private CredentialsOptions credentialsOptions; + + @picocli.CommandLine.Option( + names = "--execution", + defaultValue = "remote", + paramLabel = "ENVIRONMENT", + description = + "Execution environment is either 'local' (i.e CLI process) or via a 'remote' Solr" + + " server. Default environment is 'remote'.") + private String executionOpt; + + @picocli.CommandLine.Option( + names = {"-c", "--name"}, + paramLabel = "name", + description = + "Name of the specific collection to execute expression on if the execution is set to" + + " 'remote'. Required for 'remote' execution environment.") + private String collectionOpt; + + @picocli.CommandLine.Option( + names = "--fields", + paramLabel = "FIELDS", + description = + "The fields in the tuples to output. Defaults to fields in the first tuple of result" + + " set.") + private String fieldsOpt; + + @picocli.CommandLine.Option(names = "--header", description = "Specify to include a header line.") + private boolean headerOpt; + + @picocli.CommandLine.Option( + names = "--delimiter", + defaultValue = " ", + paramLabel = "CHARACTER", + description = "The output delimiter. Default to using three spaces.") + private String delimiterOpt; + + @picocli.CommandLine.Option( + names = "--array-delimiter", + defaultValue = "|", + paramLabel = "CHARACTER", + description = "The delimiter multi-valued fields. Default to using a pipe (|) delimiter.") + private String arrayDelimiterOpt; + + @picocli.CommandLine.Parameters( + arity = "1..*", + paramLabel = "expr", + description = + "The streaming expression, or a *.expr file, followed by $1/$2/... substitution args.") + private String[] exprArgs; + + public StreamTool() { + this(new DefaultToolRuntime()); + } + public StreamTool(ToolRuntime runtime) { super(runtime); } @@ -534,7 +609,106 @@ static String listToString(List values, String internalDelim) { @Override public int callTool() throws Exception { - throw new UnsupportedOperationException("This tool does not yet support PicoCli"); + StreamParams params = + new StreamParams( + exprArgs, + executionOpt, + arrayDelimiterOpt, + delimiterOpt, + headerOpt, + fieldsOpt, + collectionOpt, + credentialsOptions.credentials); + + String expr = readExpressionFromArgs(params.args()); + echoIfVerbose("Running Expression: " + expr); + + // Validate inputs before opening any connection to Solr. + boolean local = params.execution().equalsIgnoreCase("local"); + validateExpressionArgs(local, params.collection(), expr); + + // Mirrors the commons-cli path's call structure exactly, including which calls are skipped in + // local mode: resolveSolrUrl() is only invoked when actually needed, since (like + // CLIUtils.normalizeSolrUrl(cli)) it prints a stderr warning when no connection option was + // given. + var solrConnection = resolveSolrConnection(); + String solrUrl = local ? null : resolveSolrUrl(); + if (solrConnection == null) { + // No connection option given and none discoverable from a running Solr; fall back to the + // resolved base URL so expressions that need a Solr connection get a usable default. + solrConnection = + CloudSolrClient.CloudSolrClientConnection.parse( + solrUrl != null ? solrUrl : resolveSolrUrl()); + } + + runStream(params, expr, solrConnection, solrUrl); + return 0; + } + + /** + * Resolves the base Solr URL used for remote execution and for probing whether Solr is running in + * cloud mode, honoring whichever of {@code --solr-url}, {@code --solr-connection} or {@code + * --zk-host} was given (mirrors {@link CLIUtils#normalizeSolrUrl(CommandLine)}). + */ + private String resolveSolrUrl() throws Exception { + if (connectionOptions != null && connectionOptions.solrUrl != null) { + return CLIUtils.normalizeSolrUrl(connectionOptions.solrUrl); + } + String connectionString = + connectionOptions != null + ? (connectionOptions.solrConnection != null + ? connectionOptions.solrConnection + : connectionOptions.zkHost) + : null; + if (connectionString != null) { + return CLIUtils.solrUrlFromConnection( + CloudSolrClient.CloudSolrClientConnection.parse(connectionString), + credentialsOptions.credentials); + } + String defaultSolrUrl = CLIUtils.getDefaultSolrUrl(); + CLIO.err( + "Neither --solr-connection, --zk-host or --solr-url parameters, nor SOLR_CONNECTION, ZK_HOST env var provided, so assuming solr url is " + + defaultSolrUrl + + "."); + return defaultSolrUrl; + } + + /** + * Mirrors {@link CLIUtils#getSolrConnection}, resolving from picocli's connection options instead + * of a commons-cli {@code CommandLine}: an explicit {@code --solr-connection} or {@code + * --zk-host} wins outright, otherwise a running Solr instance is queried to see if it reports a + * ZooKeeper connection (SolrCloud mode), returning {@code null} if it does not. + */ + private CloudSolrClient.CloudSolrClientConnection resolveSolrConnection() throws Exception { + if (connectionOptions != null && connectionOptions.solrConnection != null) { + return CloudSolrClient.CloudSolrClientConnection.parse(connectionOptions.solrConnection); + } + if (connectionOptions != null && connectionOptions.zkHost != null) { + var zkSolrConnection = + CloudSolrClient.CloudSolrClientConnection.parse(connectionOptions.zkHost); + if (!zkSolrConnection.isZookeeper()) { + throw new IOException( + String.format( + Locale.ROOT, + "Expected ZooKeeper connection string, but got: '%s'.", + connectionOptions.zkHost)); + } + return zkSolrConnection; + } + try (SolrClient solrClient = + CLIUtils.getSolrClient(resolveSolrUrl(), credentialsOptions.credentials)) { + Map status = StatusTool.reportStatus(solrClient); + @SuppressWarnings("unchecked") + Map cloud = (Map) status.get("cloud"); + if (cloud == null) { + return null; + } + String zookeeper = (String) cloud.get("ZooKeeper"); + if (zookeeper.endsWith("(embedded)")) { + zookeeper = zookeeper.substring(0, zookeeper.length() - "(embedded)".length()); + } + return CloudSolrClient.CloudSolrClientConnection.parse(zookeeper); + } } static String readExpression(LineNumberReader bufferedReader, String[] args) throws IOException { From 80dc8a9c2ef448d81cde43cb4f45d1e4bf08d02d Mon Sep 17 00:00:00 2001 From: Serhiy Bzhezytskyy Date: Tue, 22 Sep 2026 14:11:10 +0300 Subject: [PATCH 04/13] SOLR-17697: Implement picocli for assert command / AssertTool Four ArgGroups (root/not-root, started/not-started, exists/not-exists, cloud/not-cloud) replicate the commons-cli OptionGroups' mutual exclusivity. callTool() inlines the same try/catch as the commons-cli path's runTool() override, since AssertTool is the one tool that maps a failed assertion to exit code 100 rather than ToolBase's default of 1. --- .../java/org/apache/solr/cli/AssertTool.java | 169 +++++++++++++++++- 1 file changed, 168 insertions(+), 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/cli/AssertTool.java b/solr/core/src/java/org/apache/solr/cli/AssertTool.java index 5ba193f9494d..89d7d5614515 100644 --- a/solr/core/src/java/org/apache/solr/cli/AssertTool.java +++ b/solr/core/src/java/org/apache/solr/cli/AssertTool.java @@ -39,6 +39,20 @@ * Supports assert command in the bin/solr script. Asserts various conditions and exists with error * code if there are failures, else continues with no output. */ +@SuppressWarnings("UnnecessarilyFullyQualified") +@picocli.CommandLine.Command( + name = "assert", + description = + "Asserts various conditions and exits with an error code if there are failures, else" + + " continues with no output.", + footerHeading = "%nExamples:%n", + footer = { + " # Assert Solr is running before continuing", + " bin/solr assert --started http://localhost:8983 --timeout 5000", + "", + " # Assert we are not running as root", + " bin/solr assert --not-root" + }) public class AssertTool extends ToolBase { private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); private String message = null; @@ -188,6 +202,109 @@ record AssertParams( String credentials, List assertions) {} + // --- picocli fields --- + + static class RootOptions { + @picocli.CommandLine.Option( + names = "--root", + description = "Asserts that we are the root user.") + boolean isRoot; + + @picocli.CommandLine.Option( + names = "--not-root", + description = "Asserts that we are NOT the root user.") + boolean isNotRoot; + } + + static class RunningOptions { + @picocli.CommandLine.Option( + names = "--started", + paramLabel = "url", + description = "Asserts that Solr is running on a certain URL. Default timeout is 1000ms.") + String startedUrl; + + @picocli.CommandLine.Option( + names = "--not-started", + paramLabel = "url", + description = + "Asserts that Solr is NOT running on a certain URL. Default timeout is 1000ms.") + String notStartedUrl; + } + + static class DirectoryOptions { + @picocli.CommandLine.Option( + names = "--exists", + paramLabel = "directory", + description = "Asserts that directory exists.") + String existsDir; + + @picocli.CommandLine.Option( + names = "--not-exists", + paramLabel = "directory", + description = "Asserts that directory does NOT exist.") + String notExistsDir; + } + + static class CloudOptions { + @picocli.CommandLine.Option( + names = "--cloud", + paramLabel = "url", + description = + "Asserts that Solr is running in cloud mode. Also fails if Solr not running. URL" + + " should be for root Solr path.") + String cloudUrl; + + @picocli.CommandLine.Option( + names = "--not-cloud", + paramLabel = "url", + description = + "Asserts that Solr is not running in cloud mode. Also fails if Solr not running. " + + " URL should be for root Solr path.") + String notCloudUrl; + } + + @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "0..1") + private RootOptions rootOptions; + + @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "0..1") + private RunningOptions runningOptions; + + @picocli.CommandLine.Option( + names = "--same-user", + paramLabel = "directory", + description = "Asserts that we run as same user that owns .") + private String sameUserOpt; + + @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "0..1") + private DirectoryOptions directoryOptions; + + @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "0..1") + private CloudOptions cloudOptions; + + @picocli.CommandLine.Option( + names = "--message", + paramLabel = "message", + description = "Exception message to be used in place of the default error message.") + private String messageOpt; + + @picocli.CommandLine.Option( + names = "--timeout", + paramLabel = "ms", + defaultValue = "1000", + description = "Timeout in ms for commands supporting a timeout.") + private long timeoutOpt; + + @picocli.CommandLine.Option( + names = "--exitcode", + description = "Return an exit code instead of printing error message on assert fail.") + private boolean exitCodeOpt; + + @picocli.CommandLine.Mixin private CredentialsOptions credentialsOptions; + + public AssertTool() { + this(new DefaultToolRuntime()); + } + public AssertTool(ToolRuntime runtime) { super(runtime); } @@ -496,7 +613,57 @@ private static boolean runningSolrIsCloud(String url, String credentials) throws @Override public int callTool() throws Exception { - throw new UnsupportedOperationException("This tool does not yet support PicoCli"); + List assertions = new ArrayList<>(); + if (rootOptions != null && rootOptions.isRoot) { + assertions.add(new Assertion.RootUser()); + } + if (rootOptions != null && rootOptions.isNotRoot) { + assertions.add(new Assertion.NotRootUser()); + } + if (directoryOptions != null && directoryOptions.existsDir != null) { + assertions.add(new Assertion.DirExists(directoryOptions.existsDir)); + } + if (directoryOptions != null && directoryOptions.notExistsDir != null) { + assertions.add(new Assertion.DirNotExists(directoryOptions.notExistsDir)); + } + if (sameUserOpt != null) { + assertions.add(new Assertion.SameUser(sameUserOpt)); + } + if (runningOptions != null && runningOptions.startedUrl != null) { + assertions.add(new Assertion.SolrRunning(runningOptions.startedUrl)); + } + if (runningOptions != null && runningOptions.notStartedUrl != null) { + assertions.add(new Assertion.SolrNotRunning(runningOptions.notStartedUrl)); + } + if (cloudOptions != null && cloudOptions.cloudUrl != null) { + assertions.add(new Assertion.CloudMode(cloudOptions.cloudUrl)); + } + if (cloudOptions != null && cloudOptions.notCloudUrl != null) { + assertions.add(new Assertion.NotCloudMode(cloudOptions.notCloudUrl)); + } + + try { + return runAssert( + new AssertParams( + messageOpt, + timeoutOpt, + exitCodeOpt, + credentialsOptions.credentials, + List.copyOf(assertions))); + } catch (Exception exc) { + // Mirrors the commons-cli path's runTool() override: an assertion failure or other error + // with a message becomes exit code 100, not the ToolBase default of 1. + String excMsg = exc.getMessage(); + if (excMsg == null) { + throw exc; + } + if (isVerbose()) { + CLIO.err("\nERROR: " + exc + "\n"); + } else { + CLIO.err("\nERROR: " + excMsg + "\n"); + } + return 100; + } } public static class AssertionFailureException extends Exception { From a66829cfacbddf686c408d27d81883bcc0444e61 Mon Sep 17 00:00:00 2001 From: Serhiy Bzhezytskyy Date: Tue, 22 Sep 2026 14:11:10 +0300 Subject: [PATCH 05/13] SOLR-17697: Implement picocli for healthcheck command / HealthcheckTool Uses the new CLIUtils.resolveSolrConnection helper; healthcheck only works in cloud mode, so a null resolution still prints the same error and exits 1 as the commons-cli path. --- .../org/apache/solr/cli/HealthcheckTool.java | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/cli/HealthcheckTool.java b/solr/core/src/java/org/apache/solr/cli/HealthcheckTool.java index 7311877cc78c..afe36da1c859 100644 --- a/solr/core/src/java/org/apache/solr/cli/HealthcheckTool.java +++ b/solr/core/src/java/org/apache/solr/cli/HealthcheckTool.java @@ -46,6 +46,12 @@ import org.slf4j.LoggerFactory; /** Supports healthcheck command in the bin/solr script. */ +@SuppressWarnings("UnnecessarilyFullyQualified") +@picocli.CommandLine.Command( + name = "healthcheck", + description = "Requests health information about a specific collection in SolrCloud.", + footerHeading = "%nExamples:%n", + footer = {" # Check the health of a collection", " bin/solr healthcheck -c gettingstarted"}) public class HealthcheckTool extends ToolBase { private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); @@ -76,6 +82,24 @@ enum ShardState { /** Parameters for the healthcheck command, independent of the command line parser. */ record HealthcheckParams(String collection, String credentials) {} + // --- picocli fields --- + + @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "0..1") + private ConnectionOptions connectionOptions; + + @picocli.CommandLine.Mixin private CredentialsOptions credentialsOptions; + + @picocli.CommandLine.Option( + names = {"-c", "--name"}, + required = true, + paramLabel = "COLLECTION", + description = "Name of the collection to check.") + private String nameOpt; + + public HealthcheckTool() { + this(new DefaultToolRuntime()); + } + /** Requests health information about a specific collection in SolrCloud. */ public HealthcheckTool(ToolRuntime runtime) { super(runtime); @@ -213,7 +237,20 @@ protected void runCloudTool(CloudSolrClient cloudSolrClient, HealthcheckParams p @Override public int callTool() throws Exception { - throw new UnsupportedOperationException("This tool does not yet support PicoCli"); + var solrConnection = + CLIUtils.resolveSolrConnection(connectionOptions, credentialsOptions.credentials); + if (solrConnection == null) { + CLIO.err("Healthcheck tool only works in Solr Cloud mode."); + runtime.exit(1); + } + HealthcheckParams params = new HealthcheckParams(nameOpt, credentialsOptions.credentials); + var builder = + new HttpJettySolrClient.Builder().withOptionalBasicAuthCredentials(params.credentials()); + try (var cloudSolrClient = CLIUtils.getCloudSolrClient(solrConnection, builder)) { + echoIfVerbose("Connecting to Solr at " + solrConnection.toString()); + runCloudTool(cloudSolrClient, params); + } + return 0; } } From 9be1048f946364d46088f0765a4f92ed63151940 Mon Sep 17 00:00:00 2001 From: Serhiy Bzhezytskyy Date: Tue, 22 Sep 2026 14:11:10 +0300 Subject: [PATCH 06/13] SOLR-17697: Implement picocli for cluster command / ClusterTool This tool only ever exposed --zk-host (not the full connection group), so its own resolveZkHost() mirrors CLIUtils.getZkHost(CommandLine) directly rather than reusing ConnectionOptions. Verified live: writes to ZooKeeper's /clusterprops.json exactly as the commons-cli path does. --- .../java/org/apache/solr/cli/ClusterTool.java | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/cli/ClusterTool.java b/solr/core/src/java/org/apache/solr/cli/ClusterTool.java index ce43eff54f1d..bb6b2d8d2448 100644 --- a/solr/core/src/java/org/apache/solr/cli/ClusterTool.java +++ b/solr/core/src/java/org/apache/solr/cli/ClusterTool.java @@ -18,6 +18,7 @@ package org.apache.solr.cli; import java.io.IOException; +import java.util.Map; import java.util.concurrent.TimeUnit; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.Option; @@ -32,6 +33,15 @@ * *

Set cluster properties by directly manipulating ZooKeeper. */ +@SuppressWarnings("UnnecessarilyFullyQualified") +@picocli.CommandLine.Command( + name = "cluster", + description = "Set cluster properties by directly manipulating ZooKeeper.", + footerHeading = "%nExamples:%n", + footer = { + " # Set the urlScheme cluster property", + " bin/solr cluster --property urlScheme --value https" + }) public class ClusterTool extends ToolBase { // It is a shame this tool doesn't more closely mimic how the ConfigTool works. @@ -55,6 +65,33 @@ public class ClusterTool extends ToolBase { /** Parameters for the cluster command, independent of the command line parser. */ record ClusterParams(String propertyName, String propertyValue, String zkHost) {} + // --- picocli fields --- + + @picocli.CommandLine.Option( + names = "--property", + required = true, + paramLabel = "PROPERTY", + description = "Name of the Cluster property to apply the action to, such as: 'urlScheme'.") + private String propertyOpt; + + @picocli.CommandLine.Option( + names = "--value", + paramLabel = "VALUE", + description = "Set the property to this value.") + private String valueOpt; + + @picocli.CommandLine.Option( + names = {"-z", "--zk-host"}, + paramLabel = "zkHost", + description = + "Zookeeper connection string; unnecessary if ZK_HOST is defined in solr.in.sh;" + + " otherwise, discovered from a running Solr instance.") + private String zkHostOpt; + + public ClusterTool() { + this(new DefaultToolRuntime()); + } + public ClusterTool(ToolRuntime runtime) { super(runtime); } @@ -111,6 +148,32 @@ void setClusterProperty(ClusterParams params) throws Exception { @Override public int callTool() throws Exception { - throw new UnsupportedOperationException("This tool does not yet support PicoCli"); + ClusterParams params = new ClusterParams(propertyOpt, valueOpt, resolveZkHost()); + setClusterProperty(params); + return 0; + } + + /** + * Mirrors {@link CLIUtils#getZkHost(CommandLine)}: explicit {@code --zk-host} wins outright, + * otherwise discovered from a running Solr instance at the default URL. + */ + private String resolveZkHost() throws Exception { + if (zkHostOpt != null && !zkHostOpt.isBlank()) { + return zkHostOpt; + } + String defaultSolrUrl = CLIUtils.getDefaultSolrUrl(); + try (var solrClient = CLIUtils.getSolrClient(defaultSolrUrl, null)) { + Map status = StatusTool.reportStatus(solrClient); + @SuppressWarnings("unchecked") + Map cloud = (Map) status.get("cloud"); + if (cloud == null) { + return null; + } + String zookeeper = (String) cloud.get("ZooKeeper"); + if (zookeeper.endsWith("(embedded)")) { + zookeeper = zookeeper.substring(0, zookeeper.length() - "(embedded)".length()); + } + return zookeeper; + } } } From b2ab84fb24896c467027da3696ae8beea1fb9824 Mon Sep 17 00:00:00 2001 From: Serhiy Bzhezytskyy Date: Tue, 22 Sep 2026 14:11:18 +0300 Subject: [PATCH 07/13] SOLR-17697: Implement picocli for config command / ConfigTool --value drops its "-v" short form: that letter is already ToolBase's --verbose, and the two silently collide in the commons-cli path today (VALUE_OPTION is added after VERBOSE_OPTION, so -v currently means --value there, not --verbose); picocli refuses the duplicate outright. Verified live against a real Solr instance, including the "value is required unless the action is unset-*" validation. --- .../java/org/apache/solr/cli/ConfigTool.java | 69 ++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/cli/ConfigTool.java b/solr/core/src/java/org/apache/solr/cli/ConfigTool.java index 4c0ac64935da..97c8869270dc 100644 --- a/solr/core/src/java/org/apache/solr/cli/ConfigTool.java +++ b/solr/core/src/java/org/apache/solr/cli/ConfigTool.java @@ -34,6 +34,16 @@ * *

Sends a POST to the Config API to perform a specified action. */ +@SuppressWarnings("UnnecessarilyFullyQualified") +@picocli.CommandLine.Command( + name = "config", + description = "Sends a POST to the Config API to perform a specified action.", + footerHeading = "%nExamples:%n", + footer = { + " # Set a config property", + " bin/solr config -c mycollection --property updateHandler.autoSoftCommit.maxTime --value" + + " 10000" + }) public class ConfigTool extends ToolBase { private static final Option COLLECTION_NAME_OPTION = @@ -81,6 +91,52 @@ record ConfigParams( String value, String credentials) {} + // --- picocli fields --- + + @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "0..1") + private ConnectionOptions connectionOptions; + + @picocli.CommandLine.Mixin private CredentialsOptions credentialsOptions; + + @picocli.CommandLine.Option( + names = {"-c", "--name"}, + required = true, + paramLabel = "NAME", + description = "Name of the collection.") + private String nameOpt; + + @picocli.CommandLine.Option( + names = {"-a", "--action"}, + defaultValue = "set-property", + paramLabel = "ACTION", + description = + "Config API action, one of: set-property, unset-property, set-user-property," + + " unset-user-property; default is 'set-property'.") + private String actionOpt; + + @picocli.CommandLine.Option( + names = "--property", + required = true, + paramLabel = "PROP", + description = + "Name of the Config API property to apply the action to, such as:" + + " 'updateHandler.autoSoftCommit.maxTime'.") + private String propertyOpt; + + // No short "-v": that letter is already ToolBase's --verbose. In the commons-cli path the two + // silently collide (VALUE_OPTION is added to the Options set after VERBOSE_OPTION, so its + // short form wins and -v currently means --value, not --verbose) - picocli refuses that + // collision outright, so --value is long-only here and -v keeps its ToolBase meaning. + @picocli.CommandLine.Option( + names = "--value", + paramLabel = "VALUE", + description = "Set the property to this value; accepts JSON objects and strings.") + private String valueOpt; + + public ConfigTool() { + this(new DefaultToolRuntime()); + } + public ConfigTool(ToolRuntime runtime) { super(runtime); } @@ -165,6 +221,17 @@ void updateConfig(ConfigParams params) throws Exception { @Override public int callTool() throws Exception { - throw new UnsupportedOperationException("This tool does not yet support PicoCli"); + String solrUrl = CLIUtils.resolveSolrUrl(connectionOptions, credentialsOptions.credentials); + + // value is required unless the property is one of the "unset-" type. + if (!actionOpt.contains("unset-") && valueOpt == null) { + throw new MissingArgumentException("'value' is a required option."); + } + + ConfigParams params = + new ConfigParams( + solrUrl, actionOpt, nameOpt, propertyOpt, valueOpt, credentialsOptions.credentials); + updateConfig(params); + return 0; } } From 699a6977ab91f6dc1d563d771ed8e88730a87c47 Mon Sep 17 00:00:00 2001 From: Serhiy Bzhezytskyy Date: Tue, 22 Sep 2026 14:11:19 +0300 Subject: [PATCH 08/13] SOLR-17697: Implement picocli for api command / ApiTool Verified live: same GenericSolrRequest/JsonMapResponseParser path, same output, against a real Solr instance. --- .../src/java/org/apache/solr/cli/ApiTool.java | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/cli/ApiTool.java b/solr/core/src/java/org/apache/solr/cli/ApiTool.java index 5bc0e42134d0..ca1bb3db8fad 100644 --- a/solr/core/src/java/org/apache/solr/cli/ApiTool.java +++ b/solr/core/src/java/org/apache/solr/cli/ApiTool.java @@ -34,6 +34,15 @@ * *

Used to send an arbitrary HTTP request to a Solr API endpoint. */ +@SuppressWarnings("UnnecessarilyFullyQualified") +@picocli.CommandLine.Command( + name = "api", + description = "Used to send an arbitrary HTTP GET request to a Solr API endpoint.", + footerHeading = "%nExamples:%n", + footer = { + " # Send a GET request to a Solr API endpoint", + " bin/solr api -s http://localhost:8983/api/cluster" + }) public class ApiTool extends ToolBase { private static final Option SOLR_URL_OPTION = @@ -48,6 +57,21 @@ public class ApiTool extends ToolBase { /** Parameters for the api command, independent of the command line parser. */ record ApiParams(String getUrl, String credentials) {} + // --- picocli fields --- + + @picocli.CommandLine.Option( + names = {"-s", "--solr-url"}, + required = true, + paramLabel = "URL", + description = "Send a GET request to a Solr API endpoint.") + private String solrUrlOpt; + + @picocli.CommandLine.Mixin private CredentialsOptions credentialsOptions; + + public ApiTool() { + this(new DefaultToolRuntime()); + } + public ApiTool(ToolRuntime runtime) { super(runtime); } @@ -130,6 +154,8 @@ public static ModifiableSolrParams getSolrParamsFromUri(URI uri) { @Override public int callTool() throws Exception { - throw new UnsupportedOperationException("This tool does not yet support PicoCli"); + ApiParams params = new ApiParams(solrUrlOpt, credentialsOptions.credentials); + callApi(params); + return 0; } } From 37b728bf87120ede134ad381e17dc0c48db4ecb5 Mon Sep 17 00:00:00 2001 From: Serhiy Bzhezytskyy Date: Tue, 22 Sep 2026 14:11:26 +0300 Subject: [PATCH 09/13] SOLR-17697: Implement picocli for export command / ExportTool The connection group is required (ArgGroup multiplicity "1"): the commons-cli path throws IllegalArgumentException when no connection target is given, so this makes picocli enforce the same requirement declaratively instead. --- .../java/org/apache/solr/cli/ExportTool.java | 85 ++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/cli/ExportTool.java b/solr/core/src/java/org/apache/solr/cli/ExportTool.java index b63c2ba002d1..d5fefce436ee 100644 --- a/solr/core/src/java/org/apache/solr/cli/ExportTool.java +++ b/solr/core/src/java/org/apache/solr/cli/ExportTool.java @@ -84,6 +84,15 @@ import org.noggit.JSONWriter; /** Supports export command in the bin/solr script. */ +@SuppressWarnings("UnnecessarilyFullyQualified") +@picocli.CommandLine.Command( + name = "export", + description = "Exports documents from a collection to a local file.", + footerHeading = "%nExamples:%n", + footer = { + " # Export a collection's documents to gettingstarted.json", + " bin/solr export -c gettingstarted --solr-url http://localhost:8983" + }) public class ExportTool extends ToolBase { private static final Option COLLECTION_NAME_OPTION = @@ -149,6 +158,66 @@ record ExportParams( String fields, String limit) {} + // --- picocli fields --- + // The connection group is mandatory (multiplicity "1"): the commons-cli path throws + // IllegalArgumentException when no connection target is given, so ArgGroup enforces the same + // requirement declaratively. + + @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "1") + private ConnectionOptions connectionOptions; + + @picocli.CommandLine.Mixin private CredentialsOptions credentialsOptions; + + @picocli.CommandLine.Option( + names = {"-c", "--name"}, + required = true, + paramLabel = "NAME", + description = "Name of the collection.") + private String nameOpt; + + @picocli.CommandLine.Option( + names = "--output", + paramLabel = "PATH", + description = + "Path to output the exported data, and optionally the file name, defaults to" + + " 'collection-name'.") + private String outputOpt; + + @picocli.CommandLine.Option( + names = "--format", + paramLabel = "FORMAT", + description = "Output format for exported docs (json, jsonl or javabin), defaulting to json.") + private String formatOpt; + + @picocli.CommandLine.Option( + names = "--compress", + description = "Compress the output. Defaults to false.") + private boolean compressOpt; + + @picocli.CommandLine.Option( + names = "--limit", + defaultValue = "100", + paramLabel = "#", + description = "Maximum number of docs to download. Default is 100, use -1 for all docs.") + private String limitOpt; + + @picocli.CommandLine.Option( + names = "--query", + defaultValue = "*:*", + paramLabel = "QUERY", + description = "A custom query, default is '*:*'.") + private String queryOpt; + + @picocli.CommandLine.Option( + names = "--fields", + paramLabel = "FIELDA,FIELDB", + description = "Comma separated list of fields to export. By default all fields are fetched.") + private String fieldsOpt; + + public ExportTool() { + this(new DefaultToolRuntime()); + } + public ExportTool(ToolRuntime runtime) { super(runtime); } @@ -729,6 +798,20 @@ static long getDocCount(String coreName, SolrClient client, String query) @Override public int callTool() throws Exception { - throw new UnsupportedOperationException("This tool does not yet support PicoCli"); + String resolvedSolrUrl = + CLIUtils.resolveSolrUrl(connectionOptions, credentialsOptions.credentials); + String url = resolvedSolrUrl + "/solr/" + nameOpt; + ExportParams params = + new ExportParams( + url, + credentialsOptions.credentials, + queryOpt, + outputOpt, + formatOpt, + compressOpt, + fieldsOpt, + limitOpt); + export(params); + return 0; } } From 4029fc6fdbc67169ee7ed9a2185ec8ec15aa7be4 Mon Sep 17 00:00:00 2001 From: Serhiy Bzhezytskyy Date: Tue, 22 Sep 2026 14:11:27 +0300 Subject: [PATCH 10/13] SOLR-17697: Implement picocli for postlogs command / PostLogsTool Same mandatory-connection-group treatment as ExportTool. Verified live against a real log file; a pre-existing LogRecordReader parsing quirk on certain QTime lines (NPE on a null params string) reproduces identically on both parsers, confirming it predates this conversion. --- .../org/apache/solr/cli/PostLogsTool.java | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/cli/PostLogsTool.java b/solr/core/src/java/org/apache/solr/cli/PostLogsTool.java index 2764ba4b9fb4..cb0efd81c314 100644 --- a/solr/core/src/java/org/apache/solr/cli/PostLogsTool.java +++ b/solr/core/src/java/org/apache/solr/cli/PostLogsTool.java @@ -45,6 +45,16 @@ import org.apache.solr.handler.component.ShardRequest; /** A command line tool for indexing Solr logs in the out-of-the-box log format. */ +@SuppressWarnings("UnnecessarilyFullyQualified") +@picocli.CommandLine.Command( + name = "postlogs", + description = "Indexes Solr logs in the out-of-the-box log format.", + footerHeading = "%nExamples:%n", + footer = { + " # Index all logs found under a directory", + " bin/solr postlogs -c gettingstarted --rootdir /var/solr/logs --solr-url" + + " http://localhost:8983" + }) public class PostLogsTool extends ToolBase { private static final Option COLLECTION_NAME_OPTION = @@ -68,6 +78,33 @@ public class PostLogsTool extends ToolBase { /** Parameters for the postlogs command, independent of the command line parser. */ record PostLogsParams(String url, String rootDir, String credentials) {} + // --- picocli fields --- + // The connection group is mandatory (multiplicity "1"), mirroring the commons-cli path's + // manual IllegalArgumentException when no connection target is given. + + @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "1") + private ConnectionOptions connectionOptions; + + @picocli.CommandLine.Mixin private CredentialsOptions credentialsOptions; + + @picocli.CommandLine.Option( + names = {"-c", "--name"}, + required = true, + paramLabel = "NAME", + description = "Name of the collection.") + private String nameOpt; + + @picocli.CommandLine.Option( + names = "--rootdir", + required = true, + paramLabel = "DIRECTORY", + description = "All files found at or below the root directory will be indexed.") + private String rootDirOpt; + + public PostLogsTool() { + this(new DefaultToolRuntime()); + } + public PostLogsTool(ToolRuntime runtime) { super(runtime); } @@ -639,6 +676,11 @@ public static String[] getRequestPurposeNames(Integer reqPurpose) { @Override public int callTool() throws Exception { - throw new UnsupportedOperationException("This tool does not yet support PicoCli"); + String resolvedSolrUrl = + CLIUtils.resolveSolrUrl(connectionOptions, credentialsOptions.credentials); + String url = resolvedSolrUrl + "/solr/" + nameOpt; + PostLogsParams params = new PostLogsParams(url, rootDirOpt, credentialsOptions.credentials); + runCommand(params); + return 0; } } From 18f72b421c223fb900c60d425bb81b4d5aefc760 Mon Sep 17 00:00:00 2001 From: Serhiy Bzhezytskyy Date: Tue, 22 Sep 2026 14:11:37 +0300 Subject: [PATCH 11/13] SOLR-17697: Implement picocli for run_example (bin/solr start -e) --port keeps no explicit defaultValue: its paramLabel "port" matches CliDefaultValueProvider's case (solr.port.listen sysprop / SOLR_PORT_LISTEN env var, else 8983), and --zk-host's paramLabel "zkHost" does the same for the case, mirroring the commons-cli path's fallbacks without duplicating them. --- .../org/apache/solr/cli/RunExampleTool.java | 184 +++++++++++++++++- 1 file changed, 183 insertions(+), 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/cli/RunExampleTool.java b/solr/core/src/java/org/apache/solr/cli/RunExampleTool.java index b5f0b162fc94..87494c1dfdd3 100644 --- a/solr/core/src/java/org/apache/solr/cli/RunExampleTool.java +++ b/solr/core/src/java/org/apache/solr/cli/RunExampleTool.java @@ -56,6 +56,20 @@ *

Enhances start command by providing an interactive session with the user to launch (or * relaunch the -e cloud example) */ +@SuppressWarnings("UnnecessarilyFullyQualified") +@picocli.CommandLine.Command( + name = "run_example", + description = + "Enhances the start command by providing an interactive session with the user to launch" + + " (or relaunch) one of the bundled examples.", + footerHeading = "%nExamples:%n", + footer = { + " # Launch the interactive SolrCloud example", + " bin/solr start -e cloud", + "", + " # Launch the techproducts example, accepting all prompt defaults", + " bin/solr start -e techproducts --no-prompt" + }) public class RunExampleTool extends ToolBase { private static final String PROMPT_FOR_NUMBER = "Please enter %s [%d]: "; @@ -221,6 +235,127 @@ record RunExampleParams(boolean isCloudMode, String zkHost, int port, StartSolrP record CloudExampleParams( boolean noPrompt, String promptInputs, String zkHost, int basePort, StartSolrParams start) {} + // --- picocli fields --- + + @picocli.CommandLine.Option( + names = {"-y", "--no-prompt"}, + description = + "Don't prompt for input; accept all defaults when running examples that accept user" + + " input.") + private boolean noPromptOpt; + + @picocli.CommandLine.Option( + names = "--prompt-inputs", + paramLabel = "VALUES", + description = + "Provide comma-separated values for prompts. Same as --no-prompt but uses provided" + + " values instead of defaults. Example: --prompt-inputs" + + " 3,8983,8984,8985,\"gettingstarted\",2,2,_default") + private String promptInputsOpt; + + @picocli.CommandLine.Option( + names = {"-e", "--example"}, + required = true, + paramLabel = "NAME", + description = + "Name of the example to launch, one of: cloud, techproducts, schemaless, films.") + private String exampleOpt; + + @picocli.CommandLine.Option( + names = "--script", + paramLabel = "PATH", + description = "Path to the bin/solr script.") + private String scriptOpt; + + @picocli.CommandLine.Option( + names = {"-d", "--server-dir"}, + required = true, + paramLabel = "DIR", + description = "Path to the Solr server directory.") + private String serverDirOpt; + + @picocli.CommandLine.Option( + names = {"-f", "--force"}, + description = "Force option in case Solr is run as root.") + private boolean forceOpt; + + @picocli.CommandLine.Option( + names = "--example-dir", + paramLabel = "DIR", + description = + "Path to the Solr example directory; if not provided, ${serverDir}/../example is" + + " expected to exist.") + private String exampleDirOpt; + + @picocli.CommandLine.Option( + names = "--solr-home", + paramLabel = "SOLR_HOME_DIR", + description = + "Path to the Solr home directory; if not provided, ${serverDir}/solr is expected to" + + " exist.") + private String solrHomeOpt; + + @picocli.CommandLine.Option( + names = "--url-scheme", + defaultValue = "http", + paramLabel = "SCHEME", + description = "Solr URL scheme: http or https, defaults to http if not specified.") + private String urlSchemeOpt; + + // No defaultValue attribute: paramLabel "port" matches CliDefaultValueProvider's case + // (falls back to the solr.port.listen sysprop / SOLR_PORT_LISTEN env var, else 8983), mirroring + // the commons-cli path's System.getenv().getOrDefault("SOLR_PORT_LISTEN", "8983"). + @picocli.CommandLine.Option( + names = {"-p", "--port"}, + paramLabel = "port", + description = "Specify the port to start the Solr HTTP listener on; default is 8983.") + private int port; + + @picocli.CommandLine.Option( + names = "--host", + paramLabel = "HOSTNAME", + description = "Specify the hostname for this Solr instance.") + private String hostOpt; + + @picocli.CommandLine.Option( + names = "--user-managed", + description = "Start Solr in User Managed mode.") + private boolean userManagedOpt; + + @picocli.CommandLine.Option( + names = {"-m", "--memory"}, + paramLabel = "MEM", + description = + "Sets the min (-Xms) and max (-Xmx) heap size for the JVM, such as: -m 4g results in:" + + " -Xms4g -Xmx4g; by default, this script sets the heap size to 512m.") + private String memoryOpt; + + @picocli.CommandLine.Option( + names = "--jvm-opts", + paramLabel = "OPTS", + description = + "Additional options to be passed to the JVM when starting example Solr server(s).") + private String jvmOptsOpt; + + // paramLabel "zkHost" matches CliDefaultValueProvider's case (falls back to the zkHost + // sysprop / ZK_HOST env var, else null), mirroring the commons-cli path's + // CLIUtils.getCliOptionOrPropValue(cli, ZK_HOST_OPTION, "zkHost", null). + @picocli.CommandLine.Option( + names = {"-z", "--zk-host"}, + paramLabel = "zkHost", + description = "Zookeeper connection string.") + private String zkHostOpt; + + @picocli.CommandLine.Parameters( + arity = "0..*", + paramLabel = "ARG", + description = "Extra arguments passed through to the underlying bin/solr start command.") + private String[] extraArgsOpt = new String[0]; + + public RunExampleTool() { + this(new DefaultToolRuntime()); + } + /** Default constructor used by the framework when running as a command-line application. */ public RunExampleTool(ToolRuntime runtime) { this(null, System.in, runtime); @@ -1140,7 +1275,54 @@ protected void copyIfNeeded(Path src, Path dest) throws IOException { @Override public int callTool() throws Exception { - throw new UnsupportedOperationException("This tool does not yet support PicoCli"); + if (noPromptOpt && promptInputsOpt != null) { + throw new IllegalArgumentException( + "Cannot use both --no-prompt and --prompt-inputs options together. " + + "Use --no-prompt to accept defaults, or --prompt-inputs to provide specific values."); + } + + this.urlScheme = urlSchemeOpt; + String exampleType = exampleOpt; + + initDirs(serverDirOpt, scriptOpt, exampleDirOpt, solrHomeOpt, exampleType); + + echoIfVerbose( + "Running with\nserverDir=" + + serverDir.toAbsolutePath() + + ",\nexampleDir=" + + exampleDir.toAbsolutePath() + + ",\nsolrHomeDir=" + + solrHomeDir.toAbsolutePath() + + "\nscript=" + + script); + + if (!"cloud".equals(exampleType) + && !"techproducts".equals(exampleType) + && !"schemaless".equals(exampleType) + && !"films".equals(exampleType)) { + throw new IllegalArgumentException( + "Unsupported example " + + exampleType + + "! Please choose one of: cloud, schemaless, techproducts, or films"); + } + + StartSolrParams startParams = + new StartSolrParams( + exampleType, + hostOpt, + memoryOpt, + jvmOptsOpt, + forceOpt, + null, + readExtraArgs(extraArgsOpt)); + + if ("cloud".equals(exampleType)) { + runCloudExample( + new CloudExampleParams(noPromptOpt, promptInputsOpt, zkHostOpt, port, startParams)); + } else { + runExample(new RunExampleParams(!userManagedOpt, zkHostOpt, port, startParams)); + } + return 0; } protected boolean isPortAvailable(int port) { From 2b37c9ef118f913c71990093ca35335359c3d677 Mon Sep 17 00:00:00 2001 From: Serhiy Bzhezytskyy Date: Tue, 22 Sep 2026 14:11:37 +0300 Subject: [PATCH 12/13] SOLR-17697: Implement picocli for the snapshot-* commands snapshot-create, snapshot-delete, snapshot-describe, snapshot-export and snapshot-list, bundled as one commit since they share the same shape (collection name + optional snapshot name + connection group) and are one cohesive unit of the CLI's snapshot lifecycle. snapshot-export keeps accepting --snapshot-name only to reject it with the same explanation as the commons-cli path (removed non-incremental backup format). Verified the full lifecycle live: create, list, describe, delete. --- .../apache/solr/cli/SnapshotCreateTool.java | 43 ++++++++++- .../apache/solr/cli/SnapshotDeleteTool.java | 43 ++++++++++- .../apache/solr/cli/SnapshotDescribeTool.java | 43 ++++++++++- .../apache/solr/cli/SnapshotExportTool.java | 73 ++++++++++++++++++- .../org/apache/solr/cli/SnapshotListTool.java | 32 +++++++- 5 files changed, 229 insertions(+), 5 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/cli/SnapshotCreateTool.java b/solr/core/src/java/org/apache/solr/cli/SnapshotCreateTool.java index 89263c9da2ca..6926d66d7b11 100644 --- a/solr/core/src/java/org/apache/solr/cli/SnapshotCreateTool.java +++ b/solr/core/src/java/org/apache/solr/cli/SnapshotCreateTool.java @@ -24,6 +24,15 @@ import org.apache.solr.client.solrj.response.CollectionAdminResponse; /** Supports snapshot-create command in the bin/solr script. */ +@SuppressWarnings("UnnecessarilyFullyQualified") +@picocli.CommandLine.Command( + name = "snapshot-create", + description = "Creates a named snapshot of a collection.", + footerHeading = "%nExamples:%n", + footer = { + " # Create a snapshot", + " bin/solr snapshot-create -c mycollection --snapshot-name snap1" + }) public class SnapshotCreateTool extends ToolBase { private static final Option COLLECTION_NAME_OPTION = @@ -48,6 +57,31 @@ public class SnapshotCreateTool extends ToolBase { record SnapshotCreateParams( String solrUrl, String credentials, String collectionName, String snapshotName) {} + // --- picocli fields --- + + @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "0..1") + private ConnectionOptions connectionOptions; + + @picocli.CommandLine.Mixin private CredentialsOptions credentialsOptions; + + @picocli.CommandLine.Option( + names = {"-c", "--name"}, + required = true, + paramLabel = "NAME", + description = "Name of collection to be snapshot.") + private String nameOpt; + + @picocli.CommandLine.Option( + names = "--snapshot-name", + required = true, + paramLabel = "NAME", + description = "Name of the snapshot to produce") + private String snapshotNameOpt; + + public SnapshotCreateTool() { + this(new DefaultToolRuntime()); + } + public SnapshotCreateTool(ToolRuntime runtime) { super(runtime); } @@ -112,6 +146,13 @@ public void createSnapshot(SolrClient solrClient, String collectionName, String @Override public int callTool() throws Exception { - throw new UnsupportedOperationException("This tool does not yet support PicoCli"); + SnapshotCreateParams params = + new SnapshotCreateParams( + CLIUtils.resolveSolrUrl(connectionOptions, credentialsOptions.credentials), + credentialsOptions.credentials, + nameOpt, + snapshotNameOpt); + createSnapshot(params); + return 0; } } diff --git a/solr/core/src/java/org/apache/solr/cli/SnapshotDeleteTool.java b/solr/core/src/java/org/apache/solr/cli/SnapshotDeleteTool.java index 7ff35e5e95a9..c660521a0994 100644 --- a/solr/core/src/java/org/apache/solr/cli/SnapshotDeleteTool.java +++ b/solr/core/src/java/org/apache/solr/cli/SnapshotDeleteTool.java @@ -24,6 +24,15 @@ import org.apache.solr.client.solrj.response.CollectionAdminResponse; /** Supports snapshot-delete command in the bin/solr script. */ +@SuppressWarnings("UnnecessarilyFullyQualified") +@picocli.CommandLine.Command( + name = "snapshot-delete", + description = "Deletes a named snapshot of a collection.", + footerHeading = "%nExamples:%n", + footer = { + " # Delete a snapshot", + " bin/solr snapshot-delete -c mycollection --snapshot-name snap1" + }) public class SnapshotDeleteTool extends ToolBase { private static final Option COLLECTION_NAME_OPTION = @@ -48,6 +57,31 @@ public class SnapshotDeleteTool extends ToolBase { record SnapshotDeleteParams( String solrUrl, String credentials, String collectionName, String snapshotName) {} + // --- picocli fields --- + + @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "0..1") + private ConnectionOptions connectionOptions; + + @picocli.CommandLine.Mixin private CredentialsOptions credentialsOptions; + + @picocli.CommandLine.Option( + names = {"-c", "--name"}, + required = true, + paramLabel = "NAME", + description = "Name of collection to manage.") + private String nameOpt; + + @picocli.CommandLine.Option( + names = "--snapshot-name", + required = true, + paramLabel = "NAME", + description = "Name of the snapshot to delete") + private String snapshotNameOpt; + + public SnapshotDeleteTool() { + this(new DefaultToolRuntime()); + } + public SnapshotDeleteTool(ToolRuntime runtime) { super(runtime); } @@ -112,6 +146,13 @@ public void deleteSnapshot(SolrClient solrClient, String collectionName, String @Override public int callTool() throws Exception { - throw new UnsupportedOperationException("This tool does not yet support PicoCli"); + SnapshotDeleteParams params = + new SnapshotDeleteParams( + CLIUtils.resolveSolrUrl(connectionOptions, credentialsOptions.credentials), + credentialsOptions.credentials, + nameOpt, + snapshotNameOpt); + deleteSnapshot(params); + return 0; } } diff --git a/solr/core/src/java/org/apache/solr/cli/SnapshotDescribeTool.java b/solr/core/src/java/org/apache/solr/cli/SnapshotDescribeTool.java index d945c23d57f6..aab58b6e8d30 100644 --- a/solr/core/src/java/org/apache/solr/cli/SnapshotDescribeTool.java +++ b/solr/core/src/java/org/apache/solr/cli/SnapshotDescribeTool.java @@ -34,6 +34,15 @@ import org.apache.solr.core.snapshots.SolrSnapshotManager; /** Supports snapshot-describe command in the bin/solr script. */ +@SuppressWarnings("UnnecessarilyFullyQualified") +@picocli.CommandLine.Command( + name = "snapshot-describe", + description = "Describes a named snapshot of a collection.", + footerHeading = "%nExamples:%n", + footer = { + " # Describe a snapshot", + " bin/solr snapshot-describe -c mycollection --snapshot-name snap1" + }) public class SnapshotDescribeTool extends ToolBase { private static final Option COLLECTION_NAME_OPTION = @@ -58,6 +67,31 @@ public class SnapshotDescribeTool extends ToolBase { record SnapshotDescribeParams( String solrUrl, String credentials, String collectionName, String snapshotName) {} + // --- picocli fields --- + + @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "0..1") + private ConnectionOptions connectionOptions; + + @picocli.CommandLine.Mixin private CredentialsOptions credentialsOptions; + + @picocli.CommandLine.Option( + names = {"-c", "--name"}, + required = true, + paramLabel = "NAME", + description = "Name of collection to be snapshot.") + private String nameOpt; + + @picocli.CommandLine.Option( + names = "--snapshot-name", + required = true, + paramLabel = "NAME", + description = "Name of the snapshot to describe") + private String snapshotNameOpt; + + public SnapshotDescribeTool() { + this(new DefaultToolRuntime()); + } + public SnapshotDescribeTool(ToolRuntime runtime) { super(runtime); } @@ -151,6 +185,13 @@ private Collection listCollectionSnapshots( @Override public int callTool() throws Exception { - throw new UnsupportedOperationException("This tool does not yet support PicoCli"); + SnapshotDescribeParams params = + new SnapshotDescribeParams( + CLIUtils.resolveSolrUrl(connectionOptions, credentialsOptions.credentials), + credentialsOptions.credentials, + nameOpt, + snapshotNameOpt); + describeSnapshot(params); + return 0; } } diff --git a/solr/core/src/java/org/apache/solr/cli/SnapshotExportTool.java b/solr/core/src/java/org/apache/solr/cli/SnapshotExportTool.java index 8544122e261c..35aba0aafc1f 100644 --- a/solr/core/src/java/org/apache/solr/cli/SnapshotExportTool.java +++ b/solr/core/src/java/org/apache/solr/cli/SnapshotExportTool.java @@ -28,6 +28,15 @@ import org.apache.solr.common.params.CollectionAdminParams; /** Supports snapshot-export command in the bin/solr script. */ +@SuppressWarnings("UnnecessarilyFullyQualified") +@picocli.CommandLine.Command( + name = "snapshot-export", + description = "Backs up a collection's current state to a local directory.", + footerHeading = "%nExamples:%n", + footer = { + " # Export a collection's current state as a backup", + " bin/solr snapshot-export -c mycollection --dest-dir /tmp/backups --backup-repo-name local" + }) public class SnapshotExportTool extends ToolBase { private static final DateTimeFormatter BACKUP_NAME_TIMESTAMP = @@ -90,6 +99,53 @@ record SnapshotExportParams( String backupRepo, String asyncReqId) {} + // --- picocli fields --- + + @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "0..1") + private ConnectionOptions connectionOptions; + + @picocli.CommandLine.Mixin private CredentialsOptions credentialsOptions; + + @picocli.CommandLine.Option( + names = {"-c", "--name"}, + required = true, + paramLabel = "NAME", + description = "Name of the collection to be backed up.") + private String nameOpt; + + // Accepted only so that passing it can be rejected with an explanation; see callTool(). + @picocli.CommandLine.Option( + names = "--snapshot-name", + paramLabel = "NAME", + description = "No longer supported; passing it fails with an error.") + private String snapshotNameOpt; + + @picocli.CommandLine.Option( + names = "--dest-dir", + required = true, + paramLabel = "DIR", + description = + "Path of a temporary directory on local filesystem during snapshot export command.") + private String destDirOpt; + + @picocli.CommandLine.Option( + names = "--backup-repo-name", + paramLabel = "DIR", + description = + "Specifies name of the backup repository to be used during snapshot export preparation.") + private String backupRepoNameOpt; + + @picocli.CommandLine.Option( + names = "--async-id", + paramLabel = "ID", + description = + "Specifies the async request identifier to be used during snapshot export preparation.") + private String asyncIdOpt; + + public SnapshotExportTool() { + this(new DefaultToolRuntime()); + } + public SnapshotExportTool(ToolRuntime runtime) { super(runtime); } @@ -178,6 +234,21 @@ public void exportSnapshot( @Override public int callTool() throws Exception { - throw new UnsupportedOperationException("This tool does not yet support PicoCli"); + if (snapshotNameOpt != null) { + throw new IllegalArgumentException( + "--snapshot-name is no longer supported. Exporting a named snapshot required the " + + "non-incremental backup format, which was removed in Solr 11; this command now " + + "always backs up the collection's current state. Re-run without --snapshot-name."); + } + SnapshotExportParams params = + new SnapshotExportParams( + CLIUtils.resolveSolrUrl(connectionOptions, credentialsOptions.credentials), + credentialsOptions.credentials, + nameOpt, + destDirOpt, + backupRepoNameOpt, + asyncIdOpt); + exportSnapshot(params); + return 0; } } diff --git a/solr/core/src/java/org/apache/solr/cli/SnapshotListTool.java b/solr/core/src/java/org/apache/solr/cli/SnapshotListTool.java index a4d7ea3b56ae..e7185f9ae11f 100644 --- a/solr/core/src/java/org/apache/solr/cli/SnapshotListTool.java +++ b/solr/core/src/java/org/apache/solr/cli/SnapshotListTool.java @@ -26,6 +26,12 @@ import org.apache.solr.core.snapshots.SolrSnapshotManager; /** Supports snapshot-list command in the bin/solr script. */ +@SuppressWarnings("UnnecessarilyFullyQualified") +@picocli.CommandLine.Command( + name = "snapshot-list", + description = "Lists the snapshots for a collection.", + footerHeading = "%nExamples:%n", + footer = {" # List a collection's snapshots", " bin/solr snapshot-list -c mycollection"}) public class SnapshotListTool extends ToolBase { private static final Option COLLECTION_NAME_OPTION = @@ -40,6 +46,24 @@ public class SnapshotListTool extends ToolBase { /** Parameters for the snapshot-list command, independent of the command line parser. */ record SnapshotListParams(String solrUrl, String credentials, String collectionName) {} + // --- picocli fields --- + + @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "0..1") + private ConnectionOptions connectionOptions; + + @picocli.CommandLine.Mixin private CredentialsOptions credentialsOptions; + + @picocli.CommandLine.Option( + names = {"-c", "--name"}, + required = true, + paramLabel = "NAME", + description = "Name of collection to list snapshots for.") + private String nameOpt; + + public SnapshotListTool() { + this(new DefaultToolRuntime()); + } + public SnapshotListTool(ToolRuntime runtime) { super(runtime); } @@ -99,6 +123,12 @@ public void listSnapshots(SolrClient solrClient, String collectionName) { @Override public int callTool() throws Exception { - throw new UnsupportedOperationException("This tool does not yet support PicoCli"); + SnapshotListParams params = + new SnapshotListParams( + CLIUtils.resolveSolrUrl(connectionOptions, credentialsOptions.credentials), + credentialsOptions.credentials, + nameOpt); + listSnapshots(params); + return 0; } } From ba3d5e8553b2489f1de53f8903eb9d1c9bb57f22 Mon Sep 17 00:00:00 2001 From: Serhiy Bzhezytskyy Date: Tue, 22 Sep 2026 14:11:49 +0300 Subject: [PATCH 13/13] SOLR-17697: Register the newly-converted tools as picocli subcommands Wires PostTool, StreamTool, AssertTool, HealthcheckTool, ClusterTool, ConfigTool, ApiTool, ExportTool, PostLogsTool, RunExampleTool and the five snapshot-* tools into SolrCLI's @Command(subcommands = ...), the one file every tool conversion in this batch shares. --- .../src/java/org/apache/solr/cli/SolrCLI.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/cli/SolrCLI.java b/solr/core/src/java/org/apache/solr/cli/SolrCLI.java index a80d7f7883e8..00bf6e48b131 100755 --- a/solr/core/src/java/org/apache/solr/cli/SolrCLI.java +++ b/solr/core/src/java/org/apache/solr/cli/SolrCLI.java @@ -80,7 +80,22 @@ ZkTool.class, AuthTool.class, CreateTool.class, - DeleteTool.class + DeleteTool.class, + PostTool.class, + StreamTool.class, + AssertTool.class, + HealthcheckTool.class, + ClusterTool.class, + ConfigTool.class, + ApiTool.class, + ExportTool.class, + PostLogsTool.class, + RunExampleTool.class, + SnapshotCreateTool.class, + SnapshotDeleteTool.class, + SnapshotDescribeTool.class, + SnapshotExportTool.class, + SnapshotListTool.class }) public class SolrCLI implements CLIO, java.util.concurrent.Callable {