Skip to content

Commit 8b7913b

Browse files
committed
Allow passing in binary source
Also change destinationDir to allow a null to make it easier to pass in a setting that could be unset.
1 parent ca68ef3 commit 8b7913b

File tree

3 files changed

+85
-30
lines changed

3 files changed

+85
-30
lines changed

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

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,27 +26,35 @@ import javax.xml.bind.annotation.adapters.HexBinaryAdapter
2626
*/
2727
class CoderCLIManager @JvmOverloads constructor(
2828
private val deploymentURL: URL,
29-
destinationDir: Path = getDataDir(),
29+
destinationDir: Path? = null,
30+
remoteBinaryURLOverride: String? = null,
3031
private val sshConfigPath: Path = Path.of(System.getProperty("user.home")).resolve(".ssh/config"),
3132
) {
32-
private var remoteBinaryUrl: URL
33+
var remoteBinaryURL: URL
3334
var localBinaryPath: Path
3435
private var coderConfigPath: Path
3536

3637
init {
3738
val binaryName = getCoderCLIForOS(getOS(), getArch())
38-
remoteBinaryUrl = URL(
39+
remoteBinaryURL = URL(
3940
deploymentURL.protocol,
4041
deploymentURL.host,
4142
deploymentURL.port,
4243
"/bin/$binaryName"
4344
)
44-
// Convert IDN to ASCII in case the file system cannot support the
45-
// necessary character set.
45+
if (!remoteBinaryURLOverride.isNullOrBlank()) {
46+
logger.info("Using remote binary override $remoteBinaryURLOverride")
47+
remoteBinaryURL = try {
48+
remoteBinaryURLOverride.toURL()
49+
} catch (e: Exception) {
50+
remoteBinaryURL.withPath(remoteBinaryURLOverride)
51+
}
52+
}
53+
val dir = destinationDir ?: getDataDir()
4654
val host = getSafeHost(deploymentURL)
4755
val subdir = if (deploymentURL.port > 0) "${host}-${deploymentURL.port}" else host
48-
localBinaryPath = destinationDir.resolve(subdir).resolve(binaryName).toAbsolutePath()
49-
coderConfigPath = destinationDir.resolve(subdir).resolve("config").toAbsolutePath()
56+
localBinaryPath = dir.resolve(subdir).resolve(binaryName).toAbsolutePath()
57+
coderConfigPath = dir.resolve(subdir).resolve("config").toAbsolutePath()
5058
}
5159

5260
/**
@@ -86,7 +94,7 @@ class CoderCLIManager @JvmOverloads constructor(
8694
*/
8795
fun downloadCLI(): Boolean {
8896
val etag = getBinaryETag()
89-
val conn = remoteBinaryUrl.openConnection() as HttpURLConnection
97+
val conn = remoteBinaryURL.openConnection() as HttpURLConnection
9098
if (etag != null) {
9199
logger.info("Found existing binary at $localBinaryPath; calculated hash as $etag")
92100
conn.setRequestProperty("If-None-Match", "\"$etag\"")
@@ -95,7 +103,7 @@ class CoderCLIManager @JvmOverloads constructor(
95103

96104
try {
97105
conn.connect()
98-
logger.info("GET ${conn.responseCode} $remoteBinaryUrl")
106+
logger.info("GET ${conn.responseCode} $remoteBinaryURL")
99107
when (conn.responseCode) {
100108
HttpURLConnection.HTTP_OK -> {
101109
logger.info("Downloading binary to $localBinaryPath")
@@ -124,7 +132,7 @@ class CoderCLIManager @JvmOverloads constructor(
124132
} finally {
125133
conn.disconnect()
126134
}
127-
throw ResponseException("Unexpected response from $remoteBinaryUrl", conn.responseCode)
135+
throw ResponseException("Unexpected response from $remoteBinaryURL", conn.responseCode)
128136
}
129137

130138
/**
@@ -356,6 +364,10 @@ class CoderCLIManager @JvmOverloads constructor(
356364
}
357365
}
358366

367+
/**
368+
* Convert IDN to ASCII in case the file system cannot support the
369+
* necessary character set.
370+
*/
359371
private fun getSafeHost(url: URL): String {
360372
return IDN.toASCII(url.host, IDN.ALLOW_UNASSIGNED)
361373
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,8 @@ fun String.toURL(): URL {
88
}
99

1010
fun URL.withPath(path: String): URL {
11-
return URL(this.protocol, this.host, this.port, path)
11+
return URL(
12+
this.protocol, this.host, this.port,
13+
if (path.startsWith("/")) path else "/$path"
14+
)
1215
}

src/test/groovy/CoderCLIManagerTest.groovy

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,18 @@ import com.sun.net.httpserver.HttpHandler
99
import com.sun.net.httpserver.HttpServer
1010
import spock.lang.Requires
1111
import spock.lang.Shared
12+
import spock.lang.Specification
1213
import spock.lang.Unroll
1314

1415
import java.nio.file.Files
1516
import java.nio.file.Path
1617
import java.nio.file.StandardCopyOption
18+
import java.security.MessageDigest
1719

1820
@Unroll
19-
class CoderCLIManagerTest extends spock.lang.Specification {
21+
class CoderCLIManagerTest extends Specification {
2022
@Shared
2123
private Path tmpdir = Path.of(System.getProperty("java.io.tmpdir")).resolve("coder-gateway-test")
22-
private String mockBinaryContent = "#!/bin/sh\necho Coder"
2324

2425
/**
2526
* Create, start, and return a server that mocks Coder.
@@ -32,22 +33,20 @@ class CoderCLIManagerTest extends spock.lang.Specification {
3233
// TODO: Is there some simple way to create an executable file
3334
// on Windows without having to execute something to generate
3435
// said executable or having to commit one to the repo?
35-
String response = mockBinaryContent
36-
36+
String response = "#!/bin/sh\necho 'http://localhost:${srv.address.port}'"
3737
String[] etags = exchange.requestHeaders.get("If-None-Match")
38-
if (etags != null && etags.contains("\"2f1960264fc0f332a2a7fef2fe678f258dcdff9c\"")) {
39-
code = HttpURLConnection.HTTP_NOT_MODIFIED
40-
response = "not modified"
41-
}
42-
43-
if (!exchange.requestURI.path.startsWith("/bin/coder-")) {
38+
if (exchange.requestURI.path == "/bin/override") {
39+
code = HttpURLConnection.HTTP_OK
40+
response = "#!/bin/sh\necho 'override binary'"
41+
} else if (!exchange.requestURI.path.startsWith("/bin/coder-")) {
4442
code = HttpURLConnection.HTTP_NOT_FOUND
4543
response = "not found"
46-
}
47-
48-
if (errorCode != 0) {
44+
} else if (errorCode != 0) {
4945
code = errorCode
50-
response = "error code ${code}"
46+
response = "error code $code"
47+
} else if (etags != null && etags.contains("\"${sha1(response)}\"")) {
48+
code = HttpURLConnection.HTTP_NOT_MODIFIED
49+
response = "not modified"
5150
}
5251

5352
byte[] body = response.getBytes()
@@ -60,6 +59,23 @@ class CoderCLIManagerTest extends spock.lang.Specification {
6059
return [srv, "http://localhost:" + srv.address.port]
6160
}
6261

62+
String sha1(String input) {
63+
MessageDigest md = MessageDigest.getInstance("SHA-1")
64+
md.update(input.getBytes("UTF-8"))
65+
return new BigInteger(1, md.digest()).toString(16)
66+
}
67+
68+
def "hashes correctly"() {
69+
expect:
70+
sha1(input) == output
71+
72+
where:
73+
input | output
74+
"#!/bin/sh\necho Coder" | "2f1960264fc0f332a2a7fef2fe678f258dcdff9c"
75+
"#!/bin/sh\necho 'override binary'" | "1b562a4b8f2617b2b94a828479656daf2dde3619"
76+
"#!/bin/sh\necho 'http://localhost:5678'" | "fd8d45a8a74475e560e2e57139923254aab75989"
77+
}
78+
6379
void setupSpec() {
6480
// Clean up from previous runs otherwise they get cluttered since the
6581
// mock server port is random.
@@ -140,7 +156,7 @@ class CoderCLIManagerTest extends spock.lang.Specification {
140156
// The mock does not serve a binary that works on Windows so do not
141157
// actually execute. Checking the contents works just as well as proof
142158
// that the binary was correctly downloaded anyway.
143-
ccm.localBinaryPath.toFile().readBytes() == mockBinaryContent.getBytes()
159+
ccm.localBinaryPath.toFile().text == "#!/bin/sh\necho '$url'"
144160

145161
cleanup:
146162
srv.stop(0)
@@ -161,6 +177,7 @@ class CoderCLIManagerTest extends spock.lang.Specification {
161177
downloaded
162178
ccm.localBinaryPath.toFile().readBytes() != "cli".getBytes()
163179
ccm.localBinaryPath.toFile().lastModified() > 0
180+
ccm.localBinaryPath.toFile().text == "#!/bin/sh\necho '$url'"
164181

165182
cleanup:
166183
srv.stop(0)
@@ -197,14 +214,37 @@ class CoderCLIManagerTest extends spock.lang.Specification {
197214

198215
then:
199216
ccm1.localBinaryPath != ccm2.localBinaryPath
200-
ccm1.localBinaryPath.toFile().exists()
201-
ccm2.localBinaryPath.toFile().exists()
217+
ccm1.localBinaryPath.toFile().text == "#!/bin/sh\necho '$url1'"
218+
ccm2.localBinaryPath.toFile().text == "#!/bin/sh\necho '$url2'"
202219

203220
cleanup:
204221
srv1.stop(0)
205222
srv2.stop(0)
206223
}
207224

225+
def "overrides binary URL"() {
226+
given:
227+
def (srv, url) = mockServer()
228+
def ccm = new CoderCLIManager(new URL(url), tmpdir, override.replace("{{url}}", url))
229+
230+
when:
231+
def downloaded = ccm.downloadCLI()
232+
233+
then:
234+
downloaded
235+
ccm.localBinaryPath.toFile().text == "#!/bin/sh\necho '${expected.replace("{{url}}", url)}'"
236+
237+
cleanup:
238+
srv.stop(0)
239+
240+
where:
241+
override | expected
242+
"/bin/override" | "override binary"
243+
"{{url}}/bin/override" | "override binary"
244+
"bin/override" | "override binary"
245+
"" | "{{url}}"
246+
}
247+
208248
Map<String, String> testEnv = [
209249
"APPDATA" : "/tmp/coder-gateway-test/appdata",
210250
"LOCALAPPDATA" : "/tmp/coder-gateway-test/localappdata",
@@ -323,7 +363,7 @@ class CoderCLIManagerTest extends spock.lang.Specification {
323363
def "configures an SSH file"() {
324364
given:
325365
def sshConfigPath = tmpdir.resolve(input + "_to_" + output + ".conf")
326-
def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir, sshConfigPath)
366+
def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir, null, sshConfigPath)
327367
if (input != null) {
328368
Files.createDirectories(sshConfigPath.getParent())
329369
def originalConf = Path.of("src/test/fixtures/inputs").resolve(input + ".conf").toFile().text
@@ -368,7 +408,7 @@ class CoderCLIManagerTest extends spock.lang.Specification {
368408
def "fails if config is malformed"() {
369409
given:
370410
def sshConfigPath = tmpdir.resolve("configured" + input + ".conf")
371-
def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir, sshConfigPath)
411+
def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir, null, sshConfigPath)
372412
Files.createDirectories(sshConfigPath.getParent())
373413
Files.copy(
374414
Path.of("src/test/fixtures/inputs").resolve(input + ".conf"),

0 commit comments

Comments
 (0)