Skip to content

Commit 16f1135

Browse files
committed
feat: add S3 and GCS bucket extensions to BigDataExtensions
- Introduced `s3Bucket` and `gcsBucket` extensions for creating buckets in LocalStack S3 and fake GCS server. - Added bucket identifiers and URI outputs for enhanced configuration. - Updated `BigDataExtensionsBuilder` with builder methods for declarative bucket setup. - Enhanced JUnit and programmatic extension support to combine TOML and code-based declarations. - Updated Spark example, documentation, and user guide to demonstrate the new bucket capabilities.
1 parent 3c4ad7c commit 16f1135

7 files changed

Lines changed: 165 additions & 35 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ The built-in extensions currently support:
5555

5656
- `s3Jceks`: creates an HDFS-backed JCEKS file from the LocalStack S3 endpoint credentials and exposes `s3-jceks.credential-provider.path`.
5757
- `kafkaAvro`: creates Kafka topics and produces Avro records through Schema Registry from inline TOML records or a records resource.
58+
- `s3Bucket` / `gcsBucket`: creates LocalStack S3 and fake-gcs-server buckets and exposes their bucket names and storage URIs.
5859

5960
JUnit usage is declaration-driven. The test declares the services with `@BigDataTest`, then points `@BigDataExtensions` at one or more TOML resources:
6061

