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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy