Skip to content

Commit abd7204

Browse files
authored
Bypass download if CLI version matches (#245)
* Bypass download if CLI version matches This is a bit faster than waiting for a 304 but more importantly it works if the user customizes the download source to be a place that does not have the same etag support that Coder does. The test bypasses Windows since we would need to create an executable there for the test. We could split out the exec and the parsing although I am not sure it is worth the noise. The part most likely to break between platforms is probably the exec, not the parsing? * Make invalid JSON test less obscure * Properly compare semver
1 parent 59dac3d commit abd7204

File tree

4 files changed

+134
-16
lines changed

4 files changed

+134
-16
lines changed

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.coder.gateway.sdk
22

33
import com.coder.gateway.models.WorkspaceAgentModel
44
import com.coder.gateway.views.steps.CoderWorkspacesStepView
5+
import com.google.gson.Gson
56
import com.intellij.openapi.diagnostic.Logger
67
import org.zeroturnaround.exec.ProcessExecutor
78
import java.io.BufferedInputStream
@@ -276,11 +277,42 @@ class CoderCLIManager @JvmOverloads constructor(
276277
}
277278
}
278279

280+
/**
281+
* Version output from the CLI's version command.
282+
*/
283+
private data class Version(
284+
val version: String,
285+
)
286+
279287
/**
280288
* Return the binary version.
289+
*
290+
* Throws if it could not be determined.
281291
*/
282-
fun version(): String {
283-
return exec("version")
292+
fun version(): CoderSemVer {
293+
val raw = exec("version", "--output", "json")
294+
val json = Gson().fromJson(raw, Version::class.java)
295+
if (json?.version == null) {
296+
throw InvalidVersionException("No version found in output")
297+
}
298+
return CoderSemVer.parse(json.version)
299+
}
300+
301+
/**
302+
* Returns true if the CLI has the same major/minor/patch version as the
303+
* provided version and false if it does not match or the CLI version could
304+
* not be determined or the provided version is invalid.
305+
*/
306+
fun matchesVersion(buildVersion: String): Boolean {
307+
return try {
308+
val cliVersion = version()
309+
val matches = cliVersion == CoderSemVer.parse(buildVersion)
310+
logger.info("$localBinaryPath version $cliVersion matches $buildVersion: $matches")
311+
matches
312+
} catch (e: Exception) {
313+
logger.info("Unable to determine $localBinaryPath version: ${e.message}")
314+
false
315+
}
284316
}
285317

286318
private fun exec(vararg args: String): String {

src/main/kotlin/com/coder/gateway/sdk/CoderSemVer.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class CoderSemVer(private val major: Long = 0, private val minor: Long = 0, priv
1414

1515

1616
override fun toString(): String {
17-
return "CoderSemVer(major=$major, minor=$minor)"
17+
return "CoderSemVer(major=$major, minor=$minor, patch=$patch)"
1818
}
1919

2020
override fun equals(other: Any?): Boolean {

src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -467,8 +467,15 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
467467
appPropertiesService.setValue(CODER_URL_KEY, deploymentURL.toString())
468468
appPropertiesService.setValue(SESSION_TOKEN, token.first)
469469

470-
this.indicator.text = "Downloading Coder CLI..."
471-
cliManager.downloadCLI()
470+
// Short-circuit if we already have the expected version. This
471+
// lets us bypass the 304 which is slower and may not be
472+
// supported if the binary is downloaded from alternate sources.
473+
// For CLIs without the JSON output flag we will fall back to
474+
// the 304 method.
475+
if (!cliManager.matchesVersion(clientService.buildVersion)) {
476+
this.indicator.text = "Downloading Coder CLI..."
477+
cliManager.downloadCLI()
478+
}
472479

473480
this.indicator.text = "Authenticating Coder CLI..."
474481
cliManager.login(token.first)

src/test/groovy/CoderCLIManagerTest.groovy

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
package com.coder.gateway.sdk
22

3+
import com.google.gson.JsonSyntaxException
34
import com.sun.net.httpserver.HttpExchange
45
import com.sun.net.httpserver.HttpHandler
56
import com.sun.net.httpserver.HttpServer
67
import org.zeroturnaround.exec.InvalidExitValueException
78
import org.zeroturnaround.exec.ProcessInitException
8-
import spock.lang.Requires
9-
import spock.lang.Shared
10-
import spock.lang.Specification
11-
import spock.lang.Unroll
9+
import spock.lang.*
1210

1311
import java.nio.file.Files
1412
import java.nio.file.Path
@@ -134,10 +132,11 @@ class CoderCLIManagerTest extends Specification {
134132

135133
when:
136134
def downloaded = ccm.downloadCLI()
135+
ccm.version()
137136

138137
then:
139138
downloaded
140-
ccm.version().contains("Coder")
139+
noExceptionThrown()
141140

142141
// Make sure login failures propagate correctly.
143142
when:
@@ -161,7 +160,7 @@ class CoderCLIManagerTest extends Specification {
161160
// The mock does not serve a binary that works on Windows so do not
162161
// actually execute. Checking the contents works just as well as proof
163162
// that the binary was correctly downloaded anyway.
164-
ccm.localBinaryPath.toFile().text == "#!/bin/sh\necho '$url'"
163+
ccm.localBinaryPath.toFile().text.contains(url)
165164

166165
cleanup:
167166
srv.stop(0)
@@ -172,7 +171,7 @@ class CoderCLIManagerTest extends Specification {
172171
def ccm = new CoderCLIManager(new URL("https://foo"), tmpdir.resolve("does-not-exist"))
173172

174173
when:
175-
ccm.version()
174+
ccm.login("token")
176175

177176
then:
178177
thrown(ProcessInitException)
@@ -193,7 +192,7 @@ class CoderCLIManagerTest extends Specification {
193192
downloaded
194193
ccm.localBinaryPath.toFile().readBytes() != "cli".getBytes()
195194
ccm.localBinaryPath.toFile().lastModified() > 0
196-
ccm.localBinaryPath.toFile().text == "#!/bin/sh\necho '$url'"
195+
ccm.localBinaryPath.toFile().text.contains(url)
197196

198197
cleanup:
199198
srv.stop(0)
@@ -207,6 +206,7 @@ class CoderCLIManagerTest extends Specification {
207206
when:
208207
def downloaded1 = ccm.downloadCLI()
209208
ccm.localBinaryPath.toFile().setLastModified(0)
209+
// Download will be skipped due to a 304.
210210
def downloaded2 = ccm.downloadCLI()
211211

212212
then:
@@ -231,8 +231,8 @@ class CoderCLIManagerTest extends Specification {
231231

232232
then:
233233
ccm1.localBinaryPath != ccm2.localBinaryPath
234-
ccm1.localBinaryPath.toFile().text == "#!/bin/sh\necho '$url1'"
235-
ccm2.localBinaryPath.toFile().text == "#!/bin/sh\necho '$url2'"
234+
ccm1.localBinaryPath.toFile().text.contains(url1)
235+
ccm2.localBinaryPath.toFile().text.contains(url2)
236236

237237
cleanup:
238238
srv1.stop(0)
@@ -249,7 +249,7 @@ class CoderCLIManagerTest extends Specification {
249249

250250
then:
251251
downloaded
252-
ccm.localBinaryPath.toFile().text == "#!/bin/sh\necho '${expected.replace("{{url}}", url)}'"
252+
ccm.localBinaryPath.toFile().text.contains(expected.replace("{{url}}", url))
253253

254254
cleanup:
255255
srv.stop(0)
@@ -429,4 +429,83 @@ class CoderCLIManagerTest extends Specification {
429429
"malformed-start-after-end",
430430
]
431431
}
432+
433+
@IgnoreIf({ os.windows })
434+
def "parses version"() {
435+
given:
436+
def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir)
437+
Files.createDirectories(ccm.localBinaryPath.parent)
438+
439+
when:
440+
ccm.localBinaryPath.toFile().text = "#!/bin/sh\n$contents"
441+
ccm.localBinaryPath.toFile().setExecutable(true)
442+
443+
then:
444+
ccm.version() == expected
445+
446+
where:
447+
contents | expected
448+
"""echo '{"version": "1.0.0"}'""" | CoderSemVer.parse("1.0.0")
449+
"""echo '{"version": "1.0.0", "foo": true, "baz": 1}'""" | CoderSemVer.parse("1.0.0")
450+
}
451+
452+
@IgnoreIf({ os.windows })
453+
def "fails to parse version"() {
454+
given:
455+
def ccm = new CoderCLIManager(new URL("https://test.coder.parse-fail.invalid"), tmpdir)
456+
Files.createDirectories(ccm.localBinaryPath.parent)
457+
458+
when:
459+
if (contents != null) {
460+
ccm.localBinaryPath.toFile().text = "#!/bin/sh\n$contents"
461+
ccm.localBinaryPath.toFile().setExecutable(true)
462+
}
463+
ccm.version()
464+
465+
then:
466+
thrown(expected)
467+
468+
where:
469+
contents | expected
470+
null | ProcessInitException
471+
"""echo '{"foo": true, "baz": 1}'""" | InvalidVersionException
472+
"""echo '{"version: '""" | JsonSyntaxException
473+
"exit 0" | InvalidVersionException
474+
"exit 1" | InvalidExitValueException
475+
}
476+
477+
@IgnoreIf({ os.windows })
478+
def "checks if version matches"() {
479+
given:
480+
def ccm = new CoderCLIManager(new URL("https://test.coder.version-matches.invalid"), tmpdir)
481+
Files.createDirectories(ccm.localBinaryPath.parent)
482+
483+
when:
484+
if (contents != null) {
485+
ccm.localBinaryPath.toFile().text = "#!/bin/sh\n$contents"
486+
ccm.localBinaryPath.toFile().setExecutable(true)
487+
}
488+
489+
then:
490+
ccm.matchesVersion(build) == matches
491+
492+
where:
493+
contents | build | matches
494+
null | "v1.0.0" | false
495+
"""echo '{"version": "v1.0.0"}'""" | "v1.0.0" | true
496+
"""echo '{"version": "v1.0.0"}'""" | "v1.0.0-devel+b5b5b5b5" | true
497+
"""echo '{"version": "v1.0.0-devel+b5b5b5b5"}'""" | "v1.0.0-devel+b5b5b5b5" | true
498+
"""echo '{"version": "v1.0.0-devel+b5b5b5b5"}'""" | "v1.0.0" | true
499+
"""echo '{"version": "v1.0.0-devel+b5b5b5b5"}'""" | "v1.0.0-devel+c6c6c6c6" | true
500+
"""echo '{"version": "v1.0.0-prod+b5b5b5b5"}'""" | "v1.0.0-devel+b5b5b5b5" | true
501+
"""echo '{"version": "v1.0.0"}'""" | "v1.0.1" | false
502+
"""echo '{"version": "v1.0.0"}'""" | "v1.1.0" | false
503+
"""echo '{"version": "v1.0.0"}'""" | "v2.0.0" | false
504+
"""echo '{"version": "v1.0.0"}'""" | "v0.0.0" | false
505+
"""echo '{"version": ""}'""" | "v1.0.0" | false
506+
"""echo '{"version": "v1.0.0"}'""" | "" | false
507+
"""echo '{"version'""" | "v1.0.0" | false
508+
"""exit 0""" | "v1.0.0" | false
509+
"""exit 1""" | "v1.0.0" | false
510+
}
432511
}

0 commit comments

Comments
 (0)