import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property import org.gradle.api.provider.ListProperty import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.Sync import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.bundling.Zip import groovy.json.JsonSlurper import java.security.MessageDigest import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec plugins { id("strataproxy.java-library-conventions") apply false id("strataproxy.application-conventions") apply false } allprojects { group = "dev.strataproxy" version = "0.1.0-SNAPSHOT" } tasks.register("check") { group = "verification" description = "Runs root-level StrataProxy verification tasks." } gradle.projectsEvaluated { val releaseName = "strataproxy-${project.version}" val releaseStaging = layout.buildDirectory.dir("release/staging/$releaseName") val releaseZip = layout.buildDirectory.file("release/$releaseName.zip") val releaseChecksum = layout.buildDirectory.file("release/$releaseName.zip.sha256") val releaseSbom = layout.buildDirectory.file("release/$releaseName.sbom.cdx.json") val releaseMetadata = layout.buildDirectory.file("release/$releaseName.metadata.json") val app = project(":proxy-app") val admin = project(":proxy-admin-cli") val query = project(":proxy-query") val appDistZip = app.layout.buildDirectory.file("distributions/strataproxy-${project.version}.zip") val adminDistZip = admin.layout.buildDirectory.file("distributions/strataproxy-admin-${project.version}.zip") val queryDistZip = query.layout.buildDirectory.file("distributions/strataproxy-query-${project.version}.zip") val writeReleaseManifest = tasks.register("writeReleaseManifest") { group = "distribution" description = "Writes the StrataProxy release manifest." dependsOn( app.tasks.named("distZip"), admin.tasks.named("distZip"), query.tasks.named("distZip") ) outputFile.set(layout.buildDirectory.file("release/RELEASE-MANIFEST.txt")) nameValue.set(releaseName) versionValue.set(project.version.toString()) appArchive.set(appDistZip.map { it.asFile.name }) adminArchive.set(adminDistZip.map { it.asFile.name }) queryArchive.set(queryDistZip.map { it.asFile.name }) } val generateReleaseSbom = tasks.register("generateReleaseSbom") { group = "distribution" description = "Generates a CycloneDX-style SBOM for StrataProxy runtime artifacts." outputFile.set(releaseSbom) bomVersion.set(project.version.toString()) components.set(runtimeComponents(app, admin, query)) } val writeReleaseMetadata = tasks.register("writeReleaseMetadata") { group = "distribution" description = "Writes release metadata with checksums and optional HMAC signature." dependsOn( app.tasks.named("distZip"), admin.tasks.named("distZip"), query.tasks.named("distZip"), generateReleaseSbom ) outputFile.set(releaseMetadata) releaseNameValue.set(releaseName) versionValue.set(project.version.toString()) signingKey.set(providers.gradleProperty("strataproxy.releaseSigningKey") .orElse(providers.environmentVariable("STRATAPROXY_RELEASE_SIGNING_KEY")) .orElse("")) artifactFiles.from(appDistZip, adminDistZip, queryDistZip, releaseSbom) } val stageRelease = tasks.register("stageRelease") { group = "distribution" description = "Stages all StrataProxy release artifacts." dependsOn( app.tasks.named("distZip"), admin.tasks.named("distZip"), query.tasks.named("distZip"), writeReleaseManifest, generateReleaseSbom, writeReleaseMetadata ) into(releaseStaging) from("README.md") from("deployment") { into("deployment") } from("proxy-app/src/main/resources/config") { into("config") } from(layout.buildDirectory.file("release/RELEASE-MANIFEST.txt")) from(releaseSbom) from(releaseMetadata) from(appDistZip) { into("archives") } from(adminDistZip) { into("archives") } from(queryDistZip) { into("archives") } } val releaseBundle = tasks.register("releaseBundle") { group = "distribution" description = "Builds a distributable StrataProxy release bundle." dependsOn(stageRelease) archiveFileName.set("$releaseName.zip") destinationDirectory.set(layout.buildDirectory.dir("release")) from(releaseStaging) } val releaseChecksums = tasks.register("releaseChecksums") { group = "distribution" description = "Writes SHA-256 checksums for StrataProxy release artifacts." dependsOn(releaseBundle) inputFiles.from(releaseZip, appDistZip, adminDistZip, queryDistZip, releaseSbom, releaseMetadata) outputFile.set(releaseChecksum) } val releaseAuditSmokeTest = tasks.register("releaseAuditSmokeTest") { group = "verification" description = "Validates release metadata, SBOM, and checksum artifacts." dependsOn(releaseChecksums) metadataFile.set(releaseMetadata) sbomFile.set(releaseSbom) checksumFile.set(releaseChecksum) } val performanceProfilesSmokeTest = tasks.register("performanceProfilesSmokeTest") { group = "verification" description = "Validates packaged StrataProxy performance profile definitions." profileFiles.from(fileTree("deployment/performance") { include("profiles/*.json") include("profile-result-template.json") }) } tasks.register("release") { group = "distribution" description = "Builds the release bundle and SHA-256 checksum." dependsOn(releaseAuditSmokeTest) } tasks.named("check") { dependsOn(releaseAuditSmokeTest) dependsOn(performanceProfilesSmokeTest) } } fun runtimeComponents(vararg projects: Project): List { return projects .flatMap { project -> project.configurations.getByName("runtimeClasspath") .resolvedConfiguration .resolvedArtifacts .map { artifact -> val id = artifact.moduleVersion.id "${id.group}:${id.name}:${id.version}:${artifact.type}:${artifact.file.name}" } } .distinct() .sorted() } abstract class ReleaseManifestTask : DefaultTask() { @get:OutputFile abstract val outputFile: RegularFileProperty @get:Input abstract val nameValue: Property @get:Input abstract val versionValue: Property @get:Input abstract val appArchive: Property @get:Input abstract val adminArchive: Property @get:Input abstract val queryArchive: Property @TaskAction fun writeManifest() { val file = outputFile.get().asFile file.parentFile.mkdirs() file.writeText( """ name=${nameValue.get()} version=${versionValue.get()} java=25 appArchive=${appArchive.get()} adminArchive=${adminArchive.get()} queryArchive=${queryArchive.get()} includes=deployment,configs,observability,checksums """.trimIndent() + System.lineSeparator() ) } } abstract class Sha256FilesTask : DefaultTask() { @get:InputFiles abstract val inputFiles: ConfigurableFileCollection @get:OutputFile abstract val outputFile: RegularFileProperty @TaskAction fun writeChecksums() { val digest = MessageDigest.getInstance("SHA-256") val lines = inputFiles.files .sortedBy { it.name } .map { archive -> digest.reset() archive.inputStream().use { input -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) while (true) { val read = input.read(buffer) if (read < 0) { break } digest.update(buffer, 0, read) } } val checksum = digest.digest().joinToString("") { "%02x".format(it) } "$checksum ${archivePathLabel(archive, outputFile.get().asFile.parentFile)}" } val checksumFile = outputFile.get().asFile checksumFile.parentFile.mkdirs() checksumFile.writeText(lines.joinToString(System.lineSeparator(), postfix = System.lineSeparator())) } private fun archivePathLabel(archive: File, checksumDirectory: File): String { return if (archive.parentFile == checksumDirectory) { archive.name } else { "archives/${archive.name}" } } } abstract class GenerateSbomTask : DefaultTask() { @get:OutputFile abstract val outputFile: RegularFileProperty @get:Input abstract val bomVersion: Property @get:Input abstract val components: ListProperty @TaskAction fun generate() { val file = outputFile.get().asFile file.parentFile.mkdirs() val componentJson = components.get().joinToString(",\n") { component -> val parts = component.split(':', limit = 5) val group = parts.getOrElse(0) { "" } val name = parts.getOrElse(1) { "" } val version = parts.getOrElse(2) { "" } val type = parts.getOrElse(3) { "jar" } val fileName = parts.getOrElse(4) { "$name-$version.$type" } """ { "type": "library", "group": "${json(group)}", "name": "${json(name)}", "version": "${json(version)}", "purl": "pkg:maven/${json(group)}/${json(name)}@${json(version)}", "properties": [ {"name": "strataproxy:artifactType", "value": "${json(type)}"}, {"name": "strataproxy:fileName", "value": "${json(fileName)}"} ] } """.trimIndent() } file.writeText( """ { "bomFormat": "CycloneDX", "specVersion": "1.5", "serialNumber": "urn:uuid:${stableUuid("strataproxy-${bomVersion.get()}-${components.get().joinToString("|")}")}", "version": 1, "metadata": { "component": { "type": "application", "group": "dev.strataproxy", "name": "strataproxy", "version": "${json(bomVersion.get())}" } }, "components": [ $componentJson ] } """.trimIndent() + System.lineSeparator() ) } private fun stableUuid(input: String): String { val hash = MessageDigest.getInstance("SHA-256").digest(input.toByteArray(Charsets.UTF_8)) hash[6] = ((hash[6].toInt() and 0x0f) or 0x50).toByte() hash[8] = ((hash[8].toInt() and 0x3f) or 0x80).toByte() val hex = hash.take(16).joinToString("") { "%02x".format(it) } return "${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20, 32)}" } private fun json(value: String): String { return value .replace("\\", "\\\\") .replace("\"", "\\\"") .replace("\n", "\\n") .replace("\r", "\\r") } } abstract class ReleaseMetadataTask : DefaultTask() { @get:OutputFile abstract val outputFile: RegularFileProperty @get:Input abstract val releaseNameValue: Property @get:Input abstract val versionValue: Property @get:Input abstract val signingKey: Property @get:InputFiles abstract val artifactFiles: ConfigurableFileCollection @TaskAction fun writeMetadata() { val artifacts = artifactFiles.files .sortedBy { it.name } .map { file -> ReleaseArtifact(artifactPathLabel(file, outputFile.get().asFile.parentFile), file.length(), sha256(file)) } val payload = artifacts.joinToString("|") { "${it.name}:${it.size}:${it.sha256}" } val key = signingKey.get() val signature = if (key.isBlank()) "" else hmacSha256(key, payload) val signatureMode = if (key.isBlank()) "unsigned" else "hmac-sha256" val artifactJson = artifacts.joinToString(",\n") { artifact -> """ { "name": "${json(artifact.name)}", "size": ${artifact.size}, "sha256": "${artifact.sha256}" } """.trimIndent() } val file = outputFile.get().asFile file.parentFile.mkdirs() file.writeText( """ { "name": "${json(releaseNameValue.get())}", "version": "${json(versionValue.get())}", "java": "25", "artifacts": [ $artifactJson ], "signature": { "mode": "$signatureMode", "payload": "${json(payload)}", "value": "$signature" } } """.trimIndent() + System.lineSeparator() ) } private data class ReleaseArtifact(val name: String, val size: Long, val sha256: String) private fun artifactPathLabel(artifact: File, metadataDirectory: File): String { return if (artifact.parentFile == metadataDirectory) { artifact.name } else { "archives/${artifact.name}" } } private fun sha256(file: File): String { val digest = MessageDigest.getInstance("SHA-256") file.inputStream().use { input -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) while (true) { val read = input.read(buffer) if (read < 0) { break } digest.update(buffer, 0, read) } } return digest.digest().joinToString("") { "%02x".format(it) } } private fun hmacSha256(key: String, payload: String): String { val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec(key.toByteArray(Charsets.UTF_8), "HmacSHA256")) return mac.doFinal(payload.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) } } private fun json(value: String): String { return value .replace("\\", "\\\\") .replace("\"", "\\\"") .replace("\n", "\\n") .replace("\r", "\\r") } } abstract class ReleaseAuditTask : DefaultTask() { @get:InputFile abstract val metadataFile: RegularFileProperty @get:InputFile abstract val sbomFile: RegularFileProperty @get:InputFile abstract val checksumFile: RegularFileProperty @TaskAction fun audit() { val metadata = metadataFile.get().asFile.readText() val sbom = sbomFile.get().asFile.readText() val checksums = checksumFile.get().asFile.readText() listOf( "\"name\": \"strataproxy-", "\"java\": \"25\"", "\"signature\":", "archives/strataproxy-admin-", "archives/strataproxy-query-" ).forEach { token -> require(metadata.contains(token)) { "release metadata missing $token" } } listOf( "\"bomFormat\": \"CycloneDX\"", "\"specVersion\": \"1.5\"", "\"components\": [", "pkg:maven/io.netty/netty-transport", "pkg:maven/com.fasterxml.jackson.core/jackson-databind", "pkg:maven/info.picocli/picocli" ).forEach { token -> require(sbom.contains(token)) { "release SBOM missing $token" } } listOf( "strataproxy-0.1.0-SNAPSHOT.zip", "strataproxy-0.1.0-SNAPSHOT.sbom.cdx.json", "strataproxy-0.1.0-SNAPSHOT.metadata.json", "archives/strataproxy-admin-0.1.0-SNAPSHOT.zip", "archives/strataproxy-query-0.1.0-SNAPSHOT.zip" ).forEach { token -> require(checksums.contains(token)) { "release checksums missing $token" } } } } abstract class PerformanceProfilesAuditTask : DefaultTask() { @get:InputFiles abstract val profileFiles: ConfigurableFileCollection @TaskAction fun audit() { val files = profileFiles.files.sortedBy { it.name } require(files.size >= 4) { "expected performance profiles and result template" } val parser = JsonSlurper() val profileIds = mutableSetOf() for (file in files) { val parsed = parser.parse(file) as Map<*, *> if (file.name == "profile-result-template.json") { require(parsed.containsKey("gateResults")) { "profile result template missing gateResults" } require(parsed.containsKey("observations")) { "profile result template missing observations" } continue } val id = parsed["id"] as? String require(!id.isNullOrBlank()) { "profile ${file.name} missing id" } require(profileIds.add(id)) { "duplicate performance profile id: $id" } require(parsed["java"] == "25") { "profile $id must target Java 25" } require(parsed.containsKey("commands")) { "profile $id missing commands" } require(parsed.containsKey("gates")) { "profile $id missing gates" } } require(profileIds.contains("smoke")) { "missing smoke performance profile" } require(profileIds.contains("acceptance-linux-native-java25")) { "missing Linux native Java 25 acceptance profile" } require(profileIds.contains("compression-rewrite-linux-native-java25")) { "missing compression rewrite performance profile" } } }