This is an automated email from the ASF dual-hosted git repository.
epugh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr.git
The following commit(s) were added to refs/heads/main by this push:
new 7b6d7ce4c4e SOLR-15701: Complete configsets api (#4264)
7b6d7ce4c4e is described below
commit 7b6d7ce4c4e740dc4830f5a5242e5ee30c19af98
Author: Eric Pugh <[email protected]>
AuthorDate: Fri Apr 24 07:48:57 2026 -0400
SOLR-15701: Complete configsets api (#4264)
Add Download and GetFile and PutFile to ConfigSets API and SolrJ and
removed overwrite parameter from the V1 single file upload.
---
.../SOLR-15701_complete_configsets_api.yml | 7 +
.../solr/client/api/endpoint/ConfigsetsApi.java | 86 +++++-
.../org/apache/solr/core/ConfigSetService.java | 26 +-
.../solr/core/FileSystemConfigSetService.java | 7 +-
.../solr/handler/admin/ConfigSetsHandler.java | 26 +-
.../solr/handler/configsets/CloneConfigSet.java | 6 +-
.../solr/handler/configsets/ConfigSetAPIBase.java | 16 +-
.../solr/handler/configsets/DeleteConfigSet.java | 6 +-
.../solr/handler/configsets/DownloadConfigSet.java | 133 ++++++++
.../solr/handler/configsets/GetConfigSetFile.java | 75 +++++
.../solr/handler/configsets/UploadConfigSet.java | 26 +-
.../cloud/MockScriptUpdateProcessorFactory.java | 7 +-
.../org/apache/solr/cloud/TestConfigSetsAPI.java | 340 ++++++++++-----------
.../solr/cloud/TestConfigSetsAPIExclusivity.java | 2 -
.../configsets/DownloadConfigSetAPITest.java | 94 ++++++
.../configsets/GetConfigSetFileAPITest.java | 197 ++++++++++++
.../handler/configsets/UploadConfigSetAPITest.java | 307 +++++++++++++++++++
.../configsets/UploadConfigSetFileAPITest.java | 101 ++++++
.../configuration-guide/pages/config-sets.adoc | 2 +-
.../configuration-guide/pages/configsets-api.adoc | 208 ++++++++++---
.../pages/major-changes-in-solr-10.adoc | 7 +
21 files changed, 1397 insertions(+), 282 deletions(-)
diff --git a/changelog/unreleased/SOLR-15701_complete_configsets_api.yml
b/changelog/unreleased/SOLR-15701_complete_configsets_api.yml
new file mode 100644
index 00000000000..3d79059b1e2
--- /dev/null
+++ b/changelog/unreleased/SOLR-15701_complete_configsets_api.yml
@@ -0,0 +1,7 @@
+title: Add ConfigSets.Download and ConfigSets.GetFile to SolrJ
+type: added
+authors:
+ - name: Eric Pugh
+links:
+ - name: SOLR-15701
+ url: https://issues.apache.org/jira/browse/SOLR-15701
diff --git
a/solr/api/src/java/org/apache/solr/client/api/endpoint/ConfigsetsApi.java
b/solr/api/src/java/org/apache/solr/client/api/endpoint/ConfigsetsApi.java
index 4bc812043e9..f7fd006ef44 100644
--- a/solr/api/src/java/org/apache/solr/client/api/endpoint/ConfigsetsApi.java
+++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/ConfigsetsApi.java
@@ -16,7 +16,12 @@
*/
package org.apache.solr.client.api.endpoint;
+import static
org.apache.solr.client.api.util.Constants.GENERIC_ENTITY_PROPERTY;
+import static org.apache.solr.client.api.util.Constants.RAW_OUTPUT_PROPERTY;
+
import io.swagger.v3.oas.annotations.Operation;
+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.DELETE;
import jakarta.ws.rs.GET;
@@ -24,7 +29,11 @@ import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.StreamingOutput;
import java.io.IOException;
import java.io.InputStream;
import org.apache.solr.client.api.model.CloneConfigsetRequestBody;
@@ -71,31 +80,92 @@ public interface ConfigsetsApi {
throws Exception;
}
+ /** V2 API definition for downloading an existing configset as a ZIP
archive. */
+ @Path("/configsets/{configSetName}")
+ interface Download {
+ @GET
+ @Path("/files")
+ @Operation(
+ summary = "Download a configset as a ZIP archive.",
+ tags = {"configsets"},
+ extensions = {
+ @Extension(properties = {@ExtensionProperty(name =
RAW_OUTPUT_PROPERTY, value = "true")})
+ })
+ @Produces("application/zip")
+ Response downloadConfigSet(@PathParam("configSetName") String
configSetName) throws Exception;
+ }
+
/**
- * V2 API definitions for uploading a configset, in whole or part.
+ * V2 API definition for reading a single file from an existing configset.
+ *
+ * <p>Returns the raw bytes of the file, suitable for both text and binary
files.
+ *
+ * <p>Equivalent to GET /api/configsets/{configSetName}/files/{filePath}
+ */
+ @Path("/configsets/{configSetName}")
+ interface GetFile {
+ @GET
+ @Path("/files/{filePath:.+}")
+ @Produces(MediaType.APPLICATION_OCTET_STREAM)
+ @Operation(
+ summary = "Get the raw contents of a file in a configset.",
+ tags = {"configsets"},
+ extensions = {
+ @Extension(properties = {@ExtensionProperty(name =
RAW_OUTPUT_PROPERTY, value = "true")})
+ })
+ StreamingOutput getConfigSetFile(
+ @PathParam("configSetName") String configSetName,
@PathParam("filePath") String filePath)
+ throws Exception;
+ }
+
+ /**
+ * V2 API definition for uploading an entire configset as a ZIP archive.
*
* <p>Equivalent to the existing v1 API /admin/configs?action=UPLOAD
*/
@Path("/configsets/{configSetName}")
interface Upload {
@PUT
- @Operation(summary = "Create a new configset.", tags = "configsets")
+ @Operation(summary = "Upload a configset as a ZIP archive.", tags =
"configsets")
SolrJerseyResponse uploadConfigSet(
@PathParam("configSetName") String configSetName,
@QueryParam("overwrite") Boolean overwrite,
@QueryParam("cleanup") Boolean cleanup,
- @RequestBody(required = true) InputStream requestBody)
+ @RequestBody(
+ required = true,
+ extensions = {
+ @Extension(
+ properties = {
+ @ExtensionProperty(name = GENERIC_ENTITY_PROPERTY,
value = "true")
+ })
+ })
+ InputStream requestBody)
throws IOException;
+ }
+ /**
+ * V2 API definition for putting a single file to an existing configset.
+ *
+ * <p>This endpoint allows updating individual configuration files without
re-uploading the entire
+ * configset. The file path is specified as part of the URL path.
+ */
+ @Path("/configsets/{configSetName}")
+ interface PutFile {
@PUT
- @Path("{filePath:.+}")
- @Operation(summary = "Create a new configset.", tags = "configsets")
+ @Path("/files/{filePath:.+}")
+ @Operation(summary = "Upload a single file to a configset.", tags =
"configsets")
SolrJerseyResponse uploadConfigSetFile(
@PathParam("configSetName") String configSetName,
@PathParam("filePath") String filePath,
- @QueryParam("overwrite") Boolean overwrite,
- @QueryParam("cleanup") Boolean cleanup,
- @RequestBody(required = true) InputStream requestBody)
+ @RequestBody(
+ required = true,
+ extensions = {
+ @Extension(
+ properties = {
+ @ExtensionProperty(name = GENERIC_ENTITY_PROPERTY,
value = "true")
+ })
+ })
+ InputStream requestBody)
throws IOException;
}
}
diff --git a/solr/core/src/java/org/apache/solr/core/ConfigSetService.java
b/solr/core/src/java/org/apache/solr/core/ConfigSetService.java
index 99d237f9e38..d88b15e0c44 100644
--- a/solr/core/src/java/org/apache/solr/core/ConfigSetService.java
+++ b/solr/core/src/java/org/apache/solr/core/ConfigSetService.java
@@ -381,8 +381,8 @@ public abstract class ConfigSetService {
public abstract void uploadConfig(String configName, Path dir) throws
IOException;
/**
- * Upload a file to config If file does not exist, it will be uploaded If
overwriteOnExists is set
- * to true then file will be overwritten
+ * Upload a file to config. If file does not exist, it will be uploaded. If
overwriteOnExists is
+ * set to true then the file will be overwritten.
*
* @param configName the name to give the config
* @param fileName the name of the file with '/' used as the file path
separator
@@ -395,7 +395,7 @@ public abstract class ConfigSetService {
throws IOException;
/**
- * Download all files from this config to the filesystem at dir
+ * Download all files from this config to the filesystem at dir.
*
* @param configName the config to download
* @param dir the {@link Path} to write files under
@@ -403,7 +403,7 @@ public abstract class ConfigSetService {
public abstract void downloadConfig(String configName, Path dir) throws
IOException;
/**
- * Download a file from config If the file does not exist, it returns null
+ * Download a file from config. If the file does not exist, it returns null.
*
* @param configName the name of the config
* @param filePath the file to download with '/' as the separator
@@ -413,7 +413,7 @@ public abstract class ConfigSetService {
throws IOException;
/**
- * Copy a config
+ * Copy a config.
*
* @param fromConfig the config to copy from
* @param toConfig the config to copy to
@@ -421,7 +421,7 @@ public abstract class ConfigSetService {
public abstract void copyConfig(String fromConfig, String toConfig) throws
IOException;
/**
- * Check whether a config exists
+ * Check whether a config exists.
*
* @param configName the config to check if it exists
* @return whether the config exists or not
@@ -429,14 +429,14 @@ public abstract class ConfigSetService {
public abstract boolean checkConfigExists(String configName) throws
IOException;
/**
- * Delete a config (recursively deletes its files if not empty)
+ * Delete a config (recursively deletes its files if not empty).
*
* @param configName the config to delete
*/
public abstract void deleteConfig(String configName) throws IOException;
/**
- * Delete files in config
+ * Delete files in config.
*
* @param configName the name of the config
* @param filesToDelete a list of file paths to delete using '/' as file
path separator
@@ -445,8 +445,8 @@ public abstract class ConfigSetService {
throws IOException;
/**
- * Set the config metadata If config does not exist, it will be created and
set metadata on it
- * Else metadata will be replaced with the provided metadata
+ * Set the config metadata. If config does not exist, it will be created and
set metadata on it.
+ * Else metadata will be replaced with the provided metadata.
*
* @param configName the config name
* @param data the metadata to be set on config
@@ -455,7 +455,7 @@ public abstract class ConfigSetService {
throws IOException;
/**
- * Get the config metadata (mutable, non-null)
+ * Get the config metadata (mutable, non-null).
*
* @param configName the config name
* @return the config metadata
@@ -463,7 +463,7 @@ public abstract class ConfigSetService {
public abstract Map<String, Object> getConfigMetadata(String configName)
throws IOException;
/**
- * List the names of configs (non-null)
+ * List the names of configs (non-null).
*
* @return list of config names
*/
@@ -471,7 +471,7 @@ public abstract class ConfigSetService {
/**
* Get the names of the files in config including dirs (mutable, non-null)
sorted
- * lexicographically e.g. solrconfig.xml, lang/, lang/stopwords_en.txt
+ * lexicographically e.g. solrconfig.xml, lang/, lang/stopwords_en.txt.
*
* @param configName the config name
* @return list of file name paths in the config with '/' uses as file path
separators
diff --git
a/solr/core/src/java/org/apache/solr/core/FileSystemConfigSetService.java
b/solr/core/src/java/org/apache/solr/core/FileSystemConfigSetService.java
index 496e9fa86a4..0baeadd727a 100644
--- a/solr/core/src/java/org/apache/solr/core/FileSystemConfigSetService.java
+++ b/solr/core/src/java/org/apache/solr/core/FileSystemConfigSetService.java
@@ -42,7 +42,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
- * FileSystem ConfigSetService impl.
+ * File system based ConfigSetService implementation.
*
* <p>Loads a ConfigSet defined by the core's configSet property, looking for
a directory named for
* the configSet property value underneath a base directory. If no configSet
property is set, loads
@@ -182,6 +182,11 @@ public class FileSystemConfigSetService extends
ConfigSetService {
}
if (overwriteOnExists || !Files.exists(configsetFilePath)) {
+ // Create parent directories if they don't exist (similar to ZK's
makePath)
+ Path parent = configsetFilePath.getParent();
+ if (parent != null && !Files.exists(parent)) {
+ Files.createDirectories(parent);
+ }
Files.write(configsetFilePath, data);
}
}
diff --git
a/solr/core/src/java/org/apache/solr/handler/admin/ConfigSetsHandler.java
b/solr/core/src/java/org/apache/solr/handler/admin/ConfigSetsHandler.java
index edcdc0b1088..50159bf1abd 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/ConfigSetsHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/ConfigSetsHandler.java
@@ -18,7 +18,6 @@ package org.apache.solr.handler.admin;
import static org.apache.solr.common.params.CommonParams.NAME;
-import java.lang.invoke.MethodHandles;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
@@ -39,21 +38,20 @@ import org.apache.solr.handler.api.V2ApiUtils;
import org.apache.solr.handler.configsets.CloneConfigSet;
import org.apache.solr.handler.configsets.ConfigSetAPIBase;
import org.apache.solr.handler.configsets.DeleteConfigSet;
+import org.apache.solr.handler.configsets.DownloadConfigSet;
+import org.apache.solr.handler.configsets.GetConfigSetFile;
import org.apache.solr.handler.configsets.ListConfigSets;
import org.apache.solr.handler.configsets.UploadConfigSet;
import org.apache.solr.request.SolrQueryRequest;
import org.apache.solr.response.SolrQueryResponse;
import org.apache.solr.security.AuthorizationContext;
import org.apache.solr.security.PermissionNameProvider;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
/** A {@link org.apache.solr.request.SolrRequestHandler} for ConfigSets API
requests. */
public class ConfigSetsHandler extends RequestHandlerBase implements
PermissionNameProvider {
// TODO refactor into o.a.s.handler.configsets package to live alongside
actual API logic
public static final String DEFAULT_CONFIGSET_NAME = "_default";
public static final String AUTOCREATED_CONFIGSET_SUFFIX = ".AUTOCREATED";
- private static final Logger log =
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
protected final CoreContainer coreContainer;
public static long CONFIG_SET_TIMEOUT = 300 * 1000;
@@ -106,10 +104,15 @@ public class ConfigSetsHandler extends RequestHandlerBase
implements PermissionN
uploadResponse =
uploadApi.uploadConfigSet(configSetName, overwrite, cleanup,
configSetData);
} else { // Uploading a single file
+ // Single file uploads do not support cleanup or overwrite parameters
+ if (cleanup) {
+ throw new SolrException(
+ ErrorCode.BAD_REQUEST,
+ "ConfigSet uploads do not allow cleanup=true when filePath is
used.");
+ }
+ // Note: overwrite parameter is ignored for single file uploads
(always overwrites)
final var filePath = req.getParams().get(ConfigSetParams.FILE_PATH);
- uploadResponse =
- uploadApi.uploadConfigSetFile(
- configSetName, filePath, overwrite, cleanup, configSetData);
+ uploadResponse = uploadApi.uploadConfigSetFile(configSetName,
filePath, configSetData);
}
V2ApiUtils.squashIntoSolrResponseWithoutHeader(rsp, uploadResponse);
break;
@@ -119,7 +122,7 @@ public class ConfigSetsHandler extends RequestHandlerBase
implements PermissionN
break;
case CREATE:
final String newConfigSetName = req.getParams().get(NAME);
- if (newConfigSetName == null || newConfigSetName.length() == 0) {
+ if (newConfigSetName == null || newConfigSetName.isEmpty()) {
throw new SolrException(ErrorCode.BAD_REQUEST, "ConfigSet name not
specified");
}
@@ -187,7 +190,12 @@ public class ConfigSetsHandler extends RequestHandlerBase
implements PermissionN
@Override
public Collection<Class<? extends JerseyResource>> getJerseyResources() {
return List.of(
- ListConfigSets.class, CloneConfigSet.class, DeleteConfigSet.class,
UploadConfigSet.class);
+ ListConfigSets.class,
+ CloneConfigSet.class,
+ DeleteConfigSet.class,
+ UploadConfigSet.class,
+ DownloadConfigSet.class,
+ GetConfigSetFile.class);
}
@Override
diff --git
a/solr/core/src/java/org/apache/solr/handler/configsets/CloneConfigSet.java
b/solr/core/src/java/org/apache/solr/handler/configsets/CloneConfigSet.java
index 0ceb5a830be..c90a3636563 100644
--- a/solr/core/src/java/org/apache/solr/handler/configsets/CloneConfigSet.java
+++ b/solr/core/src/java/org/apache/solr/handler/configsets/CloneConfigSet.java
@@ -35,7 +35,11 @@ import org.apache.solr.jersey.PermissionName;
import org.apache.solr.request.SolrQueryRequest;
import org.apache.solr.response.SolrQueryResponse;
-/** V2 API implementation for ConfigsetsApi.Clone */
+/**
+ * V2 API implementation for creating a new configset form an existing one.
+ *
+ * <p>This API (GET /v2/configsets) is analogous to the v1
/admin/configs?action=CREATE command.
+ */
public class CloneConfigSet extends ConfigSetAPIBase implements
ConfigsetsApi.Clone {
@Inject
diff --git
a/solr/core/src/java/org/apache/solr/handler/configsets/ConfigSetAPIBase.java
b/solr/core/src/java/org/apache/solr/handler/configsets/ConfigSetAPIBase.java
index 0e780cf343d..5f291cab954 100644
---
a/solr/core/src/java/org/apache/solr/handler/configsets/ConfigSetAPIBase.java
+++
b/solr/core/src/java/org/apache/solr/handler/configsets/ConfigSetAPIBase.java
@@ -51,7 +51,7 @@ import org.slf4j.LoggerFactory;
* Parent class for all APIs that manipulate configsets
*
* <p>Contains utilities for tasks common in configset manipulation, including
running configset
- * "commands" and checking configset "trusted-ness".
+ * "commands".
*/
public class ConfigSetAPIBase extends JerseyResource {
@@ -112,20 +112,6 @@ public class ConfigSetAPIBase extends JerseyResource {
return contentStreamsIterator.next().getStream();
}
- protected void createBaseNode(
- ConfigSetService configSetService,
- boolean overwritesExisting,
- boolean requestIsTrusted,
- String configName)
- throws IOException {
- if (overwritesExisting) {
- if (!requestIsTrusted) {
- throw new SolrException(
- SolrException.ErrorCode.BAD_REQUEST, "Trying to make an untrusted
ConfigSet update");
- }
- }
- }
-
private void sendToOverseer(
SolrQueryResponse rsp, ConfigSetParams.ConfigSetAction action,
Map<String, Object> result)
throws KeeperException, InterruptedException {
diff --git
a/solr/core/src/java/org/apache/solr/handler/configsets/DeleteConfigSet.java
b/solr/core/src/java/org/apache/solr/handler/configsets/DeleteConfigSet.java
index 1a4b363a833..3b26c5e2fc2 100644
--- a/solr/core/src/java/org/apache/solr/handler/configsets/DeleteConfigSet.java
+++ b/solr/core/src/java/org/apache/solr/handler/configsets/DeleteConfigSet.java
@@ -32,7 +32,11 @@ import org.apache.solr.jersey.PermissionName;
import org.apache.solr.request.SolrQueryRequest;
import org.apache.solr.response.SolrQueryResponse;
-/** V2 API implementation for ConfigsetsApi.Delete */
+/**
+ * V2 API implementation for deleting a configset
+ *
+ * <p>This API (GET /v2/configsets) is analogous to the v1
/admin/configs?action=DELETE command.
+ */
public class DeleteConfigSet extends ConfigSetAPIBase implements
ConfigsetsApi.Delete {
@Inject
diff --git
a/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java
b/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java
new file mode 100644
index 00000000000..729aaf00d91
--- /dev/null
+++
b/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java
@@ -0,0 +1,133 @@
+/*
+ * 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.configsets;
+
+import static
org.apache.solr.security.PermissionNameProvider.Name.CONFIG_READ_PERM;
+
+import jakarta.inject.Inject;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.StreamingOutput;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import org.apache.commons.io.file.PathUtils;
+import org.apache.solr.client.api.endpoint.ConfigsetsApi;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.StrUtils;
+import org.apache.solr.core.ConfigSetService;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.jersey.PermissionName;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+
+/** V2 API implementation for downloading a configset as a zip file. */
+public class DownloadConfigSet extends ConfigSetAPIBase implements
ConfigsetsApi.Download {
+
+ @Inject
+ public DownloadConfigSet(
+ CoreContainer coreContainer,
+ SolrQueryRequest solrQueryRequest,
+ SolrQueryResponse solrQueryResponse) {
+ super(coreContainer, solrQueryRequest, solrQueryResponse);
+ }
+
+ @Override
+ @PermissionName(CONFIG_READ_PERM)
+ public Response downloadConfigSet(String configSetName) throws Exception {
+ if (StrUtils.isNullOrEmpty(configSetName)) {
+ throw new SolrException(
+ SolrException.ErrorCode.BAD_REQUEST, "No configset name provided to
download");
+ }
+ if (!configSetService.checkConfigExists(configSetName)) {
+ throw new SolrException(
+ SolrException.ErrorCode.NOT_FOUND, "ConfigSet " + configSetName + "
not found!");
+ }
+ return buildZipResponse(configSetService, configSetName);
+ }
+
+ /**
+ * Build a ZIP download {@link Response} for the given configset.
+ *
+ * @param configSetService the service to use for downloading the configset
files
+ * @param configSetName the name of the configset to download
+ */
+ public static Response buildZipResponse(ConfigSetService configSetService,
String configSetName)
+ throws IOException {
+ final byte[] zipBytes = zipConfigSet(configSetService, configSetName);
+ return Response.ok((StreamingOutput) outputStream ->
outputStream.write(zipBytes))
+ .type("application/zip")
+ .build();
+ }
+
+ /**
+ * Download the named configset from {@link ConfigSetService} and return its
contents as a ZIP
+ * archive byte array.
+ */
+ public static byte[] zipConfigSet(ConfigSetService configSetService, String
configSetName)
+ throws IOException {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ Path tmpDirectory = Files.createTempDirectory("configset-download-");
+ try {
+ configSetService.downloadConfig(configSetName, tmpDirectory);
+ try (ZipOutputStream zipOut = new ZipOutputStream(baos)) {
+ Files.walkFileTree(
+ tmpDirectory,
+ new SimpleFileVisitor<>() {
+ @Override
+ public FileVisitResult preVisitDirectory(Path dir,
BasicFileAttributes attrs)
+ throws IOException {
+ if (Files.isHidden(dir)) {
+ return FileVisitResult.SKIP_SUBTREE;
+ }
+ String dirName = tmpDirectory.relativize(dir).toString();
+ if (!dirName.isEmpty()) {
+ if (!dirName.endsWith("/")) {
+ dirName += "/";
+ }
+ zipOut.putNextEntry(new ZipEntry(dirName));
+ zipOut.closeEntry();
+ }
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult visitFile(Path file, BasicFileAttributes
attrs)
+ throws IOException {
+ if (!Files.isHidden(file)) {
+ try (InputStream fis = Files.newInputStream(file)) {
+ ZipEntry zipEntry = new
ZipEntry(tmpDirectory.relativize(file).toString());
+ zipOut.putNextEntry(zipEntry);
+ fis.transferTo(zipOut);
+ }
+ }
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ }
+ } finally {
+ PathUtils.deleteDirectory(tmpDirectory);
+ }
+ return baos.toByteArray();
+ }
+}
diff --git
a/solr/core/src/java/org/apache/solr/handler/configsets/GetConfigSetFile.java
b/solr/core/src/java/org/apache/solr/handler/configsets/GetConfigSetFile.java
new file mode 100644
index 00000000000..75fdaa9272a
--- /dev/null
+++
b/solr/core/src/java/org/apache/solr/handler/configsets/GetConfigSetFile.java
@@ -0,0 +1,75 @@
+/*
+ * 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.configsets;
+
+import static
org.apache.solr.security.PermissionNameProvider.Name.CONFIG_READ_PERM;
+
+import jakarta.inject.Inject;
+import jakarta.ws.rs.core.StreamingOutput;
+import java.io.IOException;
+import org.apache.solr.client.api.endpoint.ConfigsetsApi;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.StrUtils;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.jersey.PermissionName;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+
+/** V2 API implementation for reading the contents of a single file from an
existing configset. */
+public class GetConfigSetFile extends ConfigSetAPIBase implements
ConfigsetsApi.GetFile {
+
+ @Inject
+ public GetConfigSetFile(
+ CoreContainer coreContainer,
+ SolrQueryRequest solrQueryRequest,
+ SolrQueryResponse solrQueryResponse) {
+ super(coreContainer, solrQueryRequest, solrQueryResponse);
+ }
+
+ @Override
+ @PermissionName(CONFIG_READ_PERM)
+ public StreamingOutput getConfigSetFile(String configSetName, String
filePath) throws Exception {
+ if (StrUtils.isNullOrEmpty(configSetName)) {
+ throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "No
configset name provided");
+ }
+ if (StrUtils.isNullOrEmpty(filePath)) {
+ throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "No file
path provided");
+ }
+ if (!configSetService.checkConfigExists(configSetName)) {
+ throw new SolrException(
+ SolrException.ErrorCode.NOT_FOUND, "ConfigSet '" + configSetName +
"' not found");
+ }
+
+ // Return StreamingOutput that writes raw bytes (supports both text and
binary files)
+ return output -> {
+ try {
+ final byte[] data =
configSetService.downloadFileFromConfig(configSetName, filePath);
+ if (data == null) {
+ throw new SolrException(
+ SolrException.ErrorCode.NOT_FOUND,
+ "File '" + filePath + "' not found in configset '" +
configSetName + "'");
+ }
+ output.write(data);
+ } catch (IOException e) {
+ throw new SolrException(
+ SolrException.ErrorCode.NOT_FOUND,
+ "File '" + filePath + "' not found in configset '" + configSetName
+ "'",
+ e);
+ }
+ };
+ }
+}
diff --git
a/solr/core/src/java/org/apache/solr/handler/configsets/UploadConfigSet.java
b/solr/core/src/java/org/apache/solr/handler/configsets/UploadConfigSet.java
index fee660a87f5..6728b17ef10 100644
--- a/solr/core/src/java/org/apache/solr/handler/configsets/UploadConfigSet.java
+++ b/solr/core/src/java/org/apache/solr/handler/configsets/UploadConfigSet.java
@@ -40,7 +40,13 @@ import org.apache.solr.util.FileTypeMagicUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-public class UploadConfigSet extends ConfigSetAPIBase implements
ConfigsetsApi.Upload {
+/**
+ * V2 API implementation for uploading a configset as a zip file.
+ *
+ * <p>This API (GET /v2/configsets) is analogous to the v1
/admin/configs?action=UPLOAD command.
+ */
+public class UploadConfigSet extends ConfigSetAPIBase
+ implements ConfigsetsApi.Upload, ConfigsetsApi.PutFile {
private static final Logger log =
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
@@ -104,26 +110,19 @@ public class UploadConfigSet extends ConfigSetAPIBase
implements ConfigsetsApi.U
@Override
@PermissionName(CONFIG_EDIT_PERM)
public SolrJerseyResponse uploadConfigSetFile(
- String configSetName,
- String filePath,
- Boolean overwrite,
- Boolean cleanup,
- InputStream requestBody)
- throws IOException {
+ String configSetName, String filePath, InputStream requestBody) throws
IOException {
final var response = instantiateJerseyResponse(SolrJerseyResponse.class);
ensureConfigSetUploadEnabled();
SolrIdentifierValidator.validateConfigSetName(configSetName);
- boolean overwritesExisting =
configSetService.checkConfigExists(configSetName);
+ boolean overwrite = true;
// Get upload parameters
String singleFilePath = filePath != null ? filePath : "";
- if (overwrite == null) overwrite = true;
- if (cleanup == null) cleanup = false;
String fixedSingleFilePath = singleFilePath;
- if (fixedSingleFilePath.charAt(0) == '/') {
+ if (!fixedSingleFilePath.isEmpty() && fixedSingleFilePath.charAt(0) ==
'/') {
fixedSingleFilePath = fixedSingleFilePath.substring(1);
}
byte[] data = requestBody.readAllBytes();
@@ -138,11 +137,6 @@ public class UploadConfigSet extends ConfigSetAPIBase
implements ConfigsetsApi.U
"The file type provided for upload, '"
+ singleFilePath
+ "', is forbidden for use in configSets.");
- } else if (cleanup) {
- // Cleanup is not allowed while using singleFilePath upload
- throw new SolrException(
- SolrException.ErrorCode.BAD_REQUEST,
- "ConfigSet uploads do not allow cleanup=true when file path is
used.");
} else {
// Create a node for the configuration in config
// For creating the baseNode, the cleanup parameter is only allowed to
be true when
diff --git
a/solr/core/src/test/org/apache/solr/cloud/MockScriptUpdateProcessorFactory.java
b/solr/core/src/test/org/apache/solr/cloud/MockScriptUpdateProcessorFactory.java
index de6be081bf4..87a444453fc 100644
---
a/solr/core/src/test/org/apache/solr/cloud/MockScriptUpdateProcessorFactory.java
+++
b/solr/core/src/test/org/apache/solr/cloud/MockScriptUpdateProcessorFactory.java
@@ -25,10 +25,9 @@ import
org.apache.solr.update.processor.UpdateRequestProcessor;
import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
/**
- * The scripting update processor capability is something that is only allowed
by a trusted
- * configSet. The actual code lives in the /modules/scripting project, however
the test for trusted
- * configsets lives in TestConfigSetsAPI. This class is meant to simulate the
- * ScriptUpdateProcessorFactory for this test.
+ * Mock implementation of ScriptUpdateProcessorFactory for testing purposes.
The actual code lives
+ * in the /modules/scripting project, however the test lives in
TestConfigSetsAPI. This class is
+ * meant to simulate the ScriptUpdateProcessorFactory for this test.
*/
public class MockScriptUpdateProcessorFactory extends
UpdateRequestProcessorFactory {
diff --git a/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java
b/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java
index d0e6a70e66d..6736af93b68 100644
--- a/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java
+++ b/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java
@@ -19,7 +19,6 @@ package org.apache.solr.cloud;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.apache.solr.common.params.CommonParams.NAME;
import static org.apache.solr.core.ConfigSetProperties.DEFAULT_FILENAME;
-import static org.hamcrest.CoreMatchers.containsString;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
@@ -67,6 +66,7 @@ import
org.apache.solr.client.solrj.request.ConfigSetAdminRequest;
import org.apache.solr.client.solrj.request.ConfigSetAdminRequest.Create;
import org.apache.solr.client.solrj.request.ConfigSetAdminRequest.Delete;
import org.apache.solr.client.solrj.request.ConfigSetAdminRequest.Upload;
+import org.apache.solr.client.solrj.request.ConfigsetsApi;
import org.apache.solr.client.solrj.request.GenericSolrRequest;
import org.apache.solr.client.solrj.request.schema.SchemaRequest;
import org.apache.solr.client.solrj.response.CollectionAdminResponse;
@@ -592,15 +592,9 @@ public class TestConfigSetsAPI extends SolrCloudTestCase {
}
}
- @Test
- public void testSingleFileOverwriteV1() throws Exception {
- testSingleFileOverwrite(false);
- }
+ // Single file uploads do not support overwrite parameter (always overwrites)
- @Test
- public void testSingleFileOverwriteV2() throws Exception {
- testSingleFileOverwrite(true);
- }
+ // V2 API not tested: single file uploads always overwrite (no overwrite
parameter)
public void testSingleFileOverwrite(boolean v2) throws Exception {
String configsetName = "regular";
@@ -743,19 +737,12 @@ public class TestConfigSetsAPI extends SolrCloudTestCase {
zkClient, configsetName, configsetSuffix,
"test/upload/path/solrconfig.xml"));
}
+ // Single file uploads do not support cleanup parameter
+ // V2 API not tested: single file uploads do not support cleanup parameter
@Test
- public void testSingleWithCleanupV1() throws Exception {
- testSingleWithCleanup(false);
- }
-
- @Test
- public void testSingleWithCleanupV2() throws Exception {
- testSingleWithCleanup(true);
- }
-
- public void testSingleWithCleanup(boolean v2) throws Exception {
+ public void testSingleWithCleanup() throws Exception {
String configsetName = "regular";
- String configsetSuffix = "testSinglePathCleanup-1-" + v2;
+ String configsetSuffix = "testSinglePathCleanup-1";
uploadConfigSetWithAssertions(configsetName, configsetSuffix, null);
try (SolrZkClient zkClient =
new SolrZkClient.Builder()
@@ -774,7 +761,7 @@ public class TestConfigSetsAPI extends SolrCloudTestCase {
"/test/upload/path/solrconfig.xml",
true,
true,
- v2));
+ false));
assertFalse(
"New file should not exist, since the trust check did not succeed.",
zkClient.exists(
@@ -831,19 +818,13 @@ public class TestConfigSetsAPI extends SolrCloudTestCase {
}
}
- @Test
- public void testSingleFileUntrustedV1() throws Exception {
- testSingleFileUntrusted(false);
- }
+ // Single file uploads do not support cleanup parameter
- @Test
- public void testSingleFileUntrustedV2() throws Exception {
- testSingleFileUntrusted(true);
- }
+ // V2 API not tested: single file uploads do not support cleanup parameter
- public void testSingleFileUntrusted(boolean v2) throws Exception {
+ public void testSingleFileUpload(boolean v2) throws Exception {
String configsetName = "regular";
- String configsetSuffix = "testSinglePathUntrusted-1-" + v2;
+ String configsetSuffix = "testSinglePathUpload-1-" + v2;
uploadConfigSetWithAssertions(configsetName, configsetSuffix, null);
try (SolrZkClient zkClient =
@@ -852,8 +833,7 @@ public class TestConfigSetsAPI extends SolrCloudTestCase {
.withTimeout(AbstractZkTestCase.TIMEOUT, TimeUnit.MILLISECONDS)
.withConnTimeOut(45000, TimeUnit.MILLISECONDS)
.build()) {
- // New file with trusted request
-
+ // Upload new file to first path
assertEquals(
0,
uploadSingleConfigSetFile(
@@ -872,13 +852,13 @@ public class TestConfigSetsAPI extends SolrCloudTestCase {
zkClient, configsetName, configsetSuffix,
"test/upload/path/solrconfig.xml"));
assertConfigsetFiles(configsetName, configsetSuffix, zkClient);
- // New file with untrusted request
+ // Upload new file to different path
assertEquals(
0,
uploadSingleConfigSetFile(
configsetName,
configsetSuffix,
- null,
+ "solr",
"solr/configsets/upload/regular/solrconfig.xml",
"/test/different/path/solrconfig.xml",
false,
@@ -891,7 +871,7 @@ public class TestConfigSetsAPI extends SolrCloudTestCase {
zkClient, configsetName, configsetSuffix,
"test/different/path/solrconfig.xml"));
assertConfigsetFiles(configsetName, configsetSuffix, zkClient);
- // Overwrite with trusted request
+ // Overwrite existing file
int extraFileZkVersion =
getConfigZNodeVersion(
zkClient, configsetName, configsetSuffix,
"test/different/path/solrconfig.xml");
@@ -913,29 +893,7 @@ public class TestConfigSetsAPI extends SolrCloudTestCase {
zkClient, configsetName, configsetSuffix,
"test/different/path/solrconfig.xml"));
assertConfigsetFiles(configsetName, configsetSuffix, zkClient);
- // Overwrite with untrusted request
- extraFileZkVersion =
- getConfigZNodeVersion(
- zkClient, configsetName, configsetSuffix,
"test/upload/path/solrconfig.xml");
- assertEquals(
- 0,
- uploadSingleConfigSetFile(
- configsetName,
- configsetSuffix,
- null,
- "solr/configsets/upload/regular/solrconfig.xml",
- "/test/upload/path/solrconfig.xml",
- true,
- false,
- v2));
- assertTrue(
- "Expecting version bump",
- extraFileZkVersion
- < getConfigZNodeVersion(
- zkClient, configsetName, configsetSuffix,
"test/upload/path/solrconfig.xml"));
- assertConfigsetFiles(configsetName, configsetSuffix, zkClient);
-
- // Make sure that cleanup flag does not result in configSet being
trusted.
+ // Make sure that cleanup flag validation works correctly.
ignoreException("ConfigSet uploads do not allow cleanup=true when
filePath is used.");
extraFileZkVersion =
getConfigZNodeVersion(
@@ -973,20 +931,19 @@ public class TestConfigSetsAPI extends SolrCloudTestCase {
public void testSingleFileNewConfig(boolean v2) throws Exception {
String configsetName = "regular";
- String configsetSuffixTrusted = "testSinglePathNewConfig-1-" + v2;
- String configsetSuffixUntrusted = "testSinglePathNewConfig-2-" + v2;
+ String configsetSuffix = "testSinglePathNewConfig-" + v2;
try (SolrZkClient zkClient =
new SolrZkClient.Builder()
.withUrl(cluster.getZkServer().getZkAddress())
.withTimeout(AbstractZkTestCase.TIMEOUT, TimeUnit.MILLISECONDS)
.withConnTimeOut(45000, TimeUnit.MILLISECONDS)
.build()) {
- // New file with trusted request
+ // Upload single file to create new configset
assertEquals(
0,
uploadSingleConfigSetFile(
configsetName,
- configsetSuffixTrusted,
+ configsetSuffix,
"solr",
"solr/configsets/upload/regular/solrconfig.xml",
"solrconfig.xml",
@@ -996,35 +953,10 @@ public class TestConfigSetsAPI extends SolrCloudTestCase {
assertEquals(
"Expecting first version of new file",
0,
- getConfigZNodeVersion(zkClient, configsetName,
configsetSuffixTrusted, "solrconfig.xml"));
+ getConfigZNodeVersion(zkClient, configsetName, configsetSuffix,
"solrconfig.xml"));
List<String> children =
zkClient.getChildren(
- String.format(Locale.ROOT, "/configs/%s%s", configsetName,
configsetSuffixTrusted),
- null);
- assertEquals("The configSet should only have one file uploaded.", 1,
children.size());
- assertEquals("Incorrect file uploaded.", "solrconfig.xml",
children.get(0));
-
- // New file with trusted request
- assertEquals(
- 0,
- uploadSingleConfigSetFile(
- configsetName,
- configsetSuffixUntrusted,
- null,
- "solr/configsets/upload/regular/solrconfig.xml",
- "solrconfig.xml",
- false,
- false,
- v2));
- assertEquals(
- "Expecting first version of new file",
- 0,
- getConfigZNodeVersion(
- zkClient, configsetName, configsetSuffixUntrusted,
"solrconfig.xml"));
- children =
- zkClient.getChildren(
- String.format(Locale.ROOT, "/configs/%s%s", configsetName,
configsetSuffixUntrusted),
- null);
+ String.format(Locale.ROOT, "/configs/%s%s", configsetName,
configsetSuffix), null);
assertEquals("The configSet should only have one file uploaded.", 1,
children.size());
assertEquals("Incorrect file uploaded.", "solrconfig.xml",
children.get(0));
}
@@ -1043,7 +975,7 @@ public class TestConfigSetsAPI extends SolrCloudTestCase {
@Test
public void testUpload() throws Exception {
- String suffix = "-untrusted";
+ String suffix = "-test";
uploadConfigSetWithAssertions("regular", suffix, null);
// try to create a collection with the uploaded configset
createCollection("newcollection", "regular" + suffix, 1, 1,
cluster.getSolrClient());
@@ -1054,37 +986,12 @@ public class TestConfigSetsAPI extends SolrCloudTestCase
{
Assume.assumeNotNull((new
ScriptEngineManager()).getEngineByExtension("js"));
Assume.assumeNotNull((new
ScriptEngineManager()).getEngineByName("JavaScript"));
- // Authorization off
- final String untrustedSuffix = "-untrusted";
- uploadConfigSetWithAssertions("with-script-processor", untrustedSuffix,
null);
- // try to create a collection with the uploaded configset
- ignoreException("uploaded without any authentication in place");
- Throwable thrown =
- expectThrows(
- RemoteSolrException.class,
- () -> {
- createCollection(
- "newcollection2",
- "with-script-processor" + untrustedSuffix,
- 1,
- 1,
- cluster.getSolrClient());
- });
- unIgnoreException("uploaded without any authentication in place");
-
- assertThat(thrown.getMessage(), containsString("Underlying core creation
failed"));
-
- // Authorization on
- final String trustedSuffix = "-trusted";
- uploadConfigSetWithAssertions("with-script-processor", trustedSuffix,
"solr");
- // try to create a collection with the uploaded configset
+ // Upload configset with script processor and create collection
+ final String suffix = "-test";
+ uploadConfigSetWithAssertions("with-script-processor", suffix, "solr");
CollectionAdminResponse resp =
createCollection(
- "newcollection2",
- "with-script-processor" + trustedSuffix,
- 1,
- 1,
- cluster.getSolrClient());
+ "newcollection2", "with-script-processor" + suffix, 1, 1,
cluster.getSolrClient());
scriptRequest("newcollection2");
}
@@ -1095,6 +1002,89 @@ public class TestConfigSetsAPI extends SolrCloudTestCase
{
assertEquals(400, res);
}
+ @Test
+ public void testGetFile() throws Exception {
+ String configSetName = "regular";
+ String configSetSuffix = "testGetFile";
+
+ // First upload a configset
+ uploadConfigSetWithAssertions(configSetName, configSetSuffix, null);
+
+ try (SolrZkClient zkClient =
+ new SolrZkClient.Builder()
+ .withUrl(cluster.getZkServer().getZkAddress())
+ .withTimeout(AbstractZkTestCase.TIMEOUT, TimeUnit.MILLISECONDS)
+ .withConnTimeOut(45000, TimeUnit.MILLISECONDS)
+ .build()) {
+
+ // Verify files exist in ZK
+ assertTrue(
+ zkClient.exists("/configs/" + configSetName + configSetSuffix +
"/solrconfig.xml"));
+ assertTrue(
+ zkClient.exists("/configs/" + configSetName + configSetSuffix +
"/managed-schema.xml"));
+
+ // Test getting a root-level file via V2 API
+ String baseUrl =
cluster.getJettySolrRunners().get(0).getBaseURLV2().toString();
+ String getFileUrl =
+ baseUrl + "/configsets/" + configSetName + configSetSuffix +
"/files/solrconfig.xml";
+
+ HttpClient httpClient =
cluster.getJettySolrRunners().get(0).getSolrClient().getHttpClient();
+ ContentResponse response =
httpClient.newRequest(getFileUrl).method(HttpMethod.GET).send();
+
+ byte[] responseBytes = response.getContent();
+ String content = new String(responseBytes, UTF_8);
+
+ assertNotNull(content);
+ assertTrue("Content should contain config XML",
content.contains("<config"));
+
+ // Test getting a nested file
+ getFileUrl =
+ baseUrl + "/configsets/" + configSetName + configSetSuffix +
"/files/managed-schema.xml";
+ response =
httpClient.newRequest(getFileUrl).method(HttpMethod.GET).send();
+
+ responseBytes = response.getContent();
+ content = new String(responseBytes, UTF_8);
+
+ assertNotNull(content);
+ assertTrue("Content should contain schema XML",
content.contains("schema"));
+
+ // Test binary file preservation by uploading a small binary file
+ byte[] binaryData =
+ new byte[] {
+ (byte) 0x89,
+ 0x50,
+ 0x4E,
+ 0x47,
+ 0x0D,
+ 0x0A,
+ 0x1A,
+ 0x0A, // PNG signature
+ (byte) 0xFF,
+ (byte) 0xFE,
+ 0x00,
+ 0x01,
+ (byte) 0x80
+ };
+
+ // Upload binary file to ZK directly
+ zkClient.makePath(
+ "/configs/" + configSetName + configSetSuffix + "/test.bin",
+ binaryData,
+ CreateMode.PERSISTENT,
+ null);
+
+ // Retrieve it via API
+ getFileUrl = baseUrl + "/configsets/" + configSetName + configSetSuffix
+ "/files/test.bin";
+ response =
httpClient.newRequest(getFileUrl).method(HttpMethod.GET).send();
+
+ byte[] retrievedBytes = response.getContent();
+
+ // Binary data should be preserved exactly
+ assertArrayEquals(
+ "Binary file should be preserved byte-for-byte", binaryData,
retrievedBytes);
+ }
+ }
+
private static String getSecurityJson() {
return "{\n"
+ " 'authentication':{\n"
@@ -1135,8 +1125,6 @@ public class TestConfigSetsAPI extends SolrCloudTestCase {
assertTrue(
"solrconfig.xml file should have been uploaded",
zkClient.exists("/configs/" + configSetName + suffix +
"/solrconfig.xml"));
- byte data[] = zkClient.getData("/configs/" + configSetName + suffix, null,
null);
- // assertEquals("{\"trusted\": false}", new String(data,
StandardCharsets.UTF_8));
assertArrayEquals(
"solrconfig.xml file contents on zookeeper are not exactly same as
that of the file uploaded in config",
zkClient.getData("/configs/" + configSetName + suffix +
"/solrconfig.xml", null, null),
@@ -1189,21 +1177,20 @@ public class TestConfigSetsAPI extends
SolrCloudTestCase {
throws Exception {
if (v2) {
- // TODO: switch to using V2Request
-
- final ByteBuffer fileBytes =
TestSolrConfigHandler.getFileContent(file.toString(), false);
- final String uriEnding =
- "/configsets/"
- + configSetName
- + suffix
- + (!overwrite ? "?overwrite=false" : "")
- + (cleanup ? "?cleanup=true" : "");
- final boolean usePut = true;
- JettySolrRunner jetty = cluster.getJettySolrRunners().getFirst();
- Map<?, ?> map =
- postDataAndGetResponse(
- jetty, jetty.getBaseURLV2() + uriEnding, fileBytes, username,
usePut);
- return getStatusCode(map);
+ // Use generated v2 SolrJ client (ConfigsetsApi)
+ try (InputStream fileBytes = new FileInputStream(file.toFile())) {
+ // Create and execute the upload request using generated client
+ final var uploadRequest =
+ new ConfigsetsApi.UploadConfigSet(configSetName + suffix,
fileBytes);
+ uploadRequest.setOverwrite(overwrite);
+ uploadRequest.setCleanup(cleanup);
+
+ final var response = uploadRequest.process(cluster.getSolrClient());
+ return response.responseHeader.status;
+ } catch (RemoteSolrException e) {
+ // Convert exception to status code for consistent error handling with
v1
+ return e.code();
+ }
} // else "not" a V2 request...
try {
@@ -1236,44 +1223,51 @@ public class TestConfigSetsAPI extends
SolrCloudTestCase {
final Path file = SolrTestCaseJ4.getFile(localFilePath);
if (v2) {
- // TODO: switch to use V2Request
-
- final ByteBuffer sampleConfigFile =
- TestSolrConfigHandler.getFileContent(file.toString(), false);
- if (uploadPath != null && !uploadPath.startsWith("/")) {
- uploadPath = "/" + uploadPath;
- }
- final String uriEnding =
- "/configsets/"
- + configSetName
- + suffix
- + uploadPath
- + (!overwrite ? "?overwrite=false" : "")
- + (cleanup ? "?cleanup=true" : "");
- final boolean usePut = true;
+ // Use generated v2 SolrJ client (ConfigsetsApi)
+ try {
+ final ByteBuffer sampleConfigFileBuffer =
+ TestSolrConfigHandler.getFileContent(file.toString(), false);
+
+ // Convert ByteBuffer to InputStream for the generated client
+ final InputStream sampleConfigFile =
+ new ByteArrayInputStream(
+ sampleConfigFileBuffer.array(),
+ sampleConfigFileBuffer.arrayOffset(),
+ sampleConfigFileBuffer.limit());
+
+ if (uploadPath.startsWith("/")) {
+ uploadPath = uploadPath.substring(1);
+ }
- JettySolrRunner jetty = cluster.getJettySolrRunners().getFirst();
- Map<?, ?> map =
- postDataAndGetResponse(
- jetty, jetty.getBaseURLV2() + uriEnding, sampleConfigFile,
username, usePut);
- return getStatusCode(map);
- } // else "not" a V2 request...
+ // Create and execute the upload request using generated client
+ final var uploadRequest =
+ new ConfigsetsApi.UploadConfigSetFile(
+ configSetName + suffix, uploadPath, sampleConfigFile);
- try {
- return (new Upload())
- .setConfigSetName(configSetName + suffix)
- .setFilePath(uploadPath)
- // NOTE: server doesn't actually care, and test plumbing doesn't
tell us
- .setUploadFile(file, "application/octet-stream")
- .setOverwrite(overwrite ? true : null) // expect server default to
be 'false'
- .setCleanup(cleanup ? true : null) // expect server default to be
'false'
- .setBasicAuthCredentials(username, username) // for our
MockAuthenticationPlugin
- .process(cluster.getSolrClient())
- .getStatus();
- } catch (SolrServerException e1) {
- throw new AssertionError("Server error uploading file to configset: " +
e1, e1);
- } catch (SolrException e2) {
- return e2.code();
+ final var response = uploadRequest.process(cluster.getSolrClient());
+ return response.responseHeader.status;
+ } catch (RemoteSolrException e) {
+ // Convert exception to status code for consistent error handling with
v1
+ return e.code();
+ }
+ } else { // else "not" a V2 request...
+
+ try {
+ return (new Upload())
+ .setConfigSetName(configSetName + suffix)
+ .setFilePath(uploadPath)
+ // NOTE: server doesn't actually care, and test plumbing doesn't
tell us
+ .setUploadFile(file, "application/octet-stream")
+ .setOverwrite(overwrite ? true : null) // expect server default to
be 'false'
+ .setCleanup(cleanup ? true : null) // expect server default to be
'false'
+ .setBasicAuthCredentials(username, username) // for our
MockAuthenticationPlugin
+ .process(cluster.getSolrClient())
+ .getStatus();
+ } catch (SolrServerException e1) {
+ throw new AssertionError("Server error uploading file to configset: "
+ e1, e1);
+ } catch (SolrException e2) {
+ return e2.code();
+ }
}
}
diff --git
a/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPIExclusivity.java
b/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPIExclusivity.java
index 6cc865c2fb9..722ed01c1fa 100644
--- a/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPIExclusivity.java
+++ b/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPIExclusivity.java
@@ -91,8 +91,6 @@ public class TestConfigSetsAPIExclusivity extends
SolrTestCaseJ4 {
private void setupBaseConfigSet(String baseConfigSetName) throws Exception {
solrCluster.uploadConfigSet(configset("configset-2"), baseConfigSetName);
- // Make configset untrusted
- solrCluster.getZkClient();
}
private Exception getFirstExceptionOrNull(List<Exception> list) {
diff --git
a/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java
b/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java
new file mode 100644
index 00000000000..10325b278ee
--- /dev/null
+++
b/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java
@@ -0,0 +1,94 @@
+/*
+ * 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.configsets;
+
+import static org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import jakarta.ws.rs.core.Response;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.core.FileSystemConfigSetService;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** Unit tests for {@link DownloadConfigSet}. */
+public class DownloadConfigSetAPITest extends SolrTestCase {
+
+ private CoreContainer mockCoreContainer;
+ private FileSystemConfigSetService configSetService;
+ private Path configSetBase;
+
+ @BeforeClass
+ public static void ensureWorkingMockito() {
+ assumeWorkingMockito();
+ }
+
+ @Before
+ public void initConfigSetService() {
+ configSetBase = createTempDir("configsets");
+ // Use an anonymous subclass to access the protected testing constructor
+ configSetService = new FileSystemConfigSetService(configSetBase) {};
+ mockCoreContainer = mock(CoreContainer.class);
+ when(mockCoreContainer.getConfigSetService()).thenReturn(configSetService);
+ }
+
+ /** Creates a configset directory with a single file so the API can find and
zip it. */
+ private void createConfigSet(String name, String fileName, String content)
throws Exception {
+ Path dir = configSetBase.resolve(name);
+ Files.createDirectories(dir);
+ Files.writeString(dir.resolve(fileName), content, StandardCharsets.UTF_8);
+ }
+
+ @Test
+ @SuppressWarnings("resource") // Response never created when exception is
thrown
+ public void testMissingConfigSetNameThrowsBadRequest() {
+ final var api = new DownloadConfigSet(mockCoreContainer, null, null);
+ final var ex = assertThrows(SolrException.class, () ->
api.downloadConfigSet(null));
+ assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code());
+
+ final var ex2 = assertThrows(SolrException.class, () ->
api.downloadConfigSet(""));
+ assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex2.code());
+ }
+
+ @Test
+ @SuppressWarnings("resource") // Response never created when exception is
thrown
+ public void testNonExistentConfigSetThrowsNotFound() {
+ // "missing" was never created in configSetBase, so checkConfigExists
returns false
+ final var api = new DownloadConfigSet(mockCoreContainer, null, null);
+ final var ex = assertThrows(SolrException.class, () ->
api.downloadConfigSet("missing"));
+ assertEquals(SolrException.ErrorCode.NOT_FOUND.code, ex.code());
+ }
+
+ @Test
+ public void testSuccessfulDownloadReturnsZipResponse() throws Exception {
+ createConfigSet("myconfig", "solrconfig.xml", "<config/>");
+
+ final var api = new DownloadConfigSet(mockCoreContainer, null, null);
+ try (final Response response = api.downloadConfigSet("myconfig")) {
+ assertEquals(200, response.getStatus());
+ assertEquals("application/zip", response.getMediaType().toString());
+ }
+ }
+}
diff --git
a/solr/core/src/test/org/apache/solr/handler/configsets/GetConfigSetFileAPITest.java
b/solr/core/src/test/org/apache/solr/handler/configsets/GetConfigSetFileAPITest.java
new file mode 100644
index 00000000000..2237930f89b
--- /dev/null
+++
b/solr/core/src/test/org/apache/solr/handler/configsets/GetConfigSetFileAPITest.java
@@ -0,0 +1,197 @@
+/*
+ * 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.configsets;
+
+import static org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import jakarta.ws.rs.core.StreamingOutput;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.core.FileSystemConfigSetService;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** Unit tests for {@link GetConfigSetFile}. */
+public class GetConfigSetFileAPITest extends SolrTestCase {
+
+ private CoreContainer mockCoreContainer;
+ private FileSystemConfigSetService configSetService;
+ private Path configSetBase;
+
+ @BeforeClass
+ public static void ensureWorkingMockito() {
+ assumeWorkingMockito();
+ }
+
+ @Before
+ public void initConfigSetService() {
+ configSetBase = createTempDir("configsets");
+ // Use an anonymous subclass to access the protected testing constructor
+ configSetService = new FileSystemConfigSetService(configSetBase) {};
+ mockCoreContainer = mock(CoreContainer.class);
+ when(mockCoreContainer.getConfigSetService()).thenReturn(configSetService);
+ }
+
+ /** Creates a configset directory with one file. */
+ private void createConfigSetWithFile(String configSetName, String filePath,
String content)
+ throws Exception {
+ Path dir = configSetBase.resolve(configSetName);
+ Files.createDirectories(dir);
+ Files.writeString(dir.resolve(filePath), content, StandardCharsets.UTF_8);
+ }
+
+ @Test
+ public void testMissingConfigSetNameThrowsBadRequest() {
+ final var api = new GetConfigSetFile(mockCoreContainer, null, null);
+ final var ex =
+ assertThrows(SolrException.class, () -> api.getConfigSetFile(null,
"schema.xml"));
+ assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code());
+
+ final var ex2 = assertThrows(SolrException.class, () ->
api.getConfigSetFile("", "schema.xml"));
+ assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex2.code());
+ }
+
+ @Test
+ public void testMissingFilePathThrowsBadRequest() {
+ final var api = new GetConfigSetFile(mockCoreContainer, null, null);
+ final var ex = assertThrows(SolrException.class, () ->
api.getConfigSetFile("myconfig", null));
+ assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code());
+
+ final var ex2 = assertThrows(SolrException.class, () ->
api.getConfigSetFile("myconfig", ""));
+ assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex2.code());
+ }
+
+ @Test
+ public void testNonExistentConfigSetThrowsNotFound() {
+ // "missing" was never created in configSetBase, so checkConfigExists
returns false
+ final var api = new GetConfigSetFile(mockCoreContainer, null, null);
+ final var ex =
+ assertThrows(SolrException.class, () ->
api.getConfigSetFile("missing", "schema.xml"));
+ assertEquals(SolrException.ErrorCode.NOT_FOUND.code, ex.code());
+ }
+
+ @Test
+ public void testSuccessfulFileRead() throws Exception {
+ final String configSetName = "myconfig";
+ final String filePath = "schema.xml";
+ final String fileContent = "<schema/>";
+ createConfigSetWithFile(configSetName, filePath, fileContent);
+
+ final var api = new GetConfigSetFile(mockCoreContainer, null, null);
+ final StreamingOutput streamingOutput =
api.getConfigSetFile(configSetName, filePath);
+
+ assertNotNull(streamingOutput);
+
+ // Read the streamed bytes
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ streamingOutput.write(baos);
+ String actualContent = baos.toString(StandardCharsets.UTF_8);
+
+ assertEquals(fileContent, actualContent);
+ }
+
+ @Test
+ public void testFileNotFoundInConfigSetThrowsNotFound() throws Exception {
+ final String configSetName = "myconfig";
+ // Create the configset directory but do NOT add the requested file
+ Files.createDirectories(configSetBase.resolve(configSetName));
+
+ final var api = new GetConfigSetFile(mockCoreContainer, null, null);
+ final StreamingOutput streamingOutput =
api.getConfigSetFile(configSetName, "missing.xml");
+
+ // Exception is thrown when we try to write the output
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ final var ex = assertThrows(SolrException.class, () ->
streamingOutput.write(baos));
+ assertEquals(SolrException.ErrorCode.NOT_FOUND.code, ex.code());
+ }
+
+ @Test
+ public void testEmptyFileReturnsEmptyContent() throws Exception {
+ final String configSetName = "myconfig";
+ final String filePath = "empty.xml";
+ createConfigSetWithFile(configSetName, filePath, "");
+
+ final var api = new GetConfigSetFile(mockCoreContainer, null, null);
+ final StreamingOutput streamingOutput =
api.getConfigSetFile(configSetName, filePath);
+
+ assertNotNull(streamingOutput);
+
+ // Read the streamed bytes
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ streamingOutput.write(baos);
+
+ assertEquals(0, baos.size());
+ }
+
+ @Test
+ public void testBinaryFilePreservedWithStreamingOutput() throws Exception {
+ // This test demonstrates that binary files are now correctly handled
+ // by returning raw bytes via StreamingOutput instead of UTF-8 String.
+ final String configSetName = "binarytest";
+ final String filePath = "logo.png";
+
+ // Create a file with binary data simulating a small PNG image header
+ // PNG signature: 89 50 4E 47 0D 0A 1A 0A (first 8 bytes of any PNG)
+ // Plus some additional binary data that would corrupt as UTF-8
+ byte[] binaryData =
+ new byte[] {
+ (byte) 0x89,
+ 0x50,
+ 0x4E,
+ 0x47,
+ 0x0D,
+ 0x0A,
+ 0x1A,
+ 0x0A, // PNG signature
+ (byte) 0xFF,
+ (byte) 0xFE,
+ 0x00,
+ 0x01,
+ (byte) 0x80,
+ (byte) 0xDE,
+ (byte) 0xAD,
+ (byte) 0xBE,
+ (byte) 0xEF // Binary data
+ };
+
+ Path configDir = configSetBase.resolve(configSetName);
+ Files.createDirectories(configDir);
+ Files.write(configDir.resolve(filePath), binaryData);
+
+ final var api = new GetConfigSetFile(mockCoreContainer, null, null);
+ final StreamingOutput streamingOutput =
api.getConfigSetFile(configSetName, filePath);
+
+ assertNotNull(streamingOutput);
+
+ // Read the streamed bytes
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ streamingOutput.write(baos);
+ byte[] responseBytes = baos.toByteArray();
+
+ assertArrayEquals(
+ "Binary file content should be preserved byte-for-byte", binaryData,
responseBytes);
+ }
+}
diff --git
a/solr/core/src/test/org/apache/solr/handler/configsets/UploadConfigSetAPITest.java
b/solr/core/src/test/org/apache/solr/handler/configsets/UploadConfigSetAPITest.java
new file mode 100644
index 00000000000..b376a2592b1
--- /dev/null
+++
b/solr/core/src/test/org/apache/solr/handler/configsets/UploadConfigSetAPITest.java
@@ -0,0 +1,307 @@
+/*
+ * 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.configsets;
+
+import static org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.core.FileSystemConfigSetService;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** Unit tests for {@link UploadConfigSet#uploadConfigSet} (Upload interface).
*/
+public class UploadConfigSetAPITest extends SolrTestCase {
+
+ private CoreContainer mockCoreContainer;
+ private FileSystemConfigSetService configSetService;
+ private Path configSetBase;
+
+ @BeforeClass
+ public static void ensureWorkingMockito() {
+ assumeWorkingMockito();
+ }
+
+ @Before
+ public void initConfigSetService() {
+ configSetBase = createTempDir("configsets");
+ // Use an anonymous subclass to access the protected testing constructor
+ configSetService = new FileSystemConfigSetService(configSetBase) {};
+ mockCoreContainer = mock(CoreContainer.class);
+ when(mockCoreContainer.getConfigSetService()).thenReturn(configSetService);
+ }
+
+ /** Creates an in-memory ZIP file with the specified files. */
+ @SuppressWarnings("try") // ZipOutputStream must be closed to finalize ZIP
format
+ private InputStream createZipStream(String... filePathAndContent) throws
Exception {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (ZipOutputStream zos = new ZipOutputStream(baos)) {
+ for (int i = 0; i < filePathAndContent.length; i += 2) {
+ String filePath = filePathAndContent[i];
+ String content = filePathAndContent[i + 1];
+ zos.putNextEntry(new ZipEntry(filePath));
+ zos.write(content.getBytes(StandardCharsets.UTF_8));
+ zos.closeEntry();
+ }
+ }
+ return new ByteArrayInputStream(baos.toByteArray());
+ }
+
+ /** Creates an empty ZIP file. */
+ @SuppressWarnings("try") // ZipOutputStream must be closed even with no
entries
+ private InputStream createEmptyZipStream() throws Exception {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (ZipOutputStream zos = new ZipOutputStream(baos)) {
+ // No entries
+ }
+ return new ByteArrayInputStream(baos.toByteArray());
+ }
+
+ /** Creates a configset with files on disk for testing overwrites and
cleanup. */
+ private void createExistingConfigSet(String configSetName, String...
filePathAndContent)
+ throws Exception {
+ Path configDir = configSetBase.resolve(configSetName);
+ Files.createDirectories(configDir);
+ for (int i = 0; i < filePathAndContent.length; i += 2) {
+ String filePath = filePathAndContent[i];
+ String content = filePathAndContent[i + 1];
+ Path fullPath = configDir.resolve(filePath);
+ Files.createDirectories(fullPath.getParent());
+ Files.writeString(fullPath, content, StandardCharsets.UTF_8);
+ }
+ }
+
+ @Test
+ public void testSuccessfulZipUpload() throws Exception {
+ final String configSetName = "newconfig";
+ InputStream zipStream = createZipStream("solrconfig.xml", "<config/>");
+
+ final var api = new UploadConfigSet(mockCoreContainer, null, null);
+ final var response = api.uploadConfigSet(configSetName, true, false,
zipStream);
+
+ assertNotNull(response);
+ assertTrue(
+ "ConfigSet should exist after upload",
configSetService.checkConfigExists(configSetName));
+
+ // Verify the file was uploaded
+ byte[] uploadedData =
configSetService.downloadFileFromConfig(configSetName, "solrconfig.xml");
+ assertEquals("<config/>", new String(uploadedData,
StandardCharsets.UTF_8));
+ }
+
+ @Test
+ public void testSuccessfulZipUploadWithMultipleFiles() throws Exception {
+ final String configSetName = "multifile";
+ InputStream zipStream =
+ createZipStream(
+ "solrconfig.xml", "<config/>",
+ "schema.xml", "<schema/>",
+ "stopwords.txt", "a\nthe");
+
+ final var api = new UploadConfigSet(mockCoreContainer, null, null);
+ final var response = api.uploadConfigSet(configSetName, true, false,
zipStream);
+
+ assertNotNull(response);
+ assertTrue(configSetService.checkConfigExists(configSetName));
+
+ // Verify all files were uploaded
+ byte[] solrconfig = configSetService.downloadFileFromConfig(configSetName,
"solrconfig.xml");
+ assertEquals("<config/>", new String(solrconfig, StandardCharsets.UTF_8));
+
+ byte[] schema = configSetService.downloadFileFromConfig(configSetName,
"schema.xml");
+ assertEquals("<schema/>", new String(schema, StandardCharsets.UTF_8));
+
+ byte[] stopwords = configSetService.downloadFileFromConfig(configSetName,
"stopwords.txt");
+ assertEquals("a\nthe", new String(stopwords, StandardCharsets.UTF_8));
+ }
+
+ @Test
+ public void testEmptyZipThrowsBadRequest() throws Exception {
+ try (InputStream emptyZip = createEmptyZipStream()) {
+
+ final var api = new UploadConfigSet(mockCoreContainer, null, null);
+ final var ex =
+ assertThrows(
+ SolrException.class, () -> api.uploadConfigSet("newconfig",
true, false, emptyZip));
+
+ assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code());
+ assertTrue(
+ "Error message should mention empty zip",
+ ex.getMessage().contains("empty zipped data") ||
ex.getMessage().contains("non-zipped"));
+ }
+ }
+
+ @Test
+ public void testNonZipDataThrowsBadRequest() {
+ // Send plain text instead of a ZIP
+ InputStream notAZip =
+ new ByteArrayInputStream("this is not a zip
file".getBytes(StandardCharsets.UTF_8));
+
+ final var api = new UploadConfigSet(mockCoreContainer, null, null);
+ // This should fail either as bad ZIP or as empty ZIP
+ assertThrows(Exception.class, () -> api.uploadConfigSet("newconfig", true,
false, notAZip));
+ }
+
+ @Test
+ public void testOverwriteExistingConfigSet() throws Exception {
+ final String configSetName = "existing";
+ // Create existing configset with old content
+ createExistingConfigSet(configSetName, "solrconfig.xml", "<old-config/>");
+
+ // Upload new content with overwrite=true
+ InputStream zipStream = createZipStream("solrconfig.xml", "<new-config/>");
+ final var api = new UploadConfigSet(mockCoreContainer, null, null);
+ final var response = api.uploadConfigSet(configSetName, true, false,
zipStream);
+
+ assertNotNull(response);
+
+ // Verify the file was overwritten
+ byte[] uploadedData =
configSetService.downloadFileFromConfig(configSetName, "solrconfig.xml");
+ assertEquals("<new-config/>", new String(uploadedData,
StandardCharsets.UTF_8));
+ }
+
+ @Test
+ public void testOverwriteFalseThrowsExceptionWhenExists() throws Exception {
+ final String configSetName = "existing";
+ createExistingConfigSet(configSetName, "solrconfig.xml", "<old-config/>");
+
+ try (InputStream zipStream = createZipStream("solrconfig.xml",
"<new-config/>")) {
+ final var api = new UploadConfigSet(mockCoreContainer, null, null);
+
+ final var ex =
+ assertThrows(
+ SolrException.class,
+ () -> api.uploadConfigSet(configSetName, false, false,
zipStream));
+
+ assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code());
+ assertTrue(
+ "Error message should mention config already exists",
+ ex.getMessage().contains("already"));
+ }
+ }
+
+ @Test
+ public void testCleanupRemovesUnusedFiles() throws Exception {
+ final String configSetName = "cleanuptest";
+ // Create existing configset with multiple files
+ createExistingConfigSet(
+ configSetName,
+ "solrconfig.xml",
+ "<old-config/>",
+ "schema.xml",
+ "<old-schema/>",
+ "old-file.txt",
+ "to be deleted");
+
+ // Upload new ZIP with only one file and cleanup=true
+ InputStream zipStream = createZipStream("solrconfig.xml", "<new-config/>");
+ final var api = new UploadConfigSet(mockCoreContainer, null, null);
+ final var response = api.uploadConfigSet(configSetName, true, true,
zipStream);
+
+ assertNotNull(response);
+
+ // Verify solrconfig.xml was updated
+ byte[] solrconfig = configSetService.downloadFileFromConfig(configSetName,
"solrconfig.xml");
+ assertEquals("<new-config/>", new String(solrconfig,
StandardCharsets.UTF_8));
+
+ // Verify old files were deleted (should throw or return null)
+ try {
+ byte[] oldSchema =
configSetService.downloadFileFromConfig(configSetName, "schema.xml");
+ if (oldSchema != null) {
+ fail("schema.xml should have been deleted during cleanup");
+ }
+ } catch (Exception e) {
+ // Expected - file should not exist
+ }
+ }
+
+ @Test
+ public void testCleanupFalseKeepsExistingFiles() throws Exception {
+ final String configSetName = "nocleanup";
+ // Create existing configset with multiple files
+ createExistingConfigSet(
+ configSetName, "solrconfig.xml", "<old-config/>", "schema.xml",
"<old-schema/>");
+
+ // Upload new ZIP with only one file and cleanup=false
+ InputStream zipStream = createZipStream("solrconfig.xml", "<new-config/>");
+ final var api = new UploadConfigSet(mockCoreContainer, null, null);
+ final var response = api.uploadConfigSet(configSetName, true, false,
zipStream);
+
+ assertNotNull(response);
+
+ // Verify solrconfig.xml was updated
+ byte[] solrconfig = configSetService.downloadFileFromConfig(configSetName,
"solrconfig.xml");
+ assertEquals("<new-config/>", new String(solrconfig,
StandardCharsets.UTF_8));
+
+ // Verify schema.xml still exists
+ byte[] schema = configSetService.downloadFileFromConfig(configSetName,
"schema.xml");
+ assertEquals("<old-schema/>", new String(schema, StandardCharsets.UTF_8));
+ }
+
+ @Test
+ public void testDefaultParametersWhenNull() throws Exception {
+ final String configSetName = "defaults";
+ InputStream zipStream = createZipStream("solrconfig.xml", "<config/>");
+
+ final var api = new UploadConfigSet(mockCoreContainer, null, null);
+ // Pass null for overwrite and cleanup - should use defaults
(overwrite=true, cleanup=false)
+ final var response = api.uploadConfigSet(configSetName, null, null,
zipStream);
+
+ assertNotNull(response);
+ assertTrue(configSetService.checkConfigExists(configSetName));
+ }
+
+ @Test
+ public void testZipWithDirectoryEntries() throws Exception {
+ final String configSetName = "withdirs";
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (ZipOutputStream zos = new ZipOutputStream(baos)) {
+ // Add directory entry
+ zos.putNextEntry(new ZipEntry("conf/"));
+ zos.closeEntry();
+
+ // Add file in directory
+ zos.putNextEntry(new ZipEntry("conf/solrconfig.xml"));
+ zos.write("<config/>".getBytes(StandardCharsets.UTF_8));
+ zos.closeEntry();
+ }
+ InputStream zipStream = new ByteArrayInputStream(baos.toByteArray());
+
+ final var api = new UploadConfigSet(mockCoreContainer, null, null);
+ final var response = api.uploadConfigSet(configSetName, true, false,
zipStream);
+
+ assertNotNull(response);
+ assertTrue(configSetService.checkConfigExists(configSetName));
+
+ // Directory entries should be skipped, but file should be uploaded
+ byte[] uploadedData =
+ configSetService.downloadFileFromConfig(configSetName,
"conf/solrconfig.xml");
+ assertEquals("<config/>", new String(uploadedData,
StandardCharsets.UTF_8));
+ }
+}
diff --git
a/solr/core/src/test/org/apache/solr/handler/configsets/UploadConfigSetFileAPITest.java
b/solr/core/src/test/org/apache/solr/handler/configsets/UploadConfigSetFileAPITest.java
new file mode 100644
index 00000000000..c58305b5fd3
--- /dev/null
+++
b/solr/core/src/test/org/apache/solr/handler/configsets/UploadConfigSetFileAPITest.java
@@ -0,0 +1,101 @@
+/*
+ * 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.configsets;
+
+import static org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.core.FileSystemConfigSetService;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** Unit tests for {@link UploadConfigSet#uploadConfigSetFile} */
+public class UploadConfigSetFileAPITest extends SolrTestCase {
+
+ private CoreContainer mockCoreContainer;
+ private FileSystemConfigSetService configSetService;
+ private Path configSetBase;
+
+ @BeforeClass
+ public static void ensureWorkingMockito() {
+ assumeWorkingMockito();
+ }
+
+ @Before
+ public void initConfigSetService() {
+ configSetBase = createTempDir("configsets");
+ // Use an anonymous subclass to access the protected testing constructor
+ configSetService = new FileSystemConfigSetService(configSetBase) {};
+ mockCoreContainer = mock(CoreContainer.class);
+ when(mockCoreContainer.getConfigSetService()).thenReturn(configSetService);
+ }
+
+ @Test
+ public void testSingleFileUploadSuccess() throws Exception {
+ final String configSetName = "singlefile";
+ final String filePath = "solrconfig.xml";
+ final String content = "<config/>";
+ InputStream fileStream = new
ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8));
+
+ final var api = new UploadConfigSet(mockCoreContainer, null, null);
+ api.uploadConfigSetFile(configSetName, filePath, fileStream);
+
+ // Verify the file was uploaded
+ byte[] uploadedData =
configSetService.downloadFileFromConfig(configSetName, filePath);
+ assertEquals(content, new String(uploadedData, StandardCharsets.UTF_8));
+ }
+
+ @Test
+ public void testSingleFileWithEmptyPathThrowsBadRequest() {
+ final String configSetName = "emptypath";
+ InputStream fileStream = new
ByteArrayInputStream("<config/>".getBytes(StandardCharsets.UTF_8));
+
+ final var api = new UploadConfigSet(mockCoreContainer, null, null);
+
+ // Test with empty filePath
+ final var ex =
+ assertThrows(
+ SolrException.class, () -> api.uploadConfigSetFile(configSetName,
"", fileStream));
+ assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code());
+ assertTrue("Error should mention invalid path",
ex.getMessage().contains("not valid"));
+ }
+
+ @Test
+ public void testSingleFileUploadWithNestedPath() throws Exception {
+ final String configSetName = "nested";
+ final String filePath = "lang/stopwords_en.txt";
+ final String content = "a\nthe\nis";
+ InputStream fileStream = new
ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8));
+
+ final var api = new UploadConfigSet(mockCoreContainer, null, null);
+ api.uploadConfigSetFile(configSetName, filePath, fileStream);
+
+ // Verify the file was uploaded with correct path
+ byte[] uploadedData =
configSetService.downloadFileFromConfig(configSetName, filePath);
+ assertEquals(content, new String(uploadedData, StandardCharsets.UTF_8));
+ }
+}
diff --git
a/solr/solr-ref-guide/modules/configuration-guide/pages/config-sets.adoc
b/solr/solr-ref-guide/modules/configuration-guide/pages/config-sets.adoc
index 2b79b985aba..5e22aa3200a 100644
--- a/solr/solr-ref-guide/modules/configuration-guide/pages/config-sets.adoc
+++ b/solr/solr-ref-guide/modules/configuration-guide/pages/config-sets.adoc
@@ -36,7 +36,7 @@ If you don't, then the `_default` will be copied and given a
unique name for use
A configset can be uploaded to ZooKeeper either via the
xref:configsets-api.adoc[] or more directly via
xref:deployment-guide:solr-control-script-reference.adoc#upload-a-configuration-set[`bin/solr
zk upconfig`].
The Configsets API has some other operations as well, and likewise, so does
the CLI.
-To upload a file to a configset already stored on ZooKeeper, you can use
xref:deployment-guide:solr-control-script-reference.adoc#copy-between-local-files-and-zookeeper-znodes[`bin/solr
zk cp`].
+To upload a file to a configset already stored on ZooKeeper, you can use
xref:deployment-guide:solr-control-script-reference.adoc#copy-between-local-files-and-zookeeper-znodes[`bin/solr
zk cp`] or the xref:configsets-api.adoc#configsets-upload-file[`API`].
CAUTION: By default, ZooKeeper's file size limit is 1MB.
If your files are larger than this, you'll need to either
xref:deployment-guide:zookeeper-ensemble.adoc#increasing-the-file-size-limit[increase
the ZooKeeper file size limit] or store them xref:libs.adoc[on the filesystem]
of every node in a cluster.
diff --git
a/solr/solr-ref-guide/modules/configuration-guide/pages/configsets-api.adoc
b/solr/solr-ref-guide/modules/configuration-guide/pages/configsets-api.adoc
index 74f2c6751a5..a8cfecfe430 100644
--- a/solr/solr-ref-guide/modules/configuration-guide/pages/configsets-api.adoc
+++ b/solr/solr-ref-guide/modules/configuration-guide/pages/configsets-api.adoc
@@ -82,27 +82,61 @@ The output will look like:
}
----
+[[configsets-download]]
+== Download a Configset
+
+The v2 API allows configsets to be downloaded as a single zipped file.
+This is useful for backing up configsets, sharing them between environments,
or examining their contents.
+
+The `download` command takes the following parameters:
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The name of the configset to download.
+
+The response will be a ZIP file containing all files from the configset.
+
+To download a configset named "myConfigSet":
+
+[tabs#downloadconfigset]
+======
+V1 API::
++
+====
+The v1 API does not currently support downloading configsets.
+Use the v2 API instead.
+====
+
+V2 API::
++
+====
+With the v2 API, use a `GET` request to `/configsets/__config_name__/files`:
+
+[source,bash]
+----
+curl -X GET -o myConfigSet.zip
"http://localhost:8983/api/configsets/myConfigSet/files"
+----
+
+The configset will be downloaded as a ZIP file that can be extracted and used
locally, or re-uploaded to another Solr instance.
+====
+======
+
[[configsets-upload]]
== Upload a Configset
-Upload a configset, which is sent as a zipped file.
-A single, non-zipped file can also be uploaded with the `filePath` parameter.
+You can upload an entire configset as a ZIP archive, which is useful for
creating new configsets or replacing all files in an existing configset.
This functionality is enabled by default, but can be disabled via a runtime
parameter `-Dsolr.configset.upload.enabled=false`.
Disabling this feature is advisable if you want to expose Solr installation to
untrusted users (even though you should never do that!).
-A configset is uploaded in a "trusted" mode if authentication is enabled and
the upload operation is performed as an authenticated request.
-Without authentication, a configset is uploaded in an "untrusted" mode.
-Upon creation of a collection using an "untrusted" configset, the following
functionality will not work:
-
-* The XSLT transformer (`tr` parameter) cannot be used at request processing
time.
-* If specified in the configset, the ScriptUpdateProcessorFactory will not
initialize.
-
-If you use any of these parameters or features, you must have enabled security
features in your Solr installation and you must upload the configset as an
authenticated user.
-
-Not all file types are supported for use in configSets. Please see
xref:configuration-guide:config-sets.adoc#forbidden-file-types[] for more
information.
+Not all file types are supported for use in configsets. Please see
xref:configuration-guide:config-sets.adoc#forbidden-file-types[forbidden file
types] for more information.
-The `upload` command takes the following parameters:
+The following parameters are supported when uploading a configset:
`name`::
+
@@ -121,8 +155,6 @@ The configset to be created when the upload is complete.
|===
+
If set to `true`, Solr will overwrite an existing configset with the same name
(if false, the request will fail).
-If `filePath` is provided, then this option specifies whether the specified
file within the configset should be overwritten if it already exists.
-Default is `false` when using the v1 API, but `true` when using the v2 API.
`cleanup`::
+
@@ -131,20 +163,8 @@ Default is `false` when using the v1 API, but `true` when
using the v2 API.
|Optional |Default: `false`
|===
When overwriting an existing configset (`overwrite=true`), this parameter
tells Solr to delete the files in ZooKeeper that existed in the old configset
but not in the one being uploaded.
-This parameter cannot be set to true when `filePath` is used.
-`filePath`::
-+
-[%autowidth,frame=none]
-|===
-|Optional |Default: none
-|===
-+
-This parameter allows the uploading of a single, non-zipped file to the given
path under the configset in ZooKeeper.
-This functionality respects the `overwrite` parameter, so a request will fail
if the given file path already exists in the configset and overwrite is set to
`false`.
-The `cleanup` parameter cannot be set to true when `filePath` is used.
-
-If uploading an entire configset, the body of the request should be a zip file
that contains the configset.
+When uploading an entire configset, the body of the request should be a zip
file that contains the configset.
The zip file must be created from within the `conf` directory (i.e.,
`solrconfig.xml` must be the top level entry in the zip file).
Here is an example on how to create the zip file named "myconfig.zip" and
upload it as a configset named "myConfigSet":
@@ -189,6 +209,33 @@ This behavior can be disabled with the parameter
`overwrite=false`, in which cas
====
======
+[[configsets-upload-file]]
+== Update a Single File in a Configset
+
+This API lets you modify a specific file in an existing configset.
+This is useful for making targeted changes without re-uploading the entire
configset.
+
+**URL Path Parameters:**
+
+`configSetName`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The name of the configset containing the file.
+
+`filePath`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The path to the file within the configset, specified as part of the URL path
(e.g., `solrconfig.xml` or `lang/stopwords_en.txt`).
+For nested paths, use slashes as shown in the examples below.
+
Here is an example on how to upload a single file to a configset named
"myConfigSet":
[tabs#uploadsinglefile]
@@ -203,35 +250,120 @@ The filename to upload is provided via the `filePath`
parameter:
----
curl -X POST --header "Content-Type:application/octet-stream"
--data-binary
@solr/server/solr/configsets/sample_techproducts_configs/conf/solrconfig.xml
-
"http://localhost:8983/solr/admin/configs?action=UPLOAD&name=myConfigSet&filePath=solrconfig.xml&overwrite=true"
+
"http://localhost:8983/solr/admin/configs?action=UPLOAD&name=myConfigSet&filePath=solrconfig.xml"
----
====
V2 API::
+
====
-With the v2 API, the name of the configset and file are both provided in the
URL.
-They can be substituted in `/configsets/__config_name__/__file_name__`.
-The filename may be nested and include `/` characters.
+With the v2 API, the file path is part of the URL path.
+Use a `PUT` request to `/api/configsets/{configSetName}/files/{filePath}`:
+
[source,bash]
----
curl -X PUT --header "Content-Type:application/octet-stream"
--data-binary
@solr/server/solr/configsets/sample_techproducts_configs/conf/solrconfig.xml
- "http://localhost:8983/api/configsets/myConfigSet/solrconfig.xml"
+ "http://localhost:8983/api/configsets/myConfigSet/files/solrconfig.xml"
----
-With this API, the default behavior is to overwrite the file if it already
exists within the configset.
-This behavior can be disabled with the parameter `overwrite=false`, in which
case the request will fail if the file already exists within the configset.
+For nested files within the configset:
+
+[source,bash]
+----
+curl -X PUT --header "Content-Type:application/octet-stream"
+ --data-binary
@solr/server/solr/configsets/sample_techproducts_configs/conf/lang/stopwords_en.txt
+
"http://localhost:8983/api/configsets/myConfigSet/files/lang/stopwords_en.txt"
+----
====
======
+[[configsets-get-file]]
+== Get a Single File from a Configset
+
+This API retrieves the raw contents of a single file from an existing
configset.
+This is useful for inspecting individual configuration files without
downloading the entire configset.
+
+This endpoint mirrors the upload API structure, with the file path specified
as part of the URL path.
+The response contains the raw bytes of the file, supporting both text and
binary files.
+
+**URL Path Parameters:**
+
+`configSetName`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The name of the configset containing the file.
+
+`filePath`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The path to the file within the configset, specified as part of the URL path
(e.g., `solrconfig.xml` or `lang/stopwords_en.txt`).
+For nested paths, use slashes as shown in the examples below.
+
+The response is the raw file content as `application/octet-stream`.
+This format supports both text files (XML, properties, etc.) and binary files
(images, class files, etc.).
+
+To retrieve the `solrconfig.xml` file from a configset named "myConfigSet":
+
+[tabs#getfileconfigset]
+======
+V1 API::
++
+====
+The v1 API does not currently support retrieving individual configset files.
+Use the v2 API instead.
+====
+
+V2 API::
++
+====
+With the v2 API, the file path is part of the URL path.
+Use a `GET` request to `/api/configsets/{configSetName}/files/{filePath}`:
+
+[source,bash]
+----
+curl -X GET
"http://localhost:8983/api/configsets/myConfigSet/files/solrconfig.xml"
+----
+
+For nested files within the configset:
+
+[source,bash]
+----
+curl -X GET
"http://localhost:8983/api/configsets/myConfigSet/files/lang/stopwords_en.txt"
+----
+====
+======
+
+*Example Output*
+
+The response is the raw file content. For a text file like `solrconfig.xml`:
+
+[source,xml]
+----
+<?xml version="1.0" encoding="UTF-8" ?>
+<config>
+ ...
+</config>
+----
+
+For binary files, the raw bytes are returned directly, preserving the binary
content.
+
+
[[configsets-create]]
== Create a Configset
-The `create` command creates a new configset based on a configset that has
been previously uploaded.
+The `create` command creates a new configset based on a configset that has
been previously been created.
-If you have not yet uploaded any configsets, see the <<Upload a Configset>>
command above.
+If you have not yet created any configsets, see the <<Upload a Configset>>
command above.
The following parameters are supported when creating a configset.
@@ -347,7 +479,7 @@ The name of the configset to delete is provided as a path
parameter:
[source,bash]
----
-curl -X DELETE http://localhost:8983/api/configsets/myConfigSet?omitHeader=true
+curl -X DELETE http://localhost:8983/api/configsets/myConfigSet
----
====
======
diff --git
a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc
b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc
index 4954860be11..fe6217dd9dd 100644
---
a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc
+++
b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc
@@ -332,6 +332,13 @@ Older segments will continue to be readable.
WARNING: After upgrading to Solr 10.1, downgrading to an earlier Solr 10.0.x
version may fail because the older version does not include the `Lucene104`
codec needed to read the newly written segments.
If you require the ability to roll back, back up your indexes before upgrading.
+=== API Changes
+
+Solr 10.1 changes the V1 API signature in the ConfigSets API for uploading a
single file
+Previously the default for `over write` is false when using the v1 API, but
true when using the v2 API.
+Overwrite makes sense when uploading a complete configset and you want to
eliminate any remnants configset files from the previous one when you replaced
it with a new one.
+However for a single file upload it has no bearing and only existed due to a
bad V1 API design choice.
+
=== Docker
The `gosu` binary is no longer installed in the Solr Docker image. See
https://github.com/tianon/gosu[gosu github page] for alternatives, such as
`runuser`, `setpriv` or `chroot`.