diff --git a/changelog/unreleased/SOLR-16398-enable-disable-replication.yml b/changelog/unreleased/SOLR-16398-enable-disable-replication.yml new file mode 100644 index 000000000000..3478a532225c --- /dev/null +++ b/changelog/unreleased/SOLR-16398-enable-disable-replication.yml @@ -0,0 +1,11 @@ +title: + Add V2 JAX-RS API to enable or disable replication (PUT /api/cores/coreName/replication, requires the core-admin-edit permission) +type: added +authors: + - name: Jalaz Kumar + nick: jaykay12 + - name: Eric Pugh + nick: epugh +links: + - name: SOLR-16398 + url: https://issues.apache.org/jira/browse/SOLR-16398 diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/ReplicationApis.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/ReplicationApis.java index 8b0d94618df8..3457528fa2fa 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/ReplicationApis.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/ReplicationApis.java @@ -22,8 +22,10 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.extensions.Extension; import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; +import io.swagger.v3.oas.annotations.parameters.RequestBody; import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.QueryParam; @@ -33,7 +35,9 @@ import org.apache.solr.client.api.model.FileListResponse; import org.apache.solr.client.api.model.IndexVersionResponse; import org.apache.solr.client.api.model.ReplicationDetailsResponse; +import org.apache.solr.client.api.model.ReplicationEnabledRequestBody; import org.apache.solr.client.api.model.RestoreStatusResponse; +import org.apache.solr.client.api.model.SolrJerseyResponse; import org.apache.solr.client.api.util.CoreApiParameters; @Path("/cores/{coreName}/replication") @@ -124,4 +128,12 @@ StreamingOutput fetchFile( @Parameter(description = "The generation number of the index", required = false) @QueryParam("generation") Long gen); + + @PUT + @CoreApiParameters + @Operation( + summary = "Enable or disable replication.", + tags = {"replication"}) + SolrJerseyResponse setReplicationEnabled( + @RequestBody(required = true) ReplicationEnabledRequestBody requestBody); } diff --git a/solr/api/src/java/org/apache/solr/client/api/model/ReplicationEnabledRequestBody.java b/solr/api/src/java/org/apache/solr/client/api/model/ReplicationEnabledRequestBody.java new file mode 100644 index 000000000000..0b7d64a47747 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/ReplicationEnabledRequestBody.java @@ -0,0 +1,26 @@ +/* + * 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.client.api.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Request body for enabling and disabling index replication from leader to followers. */ +public class ReplicationEnabledRequestBody { + + @JsonProperty(required = true) + public Boolean enabled; +} diff --git a/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java b/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java index 87ac3063b6c1..3196641a0f6c 100644 --- a/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java @@ -353,10 +353,10 @@ public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throw true, true); } else if (CMD_ENABLE_REPL.equalsIgnoreCase(command)) { - replicationEnabled.set(true); + setReplicationEnabled(true); rsp.add(STATUS, OK_STATUS); } else if (CMD_DISABLE_REPL.equalsIgnoreCase(command)) { - replicationEnabled.set(false); + setReplicationEnabled(false); rsp.add(STATUS, OK_STATUS); } } @@ -1035,6 +1035,10 @@ public void initializeMetrics(SolrMetricsContext parentContext, Attributes attri downloadSpeedMetric); } + public void setReplicationEnabled(boolean enabled) { + replicationEnabled.set(enabled); + } + // TODO Should a failure retrieving any piece of info mark the overall request as a failure? Is // there a core set of values that are required to make a response here useful? /** Used for showing statistics and progress information. */ diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/CoreReplication.java b/solr/core/src/java/org/apache/solr/handler/admin/api/CoreReplication.java index ea0341eb59e3..ea5de6768ed2 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/CoreReplication.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/CoreReplication.java @@ -16,6 +16,7 @@ */ package org.apache.solr.handler.admin.api; +import static org.apache.solr.security.PermissionNameProvider.Name.CORE_EDIT_PERM; import static org.apache.solr.security.PermissionNameProvider.Name.CORE_READ_PERM; import jakarta.inject.Inject; @@ -26,7 +27,9 @@ import org.apache.solr.client.api.model.FileListResponse; import org.apache.solr.client.api.model.IndexVersionResponse; import org.apache.solr.client.api.model.ReplicationDetailsResponse; +import org.apache.solr.client.api.model.ReplicationEnabledRequestBody; import org.apache.solr.client.api.model.RestoreStatusResponse; +import org.apache.solr.client.api.model.SolrJerseyResponse; import org.apache.solr.common.SolrException; import org.apache.solr.core.SolrCore; import org.apache.solr.jersey.PermissionName; @@ -94,4 +97,10 @@ public StreamingOutput fetchFile( return doFetchFile( filePath, dirType, offset, len, compression, checksum, maxWriteMBPerSec, gen); } + + @Override + @PermissionName(CORE_EDIT_PERM) + public SolrJerseyResponse setReplicationEnabled(ReplicationEnabledRequestBody requestBody) { + return doSetReplicationEnabled(requestBody); + } } diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java b/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java index a00261b388d5..107d8818ecb3 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/ReplicationAPIBase.java @@ -50,7 +50,9 @@ import org.apache.solr.client.api.model.FileMetaData; import org.apache.solr.client.api.model.IndexVersionResponse; import org.apache.solr.client.api.model.ReplicationDetailsResponse; +import org.apache.solr.client.api.model.ReplicationEnabledRequestBody; import org.apache.solr.client.api.model.RestoreStatusResponse; +import org.apache.solr.client.api.model.SolrJerseyResponse; import org.apache.solr.common.SolrException; import org.apache.solr.common.util.FastOutputStream; import org.apache.solr.common.util.IOUtils; @@ -102,6 +104,15 @@ protected ReplicationDetailsResponse doFetchReplicationDetails(boolean showFollo return replicationHandler.getReplicationDetails(showFollowerDetails); } + protected SolrJerseyResponse doSetReplicationEnabled(ReplicationEnabledRequestBody requestBody) { + ensureRequiredRequestBodyProvided(requestBody); + ensureRequiredParameterProvided("enabled", requestBody.enabled); + ReplicationHandler replicationHandler = + (ReplicationHandler) solrCore.getRequestHandler(ReplicationHandler.PATH); + replicationHandler.setReplicationEnabled(requestBody.enabled); + return instantiateJerseyResponse(SolrJerseyResponse.class); + } + protected RestoreStatusResponse doFetchRestoreStatus() { ReplicationHandler replicationHandler = (ReplicationHandler) solrCore.getRequestHandler(ReplicationHandler.PATH); diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/CoreReplicationAPITest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/CoreReplicationAPITest.java index 2512e458555e..0cd932f8c4e1 100644 --- a/solr/core/src/test/org/apache/solr/handler/admin/api/CoreReplicationAPITest.java +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/CoreReplicationAPITest.java @@ -37,6 +37,7 @@ import org.apache.solr.client.api.model.IndexVersionResponse; import org.apache.solr.client.api.model.ReplicationDetailsResponse; import org.apache.solr.client.api.model.ReplicationDetailsResponse.ReplicationDetails; +import org.apache.solr.client.api.model.ReplicationEnabledRequestBody; import org.apache.solr.client.api.model.RestoreStatusResponse; import org.apache.solr.common.SolrException; import org.apache.solr.core.SolrCore; @@ -103,6 +104,31 @@ public void testFetchReplicationDetails() { assertSame(expectedResponse, withFollowerFalse); } + @Test + public void testSetReplicationEnabled() { + for (boolean enabled : new boolean[] {true, false}) { + final var body = new ReplicationEnabledRequestBody(); + body.enabled = enabled; + assertNotNull(coreReplicationAPI.setReplicationEnabled(body)); + } + } + + @Test + public void testSetReplicationEnabledRejectsNullBody() { + SolrException ex = + expectThrows(SolrException.class, () -> coreReplicationAPI.setReplicationEnabled(null)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + } + + @Test + public void testSetReplicationEnabledRejectsBadBody() { + final var emptyBody = new ReplicationEnabledRequestBody(); + SolrException ex = + expectThrows( + SolrException.class, () -> coreReplicationAPI.setReplicationEnabled(emptyBody)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + } + @Test public void testGetRestoreStatus() { RestoreStatusResponse expected = new RestoreStatusResponse(); diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/SetReplicationEnabledAPITest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/SetReplicationEnabledAPITest.java new file mode 100644 index 000000000000..bcff126ed690 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/SetReplicationEnabledAPITest.java @@ -0,0 +1,96 @@ +/* + * 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.handler.admin.api; + +import static org.apache.solr.core.CoreContainer.ALLOW_PATHS_SYSPROP; + +import org.apache.solr.SolrTestCase; +import org.apache.solr.client.solrj.RemoteSolrException; +import org.apache.solr.client.solrj.request.ReplicationApi; +import org.apache.solr.common.SolrException; +import org.apache.solr.util.ExternalPaths; +import org.apache.solr.util.SolrJettyTestRule; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; + +/** HTTP tests for {@code PUT /api/cores/coreName/replication} via the generated SolrJ request. */ +public class SetReplicationEnabledAPITest extends SolrTestCase { + + private static final String CORE_NAME = "setReplicationEnabledApiTestCore"; + + @ClassRule public static final SolrJettyTestRule solrTestRule = new SolrJettyTestRule(); + + @BeforeClass + public static void setupSolr() throws Exception { + System.setProperty(ALLOW_PATHS_SYSPROP, ExternalPaths.SERVER_HOME.toAbsolutePath().toString()); + solrTestRule.startSolr(createTempDir()); + solrTestRule.newCollection(CORE_NAME).withConfigSet(ExternalPaths.DEFAULT_CONFIGSET).create(); + } + + @Test + public void testDisableThenEnableReplication() throws Exception { + assertEquals(Boolean.TRUE, fetchReplicationEnabledStatus()); + + setReplicationEnabled(false); + assertEquals(Boolean.FALSE, fetchReplicationEnabledStatus()); + + setReplicationEnabled(true); + assertEquals(Boolean.TRUE, fetchReplicationEnabledStatus()); + } + + @Test + public void testMissingEnabledIsRejectedAndStateUnchanged() throws Exception { + assertEquals(Boolean.TRUE, fetchReplicationEnabledStatus()); + + var request = new ReplicationApi.SetReplicationEnabled(CORE_NAME); + RemoteSolrException ex = + expectThrows( + RemoteSolrException.class, + () -> request.process(solrTestRule.getSolrClient(CORE_NAME))); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + + assertEquals(Boolean.TRUE, fetchReplicationEnabledStatus()); + } + + @Test + public void testNullEnabledIsRejectedAndStateUnchanged() throws Exception { + assertEquals(Boolean.TRUE, fetchReplicationEnabledStatus()); + + var request = new ReplicationApi.SetReplicationEnabled(CORE_NAME); + request.setEnabled(null); + RemoteSolrException ex = + expectThrows( + RemoteSolrException.class, + () -> request.process(solrTestRule.getSolrClient(CORE_NAME))); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + + assertEquals(Boolean.TRUE, fetchReplicationEnabledStatus()); + } + + private static void setReplicationEnabled(boolean enabled) throws Exception { + var request = new ReplicationApi.SetReplicationEnabled(CORE_NAME); + request.setEnabled(enabled); + var response = request.process(solrTestRule.getSolrClient(CORE_NAME)); + assertNull(response.error); + } + + private static Boolean fetchReplicationEnabledStatus() throws Exception { + var request = new ReplicationApi.FetchReplicationDetails(CORE_NAME); + return request.process(solrTestRule.getSolrClient(CORE_NAME)).details.leader.replicationEnabled; + } +} diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/user-managed-index-replication.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/user-managed-index-replication.adoc index 781c2c54861e..277038c36861 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/user-managed-index-replication.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/user-managed-index-replication.adoc @@ -367,14 +367,52 @@ You can use the HTTP commands below to control the ReplicationHandler's operatio `enablereplication`:: Enable replication on the "leader" for all its followers. + +[tabs#enablereplication-request] +====== +V1 API:: ++ +==== [source,bash] +---- http://{leaderHost}:{port}/solr/{coreName}/replication?command=enablereplication +---- +==== + +V2 API:: ++ +==== +[source,bash] +---- +curl -X PUT -H 'Content-Type: application/json' -d '{"enabled": true}' \ +http://{leaderHost}:{port}/api/cores/{coreName}/replication +---- +==== +====== `disablereplication`:: Disable replication on the leader for all its followers. + +[tabs#disablereplication-request] +====== +V1 API:: ++ +==== [source,bash] +---- http://{leaderHost}:{port}/solr/{coreName}/replication?command=disablereplication +---- +==== + +V2 API:: ++ +==== +[source,bash] +---- +curl -X PUT -H 'Content-Type: application/json' -d '{"enabled": false}' \ +http://{leaderHost}:{port}/api/cores/{coreName}/replication +---- +==== +====== `indexversion`:: Return the version of the latest replicatable index on the specified leader or follower. diff --git a/solr/webapp/web/js/angular/controllers/replication.js b/solr/webapp/web/js/angular/controllers/replication.js index 6e5fd50086a9..42c2e2700f12 100644 --- a/solr/webapp/web/js/angular/controllers/replication.js +++ b/solr/webapp/web/js/angular/controllers/replication.js @@ -16,7 +16,7 @@ */ solrAdminApp.controller('ReplicationController', - function($scope, $rootScope, $routeParams, $interval, $timeout, Replication, Constants) { + function($scope, $rootScope, $routeParams, $interval, $timeout, Replication, ReplicationV2, Constants, ApiErrorHandler) { $scope.resetMenu("replication", Constants.IS_CORE_PAGE); $scope.iterationCount = 1; @@ -58,6 +58,15 @@ solrAdminApp.controller('ReplicationController', Replication.command({core:$routeParams.core, command:command}, function(data){$scope.refresh()}); } + $scope.setReplicationEnabled = function(enabled) { + ReplicationV2.setReplicationEnabled($routeParams.core, {enabled: enabled}, function(error, data, response) { + $timeout(function() { + if (error) { ApiErrorHandler.handle(response); return; } + $scope.refresh(); + }); + }); + } + $scope.showIterations = function() { $scope.iterationCount = 100000}; // limitTo should accept undefined, but doesn't work. $scope.hideIterations = function() { $scope.iterationCount = 1}; diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index 42105030d765..bde85be4bc34 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -229,6 +229,12 @@ solrAdminServices.factory('Metrics', delete solrApi.ApiClient.instance.defaultHeaders["User-Agent"]; return new solrApi.NodeApi(); }) +.factory('ReplicationV2', + function() { + solrApi.ApiClient.instance.basePath = '/api'; + delete solrApi.ApiClient.instance.defaultHeaders["User-Agent"]; + return new solrApi.ReplicationApi(); + }) .factory('Replication', ['$resource', function($resource) { return $resource(':core/replication', {'wt':'json', core: "@core", '_':Date.now()}, { diff --git a/solr/webapp/web/partials/replication.html b/solr/webapp/web/partials/replication.html index a036b6feee18..629468412694 100644 --- a/solr/webapp/web/partials/replication.html +++ b/solr/webapp/web/partials/replication.html @@ -230,8 +230,8 @@