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/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 ddf99dc7..2f686143 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,29 @@ 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 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 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 - localBinaryPath = destinationDir.resolve(subdir).resolve(binaryName) + val host = getSafeHost(deploymentURL) + val subdir = if (deploymentURL.port > 0) "${host}-${deploymentURL.port}" else host + localBinaryPath = destinationDir.resolve(subdir).resolve(binaryName).toAbsolutePath() + coderConfigPath = destinationDir.resolve(subdir).resolve("config").toAbsolutePath() } /** @@ -81,7 +88,7 @@ class CoderCLIManager @JvmOverloads constructor(deployment: URL, destinationDir: 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") @@ -91,7 +98,7 @@ class CoderCLIManager @JvmOverloads constructor(deployment: URL, destinationDir: 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( @@ -110,7 +117,7 @@ class CoderCLIManager @JvmOverloads constructor(deployment: URL, destinationDir: } HttpURLConnection.HTTP_NOT_MODIFIED -> { - logger.info("Using cached binary at ${localBinaryPath.toAbsolutePath()}") + logger.info("Using cached binary at $localBinaryPath") return false } } @@ -137,26 +144,133 @@ class CoderCLIManager @JvmOverloads constructor(deployment: URL, destinationDir: } 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 } } /** - * 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 { + logger.info("Storing CLI credentials in $coderConfigPath") + return exec( + "login", + deploymentURL.toString(), + "--token", + token, + "--global-config", + coderConfigPath.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) { + 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" + 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" --global-config "$coderConfigPath" ssh --stdio ${it.name} + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + """.trimIndent().replace("\n", 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) + } } /** @@ -241,6 +355,15 @@ class CoderCLIManager @JvmOverloads constructor(deployment: URL, destinationDir: } } } + + 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 +378,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 b33de337..8cf9ff8a 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,12 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod localWizardModel.token = token } if (!url.isNullOrBlank() && !token.isNullOrBlank()) { - loginAndLoadWorkspaces(token, true) + // 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() @@ -397,105 +409,127 @@ 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 + connect() } - 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() + * unless retry is false. + */ + private fun connect(retry: Boolean = true) { + // Clear out old deployment details. + 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( + 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(deploymentURL, token) + // Remember these in order to default to them for future attempts. + appPropertiesService.setValue(CODER_URL_KEY, deploymentURL.toString()) + appPropertiesService.setValue(SESSION_TOKEN, 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(deploymentURL) + cliManager.downloadCLI() - loadWorkspaces() + this.indicator.text = "Authenticating Coder CLI..." + cliManager.login(token) - this.indicator.apply { - isIndeterminate = false - text = "Downloading Coder CLI..." - fraction = 0.3 - } - try { - cliManager.downloadCLI() + updateWorkspaceActions() + triggerWorkspacePolling(false) + } catch (e: AuthenticationResponseException) { + logger.error("Token was rejected by $deploymentURL; has your token expired?", e) + 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) { - 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 $deploymentURL", 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 { - minimumSize = Dimension(320, -1) + browserLink( + CoderGatewayBundle.message("gateway.connector.view.login.token.label"), + getTokenUrl.toString() + ) + sessionTokenTextField = textField().applyToComponent { + text = existingToken + minimumSize = Dimension(520, -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 +552,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 +568,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 +584,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() @@ -673,6 +711,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/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 66c4d6d7..61ff9742 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 @@ -9,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 { @@ -296,4 +301,93 @@ 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 an SSH file"() { + given: + 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 + .replaceAll("\\r?\\n", System.lineSeparator()) + sshConfigPath.toFile().write(originalConf) + } + def coderConfigPath = ccm.localBinaryPath.getParent().resolve("config") + + 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(workspaces.collect { randWorkspace(it) }) + + then: + sshConfigPath.toFile().text == expectedConf + + when: + ccm.configSsh(List.of()) + + then: + sshConfigPath.toFile().text == Path.of("src/test/fixtures/inputs").resolve(remove + ".conf").toFile().text + + where: + 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 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"), + sshConfigPath, + StandardCopyOption.REPLACE_EXISTING, + ) + + when: + ccm.configSsh(List.of()) + + then: + thrown(SSHConfigFormatException) + + where: + input << [ + "malformed-mismatched-start", + "malformed-no-end", + "malformed-no-start", + "malformed-start-after-end", + ] + } }