@@ -111,6 +112,8 @@ class MyIntegrationTest {
111112
record("alpha", mapOf("id" to 1, "name" to "alpha"))
112113
}
113114
}
115+
extensions.s3Bucket("spark-iceberg-s3-$suffix", id = "spark-s3-bucket")
116+
extensions.gcsBucket("spark-iceberg-gcs-$suffix", id = "spark-gcs-bucket")
114117
}
115118
}
116119
}
@@ -136,6 +139,8 @@ public final class MyExtensionsConfigurer implements BigDataExtensionsConfigurer
136139
}
137140
```
138141

142+
When TOML and programmatic declarations are used together, TOML is loaded first and programmatic declarations are loaded second. Extensions are merged by `extension.id`; if the same id appears more than once, the later declaration replaces the earlier one before any lifecycle hook runs. Use the same id to override file config, or distinct ids to run multiple extensions of the same type.
143+
139144
For future extension modules, implement `BigDataExtension` directly for programmatic use or publish a `BigDataExtensionProvider` via `ServiceLoader`. Providers are selected from config entries under `extensions` by `type`, and extensions can hook lifecycle events such as `AFTER_KIT_START`, `BEFORE_TEST_EXECUTION`, and `AFTER_ALL`.
140145

141146
## JUnit 5

doc/user-guide.adoc

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,9 @@ class MyIntegrationTest {
353353
record("beta", mapOf("id" to 2, "name" to "beta"))
354354
}
355355
}
356+
357+
extensions.s3Bucket("spark-iceberg-s3-$suffix", id = "spark-s3-bucket")
358+
extensions.gcsBucket("spark-iceberg-gcs-$suffix", id = "spark-gcs-bucket")
356359
}
357360
}
358361
}
@@ -387,6 +390,25 @@ public final class MyExtensionsConfigurer implements BigDataExtensionsConfigurer
387390

388391
Use `extensions.extension(...)` to add a custom `BigDataExtension` directly when the built-in builder methods are not enough.
389392

393+
When TOML and programmatic declarations are used together, `extension.id` is the identity. If the same id appears more than once, the later declaration replaces the earlier one before any lifecycle hook runs. Programmatic declarations therefore override TOML declarations with the same id. Use distinct ids when multiple extensions of the same type should run.
394+
395+
=== Object Store Bucket Extensions
396+
397+
The S3 and GCS bucket extensions create buckets after the object-store services start. They are useful when bucket names are generated per test class.
398+
399+
[source,kotlin]
400+
----
401+
extensions.s3Bucket("spark-iceberg-s3-$suffix", id = "spark-s3-bucket")
402+
extensions.gcsBucket("spark-iceberg-gcs-$suffix", id = "spark-gcs-bucket")
403+
----
404+
405+
Outputs:
406+
407+
* `spark-s3-bucket.bucket`
408+
* `spark-s3-bucket.s3a.uri`
409+
* `spark-gcs-bucket.bucket`
410+
* `spark-gcs-bucket.gs.uri`
411+
390412
=== S3 JCEKS Extension
391413

392414
The `s3Jceks` extension creates a Hadoop credential provider file in HDFS and stores LocalStack S3 credentials in it.

example/spark/src/test/kotlin/org/openprojectx/bigdata/test/example/spark/SparkBigDataTestExample.kt

Lines changed: 16 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,12 @@ import org.openprojectx.bigdata.test.core.BigDataService
88
import org.openprojectx.bigdata.test.core.BigDataTestKit
99
import org.openprojectx.bigdata.test.core.ContainerLogMode
1010
import org.openprojectx.bigdata.test.extensions.core.BigDataExtensionResult
11+
import org.openprojectx.bigdata.test.extensions.config.BigDataExtensionsBuilder
12+
import org.openprojectx.bigdata.test.extensions.config.BigDataExtensionsConfigurer
1113
import org.openprojectx.bigdata.test.extensions.junit5.BigDataExtensions
1214
import org.openprojectx.bigdata.test.junit5.BigDataTest
1315
import java.net.URI
14-
import java.net.http.HttpClient
15-
import java.net.http.HttpRequest
16-
import java.net.http.HttpResponse
17-
import java.nio.charset.StandardCharsets
1816
import java.nio.file.Files
19-
import java.time.Duration
2017

2118
@BigDataExtensions("classpath:spark-bigdata-extensions.toml")
2219
@BigDataTest(
@@ -29,24 +26,31 @@ import java.time.Duration
2926
containerLogMode = ContainerLogMode.FILE,
3027
)
3128
class SparkBigDataTestExample {
29+
companion object : BigDataExtensionsConfigurer {
30+
private val runId = System.nanoTime().toString()
31+
private const val s3BucketId = "spark-s3-bucket"
32+
private const val gcsBucketId = "spark-gcs-bucket"
33+
34+
override fun configure(extensions: BigDataExtensionsBuilder) {
35+
extensions.s3Bucket(bucket = "spark-iceberg-s3-$runId", id = s3BucketId)
36+
extensions.gcsBucket(bucket = "spark-iceberg-gcs-$runId", id = gcsBucketId)
37+
}
38+
}
39+
3240
@Test
3341
fun writesAvroKafkaEventsToIcebergOnObjectStorage(kit: BigDataTestKit, extensions: BigDataExtensionResult) {
3442
val hdfs = kit.endpoint(BigDataService.HDFS)
3543
val hiveMetastore = kit.endpoint(BigDataService.HIVE_METASTORE)
3644
val kafka = kit.endpoint(BigDataService.KAFKA)
37-
val schemaRegistry = kit.endpoint(BigDataService.SCHEMA_REGISTRY)
3845
val s3 = kit.endpoint(BigDataService.LOCALSTACK_S3)
3946
val gcs = kit.endpoint(BigDataService.FAKE_GCS)
4047

41-
val runId = System.nanoTime().toString()
4248
val topic = "spark-avro-events"
43-
val s3Bucket = "spark-iceberg-s3-$runId"
44-
val gcsBucket = "spark-iceberg-gcs-$runId"
45-
val gcsIcebergDataPath = "gs://$gcsBucket/data/demo_$runId/events_gcs"
49+
val s3Bucket = extensions.required("$s3BucketId.bucket")
50+
val gcsBucket = extensions.required("$gcsBucketId.bucket")
51+
val gcsIcebergDataPath = "${extensions.required("$gcsBucketId.gs.uri")}/data/demo_$runId/events_gcs"
4652
val s3CredentialProviderPath = extensions.required("s3-jceks.credential-provider.path")
4753
val s3CredentialProviderHdfsPath = extensions.required("s3-jceks.hdfs.path")
48-
createS3Bucket(s3.property("aws.endpoint-url.s3"), s3Bucket)
49-
createGcsBucket(gcs.property("google.cloud.storage.host"), gcsBucket)
5054

5155
sparkSession(
5256
hdfsUri = hdfs.property("fs.defaultFS"),
@@ -266,24 +270,4 @@ class SparkBigDataTestExample {
266270
client.close()
267271
}
268272
}
269-
270-
private fun createS3Bucket(endpoint: String, bucket: String) {
271-
val request = HttpRequest.newBuilder(URI.create("$endpoint/$bucket"))
272-
.timeout(Duration.ofSeconds(10))
273-
.PUT(HttpRequest.BodyPublishers.noBody())
274-
.build()
275-
val response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding())
276-
check(response.statusCode() in setOf(200, 409)) { "Failed to create S3 bucket $bucket: HTTP ${response.statusCode()}" }
277-
}
278-
279-
private fun createGcsBucket(endpoint: String, bucket: String) {
280-
val request = HttpRequest.newBuilder(URI.create("$endpoint/storage/v1/b?project=bigdata-test"))
281-
.timeout(Duration.ofSeconds(10))
282-
.header("Content-Type", "application/json")
283-
.POST(HttpRequest.BodyPublishers.ofString("""{"name":"$bucket"}""", StandardCharsets.UTF_8))
284-
.build()
285-
val response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding())
286-
check(response.statusCode() in setOf(200, 201, 409)) { "Failed to create GCS bucket $bucket: HTTP ${response.statusCode()}" }
287-
}
288-
289273
}

extensions/src/main/kotlin/org/openprojectx/bigdata/test/extensions/config/BigDataExtensionsBuilder.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import org.openprojectx.bigdata.test.extensions.hadoop.S3JceksExtension
1010
import org.openprojectx.bigdata.test.extensions.kafka.KafkaAvroRecordSeed
1111
import org.openprojectx.bigdata.test.extensions.kafka.KafkaAvroSeedExtension
1212
import org.openprojectx.bigdata.test.extensions.kafka.KafkaAvroTopicSeed
13+
import org.openprojectx.bigdata.test.extensions.objectstore.GcsBucketExtension
14+
import org.openprojectx.bigdata.test.extensions.objectstore.S3BucketExtension
1315
import java.util.function.Consumer
1416

1517
class BigDataExtensionsBuilder {
@@ -35,6 +37,26 @@ class BigDataExtensionsBuilder {
3537
extensions += KafkaAvroBuilder().also { configure.accept(it) }.build()
3638
}
3739

40+
fun s3Bucket(bucket: String, id: String = "s3-bucket-$bucket") {
41+
extensions += S3BucketExtension(id = id, bucket = bucket)
42+
}
43+
44+
fun s3Bucket(bucket: String) {
45+
s3Bucket(bucket = bucket, id = "s3-bucket-$bucket")
46+
}
47+
48+
fun gcsBucket(bucket: String, id: String = "gcs-bucket-$bucket", project: String = "bigdata-test") {
49+
extensions += GcsBucketExtension(id = id, bucket = bucket, project = project)
50+
}
51+
52+
fun gcsBucket(bucket: String) {
53+
gcsBucket(bucket = bucket, id = "gcs-bucket-$bucket", project = "bigdata-test")
54+
}
55+
56+
fun gcsBucket(bucket: String, id: String) {
57+
gcsBucket(bucket = bucket, id = id, project = "bigdata-test")
58+
}
59+
3860
internal fun build(): List<BigDataExtension> = extensions.toList()
3961
}
4062

extensions/src/main/kotlin/org/openprojectx/bigdata/test/extensions/config/BigDataExtensionsConfigLoader.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import org.openprojectx.bigdata.test.extensions.hadoop.S3JceksExtension
1818
import org.openprojectx.bigdata.test.extensions.kafka.KafkaAvroRecordSeed
1919
import org.openprojectx.bigdata.test.extensions.kafka.KafkaAvroSeedExtension
2020
import org.openprojectx.bigdata.test.extensions.kafka.KafkaAvroTopicSeed
21+
import org.openprojectx.bigdata.test.extensions.objectstore.GcsBucketExtension
22+
import org.openprojectx.bigdata.test.extensions.objectstore.S3BucketExtension
2123
import java.util.ServiceLoader
2224

2325
class BigDataExtensionsConfigLoader(
@@ -52,6 +54,27 @@ class BigDataExtensionsConfigLoader(
5254
)
5355
}
5456
}
57+
root["s3Buckets"]?.jsonArray?.forEach { item ->
58+
val config = item.jsonObject
59+
if (config.boolean("enabled", default = true)) {
60+
val bucket = config.string("bucket")
61+
extensions += S3BucketExtension(
62+
id = config.string("id", "s3-bucket-$bucket"),
63+
bucket = bucket,
64+
)
65+
}
66+
}
67+
root["gcsBuckets"]?.jsonArray?.forEach { item ->
68+
val config = item.jsonObject
69+
if (config.boolean("enabled", default = true)) {
70+
val bucket = config.string("bucket")
71+
extensions += GcsBucketExtension(
72+
id = config.string("id", "gcs-bucket-$bucket"),
73+
bucket = bucket,
74+
project = config.string("project", "bigdata-test"),
75+
)
76+
}
77+
}
5578
root["extensions"]?.jsonArray?.forEach { item ->
5679
val config = item.jsonObject
5780
val type = config.string("type")

extensions/src/main/kotlin/org/openprojectx/bigdata/test/extensions/junit5/BigDataExtensionsExtension.kt

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import org.openprojectx.bigdata.test.extensions.config.BigDataExtensionsBuilder
99
import org.openprojectx.bigdata.test.extensions.config.BigDataExtensionsConfigLoader
1010
import org.openprojectx.bigdata.test.extensions.config.BigDataExtensionsConfigurer
1111
import org.openprojectx.bigdata.test.extensions.config.NoopBigDataExtensionsConfigurer
12+
import org.openprojectx.bigdata.test.extensions.core.BigDataExtension
1213
import org.openprojectx.bigdata.test.extensions.core.BigDataExtensionEvent
1314
import org.openprojectx.bigdata.test.extensions.core.BigDataExtensionResult
1415
import org.openprojectx.bigdata.test.extensions.core.BigDataExtensionRunner
@@ -39,8 +40,10 @@ class BigDataExtensionsExtension : BeforeTestExecutionCallback, AfterAllCallback
3940
val annotation = context.requiredTestClass.getAnnotation(BigDataExtensions::class.java)
4041
?: error("@BigDataExtensions is missing")
4142
val resources = BigDataExtensionResourceLoader(context.requiredTestClass.classLoader)
42-
val extensions = BigDataExtensionsConfigLoader(resources).load(annotation.value.asIterable()) +
43-
programmaticExtensions(annotation, context)
43+
val extensions = mergeById(
44+
BigDataExtensionsConfigLoader(resources).load(annotation.value.asIterable()) +
45+
programmaticExtensions(annotation, context),
46+
)
4447
val runner = BigDataExtensionRunner(extensions, resources)
4548
val kit = BigDataTestKitStore.get(context)
4649
val result = runner.fire(BigDataExtensionEvent.AFTER_KIT_START, kit)
@@ -53,14 +56,25 @@ class BigDataExtensionsExtension : BeforeTestExecutionCallback, AfterAllCallback
5356
private fun programmaticExtensions(
5457
annotation: BigDataExtensions,
5558
context: ExtensionContext,
56-
): List<org.openprojectx.bigdata.test.extensions.core.BigDataExtension> {
59+
): List<BigDataExtension> {
5760
val builder = BigDataExtensionsBuilder()
5861
explicitConfigurer(annotation)?.configure(builder)
5962
companionConfigurer(context)?.configure(builder)
6063
instanceConfigurer(context)?.configure(builder)
6164
return builder.build()
6265
}
6366

67+
private fun mergeById(extensions: List<BigDataExtension>): List<BigDataExtension> {
68+
val merged = linkedMapOf<String, BigDataExtension>()
69+
extensions.forEach { extension ->
70+
if (merged.containsKey(extension.id)) {
71+
merged.remove(extension.id)
72+
}
73+
merged[extension.id] = extension
74+
}
75+
return merged.values.toList()
76+
}
77+
6478
private fun explicitConfigurer(annotation: BigDataExtensions): BigDataExtensionsConfigurer? {
6579
val type = annotation.configurer.java
6680
if (type == NoopBigDataExtensionsConfigurer::class.java) return null
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package org.openprojectx.bigdata.test.extensions.objectstore
2+
3+
import org.openprojectx.bigdata.test.core.BigDataService
4+
import org.openprojectx.bigdata.test.extensions.core.BigDataExtension
5+
import org.openprojectx.bigdata.test.extensions.core.BigDataExtensionContext
6+
import org.openprojectx.bigdata.test.extensions.core.BigDataExtensionEvent
7+
import java.net.URI
8+
import java.net.http.HttpClient
9+
import java.net.http.HttpRequest
10+
import java.net.http.HttpResponse
11+
import java.nio.charset.StandardCharsets
12+
import java.time.Duration
13+
14+
data class S3BucketExtension(
15+
override val id: String,
16+
val bucket: String,
17+
) : BigDataExtension {
18+
override val requiredServices: Set<BigDataService> = setOf(BigDataService.LOCALSTACK_S3)
19+
override val events: Set<BigDataExtensionEvent> = setOf(BigDataExtensionEvent.AFTER_KIT_START)
20+
21+
override fun onEvent(event: BigDataExtensionEvent, context: BigDataExtensionContext) {
22+
val endpoint = context.endpoint(BigDataService.LOCALSTACK_S3).property("aws.endpoint-url.s3")
23+
val request = HttpRequest.newBuilder(URI.create("${endpoint.trimEnd('/')}/$bucket"))
24+
.timeout(Duration.ofSeconds(10))
25+
.PUT(HttpRequest.BodyPublishers.noBody())
26+
.build()
27+
val response = httpClient.send(request, HttpResponse.BodyHandlers.discarding())
28+
check(response.statusCode() in setOf(200, 409)) {
29+
"Failed to create S3 bucket $bucket: HTTP ${response.statusCode()}"
30+
}
31+
context.putOutput("$id.bucket", bucket)
32+
context.putOutput("$id.s3a.uri", "s3a://$bucket")
33+
}
34+
}
35+
36+
data class GcsBucketExtension(
37+
override val id: String,
38+
val bucket: String,
39+
val project: String = "bigdata-test",
40+
) : BigDataExtension {
41+
override val requiredServices: Set<BigDataService> = setOf(BigDataService.FAKE_GCS)
42+
override val events: Set<BigDataExtensionEvent> = setOf(BigDataExtensionEvent.AFTER_KIT_START)
43+
44+
override fun onEvent(event: BigDataExtensionEvent, context: BigDataExtensionContext) {
45+
val endpoint = context.endpoint(BigDataService.FAKE_GCS).property("google.cloud.storage.host")
46+
val request = HttpRequest.newBuilder(URI.create("${endpoint.trimEnd('/')}/storage/v1/b?project=$project"))
47+
.timeout(Duration.ofSeconds(10))
48+
.header("Content-Type", "application/json")
49+
.POST(HttpRequest.BodyPublishers.ofString("""{"name":"$bucket"}""", StandardCharsets.UTF_8))
50+
.build()
51+
val response = httpClient.send(request, HttpResponse.BodyHandlers.discarding())
52+
check(response.statusCode() in setOf(200, 201, 409)) {
53+
"Failed to create GCS bucket $bucket: HTTP ${response.statusCode()}"
54+
}
55+
context.putOutput("$id.bucket", bucket)
56+
context.putOutput("$id.gs.uri", "gs://$bucket")
57+
}
58+
}
59+
60+
private val httpClient: HttpClient = HttpClient.newHttpClient()

0 commit comments

Comments
 (0)