diff --git a/README.md b/README.md index cd598a5..0eb8385 100644 --- a/README.md +++ b/README.md @@ -132,19 +132,51 @@ The plugin first checks for installed IDEs on the remote workspace, then queries installed) IDEs. Based on the `ide_build_number` hint, it will either pick an already installed IDE or install a new one. -### Offline Mode +### Air-Gapped and Offline Environments -The Coder Toolbox plugin supports an offline mode, which allows it to function without an internet connection. This is -particularly useful in environments with restricted network access. +By default, the plugin fetches IDE product feeds from JetBrains' public data services +(`data.services.jetbrains.com`). In air-gapped or restricted environments, there are two ways to provide IDE feed +data to the plugin. -To enable offline mode, the JetBrains Toolbox must be launched with the `--offline-mode` flag. In this mode, the plugin -relies on local JSON files (`release.json` and `eap.json`) that you must provide in the plugin's data directory. The -plugin does **not** cache these files automatically. +The resolution order is: -While online, the plugin fetches the latest IDE feeds from JetBrains' data services. In offline mode, it bypasses these -network requests and uses the local files instead. +1. **Local feed files** — if `--offline-mode` is enabled and local files (`release.json`/`eap.json`) are present, + they are used. +2. **Custom IDE feed URL** — if set (via `IDE feed base URL` in Coder Settings), the plugin fetches feeds from + this URL instead of the public JetBrains data services. This applies both in online mode and as a fallback + when `--offline-mode` is active but local feed files are not provided. +3. **Public JetBrains data services** — used only when no custom IDE feed URL is configured and local feed files + are not available. -#### Offline Mode File Schema and Location +> [!NOTE] +> The `--offline-mode` flag is a global JetBrains Toolbox setting that affects all plugins. In air-gapped +> environments you can use it together with a custom IDE feed URL — the plugin will use local files if available, +> and fall back to your internal feed server otherwise. + +#### Option 1: Custom IDE Feed URL + +If your organization hosts a web server that mirrors the JetBrains product feed data, you can point the plugin at it +instead of the public `data.services.jetbrains.com`. + +Set the `IDE feed base URL` setting (in Coder Settings) to your internal feed server URL, for example: + +``` +https://ide-feed.internal.corp.com +``` + +The plugin will then fetch feeds from `/products?type=release` and `/products?type=eap`. The feed +server must serve the same JSON format as `data.services.jetbrains.com` at those endpoints (see the +[feed file schema](#feed-file-schema-and-location) below for the expected format). + +#### Option 2: Local Feed Files + +When no internal feed server is available, the plugin can read IDE feeds from local JSON files. To enable this, +launch JetBrains Toolbox with the `--offline-mode` flag. The plugin will look for `release.json` and `eap.json` +in the plugin's data directory. If these files are not present and a custom IDE feed URL is configured, the plugin +will fall back to fetching from that URL (see resolution order above). The plugin does **not** cache feed data to +these files automatically. + +#### Feed File Schema and Location The feed files must be placed in the plugin's data directory, which varies by operating system: @@ -471,6 +503,11 @@ storage paths. The options can be configured from the plugin's main Workspaces p Helpful for customers that have their own in-house dashboards. Defaults to the Coder deployment templates page. This setting supports `$workspaceOwner` as placeholder with the replacing value being the username that logged in. +- `IDE feed base URL` specifies the base URL for fetching JetBrains IDE product feeds. When set, the plugin + fetches feeds from this URL instead of the public `data.services.jetbrains.com`. This is useful in air-gapped + environments where an internal web server mirrors the JetBrains product feed data. + See [Air-Gapped and Offline Environments](#air-gapped-and-offline-environments) for more details. + #### How CLI resolution works When connecting to a deployment the plugin ensures a compatible CLI binary is available. diff --git a/src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt b/src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt index 2d1e836..3f7ecc0 100644 --- a/src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt +++ b/src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt @@ -83,7 +83,13 @@ class IdeFeedManager( context.logger.info("Loading IDEs in ${if (isOffline) "offline" else "online"} mode") val ides = if (isOffline) { - loadIdesOffline() + val offlineIdes = loadIdesOffline() + if (offlineIdes.isEmpty() && hasCustomFeedUrl()) { + context.logger.info("No local feed files found, falling back to custom IDE feed URL") + loadIdesOnline() + } else { + offlineIdes + } } else { loadIdesOnline() } @@ -183,6 +189,10 @@ class IdeFeedManager( return runsInOfflineMode() } + private fun hasCustomFeedUrl(): Boolean { + return !context.settingsStore.readOnly().ideFeedBaseUrl.isNullOrBlank() + } + /** * Find the best matching IDE based on the provided query criteria. * diff --git a/src/main/kotlin/com/coder/toolbox/feed/JetBrainsFeedService.kt b/src/main/kotlin/com/coder/toolbox/feed/JetBrainsFeedService.kt index 54af656..5237722 100644 --- a/src/main/kotlin/com/coder/toolbox/feed/JetBrainsFeedService.kt +++ b/src/main/kotlin/com/coder/toolbox/feed/JetBrainsFeedService.kt @@ -16,9 +16,13 @@ class JetBrainsFeedService( private val context: CoderToolboxContext, private val feedApi: JetBrainsFeedApi ) { + private val baseUrl: String + get() = context.settingsStore.readOnly().ideFeedBaseUrl + ?.trim()?.trimEnd('/')?.takeIf { it.isNotEmpty() } + ?: DEFAULT_BASE_URL + companion object { - private const val RELEASE_FEED_URL = "https://data.services.jetbrains.com/products?type=release" - private const val EAP_FEED_URL = "https://data.services.jetbrains.com/products?type=eap" + private const val DEFAULT_BASE_URL = "https://data.services.jetbrains.com" } /** @@ -28,7 +32,7 @@ class JetBrainsFeedService( * @throws ResponseException if the request fails */ suspend fun fetchReleaseFeed(): List { - return fetchFeed(RELEASE_FEED_URL, "release") + return fetchFeed("$baseUrl/products?type=release", "release") } /** @@ -38,7 +42,7 @@ class JetBrainsFeedService( * @throws ResponseException if the request fails */ suspend fun fetchEapFeed(): List { - return fetchFeed(EAP_FEED_URL, "eap") + return fetchFeed("$baseUrl/products?type=eap", "eap") } /** diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index 7401375..4c77530 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -162,6 +162,14 @@ interface ReadOnlyCoderSettings { */ val networkInfoDir: String + /** + * Optional base URL for fetching JetBrains IDE product feeds. + * When set, the plugin fetches IDE feeds from this URL instead of the + * public JetBrains data services. This is useful in air-gapped environments + * where a self-hosted deployment providing the data feeds is available. + */ + val ideFeedBaseUrl: String? + /** * Where the specified deployment should put its data. */ diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index 0834132..ace6165 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -88,6 +88,9 @@ class CoderSettingsStore( override val workspaceCreateUrl: String? get() = store[WORKSPACE_CREATE_URL] + override val ideFeedBaseUrl: String? + get() = store[IDE_FEED_BASE_URL] + /** * Where the specified deployment should put its data. */ @@ -253,6 +256,10 @@ class CoderSettingsStore( store[SSH_CONFIG_OPTIONS] = options } + fun updateIdeFeedBaseUrl(url: String) { + store[IDE_FEED_BASE_URL] = url + } + fun updateAutoConnect(workspaceId: String, autoConnect: Boolean) { store["$SSH_AUTO_CONNECT_PREFIX$workspaceId"] = autoConnect.toString() } diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index f73ab9e..4d8b372 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -54,5 +54,7 @@ internal const val NETWORK_INFO_DIR = "networkInfoDir" internal const val WORKSPACE_VIEW_URL = "workspaceViewUrl" internal const val WORKSPACE_CREATE_URL = "workspaceCreateUrl" +internal const val IDE_FEED_BASE_URL = "ideFeedBaseUrl" + internal const val SSH_AUTO_CONNECT_PREFIX = "ssh_auto_connect_" diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index 0a7cd39..61de94b 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -121,6 +121,12 @@ class CoderSettingsPage( TextType.General ) + private val ideFeedBaseUrlField = TextField( + context.i18n.ptrl("IDE feed base URL"), + settings.ideFeedBaseUrl ?: "", + TextType.General + ) + private lateinit var visibilityUpdateJob: Job override val fields: StateFlow> = MutableStateFlow( listOf( @@ -143,6 +149,7 @@ class CoderSettingsPage( sshLogDirField, networkInfoDirField, sshExtraArgs, + ideFeedBaseUrlField, ) ) @@ -184,6 +191,7 @@ class CoderSettingsPage( updateSshLogDir(sshLogDirField.contentState.value) updateNetworkInfoDir(networkInfoDirField.contentState.value) updateSshConfigOptions(sshExtraArgs.contentState.value) + updateIdeFeedBaseUrl(ideFeedBaseUrlField.contentState.value) } } ) @@ -254,6 +262,10 @@ class CoderSettingsPage( settings.networkInfoDir } + ideFeedBaseUrlField.contentState.update { + settings.ideFeedBaseUrl ?: "" + } + visibilityUpdateJob = context.cs.launch(CoroutineName("Signature Verification Fallback Setting")) { disableSignatureVerificationField.checkedState.collect { state -> signatureFallbackStrategyField.visibility.update { diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index 6be1db2..d93e439 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -194,4 +194,7 @@ msgid "Unstable connection detected" msgstr "" msgid "Unstable connection between Coder server and workspace detected. Your active sessions may disconnect" +msgstr "" + +msgid "IDE feed base URL" msgstr "" \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerOfflineTest.kt b/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerOfflineTest.kt index e6eed89..a8430d9 100644 --- a/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerOfflineTest.kt +++ b/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerOfflineTest.kt @@ -5,6 +5,8 @@ import com.coder.toolbox.store.CoderSettingsStore import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.squareup.moshi.Moshi import com.squareup.moshi.Types +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -311,4 +313,73 @@ class IdeFeedManagerOfflineTest { assertEquals("241.1", result?.build) assertEquals(IdeType.RELEASE, result?.type) } + + @Test + fun `given offline mode and local files exist when loading IDEs then local files are used and feed service is not called`() = + runTest { + // Given: offline mode is active, local files exist, and a feed service is also provided + val feedService = mockk() + val offlineWithFeedService = IdeFeedManager(context, feedService) { true } + + // When + val result = offlineWithFeedService.loadIdes() + + // Then: local files are used, feed service is never called + assert(result.isNotEmpty()) + coVerify(exactly = 0) { feedService.fetchReleaseFeed() } + coVerify(exactly = 0) { feedService.fetchEapFeed() } + } + + @Test + fun `given offline mode with no local files and custom feed URL when loading IDEs then it falls back to the feed service`() = + runTest { + // Given: offline mode, no local files, custom feed URL is set + val tempDir = java.nio.file.Paths.get(context.settingsStore.globalDataDirectory) + tempDir.resolve("release.json").toFile().delete() + tempDir.resolve("eap.json").toFile().delete() + + every { settingsStore.readOnly() } returns settingsStore + every { settingsStore.ideFeedBaseUrl } returns "https://ide-feed.internal.corp.com" + + val feedService = mockk() + coEvery { feedService.fetchReleaseFeed() } returns releaseProducts.flatMap { product -> + product.releases.mapNotNull { release -> Ide.from(product, release) } + } + coEvery { feedService.fetchEapFeed() } returns eapProducts.flatMap { product -> + product.releases.mapNotNull { release -> Ide.from(product, release) } + } + + val offlineWithFeedUrl = IdeFeedManager(context, feedService) { true } + + // When + val result = offlineWithFeedUrl.loadIdes() + + // Then: feed service is called as fallback + assert(result.isNotEmpty()) + coVerify(exactly = 1) { feedService.fetchReleaseFeed() } + coVerify(exactly = 1) { feedService.fetchEapFeed() } + } + + @Test + fun `given offline mode with no local files and no custom feed URL when loading IDEs then it returns empty list`() = + runTest { + // Given: offline mode, no local files, no custom feed URL + val tempDir = java.nio.file.Paths.get(context.settingsStore.globalDataDirectory) + tempDir.resolve("release.json").toFile().delete() + tempDir.resolve("eap.json").toFile().delete() + + every { settingsStore.readOnly() } returns settingsStore + every { settingsStore.ideFeedBaseUrl } returns null + + val feedService = mockk() + val offlineNoFeedUrl = IdeFeedManager(context, feedService) { true } + + // When + val result = offlineNoFeedUrl.loadIdes() + + // Then: no fallback, empty result + assertEquals(0, result.size) + coVerify(exactly = 0) { feedService.fetchReleaseFeed() } + coVerify(exactly = 0) { feedService.fetchEapFeed() } + } } diff --git a/src/test/kotlin/com/coder/toolbox/feed/JetBrainsFeedServiceTest.kt b/src/test/kotlin/com/coder/toolbox/feed/JetBrainsFeedServiceTest.kt new file mode 100644 index 0000000..de47f37 --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/feed/JetBrainsFeedServiceTest.kt @@ -0,0 +1,122 @@ +package com.coder.toolbox.feed + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.store.CoderSettingsStore +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import retrofit2.Response + +class JetBrainsFeedServiceTest { + private lateinit var context: CoderToolboxContext + private lateinit var settingsStore: CoderSettingsStore + private lateinit var logger: Logger + private lateinit var feedApi: JetBrainsFeedApi + + @BeforeEach + fun setUp() { + context = mockk() + settingsStore = mockk(relaxed = true) + logger = mockk(relaxed = true) + feedApi = mockk() + every { context.logger } returns logger + every { context.settingsStore } returns settingsStore + } + + private fun withFeedBaseUrl(url: String?) { + every { settingsStore.readOnly() } returns settingsStore + every { settingsStore.ideFeedBaseUrl } returns url + } + + @Test + fun `given no custom base URL when fetching feeds then it uses default JetBrains URL`() = runTest { + withFeedBaseUrl(null) + val service = JetBrainsFeedService(context, feedApi) + coEvery { feedApi.fetchFeed(any()) } returns Response.success(emptyList()) + + service.fetchReleaseFeed() + service.fetchEapFeed() + + coVerify { feedApi.fetchFeed("https://data.services.jetbrains.com/products?type=release") } + coVerify { feedApi.fetchFeed("https://data.services.jetbrains.com/products?type=eap") } + } + + @Test + fun `given a custom base URL when fetching feeds then it uses the custom URL`() = runTest { + withFeedBaseUrl("https://ide-feed.internal.corp.com") + val service = JetBrainsFeedService(context, feedApi) + coEvery { feedApi.fetchFeed(any()) } returns Response.success(emptyList()) + + service.fetchReleaseFeed() + service.fetchEapFeed() + + coVerify { feedApi.fetchFeed("https://ide-feed.internal.corp.com/products?type=release") } + coVerify { feedApi.fetchFeed("https://ide-feed.internal.corp.com/products?type=eap") } + } + + @Test + fun `given a custom base URL with trailing slash when fetching feeds then it trims the slash`() = runTest { + withFeedBaseUrl("https://ide-feed.internal.corp.com/") + val service = JetBrainsFeedService(context, feedApi) + coEvery { feedApi.fetchFeed(any()) } returns Response.success(emptyList()) + + service.fetchReleaseFeed() + service.fetchEapFeed() + + coVerify { feedApi.fetchFeed("https://ide-feed.internal.corp.com/products?type=release") } + coVerify { feedApi.fetchFeed("https://ide-feed.internal.corp.com/products?type=eap") } + } + + @Test + fun `given a blank base URL when fetching feeds then it falls back to default`() = runTest { + withFeedBaseUrl(" ") + val service = JetBrainsFeedService(context, feedApi) + coEvery { feedApi.fetchFeed(any()) } returns Response.success(emptyList()) + + service.fetchReleaseFeed() + service.fetchEapFeed() + + coVerify { feedApi.fetchFeed("https://data.services.jetbrains.com/products?type=release") } + coVerify { feedApi.fetchFeed("https://data.services.jetbrains.com/products?type=eap") } + } + + @Test + fun `given a custom base URL when release feed returns products then it parses them correctly`() = runTest { + withFeedBaseUrl("https://ide-feed.internal.corp.com") + val products = listOf( + IdeProduct( + "RustRover", "RR", "RustRover", listOf( + IdeRelease("241.1", "2024.1", IdeType.RELEASE, "2024-01-01") + ) + ) + ) + val service = JetBrainsFeedService(context, feedApi) + coEvery { feedApi.fetchFeed(any()) } returns Response.success(products) + + val result = service.fetchReleaseFeed() + + assert(result.size == 1) + assert(result[0].code == "RR") + assert(result[0].build == "241.1") + } + + @Test + fun `given a custom base URL when feed returns error then it throws ResponseException`() = runTest { + withFeedBaseUrl("https://ide-feed.internal.corp.com") + val service = JetBrainsFeedService(context, feedApi) + coEvery { feedApi.fetchFeed(any()) } returns Response.error(500, "Server Error".toResponseBody()) + + try { + service.fetchReleaseFeed() + assert(false) { "Expected ResponseException" } + } catch (e: Exception) { + assert(e.message?.contains("Failed to fetch release feed") == true) + } + } +}