Skip to content

Commit 8153ba5

Browse files
committed
refactor: consolidate addons into extensions for streamlined test setup
- Replaced `addons` module with `extensions` for config-driven integration test setup. - Migrated `HadoopCredentialProviders` and `KafkaAvroProducers` to `extensions` module. - Introduced `S3JceksExtension` and `KafkaAvroSeedExtension` for declarative test configurations. - Updated Spark example to leverage JSON-configured `extensions` for HDFS and Kafka integration. - Enhanced JUnit support with `BigDataExtensions` annotation for extension configuration. - Improved modularity and reduced test boilerplate by centralizing setup logic.
1 parent 921574b commit 8153ba5

22 files changed

Lines changed: 494 additions & 84 deletions

README.md

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Composable Testcontainers-based fixtures for local big-data integration tests.
66

77
- `core`: container builder and endpoint/property model
88
- `junit5`: `@BigDataTest` extension for JUnit 5 tests
9-
- `addons`: optional Hadoop, Kafka, and Avro helpers for integration-test setup
9+
- `extensions`: config-driven extension module for Hadoop credential providers, Kafka, Avro, and future test setup hooks
1010
- `bigdata-test-spring-boot-autoconfigure`: Spring Boot auto-configuration
1111
- `bigdata-test-spring-boot-starter`: starter that brings in the auto-configuration
1212
- `example:spring`: Spring Boot local-development example
@@ -47,36 +47,60 @@ val kit = BigDataTestKit.builder()
4747
.build()
4848
```
4949

50-
## Addons
50+
## Extensions
5151

52-
`core` and `junit5` intentionally stay clean of Hadoop, Spark, and storage-client dependencies. Put optional test setup helpers behind `addons` when they need heavier runtime libraries. The current addon module includes:
52+
`core` and `junit5` intentionally stay clean of Hadoop, Spark, and storage-client dependencies. Optional heavier setup lives in `extensions`, which runs config-driven hooks against a started `BigDataTestKit`.
5353

54-
- `HadoopCredentialProviders`: creates HDFS-backed JCEKS files, useful for S3A credentials such as `fs.s3a.access.key` and `fs.s3a.secret.key`.
55-
- `KafkaAvroProducers`: creates a topic when needed and produces Avro records through Schema Registry.
54+
The built-in extensions currently support:
55+
56+
- `s3Jceks`: creates an HDFS-backed JCEKS file from the LocalStack S3 endpoint credentials and exposes `s3-jceks.credential-provider.path`.
57+
- `kafkaAvro`: creates Kafka topics and produces Avro records through Schema Registry from inline JSON records or a records resource.
58+
59+
JUnit usage is declaration-driven. The test declares the services with `@BigDataTest`, then points `@BigDataExtensions` at one or more JSON resources:
5660

5761
```kotlin
58-
val configDir = "/bigdata-test/spark/$runId"
59-
val providerPath = HadoopCredentialProviders.hdfsJceksPath(configDir, "s3.jceks")
60-
61-
HadoopCredentialProviders.createHdfsJceks(
62-
hdfsUri = hdfs.property("fs.defaultFS"),
63-
configDir = configDir,
64-
providerPath = providerPath,
65-
credentials = mapOf(
66-
"fs.s3a.access.key" to s3.property("aws.accessKeyId"),
67-
"fs.s3a.secret.key" to s3.property("aws.secretAccessKey"),
68-
),
62+
@BigDataExtensions("classpath:bigdata-extensions.json")
63+
@BigDataTest(
64+
hdfs = true,
65+
kafka = true,
66+
schemaRegistry = true,
67+
localStackS3 = true,
6968
)
69+
class MyIntegrationTest {
70+
@Test
71+
fun test(kit: BigDataTestKit, extensions: BigDataExtensionResult) {
72+
val providerPath = extensions.required("s3-jceks.credential-provider.path")
73+
}
74+
}
75+
```
7076

71-
KafkaAvroProducers.produce(
72-
bootstrapServers = kafka.property("bootstrap.servers"),
73-
schemaRegistryUrl = schemaRegistry.property("schema.registry.url"),
74-
topic = "events",
75-
schemaJson = schemaJson,
76-
records = listOf(AvroKafkaRecord("key-1", mapOf("id" to 1))),
77-
)
77+
Example config:
78+
79+
```json
80+
{
81+
"s3Jceks": {
82+
"enabled": true,
83+
"hdfsDir": "/bigdata-test/spark",
84+
"fileName": "s3.jceks"
85+
},
86+
"kafkaAvro": {
87+
"enabled": true,
88+
"topics": [
89+
{
90+
"name": "events",
91+
"schema": "classpath:schemas/event.avsc",
92+
"records": [
93+
{"key": "alpha", "value": {"id": 1, "name": "alpha"}},
94+
{"key": "beta", "value": {"id": 2, "name": "beta"}}
95+
]
96+
}
97+
]
98+
}
99+
}
78100
```
79101

102+
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`.
103+
80104
## JUnit 5
81105

82106
```kotlin
@@ -179,7 +203,7 @@ GRADLE_USER_HOME=/data/.gradle ./gradlew :example:spring:bootRun --args='--sprin
179203

180204
The JUnit examples in `example/junit` are annotated with `@Disabled`; remove that annotation from an example class to start the configured stack.
181205

182-
The Spark example in `example/spark` creates a `SparkSession` from `BigDataTestKit` endpoints, starts HDFS, Hive Metastore, Kafka, Schema Registry, LocalStack S3, and fake GCS, then uses `addons` to create the S3 JCEKS file on HDFS and produce Avro records to Kafka. HDFS is used as the config store for the JCEKS file; S3 and GCS are the object stores used by the Iceberg smoke checks.
206+
The Spark example in `example/spark` creates a `SparkSession` from `BigDataTestKit` endpoints, starts HDFS, Hive Metastore, Kafka, Schema Registry, LocalStack S3, and fake GCS, then uses `extensions` config to create the S3 JCEKS file on HDFS and produce Avro records to Kafka. HDFS is used as the config store for the JCEKS file; S3 and GCS are the object stores used by the Iceberg smoke checks.
183207

184208
```bash
185209
GRADLE_USER_HOME=/data/.gradle ./gradlew :example:spark:test

example/spark/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ configurations.all {
2121

2222
dependencies {
2323
testImplementation(project(":junit5"))
24-
testImplementation(project(":addons"))
24+
testImplementation(project(":extensions"))
2525
testImplementation(libs.sparkSql)
2626
testImplementation(libs.sparkHive)
2727
testImplementation(libs.sparkSqlKafka)

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

Lines changed: 12 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@ package org.openprojectx.bigdata.test.example.spark
22

33
import org.apache.spark.sql.SparkSession
44
import org.junit.jupiter.api.Test
5-
import org.openprojectx.bigdata.test.addons.hadoop.HadoopCredentialProviders
6-
import org.openprojectx.bigdata.test.addons.kafka.AvroKafkaRecord
7-
import org.openprojectx.bigdata.test.addons.kafka.KafkaAvroProducers
85
import org.openprojectx.bigdata.test.core.BigDataService
96
import org.openprojectx.bigdata.test.core.BigDataTestKit
107
import org.openprojectx.bigdata.test.core.ContainerLogMode
8+
import org.openprojectx.bigdata.test.extensions.core.BigDataExtensionResult
9+
import org.openprojectx.bigdata.test.extensions.junit5.BigDataExtensions
1110
import org.openprojectx.bigdata.test.junit5.BigDataTest
1211
import java.net.URI
1312
import java.net.http.HttpClient
@@ -17,6 +16,7 @@ import java.nio.charset.StandardCharsets
1716
import java.nio.file.Files
1817
import java.time.Duration
1918

19+
@BigDataExtensions("classpath:spark-bigdata-extensions.json")
2020
@BigDataTest(
2121
hdfs = true,
2222
hiveMetastore = true,
@@ -28,7 +28,7 @@ import java.time.Duration
2828
)
2929
class SparkBigDataTestExample {
3030
@Test
31-
fun writesAvroKafkaEventsToIcebergOnObjectStorage(kit: BigDataTestKit) {
31+
fun writesAvroKafkaEventsToIcebergOnObjectStorage(kit: BigDataTestKit, extensions: BigDataExtensionResult) {
3232
val hdfs = kit.endpoint(BigDataService.HDFS)
3333
val hiveMetastore = kit.endpoint(BigDataService.HIVE_METASTORE)
3434
val kafka = kit.endpoint(BigDataService.KAFKA)
@@ -37,27 +37,13 @@ class SparkBigDataTestExample {
3737
val gcs = kit.endpoint(BigDataService.FAKE_GCS)
3838

3939
val runId = System.nanoTime().toString()
40-
val topic = "spark-avro-events-$runId"
40+
val topic = "spark-avro-events"
4141
val s3Bucket = "spark-iceberg-s3-$runId"
4242
val gcsBucket = "spark-iceberg-gcs-$runId"
43-
val hdfsConfigDir = "/bigdata-test/spark/$runId"
44-
val s3CredentialProviderPath = HadoopCredentialProviders.hdfsJceksPath(hdfsConfigDir, "s3.jceks")
43+
val s3CredentialProviderPath = extensions.required("s3-jceks.credential-provider.path")
44+
val s3CredentialProviderHdfsPath = extensions.required("s3-jceks.hdfs.path")
4545
createS3Bucket(s3.property("aws.endpoint-url.s3"), s3Bucket)
4646
createGcsBucket(gcs.property("google.cloud.storage.host"), gcsBucket)
47-
HadoopCredentialProviders.createHdfsJceks(
48-
hdfsUri = hdfs.property("fs.defaultFS"),
49-
configDir = hdfsConfigDir,
50-
providerPath = s3CredentialProviderPath,
51-
credentials = mapOf(
52-
"fs.s3a.access.key" to s3.property("aws.accessKeyId"),
53-
"fs.s3a.secret.key" to s3.property("aws.secretAccessKey"),
54-
),
55-
)
56-
produceAvroEvents(
57-
bootstrapServers = kafka.property("bootstrap.servers"),
58-
schemaRegistryUrl = schemaRegistry.property("schema.registry.url"),
59-
topic = topic,
60-
)
6147

6248
sparkSession(
6349
hdfsUri = hdfs.property("fs.defaultFS"),
@@ -68,7 +54,7 @@ class SparkBigDataTestExample {
6854
gcsEndpoint = gcs.property("google.cloud.storage.host"),
6955
gcsBucket = gcsBucket,
7056
).use { spark ->
71-
assertHdfsConfigStore(spark, hdfs.property("fs.defaultFS"), hdfsConfigDir)
57+
assertHdfsConfigStore(spark, hdfs.property("fs.defaultFS"), s3CredentialProviderHdfsPath)
7258
assertKafkaAvroInput(spark, kafka.property("bootstrap.servers"), topic)
7359
assertIcebergTable(spark, catalog = "s3", namespace = "demo_$runId", table = "events_s3", storageName = "s3")
7460
assertIcebergTable(
@@ -126,10 +112,10 @@ class SparkBigDataTestExample {
126112
.enableHiveSupport()
127113
.getOrCreate()
128114

129-
private fun assertHdfsConfigStore(spark: SparkSession, hdfsUri: String, configDir: String) {
130-
check(HadoopCredentialProviders.exists(hdfsUri, "$configDir/s3.jceks")) {
131-
"Expected S3 JCEKS file in HDFS for ${spark.sparkContext().appName()}"
132-
}
115+
private fun assertHdfsConfigStore(spark: SparkSession, hdfsUri: String, hdfsPath: String) {
116+
val exists = org.apache.hadoop.fs.FileSystem.get(URI.create(hdfsUri), spark.sparkContext().hadoopConfiguration())
117+
.use { fs -> fs.exists(org.apache.hadoop.fs.Path(hdfsPath)) }
118+
check(exists) { "Expected S3 JCEKS file in HDFS for ${spark.sparkContext().appName()}" }
133119
}
134120

135121
private fun assertKafkaAvroInput(spark: SparkSession, bootstrapServers: String, topic: String) {
@@ -169,29 +155,6 @@ class SparkBigDataTestExample {
169155
}
170156

171157

172-
private fun produceAvroEvents(bootstrapServers: String, schemaRegistryUrl: String, topic: String) {
173-
KafkaAvroProducers.produce(
174-
bootstrapServers = bootstrapServers,
175-
schemaRegistryUrl = schemaRegistryUrl,
176-
topic = topic,
177-
schemaJson = """
178-
{
179-
"type": "record",
180-
"name": "SparkEvent",
181-
"namespace": "org.openprojectx.bigdata.test.example.spark",
182-
"fields": [
183-
{"name": "id", "type": "int"},
184-
{"name": "name", "type": "string"}
185-
]
186-
}
187-
""".trimIndent(),
188-
records = listOf(
189-
AvroKafkaRecord("alpha", mapOf("id" to 1, "name" to "alpha")),
190-
AvroKafkaRecord("beta", mapOf("id" to 2, "name" to "beta")),
191-
),
192-
)
193-
}
194-
195158
private fun createS3Bucket(endpoint: String, bucket: String) {
196159
val request = HttpRequest.newBuilder(URI.create("$endpoint/$bucket"))
197160
.timeout(Duration.ofSeconds(10))
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"type": "record",
3+
"name": "SparkEvent",
4+
"namespace": "org.openprojectx.bigdata.test.example.spark",
5+
"fields": [
6+
{"name": "id", "type": "int"},
7+
{"name": "name", "type": "string"}
8+
]
9+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"s3Jceks": {
3+
"enabled": true,
4+
"hdfsDir": "/bigdata-test/spark",
5+
"fileName": "s3.jceks"
6+
},
7+
"kafkaAvro": {
8+
"enabled": true,
9+
"topics": [
10+
{
11+
"name": "spark-avro-events",
12+
"schema": "classpath:schemas/spark-event.avsc",
13+
"records": [
14+
{"key": "alpha", "value": {"id": 1, "name": "alpha"}},
15+
{"key": "beta", "value": {"id": 2, "name": "beta"}}
16+
]
17+
}
18+
]
19+
}
20+
}
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
plugins {
22
id("buildsrc.convention.kotlin-jvm")
3+
alias(libs.plugins.kotlinPluginSerialization)
34
}
45

5-
description = "Optional bigdata-test helpers for Hadoop credential providers, Kafka, and Avro"
6+
description = "Config-driven bigdata-test extensions for Hadoop credential providers, Kafka, and Avro"
67

78
dependencies {
9+
api(project(":junit5"))
810
api(libs.hadoopClientApi)
911
api(libs.hadoopClientRuntime)
1012
api(libs.kafkaAvroSerializer)
1113
api(libs.avro)
14+
api(libs.kotlinxSerialization)
1215

1316
implementation(libs.kafkaSchemaRegistryClient)
1417
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package org.openprojectx.bigdata.test.extensions.config
2+
3+
import kotlinx.serialization.json.Json
4+
import kotlinx.serialization.json.JsonArray
5+
import kotlinx.serialization.json.JsonObject
6+
import kotlinx.serialization.json.JsonPrimitive
7+
import kotlinx.serialization.json.booleanOrNull
8+
import kotlinx.serialization.json.contentOrNull
9+
import kotlinx.serialization.json.intOrNull
10+
import kotlinx.serialization.json.jsonArray
11+
import kotlinx.serialization.json.jsonObject
12+
import kotlinx.serialization.json.jsonPrimitive
13+
import org.openprojectx.bigdata.test.extensions.core.BigDataExtension
14+
import org.openprojectx.bigdata.test.extensions.core.BigDataExtensionProvider
15+
import org.openprojectx.bigdata.test.extensions.core.BigDataExtensionResourceLoader
16+
import org.openprojectx.bigdata.test.extensions.hadoop.S3JceksExtension
17+
import org.openprojectx.bigdata.test.extensions.kafka.KafkaAvroRecordSeed
18+
import org.openprojectx.bigdata.test.extensions.kafka.KafkaAvroSeedExtension
19+
import org.openprojectx.bigdata.test.extensions.kafka.KafkaAvroTopicSeed
20+
import java.util.ServiceLoader
21+
22+
class BigDataExtensionsConfigLoader(
23+
private val resources: BigDataExtensionResourceLoader = BigDataExtensionResourceLoader(),
24+
providers: Iterable<BigDataExtensionProvider> = ServiceLoader.load(BigDataExtensionProvider::class.java),
25+
) {
26+
private val json = Json { ignoreUnknownKeys = true }
27+
private val providers = (builtInProviders + providers).associateBy { it.type }
28+
29+
fun load(locations: Iterable<String>): List<BigDataExtension> =
30+
locations.flatMap { load(it) }
31+
32+
fun load(location: String): List<BigDataExtension> {
33+
val root = json.parseToJsonElement(resources.readText(location)).jsonObject
34+
val extensions = mutableListOf<BigDataExtension>()
35+
root["s3Jceks"]?.jsonObject?.let { config ->
36+
if (config.boolean("enabled", default = true)) {
37+
extensions += S3JceksExtension(
38+
id = config.string("id", "s3-jceks"),
39+
hdfsDir = config.string("hdfsDir", "/bigdata-test/config"),
40+
fileName = config.string("fileName", "s3.jceks"),
41+
accessKeyAlias = config.string("accessKeyAlias", "fs.s3a.access.key"),
42+
secretKeyAlias = config.string("secretKeyAlias", "fs.s3a.secret.key"),
43+
)
44+
}
45+
}
46+
root["kafkaAvro"]?.jsonObject?.let { config ->
47+
if (config.boolean("enabled", default = true)) {
48+
extensions += KafkaAvroSeedExtension(
49+
id = config.string("id", "kafka-avro-seed"),
50+
topics = config["topics"]?.jsonArray?.map { it.jsonObject.toKafkaAvroTopic() }.orEmpty(),
51+
)
52+
}
53+
}
54+
root["extensions"]?.jsonArray?.forEach { item ->
55+
val config = item.jsonObject
56+
val type = config.string("type")
57+
val provider = providers[type] ?: error("Unknown bigdata-test extension type '$type'")
58+
extensions += provider.create(config, resources)
59+
}
60+
return extensions
61+
}
62+
63+
private fun JsonObject.toKafkaAvroTopic(): KafkaAvroTopicSeed {
64+
val inlineRecords = this["records"] as? JsonArray
65+
val resourceRecords = this["recordsResource"]?.jsonPrimitive?.contentOrNull
66+
?.let { json.parseToJsonElement(resources.readText(it)).jsonArray }
67+
val records = (inlineRecords ?: resourceRecords ?: JsonArray(emptyList()))
68+
.map { record ->
69+
val obj = record.jsonObject
70+
KafkaAvroRecordSeed(
71+
key = obj.string("key"),
72+
value = obj["value"]?.jsonObject ?: error("Kafka Avro record requires object field 'value'"),
73+
)
74+
}
75+
return KafkaAvroTopicSeed(
76+
name = string("name"),
77+
schema = string("schema"),
78+
records = records,
79+
partitions = int("partitions", 1),
80+
replicationFactor = int("replicationFactor", 1).toShort(),
81+
)
82+
}
83+
84+
private fun JsonObject.string(name: String): String =
85+
this[name]?.jsonPrimitive?.contentOrNull ?: error("Missing required extension config field '$name'")
86+
87+
private fun JsonObject.string(name: String, default: String): String =
88+
this[name]?.jsonPrimitive?.contentOrNull ?: default
89+
90+
private fun JsonObject.int(name: String, default: Int): Int =
91+
this[name]?.jsonPrimitive?.intOrNull ?: default
92+
93+
private fun JsonObject.boolean(name: String, default: Boolean): Boolean =
94+
this[name]?.jsonPrimitive?.booleanOrNull ?: default
95+
96+
private companion object {
97+
val builtInProviders = listOf<BigDataExtensionProvider>()
98+
}
99+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package org.openprojectx.bigdata.test.extensions.core
2+
3+
import org.openprojectx.bigdata.test.core.BigDataService
4+
5+
interface BigDataExtension {
6+
val id: String
7+
val requiredServices: Set<BigDataService> get() = emptySet()
8+
val events: Set<BigDataExtensionEvent> get() = setOf(BigDataExtensionEvent.AFTER_KIT_START)
9+
10+
fun onEvent(event: BigDataExtensionEvent, context: BigDataExtensionContext) {
11+
}
12+
}

0 commit comments

Comments
 (0)