diff --git a/build-plugins/build-support/build.gradle.kts b/build-plugins/build-support/build.gradle.kts index 2c8de49..9e4b274 100644 --- a/build-plugins/build-support/build.gradle.kts +++ b/build-plugins/build-support/build.gradle.kts @@ -23,6 +23,9 @@ dependencies { compileOnly(gradleApi()) implementation(libs.aws.sdk.s3) implementation(libs.aws.sdk.cloudwatch) + implementation(libs.okhttp) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) testImplementation(kotlin("test")) testImplementation(libs.kotlinx.coroutines.test) } diff --git a/build-plugins/build-support/src/main/kotlin/aws/sdk/kotlin/gradle/dsl/Publish.kt b/build-plugins/build-support/src/main/kotlin/aws/sdk/kotlin/gradle/dsl/Publish.kt index ec6cc14..7dbeb57 100644 --- a/build-plugins/build-support/src/main/kotlin/aws/sdk/kotlin/gradle/dsl/Publish.kt +++ b/build-plugins/build-support/src/main/kotlin/aws/sdk/kotlin/gradle/dsl/Publish.kt @@ -19,6 +19,8 @@ import org.jreleaser.gradle.plugin.JReleaserExtension import org.jreleaser.model.Active import java.time.Duration +// FIXME Relocate this file to `aws.sdk.kotlin.gradle.publishing` + private object Properties { const val SKIP_PUBLISHING = "skipPublish" } diff --git a/build-plugins/build-support/src/main/kotlin/aws/sdk/kotlin/gradle/publishing/SonatypeCentralPortalClient.kt b/build-plugins/build-support/src/main/kotlin/aws/sdk/kotlin/gradle/publishing/SonatypeCentralPortalClient.kt new file mode 100644 index 0000000..92fe635 --- /dev/null +++ b/build-plugins/build-support/src/main/kotlin/aws/sdk/kotlin/gradle/publishing/SonatypeCentralPortalClient.kt @@ -0,0 +1,153 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package aws.sdk.kotlin.gradle.publishing + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.* +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File +import java.time.Duration +import java.util.Base64 +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +/** + * A client used for interacting with the Sonatype Publish Portal API + * https://central.sonatype.org/publish/publish-portal-api/ + */ +class SonatypeCentralPortalClient( + private val authHeader: String, + private val client: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(Duration.ofSeconds(30)) + .readTimeout(Duration.ofSeconds(60)) + .writeTimeout(Duration.ofSeconds(60)) + .retryOnConnectionFailure(true) + .build(), + private val json: Json = Json { + ignoreUnknownKeys = true + prettyPrint = true + }, +) { + companion object { + const val CENTRAL_PORTAL_USERNAME = "SONATYPE_CENTRAL_PORTAL_USERNAME" + const val CENTRAL_PORTAL_PASSWORD = "SONATYPE_CENTRAL_PORTAL_PASSWORD" + const val CENTRAL_PORTAL_BASE_URL = "https://central.sonatype.com" + + fun buildAuthHeader(user: String, password: String): String { + val b64 = Base64.getEncoder().encodeToString("$user:$password".toByteArray(Charsets.UTF_8)) + return "Bearer $b64" + } + + fun fromEnvironment(): SonatypeCentralPortalClient { + val user = System.getenv(CENTRAL_PORTAL_USERNAME)?.takeIf { it.isNotBlank() } ?: error("$CENTRAL_PORTAL_USERNAME not configured") + val pass = System.getenv(CENTRAL_PORTAL_PASSWORD)?.takeIf { it.isNotBlank() } ?: error("$CENTRAL_PORTAL_PASSWORD not configured") + return SonatypeCentralPortalClient(buildAuthHeader(user, pass)) + } + } + + private val apiBase = CENTRAL_PORTAL_BASE_URL.toHttpUrl() + + @Serializable + data class StatusResponse( + val deploymentId: String, + val deploymentName: String? = null, + val deploymentState: String, + val purls: List? = null, + val errors: Map>? = null, + ) + + /** Uploads a bundle and returns deploymentId. */ + fun uploadBundle(bundle: File, deploymentName: String): String { + require(bundle.isFile && bundle.length() > 0L) { "Bundle does not exist or is empty: $bundle" } + + val url = apiBase.newBuilder() + .addPathSegments("api/v1/publisher/upload") + .addQueryParameter("name", deploymentName) + .addQueryParameter("publishingType", "AUTOMATIC") // set USER_MANAGED to upload the deployment, but not release it + .build() + + val body = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + "bundle", + bundle.name, + bundle.asRequestBody("application/octet-stream".toMediaType()), + ) + .build() + + val request = Request.Builder() + .url(url) + .header("Authorization", authHeader) + .post(body) + .build() + + client.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) throw httpError("upload", resp) + val id = resp.body?.string()?.trim().orEmpty() + if (resp.code != 201 || id.isEmpty()) { + throw RuntimeException("Upload returned ${resp.code} but no deploymentId body; body=$id") + } + return id + } + } + + /** Returns current deployment status. */ + fun getStatus(deploymentId: String): StatusResponse { + val url = apiBase.newBuilder() + .addPathSegments("api/v1/publisher/status") + .addQueryParameter("id", deploymentId) + .build() + + val request = Request.Builder() + .url(url) + .header("Authorization", authHeader) + .post("".toRequestBody(null)) + .build() + + client.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) throw httpError("status", resp) + val payload = resp.body?.string().orEmpty() + return try { + json.decodeFromString(payload) + } catch (e: Exception) { + throw RuntimeException("Failed to parse status JSON (HTTP ${resp.code}): $payload", e) + } + } + } + + /** Polls until one of [terminalStates] is reached, returning the final StatusResponse. */ + @OptIn(ExperimentalTime::class) // for Clock.System.now() + fun waitForStatus( + deploymentId: String, + terminalStates: Set, + pollInterval: kotlin.time.Duration, + timeout: kotlin.time.Duration, + onStateChange: (old: String?, new: String) -> Unit = { _, _ -> }, + ): StatusResponse { + val deadline = Clock.System.now() + timeout + var lastState: String? = null + + while (Clock.System.now() < deadline) { + val status = getStatus(deploymentId) + if (status.deploymentState != lastState) { + onStateChange(lastState, status.deploymentState) + lastState = status.deploymentState + } + if (status.deploymentState in terminalStates) return status + Thread.sleep(pollInterval.inWholeMilliseconds) + } + throw RuntimeException("Timed out waiting for deployment $deploymentId to reach one of $terminalStates") + } + + private fun httpError(context: String, resp: Response): RuntimeException { + val body = resp.body?.string().orEmpty() + return RuntimeException("HTTP error during $context: ${resp.code}.\nResponse: $body") + } +} diff --git a/build-plugins/build-support/src/main/kotlin/aws/sdk/kotlin/gradle/publishing/SonatypeCentralPortalPublishTask.kt b/build-plugins/build-support/src/main/kotlin/aws/sdk/kotlin/gradle/publishing/SonatypeCentralPortalPublishTask.kt new file mode 100644 index 0000000..8ea1c7d --- /dev/null +++ b/build-plugins/build-support/src/main/kotlin/aws/sdk/kotlin/gradle/publishing/SonatypeCentralPortalPublishTask.kt @@ -0,0 +1,70 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package aws.sdk.kotlin.gradle.publishing + +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.* +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * Publishes a bundle to Sonatype Portal and waits for it to be validated (but not fully published). + * States: PENDING, VALIDATING, VALIDATED, FAILED + * https://central.sonatype.org/publish/publish-portal-api/ + */ +abstract class SonatypeCentralPortalPublishTask : DefaultTask() { + @get:InputFile + abstract val bundle: RegularFileProperty + + /** Max time to wait for final state. */ + @get:Input + @get:Optional + abstract val timeoutDuration: Property + + /** Poll interval. */ + @get:Input + @get:Optional + abstract val pollInterval: Property + + init { + timeoutDuration.convention(45.minutes) + pollInterval.convention(15.seconds) + } + + @TaskAction + fun run() { + val client = SonatypeCentralPortalClient.fromEnvironment() + val file = bundle.asFile.get() + + // 1) Upload + val deploymentId = client.uploadBundle(file, file.name) + logger.lifecycle("📤 Uploaded bundle; deploymentId=$deploymentId") + + // 2) Wait for VALIDATED or FAILED + val result = client.waitForStatus( + deploymentId = deploymentId, + terminalStates = setOf("VALIDATED", "FAILED"), + pollInterval = pollInterval.get(), + timeout = timeoutDuration.get(), + ) { _, new -> + logger.lifecycle("📡 Status: $new (deploymentId=$deploymentId)") + } + + // 3) Evaluate + when (result.deploymentState) { + "VALIDATED" -> logger.lifecycle("✅ Bundle validated by Maven Central") + "FAILED" -> { + val reasons = result.errors?.values?.joinToString("\n- ", prefix = "\n- ") + ?: "\n(no error details returned)" + throw RuntimeException("❌ Sonatype deployment FAILED for ${result.deploymentId}$reasons") + } + else -> error("Unexpected terminal state: ${result.deploymentState}") + } + } +} diff --git a/build-plugins/build-support/src/main/kotlin/aws/sdk/kotlin/gradle/publishing/SonatypeCentralPortalWaitForPublicationTask.kt b/build-plugins/build-support/src/main/kotlin/aws/sdk/kotlin/gradle/publishing/SonatypeCentralPortalWaitForPublicationTask.kt new file mode 100644 index 0000000..5089f1c --- /dev/null +++ b/build-plugins/build-support/src/main/kotlin/aws/sdk/kotlin/gradle/publishing/SonatypeCentralPortalWaitForPublicationTask.kt @@ -0,0 +1,71 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package aws.sdk.kotlin.gradle.publishing + +import org.gradle.api.DefaultTask +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.options.Option +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * Waits for a given deploymentId to enter the PUBLISHED state + * https://central.sonatype.org/publish/publish-portal-api/ + */ +abstract class SonatypeCentralPortalWaitForPublicationTask : DefaultTask() { + @get:Input + abstract val deploymentId: Property + + @Option(option = "deploymentId", description = "Deployment ID to wait for") + fun setDeploymentId(id: String) { + deploymentId.set(id) + } + + /** Max time to wait for final state. */ + @get:Input + @get:Optional + abstract val timeoutDuration: Property + + /** Poll interval. */ + @get:Input + @get:Optional + abstract val pollInterval: Property + + init { + timeoutDuration.convention(90.minutes) + pollInterval.convention(30.seconds) + } + + @TaskAction + fun run() { + val client = SonatypeCentralPortalClient.fromEnvironment() + val deploymentId = deploymentId.get().takeIf { it.isNotBlank() } ?: error("deploymentId not configured") + + val result = client.waitForStatus( + deploymentId, + setOf("PUBLISHED", "FAILED"), + pollInterval.get(), + timeoutDuration.get(), + ) { _, newState -> + logger.lifecycle("📡 Status: $newState (deploymentId=$deploymentId)") + } + + when (result.deploymentState) { + "PUBLISHED" -> { + logger.lifecycle("🚀 Deployment PUBLISHED (deploymentId=$deploymentId)") + } + "FAILED" -> { + val reasons = result.errors?.values?.joinToString("\n- ", prefix = "\n- ") ?: "\n(no error details returned)" + throw RuntimeException("❌ Sonatype publication FAILED for $deploymentId$reasons") + } + else -> error("Unexpected terminal state: ${result.deploymentState}") + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c447d2b..8259583 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,8 @@ smithy-gradle-plugin-version = "1.3.0" junit-version = "5.10.1" coroutines-version = "1.10.2" slf4j-version = "2.0.17" +okhttp-version = "5.1.0" +kotlinx-serialization-version = "1.9.0" [libraries] aws-sdk-cloudwatch = { module = "aws.sdk.kotlin:cloudwatch", version.ref = "aws-sdk-version" } @@ -22,8 +24,11 @@ jreleaser-plugin = { module = "org.jreleaser:jreleaser-gradle-plugin", version.r smithy-model = { module = "software.amazon.smithy:smithy-model", version.ref = "smithy-version" } smithy-gradle-base-plugin = { module = "software.amazon.smithy.gradle:smithy-base", version.ref = "smithy-gradle-plugin-version" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-version" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines-version" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines-version" } slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j-version" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp-version" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-version" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-version" }