Skip to content

impl: visual text progress during Coder CLI downloading #130

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Added

- visual text progress during Coder CLI downloading

### Changed

- the plugin will now remember the SSH connection state for each workspace, and it will try to automatically
Expand Down
2 changes: 1 addition & 1 deletion src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ class CoderRemoteProvider(
// Show sign in page if we have not configured the client yet.
if (client == null) {
val errorBuffer = mutableListOf<Throwable>()
// When coming back to the application, authenticate immediately.
// When coming back to the application, initializeSession immediately.
val autologin = shouldDoAutoLogin()
context.secrets.lastToken.let { lastToken ->
context.secrets.lastDeploymentURL.let { lastDeploymentURL ->
Expand Down
60 changes: 47 additions & 13 deletions src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import java.net.HttpURLConnection
import java.net.URL
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.nio.file.StandardOpenOption
import java.util.zip.GZIPInputStream
import javax.net.ssl.HttpsURLConnection

Expand All @@ -44,6 +44,8 @@ internal data class Version(
@Json(name = "version") val version: String,
)

private const val DOWNLOADING_CODER_CLI = "Downloading Coder CLI..."

/**
* Do as much as possible to get a valid, up-to-date CLI.
*
Expand All @@ -60,6 +62,7 @@ fun ensureCLI(
context: CoderToolboxContext,
deploymentURL: URL,
buildVersion: String,
showTextProgress: (String) -> Unit
): CoderCLIManager {
val settings = context.settingsStore.readOnly()
val cli = CoderCLIManager(deploymentURL, context.logger, settings)
Expand All @@ -76,9 +79,10 @@ fun ensureCLI(

// If downloads are enabled download the new version.
if (settings.enableDownloads) {
context.logger.info("Downloading Coder CLI...")
context.logger.info(DOWNLOADING_CODER_CLI)
showTextProgress(DOWNLOADING_CODER_CLI)
try {
cli.download()
cli.download(showTextProgress)
return cli
} catch (e: java.nio.file.AccessDeniedException) {
// Might be able to fall back to the data directory.
Expand All @@ -98,8 +102,9 @@ fun ensureCLI(
}

if (settings.enableDownloads) {
context.logger.info("Downloading Coder CLI...")
dataCLI.download()
context.logger.info(DOWNLOADING_CODER_CLI)
showTextProgress(DOWNLOADING_CODER_CLI)
dataCLI.download(showTextProgress)
return dataCLI
}

Expand Down Expand Up @@ -137,7 +142,7 @@ class CoderCLIManager(
/**
* Download the CLI from the deployment if necessary.
*/
fun download(): Boolean {
fun download(showTextProgress: (String) -> Unit): Boolean {
val eTag = getBinaryETag()
val conn = remoteBinaryURL.openConnection() as HttpURLConnection
if (!settings.headerCommand.isNullOrBlank()) {
Expand All @@ -163,12 +168,25 @@ class CoderCLIManager(
HttpURLConnection.HTTP_OK -> {
logger.info("Downloading binary to $localBinaryPath")
Files.createDirectories(localBinaryPath.parent)
conn.inputStream.use {
Files.copy(
if (conn.contentEncoding == "gzip") GZIPInputStream(it) else it,
localBinaryPath,
StandardCopyOption.REPLACE_EXISTING,
)
val outputStream = Files.newOutputStream(
localBinaryPath,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING
)
val sourceStream = if (conn.isGzip()) GZIPInputStream(conn.inputStream) else conn.inputStream

val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytesRead: Int
var totalRead = 0L

sourceStream.use { source ->
outputStream.use { sink ->
while (source.read(buffer).also { bytesRead = it } != -1) {
sink.write(buffer, 0, bytesRead)
totalRead += bytesRead
showTextProgress("Downloaded ${totalRead.toHumanReadableSize()}...")
}
}
}
if (getOS() != OS.WINDOWS) {
localBinaryPath.toFile().setExecutable(true)
Expand All @@ -178,6 +196,7 @@ class CoderCLIManager(

HttpURLConnection.HTTP_NOT_MODIFIED -> {
logger.info("Using cached binary at $localBinaryPath")
showTextProgress("Using cached binary")
return false
}
}
Expand All @@ -190,6 +209,21 @@ class CoderCLIManager(
throw ResponseException("Unexpected response from $remoteBinaryURL", conn.responseCode)
}

private fun HttpURLConnection.isGzip(): Boolean = this.contentEncoding.equals("gzip", ignoreCase = true)

fun Long.toHumanReadableSize(): String {
if (this < 1024) return "$this B"

val kb = this / 1024.0
if (kb < 1024) return String.format("%.1f KB", kb)

val mb = kb / 1024.0
if (mb < 1024) return String.format("%.1f MB", mb)

val gb = mb / 1024.0
return String.format("%.1f GB", gb)
}

/**
* Return the entity tag for the binary on disk, if any.
*/
Expand All @@ -203,7 +237,7 @@ class CoderCLIManager(
}

/**
* Use the provided token to authenticate the CLI.
* Use the provided token to initializeSession the CLI.
*/
fun login(token: String): String {
logger.info("Storing CLI credentials in $coderConfigPath")
Expand Down
12 changes: 8 additions & 4 deletions src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,11 @@ open class CoderRestClient(
}

/**
* Authenticate and load information about the current user and the build
* version.
* Load information about the current user and the build version.
*
* @throws [APIResponseException].
*/
suspend fun authenticate(): User {
suspend fun initializeSession(): User {
me = me()
buildVersion = buildInfo().version
return me
Expand All @@ -149,7 +148,12 @@ open class CoderRestClient(
suspend fun me(): User {
val userResponse = retroRestClient.me()
if (!userResponse.isSuccessful) {
throw APIResponseException("authenticate", url, userResponse.code(), userResponse.parseErrorBody(moshi))
throw APIResponseException(
"initializeSession",
url,
userResponse.code(),
userResponse.parseErrorBody(moshi)
)
}

return userResponse.body()!!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration

private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI"
private val noOpTextProgress: (String) -> Unit = { _ -> }

@Suppress("UnstableApiUsage")
open class CoderProtocolHandler(
Expand Down Expand Up @@ -143,7 +144,7 @@ open class CoderProtocolHandler(
if (settings.requireTokenAuth) token else null,
PluginManager.pluginInfo.version
)
client.authenticate()
client.initializeSession()
return client
}

Expand Down Expand Up @@ -304,7 +305,8 @@ open class CoderProtocolHandler(
val cli = ensureCLI(
context,
deploymentURL.toURL(),
restClient.buildInfo().version
restClient.buildInfo().version,
noOpTextProgress
)

// We only need to log in if we are using token-based auth.
Expand Down
14 changes: 9 additions & 5 deletions src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ class ConnectStep(
signInJob?.cancel()
signInJob = context.cs.launch {
try {
statusField.textState.update { (context.i18n.ptrl("Authenticating to ${AuthContext.url!!.host}...")) }
val client = CoderRestClient(
context,
AuthContext.url!!,
Expand All @@ -86,12 +85,17 @@ class ConnectStep(
)
// allows interleaving with the back/cancel action
yield()
client.authenticate()
statusField.textState.update { (context.i18n.ptrl("Checking Coder binary...")) }
val cli = ensureCLI(context, client.url, client.buildVersion)
client.initializeSession()
statusField.textState.update { (context.i18n.ptrl("Checking Coder CLI...")) }
val cli = ensureCLI(
context, client.url,
client.buildVersion
) { progress ->
statusField.textState.update { (context.i18n.pnotr(progress)) }
}
// We only need to log in if we are using token-based auth.
if (client.token != null) {
statusField.textState.update { (context.i18n.ptrl("Configuring CLI...")) }
statusField.textState.update { (context.i18n.ptrl("Configuring Coder CLI...")) }
// allows interleaving with the back/cancel action
yield()
cli.login(client.token)
Expand Down
36 changes: 19 additions & 17 deletions src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue

private val noOpTextProgress: (String) -> Unit = { _ -> }

internal class CoderCLIManagerTest {
private val context = CoderToolboxContext(
mockk<ToolboxUi>(),
Expand Down Expand Up @@ -145,7 +147,7 @@ internal class CoderCLIManagerTest {
val ex =
assertFailsWith(
exceptionClass = ResponseException::class,
block = { ccm.download() },
block = { ccm.download(noOpTextProgress) },
)
assertEquals(HttpURLConnection.HTTP_INTERNAL_ERROR, ex.code)

Expand Down Expand Up @@ -200,7 +202,7 @@ internal class CoderCLIManagerTest {

assertFailsWith(
exceptionClass = AccessDeniedException::class,
block = { ccm.download() },
block = { ccm.download(noOpTextProgress) },
)

srv.stop(0)
Expand Down Expand Up @@ -229,11 +231,11 @@ internal class CoderCLIManagerTest {
).readOnly(),
)

assertTrue(ccm.download())
assertTrue(ccm.download(noOpTextProgress))
assertDoesNotThrow { ccm.version() }

// It should skip the second attempt.
assertFalse(ccm.download())
assertFalse(ccm.download(noOpTextProgress))

// Make sure login failures propagate.
assertFailsWith(
Expand All @@ -258,11 +260,11 @@ internal class CoderCLIManagerTest {
).readOnly(),
)

assertEquals(true, ccm.download())
assertEquals(true, ccm.download(noOpTextProgress))
assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version())

// It should skip the second attempt.
assertEquals(false, ccm.download())
assertEquals(false, ccm.download(noOpTextProgress))

// Should use the source override.
ccm = CoderCLIManager(
Expand All @@ -278,7 +280,7 @@ internal class CoderCLIManagerTest {
).readOnly(),
)

assertEquals(true, ccm.download())
assertEquals(true, ccm.download(noOpTextProgress))
assertContains(ccm.localBinaryPath.toFile().readText(), "0.0.0")

srv.stop(0)
Expand Down Expand Up @@ -326,7 +328,7 @@ internal class CoderCLIManagerTest {
assertEquals("cli", ccm.localBinaryPath.toFile().readText())
assertEquals(0, ccm.localBinaryPath.toFile().lastModified())

assertTrue(ccm.download())
assertTrue(ccm.download(noOpTextProgress))

assertNotEquals("cli", ccm.localBinaryPath.toFile().readText())
assertNotEquals(0, ccm.localBinaryPath.toFile().lastModified())
Expand All @@ -351,8 +353,8 @@ internal class CoderCLIManagerTest {
val ccm1 = CoderCLIManager(url1, context.logger, settings)
val ccm2 = CoderCLIManager(url2, context.logger, settings)

assertTrue(ccm1.download())
assertTrue(ccm2.download())
assertTrue(ccm1.download(noOpTextProgress))
assertTrue(ccm2.download(noOpTextProgress))

srv1.stop(0)
srv2.stop(0)
Expand Down Expand Up @@ -883,12 +885,12 @@ internal class CoderCLIManagerTest {
Result.ERROR -> {
assertFailsWith(
exceptionClass = AccessDeniedException::class,
block = { ensureCLI(localContext, url, it.buildVersion) },
block = { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) },
)
}

Result.NONE -> {
val ccm = ensureCLI(localContext, url, it.buildVersion)
val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress)
assertEquals(settings.binPath(url), ccm.localBinaryPath)
assertFailsWith(
exceptionClass = ProcessInitException::class,
Expand All @@ -897,25 +899,25 @@ internal class CoderCLIManagerTest {
}

Result.DL_BIN -> {
val ccm = ensureCLI(localContext, url, it.buildVersion)
val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress)
assertEquals(settings.binPath(url), ccm.localBinaryPath)
assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version())
}

Result.DL_DATA -> {
val ccm = ensureCLI(localContext, url, it.buildVersion)
val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress)
assertEquals(settings.binPath(url, true), ccm.localBinaryPath)
assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version())
}

Result.USE_BIN -> {
val ccm = ensureCLI(localContext, url, it.buildVersion)
val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress)
assertEquals(settings.binPath(url), ccm.localBinaryPath)
assertEquals(SemVer.parse(it.version ?: ""), ccm.version())
}

Result.USE_DATA -> {
val ccm = ensureCLI(localContext, url, it.buildVersion)
val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress)
assertEquals(settings.binPath(url, true), ccm.localBinaryPath)
assertEquals(SemVer.parse(it.fallbackVersion ?: ""), ccm.version())
}
Expand Down Expand Up @@ -955,7 +957,7 @@ internal class CoderCLIManagerTest {
context.logger,
).readOnly(),
)
assertEquals(true, ccm.download())
assertEquals(true, ccm.download(noOpTextProgress))
assertEquals(it.second, ccm.features, "version: ${it.first}")

srv.stop(0)
Expand Down
Loading