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
+ }
0 commit comments