Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 41 additions & 21 deletions plugin/src/main/kotlin/gg/grounds/push/tasks/GroundsPushTask.kt
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,10 @@ 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<TerminalState?>()
val builtImageTag = AtomicReference<String?>()
val last20 = ArrayDeque<String>(20)

val listener = object : PushSseListener {
Expand All @@ -331,8 +333,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) {
Expand All @@ -357,44 +363,58 @@ 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) {
"build_succeeded" -> TerminalState.Succeeded(detail.imageTag)
"build_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<String>) {
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")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -498,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("""
Expand Down Expand Up @@ -526,11 +530,12 @@ 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)
assertEquals("/v1/pushes/p1", server.takeRequest(5, TimeUnit.SECONDS)?.path)
assertEquals("/v1/pushes/p1", server.takeRequest(5, TimeUnit.SECONDS)?.path)
} finally {
server.shutdown()
}
Expand Down