From 69d442cc107e00880c3e46cf2ee18b04f251521b Mon Sep 17 00:00:00 2001 From: Yonatan Ginsburg Date: Tue, 19 May 2026 15:08:17 -0400 Subject: [PATCH 1/3] etags --- .../koop/queryprocessor/gateway/Main.java | 4 + system-tests/pom.xml | 8 + .../src/test/java/koop/Boto3UploadFileIT.java | 383 ++++++++++++++++++ .../test/java/koop/DockerComposeS3E2EIT.java | 78 +++- 4 files changed, 467 insertions(+), 6 deletions(-) create mode 100644 system-tests/src/test/java/koop/Boto3UploadFileIT.java diff --git a/query-processor/src/main/java/com/github/koop/queryprocessor/gateway/Main.java b/query-processor/src/main/java/com/github/koop/queryprocessor/gateway/Main.java index a9774e08..af3c1750 100644 --- a/query-processor/src/main/java/com/github/koop/queryprocessor/gateway/Main.java +++ b/query-processor/src/main/java/com/github/koop/queryprocessor/gateway/Main.java @@ -371,6 +371,7 @@ private static void putOrUploadPartHandler(Context ctx, StorageService storage) } ctx.status(200); + ctx.header("ETag", "\"" + UUID.randomUUID().toString().replace("-", "") + "\""); ctx.result(""); } else { InputStream data = ctx.bodyInputStream(); @@ -381,6 +382,7 @@ private static void putOrUploadPartHandler(Context ctx, StorageService storage) return; } ctx.status(200); + ctx.header("ETag", "\"" + UUID.randomUUID().toString().replace("-", "") + "\""); ctx.result(""); } } catch (UnsupportedOperationException e) { @@ -572,11 +574,13 @@ private static String buildInitiateMultipartUploadXml(String bucket, String key, private static String buildCompleteMultipartUploadXml(String bucket, String key) { String encodedKey = URLEncoder.encode(key, StandardCharsets.UTF_8).replace("+", "%20"); + String etag = UUID.randomUUID().toString().replace("-", ""); return "\n" + "\n" + " http://localhost:8080/" + escapeXml(bucket) + "/" + encodedKey + "\n" + " " + escapeXml(bucket) + "\n" + " " + escapeXml(key) + "\n" + + " \"" + etag + "\"\n" + ""; } diff --git a/system-tests/pom.xml b/system-tests/pom.xml index 116a307b..2e28e95a 100644 --- a/system-tests/pom.xml +++ b/system-tests/pom.xml @@ -54,6 +54,14 @@ test + + + software.amazon.awssdk + s3-transfer-manager + 2.41.34 + test + + org.testcontainers testcontainers diff --git a/system-tests/src/test/java/koop/Boto3UploadFileIT.java b/system-tests/src/test/java/koop/Boto3UploadFileIT.java new file mode 100644 index 00000000..9e873185 --- /dev/null +++ b/system-tests/src/test/java/koop/Boto3UploadFileIT.java @@ -0,0 +1,383 @@ +package koop; + +import com.github.koop.common.metadata.ErasureSetConfiguration; +import com.github.koop.common.metadata.ErasureSetConfiguration.ErasureSet; +import com.github.koop.common.metadata.ErasureSetConfiguration.Machine; +import com.github.koop.common.metadata.MemoryFetcher; +import com.github.koop.common.metadata.MetadataClient; +import com.github.koop.common.metadata.PartitionSpreadConfiguration; +import com.github.koop.common.metadata.PartitionSpreadConfiguration.PartitionSpread; +import com.github.koop.common.pubsub.MemoryPubSub; +import com.github.koop.common.pubsub.PubSubClient; +import com.github.koop.queryprocessor.gateway.Main; +import com.github.koop.queryprocessor.gateway.StorageServices.StorageService; +import com.github.koop.queryprocessor.gateway.StorageServices.StorageWorkerService; +import com.github.koop.queryprocessor.processor.CommitCoordinator; +import com.github.koop.queryprocessor.processor.StorageWorker; +import com.github.koop.queryprocessor.processor.cache.MemoryCacheClient; +import com.github.koop.storagenode.RocksDbRepairQueue; +import com.github.koop.storagenode.StorageNodeServerV2; +import com.github.koop.storagenode.db.Database; +import com.github.koop.storagenode.db.RocksDbStorageStrategy; +import io.javalin.Javalin; +import org.junit.jupiter.api.*; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.core.sync.ResponseTransformer; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.multipart.MultipartConfiguration; +import software.amazon.awssdk.transfer.s3.S3TransferManager; +import software.amazon.awssdk.transfer.s3.model.CompletedFileUpload; +import software.amazon.awssdk.transfer.s3.model.FileUpload; +import software.amazon.awssdk.transfer.s3.model.UploadFileRequest; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for file-based uploads via the Java S3 SDK — the equivalent + * of boto3's upload_file. Three approaches are exercised: + * + *
    + *
  1. Single-part PUT using {@code RequestBody.fromFile()} — reads directly from disk.
  2. + *
  3. Multipart upload reading file chunks with {@code RandomAccessFile}.
  4. + *
  5. {@link S3TransferManager#uploadFile} — the high-level SDK equivalent of + * boto3's {@code s3.upload_file()}, which selects single- or multi-part + * automatically based on file size.
  6. + *
+ */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class Boto3UploadFileIT { + + private static final int TOTAL_NODES = 9; + private static final String BUCKET = "upload-file-bucket"; + + private final List servers = new ArrayList<>(); + private final List dataDirs = new ArrayList<>(); + private final List databases = new ArrayList<>(); + + private MemoryFetcher sharedFetcher; + private MemoryPubSub sharedPubSub; + private MetadataClient sharedMetadataClient; + private PubSubClient sharedPubSubClient; + private CommitCoordinator commitCoordinator; + private StorageWorker worker; + + private Javalin app; + private S3Client s3; + private S3AsyncClient asyncS3; + private S3TransferManager transferManager; + + private static void log(String msg) { + System.out.printf("[%s] %s%n", LocalTime.now().withNano(0), msg); + } + + @BeforeEach + void startFullStack() throws Exception { + log("=== STARTING FULL STACK (Boto3UploadFileIT) ==="); + + sharedFetcher = new MemoryFetcher(); + sharedPubSub = new MemoryPubSub(); + + sharedPubSubClient = new PubSubClient(sharedPubSub); + sharedPubSubClient.start(); + + sharedMetadataClient = new MetadataClient(sharedFetcher); + sharedMetadataClient.start(); + + commitCoordinator = new CommitCoordinator(sharedPubSubClient, 0, 10); + worker = new StorageWorker(sharedMetadataClient, commitCoordinator); + + List addrs = new ArrayList<>(); + for (int i = 0; i < TOTAL_NODES; i++) { + int port = freePort(); + Path dir = Files.createTempDirectory("upload-file-node-" + i + "-"); + + RocksDbStorageStrategy strategy = new RocksDbStorageStrategy(dir.resolve("db").toString()); + RocksDbRepairQueue repairQueue = new RocksDbRepairQueue(strategy); + Database db = new Database(strategy); + StorageNodeServerV2 server = + new StorageNodeServerV2(port, "127.0.0.1", db, dir.resolve("data"), + sharedMetadataClient, sharedPubSubClient, repairQueue); + + servers.add(server); + dataDirs.add(dir); + databases.add(db); + addrs.add(new InetSocketAddress("127.0.0.1", port)); + } + + ErasureSetConfiguration esConfig = new ErasureSetConfiguration(); + ErasureSet es = new ErasureSet(); + es.setNumber(1); + es.setN(9); + es.setM(6); + es.setWriteQuorum(7); + List machines = new ArrayList<>(); + for (InetSocketAddress addr : addrs) { + Machine m = new Machine(); + m.setIp(addr.getHostString()); + m.setPort(addr.getPort()); + machines.add(m); + } + es.setMachines(machines); + esConfig.setErasureSets(List.of(es)); + + PartitionSpreadConfiguration psConfig = new PartitionSpreadConfiguration(); + PartitionSpread ps = new PartitionSpread(); + ps.setErasureSet(1); + List parts = new ArrayList<>(); + for (int i = 0; i < 3; i++) parts.add(i); + ps.setPartitions(parts); + psConfig.setPartitionSpread(List.of(ps)); + + sharedFetcher.update(esConfig); + sharedFetcher.update(psConfig); + + servers.forEach(StorageNodeServerV2::start); + + MemoryCacheClient cache = new MemoryCacheClient(); + StorageService storage = new StorageWorkerService(worker, cache); + app = Main.createApp(storage).start(0); + + URI endpoint = URI.create("http://localhost:" + app.port()); + StaticCredentialsProvider creds = StaticCredentialsProvider.create( + AwsBasicCredentials.create("fake-access-key", "fake-secret-key")); + S3Configuration s3Config = S3Configuration.builder() + .pathStyleAccessEnabled(true) + .chunkedEncodingEnabled(false) + .build(); + + s3 = S3Client.builder() + .endpointOverride(endpoint) + .credentialsProvider(creds) + .region(Region.US_EAST_1) + .serviceConfiguration(s3Config) + .build(); + + // Async client with a low multipart threshold so the transfer manager + // exercises multipart upload even for moderately sized test files. + asyncS3 = S3AsyncClient.builder() + .endpointOverride(endpoint) + .credentialsProvider(creds) + .region(Region.US_EAST_1) + .serviceConfiguration(s3Config) + .multipartEnabled(true) + .multipartConfiguration(MultipartConfiguration.builder() + .thresholdInBytes(1024 * 1024L) // 1 MB + .minimumPartSizeInBytes(1024 * 1024L) // 1 MB per part + .build()) + .build(); + + transferManager = S3TransferManager.builder() + .s3Client(asyncS3) + .build(); + + log("=== FULL STACK READY on port " + app.port() + " ==="); + } + + @AfterEach + void stopFullStack() throws Exception { + if (transferManager != null) transferManager.close(); + if (asyncS3 != null) asyncS3.close(); + if (s3 != null) s3.close(); + if (app != null) app.stop(); + if (worker != null) worker.shutdown(); + if (sharedMetadataClient != null) sharedMetadataClient.close(); + if (sharedPubSubClient != null) sharedPubSubClient.close(); + + for (StorageNodeServerV2 server : servers) server.stop(); + for (Database db : databases) { try { db.close(); } catch (Exception ignored) {} } + for (Path d : dataDirs) deleteRecursive(d); + + servers.clear(); + databases.clear(); + dataDirs.clear(); + + log("=== FULL STACK STOPPED ==="); + } + + // ===================================================================== + // TEST: single-part upload from file (RequestBody.fromFile) + // ===================================================================== + @Test + void uploadFile_singlePart_roundTrip() throws Exception { + log("\n--- TEST: uploadFile single-part (RequestBody.fromFile) ---"); + + byte[] data = new byte[2 * 1024 * 1024]; // 2 MB + new SecureRandom().nextBytes(data); + String key = "upload-file-single.bin"; + + Path tmpFile = Files.createTempFile("upload-single-", ".bin"); + try { + Files.write(tmpFile, data); + + log("Step 1: PUT from file path via RequestBody.fromFile"); + s3.putObject( + PutObjectRequest.builder() + .bucket(BUCKET) + .key(key) + .contentLength((long) data.length) + .build(), + RequestBody.fromFile(tmpFile) + ); + log("Step 1: PUT completed"); + + log("Step 2: GET and verify"); + ResponseBytes response = s3.getObject( + GetObjectRequest.builder().bucket(BUCKET).key(key).build(), + ResponseTransformer.toBytes() + ); + assertArrayEquals(data, response.asByteArray(), + "Downloaded bytes must match what was uploaded from file"); + log("--- TEST PASSED ---\n"); + } finally { + Files.deleteIfExists(tmpFile); + } + } + + // ===================================================================== + // TEST: multipart upload reading parts from disk via RandomAccessFile + // ===================================================================== + @Test + void uploadFile_multipart_roundTrip() throws Exception { + log("\n--- TEST: uploadFile multipart (parts read from file) ---"); + + int partSize = 1024 * 1024; // 1 MB per part + int numParts = 5; + byte[] data = new byte[numParts * partSize]; + new SecureRandom().nextBytes(data); + String key = "upload-file-multipart.bin"; + + Path tmpFile = Files.createTempFile("upload-multi-", ".bin"); + try { + Files.write(tmpFile, data); + + log("Step 1: CreateMultipartUpload"); + CreateMultipartUploadResponse initResp = s3.createMultipartUpload( + CreateMultipartUploadRequest.builder().bucket(BUCKET).key(key).build() + ); + String uploadId = initResp.uploadId(); + log("Step 1: Upload ID = " + uploadId); + + log("Step 2: Uploading " + numParts + " parts from file"); + List completedParts = new ArrayList<>(); + try (RandomAccessFile raf = new RandomAccessFile(tmpFile.toFile(), "r")) { + for (int i = 0; i < numParts; i++) { + byte[] chunk = new byte[partSize]; + raf.readFully(chunk); + + UploadPartResponse partResp = s3.uploadPart( + UploadPartRequest.builder() + .bucket(BUCKET) + .key(key) + .uploadId(uploadId) + .partNumber(i + 1) + .contentLength((long) partSize) + .build(), + RequestBody.fromBytes(chunk) + ); + log(" -> Part " + (i + 1) + " ETag: " + partResp.eTag()); + completedParts.add(CompletedPart.builder() + .partNumber(i + 1) + .eTag(partResp.eTag()) + .build()); + } + } + + log("Step 3: CompleteMultipartUpload"); + CompleteMultipartUploadResponse completeResp = s3.completeMultipartUpload( + CompleteMultipartUploadRequest.builder() + .bucket(BUCKET) + .key(key) + .uploadId(uploadId) + .multipartUpload(CompletedMultipartUpload.builder().parts(completedParts).build()) + .build() + ); + log("Step 3: Completed. ETag = " + completeResp.eTag()); + assertNotNull(completeResp.eTag(), "CompleteMultipartUpload response must include an ETag"); + + log("Step 4: GET and verify " + data.length + " bytes"); + ResponseBytes getResp = s3.getObject( + GetObjectRequest.builder().bucket(BUCKET).key(key).build(), + ResponseTransformer.toBytes() + ); + assertArrayEquals(data, getResp.asByteArray(), + "Downloaded bytes must match the multipart-uploaded file content"); + log("--- TEST PASSED ---\n"); + } finally { + Files.deleteIfExists(tmpFile); + } + } + + // ===================================================================== + // TEST: S3TransferManager.uploadFile — the direct Java SDK equivalent + // of boto3's s3.upload_file(). The async client is configured with a + // 1 MB multipart threshold so this exercises the full multipart path. + // ===================================================================== + @Test + void uploadFile_transferManager_roundTrip() throws Exception { + log("\n--- TEST: S3TransferManager.uploadFile (boto3 upload_file equivalent) ---"); + + byte[] data = new byte[5 * 1024 * 1024]; // 5 MB → 5 × 1 MB parts + new SecureRandom().nextBytes(data); + String key = "transfer-manager-upload.bin"; + + Path tmpFile = Files.createTempFile("transfer-upload-", ".bin"); + try { + Files.write(tmpFile, data); + + log("Step 1: S3TransferManager.uploadFile from " + tmpFile); + FileUpload upload = transferManager.uploadFile(UploadFileRequest.builder() + .putObjectRequest(r -> r.bucket(BUCKET).key(key)) + .source(tmpFile) + .build()); + + CompletedFileUpload completed = upload.completionFuture().join(); + log("Step 1: Upload complete. HTTP status = " + completed.response().sdkHttpResponse().statusCode()); + + log("Step 2: GET and verify " + data.length + " bytes"); + ResponseBytes getResp = s3.getObject( + GetObjectRequest.builder().bucket(BUCKET).key(key).build(), + ResponseTransformer.toBytes() + ); + assertArrayEquals(data, getResp.asByteArray(), + "Data downloaded after S3TransferManager.uploadFile must match source file"); + log("--- TEST PASSED ---\n"); + } finally { + Files.deleteIfExists(tmpFile); + } + } + + private static int freePort() throws IOException { + try (ServerSocket ss = new ServerSocket(0)) { + ss.setReuseAddress(true); + return ss.getLocalPort(); + } + } + + private static void deleteRecursive(Path root) throws IOException { + if (!Files.exists(root)) return; + try (var paths = Files.walk(root)) { + paths.sorted(Comparator.reverseOrder()) + .forEach(p -> { try { Files.deleteIfExists(p); } catch (IOException ignored) {} }); + } + } +} diff --git a/system-tests/src/test/java/koop/DockerComposeS3E2EIT.java b/system-tests/src/test/java/koop/DockerComposeS3E2EIT.java index 6d510a62..ce2792d6 100644 --- a/system-tests/src/test/java/koop/DockerComposeS3E2EIT.java +++ b/system-tests/src/test/java/koop/DockerComposeS3E2EIT.java @@ -11,13 +11,21 @@ import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.core.sync.ResponseTransformer; import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3AsyncClient; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3Configuration; import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.multipart.MultipartConfiguration; +import software.amazon.awssdk.transfer.s3.S3TransferManager; +import software.amazon.awssdk.transfer.s3.model.CompletedFileUpload; +import software.amazon.awssdk.transfer.s3.model.FileUpload; +import software.amazon.awssdk.transfer.s3.model.UploadFileRequest; import java.io.File; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.SecureRandom; import java.time.LocalTime; import java.util.ArrayList; @@ -31,6 +39,8 @@ public class DockerComposeS3E2EIT { private static final String BUCKET = "e2e-bucket"; private S3Client s3; + private S3AsyncClient asyncS3; + private S3TransferManager transferManager; @Container public static ComposeContainer environment = @@ -52,17 +62,37 @@ void setupS3Client() { String host = "127.0.0.1"; Integer port = 9001; + URI endpoint = URI.create("http://" + host + ":" + port); + StaticCredentialsProvider creds = StaticCredentialsProvider.create( + AwsBasicCredentials.create("fake-access-key", "fake-secret-key")); + S3Configuration s3Config = S3Configuration.builder() + .pathStyleAccessEnabled(true) + .chunkedEncodingEnabled(false) + .build(); + s3 = S3Client.builder() - .endpointOverride(URI.create("http://" + host + ":" + port)) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create("fake-access-key", "fake-secret-key"))) + .endpointOverride(endpoint) + .credentialsProvider(creds) + .region(Region.US_EAST_1) + .serviceConfiguration(s3Config) + .build(); + + asyncS3 = S3AsyncClient.builder() + .endpointOverride(endpoint) + .credentialsProvider(creds) .region(Region.US_EAST_1) - .serviceConfiguration(S3Configuration.builder() - .pathStyleAccessEnabled(true) - .chunkedEncodingEnabled(false) + .serviceConfiguration(s3Config) + .multipartEnabled(true) + .multipartConfiguration(MultipartConfiguration.builder() + .thresholdInBytes(1024 * 1024L) + .minimumPartSizeInBytes(1024 * 1024L) .build()) .build(); + transferManager = S3TransferManager.builder() + .s3Client(asyncS3) + .build(); + log("3. Polling the Gateway S3 API to check if it is awake (Max 3 minutes)..."); long startTime = System.currentTimeMillis(); boolean isUp = false; @@ -103,6 +133,8 @@ void teardown() { log("======================================================"); log("PHASE: TEARDOWN"); log("======================================================"); + if (transferManager != null) transferManager.close(); + if (asyncS3 != null) asyncS3.close(); if (s3 != null) { log("Closing S3 Java Client connection..."); s3.close(); @@ -224,6 +256,40 @@ void e2e_multipartUpload_fullLifecycle_thenGet() { log("--- TEST PASSED ---\n"); } + @Test + void e2e_uploadFile_transferManager_roundTrip() throws Exception { + log("\n--- TEST: S3TransferManager.uploadFile (boto3 upload_file equivalent) ---"); + + byte[] data = new byte[5 * 1024 * 1024]; // 5 MB → 5 × 1 MB parts + new SecureRandom().nextBytes(data); + String key = "transfer-manager-upload.bin"; + + Path tmpFile = Files.createTempFile("docker-transfer-", ".bin"); + try { + Files.write(tmpFile, data); + + log("Step 1: S3TransferManager.uploadFile from " + tmpFile); + FileUpload upload = transferManager.uploadFile(UploadFileRequest.builder() + .putObjectRequest(r -> r.bucket(BUCKET).key(key)) + .source(tmpFile) + .build()); + + CompletedFileUpload completed = upload.completionFuture().join(); + log("Step 1: Upload complete. HTTP status = " + completed.response().sdkHttpResponse().statusCode()); + + log("Step 2: GET and verify " + data.length + " bytes"); + ResponseBytes getResp = s3.getObject( + GetObjectRequest.builder().bucket(BUCKET).key(key).build(), + ResponseTransformer.toBytes() + ); + assertArrayEquals(data, getResp.asByteArray(), + "Data downloaded after S3TransferManager.uploadFile must match source file"); + log("--- TEST PASSED ---\n"); + } finally { + Files.deleteIfExists(tmpFile); + } + } + @Test @Disabled("Buggy will address") void e2e_createThenDeleteBucket_headReturns404() { From 713d1cae2a30d8fded0d2935338ef8d8b25873a9 Mon Sep 17 00:00:00 2001 From: Yonatan Ginsburg Date: Tue, 19 May 2026 15:34:16 -0400 Subject: [PATCH 2/3] disable checksum validation --- system-tests/src/test/java/koop/Boto3UploadFileIT.java | 6 ++++++ system-tests/src/test/java/koop/DockerComposeS3E2EIT.java | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/system-tests/src/test/java/koop/Boto3UploadFileIT.java b/system-tests/src/test/java/koop/Boto3UploadFileIT.java index 9e873185..f37aa62a 100644 --- a/system-tests/src/test/java/koop/Boto3UploadFileIT.java +++ b/system-tests/src/test/java/koop/Boto3UploadFileIT.java @@ -24,6 +24,8 @@ import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.checksums.RequestChecksumCalculation; +import software.amazon.awssdk.core.checksums.ResponseChecksumValidation; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.core.sync.ResponseTransformer; import software.amazon.awssdk.regions.Region; @@ -170,6 +172,8 @@ void startFullStack() throws Exception { .credentialsProvider(creds) .region(Region.US_EAST_1) .serviceConfiguration(s3Config) + .requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED) + .responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED) .build(); // Async client with a low multipart threshold so the transfer manager @@ -180,6 +184,8 @@ void startFullStack() throws Exception { .region(Region.US_EAST_1) .serviceConfiguration(s3Config) .multipartEnabled(true) + .requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED) + .responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED) .multipartConfiguration(MultipartConfiguration.builder() .thresholdInBytes(1024 * 1024L) // 1 MB .minimumPartSizeInBytes(1024 * 1024L) // 1 MB per part diff --git a/system-tests/src/test/java/koop/DockerComposeS3E2EIT.java b/system-tests/src/test/java/koop/DockerComposeS3E2EIT.java index ce2792d6..fc5f092c 100644 --- a/system-tests/src/test/java/koop/DockerComposeS3E2EIT.java +++ b/system-tests/src/test/java/koop/DockerComposeS3E2EIT.java @@ -8,6 +8,8 @@ import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.checksums.RequestChecksumCalculation; +import software.amazon.awssdk.core.checksums.ResponseChecksumValidation; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.core.sync.ResponseTransformer; import software.amazon.awssdk.regions.Region; @@ -75,6 +77,8 @@ void setupS3Client() { .credentialsProvider(creds) .region(Region.US_EAST_1) .serviceConfiguration(s3Config) + .requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED) + .responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED) .build(); asyncS3 = S3AsyncClient.builder() @@ -83,6 +87,8 @@ void setupS3Client() { .region(Region.US_EAST_1) .serviceConfiguration(s3Config) .multipartEnabled(true) + .requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED) + .responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED) .multipartConfiguration(MultipartConfiguration.builder() .thresholdInBytes(1024 * 1024L) .minimumPartSizeInBytes(1024 * 1024L) From 01bb707711953d63d79f8af994cdf0d06616f8b6 Mon Sep 17 00:00:00 2001 From: Yonatan Ginsburg Date: Tue, 19 May 2026 15:45:29 -0400 Subject: [PATCH 3/3] unsigned payload interceptor --- .../src/test/java/koop/Boto3UploadFileIT.java | 4 ++++ .../test/java/koop/DockerComposeS3E2EIT.java | 4 ++++ .../java/koop/UnsignedPayloadInterceptor.java | 17 +++++++++++++++++ 3 files changed, 25 insertions(+) create mode 100644 system-tests/src/test/java/koop/UnsignedPayloadInterceptor.java diff --git a/system-tests/src/test/java/koop/Boto3UploadFileIT.java b/system-tests/src/test/java/koop/Boto3UploadFileIT.java index f37aa62a..1b1afebb 100644 --- a/system-tests/src/test/java/koop/Boto3UploadFileIT.java +++ b/system-tests/src/test/java/koop/Boto3UploadFileIT.java @@ -26,6 +26,7 @@ import software.amazon.awssdk.core.ResponseBytes; import software.amazon.awssdk.core.checksums.RequestChecksumCalculation; import software.amazon.awssdk.core.checksums.ResponseChecksumValidation; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.core.sync.ResponseTransformer; import software.amazon.awssdk.regions.Region; @@ -190,6 +191,9 @@ void startFullStack() throws Exception { .thresholdInBytes(1024 * 1024L) // 1 MB .minimumPartSizeInBytes(1024 * 1024L) // 1 MB per part .build()) + .overrideConfiguration(ClientOverrideConfiguration.builder() + .addExecutionInterceptor(new UnsignedPayloadInterceptor()) // Bypasses the hash requirement + .build()) .build(); transferManager = S3TransferManager.builder() diff --git a/system-tests/src/test/java/koop/DockerComposeS3E2EIT.java b/system-tests/src/test/java/koop/DockerComposeS3E2EIT.java index fc5f092c..e234cd26 100644 --- a/system-tests/src/test/java/koop/DockerComposeS3E2EIT.java +++ b/system-tests/src/test/java/koop/DockerComposeS3E2EIT.java @@ -10,6 +10,7 @@ import software.amazon.awssdk.core.ResponseBytes; import software.amazon.awssdk.core.checksums.RequestChecksumCalculation; import software.amazon.awssdk.core.checksums.ResponseChecksumValidation; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.core.sync.ResponseTransformer; import software.amazon.awssdk.regions.Region; @@ -93,6 +94,9 @@ void setupS3Client() { .thresholdInBytes(1024 * 1024L) .minimumPartSizeInBytes(1024 * 1024L) .build()) + .overrideConfiguration(ClientOverrideConfiguration.builder() + .addExecutionInterceptor(new UnsignedPayloadInterceptor()) // Bypasses the hash requirement + .build()) .build(); transferManager = S3TransferManager.builder() diff --git a/system-tests/src/test/java/koop/UnsignedPayloadInterceptor.java b/system-tests/src/test/java/koop/UnsignedPayloadInterceptor.java new file mode 100644 index 00000000..c4091065 --- /dev/null +++ b/system-tests/src/test/java/koop/UnsignedPayloadInterceptor.java @@ -0,0 +1,17 @@ +package koop; +import software.amazon.awssdk.core.interceptor.Context; +import software.amazon.awssdk.core.interceptor.ExecutionAttributes; +import software.amazon.awssdk.core.interceptor.ExecutionInterceptor; +import software.amazon.awssdk.http.SdkHttpRequest; + +public class UnsignedPayloadInterceptor implements ExecutionInterceptor { + @Override + public SdkHttpRequest modifyHttpRequest(Context.ModifyHttpRequest context, ExecutionAttributes executionAttributes) { + if (!context.httpRequest().headers().containsKey("x-amz-content-sha256")) { + return context.httpRequest().toBuilder() + .putHeader("x-amz-content-sha256", "UNSIGNED-PAYLOAD") + .build(); + } + return context.httpRequest(); + } +} \ No newline at end of file