Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 46 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<base-url>/products?type=release` and `<base-url>/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:

Expand Down Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment on lines +88 to +89
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is definitely a big nit so feel free to ignore, but it feels weird to call something called online under a block of isOffline.

Wonder if we should do something like loadIdesFromFeed(feedUrl) or something, where passing in the feed URL would make it obvious that we were loading from a user-provided value or from jetbrains.com?

} else {
offlineIdes
}
} else {
loadIdesOnline()
}
Expand Down Expand Up @@ -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.
*
Expand Down
12 changes: 8 additions & 4 deletions src/main/kotlin/com/coder/toolbox/feed/JetBrainsFeedService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

/**
Expand All @@ -28,7 +32,7 @@ class JetBrainsFeedService(
* @throws ResponseException if the request fails
*/
suspend fun fetchReleaseFeed(): List<Ide> {
return fetchFeed(RELEASE_FEED_URL, "release")
return fetchFeed("$baseUrl/products?type=release", "release")
}

/**
Expand All @@ -38,7 +42,7 @@ class JetBrainsFeedService(
* @throws ResponseException if the request fails
*/
suspend fun fetchEapFeed(): List<Ide> {
return fetchFeed(EAP_FEED_URL, "eap")
return fetchFeed("$baseUrl/products?type=eap", "eap")
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
7 changes: 7 additions & 0 deletions src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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()
}
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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_"

12 changes: 12 additions & 0 deletions src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<UiField>> = MutableStateFlow(
listOf(
Expand All @@ -143,6 +149,7 @@ class CoderSettingsPage(
sshLogDirField,
networkInfoDirField,
sshExtraArgs,
ideFeedBaseUrlField,
)
)

Expand Down Expand Up @@ -184,6 +191,7 @@ class CoderSettingsPage(
updateSshLogDir(sshLogDirField.contentState.value)
updateNetworkInfoDir(networkInfoDirField.contentState.value)
updateSshConfigOptions(sshExtraArgs.contentState.value)
updateIdeFeedBaseUrl(ideFeedBaseUrlField.contentState.value)
}
}
)
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/localization/defaultMessages.po
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<JetBrainsFeedService>()
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<JetBrainsFeedService>()
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<JetBrainsFeedService>()
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() }
}
}
Loading
Loading