From b6983bb004c159dcc872ff8f82acd4959901f7e5 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 3 Aug 2026 15:21:53 +0200 Subject: [PATCH 1/2] fix: wait for deployment terminal status --- .../gg/grounds/push/tasks/GroundsPushTask.kt | 17 +++++++++++------ .../gg/grounds/push/GroundsPushPluginTest.kt | 5 +++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/plugin/src/main/kotlin/gg/grounds/push/tasks/GroundsPushTask.kt b/plugin/src/main/kotlin/gg/grounds/push/tasks/GroundsPushTask.kt index 1c07a95..45b6ebb 100644 --- a/plugin/src/main/kotlin/gg/grounds/push/tasks/GroundsPushTask.kt +++ b/plugin/src/main/kotlin/gg/grounds/push/tasks/GroundsPushTask.kt @@ -321,6 +321,7 @@ abstract class GroundsPushTask : DefaultTask() { logger.lifecycle("[grounds-push] Build log stream opened (pushId=$pushId, target=$target)") val done = CountDownLatch(1) val terminal = AtomicReference() + val builtImageTag = AtomicReference() val last20 = ArrayDeque(20) val listener = object : PushSseListener { @@ -331,8 +332,12 @@ abstract class GroundsPushTask : DefaultTask() { "${failureReason?.let { ", reason=$it" } ?: ""})" ) when (status) { - "build_succeeded" -> terminal.compareAndSet(null, TerminalState.Succeeded(imageTag)) - "build_failed" -> terminal.compareAndSet(null, TerminalState.Failed(failureReason ?: "unknown")) + "build_succeeded" -> builtImageTag.set(imageTag) + "ready" -> terminal.compareAndSet(null, TerminalState.Succeeded(builtImageTag.get())) + "build_failed", "deploy_failed" -> terminal.compareAndSet( + null, + TerminalState.Failed(failureReason ?: "unknown"), + ) } } override fun onLog(ts: String, line: String) { @@ -371,8 +376,8 @@ abstract class GroundsPushTask : DefaultTask() { try { val detail = client.getPush(pushId) return when (detail.status) { - "build_succeeded" -> TerminalState.Succeeded(detail.imageTag) - "build_failed" -> TerminalState.Failed(detail.failureReason ?: "unknown") + "ready", "build_succeeded" -> TerminalState.Succeeded(detail.imageTag) + "build_failed", "deploy_failed" -> TerminalState.Failed(detail.failureReason ?: "unknown") else -> throw GradleException( "grounds-push: stream closed with non-terminal status " + "(pushId=$pushId, status=${detail.status})" @@ -390,11 +395,11 @@ abstract class GroundsPushTask : DefaultTask() { private fun handleTerminalState(pushId: String, terminalState: TerminalState, last20: ArrayDeque) { when (terminalState) { is TerminalState.Succeeded -> { - logger.lifecycle("[grounds-push] Build succeeded (pushId=$pushId, imageTag=${terminalState.imageTag ?: "unknown"})") + logger.lifecycle("[grounds-push] Deployment ready (pushId=$pushId, imageTag=${terminalState.imageTag ?: "unknown"})") } is TerminalState.Failed -> { val tailMsg = if (last20.isNotEmpty()) "\n last 20 lines:\n" + last20.joinToString("\n") { " $it" } else "" - throw GradleException("grounds-push: build_failed (pushId=$pushId, reason=${terminalState.reason})$tailMsg") + throw GradleException("grounds-push: push_failed (pushId=$pushId, reason=${terminalState.reason})$tailMsg") } } } diff --git a/plugin/src/test/kotlin/gg/grounds/push/GroundsPushPluginTest.kt b/plugin/src/test/kotlin/gg/grounds/push/GroundsPushPluginTest.kt index 40b5ff3..4461700 100644 --- a/plugin/src/test/kotlin/gg/grounds/push/GroundsPushPluginTest.kt +++ b/plugin/src/test/kotlin/gg/grounds/push/GroundsPushPluginTest.kt @@ -281,6 +281,7 @@ class GroundsPushPluginTest { )) val sseBody = "event: status\ndata: {\"status\":\"building\"}\n\n" + "event: status\ndata: {\"status\":\"build_succeeded\",\"imageTag\":\"zot/test:abc\"}\n\n" + + "event: status\ndata: {\"status\":\"ready\"}\n\n" + "event: done\ndata: {}\n\n" server.enqueue(MockResponse() .setHeader("Content-Type", "text/event-stream") @@ -326,7 +327,7 @@ class GroundsPushPluginTest { assertTrue(result.output.contains("Push accepted (pushId=p1, target=staging, statusCode=202, reused=false)"), result.output) assertTrue(result.output.contains("Build link available (pushId=p1, url=https://platform.grnds.io/builds/p1)"), result.output) assertTrue(result.output.contains("Build status received (pushId=p1, status=building"), result.output) - assertTrue(result.output.contains("Build succeeded (pushId=p1, imageTag=zot/test:abc)"), result.output) + assertTrue(result.output.contains("Deployment ready (pushId=p1, imageTag=zot/test:abc)"), result.output) assertTrue(!result.output.contains("Plugin initialized (version=consumer-app)"), result.output) assertTrue(!result.output.contains("Resolving credentials"), result.output) assertTrue(!result.output.contains("→"), result.output) @@ -526,7 +527,7 @@ class GroundsPushPluginTest { .withArguments("-Duser.home=${tmp.absolutePath}", "groundsPush") .build() - assertTrue(result.output.contains("Build succeeded (pushId=p1, imageTag=zot/test:abc)"), result.output) + assertTrue(result.output.contains("Deployment ready (pushId=p1, imageTag=zot/test:abc)"), result.output) assertEquals("/v1/base-images", server.takeRequest(5, TimeUnit.SECONDS)?.path) assertEquals("/v1/pushes", server.takeRequest(5, TimeUnit.SECONDS)?.path) assertEquals("/v1/pushes/p1/logs", server.takeRequest(5, TimeUnit.SECONDS)?.path) From 4d549e478240f7b6f6396ad12da7de533746cd06 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 3 Aug 2026 15:49:44 +0200 Subject: [PATCH 2/2] fix: poll deployment terminal status --- .../gg/grounds/push/tasks/GroundsPushTask.kt | 49 ++++++++++++------- .../gg/grounds/push/GroundsPushPluginTest.kt | 4 ++ 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/plugin/src/main/kotlin/gg/grounds/push/tasks/GroundsPushTask.kt b/plugin/src/main/kotlin/gg/grounds/push/tasks/GroundsPushTask.kt index 45b6ebb..ccdb372 100644 --- a/plugin/src/main/kotlin/gg/grounds/push/tasks/GroundsPushTask.kt +++ b/plugin/src/main/kotlin/gg/grounds/push/tasks/GroundsPushTask.kt @@ -319,6 +319,7 @@ abstract class GroundsPushTask : DefaultTask() { private fun streamAndWait(client: GroundsForgeClient, pushId: String, target: String) { logger.lifecycle("[grounds-push] Build log stream opened (pushId=$pushId, target=$target)") + val deadlineNanos = System.nanoTime() + TimeUnit.MINUTES.toNanos(timeoutMinutes.get().toLong()) val done = CountDownLatch(1) val terminal = AtomicReference() val builtImageTag = AtomicReference() @@ -362,34 +363,48 @@ abstract class GroundsPushTask : DefaultTask() { } val es = client.streamLogs(pushId, listener) - val waited = done.await(timeoutMinutes.get().toLong(), TimeUnit.MINUTES) + val waited = done.await((deadlineNanos - System.nanoTime()).coerceAtLeast(0), TimeUnit.NANOSECONDS) es.cancel() if (!waited) { throw GradleException("grounds-push: push $pushId exceeded ${timeoutMinutes.get()}-minute timeout") } - val terminalState = terminal.get() ?: pollTerminalState(client, pushId) + val terminalState = terminal.get() ?: pollTerminalState(client, pushId, deadlineNanos) handleTerminalState(pushId, terminalState, last20) } - private fun pollTerminalState(client: GroundsForgeClient, pushId: String): TerminalState { - try { - val detail = client.getPush(pushId) - return when (detail.status) { - "ready", "build_succeeded" -> TerminalState.Succeeded(detail.imageTag) - "build_failed", "deploy_failed" -> TerminalState.Failed(detail.failureReason ?: "unknown") - else -> throw GradleException( - "grounds-push: stream closed with non-terminal status " + - "(pushId=$pushId, status=${detail.status})" + private fun pollTerminalState(client: GroundsForgeClient, pushId: String, deadlineNanos: Long): TerminalState { + var builtImageTag: String? = null + while (System.nanoTime() < deadlineNanos) { + try { + val detail = client.getPush(pushId) + when (detail.status) { + "ready" -> return TerminalState.Succeeded(detail.imageTag ?: builtImageTag) + "build_failed", "deploy_failed" -> return TerminalState.Failed(detail.failureReason ?: "unknown") + "build_succeeded" -> builtImageTag = detail.imageTag ?: builtImageTag + "received", "building", "deploying" -> Unit + else -> throw GradleException( + "grounds-push: stream closed with unknown status " + + "(pushId=$pushId, status=${detail.status})" + ) + } + } catch (e: GroundsForgeClient.ApiException) { + throw GradleException( + "grounds-push: stream closed and status poll failed " + + "(pushId=$pushId, statusCode=${e.statusCode}, reason=${e.message})", + e, ) } - } catch (e: GroundsForgeClient.ApiException) { - throw GradleException( - "grounds-push: stream closed and status poll failed " + - "(pushId=$pushId, statusCode=${e.statusCode}, reason=${e.message})", - e, - ) + try { + TimeUnit.NANOSECONDS.sleep( + minOf(TimeUnit.SECONDS.toNanos(1), (deadlineNanos - System.nanoTime()).coerceAtLeast(0)), + ) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw GradleException("grounds-push: status poll interrupted (pushId=$pushId)", e) + } } + throw GradleException("grounds-push: push did not reach a terminal status (pushId=$pushId)") } private fun handleTerminalState(pushId: String, terminalState: TerminalState, last20: ArrayDeque) { diff --git a/plugin/src/test/kotlin/gg/grounds/push/GroundsPushPluginTest.kt b/plugin/src/test/kotlin/gg/grounds/push/GroundsPushPluginTest.kt index 4461700..26e0081 100644 --- a/plugin/src/test/kotlin/gg/grounds/push/GroundsPushPluginTest.kt +++ b/plugin/src/test/kotlin/gg/grounds/push/GroundsPushPluginTest.kt @@ -499,6 +499,9 @@ class GroundsPushPluginTest { server.enqueue(MockResponse().setResponseCode(200).setBody( """{"id":"p1","status":"build_succeeded","target":"dev","baseImage":"paper","imageTag":"zot/test:abc","failureReason":null,"createdAt":"2026-04-24T10:00:00Z","updatedAt":"2026-04-24T10:00:01Z"}""" )) + server.enqueue(MockResponse().setResponseCode(200).setBody( + """{"id":"p1","status":"ready","target":"dev","baseImage":"paper","imageTag":"zot/test:abc","failureReason":null,"createdAt":"2026-04-24T10:00:00Z","updatedAt":"2026-04-24T10:00:02Z"}""" + )) File(tmp, "settings.gradle.kts").writeText("rootProject.name = \"test\"\n") File(tmp, "app.jar").writeBytes(byteArrayOf(0x50, 0x4b, 0x03, 0x04) + ByteArray(100)) File(tmp, "grounds.yaml").writeText(""" @@ -532,6 +535,7 @@ class GroundsPushPluginTest { assertEquals("/v1/pushes", server.takeRequest(5, TimeUnit.SECONDS)?.path) assertEquals("/v1/pushes/p1/logs", server.takeRequest(5, TimeUnit.SECONDS)?.path) assertEquals("/v1/pushes/p1", server.takeRequest(5, TimeUnit.SECONDS)?.path) + assertEquals("/v1/pushes/p1", server.takeRequest(5, TimeUnit.SECONDS)?.path) } finally { server.shutdown() }