You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by ge...@apache.org on 2023/07/31 12:31:19 UTC

[solr] branch branch_9x updated: SOLR-16490: Create v2 equivalent for core backup API (#1740)

This is an automated email from the ASF dual-hosted git repository.

gerlowskija pushed a commit to branch branch_9x
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/branch_9x by this push:
     new 49419a562a1 SOLR-16490: Create v2 equivalent for core backup API (#1740)
49419a562a1 is described below

commit 49419a562a107d597d0a72bc97c0a1afe1aa13a7
Author: Sanjay Dutt <sa...@gmail.com>
AuthorDate: Tue Jul 25 03:50:01 2023 -0700

    SOLR-16490: Create v2 equivalent for core backup API (#1740)
    
    No v2 equivalent existed prior to this commit.  The new v2 API is
    `POST /api/cores/cName/backups`.
    
    ---------
    
    Co-authored-by: Jason Gerlowski <ge...@apache.org>
---
 solr/CHANGES.txt                                   |   3 +-
 .../solr/handler/IncrementalShardBackup.java       |  63 ++++++--
 .../java/org/apache/solr/handler/SnapShooter.java  |  64 ++++++--
 .../apache/solr/handler/admin/BackupCoreOp.java    |  89 +++--------
 .../solr/handler/admin/CoreAdminHandler.java       |   3 +-
 .../solr/handler/admin/api/BackupCoreAPI.java      | 177 ++++++++++++++++++++
 .../org/apache/solr/handler/api/V2ApiUtils.java    |   5 +
 .../solr/handler/admin/api/BackupCoreAPITest.java  | 178 +++++++++++++++++++++
 8 files changed, 490 insertions(+), 92 deletions(-)

diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index bb87cc33125..a80e887b9b6 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -12,7 +12,8 @@ New Features
 
 Improvements
 ---------------------
-(No changes)
+* SOLR-16490: `/admin/cores?action=backupcore` now has a v2 equivalent, available at
+  `GET /api/cores/coreName/backups` (Sanjay Dutt via Jason Gerlowski)
 
 Optimizations
 ---------------------
diff --git a/solr/core/src/java/org/apache/solr/handler/IncrementalShardBackup.java b/solr/core/src/java/org/apache/solr/handler/IncrementalShardBackup.java
index a4f9d479872..74128375ad0 100644
--- a/solr/core/src/java/org/apache/solr/handler/IncrementalShardBackup.java
+++ b/solr/core/src/java/org/apache/solr/handler/IncrementalShardBackup.java
@@ -17,6 +17,8 @@
 
 package org.apache.solr.handler;
 
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
 import java.io.IOException;
 import java.lang.invoke.MethodHandles;
 import java.net.URI;
@@ -29,8 +31,6 @@ import org.apache.lucene.index.IndexCommit;
 import org.apache.lucene.store.Directory;
 import org.apache.solr.cloud.CloudDescriptor;
 import org.apache.solr.common.SolrException;
-import org.apache.solr.common.util.NamedList;
-import org.apache.solr.common.util.SimpleOrderedMap;
 import org.apache.solr.core.DirectoryFactory;
 import org.apache.solr.core.IndexDeletionPolicyWrapper;
 import org.apache.solr.core.SolrCore;
@@ -39,6 +39,7 @@ import org.apache.solr.core.backup.Checksum;
 import org.apache.solr.core.backup.ShardBackupId;
 import org.apache.solr.core.backup.ShardBackupMetadata;
 import org.apache.solr.core.backup.repository.BackupRepository;
+import org.apache.solr.jersey.SolrJerseyResponse;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -80,7 +81,7 @@ public class IncrementalShardBackup {
     this.commitNameOption = commitNameOption;
   }
 
-  public NamedList<Object> backup() throws Exception {
+  public IncrementalShardSnapshotResponse backup() throws Exception {
     final IndexCommit indexCommit = getAndSaveIndexCommit();
     try {
       return backup(indexCommit);
@@ -134,14 +135,14 @@ public class IncrementalShardBackup {
   }
 
   // note: remember to reserve the indexCommit first so it won't get deleted concurrently
-  protected NamedList<Object> backup(final IndexCommit indexCommit) throws Exception {
+  protected IncrementalShardSnapshotResponse backup(final IndexCommit indexCommit)
+      throws Exception {
     assert indexCommit != null;
     URI backupLocation = incBackupFiles.getBackupLocation();
     log.info(
         "Creating backup snapshot at {} shardBackupMetadataFile:{}", backupLocation, shardBackupId);
-    NamedList<Object> details = new SimpleOrderedMap<>();
-    ;
-    details.add("startTime", Instant.now().toString());
+    IncrementalShardSnapshotResponse details = new IncrementalShardSnapshotResponse();
+    details.startTime = Instant.now().toString();
 
     Collection<String> files = indexCommit.getFileNames();
     Directory dir =
@@ -153,21 +154,21 @@ public class IncrementalShardBackup {
                 solrCore.getSolrConfig().indexConfig.lockType);
     try {
       BackupStats stats = incrementalCopy(files, dir);
-      details.add("indexFileCount", stats.fileCount);
-      details.add("uploadedIndexFileCount", stats.uploadedFileCount);
-      details.add("indexSizeMB", stats.getIndexSizeMB());
-      details.add("uploadedIndexFileMB", stats.getTotalUploadedMB());
+      details.indexFileCount = stats.fileCount;
+      details.uploadedIndexFileCount = stats.uploadedFileCount;
+      details.indexSizeMB = stats.getIndexSizeMB();
+      details.uploadedIndexFileMB = stats.getTotalUploadedMB();
     } finally {
       solrCore.getDirectoryFactory().release(dir);
     }
 
     CloudDescriptor cd = solrCore.getCoreDescriptor().getCloudDescriptor();
     if (cd != null) {
-      details.add("shard", cd.getShardId());
+      details.shard = cd.getShardId();
     }
 
-    details.add("endTime", Instant.now().toString());
-    details.add("shardBackupId", shardBackupId.getIdAsString());
+    details.endTime = Instant.now().toString();
+    details.shardBackupId = shardBackupId.getIdAsString();
     log.info(
         "Done creating backup snapshot at {} shardBackupMetadataFile:{}",
         backupLocation,
@@ -241,4 +242,38 @@ public class IncrementalShardBackup {
       return Precision.round(totalUploadedBytes / (1024.0 * 1024), 3);
     }
   }
+
+  public static class IncrementalShardSnapshotResponse extends SolrJerseyResponse {
+    @Schema(description = "The time at which backup snapshot started at.")
+    @JsonProperty("startTime")
+    public String startTime;
+
+    @Schema(description = "The count of index files in the snapshot.")
+    @JsonProperty("indexFileCount")
+    public int indexFileCount;
+
+    @Schema(description = "The count of uploaded index files.")
+    @JsonProperty("uploadedIndexFileCount")
+    public int uploadedIndexFileCount;
+
+    @Schema(description = "The size of index in MB.")
+    @JsonProperty("indexSizeMB")
+    public double indexSizeMB;
+
+    @Schema(description = "The size of uploaded index in MB.")
+    @JsonProperty("uploadedIndexFileMB")
+    public double uploadedIndexFileMB;
+
+    @Schema(description = "Shard Id.")
+    @JsonProperty("shard")
+    public String shard;
+
+    @Schema(description = "The time at which backup snapshot completed at.")
+    @JsonProperty("endTime")
+    public String endTime;
+
+    @Schema(description = "ShardId of shard to which core belongs to.")
+    @JsonProperty("shardBackupId")
+    public String shardBackupId;
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/handler/SnapShooter.java b/solr/core/src/java/org/apache/solr/handler/SnapShooter.java
index 5590bdf344a..184d2aeb0c2 100644
--- a/solr/core/src/java/org/apache/solr/handler/SnapShooter.java
+++ b/solr/core/src/java/org/apache/solr/handler/SnapShooter.java
@@ -16,6 +16,8 @@
  */
 package org.apache.solr.handler;
 
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
 import java.io.IOException;
 import java.lang.invoke.MethodHandles;
 import java.net.URI;
@@ -44,6 +46,8 @@ import org.apache.solr.core.backup.repository.BackupRepository;
 import org.apache.solr.core.backup.repository.BackupRepository.PathType;
 import org.apache.solr.core.backup.repository.LocalFileSystemRepository;
 import org.apache.solr.core.snapshots.SolrSnapshotMetaDataManager;
+import org.apache.solr.handler.api.V2ApiUtils;
+import org.apache.solr.jersey.SolrJerseyResponse;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -170,7 +174,7 @@ public class SnapShooter {
     }
   }
 
-  public NamedList<Object> createSnapshot() throws Exception {
+  public CoreSnapshotResponse createSnapshot() throws Exception {
     final IndexCommit indexCommit = getAndSaveIndexCommit();
     try {
       return createSnapshot(indexCommit);
@@ -246,9 +250,9 @@ public class SnapShooter {
     // TODO should use Solr's ExecutorUtil
     new Thread(
             () -> {
-              NamedList<Object> snapShootDetails;
+              NamedList<Object> snapShootDetails = new SimpleOrderedMap<>();
               try {
-                snapShootDetails = createSnapshot();
+                V2ApiUtils.squashIntoNamedListWithoutHeader(snapShootDetails, createSnapshot());
               } catch (Exception e) {
                 log.error("Exception while creating snapshot", e);
                 snapShootDetails = new NamedList<>();
@@ -277,7 +281,7 @@ public class SnapShooter {
    * @see IndexDeletionPolicyWrapper#saveCommitPoint
    * @see IndexDeletionPolicyWrapper#releaseCommitPoint
    */
-  protected NamedList<Object> createSnapshot(final IndexCommit indexCommit) throws Exception {
+  protected CoreSnapshotResponse createSnapshot(final IndexCommit indexCommit) throws Exception {
     assert indexCommit != null;
     if (log.isInfoEnabled()) {
       log.info(
@@ -287,8 +291,8 @@ public class SnapShooter {
     }
     boolean success = false;
     try {
-      NamedList<Object> details = new SimpleOrderedMap<>();
-      details.add("startTime", Instant.now().toString());
+      CoreSnapshotResponse details = new CoreSnapshotResponse();
+      details.startTime = Instant.now().toString();
 
       Collection<String> files = indexCommit.getFileNames();
       Directory dir =
@@ -311,13 +315,13 @@ public class SnapShooter {
       String endTime = Instant.now().toString();
 
       // DEPRECATED: fileCount for removal, replaced with indexFileCount
-      details.add("fileCount", files.size());
-      details.add("indexFileCount", files.size());
-      details.add("status", "success");
-      details.add("snapshotCompletedAt", endTime); // DEPRECATED: for removal, replaced with endTime
-      details.add("endTime", endTime);
-      details.add("snapshotName", snapshotName);
-      details.add("directoryName", directoryName);
+      details.fileCount = files.size();
+      details.indexFileCount = files.size();
+      details.status = "success";
+      details.snapshotCompletedAt = endTime; // DEPRECATED: for removal, replaced with endTime
+      details.endTime = endTime;
+      details.snapshotName = snapshotName;
+      details.directoryName = directoryName;
       if (log.isInfoEnabled()) {
         log.info(
             "Done creating backup snapshot: {} into {}",
@@ -389,4 +393,38 @@ public class SnapShooter {
   }
 
   public static final String DATE_FMT = "yyyyMMddHHmmssSSS";
+
+  public static class CoreSnapshotResponse extends SolrJerseyResponse {
+    @Schema(description = "The time at which snapshot started at.")
+    @JsonProperty("startTime")
+    public String startTime;
+
+    @Schema(description = "The number of files in the snapshot.")
+    @JsonProperty("fileCount")
+    public int fileCount;
+
+    @Schema(description = "The number of index files in the snapshot.")
+    @JsonProperty("indexFileCount")
+    public int indexFileCount;
+
+    @Schema(description = "The status of the snapshot")
+    @JsonProperty("status")
+    public String status;
+
+    @Schema(description = "The time at which snapshot completed at.")
+    @JsonProperty("snapshotCompletedAt")
+    public String snapshotCompletedAt;
+
+    @Schema(description = "The time at which snapshot completed at.")
+    @JsonProperty("endTime")
+    public String endTime;
+
+    @Schema(description = "The name of the snapshot")
+    @JsonProperty("snapshotName")
+    public String snapshotName;
+
+    @Schema(description = "The name of the directory where snapshot created.")
+    @JsonProperty("directoryName")
+    public String directoryName;
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/BackupCoreOp.java b/solr/core/src/java/org/apache/solr/handler/admin/BackupCoreOp.java
index 3b2f11bec65..2f5407416c4 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/BackupCoreOp.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/BackupCoreOp.java
@@ -17,84 +17,47 @@
 
 package org.apache.solr.handler.admin;
 
-import java.net.URI;
-import java.nio.file.Paths;
-import java.util.Optional;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.params.CoreAdminParams;
 import org.apache.solr.common.params.SolrParams;
 import org.apache.solr.common.util.NamedList;
-import org.apache.solr.core.SolrCore;
-import org.apache.solr.core.backup.BackupFilePaths;
+import org.apache.solr.common.util.SimpleOrderedMap;
 import org.apache.solr.core.backup.ShardBackupId;
-import org.apache.solr.core.backup.repository.BackupRepository;
-import org.apache.solr.handler.IncrementalShardBackup;
-import org.apache.solr.handler.SnapShooter;
+import org.apache.solr.handler.admin.api.BackupCoreAPI;
+import org.apache.solr.handler.api.V2ApiUtils;
+import org.apache.solr.jersey.SolrJerseyResponse;
 
 class BackupCoreOp implements CoreAdminHandler.CoreAdminOp {
 
   @Override
   public void execute(CoreAdminHandler.CallInfo it) throws Exception {
     final SolrParams params = it.req.getParams();
-
-    String cname = params.required().get(CoreAdminParams.CORE);
-    boolean incremental = isIncrementalBackup(params);
-    final String name = parseBackupName(params);
-    final ShardBackupId shardBackupId = parseShardBackupId(params);
-    String prevShardBackupIdStr = params.get(CoreAdminParams.PREV_SHARD_BACKUP_ID, null);
-    String repoName = params.get(CoreAdminParams.BACKUP_REPOSITORY);
+    BackupCoreAPI.BackupCoreRequestBody backupCoreRequestBody =
+        new BackupCoreAPI.BackupCoreRequestBody();
+    backupCoreRequestBody.repository = params.get(CoreAdminParams.BACKUP_REPOSITORY);
+    backupCoreRequestBody.location = params.get(CoreAdminParams.BACKUP_LOCATION);
     // An optional parameter to describe the snapshot to be backed-up. If this
     // parameter is not supplied, the latest index commit is backed-up.
-    String commitName = params.get(CoreAdminParams.COMMIT_NAME);
-
-    try (BackupRepository repository = it.handler.coreContainer.newBackupRepository(repoName);
-        SolrCore core = it.handler.coreContainer.getCore(cname)) {
-      String location = repository.getBackupLocation(params.get(CoreAdminParams.BACKUP_LOCATION));
-      if (location == null) {
-        throw new SolrException(
-            SolrException.ErrorCode.BAD_REQUEST,
-            "'location' is not specified as a query"
-                + " parameter or as a default repository property");
-      }
+    backupCoreRequestBody.commitName = params.get(CoreAdminParams.COMMIT_NAME);
 
-      URI locationUri = repository.createDirectoryURI(location);
-      repository.createDirectory(locationUri);
+    String cname = params.required().get(CoreAdminParams.CORE);
+    backupCoreRequestBody.backupName = parseBackupName(params);
+    boolean incremental = isIncrementalBackup(params);
+    if (incremental) {
+      backupCoreRequestBody.shardBackupId = params.required().get(CoreAdminParams.SHARD_BACKUP_ID);
+      backupCoreRequestBody.prevShardBackupId =
+          params.get(CoreAdminParams.PREV_SHARD_BACKUP_ID, null);
+      backupCoreRequestBody.incremental = true;
+    }
+    BackupCoreAPI backupCoreAPI =
+        new BackupCoreAPI(
+            it.handler.coreContainer, it.req, it.rsp, it.handler.coreAdminAsyncTracker);
+    try {
+      SolrJerseyResponse response = backupCoreAPI.createBackup(cname, backupCoreRequestBody);
+      NamedList<Object> namedList = new SimpleOrderedMap<>();
+      V2ApiUtils.squashIntoNamedListWithoutHeader(namedList, response);
+      it.rsp.addResponse(namedList);
 
-      if (incremental) {
-        if ("file".equals(locationUri.getScheme())) {
-          core.getCoreContainer().assertPathAllowed(Paths.get(locationUri));
-        }
-        final ShardBackupId prevShardBackupId =
-            prevShardBackupIdStr != null ? ShardBackupId.from(prevShardBackupIdStr) : null;
-        BackupFilePaths incBackupFiles = new BackupFilePaths(repository, locationUri);
-        IncrementalShardBackup incSnapShooter =
-            new IncrementalShardBackup(
-                repository,
-                core,
-                incBackupFiles,
-                prevShardBackupId,
-                shardBackupId,
-                Optional.ofNullable(commitName));
-        NamedList<Object> rsp = incSnapShooter.backup();
-        it.rsp.addResponse(rsp);
-      } else {
-        SnapShooter snapShooter = new SnapShooter(repository, core, locationUri, name, commitName);
-        // validateCreateSnapshot will create parent dirs instead of throw; that choice is dubious.
-        // But we want to throw. One reason is that this dir really should, in fact must, already
-        // exist here if triggered via a collection backup on a shared file system. Otherwise,
-        // perhaps the FS location isn't shared -- we want an error.
-        if (!snapShooter.getBackupRepository().exists(snapShooter.getLocation())) {
-          throw new SolrException(
-              SolrException.ErrorCode.BAD_REQUEST,
-              "Directory to contain snapshots doesn't exist: "
-                  + snapShooter.getLocation()
-                  + ". "
-                  + "Note that Backup/Restore of a SolrCloud collection "
-                  + "requires a shared file system mounted at the same path on all nodes!");
-        }
-        snapShooter.validateCreateSnapshot();
-        it.rsp.addResponse(snapShooter.createSnapshot());
-      }
     } catch (Exception e) {
       throw new SolrException(
           SolrException.ErrorCode.SERVER_ERROR,
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/CoreAdminHandler.java b/solr/core/src/java/org/apache/solr/handler/admin/CoreAdminHandler.java
index e080af316bf..846055d8d21 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/CoreAdminHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/CoreAdminHandler.java
@@ -55,6 +55,7 @@ import org.apache.solr.core.CoreContainer;
 import org.apache.solr.core.CoreDescriptor;
 import org.apache.solr.handler.RequestHandlerBase;
 import org.apache.solr.handler.admin.api.AllCoresStatusAPI;
+import org.apache.solr.handler.admin.api.BackupCoreAPI;
 import org.apache.solr.handler.admin.api.CoreSnapshotAPI;
 import org.apache.solr.handler.admin.api.CreateCoreAPI;
 import org.apache.solr.handler.admin.api.InstallCoreDataAPI;
@@ -381,7 +382,7 @@ public class CoreAdminHandler extends RequestHandlerBase implements PermissionNa
 
   @Override
   public Collection<Class<? extends JerseyResource>> getJerseyResources() {
-    return List.of(CoreSnapshotAPI.class, InstallCoreDataAPI.class);
+    return List.of(CoreSnapshotAPI.class, InstallCoreDataAPI.class, BackupCoreAPI.class);
   }
 
   static {
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/BackupCoreAPI.java b/solr/core/src/java/org/apache/solr/handler/admin/api/BackupCoreAPI.java
new file mode 100644
index 00000000000..833149cb07b
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/BackupCoreAPI.java
@@ -0,0 +1,177 @@
+/*
+ * 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.client.solrj.impl.BinaryResponseParser.BINARY_CONTENT_TYPE_V2;
+import static org.apache.solr.security.PermissionNameProvider.Name.COLL_EDIT_PERM;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.parameters.RequestBody;
+import java.net.URI;
+import java.nio.file.Paths;
+import java.util.Optional;
+import javax.inject.Inject;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.backup.BackupFilePaths;
+import org.apache.solr.core.backup.ShardBackupId;
+import org.apache.solr.core.backup.repository.BackupRepository;
+import org.apache.solr.handler.IncrementalShardBackup;
+import org.apache.solr.handler.SnapShooter;
+import org.apache.solr.handler.admin.CoreAdminHandler;
+import org.apache.solr.jersey.PermissionName;
+import org.apache.solr.jersey.SolrJerseyResponse;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+
+@Path("/cores/{coreName}/backups")
+public class BackupCoreAPI extends CoreAdminAPIBase {
+  @Inject
+  public BackupCoreAPI(
+      CoreContainer coreContainer,
+      SolrQueryRequest solrQueryRequest,
+      SolrQueryResponse solrQueryResponse,
+      CoreAdminHandler.CoreAdminAsyncTracker coreAdminAsyncTracker) {
+    super(coreContainer, coreAdminAsyncTracker, solrQueryRequest, solrQueryResponse);
+  }
+
+  @POST
+  @Produces({"application/json", "application/xml", BINARY_CONTENT_TYPE_V2})
+  @PermissionName(COLL_EDIT_PERM)
+  public SolrJerseyResponse createBackup(
+      @Parameter(description = "The name of the core.") @PathParam("coreName") String coreName,
+      @Schema(description = "The POJO for representing additional backup params.") @RequestBody
+          BackupCoreRequestBody backupCoreRequestBody)
+      throws Exception {
+    ensureRequiredParameterProvided("coreName", coreName);
+    return handlePotentiallyAsynchronousTask(
+        null,
+        coreName,
+        backupCoreRequestBody.async,
+        "backup",
+        () -> {
+          try (BackupRepository repository =
+                  coreContainer.newBackupRepository(backupCoreRequestBody.repository);
+              SolrCore core = coreContainer.getCore(coreName)) {
+            String location = repository.getBackupLocation(backupCoreRequestBody.location);
+            if (location == null) {
+              throw new SolrException(
+                  SolrException.ErrorCode.BAD_REQUEST,
+                  "'location' parameter is not specified in the request body or as a default repository property");
+            }
+            URI locationUri = repository.createDirectoryURI(location);
+            repository.createDirectory(locationUri);
+
+            if (Boolean.TRUE.equals(backupCoreRequestBody.incremental)) {
+              if ("file".equals(locationUri.getScheme())) {
+                core.getCoreContainer().assertPathAllowed(Paths.get(locationUri));
+              }
+              ensureRequiredParameterProvided("shardBackupId", backupCoreRequestBody.shardBackupId);
+              final ShardBackupId shardBackupId =
+                  ShardBackupId.from(backupCoreRequestBody.shardBackupId);
+              final ShardBackupId prevShardBackupId =
+                  backupCoreRequestBody.prevShardBackupId != null
+                      ? ShardBackupId.from(backupCoreRequestBody.prevShardBackupId)
+                      : null;
+              BackupFilePaths incBackupFiles = new BackupFilePaths(repository, locationUri);
+              IncrementalShardBackup incSnapShooter =
+                  new IncrementalShardBackup(
+                      repository,
+                      core,
+                      incBackupFiles,
+                      prevShardBackupId,
+                      shardBackupId,
+                      Optional.ofNullable(backupCoreRequestBody.commitName));
+              return incSnapShooter.backup();
+            } else {
+              SnapShooter snapShooter =
+                  new SnapShooter(
+                      repository,
+                      core,
+                      locationUri,
+                      backupCoreRequestBody.backupName,
+                      backupCoreRequestBody.commitName);
+              // validateCreateSnapshot will create parent dirs instead of throw; that choice is
+              // dubious.
+              // But we want to throw. One reason is that this dir really should, in fact must,
+              // already
+              // exist here if triggered via a collection backup on a shared file system. Otherwise,
+              // perhaps the FS location isn't shared -- we want an error.
+              if (!snapShooter.getBackupRepository().exists(snapShooter.getLocation())) {
+                throw new SolrException(
+                    SolrException.ErrorCode.BAD_REQUEST,
+                    "Directory to contain snapshots doesn't exist: "
+                        + snapShooter.getLocation()
+                        + ". "
+                        + "Note that Backup/Restore of a SolrCloud collection "
+                        + "requires a shared file system mounted at the same path on all nodes!");
+              }
+              snapShooter.validateCreateSnapshot();
+              return snapShooter.createSnapshot();
+            }
+          } catch (Exception exp) {
+            throw new SolrException(
+                SolrException.ErrorCode.SERVER_ERROR,
+                "Failed to backup core=" + coreName + " because " + exp,
+                exp);
+          }
+        });
+  }
+
+  public static class BackupCoreRequestBody extends SolrJerseyResponse {
+
+    @Schema(description = "The name of the repository to be used for backup.")
+    @JsonProperty("repository")
+    public String repository;
+
+    @Schema(description = "The path where the backup will be created")
+    @JsonProperty("location")
+    public String location;
+
+    @JsonProperty("shardBackupId")
+    public String shardBackupId;
+
+    @JsonProperty("prevShardBackupId")
+    public String prevShardBackupId;
+
+    @Schema(
+        description = "A descriptive name for the backup.  Only used by non-incremental backups.")
+    @JsonProperty("name")
+    public String backupName;
+
+    @Schema(
+        description =
+            "The name of the commit which was used while taking a snapshot using the CREATESNAPSHOT command.")
+    @JsonProperty("commitName")
+    public String commitName;
+
+    @Schema(description = "To turn on incremental backup feature")
+    @JsonProperty("incremental")
+    public Boolean incremental;
+
+    @Schema(description = "Request ID to track this action which will be processed asynchronously.")
+    @JsonProperty("async")
+    public String async;
+  }
+}
diff --git a/solr/core/src/java/org/apache/solr/handler/api/V2ApiUtils.java b/solr/core/src/java/org/apache/solr/handler/api/V2ApiUtils.java
index 300082406e7..454a1bcbde6 100644
--- a/solr/core/src/java/org/apache/solr/handler/api/V2ApiUtils.java
+++ b/solr/core/src/java/org/apache/solr/handler/api/V2ApiUtils.java
@@ -85,6 +85,11 @@ public class V2ApiUtils {
     squashIntoNamedList(destination, mw, false);
   }
 
+  public static void squashIntoNamedListWithoutHeader(
+      NamedList<Object> destination, JacksonReflectMapWriter mw) {
+    squashIntoNamedList(destination, mw, true);
+  }
+
   public static String getMediaTypeFromWtParam(
       SolrQueryRequest solrQueryRequest, String defaultMediaType) {
     final String wtParam = solrQueryRequest.getParams().get(WT);
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/BackupCoreAPITest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/BackupCoreAPITest.java
new file mode 100644
index 00000000000..4504d6d77a2
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/handler/admin/api/BackupCoreAPITest.java
@@ -0,0 +1,178 @@
+/*
+ * 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 java.io.IOException;
+import java.net.URI;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.core.backup.BackupFilePaths;
+import org.apache.solr.core.backup.repository.BackupRepository;
+import org.apache.solr.handler.IncrementalShardBackup;
+import org.apache.solr.handler.SnapShooter;
+import org.apache.solr.handler.admin.CoreAdminHandler;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class BackupCoreAPITest extends SolrTestCaseJ4 {
+
+  private BackupCoreAPI backupCoreAPI;
+  private static final String backupName = "my-new-backup";
+
+  @BeforeClass
+  public static void initializeCoreAndRequestFactory() throws Exception {
+    initCore("solrconfig.xml", "schema.xml");
+    lrf = h.getRequestFactory("/api", 0, 10);
+  }
+
+  @Before
+  @Override
+  public void setUp() throws Exception {
+    super.setUp();
+    SolrQueryRequest solrQueryRequest = req();
+    SolrQueryResponse solrQueryResponse = new SolrQueryResponse();
+    CoreContainer coreContainer = h.getCoreContainer();
+
+    CoreAdminHandler.CoreAdminAsyncTracker coreAdminAsyncTracker =
+        new CoreAdminHandler.CoreAdminAsyncTracker();
+    backupCoreAPI =
+        new BackupCoreAPI(
+            coreContainer, solrQueryRequest, solrQueryResponse, coreAdminAsyncTracker);
+  }
+
+  @Test
+  public void testCreateNonIncrementalBackupReturnsValidResponse() throws Exception {
+    BackupCoreAPI.BackupCoreRequestBody backupCoreRequestBody = createBackupCoreRequestBody();
+    backupCoreRequestBody.incremental = false;
+    backupCoreRequestBody.backupName = backupName;
+    SnapShooter.CoreSnapshotResponse response =
+        (SnapShooter.CoreSnapshotResponse)
+            backupCoreAPI.createBackup(coreName, backupCoreRequestBody);
+
+    assertEquals(backupName, response.snapshotName);
+    assertEquals("snapshot." + backupName, response.directoryName);
+    assertEquals(1, response.fileCount);
+    assertEquals(1, response.indexFileCount);
+  }
+
+  @Test
+  public void testMissingLocationParameter() throws Exception {
+    BackupCoreAPI.BackupCoreRequestBody backupCoreRequestBody = createBackupCoreRequestBody();
+    backupCoreRequestBody.location = null;
+    backupCoreRequestBody.incremental = false;
+    backupCoreRequestBody.backupName = backupName;
+    final SolrException solrException =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              backupCoreAPI.createBackup(coreName, backupCoreRequestBody);
+            });
+    assertEquals(500, solrException.code());
+    assertTrue(
+        "Exception message differed from expected: " + solrException.getMessage(),
+        solrException
+            .getMessage()
+            .contains("'location' parameter is not specified in the request body"));
+  }
+
+  @Test
+  public void testMissingCoreNameParameter() throws Exception {
+    BackupCoreAPI.BackupCoreRequestBody backupCoreRequestBody = createBackupCoreRequestBody();
+    backupCoreRequestBody.location = null;
+    backupCoreRequestBody.incremental = false;
+    backupCoreRequestBody.backupName = backupName;
+
+    final SolrException solrException =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              backupCoreAPI.createBackup(null, backupCoreRequestBody);
+            });
+    assertEquals(400, solrException.code());
+    assertTrue(
+        "Exception message differed from expected: " + solrException.getMessage(),
+        solrException.getMessage().contains("Missing required parameter:"));
+  }
+
+  @Test
+  public void testNonIncrementalBackupForNonExistentCore() throws Exception {
+    BackupCoreAPI.BackupCoreRequestBody backupCoreRequestBody = createBackupCoreRequestBody();
+    backupCoreRequestBody.location = null;
+    backupCoreRequestBody.incremental = false;
+    backupCoreRequestBody.backupName = backupName;
+    final SolrException solrException =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              backupCoreAPI.createBackup("non-existent-core", backupCoreRequestBody);
+            });
+    assertEquals(500, solrException.code());
+  }
+
+  @Test
+  public void testCreateIncrementalBackupReturnsValidResponse() throws Exception {
+    BackupCoreAPI.BackupCoreRequestBody backupCoreRequestBody = createBackupCoreRequestBody();
+    backupCoreRequestBody.incremental = true;
+    backupCoreRequestBody.shardBackupId = "md_shard1_0";
+    IncrementalShardBackup.IncrementalShardSnapshotResponse response =
+        (IncrementalShardBackup.IncrementalShardSnapshotResponse)
+            backupCoreAPI.createBackup(coreName, backupCoreRequestBody);
+
+    assertEquals(1, response.indexFileCount);
+    assertEquals(1, response.uploadedIndexFileCount);
+    assertEquals(backupCoreRequestBody.shardBackupId, response.shardBackupId);
+  }
+
+  @AfterClass // unique core per test
+  public static void coreDestroy() {
+    deleteCore();
+  }
+
+  private Path createBackupLocation() {
+    return createTempDir().toAbsolutePath();
+  }
+
+  private URI bootstrapBackupLocation(Path locationPath) throws IOException {
+    final String locationPathStr = locationPath.toString();
+    h.getCoreContainer().getAllowPaths().add(locationPath);
+    try (BackupRepository backupRepo = h.getCoreContainer().newBackupRepository(null)) {
+      final URI locationUri = backupRepo.createDirectoryURI(locationPathStr);
+      final BackupFilePaths backupFilePaths = new BackupFilePaths(backupRepo, locationUri);
+      backupFilePaths.createIncrementalBackupFolders();
+      return locationUri;
+    }
+  }
+
+  private BackupCoreAPI.BackupCoreRequestBody createBackupCoreRequestBody() throws Exception {
+    final Path locationPath = createBackupLocation();
+    final URI locationUri = bootstrapBackupLocation(locationPath);
+    final CoreContainer cores = h.getCoreContainer();
+    cores.getAllowPaths().add(Paths.get(locationUri));
+    final BackupCoreAPI.BackupCoreRequestBody backupCoreRequestBody =
+        new BackupCoreAPI.BackupCoreRequestBody();
+    backupCoreRequestBody.location = locationPath.toString();
+    return backupCoreRequestBody;
+  }
+}