From 56909a3c80ba5317e350d6e51487d880a1e13b76 Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 10 Apr 2023 13:44:07 -0800 Subject: [PATCH 1/8] Refactor connect flow somewhat Mostly trying to reduce the direct uses of the local model in favor of passing in arguments so they will be easier to test (specifically askToken is pure now). Mention use of the local model in the functions where that is the case and where they use the model I use it for all things (rather than just the URL but pass in the token for example) since everything we need is already in the model. Also rename/suffix some functions to/with connect to match the button. Remove the progress updates to the indicator since they are indeterminate. --- .../gateway/CoderGatewayConnectionProvider.kt | 4 +- .../com/coder/gateway/sdk/CoderCLIManager.kt | 8 +- .../views/steps/CoderWorkspacesStepView.kt | 208 ++++++++++-------- 3 files changed, 127 insertions(+), 93 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt b/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt index ec696796..07b7b961 100644 --- a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt +++ b/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt @@ -21,6 +21,8 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider { override suspend fun connect(parameters: Map, requestor: ConnectionRequestor): GatewayConnectionHandle? { val clientLifetime = LifetimeDefinition() + // TODO: If this fails determine if it is an auth error and if so prompt + // for a new token, configure the CLI, then try again. clientLifetime.launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.coder.connection.provider.title"), canBeCancelled = true, isIndeterminate = true, project = null) { val context = SshMultistagePanelContext(parameters.toHostDeployInputs()) logger.info("Deploying and starting IDE with $context") @@ -43,4 +45,4 @@ class CoderGatewayConnectionProvider : GatewayConnectionProvider { companion object { val logger = Logger.getInstance(CoderGatewayConnectionProvider::class.java.simpleName) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt index ddf99dc7..3f1fc0e8 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt @@ -23,7 +23,7 @@ import javax.xml.bind.annotation.adapters.HexBinaryAdapter /** * Manage the CLI for a single deployment. */ -class CoderCLIManager @JvmOverloads constructor(deployment: URL, destinationDir: Path = getDataDir()) { +class CoderCLIManager @JvmOverloads constructor(private val deployment: URL, destinationDir: Path = getDataDir()) { private var remoteBinaryUrl: URL var localBinaryPath: Path @@ -143,10 +143,10 @@ class CoderCLIManager @JvmOverloads constructor(deployment: URL, destinationDir: } /** - * Use the provided credentials to authenticate the CLI. + * Use the provided token to authenticate the CLI. */ - fun login(url: String, token: String): String { - return exec("login", url, "--token", token) + fun login(token: String): String { + return exec("login", deployment.toString(), "--token", token) } /** diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt index b33de337..becc16b1 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -44,7 +44,15 @@ import com.intellij.ui.RelativeFont import com.intellij.ui.ToolbarDecorator import com.intellij.ui.components.JBTextField import com.intellij.ui.components.dialog -import com.intellij.ui.dsl.builder.* +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.AlignY +import com.intellij.ui.dsl.builder.BottomGap +import com.intellij.ui.dsl.builder.RightGap +import com.intellij.ui.dsl.builder.RowLayout +import com.intellij.ui.dsl.builder.TopGap +import com.intellij.ui.dsl.builder.bindSelected +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.panel import com.intellij.ui.table.TableView import com.intellij.util.ui.ColumnInfo import com.intellij.util.ui.JBFont @@ -68,6 +76,7 @@ import java.awt.event.MouseMotionListener import java.awt.font.TextAttribute import java.awt.font.TextAttribute.UNDERLINE_ON import java.net.SocketTimeoutException +import java.net.URL import javax.swing.Icon import javax.swing.JCheckBox import javax.swing.JTable @@ -214,15 +223,13 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod tfUrl = textField().resizableColumn().align(AlignX.FILL).gap(RightGap.SMALL) .bindText(localWizardModel::coderURL).applyToComponent { addActionListener { - poller?.cancel() - listTableModelOfWorkspaces.items = emptyList() - askTokenAndOpenSession(true) + // Reconnect when the enter key is pressed. + askTokenAndConnect() } }.component button(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.connect.text")) { - poller?.cancel() - listTableModelOfWorkspaces.items = emptyList() - askTokenAndOpenSession(true) + // Reconnect when the connect button is pressed. + askTokenAndConnect() }.applyToComponent { background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() } @@ -347,7 +354,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod localWizardModel.token = token } if (!url.isNullOrBlank() && !token.isNullOrBlank()) { - loginAndLoadWorkspaces(token, true) + connect() } } updateWorkspaceActions() @@ -397,105 +404,126 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod ActivityTracker.getInstance().inc() } - private fun askTokenAndOpenSession(openBrowser: Boolean) { - // force bindings to be filled - component.apply() - - val pastedToken = askToken(openBrowser) + /** + * Ask for a new token (regardless of whether we already have a token), + * place it in the local model, then connect. + */ + private fun askTokenAndConnect(openBrowser: Boolean = true) { + component.apply() // Force bindings to be filled. + val pastedToken = askToken( + localWizardModel.coderURL.toURL(), + localWizardModel.token, + openBrowser, + localWizardModel.useExistingToken, + ) if (pastedToken.isNullOrBlank()) { - return + return // User aborted. } - // False so that subsequent authentication failures do not keep opening - // the browser as it was already opened earlier. - loginAndLoadWorkspaces(pastedToken, false) + localWizardModel.token = pastedToken + // If the token ends up being invalid we will ask for it again; pass + // false so we do not keep endlessly opening the browser. + connect(false) } - private fun loginAndLoadWorkspaces(token: String, openBrowser: Boolean) { - LifetimeDefinition().launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.cli.downloader.dialog.title"), canBeCancelled = false, isIndeterminate = true) { - this.indicator.apply { - text = "Authenticating..." - } + /** + * Connect to the deployment in the local model and if successful store the + * URL and token for use as the default in subsequent launches then load + * workspaces into the table and keep it updated with a poll. + * + * Existing workspaces will be immediately cleared before attempting to + * connect to the new deployment. + * + * If the token is invalid abort and start over from askTokenAndConnect(). + */ + private fun connect(openBrowser: Boolean = true) { + // Clear out old deployment details. + poller?.cancel() + listTableModelOfWorkspaces.items = emptyList() + // Authenticate and load in a background process with progress. + // TODO: Make this cancelable. + LifetimeDefinition().launchUnderBackgroundProgress( + CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.cli.downloader.dialog.title"), + canBeCancelled = false, + isIndeterminate = true + ) { try { - authenticate(token) - } catch (e: AuthenticationResponseException) { - logger.error("Unable to authenticate to ${localWizardModel.coderURL}; has your token expired?", e) - askTokenAndOpenSession(openBrowser) - return@launchUnderBackgroundProgress - } catch (e: SocketTimeoutException) { - logger.error("Unable to connect to ${localWizardModel.coderURL}; is it up?", e) - return@launchUnderBackgroundProgress - } + this.indicator.text = "Authenticating client..." + authenticate(localWizardModel.coderURL.toURL(), localWizardModel.token) + // Remember these in order to default to them for future attempts. + appPropertiesService.setValue(CODER_URL_KEY, localWizardModel.coderURL) + appPropertiesService.setValue(SESSION_TOKEN, localWizardModel.token) - val cliManager = CoderCLIManager(localWizardModel.coderURL.toURL()) - localWizardModel.token = token + this.indicator.text = "Retrieving workspaces..." + loadWorkspaces() - this.indicator.apply { - isIndeterminate = false - text = "Retrieving Workspaces..." - fraction = 0.1 - } + this.indicator.text = "Downloading Coder CLI..." + val cliManager = CoderCLIManager(localWizardModel.coderURL.toURL()) + cliManager.downloadCLI() - loadWorkspaces() + this.indicator.text = "Authenticating Coder CLI..." + cliManager.login(localWizardModel.token) - this.indicator.apply { - isIndeterminate = false - text = "Downloading Coder CLI..." - fraction = 0.3 - } - try { - cliManager.downloadCLI() + this.indicator.text = "Configuring SSH..." + cliManager.configSsh() + + updateWorkspaceActions() + triggerWorkspacePolling(false) + } catch (e: AuthenticationResponseException) { + logger.error("Token was rejected by ${localWizardModel.coderURL}; has your token expired?", e) + askTokenAndConnect(openBrowser) // Try again but no more opening browser windows. + } catch (e: SocketTimeoutException) { + logger.error("Unable to connect to ${localWizardModel.coderURL}; is it up?", e) } catch (e: ResponseException) { - logger.error("Download failed with response code ${e.code}", e) - return@launchUnderBackgroundProgress - } catch (e: Exception) { logger.error("Failed to download Coder CLI", e) - return@launchUnderBackgroundProgress - } - this.indicator.apply { - text = "Logging in..." - fraction = 0.5 - } - cliManager.login(localWizardModel.coderURL, localWizardModel.token) - - this.indicator.apply { - text = "Configuring SSH..." - fraction = 0.7 + } catch (e: Exception) { + logger.error("Failed to configure connection to ${localWizardModel.coderURL}", e) } - cliManager.configSsh() - - this.indicator.fraction = 1.0 - updateWorkspaceActions() - triggerWorkspacePolling(false) } } - private fun askToken(openBrowser: Boolean): String? { - val getTokenUrl = localWizardModel.coderURL.toURL().withPath("/login?redirect=%2Fcli-auth") - if (openBrowser && !localWizardModel.useExistingToken) { + /** + * Open a dialog for providing the token. Show the existing token so the + * user can validate it if a previous connection failed. Open a browser to + * the auth page if openBrowser is true and useExisting is false. If + * useExisting is true then populate the dialog with the token on disk if + * there is one and it matches the url (this will overwrite the provided + * token). Return the token submitted by the user. + */ + private fun askToken(url: URL, token: String, openBrowser: Boolean, useExisting: Boolean): String? { + var existingToken = token + val getTokenUrl = url.withPath("/login?redirect=%2Fcli-auth") + if (openBrowser && !useExisting) { BrowserUtil.browse(getTokenUrl) - } else if (localWizardModel.useExistingToken) { - val (url, token) = CoderCLIManager.readConfig() - if (url == localWizardModel.coderURL && !token.isNullOrBlank()) { + } else if (useExisting) { + val (u, t) = CoderCLIManager.readConfig() + if (url == u?.toURL() && !t.isNullOrBlank()) { logger.info("Injecting valid token from CLI config") - localWizardModel.token = token + existingToken = t } } var tokenFromUser: String? = null ApplicationManager.getApplication().invokeAndWait({ lateinit var sessionTokenTextField: JBTextField - val panel = panel { row { - browserLink(CoderGatewayBundle.message("gateway.connector.view.login.token.label"), getTokenUrl.toString()) - sessionTokenTextField = textField().bindText(localWizardModel::token).applyToComponent { + browserLink( + CoderGatewayBundle.message("gateway.connector.view.login.token.label"), + getTokenUrl.toString() + ) + sessionTokenTextField = textField().applyToComponent { + text = existingToken minimumSize = Dimension(320, -1) }.component } } - AppIcon.getInstance().requestAttention(null, true) - if (!dialog(CoderGatewayBundle.message("gateway.connector.view.login.token.dialog"), panel = panel, focusedComponent = sessionTokenTextField).showAndGet()) { + if (!dialog( + CoderGatewayBundle.message("gateway.connector.view.login.token.dialog"), + panel = panel, + focusedComponent = sessionTokenTextField + ).showAndGet() + ) { return@invokeAndWait } tokenFromUser = sessionTokenTextField.text @@ -518,13 +546,13 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } /** - * Check that the token is valid for the URL in the wizard and throw if not. - * On success store the URL and token and display warning banners if - * versions do not match. + * Authenticate the Coder client with the provided token and URL. On + * failure throw an error. On success display warning banners if versions + * do not match. */ - private fun authenticate(token: String) { - logger.info("Authenticating to ${localWizardModel.coderURL}...") - coderClient.initClientSession(localWizardModel.coderURL.toURL(), token) + private fun authenticate(url: URL, token: String) { + logger.info("Authenticating to $url...") + coderClient.initClientSession(url, token) try { logger.info("Checking compatibility with Coder version ${coderClient.buildVersion}...") @@ -534,7 +562,12 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod logger.warn(e) notificationBanner.apply { component.isVisible = true - showWarning(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.invalid.coder.version", coderClient.buildVersion)) + showWarning( + CoderGatewayBundle.message( + "gateway.connector.view.coder.workspaces.invalid.coder.version", + coderClient.buildVersion + ) + ) } } catch (e: IncompatibleVersionException) { logger.warn(e) @@ -545,12 +578,11 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } logger.info("Authenticated successfully") - - // Remember these in order to default to them for future attempts. - appPropertiesService.setValue(CODER_URL_KEY, localWizardModel.coderURL) - appPropertiesService.setValue(SESSION_TOKEN, token) } + /** + * Request workspaces then update the table. + */ private suspend fun loadWorkspaces() { val ws = withContext(Dispatchers.IO) { val timeBeforeRequestingWorkspaces = System.currentTimeMillis() From 9eaeb0f7404f2584f6f11bf99fc599633d7f665c Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 13 Apr 2023 15:34:56 -0800 Subject: [PATCH 2/8] Support multiple deployments This also lets us set a custom environment variable to track JetBrains sessions. --- .../models/CoderWorkspacesWizardModel.kt | 2 +- .../com/coder/gateway/sdk/CoderCLIManager.kt | 114 ++++- .../steps/CoderLocateRemoteProjectStepView.kt | 32 +- .../views/steps/CoderWorkspacesStepView.kt | 29 +- src/test/groovy/CoderCLIManagerTest.groovy | 411 ++++++++++++++++++ 5 files changed, 556 insertions(+), 32 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt b/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt index 78c63120..290092ff 100644 --- a/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt +++ b/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt @@ -4,5 +4,5 @@ data class CoderWorkspacesWizardModel( var coderURL: String = "https://coder.example.com", var token: String = "", var selectedWorkspace: WorkspaceAgentModel? = null, - var useExistingToken: Boolean = false + var useExistingToken: Boolean = false, ) diff --git a/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt index 3f1fc0e8..be3e27a2 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt @@ -1,5 +1,6 @@ package com.coder.gateway.sdk +import com.coder.gateway.models.WorkspaceAgentModel import com.coder.gateway.views.steps.CoderWorkspacesStepView import com.intellij.openapi.diagnostic.Logger import org.zeroturnaround.exec.ProcessExecutor @@ -23,23 +24,25 @@ import javax.xml.bind.annotation.adapters.HexBinaryAdapter /** * Manage the CLI for a single deployment. */ -class CoderCLIManager @JvmOverloads constructor(private val deployment: URL, destinationDir: Path = getDataDir()) { +class CoderCLIManager @JvmOverloads constructor(private val deploymentURL: URL, destinationDir: Path = getDataDir()) { private var remoteBinaryUrl: URL var localBinaryPath: Path + private var coderConfigPath: Path init { val binaryName = getCoderCLIForOS(getOS(), getArch()) remoteBinaryUrl = URL( - deployment.protocol, - deployment.host, - deployment.port, + deploymentURL.protocol, + deploymentURL.host, + deploymentURL.port, "/bin/$binaryName" ) // Convert IDN to ASCII in case the file system cannot support the // necessary character set. - val host = IDN.toASCII(deployment.host, IDN.ALLOW_UNASSIGNED) - val subdir = if (deployment.port > 0) "${host}-${deployment.port}" else host + val host = getSafeHost(deploymentURL) + val subdir = if (deploymentURL.port > 0) "${host}-${deploymentURL.port}" else host localBinaryPath = destinationDir.resolve(subdir).resolve(binaryName) + coderConfigPath = destinationDir.resolve(subdir).resolve("config") } /** @@ -146,17 +149,93 @@ class CoderCLIManager @JvmOverloads constructor(private val deployment: URL, des * Use the provided token to authenticate the CLI. */ fun login(token: String): String { - return exec("login", deployment.toString(), "--token", token) + logger.info("Storing CLI credentials in $coderConfigPath") + return exec( + "login", + deploymentURL.toString(), + "--token", + token, + "--global-config", + coderConfigPath.toAbsolutePath().toString(), + ) } /** * Configure SSH to use this binary. - * - * TODO: Support multiple deployments; currently they will clobber each - * other. */ - fun configSsh(): String { - return exec("config-ssh", "--yes", "--use-previous-options") + fun configSsh( + workspaces: List, + sshConfigPath: Path = Path.of(System.getProperty("user.home")).resolve(".ssh/config"), + ) { + val host = getSafeHost(deploymentURL) + val startBlock = "# --- START CODER JETBRAINS $host" + val endBlock = "# --- END CODER JETBRAINS $host" + val isRemoving = workspaces.isEmpty() + val blockContent = workspaces.joinToString( + System.lineSeparator(), + startBlock + System.lineSeparator(), + System.lineSeparator() + endBlock, + transform = { + """ + Host ${getHostName(deploymentURL, it)} + HostName coder.${it.name} + ProxyCommand "${localBinaryPath.toAbsolutePath()}" --global-config "${coderConfigPath.toAbsolutePath()}" ssh --stdio ${it.name} + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + """.trimIndent().replace("\n", System.lineSeparator()) + }) + Files.createDirectories(sshConfigPath.parent) + try { + val contents = sshConfigPath.toFile().readText() + val start = "(\\s*)$startBlock".toRegex().find(contents) + val end = "$endBlock(\\s*)".toRegex().find(contents) + if (start == null && end == null && isRemoving) { + logger.info("Leaving $sshConfigPath alone since there are no workspaces and no config to remove") + } else if (start == null && end == null) { + logger.info("Appending config to $sshConfigPath") + sshConfigPath.toFile().writeText( + if (contents.isEmpty()) blockContent else listOf( + contents, + blockContent + ).joinToString(System.lineSeparator()) + ) + } else if (start == null) { + throw SSHConfigFormatException("End block exists but no start block") + } else if (end == null) { + throw SSHConfigFormatException("Start block exists but no end block") + } else if (start.range.first > end.range.first) { + throw SSHConfigFormatException("Start block found after end block") + } else if (isRemoving) { + logger.info("Removing config from $sshConfigPath") + sshConfigPath.toFile().writeText( + listOf( + contents.substring(0, start.range.first), + // Need to keep the trailing newline(s) if we are not at + // the front of the file otherwise the before and after + // lines would get joined. + if (start.range.first > 0) end.groupValues[1] else "", + contents.substring(end.range.last + 1) + ).joinToString("") + ) + } else { + logger.info("Replacing config in $sshConfigPath") + sshConfigPath.toFile().writeText( + listOf( + contents.substring(0, start.range.first), + start.groupValues[1], // Leading newline(s). + blockContent, + end.groupValues[1], // Trailing newline(s). + contents.substring(end.range.last + 1) + ).joinToString("") + ) + } + } catch (e: FileNotFoundException) { + logger.info("Writing config to $sshConfigPath") + sshConfigPath.toFile().writeText(blockContent) + } } /** @@ -241,6 +320,15 @@ class CoderCLIManager @JvmOverloads constructor(private val deployment: URL, des } } } + + private fun getSafeHost(url: URL): String { + return IDN.toASCII(url.host, IDN.ALLOW_UNASSIGNED) + } + + @JvmStatic + fun getHostName(url: URL, ws: WorkspaceAgentModel): String { + return "coder-jetbrains--${ws.name}--${getSafeHost(url)}" + } } } @@ -255,3 +343,5 @@ class Environment(private val env: Map = emptyMap()) { } class ResponseException(message: String, val code: Int) : Exception(message) + +class SSHConfigFormatException(message: String) : Exception(message) diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt index a3b6a833..420c7eac 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt @@ -5,8 +5,10 @@ import com.coder.gateway.icons.CoderIcons import com.coder.gateway.models.CoderWorkspacesWizardModel import com.coder.gateway.models.WorkspaceAgentModel import com.coder.gateway.sdk.Arch +import com.coder.gateway.sdk.CoderCLIManager import com.coder.gateway.sdk.CoderRestClientService import com.coder.gateway.sdk.OS +import com.coder.gateway.sdk.toURL import com.coder.gateway.sdk.withPath import com.coder.gateway.toWorkspaceParams import com.coder.gateway.views.LazyBrowserLink @@ -30,7 +32,12 @@ import com.intellij.ui.AnimatedIcon import com.intellij.ui.ColoredListCellRenderer import com.intellij.ui.DocumentAdapter import com.intellij.ui.components.JBTextField -import com.intellij.ui.dsl.builder.* +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.BottomGap +import com.intellij.ui.dsl.builder.RightGap +import com.intellij.ui.dsl.builder.RowLayout +import com.intellij.ui.dsl.builder.TopGap +import com.intellij.ui.dsl.builder.panel import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil @@ -61,7 +68,7 @@ import kotlinx.coroutines.withContext import java.awt.Component import java.awt.FlowLayout import java.time.Duration -import java.util.* +import java.util.Locale import javax.swing.ComboBoxModel import javax.swing.DefaultComboBoxModel import javax.swing.JLabel @@ -151,9 +158,11 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea override fun onInit(wizardModel: CoderWorkspacesWizardModel) { cbIDE.renderer = IDECellRenderer() ideComboBoxModel.removeAllElements() + val deploymentURL = wizardModel.coderURL.toURL() val selectedWorkspace = wizardModel.selectedWorkspace if (selectedWorkspace == null) { - logger.warn("No workspace was selected. Please go back to the previous step and select a Coder Workspace") + // TODO: Should be impossible, tweak the types/flow to enforce this. + logger.warn("No workspace was selected. Please go back to the previous step and select a workspace") return } @@ -163,7 +172,9 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea ideResolvingJob = cs.launch { try { - val executor = withTimeout(Duration.ofSeconds(60)) { createRemoteExecutor(selectedWorkspace) } + val executor = withTimeout(Duration.ofSeconds(60)) { + createRemoteExecutor(CoderCLIManager.getHostName(deploymentURL, selectedWorkspace)) + } retrieveIDES(executor, selectedWorkspace) if (ComponentValidator.getInstance(tfProject).isEmpty) { installRemotePathValidator(executor) @@ -235,10 +246,10 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea }) } - private suspend fun createRemoteExecutor(selectedWorkspace: WorkspaceAgentModel): HighLevelHostAccessor { + private suspend fun createRemoteExecutor(host: String): HighLevelHostAccessor { return HighLevelHostAccessor.create( RemoteCredentialsHolder().apply { - setHost("coder.${selectedWorkspace.name}") + setHost(host) userName = "coder" port = 22 authType = AuthType.OPEN_SSH @@ -310,11 +321,18 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea override fun onNext(wizardModel: CoderWorkspacesWizardModel): Boolean { val selectedIDE = cbIDE.selectedItem ?: return false logger.info("Going to launch the IDE") + val deploymentURL = wizardModel.coderURL.toURL() + val selectedWorkspace = wizardModel.selectedWorkspace + if (selectedWorkspace == null) { + // TODO: Should be impossible, tweak the types/flow to enforce this. + logger.warn("No workspace was selected. Please go back to the previous step and select a workspace") + return false + } cs.launch { GatewayUI.getInstance().connect( selectedIDE .toWorkspaceParams() - .withWorkspaceHostname("coder.${wizardModel.selectedWorkspace?.name}") + .withWorkspaceHostname(CoderCLIManager.getHostName(deploymentURL, selectedWorkspace)) .withProjectPath(tfProject.text) .withWebTerminalLink("${terminalLink.url}") ) diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt index becc16b1..386d6d81 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -440,6 +440,9 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod poller?.cancel() listTableModelOfWorkspaces.items = emptyList() + val deploymentURL = localWizardModel.coderURL.toURL() + val token = localWizardModel.token + // Authenticate and load in a background process with progress. // TODO: Make this cancelable. LifetimeDefinition().launchUnderBackgroundProgress( @@ -449,35 +452,32 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod ) { try { this.indicator.text = "Authenticating client..." - authenticate(localWizardModel.coderURL.toURL(), localWizardModel.token) + authenticate(deploymentURL, token) // Remember these in order to default to them for future attempts. - appPropertiesService.setValue(CODER_URL_KEY, localWizardModel.coderURL) - appPropertiesService.setValue(SESSION_TOKEN, localWizardModel.token) + appPropertiesService.setValue(CODER_URL_KEY, deploymentURL.toString()) + appPropertiesService.setValue(SESSION_TOKEN, token) this.indicator.text = "Retrieving workspaces..." loadWorkspaces() this.indicator.text = "Downloading Coder CLI..." - val cliManager = CoderCLIManager(localWizardModel.coderURL.toURL()) + val cliManager = CoderCLIManager(deploymentURL) cliManager.downloadCLI() this.indicator.text = "Authenticating Coder CLI..." - cliManager.login(localWizardModel.token) - - this.indicator.text = "Configuring SSH..." - cliManager.configSsh() + cliManager.login(token) updateWorkspaceActions() triggerWorkspacePolling(false) } catch (e: AuthenticationResponseException) { - logger.error("Token was rejected by ${localWizardModel.coderURL}; has your token expired?", e) - askTokenAndConnect(openBrowser) // Try again but no more opening browser windows. + logger.error("Token was rejected by $deploymentURL; has your token expired?", e) + askTokenAndConnect(openBrowser) } catch (e: SocketTimeoutException) { - logger.error("Unable to connect to ${localWizardModel.coderURL}; is it up?", e) + logger.error("Unable to connect to $deploymentURL; is it up?", e) } catch (e: ResponseException) { logger.error("Failed to download Coder CLI", e) } catch (e: Exception) { - logger.error("Failed to configure connection to ${localWizardModel.coderURL}", e) + logger.error("Failed to configure connection to $deploymentURL", e) } } } @@ -705,6 +705,11 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod if (workspace != null) { wizardModel.selectedWorkspace = workspace poller?.cancel() + + logger.info("Configuring Coder CLI...") + val cliManager = CoderCLIManager(wizardModel.coderURL.toURL()) + cliManager.configSsh(listTableModelOfWorkspaces.items) + logger.info("Opening IDE and Project Location window for ${workspace.name}") return true } diff --git a/src/test/groovy/CoderCLIManagerTest.groovy b/src/test/groovy/CoderCLIManagerTest.groovy index 66c4d6d7..08553439 100644 --- a/src/test/groovy/CoderCLIManagerTest.groovy +++ b/src/test/groovy/CoderCLIManagerTest.groovy @@ -1,5 +1,9 @@ package com.coder.gateway.sdk +import com.coder.gateway.models.WorkspaceAgentModel +import com.coder.gateway.models.WorkspaceAgentStatus +import com.coder.gateway.models.WorkspaceVersionStatus +import com.coder.gateway.sdk.v2.models.WorkspaceTransition import com.sun.net.httpserver.HttpExchange import com.sun.net.httpserver.HttpHandler import com.sun.net.httpserver.HttpServer @@ -296,4 +300,411 @@ class CoderCLIManagerTest extends spock.lang.Specification { expect: Path.of("/tmp/coder-gateway-test/localappdata/coder-gateway") == dataDir() } + + private WorkspaceAgentModel randWorkspace(String name) { + return new WorkspaceAgentModel( + UUID.randomUUID(), + name, + name, + UUID.randomUUID(), + "template-name", + "template-icon-path", + null, + WorkspaceVersionStatus.UPDATED, + WorkspaceAgentStatus.RUNNING, + WorkspaceTransition.START, + null, + null, + null + ) + } + + def "configures empty SSH file with multiple hosts"() { + given: + def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir) + def sshConfigPath = tmpdir.resolve("config-nonexistent") + def coderConfigPath = ccm.localBinaryPath.getParent().resolve("config") + + when: + ccm.configSsh(List.of(randWorkspace("foo"), randWorkspace("bar")), sshConfigPath) + + then: + sshConfigPath.toFile().text == """\ + # --- START CODER JETBRAINS test.coder.invalid + Host coder-jetbrains--foo--test.coder.invalid + HostName coder.foo + ProxyCommand "${ccm.localBinaryPath}" --global-config "$coderConfigPath" ssh --stdio foo + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + Host coder-jetbrains--bar--test.coder.invalid + HostName coder.bar + ProxyCommand "${ccm.localBinaryPath}" --global-config "$coderConfigPath" ssh --stdio bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS test.coder.invalid""".stripIndent().replace("\n", System.lineSeparator()) + } + + def "configures existing SSH file with one host"() { + given: + def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir) + def sshConfigPath = tmpdir.resolve("config-existing") + Files.createDirectories(sshConfigPath.getParent()) + sshConfigPath.toFile().write(contents.stripIndent().replace("\n", System.lineSeparator())) + + when: + ccm.configSsh(List.of(randWorkspace("foo-bar")), sshConfigPath) + + then: + sshConfigPath.toFile().text == expected.stripIndent().replace("\n", System.lineSeparator()) + + where: + contents << { + // Blank file. + ["", + + // Preserve existing newlines. + """\ + + + """, + + // Append to config; ignore unrelated blocks. + """\ + Host test + Port 80 + # ------------START-CODER----------- + some coder config + # ------------END-CODER------------""", + + // Replace config at end without newlines. + """\ + Host test + Port 80 # --- START CODER JETBRAINS test.coder.invalid + some jetbrains config # --- END CODER JETBRAINS test.coder.invalid""", + + // Replace config at the middle; ignore unrelated blocks. + """\ + Host test + Port 80 + # ------------START-CODER----------- + some coder config + # ------------END-CODER------------ + # --- START CODER JETBRAINS test.coder.invalid + some jetbrains config + # --- END CODER JETBRAINS test.coder.invalid + Host * + HostName localhost + # --- START CODER JETBRAINS test.coder.unrelated + some jetbrains config + # --- END CODER JETBRAINS test.coder.unrelated""", + + // Replace config at the start without leading newline. + """\ + # --- START CODER JETBRAINS test.coder.invalid + some jetbrains config + # --- END CODER JETBRAINS test.coder.invalid + Host test + Port 80""", + + // Replace config at the end without trailing newline. + """\ + Host test + Port 80 + # --- START CODER JETBRAINS test.coder.invalid + some jetbrains config + # --- END CODER JETBRAINS test.coder.invalid""", + + // Replace config at the end with newlines. + """\ + Host test + Port 80 + # --- START CODER JETBRAINS test.coder.invalid + some jetbrains config + # --- END CODER JETBRAINS test.coder.invalid + """, + ] + }() + + expected << { + // Unfortunately you cannot access local vars in the where block so + // they have to be recreated. + def lbp = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir).localBinaryPath + def cp = lbp.getParent().resolve("config") + ["""\ + # --- START CODER JETBRAINS test.coder.invalid + Host coder-jetbrains--foo-bar--test.coder.invalid + HostName coder.foo-bar + ProxyCommand "$lbp" --global-config "$cp" ssh --stdio foo-bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS test.coder.invalid""", + + """\ + + + + # --- START CODER JETBRAINS test.coder.invalid + Host coder-jetbrains--foo-bar--test.coder.invalid + HostName coder.foo-bar + ProxyCommand "$lbp" --global-config "$cp" ssh --stdio foo-bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS test.coder.invalid""", + + """\ + Host test + Port 80 + # ------------START-CODER----------- + some coder config + # ------------END-CODER------------ + # --- START CODER JETBRAINS test.coder.invalid + Host coder-jetbrains--foo-bar--test.coder.invalid + HostName coder.foo-bar + ProxyCommand "$lbp" --global-config "$cp" ssh --stdio foo-bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS test.coder.invalid""", + + """\ + Host test + Port 80 # --- START CODER JETBRAINS test.coder.invalid + Host coder-jetbrains--foo-bar--test.coder.invalid + HostName coder.foo-bar + ProxyCommand "$lbp" --global-config "$cp" ssh --stdio foo-bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS test.coder.invalid""", + + """\ + Host test + Port 80 + # ------------START-CODER----------- + some coder config + # ------------END-CODER------------ + # --- START CODER JETBRAINS test.coder.invalid + Host coder-jetbrains--foo-bar--test.coder.invalid + HostName coder.foo-bar + ProxyCommand "$lbp" --global-config "$cp" ssh --stdio foo-bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS test.coder.invalid + Host * + HostName localhost + # --- START CODER JETBRAINS test.coder.unrelated + some jetbrains config + # --- END CODER JETBRAINS test.coder.unrelated""", + + """\ + # --- START CODER JETBRAINS test.coder.invalid + Host coder-jetbrains--foo-bar--test.coder.invalid + HostName coder.foo-bar + ProxyCommand "$lbp" --global-config "$cp" ssh --stdio foo-bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS test.coder.invalid + Host test + Port 80""", + + """\ + Host test + Port 80 + # --- START CODER JETBRAINS test.coder.invalid + Host coder-jetbrains--foo-bar--test.coder.invalid + HostName coder.foo-bar + ProxyCommand "$lbp" --global-config "$cp" ssh --stdio foo-bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS test.coder.invalid""", + + """\ + Host test + Port 80 + # --- START CODER JETBRAINS test.coder.invalid + Host coder-jetbrains--foo-bar--test.coder.invalid + HostName coder.foo-bar + ProxyCommand "$lbp" --global-config "$cp" ssh --stdio foo-bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS test.coder.invalid + """, + ] + }() + } + + def "removes empty block including newlines"() { + def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir) + def configPath = tmpdir.resolve("config-remove") + Files.createDirectories(configPath.getParent()) + configPath.toFile().write(contents.stripIndent().replace("\n", System.lineSeparator())) + + when: + ccm.configSsh(List.of(), configPath) + + then: + configPath.toFile().text == expected.stripIndent().replace("\n", System.lineSeparator()) + + where: + contents << [ + // Nothing to remove. + """\ + Host test + Port 80 + Host test2 + Port 443""", + + // Remove at the end without a trailing newline. + """\ + Host test + Port 80 + Host test2 + Port 443 + # --- START CODER JETBRAINS test.coder.invalid + some jetbrains config + # --- END CODER JETBRAINS test.coder.invalid""", + + // Remove in the middle without a leading newline. + """\ + Host test + Port 80 # --- START CODER JETBRAINS test.coder.invalid + some jetbrains config + # --- END CODER JETBRAINS test.coder.invalid + Host test2 + Port 443""", + + // Remove at the middle without any newlines. + """\ + Host test + Port 80 + Host test2 + Port 443 # --- START CODER JETBRAINS test.coder.invalid + some jetbrains config # --- END CODER JETBRAINS test.coder.invalid""", + + // Remove at the start. + """\ + # --- START CODER JETBRAINS test.coder.invalid + some jetbrains config + # --- END CODER JETBRAINS test.coder.invalid + Host test + Port 80 + Host test2 + Port 443""", + + // Remove at the end with a trailing newline. + """\ + Host test + Port 80 + Host test2 + Port 443 + # --- START CODER JETBRAINS test.coder.invalid + some jetbrains config + # --- END CODER JETBRAINS test.coder.invalid + """, + + // Remove everything. + """\ + # --- START CODER JETBRAINS test.coder.invalid + some jetbrains config + # --- END CODER JETBRAINS test.coder.invalid + """ + ] + + expected << [ + """\ + Host test + Port 80 + Host test2 + Port 443""", + + """\ + Host test + Port 80 + Host test2 + Port 443""", + + """\ + Host test + Port 80 + Host test2 + Port 443""", + + """\ + Host test + Port 80 + Host test2 + Port 443""", + + """\ + Host test + Port 80 + Host test2 + Port 443""", + + """\ + Host test + Port 80 + Host test2 + Port 443 + """, + + """""", + ] + } + + def "fails if config is malformed"() { + given: + def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir) + def configPath = tmpdir.resolve("config-malformed") + Files.createDirectories(configPath.getParent()) + configPath.toFile().write(content.stripIndent().replace("\n", System.lineSeparator())) + + when: + ccm.configSsh(List.of(), configPath) + + then: + thrown(SSHConfigFormatException) + + where: + content << [ + """# --- START CODER JETBRAINS test.coder.invalid + some jetbrains config""", + """some jetbrains config + # --- END CODER JETBRAINS test.coder.invalid""", + """# --- END CODER JETBRAINS test.coder.invalid + some jetbrains config + # --- START CODER JETBRAINS test.coder.invalid""", + """# --- START CODER JETBRAINS test.coder.something-else + some jetbrains config + # --- END CODER JETBRAINS test.coder.invalid""", + ] + } } From cbdc9bd27c9c21c4cee59a328b5663d59cda40dc Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 14 Apr 2023 11:12:26 -0800 Subject: [PATCH 3/8] Tweak open browser logic Currently if you launch the Coder plugin from Gateway and it automatically picks up a token from the config or from your last launch but it is invalid it will open a browser window for a new token which I think could be jarring. We should only open a browser window when the user explicitly wants that by pressing connect for example. --- .../coder/gateway/views/steps/CoderWorkspacesStepView.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt index 386d6d81..0a9bd750 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -420,9 +420,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod return // User aborted. } localWizardModel.token = pastedToken - // If the token ends up being invalid we will ask for it again; pass - // false so we do not keep endlessly opening the browser. - connect(false) + connect() } /** @@ -435,7 +433,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod * * If the token is invalid abort and start over from askTokenAndConnect(). */ - private fun connect(openBrowser: Boolean = true) { + private fun connect() { // Clear out old deployment details. poller?.cancel() listTableModelOfWorkspaces.items = emptyList() @@ -471,7 +469,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod triggerWorkspacePolling(false) } catch (e: AuthenticationResponseException) { logger.error("Token was rejected by $deploymentURL; has your token expired?", e) - askTokenAndConnect(openBrowser) + askTokenAndConnect(false) // Try again but no more opening browser windows. } catch (e: SocketTimeoutException) { logger.error("Unable to connect to $deploymentURL; is it up?", e) } catch (e: ResponseException) { From 9534c0a4c4fd0fa8096908e7e6317a1ccd65a777 Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 14 Apr 2023 11:20:42 -0800 Subject: [PATCH 4/8] Tweak retry logic --- .../views/steps/CoderWorkspacesStepView.kt | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt index 0a9bd750..60c974e0 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -354,7 +354,12 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod localWizardModel.token = token } if (!url.isNullOrBlank() && !token.isNullOrBlank()) { - connect() + // It could be jarring to suddenly ask for a token when you are + // just trying to launch the Coder plugin so in this case where + // we are trying to automatically connect to the last deployment + // (or the deployment in the CLI config) do not ask for the + // token again until they explicitly press connect. + connect(false) } } updateWorkspaceActions() @@ -431,9 +436,10 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod * Existing workspaces will be immediately cleared before attempting to * connect to the new deployment. * - * If the token is invalid abort and start over from askTokenAndConnect(). + * If the token is invalid abort and start over from askTokenAndConnect() + * unless retry is false. */ - private fun connect() { + private fun connect(retry: Boolean = true) { // Clear out old deployment details. poller?.cancel() listTableModelOfWorkspaces.items = emptyList() @@ -469,7 +475,9 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod triggerWorkspacePolling(false) } catch (e: AuthenticationResponseException) { logger.error("Token was rejected by $deploymentURL; has your token expired?", e) - askTokenAndConnect(false) // Try again but no more opening browser windows. + if (retry) { + askTokenAndConnect(false) // Try again but no more opening browser windows. + } } catch (e: SocketTimeoutException) { logger.error("Unable to connect to $deploymentURL; is it up?", e) } catch (e: ResponseException) { From 59527b05e0dd9b485ad8efee3e57547ebf70dc0c Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 14 Apr 2023 11:24:18 -0800 Subject: [PATCH 5/8] Increase token input width Wide enough to see the whole token at once. --- .../com/coder/gateway/views/steps/CoderWorkspacesStepView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt index 60c974e0..8cf9ff8a 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -519,7 +519,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod ) sessionTokenTextField = textField().applyToComponent { text = existingToken - minimumSize = Dimension(320, -1) + minimumSize = Dimension(520, -1) }.component } } From 17c0edf0c853688445121eac2441b021b11d61f5 Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 17 Apr 2023 11:20:50 -0800 Subject: [PATCH 6/8] Ensure absolute paths --- .../com/coder/gateway/sdk/CoderCLIManager.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt index be3e27a2..1d685eaf 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt @@ -41,8 +41,8 @@ class CoderCLIManager @JvmOverloads constructor(private val deploymentURL: URL, // necessary character set. val host = getSafeHost(deploymentURL) val subdir = if (deploymentURL.port > 0) "${host}-${deploymentURL.port}" else host - localBinaryPath = destinationDir.resolve(subdir).resolve(binaryName) - coderConfigPath = destinationDir.resolve(subdir).resolve("config") + localBinaryPath = destinationDir.resolve(subdir).resolve(binaryName).toAbsolutePath() + coderConfigPath = destinationDir.resolve(subdir).resolve("config").toAbsolutePath() } /** @@ -84,7 +84,7 @@ class CoderCLIManager @JvmOverloads constructor(private val deploymentURL: URL, val etag = getBinaryETag() val conn = remoteBinaryUrl.openConnection() as HttpURLConnection if (etag != null) { - logger.info("Found existing binary at ${localBinaryPath.toAbsolutePath()}; calculated hash as $etag") + logger.info("Found existing binary at $localBinaryPath; calculated hash as $etag") conn.setRequestProperty("If-None-Match", "\"$etag\"") } conn.setRequestProperty("Accept-Encoding", "gzip") @@ -94,7 +94,7 @@ class CoderCLIManager @JvmOverloads constructor(private val deploymentURL: URL, logger.info("GET ${conn.responseCode} $remoteBinaryUrl") when (conn.responseCode) { HttpURLConnection.HTTP_OK -> { - logger.info("Downloading binary to ${localBinaryPath.toAbsolutePath()}") + logger.info("Downloading binary to $localBinaryPath") Files.createDirectories(localBinaryPath.parent) conn.inputStream.use { Files.copy( @@ -113,7 +113,7 @@ class CoderCLIManager @JvmOverloads constructor(private val deploymentURL: URL, } HttpURLConnection.HTTP_NOT_MODIFIED -> { - logger.info("Using cached binary at ${localBinaryPath.toAbsolutePath()}") + logger.info("Using cached binary at $localBinaryPath") return false } } @@ -140,7 +140,7 @@ class CoderCLIManager @JvmOverloads constructor(private val deploymentURL: URL, } catch (e: FileNotFoundException) { null } catch (e: Exception) { - logger.warn("Unable to calculate hash for ${localBinaryPath.toAbsolutePath()}", e) + logger.warn("Unable to calculate hash for $localBinaryPath", e) null } } @@ -156,7 +156,7 @@ class CoderCLIManager @JvmOverloads constructor(private val deploymentURL: URL, "--token", token, "--global-config", - coderConfigPath.toAbsolutePath().toString(), + coderConfigPath.toString(), ) } @@ -179,7 +179,7 @@ class CoderCLIManager @JvmOverloads constructor(private val deploymentURL: URL, """ Host ${getHostName(deploymentURL, it)} HostName coder.${it.name} - ProxyCommand "${localBinaryPath.toAbsolutePath()}" --global-config "${coderConfigPath.toAbsolutePath()}" ssh --stdio ${it.name} + ProxyCommand "$localBinaryPath" --global-config "$coderConfigPath" ssh --stdio ${it.name} ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null From a605d79529bc2aca489be4eac4e221cbd8f7c299 Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 17 Apr 2023 11:54:55 -0800 Subject: [PATCH 7/8] Use golden files for SSH config tests Also add trailing newline when appending to file. Just a bit nicer, for example when you cat the file. If we are replacing a block at the end and there is no newline then we will preserve that; no newlines are added when replacing. --- .../com/coder/gateway/sdk/CoderCLIManager.kt | 13 +- src/test/fixtures/inputs/blank-newlines.conf | 3 + src/test/fixtures/inputs/blank.conf | 0 .../inputs/existing-end-no-newline.conf | 5 + src/test/fixtures/inputs/existing-end.conf | 7 + .../inputs/existing-middle-and-unrelated.conf | 13 + src/test/fixtures/inputs/existing-middle.conf | 7 + src/test/fixtures/inputs/existing-only.conf | 3 + src/test/fixtures/inputs/existing-start.conf | 7 + .../inputs/malformed-mismatched-start.conf | 3 + .../fixtures/inputs/malformed-no-end.conf | 2 + .../fixtures/inputs/malformed-no-start.conf | 2 + .../inputs/malformed-start-after-end.conf | 3 + src/test/fixtures/inputs/no-blocks.conf | 4 + src/test/fixtures/inputs/no-newline.conf | 4 + .../fixtures/inputs/no-related-blocks.conf | 10 + .../outputs/append-blank-newlines.conf | 14 + src/test/fixtures/outputs/append-blank.conf | 10 + .../fixtures/outputs/append-no-blocks.conf | 15 + .../fixtures/outputs/append-no-newline.conf | 14 + .../outputs/append-no-related-blocks.conf | 21 + .../fixtures/outputs/multiple-workspaces.conf | 18 + .../outputs/replace-end-no-newline.conf | 13 + src/test/fixtures/outputs/replace-end.conf | 14 + .../replace-middle-ignore-unrelated.conf | 20 + src/test/fixtures/outputs/replace-middle.conf | 14 + src/test/fixtures/outputs/replace-only.conf | 10 + src/test/fixtures/outputs/replace-start.conf | 14 + src/test/groovy/CoderCLIManagerTest.groovy | 403 ++---------------- 29 files changed, 299 insertions(+), 367 deletions(-) create mode 100644 src/test/fixtures/inputs/blank-newlines.conf create mode 100644 src/test/fixtures/inputs/blank.conf create mode 100644 src/test/fixtures/inputs/existing-end-no-newline.conf create mode 100644 src/test/fixtures/inputs/existing-end.conf create mode 100644 src/test/fixtures/inputs/existing-middle-and-unrelated.conf create mode 100644 src/test/fixtures/inputs/existing-middle.conf create mode 100644 src/test/fixtures/inputs/existing-only.conf create mode 100644 src/test/fixtures/inputs/existing-start.conf create mode 100644 src/test/fixtures/inputs/malformed-mismatched-start.conf create mode 100644 src/test/fixtures/inputs/malformed-no-end.conf create mode 100644 src/test/fixtures/inputs/malformed-no-start.conf create mode 100644 src/test/fixtures/inputs/malformed-start-after-end.conf create mode 100644 src/test/fixtures/inputs/no-blocks.conf create mode 100644 src/test/fixtures/inputs/no-newline.conf create mode 100644 src/test/fixtures/inputs/no-related-blocks.conf create mode 100644 src/test/fixtures/outputs/append-blank-newlines.conf create mode 100644 src/test/fixtures/outputs/append-blank.conf create mode 100644 src/test/fixtures/outputs/append-no-blocks.conf create mode 100644 src/test/fixtures/outputs/append-no-newline.conf create mode 100644 src/test/fixtures/outputs/append-no-related-blocks.conf create mode 100644 src/test/fixtures/outputs/multiple-workspaces.conf create mode 100644 src/test/fixtures/outputs/replace-end-no-newline.conf create mode 100644 src/test/fixtures/outputs/replace-end.conf create mode 100644 src/test/fixtures/outputs/replace-middle-ignore-unrelated.conf create mode 100644 src/test/fixtures/outputs/replace-middle.conf create mode 100644 src/test/fixtures/outputs/replace-only.conf create mode 100644 src/test/fixtures/outputs/replace-start.conf diff --git a/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt index 1d685eaf..6cfcabb1 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt @@ -196,12 +196,11 @@ class CoderCLIManager @JvmOverloads constructor(private val deploymentURL: URL, logger.info("Leaving $sshConfigPath alone since there are no workspaces and no config to remove") } else if (start == null && end == null) { logger.info("Appending config to $sshConfigPath") - sshConfigPath.toFile().writeText( - if (contents.isEmpty()) blockContent else listOf( - contents, - blockContent - ).joinToString(System.lineSeparator()) - ) + val toAppend = if (contents.isEmpty()) blockContent else listOf( + contents, + blockContent + ).joinToString(System.lineSeparator()) + sshConfigPath.toFile().writeText(toAppend + System.lineSeparator()) } else if (start == null) { throw SSHConfigFormatException("End block exists but no start block") } else if (end == null) { @@ -234,7 +233,7 @@ class CoderCLIManager @JvmOverloads constructor(private val deploymentURL: URL, } } catch (e: FileNotFoundException) { logger.info("Writing config to $sshConfigPath") - sshConfigPath.toFile().writeText(blockContent) + sshConfigPath.toFile().writeText(blockContent + System.lineSeparator()) } } diff --git a/src/test/fixtures/inputs/blank-newlines.conf b/src/test/fixtures/inputs/blank-newlines.conf new file mode 100644 index 00000000..b28b04f6 --- /dev/null +++ b/src/test/fixtures/inputs/blank-newlines.conf @@ -0,0 +1,3 @@ + + + diff --git a/src/test/fixtures/inputs/blank.conf b/src/test/fixtures/inputs/blank.conf new file mode 100644 index 00000000..e69de29b diff --git a/src/test/fixtures/inputs/existing-end-no-newline.conf b/src/test/fixtures/inputs/existing-end-no-newline.conf new file mode 100644 index 00000000..28a545fb --- /dev/null +++ b/src/test/fixtures/inputs/existing-end-no-newline.conf @@ -0,0 +1,5 @@ +Host test + Port 80 +Host test2 + Port 443 # --- START CODER JETBRAINS test.coder.invalid +some jetbrains config # --- END CODER JETBRAINS test.coder.invalid diff --git a/src/test/fixtures/inputs/existing-end.conf b/src/test/fixtures/inputs/existing-end.conf new file mode 100644 index 00000000..93837892 --- /dev/null +++ b/src/test/fixtures/inputs/existing-end.conf @@ -0,0 +1,7 @@ +Host test + Port 80 +Host test2 + Port 443 +# --- START CODER JETBRAINS test.coder.invalid +some jetbrains config +# --- END CODER JETBRAINS test.coder.invalid diff --git a/src/test/fixtures/inputs/existing-middle-and-unrelated.conf b/src/test/fixtures/inputs/existing-middle-and-unrelated.conf new file mode 100644 index 00000000..297d6889 --- /dev/null +++ b/src/test/fixtures/inputs/existing-middle-and-unrelated.conf @@ -0,0 +1,13 @@ +Host test + Port 80 +# ------------START-CODER----------- +some coder config +# ------------END-CODER------------ +# --- START CODER JETBRAINS test.coder.invalid +some jetbrains config +# --- END CODER JETBRAINS test.coder.invalid +Host test2 + Port 443 +# --- START CODER JETBRAINS test.coder.unrelated +some jetbrains config +# --- END CODER JETBRAINS test.coder.unrelated diff --git a/src/test/fixtures/inputs/existing-middle.conf b/src/test/fixtures/inputs/existing-middle.conf new file mode 100644 index 00000000..90b05556 --- /dev/null +++ b/src/test/fixtures/inputs/existing-middle.conf @@ -0,0 +1,7 @@ +Host test + Port 80 +# --- START CODER JETBRAINS test.coder.invalid +some jetbrains config +# --- END CODER JETBRAINS test.coder.invalid +Host test2 + Port 443 diff --git a/src/test/fixtures/inputs/existing-only.conf b/src/test/fixtures/inputs/existing-only.conf new file mode 100644 index 00000000..0e960a22 --- /dev/null +++ b/src/test/fixtures/inputs/existing-only.conf @@ -0,0 +1,3 @@ +# --- START CODER JETBRAINS test.coder.invalid +some jetbrains config +# --- END CODER JETBRAINS test.coder.invalid diff --git a/src/test/fixtures/inputs/existing-start.conf b/src/test/fixtures/inputs/existing-start.conf new file mode 100644 index 00000000..0cf11597 --- /dev/null +++ b/src/test/fixtures/inputs/existing-start.conf @@ -0,0 +1,7 @@ +# --- START CODER JETBRAINS test.coder.invalid +some jetbrains config +# --- END CODER JETBRAINS test.coder.invalid +Host test + Port 80 +Host test2 + Port 443 diff --git a/src/test/fixtures/inputs/malformed-mismatched-start.conf b/src/test/fixtures/inputs/malformed-mismatched-start.conf new file mode 100644 index 00000000..7631e64e --- /dev/null +++ b/src/test/fixtures/inputs/malformed-mismatched-start.conf @@ -0,0 +1,3 @@ +# --- START CODER JETBRAINS test.coder.something-else +some jetbrains config +# --- END CODER JETBRAINS test.coder.invalid diff --git a/src/test/fixtures/inputs/malformed-no-end.conf b/src/test/fixtures/inputs/malformed-no-end.conf new file mode 100644 index 00000000..dbcd97eb --- /dev/null +++ b/src/test/fixtures/inputs/malformed-no-end.conf @@ -0,0 +1,2 @@ +# --- START CODER JETBRAINS test.coder.invalid +some jetbrains config diff --git a/src/test/fixtures/inputs/malformed-no-start.conf b/src/test/fixtures/inputs/malformed-no-start.conf new file mode 100644 index 00000000..ba6c18fa --- /dev/null +++ b/src/test/fixtures/inputs/malformed-no-start.conf @@ -0,0 +1,2 @@ +some jetbrains config +# --- END CODER JETBRAINS test.coder.invalid diff --git a/src/test/fixtures/inputs/malformed-start-after-end.conf b/src/test/fixtures/inputs/malformed-start-after-end.conf new file mode 100644 index 00000000..e9f411cb --- /dev/null +++ b/src/test/fixtures/inputs/malformed-start-after-end.conf @@ -0,0 +1,3 @@ +# --- END CODER JETBRAINS test.coder.invalid +some jetbrains config +# --- START CODER JETBRAINS test.coder.invalid diff --git a/src/test/fixtures/inputs/no-blocks.conf b/src/test/fixtures/inputs/no-blocks.conf new file mode 100644 index 00000000..98bd2a71 --- /dev/null +++ b/src/test/fixtures/inputs/no-blocks.conf @@ -0,0 +1,4 @@ +Host test + Port 80 +Host test2 + Port 443 diff --git a/src/test/fixtures/inputs/no-newline.conf b/src/test/fixtures/inputs/no-newline.conf new file mode 100644 index 00000000..650ebf77 --- /dev/null +++ b/src/test/fixtures/inputs/no-newline.conf @@ -0,0 +1,4 @@ +Host test + Port 80 +Host test2 + Port 443 \ No newline at end of file diff --git a/src/test/fixtures/inputs/no-related-blocks.conf b/src/test/fixtures/inputs/no-related-blocks.conf new file mode 100644 index 00000000..34b2b597 --- /dev/null +++ b/src/test/fixtures/inputs/no-related-blocks.conf @@ -0,0 +1,10 @@ +Host test + Port 80 +# ------------START-CODER----------- +some coder config +# ------------END-CODER------------ +Host test2 + Port 443 +# --- START CODER JETBRAINS test.coder.unrelated +some jetbrains config +# --- END CODER JETBRAINS test.coder.unrelated diff --git a/src/test/fixtures/outputs/append-blank-newlines.conf b/src/test/fixtures/outputs/append-blank-newlines.conf new file mode 100644 index 00000000..95a17ef6 --- /dev/null +++ b/src/test/fixtures/outputs/append-blank-newlines.conf @@ -0,0 +1,14 @@ + + + + +# --- START CODER JETBRAINS test.coder.invalid +Host coder-jetbrains--foo-bar--test.coder.invalid + HostName coder.foo-bar + ProxyCommand "/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64" --global-config "/tmp/coder-gateway/test.coder.invalid/config" ssh --stdio foo-bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains +# --- END CODER JETBRAINS test.coder.invalid diff --git a/src/test/fixtures/outputs/append-blank.conf b/src/test/fixtures/outputs/append-blank.conf new file mode 100644 index 00000000..d61c4e7a --- /dev/null +++ b/src/test/fixtures/outputs/append-blank.conf @@ -0,0 +1,10 @@ +# --- START CODER JETBRAINS test.coder.invalid +Host coder-jetbrains--foo-bar--test.coder.invalid + HostName coder.foo-bar + ProxyCommand "/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64" --global-config "/tmp/coder-gateway/test.coder.invalid/config" ssh --stdio foo-bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains +# --- END CODER JETBRAINS test.coder.invalid diff --git a/src/test/fixtures/outputs/append-no-blocks.conf b/src/test/fixtures/outputs/append-no-blocks.conf new file mode 100644 index 00000000..a8ad518b --- /dev/null +++ b/src/test/fixtures/outputs/append-no-blocks.conf @@ -0,0 +1,15 @@ +Host test + Port 80 +Host test2 + Port 443 + +# --- START CODER JETBRAINS test.coder.invalid +Host coder-jetbrains--foo-bar--test.coder.invalid + HostName coder.foo-bar + ProxyCommand "/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64" --global-config "/tmp/coder-gateway/test.coder.invalid/config" ssh --stdio foo-bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains +# --- END CODER JETBRAINS test.coder.invalid diff --git a/src/test/fixtures/outputs/append-no-newline.conf b/src/test/fixtures/outputs/append-no-newline.conf new file mode 100644 index 00000000..9a22df02 --- /dev/null +++ b/src/test/fixtures/outputs/append-no-newline.conf @@ -0,0 +1,14 @@ +Host test + Port 80 +Host test2 + Port 443 +# --- START CODER JETBRAINS test.coder.invalid +Host coder-jetbrains--foo-bar--test.coder.invalid + HostName coder.foo-bar + ProxyCommand "/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64" --global-config "/tmp/coder-gateway/test.coder.invalid/config" ssh --stdio foo-bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains +# --- END CODER JETBRAINS test.coder.invalid diff --git a/src/test/fixtures/outputs/append-no-related-blocks.conf b/src/test/fixtures/outputs/append-no-related-blocks.conf new file mode 100644 index 00000000..0269b92f --- /dev/null +++ b/src/test/fixtures/outputs/append-no-related-blocks.conf @@ -0,0 +1,21 @@ +Host test + Port 80 +# ------------START-CODER----------- +some coder config +# ------------END-CODER------------ +Host test2 + Port 443 +# --- START CODER JETBRAINS test.coder.unrelated +some jetbrains config +# --- END CODER JETBRAINS test.coder.unrelated + +# --- START CODER JETBRAINS test.coder.invalid +Host coder-jetbrains--foo-bar--test.coder.invalid + HostName coder.foo-bar + ProxyCommand "/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64" --global-config "/tmp/coder-gateway/test.coder.invalid/config" ssh --stdio foo-bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains +# --- END CODER JETBRAINS test.coder.invalid diff --git a/src/test/fixtures/outputs/multiple-workspaces.conf b/src/test/fixtures/outputs/multiple-workspaces.conf new file mode 100644 index 00000000..db39a4e2 --- /dev/null +++ b/src/test/fixtures/outputs/multiple-workspaces.conf @@ -0,0 +1,18 @@ +# --- START CODER JETBRAINS test.coder.invalid +Host coder-jetbrains--foo--test.coder.invalid + HostName coder.foo + ProxyCommand "/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64" --global-config "/tmp/coder-gateway/test.coder.invalid/config" ssh --stdio foo + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains +Host coder-jetbrains--bar--test.coder.invalid + HostName coder.bar + ProxyCommand "/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64" --global-config "/tmp/coder-gateway/test.coder.invalid/config" ssh --stdio bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains +# --- END CODER JETBRAINS test.coder.invalid diff --git a/src/test/fixtures/outputs/replace-end-no-newline.conf b/src/test/fixtures/outputs/replace-end-no-newline.conf new file mode 100644 index 00000000..96af3482 --- /dev/null +++ b/src/test/fixtures/outputs/replace-end-no-newline.conf @@ -0,0 +1,13 @@ +Host test + Port 80 +Host test2 + Port 443 # --- START CODER JETBRAINS test.coder.invalid +Host coder-jetbrains--foo-bar--test.coder.invalid + HostName coder.foo-bar + ProxyCommand "/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64" --global-config "/tmp/coder-gateway/test.coder.invalid/config" ssh --stdio foo-bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains +# --- END CODER JETBRAINS test.coder.invalid diff --git a/src/test/fixtures/outputs/replace-end.conf b/src/test/fixtures/outputs/replace-end.conf new file mode 100644 index 00000000..9a22df02 --- /dev/null +++ b/src/test/fixtures/outputs/replace-end.conf @@ -0,0 +1,14 @@ +Host test + Port 80 +Host test2 + Port 443 +# --- START CODER JETBRAINS test.coder.invalid +Host coder-jetbrains--foo-bar--test.coder.invalid + HostName coder.foo-bar + ProxyCommand "/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64" --global-config "/tmp/coder-gateway/test.coder.invalid/config" ssh --stdio foo-bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains +# --- END CODER JETBRAINS test.coder.invalid diff --git a/src/test/fixtures/outputs/replace-middle-ignore-unrelated.conf b/src/test/fixtures/outputs/replace-middle-ignore-unrelated.conf new file mode 100644 index 00000000..221788a1 --- /dev/null +++ b/src/test/fixtures/outputs/replace-middle-ignore-unrelated.conf @@ -0,0 +1,20 @@ +Host test + Port 80 +# ------------START-CODER----------- +some coder config +# ------------END-CODER------------ +# --- START CODER JETBRAINS test.coder.invalid +Host coder-jetbrains--foo-bar--test.coder.invalid + HostName coder.foo-bar + ProxyCommand "/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64" --global-config "/tmp/coder-gateway/test.coder.invalid/config" ssh --stdio foo-bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains +# --- END CODER JETBRAINS test.coder.invalid +Host test2 + Port 443 +# --- START CODER JETBRAINS test.coder.unrelated +some jetbrains config +# --- END CODER JETBRAINS test.coder.unrelated diff --git a/src/test/fixtures/outputs/replace-middle.conf b/src/test/fixtures/outputs/replace-middle.conf new file mode 100644 index 00000000..89e5c11d --- /dev/null +++ b/src/test/fixtures/outputs/replace-middle.conf @@ -0,0 +1,14 @@ +Host test + Port 80 +# --- START CODER JETBRAINS test.coder.invalid +Host coder-jetbrains--foo-bar--test.coder.invalid + HostName coder.foo-bar + ProxyCommand "/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64" --global-config "/tmp/coder-gateway/test.coder.invalid/config" ssh --stdio foo-bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains +# --- END CODER JETBRAINS test.coder.invalid +Host test2 + Port 443 diff --git a/src/test/fixtures/outputs/replace-only.conf b/src/test/fixtures/outputs/replace-only.conf new file mode 100644 index 00000000..d61c4e7a --- /dev/null +++ b/src/test/fixtures/outputs/replace-only.conf @@ -0,0 +1,10 @@ +# --- START CODER JETBRAINS test.coder.invalid +Host coder-jetbrains--foo-bar--test.coder.invalid + HostName coder.foo-bar + ProxyCommand "/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64" --global-config "/tmp/coder-gateway/test.coder.invalid/config" ssh --stdio foo-bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains +# --- END CODER JETBRAINS test.coder.invalid diff --git a/src/test/fixtures/outputs/replace-start.conf b/src/test/fixtures/outputs/replace-start.conf new file mode 100644 index 00000000..b8477f17 --- /dev/null +++ b/src/test/fixtures/outputs/replace-start.conf @@ -0,0 +1,14 @@ +# --- START CODER JETBRAINS test.coder.invalid +Host coder-jetbrains--foo-bar--test.coder.invalid + HostName coder.foo-bar + ProxyCommand "/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64" --global-config "/tmp/coder-gateway/test.coder.invalid/config" ssh --stdio foo-bar + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains +# --- END CODER JETBRAINS test.coder.invalid +Host test + Port 80 +Host test2 + Port 443 diff --git a/src/test/groovy/CoderCLIManagerTest.groovy b/src/test/groovy/CoderCLIManagerTest.groovy index 08553439..1c20a765 100644 --- a/src/test/groovy/CoderCLIManagerTest.groovy +++ b/src/test/groovy/CoderCLIManagerTest.groovy @@ -13,6 +13,7 @@ import spock.lang.Unroll import java.nio.file.Files import java.nio.file.Path +import java.nio.file.StandardCopyOption @Unroll class CoderCLIManagerTest extends spock.lang.Specification { @@ -319,392 +320,74 @@ class CoderCLIManagerTest extends spock.lang.Specification { ) } - def "configures empty SSH file with multiple hosts"() { + def "configures an SSH file"() { given: def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir) - def sshConfigPath = tmpdir.resolve("config-nonexistent") + def sshConfigPath = tmpdir.resolve(input + "_to_" + output + ".conf") + if (input != null) { + Files.createDirectories(sshConfigPath.getParent()) + def originalConf = Path.of("src/test/fixtures/inputs").resolve(input + ".conf").toFile().text + .replaceAll("\\r?\\n", System.lineSeparator()) + sshConfigPath.toFile().write(originalConf) + } def coderConfigPath = ccm.localBinaryPath.getParent().resolve("config") - when: - ccm.configSsh(List.of(randWorkspace("foo"), randWorkspace("bar")), sshConfigPath) - - then: - sshConfigPath.toFile().text == """\ - # --- START CODER JETBRAINS test.coder.invalid - Host coder-jetbrains--foo--test.coder.invalid - HostName coder.foo - ProxyCommand "${ccm.localBinaryPath}" --global-config "$coderConfigPath" ssh --stdio foo - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains - Host coder-jetbrains--bar--test.coder.invalid - HostName coder.bar - ProxyCommand "${ccm.localBinaryPath}" --global-config "$coderConfigPath" ssh --stdio bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains - # --- END CODER JETBRAINS test.coder.invalid""".stripIndent().replace("\n", System.lineSeparator()) - } - - def "configures existing SSH file with one host"() { - given: - def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir) - def sshConfigPath = tmpdir.resolve("config-existing") - Files.createDirectories(sshConfigPath.getParent()) - sshConfigPath.toFile().write(contents.stripIndent().replace("\n", System.lineSeparator())) + def expectedConf = Path.of("src/test/fixtures/outputs/").resolve(output + ".conf").toFile().text + .replaceAll("\\r?\\n", System.lineSeparator()) + .replace("/tmp/coder-gateway/test.coder.invalid/config", coderConfigPath.toString()) + .replace("/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64", ccm.localBinaryPath.toString()) when: - ccm.configSsh(List.of(randWorkspace("foo-bar")), sshConfigPath) + ccm.configSsh(workspaces.collect { randWorkspace(it) }, sshConfigPath) then: - sshConfigPath.toFile().text == expected.stripIndent().replace("\n", System.lineSeparator()) - - where: - contents << { - // Blank file. - ["", - - // Preserve existing newlines. - """\ - - - """, - - // Append to config; ignore unrelated blocks. - """\ - Host test - Port 80 - # ------------START-CODER----------- - some coder config - # ------------END-CODER------------""", - - // Replace config at end without newlines. - """\ - Host test - Port 80 # --- START CODER JETBRAINS test.coder.invalid - some jetbrains config # --- END CODER JETBRAINS test.coder.invalid""", - - // Replace config at the middle; ignore unrelated blocks. - """\ - Host test - Port 80 - # ------------START-CODER----------- - some coder config - # ------------END-CODER------------ - # --- START CODER JETBRAINS test.coder.invalid - some jetbrains config - # --- END CODER JETBRAINS test.coder.invalid - Host * - HostName localhost - # --- START CODER JETBRAINS test.coder.unrelated - some jetbrains config - # --- END CODER JETBRAINS test.coder.unrelated""", - - // Replace config at the start without leading newline. - """\ - # --- START CODER JETBRAINS test.coder.invalid - some jetbrains config - # --- END CODER JETBRAINS test.coder.invalid - Host test - Port 80""", - - // Replace config at the end without trailing newline. - """\ - Host test - Port 80 - # --- START CODER JETBRAINS test.coder.invalid - some jetbrains config - # --- END CODER JETBRAINS test.coder.invalid""", - - // Replace config at the end with newlines. - """\ - Host test - Port 80 - # --- START CODER JETBRAINS test.coder.invalid - some jetbrains config - # --- END CODER JETBRAINS test.coder.invalid - """, - ] - }() - - expected << { - // Unfortunately you cannot access local vars in the where block so - // they have to be recreated. - def lbp = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir).localBinaryPath - def cp = lbp.getParent().resolve("config") - ["""\ - # --- START CODER JETBRAINS test.coder.invalid - Host coder-jetbrains--foo-bar--test.coder.invalid - HostName coder.foo-bar - ProxyCommand "$lbp" --global-config "$cp" ssh --stdio foo-bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains - # --- END CODER JETBRAINS test.coder.invalid""", - - """\ - - - - # --- START CODER JETBRAINS test.coder.invalid - Host coder-jetbrains--foo-bar--test.coder.invalid - HostName coder.foo-bar - ProxyCommand "$lbp" --global-config "$cp" ssh --stdio foo-bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains - # --- END CODER JETBRAINS test.coder.invalid""", - - """\ - Host test - Port 80 - # ------------START-CODER----------- - some coder config - # ------------END-CODER------------ - # --- START CODER JETBRAINS test.coder.invalid - Host coder-jetbrains--foo-bar--test.coder.invalid - HostName coder.foo-bar - ProxyCommand "$lbp" --global-config "$cp" ssh --stdio foo-bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains - # --- END CODER JETBRAINS test.coder.invalid""", - - """\ - Host test - Port 80 # --- START CODER JETBRAINS test.coder.invalid - Host coder-jetbrains--foo-bar--test.coder.invalid - HostName coder.foo-bar - ProxyCommand "$lbp" --global-config "$cp" ssh --stdio foo-bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains - # --- END CODER JETBRAINS test.coder.invalid""", - - """\ - Host test - Port 80 - # ------------START-CODER----------- - some coder config - # ------------END-CODER------------ - # --- START CODER JETBRAINS test.coder.invalid - Host coder-jetbrains--foo-bar--test.coder.invalid - HostName coder.foo-bar - ProxyCommand "$lbp" --global-config "$cp" ssh --stdio foo-bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains - # --- END CODER JETBRAINS test.coder.invalid - Host * - HostName localhost - # --- START CODER JETBRAINS test.coder.unrelated - some jetbrains config - # --- END CODER JETBRAINS test.coder.unrelated""", - - """\ - # --- START CODER JETBRAINS test.coder.invalid - Host coder-jetbrains--foo-bar--test.coder.invalid - HostName coder.foo-bar - ProxyCommand "$lbp" --global-config "$cp" ssh --stdio foo-bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains - # --- END CODER JETBRAINS test.coder.invalid - Host test - Port 80""", - - """\ - Host test - Port 80 - # --- START CODER JETBRAINS test.coder.invalid - Host coder-jetbrains--foo-bar--test.coder.invalid - HostName coder.foo-bar - ProxyCommand "$lbp" --global-config "$cp" ssh --stdio foo-bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains - # --- END CODER JETBRAINS test.coder.invalid""", - - """\ - Host test - Port 80 - # --- START CODER JETBRAINS test.coder.invalid - Host coder-jetbrains--foo-bar--test.coder.invalid - HostName coder.foo-bar - ProxyCommand "$lbp" --global-config "$cp" ssh --stdio foo-bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains - # --- END CODER JETBRAINS test.coder.invalid - """, - ] - }() - } - - def "removes empty block including newlines"() { - def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir) - def configPath = tmpdir.resolve("config-remove") - Files.createDirectories(configPath.getParent()) - configPath.toFile().write(contents.stripIndent().replace("\n", System.lineSeparator())) + sshConfigPath.toFile().text == expectedConf when: - ccm.configSsh(List.of(), configPath) + ccm.configSsh(List.of(), sshConfigPath) then: - configPath.toFile().text == expected.stripIndent().replace("\n", System.lineSeparator()) + sshConfigPath.toFile().text == Path.of("src/test/fixtures/inputs").resolve(remove + ".conf").toFile().text where: - contents << [ - // Nothing to remove. - """\ - Host test - Port 80 - Host test2 - Port 443""", - - // Remove at the end without a trailing newline. - """\ - Host test - Port 80 - Host test2 - Port 443 - # --- START CODER JETBRAINS test.coder.invalid - some jetbrains config - # --- END CODER JETBRAINS test.coder.invalid""", - - // Remove in the middle without a leading newline. - """\ - Host test - Port 80 # --- START CODER JETBRAINS test.coder.invalid - some jetbrains config - # --- END CODER JETBRAINS test.coder.invalid - Host test2 - Port 443""", - - // Remove at the middle without any newlines. - """\ - Host test - Port 80 - Host test2 - Port 443 # --- START CODER JETBRAINS test.coder.invalid - some jetbrains config # --- END CODER JETBRAINS test.coder.invalid""", - - // Remove at the start. - """\ - # --- START CODER JETBRAINS test.coder.invalid - some jetbrains config - # --- END CODER JETBRAINS test.coder.invalid - Host test - Port 80 - Host test2 - Port 443""", - - // Remove at the end with a trailing newline. - """\ - Host test - Port 80 - Host test2 - Port 443 - # --- START CODER JETBRAINS test.coder.invalid - some jetbrains config - # --- END CODER JETBRAINS test.coder.invalid - """, - - // Remove everything. - """\ - # --- START CODER JETBRAINS test.coder.invalid - some jetbrains config - # --- END CODER JETBRAINS test.coder.invalid - """ - ] - - expected << [ - """\ - Host test - Port 80 - Host test2 - Port 443""", - - """\ - Host test - Port 80 - Host test2 - Port 443""", - - """\ - Host test - Port 80 - Host test2 - Port 443""", - - """\ - Host test - Port 80 - Host test2 - Port 443""", - - """\ - Host test - Port 80 - Host test2 - Port 443""", - - """\ - Host test - Port 80 - Host test2 - Port 443 - """, - - """""", - ] + workspaces | input | output | remove + ["foo", "bar"] | null | "multiple-workspaces" | "blank" + ["foo-bar"] | "blank" | "append-blank" | "blank" + ["foo-bar"] | "blank-newlines" | "append-blank-newlines" | "blank" + ["foo-bar"] | "existing-end" | "replace-end" | "no-blocks" + ["foo-bar"] | "existing-end-no-newline" | "replace-end-no-newline" | "no-blocks" + ["foo-bar"] | "existing-middle" | "replace-middle" | "no-blocks" + ["foo-bar"] | "existing-middle-and-unrelated" | "replace-middle-ignore-unrelated" | "no-related-blocks" + ["foo-bar"] | "existing-only" | "replace-only" | "blank" + ["foo-bar"] | "existing-start" | "replace-start" | "no-blocks" + ["foo-bar"] | "no-blocks" | "append-no-blocks" | "no-blocks" + ["foo-bar"] | "no-related-blocks" | "append-no-related-blocks" | "no-related-blocks" + ["foo-bar"] | "no-newline" | "append-no-newline" | "no-blocks" } def "fails if config is malformed"() { given: def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir) - def configPath = tmpdir.resolve("config-malformed") - Files.createDirectories(configPath.getParent()) - configPath.toFile().write(content.stripIndent().replace("\n", System.lineSeparator())) + def sshConfigPath = tmpdir.resolve("configured" + input + ".conf") + Files.createDirectories(sshConfigPath.getParent()) + Files.copy( + Path.of("src/test/fixtures/inputs").resolve(input + ".conf"), + sshConfigPath, + StandardCopyOption.REPLACE_EXISTING, + ) when: - ccm.configSsh(List.of(), configPath) + ccm.configSsh(List.of(), sshConfigPath) then: thrown(SSHConfigFormatException) where: - content << [ - """# --- START CODER JETBRAINS test.coder.invalid - some jetbrains config""", - """some jetbrains config - # --- END CODER JETBRAINS test.coder.invalid""", - """# --- END CODER JETBRAINS test.coder.invalid - some jetbrains config - # --- START CODER JETBRAINS test.coder.invalid""", - """# --- START CODER JETBRAINS test.coder.something-else - some jetbrains config - # --- END CODER JETBRAINS test.coder.invalid""", + input << [ + "malformed-mismatched-start", + "malformed-no-end", + "malformed-no-start", + "malformed-start-after-end", ] } } From a631a4e39c41803068a2a26bbf344f1a6399f602 Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 17 Apr 2023 13:08:12 -0800 Subject: [PATCH 8/8] Break out read/write/modify SSH config --- .../com/coder/gateway/sdk/CoderCLIManager.kt | 140 +++++++++++------- src/test/groovy/CoderCLIManagerTest.groovy | 10 +- 2 files changed, 93 insertions(+), 57 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt index 6cfcabb1..2f686143 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt @@ -24,7 +24,11 @@ import javax.xml.bind.annotation.adapters.HexBinaryAdapter /** * Manage the CLI for a single deployment. */ -class CoderCLIManager @JvmOverloads constructor(private val deploymentURL: URL, destinationDir: Path = getDataDir()) { +class CoderCLIManager @JvmOverloads constructor( + private val deploymentURL: URL, + destinationDir: Path = getDataDir(), + private val sshConfigPath: Path = Path.of(System.getProperty("user.home")).resolve(".ssh/config"), +) { private var remoteBinaryUrl: URL var localBinaryPath: Path private var coderConfigPath: Path @@ -163,10 +167,27 @@ class CoderCLIManager @JvmOverloads constructor(private val deploymentURL: URL, /** * Configure SSH to use this binary. */ - fun configSsh( - workspaces: List, - sshConfigPath: Path = Path.of(System.getProperty("user.home")).resolve(".ssh/config"), - ) { + fun configSsh(workspaces: List) { + writeSSHConfig(modifySSHConfig(readSSHConfig(), workspaces)) + } + + /** + * Return the contents of the SSH config or null if it does not exist. + */ + private fun readSSHConfig(): String? { + return try { + sshConfigPath.toFile().readText() + } catch (e: FileNotFoundException) { + null + } + } + + /** + * Given an existing SSH config modify it to add or remove the config for + * this deployment and return the modified config or null if it does not + * need to be modified. + */ + private fun modifySSHConfig(contents: String?, workspaces: List): String? { val host = getSafeHost(deploymentURL) val startBlock = "# --- START CODER JETBRAINS $host" val endBlock = "# --- END CODER JETBRAINS $host" @@ -187,53 +208,68 @@ class CoderCLIManager @JvmOverloads constructor(private val deploymentURL: URL, SetEnv CODER_SSH_SESSION_TYPE=JetBrains """.trimIndent().replace("\n", System.lineSeparator()) }) - Files.createDirectories(sshConfigPath.parent) - try { - val contents = sshConfigPath.toFile().readText() - val start = "(\\s*)$startBlock".toRegex().find(contents) - val end = "$endBlock(\\s*)".toRegex().find(contents) - if (start == null && end == null && isRemoving) { - logger.info("Leaving $sshConfigPath alone since there are no workspaces and no config to remove") - } else if (start == null && end == null) { - logger.info("Appending config to $sshConfigPath") - val toAppend = if (contents.isEmpty()) blockContent else listOf( - contents, - blockContent - ).joinToString(System.lineSeparator()) - sshConfigPath.toFile().writeText(toAppend + System.lineSeparator()) - } else if (start == null) { - throw SSHConfigFormatException("End block exists but no start block") - } else if (end == null) { - throw SSHConfigFormatException("Start block exists but no end block") - } else if (start.range.first > end.range.first) { - throw SSHConfigFormatException("Start block found after end block") - } else if (isRemoving) { - logger.info("Removing config from $sshConfigPath") - sshConfigPath.toFile().writeText( - listOf( - contents.substring(0, start.range.first), - // Need to keep the trailing newline(s) if we are not at - // the front of the file otherwise the before and after - // lines would get joined. - if (start.range.first > 0) end.groupValues[1] else "", - contents.substring(end.range.last + 1) - ).joinToString("") - ) - } else { - logger.info("Replacing config in $sshConfigPath") - sshConfigPath.toFile().writeText( - listOf( - contents.substring(0, start.range.first), - start.groupValues[1], // Leading newline(s). - blockContent, - end.groupValues[1], // Trailing newline(s). - contents.substring(end.range.last + 1) - ).joinToString("") - ) - } - } catch (e: FileNotFoundException) { - logger.info("Writing config to $sshConfigPath") - sshConfigPath.toFile().writeText(blockContent + System.lineSeparator()) + + if (contents == null) { + logger.info("No existing SSH config to modify") + return blockContent + System.lineSeparator() + } + + val start = "(\\s*)$startBlock".toRegex().find(contents) + val end = "$endBlock(\\s*)".toRegex().find(contents) + + if (start == null && end == null && isRemoving) { + logger.info("No workspaces and no existing config blocks to remove") + return null + } + + if (start == null && end == null) { + logger.info("Appending config block") + val toAppend = if (contents.isEmpty()) blockContent else listOf( + contents, + blockContent + ).joinToString(System.lineSeparator()) + return toAppend + System.lineSeparator() + } + + if (start == null) { + throw SSHConfigFormatException("End block exists but no start block") + } + if (end == null) { + throw SSHConfigFormatException("Start block exists but no end block") + } + if (start.range.first > end.range.first) { + throw SSHConfigFormatException("Start block found after end block") + } + + if (isRemoving) { + logger.info("No workspaces; removing config block") + return listOf( + contents.substring(0, start.range.first), + // Need to keep the trailing newline(s) if we are not at the + // front of the file otherwise the before and after lines would + // get joined. + if (start.range.first > 0) end.groupValues[1] else "", + contents.substring(end.range.last + 1) + ).joinToString("") + } + + logger.info("Replacing existing config block") + return listOf( + contents.substring(0, start.range.first), + start.groupValues[1], // Leading newline(s). + blockContent, + end.groupValues[1], // Trailing newline(s). + contents.substring(end.range.last + 1) + ).joinToString("") + } + + /** + * Write the provided SSH config or do nothing if null. + */ + private fun writeSSHConfig(contents: String?) { + if (contents != null) { + Files.createDirectories(sshConfigPath.parent) + sshConfigPath.toFile().writeText(contents) } } diff --git a/src/test/groovy/CoderCLIManagerTest.groovy b/src/test/groovy/CoderCLIManagerTest.groovy index 1c20a765..61ff9742 100644 --- a/src/test/groovy/CoderCLIManagerTest.groovy +++ b/src/test/groovy/CoderCLIManagerTest.groovy @@ -322,8 +322,8 @@ class CoderCLIManagerTest extends spock.lang.Specification { def "configures an SSH file"() { given: - def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir) def sshConfigPath = tmpdir.resolve(input + "_to_" + output + ".conf") + def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir, sshConfigPath) if (input != null) { Files.createDirectories(sshConfigPath.getParent()) def originalConf = Path.of("src/test/fixtures/inputs").resolve(input + ".conf").toFile().text @@ -338,13 +338,13 @@ class CoderCLIManagerTest extends spock.lang.Specification { .replace("/tmp/coder-gateway/test.coder.invalid/coder-linux-amd64", ccm.localBinaryPath.toString()) when: - ccm.configSsh(workspaces.collect { randWorkspace(it) }, sshConfigPath) + ccm.configSsh(workspaces.collect { randWorkspace(it) }) then: sshConfigPath.toFile().text == expectedConf when: - ccm.configSsh(List.of(), sshConfigPath) + ccm.configSsh(List.of()) then: sshConfigPath.toFile().text == Path.of("src/test/fixtures/inputs").resolve(remove + ".conf").toFile().text @@ -367,8 +367,8 @@ class CoderCLIManagerTest extends spock.lang.Specification { def "fails if config is malformed"() { given: - def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir) def sshConfigPath = tmpdir.resolve("configured" + input + ".conf") + def ccm = new CoderCLIManager(new URL("https://test.coder.invalid"), tmpdir, sshConfigPath) Files.createDirectories(sshConfigPath.getParent()) Files.copy( Path.of("src/test/fixtures/inputs").resolve(input + ".conf"), @@ -377,7 +377,7 @@ class CoderCLIManagerTest extends spock.lang.Specification { ) when: - ccm.configSsh(List.of(), sshConfigPath) + ccm.configSsh(List.of()) then: thrown(SSHConfigFormatException)