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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
- `Binary destination` can now point directly to an executable, used as-is; otherwise it is treated as a base directory
as before
- support for OAuth2
- workspaces shared with the current user via RBAC now appear in the workspace list alongside your own; shared
workspaces are namespaced by owner (`<owner>.<workspace>.<agent>`) so they don't collide with yours
- the URI handler accepts an optional `owner` query parameter to disambiguate shared workspaces

### Removed

Expand Down
23 changes: 20 additions & 3 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ import kotlin.time.Duration.Companion.seconds

private val POLL_INTERVAL = 5.seconds

/**
* Build the environment id for a workspace/agent pair. Workspaces shared
* with the current user are namespaced by owner (`<owner>.<workspace>.<agent>`)
* so they don't collide with the user's own workspaces, while owned
* workspaces keep the legacy `<workspace>.<agent>` id to preserve persistent
* per-environment state (auto-connect, etc.).
*/
fun environmentId(workspace: Workspace, agent: WorkspaceAgent, currentUser: String): String =
if (workspace.ownerName == currentUser) {
"${workspace.name}.${agent.name}"
} else {
"${workspace.ownerName}.${workspace.name}.${agent.name}"
}

/**
* Represents an agent and workspace combination.
*
Expand All @@ -53,18 +67,21 @@ class CoderRemoteEnvironment(
internal var cli: CoderCLIManager,
private var workspace: Workspace,
private var agent: WorkspaceAgent,
) : RemoteProviderEnvironment("${workspace.name}.${agent.name}"), BeforeConnectionHook, AfterDisconnectHook {
) : RemoteProviderEnvironment(environmentId(workspace, agent, client.me.username)),
BeforeConnectionHook,
AfterDisconnectHook {
private var environmentStatus = WorkspaceAndAgentStatus.from(workspace, agent)

override var name: String = "${workspace.name}.${agent.name}"
override var name: String = environmentId(workspace, agent, client.me.username)
private var isConnected: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val connectionRequest: MutableStateFlow<Boolean> = MutableStateFlow(false)

override val state: MutableStateFlow<RemoteEnvironmentState> =
MutableStateFlow(environmentStatus.toRemoteEnvironmentState(context))
override val description: MutableStateFlow<EnvironmentDescription> =
MutableStateFlow(EnvironmentDescription.General(context.i18n.pnotr(workspace.templateDisplayName)))
override val additionalEnvironmentInformation: MutableMap<LocalizableString, String> = mutableMapOf()
override val additionalEnvironmentInformation: MutableMap<LocalizableString, String> =
mutableMapOf(context.i18n.ptrl("Owner") to workspace.ownerName)
override val actionsList: MutableStateFlow<List<ActionDescription>> = MutableStateFlow(emptyList())

private val networkMetricsMarshaller = Moshi.Builder().build().adapter(NetworkMetrics::class.java)
Expand Down
3 changes: 2 additions & 1 deletion src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,8 @@ class CoderRemoteProvider(
.flatMap { it.agents ?: emptyList() }
.distinctBy { it.name }
.map { agent ->
lastEnvironments.firstOrNull { it.id == "${ws.name}.${agent.name}" }
val envId = environmentId(ws, agent, client.me.username)
lastEnvironments.firstOrNull { it.id == envId }
?.also {
// If we have an environment already, update that.
it.update(ws, agent)
Expand Down
45 changes: 36 additions & 9 deletions src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -161,21 +161,48 @@ open class CoderRestClient(
}

/**
* Retrieves the available workspaces created by the user.
* @throws [APIResponseException].
* Retrieves the available workspaces visible to the current user.
*
* Runs two queries against the server, `owner:me` (workspaces owned by the
* current user) and `shared:true` (workspaces shared with them via RBAC),
* and returns the union deduplicated by workspace id. The `shared:true`
* query is best-effort: if the server does not understand it (older
* deployments) we log the error and return only the owned set.
*
* @throws [APIResponseException] when the `owner:me` query fails.
*/
suspend fun workspaces(): List<Workspace> {
val workspacesResponse = callWithRetry { retroRestClient.workspaces("owner:me") }
if (!workspacesResponse.isSuccessful) {
val owned = fetchWorkspaces("owner:me")
val shared = try {
fetchWorkspaces("shared:true")
} catch (ex: APIResponseException) {
context.logger.warn(ex, "Failed to list shared workspaces on $url; continuing with owned workspaces only")
emptyList()
}

if (shared.isEmpty()) return owned

// Dedupe by id in case the server returns a workspace in both queries
// (for example if a user shares a workspace with themselves).
val seen = HashSet<UUID>(owned.size + shared.size)
return buildList {
for (ws in owned + shared) {
if (seen.add(ws.id)) add(ws)
}
}
}

private suspend fun fetchWorkspaces(query: String): List<Workspace> {
val response = callWithRetry { retroRestClient.workspaces(query) }
if (!response.isSuccessful) {
throw APIResponseException(
"retrieve workspaces",
"retrieve workspaces matching '$query'",
url,
workspacesResponse.code(),
workspacesResponse.parseErrorBody(moshi)
response.code(),
response.parseErrorBody(moshi)
)
}

return requireNotNull(workspacesResponse.body()?.workspaces) {
return requireNotNull(response.body()?.workspaces) {
"Successful response returned null body or workspaces"
}
}
Expand Down
21 changes: 16 additions & 5 deletions src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.coder.toolbox.util

import com.coder.toolbox.CoderToolboxContext
import com.coder.toolbox.cli.CoderCLIManager
import com.coder.toolbox.environmentId
import com.coder.toolbox.feed.IdeFeedManager
import com.coder.toolbox.feed.IdeType
import com.coder.toolbox.models.WorkspaceAndAgentStatus
Expand Down Expand Up @@ -46,7 +47,7 @@ open class CoderProtocolHandler(
cli: CoderCLIManager
) {
val workspaceName = resolveWorkspaceName(params) ?: return
val workspace = restClient.workspaces().matchName(workspaceName, url)
val workspace = restClient.workspaces().matchName(workspaceName, params.owner(), url)
if (workspace != null) {
if (!prepareWorkspace(workspace, restClient, cli, url)) return
// we resolve the agent after the workspace is started otherwise we can get misleading
Expand All @@ -59,7 +60,7 @@ open class CoderProtocolHandler(
) ?: return
if (!ensureAgentIsReady(workspace, agent)) return
delay(2.seconds)
val environmentId = "${workspace.name}.${agent.name}"
val environmentId = environmentId(workspace, agent, restClient.me.username)
context.showEnvironmentPage(environmentId)

val productCode = params.ideProductCode()
Expand All @@ -82,12 +83,22 @@ open class CoderProtocolHandler(
return workspace
}

private suspend fun List<Workspace>.matchName(workspaceName: String, deploymentURL: URL): Workspace? {
val workspace = this.firstOrNull { it.name == workspaceName }
private suspend fun List<Workspace>.matchName(
workspaceName: String,
owner: String?,
deploymentURL: URL,
): Workspace? {
val candidates = this.filter { it.name == workspaceName }
val workspace = if (owner.isNullOrBlank()) {
candidates.firstOrNull()
} else {
candidates.firstOrNull { it.ownerName == owner }
}
if (workspace == null) {
val descriptor = if (owner.isNullOrBlank()) workspaceName else "$owner/$workspaceName"
context.logAndShowError(
CAN_T_HANDLE_URI_TITLE,
"There is no workspace with name $workspaceName on $deploymentURL"
"There is no workspace with name $descriptor on $deploymentURL"
)
return null
}
Expand Down
3 changes: 3 additions & 0 deletions src/main/kotlin/com/coder/toolbox/util/LinkMap.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.coder.toolbox.util
const val URL = "url"
const val TOKEN = "token"
const val WORKSPACE = "workspace"
const val OWNER = "owner"
const val AGENT_NAME = "agent_name"
private const val IDE_PRODUCT_CODE = "ide_product_code"
private const val IDE_BUILD_NUMBER = "ide_build_number"
Expand All @@ -14,6 +15,8 @@ fun Map<String, String>.token() = this[TOKEN]

fun Map<String, String>.workspace() = this[WORKSPACE]

fun Map<String, String>.owner() = this[OWNER]

fun Map<String, String?>.agentName() = this[AGENT_NAME]

fun Map<String, String>.ideProductCode() = this[IDE_PRODUCT_CODE]
Expand Down
25 changes: 24 additions & 1 deletion src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ class CoderRemoteProviderTest {
mockCli = mockk(relaxed = true)
mockContext = mockk(relaxed = true)
remoteProvider = CoderRemoteProvider(mockContext)
// Default mocked client is the owner of every mocked workspace so the
// env id stays in the legacy `<workspace>.<agent>` form. Tests that
// exercise shared workspaces override `mockClient.me.username`.
every { mockClient.me.username } returns "owner"
}

@AfterTest
Expand Down Expand Up @@ -495,6 +499,23 @@ class CoderRemoteProviderTest {
assertEquals("workspace2.mockAgent", result[1].id)
}

@Test
fun `given a workspace owned by someone else then env id is namespaced by owner`() = runTest {
// given
val agent = mockAgent("agent1")
val resource = mockResource(agents = listOf(agent))
val owned = mockWorkspace("mine", WorkspaceStatus.RUNNING, listOf(resource), ownerName = "me")
val shared = mockWorkspace("shared", WorkspaceStatus.RUNNING, listOf(resource), ownerName = "coworker")
every { mockClient.me.username } returns "me"
coEvery { mockClient.workspaces() } returns listOf(owned, shared)

// when
val result = remoteProvider.resolveWorkspaceEnvironments(mockClient, mockCli)

// then
assertEquals(setOf("mine.agent1", "coworker.shared.agent1"), result.map { it.id }.toSet())
}

// Helper functions
private fun mockAgent(name: String, status: WorkspaceAgentStatus = WorkspaceAgentStatus.CONNECTED): WorkspaceAgent {
return mockk {
Expand All @@ -514,14 +535,16 @@ class CoderRemoteProviderTest {
private fun mockWorkspace(
name: String,
status: WorkspaceStatus,
resources: List<WorkspaceResource>
resources: List<WorkspaceResource>,
ownerName: String = "owner",
): Workspace {
val latestBuild = mockk<WorkspaceBuild> {
every { this@mockk.status } returns status
every { this@mockk.resources } returns resources
}
return mockk {
every { this@mockk.name } returns name
every { this@mockk.ownerName } returns ownerName
every { this@mockk.latestBuild } returns latestBuild
every { this@mockk.templateDisplayName } returns name
every { this@mockk.outdated } returns false
Expand Down
58 changes: 58 additions & 0 deletions src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,64 @@ class CoderRestClientTest {
}
}

@Test
fun testMergesOwnedAndSharedWorkspaces() {
val owned = DataGen.workspace("owned", ownerName = "me")
val shared = DataGen.workspace("shared", ownerName = "coworker")
val (srv, url) = mockServer()
val client = CoderRestClient(context, URL(url), "token")
srv.createContext(
"/api/v2/workspaces",
BaseHttpHandler("GET") { exchange ->
val query = exchange.requestURI.rawQuery.orEmpty()
val workspaces = when {
query.contains("owner%3Ame") -> listOf(owned)
query.contains("shared%3Atrue") -> listOf(shared, owned)
else -> emptyList()
}
val body = moshi.adapter(WorkspacesResponse::class.java)
.toJson(WorkspacesResponse(workspaces))
.toByteArray()
exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.size.toLong())
exchange.responseBody.write(body)
},
)

val result = runBlocking { client.workspaces() }
assertEquals(listOf("owned", "shared"), result.map { it.name })
srv.stop(0)
}

@Test
fun testIgnoresFailedSharedQuery() {
val owned = DataGen.workspace("owned", ownerName = "me")
val (srv, url) = mockServer()
val client = CoderRestClient(context, URL(url), "token")
srv.createContext(
"/api/v2/workspaces",
BaseHttpHandler("GET") { exchange ->
val query = exchange.requestURI.rawQuery.orEmpty()
if (query.contains("shared%3Atrue")) {
val response = Response("Bad request", "shared:true is not supported")
val body = moshi.adapter(Response::class.java).toJson(response).toByteArray()
exchange.sendResponseHeaders(HttpURLConnection.HTTP_BAD_REQUEST, body.size.toLong())
exchange.responseBody.write(body)
return@BaseHttpHandler
}
val body = moshi.adapter(WorkspacesResponse::class.java)
.toJson(WorkspacesResponse(listOf(owned)))
.toByteArray()
exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.size.toLong())
exchange.responseBody.write(body)
},
)

val result = runBlocking { client.workspaces() }
assertEquals(listOf("owned"), result.map { it.name })
srv.stop(0)
}


@Test
fun testGetsResources() {
val tests =
Expand Down
3 changes: 2 additions & 1 deletion src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class DataGen {
name: String,
templateID: UUID = UUID.randomUUID(),
agents: Map<String, String> = emptyMap(),
ownerName: String = "owner",
): Workspace {
val wsId = UUID.randomUUID()
return Workspace(
Expand All @@ -54,7 +55,7 @@ class DataGen {
),
outdated = false,
name = name,
ownerName = "owner",
ownerName = ownerName,
)
}

Expand Down
Loading