diff --git a/changelog/unreleased/SOLR-17697-picocli-experimental-cli.yml b/changelog/unreleased/SOLR-17697-picocli-experimental-cli.yml index 3510707f7e09..84e285fbbb2e 100644 --- a/changelog/unreleased/SOLR-17697-picocli-experimental-cli.yml +++ b/changelog/unreleased/SOLR-17697-picocli-experimental-cli.yml @@ -21,3 +21,5 @@ links: url: https://issues.apache.org/jira/browse/SOLR-18512 - name: SOLR-18513 url: https://issues.apache.org/jira/browse/SOLR-18513 + - name: SOLR-18514 + url: https://issues.apache.org/jira/browse/SOLR-18514 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..005be05cec77 100644 --- a/solr/core/src/java/org/apache/solr/cli/HealthcheckTool.java +++ b/solr/core/src/java/org/apache/solr/cli/HealthcheckTool.java @@ -20,16 +20,19 @@ import static org.apache.solr.common.params.CommonParams.DISTRIB; import static org.apache.solr.common.params.CommonParams.NAME; +import java.io.IOException; import java.lang.invoke.MethodHandles; import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; 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.jetty.HttpJettySolrClient; import org.apache.solr.client.solrj.request.SolrQuery; @@ -40,15 +43,30 @@ import org.apache.solr.common.cloud.DocCollection; import org.apache.solr.common.cloud.Replica; import org.apache.solr.common.cloud.Slice; +import org.apache.solr.common.util.EnvUtils; import org.noggit.CharArr; import org.noggit.JSONWriter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** Supports healthcheck command in the bin/solr script. */ +@SuppressWarnings("UnnecessarilyFullyQualified") +@picocli.CommandLine.Command( + name = "healthcheck", + description = + "Verifies that a collection is functioning: queries every replica directly, compares" + + " document counts and checks that each shard has a leader and every replica is" + + " ACTIVE. Requires 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()); + /** + * @deprecated Only used by the commons-cli parser; the picocli path declares this as an annotated + * field. + */ + @Deprecated private static final Option COLLECTION_NAME_OPTION = Option.builder("c") .longOpt("name") @@ -76,6 +94,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 +249,65 @@ 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 = resolveSolrConnection(credentialsOptions.credentials); + if (solrConnection == null) { + CLIO.err("Healthcheck tool only works in Solr Cloud mode."); + return 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; + } + + /** + * Mirrors {@link CLIUtils#getSolrConnection(CommandLine)}: an explicit {@code --solr-connection} + * or {@code --zk-host} (or the matching property) wins, otherwise a running Solr is asked for its + * ZooKeeper, and null means it is not in SolrCloud mode. + */ + private CloudSolrClient.CloudSolrClientConnection resolveSolrConnection(String credentials) + throws Exception { + String solrConnection = + (connectionOptions != null && connectionOptions.solrConnection != null) + ? connectionOptions.solrConnection + : EnvUtils.getProperty("solr.connection"); + if (solrConnection != null && !solrConnection.isBlank()) { + return CloudSolrClient.CloudSolrClientConnection.parse(solrConnection); + } + String zkHost = + (connectionOptions != null && connectionOptions.zkHost != null) + ? connectionOptions.zkHost + : EnvUtils.getProperty("zkHost"); + if (zkHost != null && !zkHost.isBlank()) { + var zkConnection = CloudSolrClient.CloudSolrClientConnection.parse(zkHost); + if (!zkConnection.isZookeeper()) { + throw new IOException( + String.format( + Locale.ROOT, "Expected ZooKeeper connection string, but got: '%s'.", zkHost)); + } + return zkConnection; + } + String solrUrl = + (connectionOptions != null && connectionOptions.solrUrl != null) + ? CLIUtils.normalizeSolrUrl(connectionOptions.solrUrl) + : CLIUtils.getDefaultSolrUrl(); + try (SolrClient solrClient = CLIUtils.getSolrClient(solrUrl, 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); + } } } 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 2dd48b27cad6..57b22e79d3ea 100755 --- a/solr/core/src/java/org/apache/solr/cli/SolrCLI.java +++ b/solr/core/src/java/org/apache/solr/cli/SolrCLI.java @@ -85,7 +85,8 @@ ApiTool.class, ClusterTool.class, ConfigTool.class, - ExportTool.class + ExportTool.class, + HealthcheckTool.class }) public class SolrCLI implements CLIO, java.util.concurrent.Callable { diff --git a/solr/core/src/test/org/apache/solr/cli/HealthcheckToolPicocliTest.java b/solr/core/src/test/org/apache/solr/cli/HealthcheckToolPicocliTest.java new file mode 100644 index 000000000000..bcef8b7e705b --- /dev/null +++ b/solr/core/src/test/org/apache/solr/cli/HealthcheckToolPicocliTest.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.cli; + +import java.util.Arrays; +import org.junit.Test; +import picocli.CommandLine; + +/** + * Runs all {@link HealthcheckToolTest} tests through the picocli invocation path. + * + *

