Skip to content

Commit 4001f7d

Browse files
committed
impl: support for downloading and verifying cli signatures
1 parent 26ac983 commit 4001f7d

15 files changed

+713
-114
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
## Unreleased
66

7+
### Added
8+
9+
- support for checking if CLI is signed
10+
- improved progress reporting while downloading the CLI
11+
712
## 2.21.1 - 2025-06-26
813

914
### Fixed

src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ class CoderRemoteConnectionHandle {
6666
private val localTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MMM-dd HH:mm")
6767
private val dialogUi = DialogUi(settings)
6868

69-
fun connect(getParameters: (indicator: ProgressIndicator) -> WorkspaceProjectIDE) {
69+
fun connect(getParameters: suspend (indicator: ProgressIndicator) -> WorkspaceProjectIDE) {
7070
val clientLifetime = LifetimeDefinition()
7171
clientLifetime.launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.coder.connection.provider.title")) {
7272
try {

src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") {
6868
CoderGatewayBundle.message("gateway.connector.settings.enable-binary-directory-fallback.comment"),
6969
)
7070
}.layout(RowLayout.PARENT_GRID)
71+
row {
72+
cell() // For alignment.
73+
checkBox(CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.title"))
74+
.bindSelected(state::fallbackOnCoderForSignatures)
75+
.comment(
76+
CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.comment"),
77+
)
78+
}.layout(RowLayout.PARENT_GRID)
7179
row(CoderGatewayBundle.message("gateway.connector.settings.header-command.title")) {
7280
textField().resizableColumn().align(AlignX.FILL)
7381
.bindText(state::headerCommand)

src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt

Lines changed: 159 additions & 80 deletions
Large diffs are not rendered by default.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.coder.gateway.cli.downloader
2+
3+
import okhttp3.ResponseBody
4+
import retrofit2.Response
5+
import retrofit2.http.GET
6+
import retrofit2.http.Header
7+
import retrofit2.http.HeaderMap
8+
import retrofit2.http.Streaming
9+
import retrofit2.http.Url
10+
11+
/**
12+
* Retrofit API for downloading CLI
13+
*/
14+
interface CoderDownloadApi {
15+
@GET
16+
@Streaming
17+
suspend fun downloadCli(
18+
@Url url: String,
19+
@Header("If-None-Match") eTag: String? = null,
20+
@HeaderMap headers: Map<String, String> = emptyMap(),
21+
@Header("Accept-Encoding") acceptEncoding: String = "gzip",
22+
): Response<ResponseBody>
23+
24+
@GET
25+
suspend fun downloadSignature(
26+
@Url url: String,
27+
@HeaderMap headers: Map<String, String> = emptyMap()
28+
): Response<ResponseBody>
29+
}
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
package com.coder.gateway.cli.downloader
2+
3+
import com.coder.gateway.cli.ex.ResponseException
4+
import com.coder.gateway.settings.CoderSettings
5+
import com.coder.gateway.util.OS
6+
import com.coder.gateway.util.SemVer
7+
import com.coder.gateway.util.getHeaders
8+
import com.coder.gateway.util.getOS
9+
import com.coder.gateway.util.sha1
10+
import com.intellij.openapi.diagnostic.Logger
11+
import kotlinx.coroutines.Dispatchers
12+
import kotlinx.coroutines.withContext
13+
import okhttp3.ResponseBody
14+
import retrofit2.Response
15+
import java.io.FileInputStream
16+
import java.net.HttpURLConnection.HTTP_NOT_FOUND
17+
import java.net.HttpURLConnection.HTTP_NOT_MODIFIED
18+
import java.net.HttpURLConnection.HTTP_OK
19+
import java.net.URI
20+
import java.net.URL
21+
import java.nio.file.Files
22+
import java.nio.file.Path
23+
import java.nio.file.StandardCopyOption
24+
import java.nio.file.StandardOpenOption
25+
import java.util.zip.GZIPInputStream
26+
import kotlin.io.path.name
27+
import kotlin.io.path.notExists
28+
29+
/**
30+
* Handles the download steps of Coder CLI
31+
*/
32+
class CoderDownloadService(
33+
private val settings: CoderSettings,
34+
private val downloadApi: CoderDownloadApi,
35+
private val deploymentUrl: URL,
36+
forceDownloadToData: Boolean,
37+
) {
38+
private val remoteBinaryURL: URL = settings.binSource(deploymentUrl)
39+
private val cliFinalDst: Path = settings.binPath(deploymentUrl, forceDownloadToData)
40+
private val cliTempDst: Path = cliFinalDst.resolveSibling("${cliFinalDst.name}.tmp")
41+
42+
suspend fun downloadCli(buildVersion: String, showTextProgress: ((t: String) -> Unit)? = null): DownloadResult {
43+
val eTag = calculateLocalETag()
44+
if (eTag != null) {
45+
logger.info("Found existing binary at $cliFinalDst; calculated hash as $eTag")
46+
}
47+
val response = downloadApi.downloadCli(
48+
url = remoteBinaryURL.toString(),
49+
eTag = eTag?.let { "\"$it\"" },
50+
headers = getRequestHeaders()
51+
)
52+
53+
return when (response.code()) {
54+
HTTP_OK -> {
55+
logger.info("Downloading binary to temporary $cliTempDst")
56+
response.saveToDisk(cliTempDst, showTextProgress, buildVersion)?.makeExecutable()
57+
DownloadResult.Downloaded(remoteBinaryURL, cliTempDst)
58+
}
59+
60+
HTTP_NOT_MODIFIED -> {
61+
logger.info("Using cached binary at $cliFinalDst")
62+
showTextProgress?.invoke("Using cached binary")
63+
DownloadResult.Skipped
64+
}
65+
66+
else -> {
67+
throw ResponseException(
68+
"Unexpected response from $remoteBinaryURL",
69+
response.code()
70+
)
71+
}
72+
}
73+
}
74+
75+
/**
76+
* Renames the temporary binary file to its original destination name.
77+
* The implementation will override sibling file that has the original
78+
* destination name.
79+
*/
80+
suspend fun commit(): Path {
81+
return withContext(Dispatchers.IO) {
82+
logger.info("Renaming binary from $cliTempDst to $cliFinalDst")
83+
Files.move(cliTempDst, cliFinalDst, StandardCopyOption.REPLACE_EXISTING)
84+
cliFinalDst.makeExecutable()
85+
cliFinalDst
86+
}
87+
}
88+
89+
/**
90+
* Cleans up the temporary binary file if it exists.
91+
*/
92+
suspend fun cleanup() {
93+
withContext(Dispatchers.IO) {
94+
runCatching { Files.deleteIfExists(cliTempDst) }
95+
.onFailure { ex ->
96+
logger.warn("Failed to delete temporary CLI file: $cliTempDst", ex)
97+
}
98+
}
99+
}
100+
101+
private fun calculateLocalETag(): String? {
102+
return try {
103+
if (cliFinalDst.notExists()) {
104+
return null
105+
}
106+
sha1(FileInputStream(cliFinalDst.toFile()))
107+
} catch (e: Exception) {
108+
logger.warn("Unable to calculate hash for $cliFinalDst", e)
109+
null
110+
}
111+
}
112+
113+
private fun getRequestHeaders(): Map<String, String> {
114+
return if (settings.headerCommand.isBlank()) {
115+
emptyMap()
116+
} else {
117+
getHeaders(deploymentUrl, settings.headerCommand)
118+
}
119+
}
120+
121+
private fun Response<ResponseBody>.saveToDisk(
122+
localPath: Path,
123+
showTextProgress: ((t: String) -> Unit)? = null,
124+
buildVersion: String? = null
125+
): Path? {
126+
val responseBody = this.body() ?: return null
127+
Files.deleteIfExists(localPath)
128+
Files.createDirectories(localPath.parent)
129+
130+
val outputStream = Files.newOutputStream(
131+
localPath,
132+
StandardOpenOption.CREATE,
133+
StandardOpenOption.TRUNCATE_EXISTING
134+
)
135+
val contentEncoding = this.headers()["Content-Encoding"]
136+
val sourceStream = if (contentEncoding?.contains("gzip", ignoreCase = true) == true) {
137+
GZIPInputStream(responseBody.byteStream())
138+
} else {
139+
responseBody.byteStream()
140+
}
141+
142+
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
143+
var bytesRead: Int
144+
var totalRead = 0L
145+
// local path is a temporary filename, reporting the progress with the real name
146+
val binaryName = localPath.name.removeSuffix(".tmp")
147+
sourceStream.use { source ->
148+
outputStream.use { sink ->
149+
while (source.read(buffer).also { bytesRead = it } != -1) {
150+
sink.write(buffer, 0, bytesRead)
151+
totalRead += bytesRead
152+
val prettyBuildVersion = buildVersion ?: ""
153+
showTextProgress?.invoke(
154+
"$binaryName $prettyBuildVersion - ${totalRead.toHumanReadableSize()} downloaded"
155+
)
156+
}
157+
}
158+
}
159+
return cliFinalDst
160+
}
161+
162+
163+
private fun Path.makeExecutable() {
164+
if (getOS() != OS.WINDOWS) {
165+
logger.info("Making $this executable...")
166+
this.toFile().setExecutable(true)
167+
}
168+
}
169+
170+
private fun Long.toHumanReadableSize(): String {
171+
if (this < 1024) return "$this B"
172+
173+
val kb = this / 1024.0
174+
if (kb < 1024) return String.format("%.1f KB", kb)
175+
176+
val mb = kb / 1024.0
177+
if (mb < 1024) return String.format("%.1f MB", mb)
178+
179+
val gb = mb / 1024.0
180+
return String.format("%.1f GB", gb)
181+
}
182+
183+
suspend fun downloadSignature(showTextProgress: ((t: String) -> Unit)? = null): DownloadResult {
184+
return downloadSignature(remoteBinaryURL, showTextProgress, getRequestHeaders())
185+
}
186+
187+
private suspend fun downloadSignature(
188+
url: URL,
189+
showTextProgress: ((t: String) -> Unit)? = null,
190+
headers: Map<String, String> = emptyMap()
191+
): DownloadResult {
192+
val signatureURL = url.toURI().resolve(settings.defaultSignatureNameByOsAndArch).toURL()
193+
val localSignaturePath = cliFinalDst.parent.resolve(settings.defaultSignatureNameByOsAndArch)
194+
logger.info("Downloading signature from $signatureURL")
195+
196+
val response = downloadApi.downloadSignature(
197+
url = signatureURL.toString(),
198+
headers = headers
199+
)
200+
201+
return when (response.code()) {
202+
HTTP_OK -> {
203+
response.saveToDisk(localSignaturePath, showTextProgress)
204+
DownloadResult.Downloaded(signatureURL, localSignaturePath)
205+
}
206+
207+
HTTP_NOT_FOUND -> {
208+
logger.warn("Signature file not found at $signatureURL")
209+
DownloadResult.NotFound
210+
}
211+
212+
else -> {
213+
DownloadResult.Failed(
214+
ResponseException(
215+
"Failed to download signature from $signatureURL",
216+
response.code()
217+
)
218+
)
219+
}
220+
}
221+
222+
}
223+
224+
suspend fun downloadReleasesSignature(
225+
buildVersion: String,
226+
showTextProgress: ((t: String) -> Unit)? = null
227+
): DownloadResult {
228+
val semVer = SemVer.parse(buildVersion)
229+
return downloadSignature(
230+
URI.create("https://releases.coder.com/coder-cli/${semVer.major}.${semVer.minor}.${semVer.patch}/").toURL(),
231+
showTextProgress
232+
)
233+
}
234+
235+
companion object {
236+
val logger = Logger.getInstance(CoderDownloadService::class.java.simpleName)
237+
}
238+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.coder.gateway.cli.downloader
2+
3+
import java.net.URL
4+
import java.nio.file.Path
5+
6+
7+
/**
8+
* Result of a download operation
9+
*/
10+
sealed class DownloadResult {
11+
object Skipped : DownloadResult()
12+
object NotFound : DownloadResult()
13+
data class Downloaded(val source: URL, val dst: Path) : DownloadResult()
14+
data class Failed(val error: Exception) : DownloadResult()
15+
16+
fun isSkipped(): Boolean = this is Skipped
17+
18+
fun isNotFound(): Boolean = this is NotFound
19+
20+
fun isFailed(): Boolean = this is Failed
21+
22+
fun isNotDownloaded(): Boolean = this !is Downloaded
23+
}

src/main/kotlin/com/coder/gateway/cli/ex/Exceptions.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ class ResponseException(message: String, val code: Int) : Exception(message)
55
class SSHConfigFormatException(message: String) : Exception(message)
66

77
class MissingVersionException(message: String) : Exception(message)
8+
9+
class UnsignedBinaryExecutionDeniedException(message: String?) : Exception(message)

0 commit comments

Comments
 (0)