Skip to content

Commit ae7b8df

Browse files
committed
Use ETag for downloading binary
This way we will not need to name the binaries with their versions or otherwise extract the version from each binary since we can just grab their hash and set that in the request. This also means we will not need to remove old versions as there will only be the one file.
1 parent 125b1f2 commit ae7b8df

File tree

3 files changed

+87
-80
lines changed

3 files changed

+87
-80
lines changed

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

Lines changed: 58 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,29 @@ package com.coder.gateway.sdk
33
import com.coder.gateway.views.steps.CoderWorkspacesStepView
44
import com.intellij.openapi.diagnostic.Logger
55
import org.zeroturnaround.exec.ProcessExecutor
6-
import java.io.InputStream
6+
import java.io.BufferedInputStream
7+
import java.io.FileInputStream
8+
import java.io.FileNotFoundException
9+
import java.net.HttpURLConnection
710
import java.net.URL
811
import java.nio.file.Files
912
import java.nio.file.Path
1013
import java.nio.file.Paths
14+
import java.nio.file.StandardCopyOption
1115
import java.nio.file.attribute.PosixFilePermissions
16+
import java.security.DigestInputStream
17+
import java.security.MessageDigest
18+
import javax.xml.bind.annotation.adapters.HexBinaryAdapter
19+
1220

