Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

## Unreleased

### Added

- support for checking if CLI is signed
- improved progress reporting while downloading the CLI

## 2.21.1 - 2025-06-26

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ dependencies {
testImplementation(kotlin("test"))
// required by the unit tests
testImplementation(kotlin("test-junit5"))
testImplementation("io.mockk:mockk:1.13.12")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
// required by IntelliJ test framework
testImplementation("junit:junit:4.13.2")

Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ pluginGroup=com.coder.gateway
artifactName=coder-gateway
pluginName=Coder
# SemVer format -> https://semver.org
pluginVersion=2.21.1
pluginVersion=2.22.0
# See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
# for insight into build numbers and IntelliJ Platform versions.
pluginSinceBuild=243.26574
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
private val localTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MMM-dd HH:mm")
private val dialogUi = DialogUi(settings)

fun connect(getParameters: (indicator: ProgressIndicator) -> WorkspaceProjectIDE) {
fun connect(getParameters: suspend (indicator: ProgressIndicator) -> WorkspaceProjectIDE) {
val clientLifetime = LifetimeDefinition()
clientLifetime.launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.coder.connection.provider.title")) {
try {
Expand Down Expand Up @@ -274,7 +274,7 @@
},
),
)
val handle = client.connect(URI(joinLink)) // Downloads the client too, if needed.

Check warning on line 277 in src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Usage of redundant or deprecated syntax or deprecated symbols

'connect(URI): ThinClientHandle' is deprecated. Use connect(URI, ExtractedJetBrainsClientData)

// Reconnect if the join link changes.
logger.info("Launched ${workspace.ideName} client; beginning backend monitoring")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") {
CoderGatewayBundle.message("gateway.connector.settings.enable-binary-directory-fallback.comment"),
)
}.layout(RowLayout.PARENT_GRID)
row {
cell() // For alignment.
checkBox(CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.title"))
.bindSelected(state::fallbackOnCoderForSignatures)
.comment(
CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.comment"),
)
}.layout(RowLayout.PARENT_GRID)
row(CoderGatewayBundle.message("gateway.connector.settings.header-command.title")) {
textField().resizableColumn().align(AlignX.FILL)
.bindText(state::headerCommand)
Expand Down
239 changes: 159 additions & 80 deletions src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.coder.gateway.cli.downloader

import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.HeaderMap
import retrofit2.http.Streaming
import retrofit2.http.Url

/**
* Retrofit API for downloading CLI
*/
interface CoderDownloadApi {
@GET
@Streaming
suspend fun downloadCli(
@Url url: String,
@Header("If-None-Match") eTag: String? = null,
@HeaderMap headers: Map<String, String> = emptyMap(),
@Header("Accept-Encoding") acceptEncoding: String = "gzip",
): Response<ResponseBody>

@GET
suspend fun downloadSignature(
@Url url: String,
@HeaderMap headers: Map<String, String> = emptyMap()
): Response<ResponseBody>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
package com.coder.gateway.cli.downloader

import com.coder.gateway.cli.ex.ResponseException
import com.coder.gateway.settings.CoderSettings
import com.coder.gateway.util.OS
import com.coder.gateway.util.SemVer
import com.coder.gateway.util.getHeaders
import com.coder.gateway.util.getOS
import com.coder.gateway.util.sha1
import com.intellij.openapi.diagnostic.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.ResponseBody
import retrofit2.Response
import java.io.FileInputStream
import java.net.HttpURLConnection.HTTP_NOT_FOUND
import java.net.HttpURLConnection.HTTP_NOT_MODIFIED
import java.net.HttpURLConnection.HTTP_OK
import java.net.URI
import java.net.URL
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.nio.file.StandardOpenOption
import java.util.zip.GZIPInputStream
import kotlin.io.path.name
import kotlin.io.path.notExists

/**
* Handles the download steps of Coder CLI
*/
class CoderDownloadService(
private val settings: CoderSettings,
private val downloadApi: CoderDownloadApi,
private val deploymentUrl: URL,
forceDownloadToData: Boolean,
) {
private val remoteBinaryURL: URL = settings.binSource(deploymentUrl)
private val cliFinalDst: Path = settings.binPath(deploymentUrl, forceDownloadToData)
private val cliTempDst: Path = cliFinalDst.resolveSibling("${cliFinalDst.name}.tmp")

suspend fun downloadCli(buildVersion: String, showTextProgress: ((t: String) -> Unit)? = null): DownloadResult {
val eTag = calculateLocalETag()
if (eTag != null) {
logger.info("Found existing binary at $cliFinalDst; calculated hash as $eTag")
}
val response = downloadApi.downloadCli(
url = remoteBinaryURL.toString(),
eTag = eTag?.let { "\"$it\"" },
headers = getRequestHeaders()
)

return when (response.code()) {
HTTP_OK -> {
logger.info("Downloading binary to temporary $cliTempDst")
response.saveToDisk(cliTempDst, showTextProgress, buildVersion)?.makeExecutable()
DownloadResult.Downloaded(remoteBinaryURL, cliTempDst)
}

HTTP_NOT_MODIFIED -> {
logger.info("Using cached binary at $cliFinalDst")
showTextProgress?.invoke("Using cached binary")
DownloadResult.Skipped
}

else -> {
throw ResponseException(
"Unexpected response from $remoteBinaryURL",
response.code()
)
}
}
}

/**
* Renames the temporary binary file to its original destination name.
* The implementation will override sibling file that has the original
* destination name.
*/
suspend fun commit(): Path {
return withContext(Dispatchers.IO) {
logger.info("Renaming binary from $cliTempDst to $cliFinalDst")
Files.move(cliTempDst, cliFinalDst, StandardCopyOption.REPLACE_EXISTING)
cliFinalDst.makeExecutable()
cliFinalDst
}
}

/**
* Cleans up the temporary binary file if it exists.
*/
suspend fun cleanup() {
withContext(Dispatchers.IO) {
runCatching { Files.deleteIfExists(cliTempDst) }
.onFailure { ex ->
logger.warn("Failed to delete temporary CLI file: $cliTempDst", ex)
}
}
}

private fun calculateLocalETag(): String? {
return try {
if (cliFinalDst.notExists()) {
return null
}
sha1(FileInputStream(cliFinalDst.toFile()))
} catch (e: Exception) {
logger.warn("Unable to calculate hash for $cliFinalDst", e)
null
}
}

private fun getRequestHeaders(): Map<String, String> {
return if (settings.headerCommand.isBlank()) {
emptyMap()
} else {
getHeaders(deploymentUrl, settings.headerCommand)
}
}

private fun Response<ResponseBody>.saveToDisk(
localPath: Path,
showTextProgress: ((t: String) -> Unit)? = null,
buildVersion: String? = null
): Path? {
val responseBody = this.body() ?: return null
Files.deleteIfExists(localPath)
Files.createDirectories(localPath.parent)

val outputStream = Files.newOutputStream(
localPath,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING
)
val contentEncoding = this.headers()["Content-Encoding"]
val sourceStream = if (contentEncoding?.contains("gzip", ignoreCase = true) == true) {
GZIPInputStream(responseBody.byteStream())
} else {
responseBody.byteStream()
}

val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytesRead: Int
var totalRead = 0L
// local path is a temporary filename, reporting the progress with the real name
val binaryName = localPath.name.removeSuffix(".tmp")
sourceStream.use { source ->
outputStream.use { sink ->
while (source.read(buffer).also { bytesRead = it } != -1) {
sink.write(buffer, 0, bytesRead)
totalRead += bytesRead
val prettyBuildVersion = buildVersion ?: ""
showTextProgress?.invoke(
"$binaryName $prettyBuildVersion - ${totalRead.toHumanReadableSize()} downloaded"
)
}
}
}
return cliFinalDst
}


private fun Path.makeExecutable() {
if (getOS() != OS.WINDOWS) {
logger.info("Making $this executable...")
this.toFile().setExecutable(true)
}
}

private fun Long.toHumanReadableSize(): String {
if (this < 1024) return "$this B"

val kb = this / 1024.0
if (kb < 1024) return String.format("%.1f KB", kb)

val mb = kb / 1024.0
if (mb < 1024) return String.format("%.1f MB", mb)

val gb = mb / 1024.0
return String.format("%.1f GB", gb)
}

suspend fun downloadSignature(showTextProgress: ((t: String) -> Unit)? = null): DownloadResult {
return downloadSignature(remoteBinaryURL, showTextProgress, getRequestHeaders())
}

private suspend fun downloadSignature(
url: URL,
showTextProgress: ((t: String) -> Unit)? = null,
headers: Map<String, String> = emptyMap()
): DownloadResult {
val signatureURL = url.toURI().resolve(settings.defaultSignatureNameByOsAndArch).toURL()
val localSignaturePath = cliFinalDst.parent.resolve(settings.defaultSignatureNameByOsAndArch)
logger.info("Downloading signature from $signatureURL")

val response = downloadApi.downloadSignature(
url = signatureURL.toString(),
headers = headers
)

return when (response.code()) {
HTTP_OK -> {
response.saveToDisk(localSignaturePath, showTextProgress)
DownloadResult.Downloaded(signatureURL, localSignaturePath)
}

HTTP_NOT_FOUND -> {
logger.warn("Signature file not found at $signatureURL")
DownloadResult.NotFound
}

else -> {
DownloadResult.Failed(
ResponseException(
"Failed to download signature from $signatureURL",
response.code()
)
)
}
}

}

suspend fun downloadReleasesSignature(
buildVersion: String,
showTextProgress: ((t: String) -> Unit)? = null
): DownloadResult {
val semVer = SemVer.parse(buildVersion)
return downloadSignature(
URI.create("https://releases.coder.com/coder-cli/${semVer.major}.${semVer.minor}.${semVer.patch}/").toURL(),
showTextProgress
)
}

companion object {
val logger = Logger.getInstance(CoderDownloadService::class.java.simpleName)
}
}
23 changes: 23 additions & 0 deletions src/main/kotlin/com/coder/gateway/cli/downloader/DownloadResult.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.coder.gateway.cli.downloader

import java.net.URL
import java.nio.file.Path


/**
* Result of a download operation
*/
sealed class DownloadResult {
object Skipped : DownloadResult()

Check notice on line 11 in src/main/kotlin/com/coder/gateway/cli/downloader/DownloadResult.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Convert 'object' to 'data object'

'sealed' sub-object can be converted to 'data object'
object NotFound : DownloadResult()

Check notice on line 12 in src/main/kotlin/com/coder/gateway/cli/downloader/DownloadResult.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Convert 'object' to 'data object'

'sealed' sub-object can be converted to 'data object'
data class Downloaded(val source: URL, val dst: Path) : DownloadResult()
data class Failed(val error: Exception) : DownloadResult()

fun isSkipped(): Boolean = this is Skipped

fun isNotFound(): Boolean = this is NotFound

fun isFailed(): Boolean = this is Failed

fun isNotDownloaded(): Boolean = this !is Downloaded
}
2 changes: 2 additions & 0 deletions src/main/kotlin/com/coder/gateway/cli/ex/Exceptions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ class ResponseException(message: String, val code: Int) : Exception(message)
class SSHConfigFormatException(message: String) : Exception(message)

class MissingVersionException(message: String) : Exception(message)

class UnsignedBinaryExecutionDeniedException(message: String?) : Exception(message)
Loading
Loading