All {@code @Test} methods are inherited; only the invocation strategy is overridden. + */ +public class HealthcheckToolPicocliTest extends HealthcheckToolTest { + + @Override + protected int runTool(String[] args, Class clazz) throws Exception { + // args[0] is the tool name used by commons-cli dispatch; strip it for picocli. + String[] toolArgs = Arrays.copyOfRange(args, 1, args.length); + ToolRuntime runtime = new CLITestHelper.TestingRuntime(false); + ToolBase tool = clazz.getDeclaredConstructor(ToolRuntime.class).newInstance(runtime); + return new CommandLine(tool) + .setDefaultValueProvider(new CliDefaultValueProvider()) + .execute(toolArgs); + } + + @Test + public void testHealthcheckWithSolrConnectionProperty() throws Exception { + // SOLR_CONNECTION reaches the JVM as the solr.connection property + System.setProperty("solr.connection", getHttpSolrConnection().toString()); + String[] args = new String[] {"healthcheck", "-c", "bob"}; + assertEquals(0, runTool(args, HealthcheckTool.class)); + } +} diff --git a/solr/core/src/test/org/apache/solr/cli/HealthcheckToolTest.java b/solr/core/src/test/org/apache/solr/cli/HealthcheckToolTest.java index 68d7fba3c5d5..0cd3d8b583ca 100644 --- a/solr/core/src/test/org/apache/solr/cli/HealthcheckToolTest.java +++ b/solr/core/src/test/org/apache/solr/cli/HealthcheckToolTest.java @@ -27,6 +27,11 @@ public class HealthcheckToolTest extends SolrCloudTestCase { + /** Runs the tool. Overridden by the picocli variant of this test. */ + protected int runTool(String[] args, Class clazz) throws Exception { + return CLITestHelper.runTool(args, clazz); + } + @BeforeClass public static void setupCluster() throws Exception { configureCluster(1) @@ -44,7 +49,7 @@ public void testHealthcheckWithZkHostParameter() throws Exception { String[] args = new String[] {"healthcheck", "-c", "bob", "-z", cluster.getZkClient().getZkServerAddress()}; - assertEquals(0, CLITestHelper.runTool(args, HealthcheckTool.class)); + assertEquals(0, runTool(args, HealthcheckTool.class)); } @Test @@ -56,7 +61,7 @@ public void testHealthcheckWithSolrUrlParameter() throws Exception { ZkStateReader.from(cluster.getSolrClient()).getBaseUrlForNodeName(firstLiveNode); String[] args = new String[] {"healthcheck", "-c", "bob", "--solr-url", solrUrl}; - assertEquals(0, CLITestHelper.runTool(args, HealthcheckTool.class)); + assertEquals(0, runTool(args, HealthcheckTool.class)); } @Test @@ -70,7 +75,7 @@ public void testHealthcheckWithSolrRootUrlParameter() throws Exception { solrUrl = solrUrl.substring(0, solrUrl.indexOf("/solr")); String[] args = new String[] {"healthcheck", "-c", "bob", "--solr-url", solrUrl}; - assertEquals(0, CLITestHelper.runTool(args, HealthcheckTool.class)); + assertEquals(0, runTool(args, HealthcheckTool.class)); } @Test @@ -81,7 +86,7 @@ public void testHealthcheckWithSolrConnectionParameter() throws Exception { for (String connectionString : connStrings) { String[] args = new String[] {"healthcheck", "-c", "bob", "--solr-connection", connectionString}; - assertEquals(0, CLITestHelper.runTool(args, HealthcheckTool.class)); + assertEquals(0, runTool(args, HealthcheckTool.class)); } } @@ -89,6 +94,6 @@ public void testHealthcheckWithSolrConnectionParameter() throws Exception { public void testHealthcheckWithZookeeperParameter() throws Exception { String zkHost = cluster.getZkServer().getZkAddress(); String[] args = new String[] {"healthcheck", "-c", "bob", "--zk-host", zkHost}; - assertEquals(0, CLITestHelper.runTool(args, HealthcheckTool.class)); + assertEquals(0, runTool(args, HealthcheckTool.class)); } } diff --git a/solr/packaging/build.gradle b/solr/packaging/build.gradle index 19c9de386731..50efa69ddbfa 100644 --- a/solr/packaging/build.gradle +++ b/solr/packaging/build.gradle @@ -366,6 +366,7 @@ task integrationTestsPicocli(type: BatsTask) { 'test/test_auth.bats', 'test/test_modules.bats', 'test/test_security_manager.bats', + 'test/test_healthcheck.bats', ] } diff --git a/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc b/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc index 712f9fee593d..8336adf0a112 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc @@ -28,6 +28,7 @@ *** xref:cli/solr-create.adoc[create] *** xref:cli/solr-delete.adoc[delete] *** xref:cli/solr-export.adoc[export] +*** xref:cli/solr-healthcheck.adoc[healthcheck] *** xref:cli/solr-start.adoc[start] *** xref:cli/solr-status.adoc[status] *** xref:cli/solr-stop.adoc[stop] diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/index.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/index.adoc index 051f9f6f1b74..4bf311d33155 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/index.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/index.adoc @@ -67,6 +67,7 @@ Any command not listed here is unavailable when the picocli implementation is en |xref:cli/solr-create.adoc[bin/solr create] |xref:cli/solr-delete.adoc[bin/solr delete] |xref:cli/solr-export.adoc[bin/solr export] +|xref:cli/solr-healthcheck.adoc[bin/solr healthcheck] |xref:cli/solr-start.adoc[bin/solr start] |xref:cli/solr-status.adoc[bin/solr status] |xref:cli/solr-stop.adoc[bin/solr stop] diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-healthcheck.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-healthcheck.adoc new file mode 100644 index 000000000000..15365fde2fce --- /dev/null +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-healthcheck.adoc @@ -0,0 +1,81 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// +// DO NOT EDIT -- this page is auto-generated from picocli annotations. +// To update: modify the @Command/@Option annotations in the Java source, then run: +// ./gradlew :solr:solr-ref-guide:generateCliDocs + += bin/solr healthcheck +:page-toclevels: 2 + +[WARNING] +==== +This page documents the *experimental* picocli implementation of `bin/solr`, which is not enabled by default. +For what `bin/solr` does by default, see xref:solr-control-script-reference.adoc[]. +See xref:cli/index.adoc[] to enable this implementation and for its known limitations. +==== + +// tag::picocli-generated-man-section-synopsis[] +== Synopsis + +.... +bin/solr healthcheck [-hv] -c=COLLECTION [-u=] [-s= | + --solr-url= | -z=] +.... + +// end::picocli-generated-man-section-synopsis[] + +// tag::picocli-generated-man-section-description[] +== Description + +Verifies that a collection is functioning: queries every replica directly, compares document counts and checks that each shard has a leader and every replica is ACTIVE. Requires SolrCloud. + +// end::picocli-generated-man-section-description[] + +// tag::picocli-generated-man-section-options[] +== Options + +*-c*, *--name*=_COLLECTION_:: + *(required)* Name of the collection to check. + +*-h*, *--help*:: + Print this help message and exit. + +*-s*, *--solr-connection*=__:: + Zookeeper or HTTP(s) connection string; unnecessary if SOLR_CONNECTION is defined in solr.in.sh; otherwise, defaults to localhost:9983. + +*--solr-url*=__:: + Base Solr URL, which can be used to determine the zk-host if that's not known. + +*-u*, *--credentials*=__:: + Credentials in the format username:password. Example: --credentials solr:SolrRocks + +*-v*, *--verbose*:: + Enable verbose mode. + +*-z*, *--zk-host*=__:: + Zookeeper connection string; unnecessary if ZK_HOST is defined in solr.in.sh; otherwise, defaults to localhost:9983. + +// end::picocli-generated-man-section-options[] + +// tag::picocli-generated-man-section-footer[] +== Examples +[%hardbreaks] + # Check the health of a collection + bin/solr healthcheck -c gettingstarted + +// end::picocli-generated-man-section-footer[]