1321
/**
1422
* Manage the CLI for a single deployment.
1523
*/
16-
class CoderCLIManager(deployment: URL, buildVersion: String) {
24+
class CoderCLIManager(deployment: URL) {
1725
private var remoteBinaryUrl: URL
1826
var localBinaryPath: Path
1927
private var binaryNamePrefix: String
2028
private var destinationDir: Path
21-
private var localBinaryName: String
2229

2330
init {
2431
// TODO: Should use a persistent path to avoid needing to download on
@@ -28,23 +35,21 @@ class CoderCLIManager(deployment: URL, buildVersion: String) {
2835
val os = getOS()
2936
binaryNamePrefix = getCoderCLIForOS(os, getArch())
3037
val binaryName = if (os == OS.WINDOWS) "$binaryNamePrefix.exe" else binaryNamePrefix
31-
localBinaryName =
32-
if (os == OS.WINDOWS) "${binaryNamePrefix}-${buildVersion}.exe" else "${binaryNamePrefix}-${buildVersion}"
3338
remoteBinaryUrl = URL(
3439
deployment.protocol,
3540
deployment.host,
3641
deployment.port,
3742
"/bin/$binaryName"
3843
)
39-
localBinaryPath = destinationDir.resolve(localBinaryName)
44+
localBinaryPath = destinationDir.resolve(binaryName)
4045
}
4146

4247
/**
4348
* Return the name of the binary (sans extension) for the provided OS and
4449
* architecture.
4550
*/
4651
private fun getCoderCLIForOS(os: OS?, arch: Arch?): String {
47-
logger.info("Resolving coder cli for $os $arch")
52+
logger.info("Resolving binary for $os $arch")
4853
if (os == null) {
4954
logger.error("Could not resolve client OS and architecture, defaulting to WINDOWS AMD64")
5055
return "coder-windows-amd64"
@@ -75,42 +80,59 @@ class CoderCLIManager(deployment: URL, buildVersion: String) {
7580
* Download the CLI from the deployment if necessary.
7681
*/
7782
fun downloadCLI(): Boolean {
78-
Files.createDirectories(destinationDir)
79-
try {
80-
logger.info("Downloading Coder CLI to ${localBinaryPath.toAbsolutePath()}")
81-
remoteBinaryUrl.openStream().use {
82-
Files.copy(it as InputStream, localBinaryPath)
83-
}
84-
} catch (e: java.nio.file.FileAlreadyExistsException) {
85-
// This relies on the provided build version being the latest. It
86-
// must be freshly fetched immediately before downloading.
87-
// TODO: Use etags instead?
88-
logger.info("${localBinaryPath.toAbsolutePath()} already exists, skipping download")
89-
return false
83+
val etag = getBinaryETag()
84+
val conn = remoteBinaryUrl.openConnection() as HttpURLConnection
85+
if (etag != null) {
86+
logger.info("Found existing binary at ${localBinaryPath.toAbsolutePath()}; calculated hash as $etag")
87+
conn.setRequestProperty("If-None-Match", "\"$etag\"")
9088
}
91-
if (getOS() != OS.WINDOWS) {
92-
Files.setPosixFilePermissions(
93-
localBinaryPath,
94-
PosixFilePermissions.fromString("rwxr-x---")
95-
)
89+
conn.connect()
90+
logger.info("GET ${conn.responseCode} $remoteBinaryUrl")
91+
when (conn.responseCode) {
92+
200 -> {
93+
logger.info("Downloading binary to ${localBinaryPath.toAbsolutePath()}")
94+
Files.createDirectories(destinationDir)
95+
conn.inputStream.use {
96+
Files.copy(it, localBinaryPath, StandardCopyOption.REPLACE_EXISTING)
97+
}
98+
if (getOS() != OS.WINDOWS) {
99+
Files.setPosixFilePermissions(
100+
localBinaryPath,
101+
PosixFilePermissions.fromString("rwxr-x---")
102+
)
103+
}
104+
conn.disconnect()
105+
return true
106+
}
107+
108+
304 -> {
109+
logger.info("Using cached binary at ${localBinaryPath.toAbsolutePath()}")
110+
conn.disconnect()
111+
return false
112+
}
96113
}
97-
return true
114+
conn.disconnect()
115+
throw Exception("Unable to download $remoteBinaryUrl (got response code `${conn.responseCode}`)")
98116
}
99117

100118
/**
101-
* Remove all versions of the CLI for this deployment that do not match the
102-
* current build version.
119+
* Return the entity tag for the binary on disk, if any.
103120
*/
104-
fun removeOldCli() {
105-
if (Files.isReadable(destinationDir)) {
106-
Files.walk(destinationDir, 1).use {
107-
it.sorted().map { pt -> pt.toFile() }
108-
.filter { fl -> fl.name.contains(binaryNamePrefix) && fl.name != localBinaryName }
109-
.forEach { fl ->
110-
logger.info("Removing $fl because it is an old version")
111-
fl.delete()
112-
}
121+
private fun getBinaryETag(): String? {
122+
try {
123+
val md = MessageDigest.getInstance("SHA-1")
124+
val fis = FileInputStream(localBinaryPath.toFile())
125+
val dis = DigestInputStream(BufferedInputStream(fis), md)
126+
fis.use {
127+
while (dis.read() != -1) {
128+
}
113129
}
130+
return HexBinaryAdapter().marshal(md.digest()).lowercase()
131+
} catch (e: FileNotFoundException) {
132+
return null
133+
} catch (e: Exception) {
134+
logger.warn("Unable to calculate hash for ${localBinaryPath.toAbsolutePath()}", e)
135+
return null
114136
}
115137
}
116138

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

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
425425
return@launchUnderBackgroundProgress
426426
}
427427

428-
val cliManager = CoderCLIManager(localWizardModel.coderURL.toURL(), coderClient.buildVersion)
428+
val cliManager = CoderCLIManager(localWizardModel.coderURL.toURL())
429429
localWizardModel.token = token
430430

431431
this.indicator.apply {
@@ -459,12 +459,6 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod
459459
}
460460
cliManager.configSsh()
461461

462-
this.indicator.apply {
463-
text = "Removing old Coder CLI versions..."
464-
fraction = 0.9
465-
}
466-
cliManager.removeOldCli()
467-
468462
this.indicator.fraction = 1.0
469463
updateWorkspaceActions()
470464
triggerWorkspacePolling(false)

src/test/groovy/CoderCLIManagerTest.groovy

Lines changed: 28 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,68 +8,59 @@ import java.nio.file.Path
88

99
@Unroll
1010
class CoderCLIManagerTest extends spock.lang.Specification {
11-
def "deletes old versions"() {
11+
// TODO: Probably not good to depend on dev.coder.com being up...should use
12+
// a mock? Or spin up a Coder deployment in CI?
13+
def "downloads a working cli"() {
1214
given:
13-
// Simulate downloading an old version.
14-
def oldVersion = new CoderCLIManager(new URL("https://test.coder.invalid"), "0.0.1").localBinaryPath.toFile()
15-
def dir = oldVersion.toPath().getParent()
16-
dir.toFile().deleteDir()
17-
Files.createDirectories(dir)
18-
oldVersion.write("old-version")
19-
20-
// Simulate downloading a new version.
21-
def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), "1.0.2")
22-
def newVersion = ccm.localBinaryPath.toFile()
23-
newVersion.write("new-version")
24-
25-
// Anything that does not start with the expected prefix is ignored.
26-
def otherOsVersion = dir.resolve("coder-alpine-amd64-1.0.2").toFile()
27-
otherOsVersion.write("alpine")
28-
29-
// Anything else matching the prefix is deleted.
30-
def invalidVersion = dir.resolve(newVersion.getName() + "-extra-random-text").toFile()
31-
invalidVersion.write("invalid")
15+
def ccm = new CoderCLIManager(new URL("https://dev.coder.com"))
16+
ccm.localBinaryPath.getParent().toFile().deleteDir()
3217

3318
when:
34-
ccm.removeOldCli()
19+
def downloaded = ccm.downloadCLI()
3520

3621
then:
37-
!oldVersion.exists()
38-
newVersion.exists()
39-
otherOsVersion.exists()
40-
!invalidVersion.exists()
22+
downloaded
23+
ccm.version().contains("Coder")
4124
}
4225

43-
// TODO: Probably not good to depend on dev.coder.com being up...should use
44-
// a mock? Or spin up a Coder deployment in CI?
45-
def "downloads a working cli"() {
26+
def "overwrites cli if incorrect version"() {
4627
given:
47-
def ccm = new CoderCLIManager(new URL("https://dev.coder.com"), "1.0.1")
48-
def dir = Path.of(System.getProperty("java.io.tmpdir")).resolve("coder-gateway/dev.coder.com")
49-
ccm.localBinaryPath.getParent().toFile().deleteDir()
28+
def ccm = new CoderCLIManager(new URL("https://dev.coder.com"))
29+
Files.createDirectories(ccm.localBinaryPath.getParent())
30+
ccm.localBinaryPath.toFile().write("cli")
5031

5132
when:
5233
def downloaded = ccm.downloadCLI()
5334

5435
then:
5536
downloaded
56-
ccm.localBinaryPath.getParent() == dir
57-
ccm.localBinaryPath.toFile().exists()
5837
ccm.version().contains("Coder")
5938
}
6039

6140
def "skips cli download if it already exists"() {
6241
given:
63-
def ccm = new CoderCLIManager(new URL("https://dev.coder.com"), "1.0.1")
64-
Files.createDirectories(ccm.localBinaryPath.getParent())
65-
ccm.localBinaryPath.toFile().write("cli")
42+
def ccm = new CoderCLIManager(new URL("https://dev.coder.com"))
6643

6744
when:
45+
ccm.downloadCLI()
6846
def downloaded = ccm.downloadCLI()
6947

7048
then:
7149
!downloaded
72-
ccm.localBinaryPath.toFile().readLines() == ["cli"]
50+
ccm.version().contains("Coder")
51+
}
52+
53+
def "does not clobber other deployments"() {
54+
given:
55+
def ccm1 = new CoderCLIManager(new URL("https://oss.demo.coder.com"))
56+
def ccm2 = new CoderCLIManager(new URL("https://dev.coder.com"))
57+
58+
when:
59+
ccm1.downloadCLI()
60+
ccm2.downloadCLI()
61+
62+
then:
63+
ccm1.localBinaryPath != ccm2.localBinaryPath
7364
}
7465

7566
/**

0 commit comments

Comments
 (0)